Initial import of borg-restore

This commit is contained in:
Marek Miklewicz
2026-07-04 11:20:44 +02:00
commit bd66a92f30
24 changed files with 11365 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
# borg-restore 2.x - state snapshot
## Current state
- Working dir: `borg-restore_2x/borg-restore`
- Latest version in `plugin.conf`: `2.0.7`
- Latest package: `borg-restore_2x/2.0.7/borg-restore.tar.gz`
## Packaging layout (required)
- Source (editable): `borg-restore_2x/borg-restore/`
- Packages: `borg-restore_2x/<version>/borg-restore.tar.gz`
## Key behavior (current)
- Restore types:
- `web` (Pliki www)
- `mail` (Zawartosc skrzynki pocztowej)
- Restore modes:
- `location` (przywracanie do lokalizacji)
- `overwrite` (nadpisywanie)
- `update` (scalenie; UI label: "Polaczenie danych kopii zapasowej z obecnymi danymi")
- For mail merge:
- rsync excludes Dovecot indexes and can optionally delete them after merge.
- Target path picker (location mode) is limited to `/home/DA_USER`.
- Restore path restriction (if enabled) is applied only to restore path and overwrite/update.
- Warning modal uses pink background; overlay uses transparent dark scrim.
## Config (plugin-settings.conf)
- `BB_PATH=/opt/bb`
- `BORG_PATH=/usr/bin/borg`
- `BORG_VARIABLES_FILE=/opt/bb/bbvars.sh`
- `WEB_RESTORE_LOCATION=/home/DA_USER/domains`
- `MAIL_RESTORE_LOCATION=/home/DA_USER/imap`
- `DEFAULT_RESTORE_DESTINATION=/home/DA_USER/bb`
- `BLOCK_RESTORE_OUTSIDE_LOCATION=TRUE`
- `PREVENT_RESTORE_MAIN_DOMAINS_DIR=TRUE`
- `PREVENT_RESTORE_MAIN_IMAP_DIR=TRUE`
- `CLEAR_DOVECOT_INDEXES_AFTER_MERGE=TRUE`
- `RESTORE_PATH_MODE=full`
## Notable logic changes
- Mail merge uses rsync with excludes for:
- `dovecot.index*`, `dovecot-uidlist`, `dovecot-uidvalidity`,
`dovecot.list.index*`, `dovecot.list.log`
- Optional cleanup of these indexes after merge if config flag is TRUE.
- Logs: lines containing "Permanently added" and "known host" are filtered out of user-facing logs.
- New config flags to allow/deny restoring whole `/home/DA_USER/domains` and `/home/DA_USER/imap`.
- Default target path for location mode uses `DEFAULT_RESTORE_DESTINATION`.
- Picker: creating a new dir jumps into it.
## CSS/UI
- Overlay: `rgba(15, 23, 42, 0.35)`
- Warning modal: class `br-modal-warning` with background `#f3c8c8`
- Picker modal: white background (default `.br-modal`)
## Recent files touched
- `borg-restore_2x/borg-restore/user/index.html`
- `borg-restore_2x/borg-restore/scripts/restore.sh`
- `borg-restore_2x/borg-restore/plugin-settings.conf`
- `borg-restore_2x/borg-restore/user/enhanced.css`
- `borg-restore_2x/borg-restore/user/evolution.css`
## Next step template (for new chat)
- "Pracujemy w `borg-restore_2x/borg-restore/`. Aktualna wersja w plugin.conf: 2.0.7."
- "Ostatnia paczka: `borg-restore_2x/2.0.7/borg-restore.tar.gz`."
- "Cel: <opis zmiany>."
+787
View File
@@ -0,0 +1,787 @@
#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/borg-restore/php.ini
<?php
function h(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}
function parse_sh_value(string $value): string
{
$value = trim($value);
if ($value === '') {
return '';
}
if ($value[0] === "'" && substr($value, -1) === "'") {
return substr($value, 1, -1);
}
if ($value[0] === '"' && substr($value, -1) === '"') {
return stripcslashes(substr($value, 1, -1));
}
$value = preg_replace('/\s+#.*$/', '', $value);
return trim((string)$value);
}
function parse_bool(string $value, bool $default = false): bool
{
$normalized = strtolower(trim($value));
if ($normalized === '') {
return $default;
}
if (in_array($normalized, ['1', 'true', 'yes', 'y', 'on'], true)) {
return true;
}
if (in_array($normalized, ['0', 'false', 'no', 'n', 'off'], true)) {
return false;
}
return $default;
}
function read_params(): array
{
$params = [];
if (!empty($_GET)) {
$params = array_merge($params, $_GET);
}
if (!empty($_POST)) {
$params = array_merge($params, $_POST);
}
$get_raw = getenv('GET') ?: getenv('get') ?: '';
if ($get_raw !== '') {
parse_str($get_raw, $parsed);
$params = array_merge($parsed, $params);
}
$qs = $_SERVER['QUERY_STRING'] ?? '';
if ($qs !== '') {
parse_str($qs, $parsed);
$params = array_merge($parsed, $params);
}
return $params;
}
function load_settings(string $path): array
{
$settings = [
'BB_PATH' => '/opt/bb',
'BORG_VARIABLES_FILE' => '/opt/bb/bbvars.sh',
'ADMIN_MODE_ENABLE' => 'TRUE',
'RESULTS_PER_PAGE' => '10',
'DEBUG_MODE' => 'FALSE',
];
if (!is_readable($path)) {
return $settings;
}
$lines = file($path, FILE_IGNORE_NEW_LINES);
if ($lines === false) {
return $settings;
}
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') {
continue;
}
if (preg_match('/^(BB_PATH|BORG_VARIABLES_FILE|ADMIN_MODE_ENABLE|RESULTS_PER_PAGE|DEBUG_MODE)=(.*)$/', $line, $m)) {
$settings[$m[1]] = parse_sh_value($m[2]);
}
}
return $settings;
}
function load_borg_vars(string $path): array
{
$vars = [
'BORG_PASSPHRASE' => null,
'REPO' => null,
];
if (!is_readable($path)) {
return $vars;
}
$lines = file($path, FILE_IGNORE_NEW_LINES);
if ($lines === false) {
return $vars;
}
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') {
continue;
}
if (preg_match('/^(?:export\\s+)?(BORG_PASSPHRASE|REPO)=(.*)$/', $line, $m)) {
$vars[$m[1]] = parse_sh_value($m[2]);
}
}
return $vars;
}
function load_user_skin(string $user): string
{
$path = '/usr/local/directadmin/data/users/' . $user . '/user.conf';
if (!is_readable($path)) {
return 'enhanced';
}
$lines = file($path, FILE_IGNORE_NEW_LINES);
if ($lines === false) {
return 'enhanced';
}
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') {
continue;
}
if (preg_match('/^skin=(.+)$/', $line, $m)) {
$skin = strtolower(trim($m[1]));
return $skin !== '' ? $skin : 'enhanced';
}
}
return 'enhanced';
}
function load_job_vars(string $path): array
{
$vars = [
'DA_USER' => '',
'REPONAME' => '',
'USERRESTOREPATH' => '',
'TARGETPATH' => '',
'RESTOREMODE' => '',
'RESTORETYPE' => '',
'START_TS' => '',
];
if (!is_readable($path)) {
return $vars;
}
$lines = file($path, FILE_IGNORE_NEW_LINES);
if ($lines === false) {
return $vars;
}
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') {
continue;
}
if (preg_match('/^(DA_USER|REPONAME|USERRESTOREPATH|TARGETPATH|RESTOREMODE|RESTORETYPE|START_TS|USERLANG)=(.*)$/', $line, $m)) {
$vars[$m[1]] = parse_sh_value($m[2]);
}
}
return $vars;
}
function job_id_from_file(string $file): string
{
$base = basename($file);
if (substr($base, -3) === '.ok') {
$base = substr($base, 0, -3);
} elseif (substr($base, -5) === '.fail') {
$base = substr($base, 0, -5);
} elseif (substr($base, -7) === '.cancel') {
$base = substr($base, 0, -7);
}
if (substr($base, -4) === '.env') {
$base = substr($base, 0, -4);
}
return $base;
}
function find_job_file(string $job_base, string $file, string $job_id): string
{
$candidates = [
$job_base . '/processing/' . $file,
$job_base . '/pending/' . $file,
$job_base . '/done/' . $file,
];
if ($job_id !== '') {
$candidates[] = $job_base . '/processing/' . $job_id . '.env';
$candidates[] = $job_base . '/pending/' . $job_id . '.env';
$candidates[] = $job_base . '/done/' . $job_id . '.ok';
$candidates[] = $job_base . '/done/' . $job_id . '.fail';
$candidates[] = $job_base . '/done/' . $job_id . '.cancel';
$candidates[] = $job_base . '/done/' . $job_id . '.env.ok';
$candidates[] = $job_base . '/done/' . $job_id . '.env.fail';
$candidates[] = $job_base . '/done/' . $job_id . '.env.cancel';
}
foreach ($candidates as $path) {
if (is_file($path)) {
return $path;
}
}
return '';
}
function read_log_tail(string $path, int $max_bytes = 200000): string
{
if (!is_readable($path)) {
return '';
}
$size = filesize($path);
if ($size === false || $size <= $max_bytes) {
return (string)file_get_contents($path);
}
$fp = fopen($path, 'rb');
if ($fp === false) {
return '';
}
fseek($fp, -$max_bytes, SEEK_END);
$data = fread($fp, $max_bytes);
fclose($fp);
return (string)$data;
}
function normalize_log(string $text): string
{
$text = str_replace("\r", "\n", $text);
$lines = preg_split('/\n/', $text);
if (!is_array($lines)) {
return $text;
}
$filtered = [];
foreach ($lines as $line) {
$trimmed = trim($line);
if ($trimmed !== '' && stripos($trimmed, 'Permanently added') !== false && stripos($trimmed, 'known host') !== false) {
continue;
}
$filtered[] = $line;
}
return implode("\n", $filtered);
}
function last_non_empty_line(string $text): string
{
$lines = preg_split('/\r\n|\n|\r/', $text);
if (!is_array($lines)) {
return '';
}
for ($i = count($lines) - 1; $i >= 0; $i--) {
$line = trim((string)$lines[$i]);
if ($line !== '') {
return $line;
}
}
return '';
}
function is_calculating_line(string $line): bool
{
return (bool)preg_match('/calculat/i', $line);
}
function extract_progress(string $line): ?int
{
if (preg_match('/\b(\d{1,3})%\b/', $line, $m)) {
$p = (int)$m[1];
if ($p < 0) {
$p = 0;
} elseif ($p > 100) {
$p = 100;
}
return $p;
}
return null;
}
function get_job_status(string $job_id, string $job_base): string
{
if ($job_id === '') {
return 'unknown';
}
if (is_file($job_base . '/processing/' . $job_id . '.env')) {
return 'processing';
}
if (is_file($job_base . '/pending/' . $job_id . '.env')) {
return 'pending';
}
if (is_file($job_base . '/done/' . $job_id . '.ok') || is_file($job_base . '/done/' . $job_id . '.env.ok')) {
return 'done';
}
if (is_file($job_base . '/done/' . $job_id . '.fail') || is_file($job_base . '/done/' . $job_id . '.env.fail')) {
return 'failed';
}
if (is_file($job_base . '/done/' . $job_id . '.cancel') || is_file($job_base . '/done/' . $job_id . '.env.cancel')) {
return 'canceled';
}
return 'unknown';
}
function display_status(string $base_status, string $last_line): string
{
if ($base_status === 'processing' && $last_line !== '' && is_calculating_line($last_line)) {
return 'calculating';
}
return $base_status;
}
function normalize_admin_status(string $status): string
{
if (in_array($status, ['pending', 'processing', 'calculating'], true)) {
return 'processing';
}
if ($status === 'done') {
return 'done';
}
if (in_array($status, ['failed', 'canceled'], true)) {
return 'failed';
}
return 'failed';
}
function admin_status_label(string $status): string
{
if ($status === 'done') {
return 'Zakończone';
}
if ($status === 'processing') {
return 'W trakcie';
}
return 'Błąd';
}
function admin_status_sort(string $status): int
{
if ($status === 'processing') {
return 1;
}
if ($status === 'done') {
return 2;
}
return 3;
}
function restore_mode_label(string $mode): string
{
if ($mode === 'overwrite') {
return 'Nadpisz aktualne dane';
}
if ($mode === 'update') {
return 'Scalenie';
}
if ($mode === 'location') {
return 'Przywracanie do lokalizacji';
}
return '-';
}
function restore_type_label(string $type): string
{
if ($type === 'mail') {
return 'Zawartość skrzynki pocztowej';
}
if ($type === 'web') {
return 'Pliki www';
}
return '-';
}
function display_restore_path(string $value): string
{
$trimmed = trim($value);
if ($trimmed === '') {
return '-';
}
if ($trimmed[0] !== '/') {
$trimmed = '/' . $trimmed;
}
return $trimmed;
}
function log_missing_include_explanation(string $log_text, string $restore_path_label = ''): string
{
if (stripos($log_text, 'W przywracanej kopii zapasowej nie odnaleziono wskazanej ścieżki') !== false) {
return '';
}
if (stripos($log_text, 'The specified restore path was not found in the backup') !== false) {
return '';
}
if (preg_match("/Include pattern '.*' never matched\\./i", $log_text)) {
$suffix = '';
if ($restore_path_label !== '' && $restore_path_label !== '-') {
$suffix = ' (' . $restore_path_label . ')';
}
return 'W przywracanej kopii zapasowej nie odnaleziono wskazanej ścieżki' . $suffix . '. Prosimy spróbować przywrócić dane z innego archiwum, w którym te pliki mogły się znajdować.';
}
return '';
}
function collect_jobs(string $job_base, string $log_dir): array
{
$rows = [];
$sources = [
['dir' => $job_base . '/pending', 'pattern' => '*.env', 'status' => 'pending'],
['dir' => $job_base . '/processing', 'pattern' => '*.env', 'status' => 'processing'],
['dir' => $job_base . '/done', 'pattern' => '*.ok', 'status' => 'done'],
['dir' => $job_base . '/done', 'pattern' => '*.env.ok', 'status' => 'done'],
['dir' => $job_base . '/done', 'pattern' => '*.fail', 'status' => 'failed'],
['dir' => $job_base . '/done', 'pattern' => '*.env.fail', 'status' => 'failed'],
['dir' => $job_base . '/done', 'pattern' => '*.cancel', 'status' => 'canceled'],
['dir' => $job_base . '/done', 'pattern' => '*.env.cancel', 'status' => 'canceled'],
];
foreach ($sources as $source) {
foreach (glob($source['dir'] . '/' . $source['pattern']) ?: [] as $path) {
$file = basename($path);
if (isset($rows[$file])) {
continue;
}
$vars = load_job_vars($path);
$job_id = job_id_from_file($file);
if ($job_id === '') {
continue;
}
$log_path = $log_dir . '/' . $job_id . '.log';
$base_status = $source['status'];
if ($base_status === 'processing' && is_readable($log_path)) {
$tail = normalize_log(read_log_tail($log_path, 20000));
$last = last_non_empty_line($tail);
$base_status = display_status($base_status, $last);
}
$status = normalize_admin_status($base_status);
$start_ts = is_numeric($vars['START_TS'] ?? '') ? (int)$vars['START_TS'] : (int)(@filemtime($path) ?: 0);
if ($start_ts <= 0) {
$start_ts = time();
}
$rows[$file] = [
'file' => $file,
'job_id' => $job_id,
'user' => (string)($vars['DA_USER'] ?? ''),
'start_ts' => $start_ts,
'start_text' => date('Y-m-d H:i:s', $start_ts),
'type' => (string)($vars['RESTORETYPE'] ?? ''),
'type_label' => restore_type_label((string)($vars['RESTORETYPE'] ?? '')),
'mode' => (string)($vars['RESTOREMODE'] ?? ''),
'mode_label' => restore_mode_label((string)($vars['RESTOREMODE'] ?? '')),
'status' => $status,
'status_label' => admin_status_label($status),
'status_sort' => admin_status_sort($status),
];
}
}
return array_values($rows);
}
function compare_rows(array $a, array $b, string $sort, string $dir): int
{
$cmp = 0;
switch ($sort) {
case 'user':
$cmp = strcasecmp((string)$a['user'], (string)$b['user']);
break;
case 'type':
$cmp = strcasecmp((string)$a['type_label'], (string)$b['type_label']);
break;
case 'mode':
$cmp = strcasecmp((string)$a['mode_label'], (string)$b['mode_label']);
break;
case 'status':
$cmp = ((int)$a['status_sort']) <=> ((int)$b['status_sort']);
break;
case 'start':
default:
$cmp = ((int)$a['start_ts']) <=> ((int)$b['start_ts']);
break;
}
if ($cmp === 0) {
$cmp = ((int)$a['start_ts']) <=> ((int)$b['start_ts']);
}
if ($cmp === 0) {
$cmp = strcmp((string)$a['job_id'], (string)$b['job_id']);
}
return $dir === 'asc' ? $cmp : -$cmp;
}
function build_admin_url(array $base, array $override = []): string
{
$query = array_merge($base, $override);
foreach ($query as $key => $value) {
if ($value === null || $value === '') {
unset($query[$key]);
}
}
$qs = http_build_query($query);
return '/CMD_PLUGINS_ADMIN/borg-restore/index.html' . ($qs !== '' ? ('?' . $qs) : '');
}
$params = read_params();
$action = (string)($params['action'] ?? '');
$username = getenv('USERNAME');
if ($username === false || $username === '') {
$username = getenv('LOGNAME') ?: getenv('USER') ?: 'admin';
}
$plugin_dir = realpath(__DIR__ . '/..') ?: '/usr/local/directadmin/plugins/borg-restore';
$settings_path = $plugin_dir . '/plugin-settings.conf';
$settings = load_settings($settings_path);
$admin_mode_enabled = parse_bool((string)($settings['ADMIN_MODE_ENABLE'] ?? 'TRUE'), true);
$debug_mode = parse_bool((string)($settings['DEBUG_MODE'] ?? 'FALSE'), false);
$bb_path = trim((string)($settings['BB_PATH'] ?? '/opt/bb'));
$borg_vars_file = trim((string)($settings['BORG_VARIABLES_FILE'] ?? '/opt/bb/bbvars.sh'));
$bbvars_path = $borg_vars_file !== '' ? $borg_vars_file : $bb_path;
if ($bbvars_path !== '' && substr($bbvars_path, -3) !== '.sh') {
$bbvars_path = rtrim($bbvars_path, '/') . '/bbvars.sh';
}
$bbvars = load_borg_vars($bbvars_path);
$admin_missing_passphrase = $debug_mode && is_readable($bbvars_path) && trim((string)($bbvars['BORG_PASSPHRASE'] ?? '')) === '';
$results_per_page = (int)trim((string)($settings['RESULTS_PER_PAGE'] ?? '10'));
if ($results_per_page <= 0) {
$results_per_page = 10;
}
if ($results_per_page > 200) {
$results_per_page = 200;
}
$skin = load_user_skin($username);
$css_file = $skin === 'evolution' ? 'evolution.css' : 'enhanced.css';
$inline_css = '';
$css_path = $plugin_dir . '/user/' . $css_file;
if (is_readable($css_path)) {
$inline_css = (string)file_get_contents($css_path);
}
$job_base = '/usr/local/directadmin/plugins/borg-restore/data/jobs';
$log_dir = '/usr/local/directadmin/plugins/borg-restore/data/logs';
$allowed_sorts = ['user', 'start', 'type', 'mode', 'status'];
$sort = strtolower((string)($params['sort'] ?? 'start'));
if (!in_array($sort, $allowed_sorts, true)) {
$sort = 'start';
}
$dir = strtolower((string)($params['dir'] ?? 'desc')) === 'asc' ? 'asc' : 'desc';
$page = (int)($params['page'] ?? 1);
if ($page < 1) {
$page = 1;
}
$base_link_params = ['sort' => $sort, 'dir' => $dir, 'page' => $page];
$jobs = [];
$total = 0;
$total_pages = 1;
$page_start = 0;
$page_end = 0;
$page_jobs = [];
if ($admin_mode_enabled) {
$jobs = collect_jobs($job_base, $log_dir);
usort($jobs, static function (array $a, array $b) use ($sort, $dir): int {
return compare_rows($a, $b, $sort, $dir);
});
$total = count($jobs);
$total_pages = max(1, (int)ceil($total / $results_per_page));
if ($page > $total_pages) {
$page = $total_pages;
$base_link_params['page'] = $page;
}
$offset = ($page - 1) * $results_per_page;
$page_jobs = array_slice($jobs, $offset, $results_per_page);
if ($total > 0) {
$page_start = $offset + 1;
$page_end = min($offset + count($page_jobs), $total);
}
}
$view_log = null;
if ($admin_mode_enabled && $action === 'view_log') {
$file = basename((string)($params['job'] ?? ''));
if ($file !== '' && preg_match('/^[-A-Za-z0-9_.]+$/', $file)) {
$job_id = job_id_from_file($file);
$job_file_path = find_job_file($job_base, $file, $job_id);
if ($job_file_path !== '') {
$vars = load_job_vars($job_file_path);
$log_path = $log_dir . '/' . $job_id . '.log';
$log_message = is_readable($log_path) ? normalize_log((string)file_get_contents($log_path)) : 'Log nie istnieje.';
$restore_path_label = display_restore_path((string)($vars['USERRESTOREPATH'] ?? ''));
$explanation = log_missing_include_explanation($log_message, $restore_path_label);
if ($explanation !== '') {
$log_message = rtrim($log_message) . "\n\n" . $explanation;
}
$base_status = get_job_status($job_id, $job_base);
$last_line = last_non_empty_line($log_message);
$progress = extract_progress($last_line);
$display = display_status($base_status, $last_line);
$status = normalize_admin_status($display);
if ($status === 'done') {
$progress_text = '100%';
} elseif ($status === 'failed') {
$progress_text = '0%';
} else {
$progress_text = $progress !== null ? ($progress . '%') : ($last_line !== '' ? $last_line : '-');
}
$start_ts = is_numeric($vars['START_TS'] ?? '') ? (int)$vars['START_TS'] : (int)(@filemtime($job_file_path) ?: 0);
if ($start_ts <= 0) {
$start_ts = time();
}
$view_log = [
'job' => $file,
'user' => (string)($vars['DA_USER'] ?? '-'),
'start' => date('Y-m-d H:i:s', $start_ts),
'type' => restore_type_label((string)($vars['RESTORETYPE'] ?? '')),
'mode' => restore_mode_label((string)($vars['RESTOREMODE'] ?? '')),
'path' => $restore_path_label,
'target' => display_restore_path((string)($vars['TARGETPATH'] ?? '')),
'status' => admin_status_label($status),
'progress' => $progress_text,
'content' => $log_message,
];
} else {
$view_log = [
'error' => 'Nie znaleziono wskazanego zadania.',
];
}
} else {
$view_log = [
'error' => 'Nieprawidłowy identyfikator zadania.',
];
}
}
$sort_labels = [
'user' => 'Nazwa użytkownika',
'start' => 'Data rozpoczęcia',
'type' => 'Rodzaj przywracanych danych',
'mode' => 'Tryb przywracania',
'status' => 'Status',
];
$sort_arrow = static function (string $field) use ($sort, $dir): string {
if ($field !== $sort) {
return '';
}
return $dir === 'asc' ? ' ▲' : ' ▼';
};
$sort_link = static function (string $field) use ($sort, $dir, $base_link_params): string {
$next_dir = ($sort === $field && $dir === 'asc') ? 'desc' : 'asc';
return build_admin_url($base_link_params, ['sort' => $field, 'dir' => $next_dir, 'page' => 1]);
};
$view_back_url = build_admin_url($base_link_params, ['action' => null, 'job' => null]);
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>BorgBackup - Przywracanie plików (Admin)</title>
<?php if ($inline_css !== '') { ?>
<style><?php echo $inline_css; ?></style>
<?php } ?>
<style>
.br-admin-subtitle { margin: 0 0 8px; color: var(--br-muted); }
.br-sort-link { color: inherit; text-decoration: none; }
.br-sort-link:hover { text-decoration: underline; }
.br-col-log { width: 130px; text-align: center; }
.br-pagination {
margin-top: 12px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.br-pagination .br-page-meta { color: var(--br-muted); font-size: 13px; }
.br-pagination .br-page-actions { display: flex; gap: 8px; }
.br-log-content { max-height: 58vh; overflow: auto; }
</style>
</head>
<body>
<div class="br-container">
<div class="br-header">
<div class="br-title">BorgBackup - Przywracanie plików (Admin)</div>
</div>
<?php if (!$admin_mode_enabled) { ?>
<div class="br-card">
<div class="br-alert br-alert-warn">Poziom administratora został wyłączony</div>
</div>
<?php } else { ?>
<?php if ($view_log !== null) { ?>
<div class="br-card">
<div class="br-actions" style="justify-content:flex-start; margin-bottom: 10px;">
<a class="br-btn br-btn-ghost" href="<?php echo h($view_back_url); ?>">Powrót do zestawienia</a>
</div>
<?php if (!empty($view_log['error'])) { ?>
<div class="br-alert br-alert-error"><?php echo h((string)$view_log['error']); ?></div>
<?php } else { ?>
<h3 class="m-0" style="margin-bottom:10px;">Log zadania</h3>
<div class="br-log-meta">
<div><strong>Nazwa użytkownika:</strong> <?php echo h((string)$view_log['user']); ?></div>
<div><strong>Data rozpoczęcia:</strong> <?php echo h((string)$view_log['start']); ?></div>
<div><strong>Rodzaj przywracanych danych:</strong> <?php echo h((string)$view_log['type']); ?></div>
<div><strong>Tryb przywracania:</strong> <?php echo h((string)$view_log['mode']); ?></div>
<div><strong>Ścieżka do przywrócenia:</strong> <?php echo h((string)$view_log['path']); ?></div>
<div><strong>Lokalizacja docelowa:</strong> <?php echo h((string)$view_log['target']); ?></div>
</div>
<div class="br-status-line">Status: <strong><?php echo h((string)$view_log['status']); ?></strong></div>
<div class="br-status-line">Postęp: <strong><?php echo h((string)$view_log['progress']); ?></strong></div>
<pre class="br-log-content" id="log-content"><?php echo h((string)$view_log['content']); ?></pre>
<?php } ?>
</div>
<?php } ?>
<div class="br-card">
<h3 style="margin-bottom:6px;">Zestawienie operacji przywracania</h3>
<p class="br-admin-subtitle">Wyniki na stronę: <?php echo h((string)$results_per_page); ?></p>
<?php if ($admin_missing_passphrase) { ?>
<div class="br-alert br-alert-warn">
Brak zmiennej <strong>BORG_PASSPHRASE</strong> w pliku <?php echo h($bbvars_path); ?>.
</div>
<?php } ?>
<?php if (empty($jobs)) { ?>
<p class="br-muted">Brak operacji do wyświetlenia.</p>
<?php } else { ?>
<table class="br-table" border="1" cellpadding="6" cellspacing="0">
<thead>
<tr>
<?php foreach ($sort_labels as $field => $label) { ?>
<th>
<a class="br-sort-link" href="<?php echo h($sort_link($field)); ?>">
<?php echo h($label . $sort_arrow($field)); ?>
</a>
</th>
<?php } ?>
<th class="br-col-log">Logi</th>
</tr>
</thead>
<tbody>
<?php foreach ($page_jobs as $job) { ?>
<tr>
<td><?php echo h((string)$job['user']); ?></td>
<td><?php echo h((string)$job['start_text']); ?></td>
<td><?php echo h((string)$job['type_label']); ?></td>
<td><?php echo h((string)$job['mode_label']); ?></td>
<td><?php echo h((string)$job['status_label']); ?></td>
<td class="br-col-log">
<a class="br-btn br-btn-ghost br-btn-small" href="<?php echo h(build_admin_url($base_link_params, ['action' => 'view_log', 'job' => (string)$job['file']])); ?>">Pokaż logi</a>
</td>
</tr>
<?php } ?>
</tbody>
</table>
<div class="br-pagination">
<div class="br-page-meta">
<?php echo h('Strona ' . $page . ' z ' . $total_pages . ' (wyniki ' . $page_start . '-' . $page_end . ' z ' . $total . ')'); ?>
</div>
<div class="br-page-actions">
<?php if ($page > 1) { ?>
<a class="br-btn br-btn-ghost br-btn-small" href="<?php echo h(build_admin_url($base_link_params, ['page' => $page - 1])); ?>">Poprzednia</a>
<?php } ?>
<?php if ($page < $total_pages) { ?>
<a class="br-btn br-btn-ghost br-btn-small" href="<?php echo h(build_admin_url($base_link_params, ['page' => $page + 1])); ?>">Następna</a>
<?php } ?>
</div>
</div>
<?php } ?>
</div>
<?php } ?>
</div>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<a href="/CMD_PLUGINS_ADMIN/borg-restore/index.html" target="_top"><img src="/CMD_PLUGINS_ADMIN/borg-restore/images/admin_icon.svg">BorgBackup - Przywracanie plików (Admin)<br></a>
+1
View File
@@ -0,0 +1 @@
<a href="/CMD_PLUGINS_ADMIN/borg-restore/index.html" target="_top">BorgBackup - Przywracanie plików (Admin)</a>
+27
View File
@@ -0,0 +1,27 @@
#!/bin/sh
user="${USERNAME:-${LOGNAME:-${USER:-}}}"
lang="${LANGUAGE:-}"
normalize_lang() {
val="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
case "$val" in
pl*|polish*) echo "pl" ;;
en*|english*) echo "en" ;;
*) echo "" ;;
esac
}
lang="$(normalize_lang "$lang")"
if [ -z "$lang" ] && [ -n "$user" ] && [ -r "/usr/local/directadmin/data/users/$user/user.conf" ]; then
cfg_lang="$(grep -i -m1 -E '^(lang|language|locale)=' "/usr/local/directadmin/data/users/$user/user.conf" | cut -d= -f2-)"
lang="$(normalize_lang "$cfg_lang")"
fi
if [ "$lang" = "pl" ]; then
label="BorgBackup - Przywracanie plików"
else
label="BorgBackup - File Restore"
fi
printf '[{"name":"%s","icon":"","entries":[{"name":"%s","icon":"","href":"/CMD_PLUGINS/borg-restore","newTab":false,"updates":0}]}]\n' "$label" "$label"
+1
View File
@@ -0,0 +1 @@
<a href="/CMD_PLUGINS/borg-restore">BorgBackup - Przywracanie plików</a>
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

Executable
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
set -eu
PLUGIN_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPT="$PLUGIN_DIR/scripts/install.sh"
if [ ! -x "$SCRIPT" ]; then
chmod 755 "$SCRIPT" 2>/dev/null || true
fi
exec "$SCRIPT"
+164
View File
@@ -0,0 +1,164 @@
<?php
return [
'BorgBackup - Przywracanie Plików' => 'BorgBackup - File Restore',
'Lista backupow (borg list)' => 'Backup list (borg list)',
'Status zadań' => 'Job status',
'Nazwa' => 'Name',
'Data' => 'Date',
'Hash' => 'Hash',
'PID' => 'PID',
'Status' => 'Status',
'Backup' => 'Backup',
'Ścieżka do przywrócenia:' => 'Restore path:',
'Rodzaj przywracanych danych' => 'Type of data to restore',
'Rodzaj przywracanych danych:' => 'Type of data to restore:',
'Odzyskiwanie plików stron www' => 'Restore website files',
'Odzyskiwanie zawartości skrzynki pocztowej' => 'Restore mailbox contents',
'Pliki www' => 'Website files',
'Zawartość skrzynki pocztowej' => 'Mailbox contents',
'Tryb przywracania' => 'Restore mode',
'Tryb przywracania:' => 'Restore mode:',
'Tryb przywracania danych:' => 'Restore data mode:',
'Przywracanie do lokalizacji' => 'Restore to location',
'Połączenie danych kopii zapasowej z obecnymi danymi' => 'Merge backup data with existing data',
'Scalenie' => 'Merge',
'Nadpisz aktualne dane' => 'Overwrite existing data',
'Przykłady użycia:' => 'Usage examples:',
'Uwaga: znaki / z początku i końca linii zostaną usunięte.' => 'Note: leading and trailing / characters will be removed.',
'Lokalizacja docelowa:' => 'Target location:',
'Domyślna lokalizacja docelowa:' => 'Default target location:',
'Przywróć' => 'Restore',
'Brak wpisów do wyświetlenia.' => 'No entries to display.',
'Odśwież' => 'Refresh',
'Log zadania' => 'Job log',
'Kopiuj do schowka' => 'Copy to clipboard',
'Zamknij' => 'Close',
'Podgląd na żywo (odświeżanie co 3s)' => 'Live view (refresh every 3s)',
'Data rozpoczęcia:' => 'Start time:',
'Status:' => 'Status:',
'Postęp:' => 'Progress:',
'Zapisz log' => 'Download log',
'Wybierz lokalizację' => 'Select location',
'Wybierz katalog' => 'Select directory',
'Nowy katalog' => 'New directory',
'Utwórz' => 'Create',
'Anuluj' => 'Cancel',
'Wybierz' => 'Select',
'Zaznacz wszystkie' => 'Select all',
'Usuń zaznaczone zdarzenia' => 'Delete selected entries',
'Czy na pewno usunąć zaznaczone zdarzenia?' => 'Are you sure you want to delete the selected entries?',
'Anulować zadanie?' => 'Cancel the job?',
'Usunąć zadanie?' => 'Delete the job?',
'Użytkownik' => 'User',
'Ścieżka' => 'Path',
'Cel' => 'Target',
'Czas' => 'Time',
'Akcje' => 'Actions',
'Postęp' => 'Progress',
'Logi' => 'Logs',
'Usuń' => 'Delete',
'Potwierdź nadpisanie danych' => 'Confirm data overwrite',
'Potwierdź scalenie danych' => 'Confirm data merge',
'Wybrano tryb nadpisywania danych. Istniejący katalog zostanie zastąpiony.' => 'Overwrite mode selected. The existing directory will be replaced.',
'Wybrano tryb scalenia danych. Istniejący katalog zostanie połączony z danymi z backupu.' => 'Merge mode selected. The existing directory will be merged with backup data.',
'DANE W KATALOGU ' => 'DATA IN DIRECTORY ',
'ZOSTANĄ UTRACONE.' => 'WILL BE LOST.',
'Tak, kontynuuj' => 'Yes, continue',
'Uwaga:' => 'Warning:',
'Brak zadań do wyświetlenia.' => 'No jobs to display.',
'Brak zadań do wyczyszczenia.' => 'No jobs to clear.',
'Zadania zostały wyczyszczone.' => 'Jobs have been cleared.',
'Zadanie zostało anulowane.' => 'Job has been canceled.',
'Zadanie zostało usunięte.' => 'Job has been deleted.',
'Zadanie nie jest w trakcie.' => 'Job is not in progress.',
'Wybrane zadania zostały usunięte.' => 'Selected jobs have been deleted.',
'Oczekuje' => 'Pending',
'W trakcie' => 'In progress',
'Przygotowanie' => 'Preparing',
'Zakończone' => 'Completed',
'Nie udało się anulować zadania.' => 'Failed to cancel job.',
'Nie znaleziono procesu do anulowania.' => 'No process found to cancel.',
'Brak obsługi posix_kill na serwerze.' => 'posix_kill is not available on the server.',
'Nie znaleziono zadania.' => 'Job not found.',
'Nieprawidłowe zadanie.' => 'Invalid job.',
'Nieprawidłowy identyfikator logu.' => 'Invalid log identifier.',
'Nieprawidłowy identyfikator logu.\n' => "Invalid log identifier.\n",
'Nieprawidłowy token CSRF.' => 'Invalid CSRF token.',
'Nieprawidłowa nazwa katalogu.' => 'Invalid directory name.',
'Katalog już istnieje.' => 'Directory already exists.',
'Podaj nazwę katalogu.' => 'Provide a directory name.',
'Nie udało się utworzyć katalogu.' => 'Failed to create directory.',
'Nie udało się utworzyć katalogu zadań.' => 'Failed to create jobs directory.',
'Nie udało się zapisać pliku zadania.' => 'Failed to write job file.',
'Nie udało się wczytać katalogów.' => 'Failed to load directories.',
'Nie udało się wysłać żądania.' => 'Failed to send request.',
'Nie udało się przetworzyć odpowiedzi.' => 'Failed to process response.',
'Nie udało się pobrać logu.' => 'Failed to download log.',
'Nie udało się uruchomić borg.' => 'Failed to run borg.',
'Nie podano ścieżki do przywrócenia.' => 'Restore path not provided.',
'Nie podano lokalizacji docelowej.' => 'Target location not provided.',
'Nie wybrano backupu.' => 'No backup selected.',
'Nie wybrano żadnych zadań.' => 'No jobs selected.',
'Brak katalogów' => 'No directories',
'Brak wpisów do wyświetlenia.' => 'No entries to display.',
'Brak dostępu' => 'Access denied',
'Brak dostępu do zadania.' => 'No access to the job.',
"Brak dostępu do logu.\n" => "No access to the log.\n",
'Log nie istnieje.' => 'Log does not exist.',
"Log nie istnieje.\n" => "Log does not exist.\n",
'Kod bledu: ' => 'Error code: ',
'Błąd borg list: ' => 'borg list error: ',
'Błąd' => 'Error',
'Anulowane' => 'Canceled',
'Nieznany' => 'Unknown',
'BB_PATH jest puste w plugin-settings.conf.' => 'BB_PATH is empty in plugin-settings.conf.',
'BORG_PATH jest puste w plugin-settings.conf.' => 'BORG_PATH is empty in plugin-settings.conf.',
'BORG_PATH nie jest plikiem wykonywalnym: ' => 'BORG_PATH is not executable: ',
'BORG_SSH_KEY nie jest plikiem: ' => 'BORG_SSH_KEY is not a file: ',
'BORG_VARIABLES_FILE jest puste w plugin-settings.conf.' => 'BORG_VARIABLES_FILE is empty in plugin-settings.conf.',
'WEB_RESTORE_LOCATION jest puste w plugin-settings.conf.' => 'WEB_RESTORE_LOCATION is empty in plugin-settings.conf.',
'MAIL_RESTORE_LOCATION jest puste w plugin-settings.conf.' => 'MAIL_RESTORE_LOCATION is empty in plugin-settings.conf.',
'DEFAULT_RESTORE_DESTINATION jest puste w plugin-settings.conf.' => 'DEFAULT_RESTORE_DESTINATION is empty in plugin-settings.conf.',
'Brak pliku plugin-settings.conf.' => 'Missing plugin-settings.conf file.',
'Brak pliku bbvars.sh: ' => 'Missing bbvars.sh file: ',
'Brak zmiennej REPO w ' => 'Missing REPO variable in ',
'nie ustawiono BB_PATH' => 'BB_PATH not set',
'BLOCK_RESTORE_OUTSIDE_LOCATION jest wyłączone - ścieżka do przywrócenia nie jest ograniczona.' => 'BLOCK_RESTORE_OUTSIDE_LOCATION is disabled - restore path is not restricted.',
'Ścieżka nie może zawierać "..".' => 'Path cannot contain "..".',
'Lokalizacja docelowa nie może zawierać "..".' => 'Target location cannot contain "..".',
'Lokalizacja docelowa musi zaczynać się od /.' => 'Target location must start with /.',
'Lokalizacja docelowa musi być w katalogu: ' => 'Target location must be inside: ',
'Ścieżka musi być w katalogu: ' => 'Path must be inside: ',
'Lokalizacja docelowa istnieje, ale nie jest katalogiem.' => 'Target location exists but is not a directory.',
'Lokalizacja docelowa nie jest pusta.' => 'Target location is not empty.',
'Nie można odczytać katalogu docelowego.' => 'Cannot read target directory.',
'Nie można utworzyć katalogu poza dozwoloną ścieżką.' => 'Cannot create directory outside the allowed path.',
'Nie można utworzyć katalogu bezpośrednio w ' => 'Cannot create directory directly in ',
' w katalogu nadrzędnym ścieżki do przywrócenia.' => ' in the parent directory of the restore path.',
'Nie można przywrócić całego ' => 'Cannot restore the entire ',
' w trybie nadpisywania danych. Podaj podkatalog w ramach ' => ' in overwrite mode. Provide a subdirectory under ',
' lub wybierz tryb przywracania do lokalizacji.' => ' or choose restore-to-location mode.',
' w trybie nadpisywania lub scalania danych. Podaj podkatalog w ramach ' => ' in overwrite or merge mode. Provide a subdirectory under ',
'Backup zostanie utworzony jako *_przed_przywroceniem_' => 'Backup will be created as *_before_restore_',
'Backup zostanie utworzony jako: ' => 'Backup will be created as: ',
'Nieprawidłowe uprawnienia plików konfiguracyjnych lub zadań. Przywracanie zablokowane.' => 'Invalid permissions for configuration or job files. Restore blocked.',
'Problem z konfiguracją środowiska backupowego - skontaktuj się z administratorem w celu uzyskania pomocy' => 'Backup environment configuration problem — contact the administrator for help',
'W przywracanej kopii zapasowej nie odnaleziono wskazanej ścieżki' => 'The specified restore path was not found in the backup',
'Prosimy spróbować przywrócić dane z innego archiwum, w którym te pliki mogły się znajdować.' => 'Please try restoring from another archive that may contain those files.',
'Wyjaśnienie: wskazana ścieżka do przywrócenia' => 'Explanation: the specified restore path',
'nie została odnaleziona w kopii zapasowej.' => 'was not found in the backup.',
'----- logi Borg -----' => '----- Borg logs -----',
'Przywracana ścieżka:' => 'Restored path:',
'Zadanie przywracania danych dodane do kolejki, zostaniesz powiadomiony przez system wiadomości po zakończeniu tego procesu.' => 'Restore job queued; you will be notified by the message system when it completes.',
'Plik zadania' => 'Job file',
'⟵ W górę' => '⟵ Up',
'Metoda niedozwolona.' => 'Method Not Allowed.',
'Method Not Allowed.' => 'Method Not Allowed.',
'ma nieprawidłowe uprawnienia: ' => 'has invalid permissions: ',
'ma nieprawidłowego właściciela (' => 'has invalid owner (',
'ma nieprawidłowego właściciela: ' => 'has invalid owner: ',
'ma pustą ścieżkę.' => 'has an empty path.',
'nie istnieje: ' => 'does not exist: ',
'nie jest zwykłym plikiem: ' => 'is not a regular file: ',
'nie może być zapisywalny dla grupy/innych: ' => 'cannot be group/other writable: ',
];
+7
View File
@@ -0,0 +1,7 @@
<?php
$en = include __DIR__ . '/en.php';
$pl = [];
foreach ($en as $key => $_value) {
$pl[$key] = $key;
}
return $pl;
+1961
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
# Ustawienia pluginu borg-restore.
# BB_PATH - katalog z plikiem bbvars.sh (zawiera BORG_PASSPHRASE, REPO i opcjonalnie BORG_SSH_KEY).
BB_PATH=/opt/bb
# BORG_PATH - pełna ścieżka do binarki borg.
BORG_PATH=/usr/bin/borg
# BORG_VARIABLES_FILE - ścieżka do pliku z REPO/BORG_PASSPHRASE/BORG_SSH_KEY.
BORG_VARIABLES_FILE=/opt/bb/bbvars.sh
# ADMIN_MODE_ENABLE - TRUE włącza panel administratora z raportem przywraceń.
ADMIN_MODE_ENABLE=TRUE
# RESULTS_PER_PAGE - liczba rekordów na stronę w tabeli administratora.
RESULTS_PER_PAGE=10
# WEB_RESTORE_LOCATION - domyślna ścieżka bazowa dla przywracania stron www.
WEB_RESTORE_LOCATION=/home/DA_USER/domains
# MAIL_RESTORE_LOCATION - domyślna ścieżka bazowa dla przywracania poczty.
MAIL_RESTORE_LOCATION=/home/DA_USER/imap
# ENABLE_WEB_RESTORE - TRUE pozwala przywracać dane stron www.
ENABLE_WEB_RESTORE=TRUE
# ENABLE_MAILBOX_RESTORE - TRUE pozwala przywracać dane skrzynek pocztowych.
ENABLE_MAILBOX_RESTORE=TRUE
# DEFAULT_RESTORE_DESTINATION - domyślna lokalizacja docelowa dla trybu przywracania do lokalizacji.
DEFAULT_RESTORE_DESTINATION=/home/DA_USER/bb
# BLOCK_RESTORE_OUTSIDE_LOCATION - TRUE blokuje przywracanie poza WEB/MAIL_RESTORE_LOCATION.
BLOCK_RESTORE_OUTSIDE_LOCATION=TRUE
# PREVENT_RESTORE_MAIN_DOMAINS_DIR - TRUE blokuje przywrócenie całego katalogu WEB_RESTORE_LOCATION.
PREVENT_RESTORE_MAIN_DOMAINS_DIR=TRUE
# PREVENT_RESTORE_MAIN_IMAP_DIR - TRUE blokuje przywrócenie całego katalogu MAIL_RESTORE_LOCATION.
PREVENT_RESTORE_MAIN_IMAP_DIR=TRUE
# PREVENT_CREATE_DIRS_IN_MAIN_DOMAIN - TRUE blokuje tworzenie katalogów bezpośrednio w /home/DA_USER/domains.
PREVENT_CREATE_DIRS_IN_MAIN_DOMAIN=TRUE
# PREVENT_CREATE_DIRS_IN_MAIN_IMAP - TRUE blokuje tworzenie katalogów bezpośrednio w /home/DA_USER/imap.
PREVENT_CREATE_DIRS_IN_MAIN_IMAP=TRUE
# HASH_VISIBILITY - FALSE ukrywa kolumnę Hash w tabeli archiwów.
HASH_VISIBILITY=FALSE
# ENABLE_LOGS_OVERLAY - TRUE pokazuje logi jako overlay.
ENABLE_LOGS_OVERLAY=TRUE
# SELECTABLE_DOMAIN_RESTORE_DESTINATION - TRUE pozwala wybrać lokalizację docelową (www).
SELECTABLE_DOMAIN_RESTORE_DESTINATION=TRUE
# SELECTABLE_MAIL_RESTORE_DESTINATION - TRUE pozwala wybrać lokalizację docelową (poczta).
SELECTABLE_MAIL_RESTORE_DESTINATION=TRUE
# CLEAR_DOVECOT_INDEXES_AFTER_MERGE - TRUE usuwa indeksy Dovecota po scaleniu poczty.
CLEAR_DOVECOT_INDEXES_AFTER_MERGE=TRUE
# RESTORE_PATH_MODE - full (pełna ścieżka) lub dir (tylko ostatni katalog).
RESTORE_PATH_MODE=dir
# ADD_DATE_TO_RESTORED_DIR - TRUE dodaje datę do ostatniego katalogu w trybie przywrócenia do lokalizacji.
ADD_DATE_TO_RESTORED_DIR=FALSE
# DEBUG_MODE - TRUE pokazuje szczegółowe błędy i pełny output systemowy.
DEBUG_MODE=FALSE
+19
View File
@@ -0,0 +1,19 @@
active=yes
admin_run_as=root
admin_widgets=
author=HITME.PL
id=borg-restore
installed=yes
menu_admin=
menu_reseller=
menu_user=
name=Borg-restore
reseller_run_as=
reseller_widgets=
timeout=
type=user
update_url=
user_run_as=root
user_widgets=
version=2.3.3
version_url=
+129
View File
@@ -0,0 +1,129 @@
#!/bin/bash
set -euo pipefail
PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
CONFIG_FILE="${PLUGIN_DIR}/plugin-settings.conf"
DATA_DIR="${PLUGIN_DIR}/data"
JOB_DIR="${DATA_DIR}/jobs"
LOG_DIR="${DATA_DIR}/logs"
PID_DIR="${DATA_DIR}/pids"
CANCEL_DIR="${DATA_DIR}/cancel"
TMP_DIR="${DATA_DIR}/tmp"
CSRF_DIR="${DATA_DIR}/csrf"
warn() {
printf '[fix_permissions] %s\n' "$*" >&2
}
strip_quotes() {
local v="$1"
v="${v#"${v%%[![:space:]]*}"}"
v="${v%"${v##*[![:space:]]}"}"
if [[ "$v" == \"*\" && "$v" == *\" ]]; then
v="${v:1:${#v}-2}"
elif [[ "$v" == \'*\' && "$v" == *\' ]]; then
v="${v:1:${#v}-2}"
fi
printf '%s' "$v"
}
get_cfg() {
local key="$1"
local value=""
if [ -r "$CONFIG_FILE" ]; then
while IFS= read -r line; do
line="${line%%#*}"
line="$(strip_quotes "$line")"
if [[ "$line" == "$key="* ]]; then
value="${line#*=}"
fi
done < "$CONFIG_FILE"
fi
printf '%s' "$value"
}
owner_name="$(stat -c %U "$PLUGIN_DIR" 2>/dev/null || true)"
if [ "$owner_name" != "root" ] && [ "$owner_name" != "diradmin" ]; then
owner_name="root"
fi
owner_group="$owner_name"
fix_dir() {
local path="$1"
if [ -z "$path" ]; then
return
fi
mkdir -p "$path"
chown "$owner_name":"$owner_group" "$path" || true
chmod 700 "$path" || true
}
fix_file() {
local path="$1"
local label="$2"
if [ -z "$path" ]; then
return
fi
if [ ! -e "$path" ]; then
warn "${label} missing: ${path}"
return
fi
if [ -L "$path" ] || [ ! -f "$path" ]; then
warn "${label} not a regular file: ${path}"
return
fi
chown "$owner_name":"$owner_group" "$path" || true
chmod 600 "$path" || true
}
BB_PATH="$(get_cfg "BB_PATH")"
BORG_VARIABLES_FILE="$(get_cfg "BORG_VARIABLES_FILE")"
if [ -z "$BB_PATH" ]; then
BB_PATH="/opt/bb"
fi
bbvars_path=""
if [ -n "$BORG_VARIABLES_FILE" ]; then
bbvars_path="${BORG_VARIABLES_FILE}"
if [[ "$bbvars_path" == *DA_USER* ]]; then
if [ -n "${DA_USER:-}" ]; then
bbvars_path="${bbvars_path//DA_USER/${DA_USER}}"
elif [ -f "/opt/bb/bbvars.sh" ]; then
bbvars_path="/opt/bb/bbvars.sh"
else
bbvars_path=""
fi
fi
else
if [[ "$BB_PATH" == *.sh ]]; then
bbvars_path="$BB_PATH"
else
bbvars_path="${BB_PATH%/}/bbvars.sh"
fi
fi
fix_dir "$DATA_DIR"
fix_dir "$JOB_DIR/pending"
fix_dir "$JOB_DIR/processing"
fix_dir "$JOB_DIR/done"
fix_dir "$LOG_DIR"
fix_dir "$PID_DIR"
fix_dir "$CANCEL_DIR"
fix_dir "$TMP_DIR"
fix_dir "$CSRF_DIR"
fix_file "$CONFIG_FILE" "plugin-settings.conf"
if [ -n "$bbvars_path" ]; then
fix_file "$bbvars_path" "bbvars.sh"
else
warn "bbvars.sh path not resolved; skipped"
fi
for f in "$JOB_DIR"/pending/*.env "$JOB_DIR"/processing/*.env "$JOB_DIR"/done/*.{ok,fail,cancel,env.ok,env.fail,env.cancel}; do
if [ -f "$f" ]; then
fix_file "$f" "job file"
fi
done
exit 0
+32
View File
@@ -0,0 +1,32 @@
#!/bin/bash
set -euo pipefail
PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
# Ensure expected permissions and ownership so DirectAdmin can update plugin.conf.
if [ "$(id -u)" -eq 0 ]; then
chown -R diradmin:diradmin "$PLUGIN_DIR" || true
else
echo "borg-restore: warning: not running as root, cannot chown to diradmin" >&2
fi
chmod 755 "$PLUGIN_DIR" "$PLUGIN_DIR/hooks" "$PLUGIN_DIR/user" "$PLUGIN_DIR/scripts" "$PLUGIN_DIR/images" || true
chmod 755 "$PLUGIN_DIR/user/index.html" "$PLUGIN_DIR/scripts/install.sh" "$PLUGIN_DIR/scripts/uninstall.sh" "$PLUGIN_DIR/scripts/restore.sh" "$PLUGIN_DIR/scripts/worker.sh" || true
chmod 644 "$PLUGIN_DIR/plugin.conf" "$PLUGIN_DIR/hooks/user_txt.html" "$PLUGIN_DIR/plugin-settings.conf" "$PLUGIN_DIR/user/enhanced.css" "$PLUGIN_DIR/user/evolution.css" "$PLUGIN_DIR/images/user_icon.svg" || true
DATA_DIR="$PLUGIN_DIR/data"
mkdir -p "$DATA_DIR/jobs/pending" "$DATA_DIR/jobs/processing" "$DATA_DIR/jobs/done" "$DATA_DIR/logs" "$DATA_DIR/pids" "$DATA_DIR/cancel"
chmod 700 "$DATA_DIR" "$DATA_DIR/jobs" "$DATA_DIR/jobs/pending" "$DATA_DIR/jobs/processing" "$DATA_DIR/jobs/done" "$DATA_DIR/logs" "$DATA_DIR/pids" "$DATA_DIR/cancel" || true
if [ "$(id -u)" -eq 0 ]; then
chown -R diradmin:diradmin "$DATA_DIR" || true
fi
CRON_FILE="/etc/cron.d/borg-restore"
if [ "$(id -u)" -eq 0 ]; then
echo "* * * * * root /usr/local/directadmin/plugins/borg-restore/scripts/worker.sh >/dev/null 2>&1" > "$CRON_FILE"
chmod 644 "$CRON_FILE" || true
else
echo "borg-restore: warning: not running as root, cannot install cron job" >&2
fi
echo "borg-restore: install complete"
+495
View File
@@ -0,0 +1,495 @@
#!/bin/bash
set -euo pipefail
ERROR_MSG=""
SUCCESS_MSG=""
RESTORE_OK=0
DA_USER=""
JOB_FILE="${1:-}"
if [ -z "$JOB_FILE" ] || [ ! -f "$JOB_FILE" ]; then
echo "borg-restore: missing job file" >&2
exit 1
fi
JOB_BASE="$(basename "$JOB_FILE")"
JOB_ID="${JOB_BASE%%.*}"
PLUGIN_DIR="/usr/local/directadmin/plugins/borg-restore"
LOG_DIR="${PLUGIN_DIR}/data/logs"
LOG_FILE="${LOG_DIR}/${JOB_ID}.log"
mkdir -p "$LOG_DIR"
fail() {
local msg="$1"
ERROR_MSG="$msg"
echo "$msg" >> "$LOG_FILE"
}
format_restore_path() {
local path="${1:-}"
path="$(echo "$path" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
if [ -z "$path" ]; then
echo ""
return
fi
if [[ "$path" != /* ]]; then
path="/$path"
fi
echo "$path"
}
append_missing_include_explanation() {
if [ ! -f "$LOG_FILE" ]; then
return
fi
if ! grep -qi "Include pattern '.*' never matched" "$LOG_FILE"; then
return
fi
if grep -Fq "W przywracanej kopii zapasowej nie odnaleziono wskazanej ścieżki" "$LOG_FILE" || grep -Fq "The specified restore path was not found in the backup" "$LOG_FILE"; then
return
fi
local label
label="$(format_restore_path "${USERRESTOREPATH:-}")"
local msg
if [ "${USERLANG:-}" = "en" ]; then
msg="The specified restore path"
else
msg="W przywracanej kopii zapasowej nie odnaleziono wskazanej ścieżki"
fi
if [ -n "$label" ]; then
msg+=" (${label})"
fi
if [ "${USERLANG:-}" = "en" ]; then
msg+=" was not found in the backup. Please try restoring from another archive that may contain those files."
else
msg+=". Prosimy spróbować przywrócić dane z innego archiwum, w którym te pliki mogły się znajdować."
fi
printf "\n%s\n" "$msg" >> "$LOG_FILE"
}
has_missing_include() {
if [ ! -f "$LOG_FILE" ]; then
return 1
fi
grep -qi "Include pattern '.*' never matched" "$LOG_FILE"
}
filter_log_content() {
awk '{
line=tolower($0);
if (line ~ /permanently added/) next;
if (line ~ /known host/) next;
print;
}'
}
secure_source_check() {
local path="$1"
local label="$2"
local check_writable="${3:-1}"
if [ -z "$path" ] || [ ! -e "$path" ]; then
fail "Missing ${label}: ${path}"
exit 1
fi
if [ -L "$path" ] || [ ! -f "$path" ]; then
fail "Insecure ${label}: not a regular file (${path})"
exit 1
fi
local owner_uid owner_name mode
owner_uid="$(stat -c %u "$path" 2>/dev/null || true)"
owner_name="$(stat -c %U "$path" 2>/dev/null || true)"
mode="$(stat -c %a "$path" 2>/dev/null || true)"
if [ -z "$owner_uid" ] || [ -z "$mode" ]; then
fail "Unable to stat ${label}: ${path}"
exit 1
fi
if [ "$owner_uid" != "0" ] && [ "$owner_uid" != "500" ] && [ "$owner_name" != "root" ] && [ "$owner_name" != "diradmin" ]; then
fail "Insecure ${label} owner (${owner_name}:${owner_uid}): ${path}"
exit 1
fi
mode=$((10#$mode))
if [ "$check_writable" = "1" ] && (( (mode & 022) != 0 )); then
fail "Insecure ${label} permissions (group/other writable): ${path}"
exit 1
fi
}
urlencode() {
local input="$1"
local hex out i
hex="$(printf '%s' "$input" | od -An -tx1 | tr -d ' \n')"
out=""
for ((i=0; i<${#hex}; i+=2)); do
out+="%${hex:i:2}"
done
printf '%s' "$out"
}
notify_user() {
local code="$1"
if [ -z "${DA_USER:-}" ]; then
return
fi
local subject message
if [ "$code" -eq 0 ] && [ "$RESTORE_OK" -eq 1 ]; then
if [ "${USERLANG:-}" = "en" ]; then
subject="BorgBackup restore completed successfully"
message="Restore of ${USERRESTOREPATH:-} from ${REPONAME:-} completed."
else
subject="Przywracanie plików BorgBackup zakończone pomyślnie"
message="${SUCCESS_MSG:-Przywracanie ${USERRESTOREPATH:-} z ${REPONAME:-} zakończone.}"
fi
else
if [ "${USERLANG:-}" = "en" ]; then
subject="BorgBackup restore error"
message="Restore failed for ${USERRESTOREPATH:-} from ${REPONAME:-}."
else
subject="Błąd przywracania plików BorgBackup"
if [ -n "$ERROR_MSG" ]; then
message="Błąd przywracania: ${ERROR_MSG}"
else
message="Błąd przywracania ${USERRESTOREPATH:-} z ${REPONAME:-}."
fi
fi
if [ -n "${LOG_FILE:-}" ] && [ -f "$LOG_FILE" ]; then
local log_content
log_content="$(cat "$LOG_FILE" | filter_log_content)"
message="${message}\n\nLog:\n${log_content}"
fi
fi
local task_queue="/usr/local/directadmin/data/task.queue"
local users="select1%3D$(urlencode "$DA_USER")"
local line="action=notify&value=users&subject=$(urlencode "$subject")&message=$(urlencode "$message")&users=${users}"
printf "%s\n" "$line" >> "$task_queue"
}
on_exit() {
local code="$1"
notify_user "$code"
}
trap 'on_exit $?' EXIT
secure_source_check "$JOB_FILE" "job file" 0
# shellcheck disable=SC1090
source "$JOB_FILE"
DA_USER="${DA_USER:-}"
if [ -z "${DA_USER:-}" ]; then
echo "Missing DA_USER in job file" >&2
exit 1
fi
CONFIG_FILE="${PLUGIN_DIR}/plugin-settings.conf"
set_owner() {
local path="$1"
if [ -z "$path" ] || [ ! -e "$path" ]; then
return
fi
if [ "$RESTORETYPE" = "mail" ]; then
chown -R "${DA_USER}:mail" "$path"
else
chown -R "${DA_USER}:${DA_USER}" "$path"
fi
}
is_true() {
local v="${1:-}"
v="$(printf '%s' "$v" | tr '[:upper:]' '[:lower:]')"
case "$v" in
1|true|yes|on) return 0 ;;
*) return 1 ;;
esac
}
if [ ! -f "$CONFIG_FILE" ]; then
fail "Missing ${CONFIG_FILE}"
exit 1
fi
secure_source_check "$CONFIG_FILE" "plugin-settings.conf"
# shellcheck disable=SC1090
source "$CONFIG_FILE"
BB_PATH="${BB_PATH:-/opt/bb}"
BORG_PATH="${BORG_PATH:-/usr/bin/borg}"
BORG_VARIABLES_FILE="${BORG_VARIABLES_FILE:-}"
WEB_RESTORE_LOCATION="${WEB_RESTORE_LOCATION:-/home/DA_USER/domains}"
MAIL_RESTORE_LOCATION="${MAIL_RESTORE_LOCATION:-/home/DA_USER/imap}"
BLOCK_RESTORE_OUTSIDE_LOCATION="${BLOCK_RESTORE_OUTSIDE_LOCATION:-TRUE}"
CLEAR_DOVECOT_INDEXES_AFTER_MERGE="${CLEAR_DOVECOT_INDEXES_AFTER_MERGE:-TRUE}"
RESTORE_PATH_MODE="${RESTORE_PATH_MODE:-dir}"
if [ -z "$BB_PATH" ]; then
fail "BB_PATH is empty in plugin-settings.conf"
exit 1
fi
if [ -z "$BORG_PATH" ]; then
fail "BORG_PATH is empty in plugin-settings.conf"
exit 1
fi
BBVARS_PATH="${BORG_VARIABLES_FILE:-}"
BBVARS_PATH="${BBVARS_PATH//DA_USER/${DA_USER}}"
if [ -z "$BBVARS_PATH" ]; then
BBVARS_PATH="$BB_PATH"
if [[ "$BBVARS_PATH" != *.sh ]]; then
BBVARS_PATH="${BBVARS_PATH%/}/bbvars.sh"
fi
fi
if [ ! -f "$BBVARS_PATH" ]; then
fail "Missing ${BBVARS_PATH}"
exit 1
fi
secure_source_check "$BBVARS_PATH" "bbvars.sh" 0
set -a
# shellcheck disable=SC1091
source "$BBVARS_PATH"
set +a
if [ -z "${REPO:-}" ]; then
fail "Missing REPO in ${BBVARS_PATH}"
exit 1
fi
if [ -n "${BORG_SSH_KEY:-}" ]; then
if [ ! -f "${BORG_SSH_KEY}" ]; then
fail "BORG_SSH_KEY is not a file: ${BORG_SSH_KEY}"
exit 1
fi
export BORG_RSH="ssh -i \"${BORG_SSH_KEY}\""
fi
if [ -z "${REPONAME:-}" ] || [ -z "${USERRESTOREPATH:-}" ]; then
fail "Missing REPONAME or USERRESTOREPATH in job file"
exit 1
fi
RESTOREMODE="${RESTOREMODE:-location}"
RESTORETYPE="${RESTORETYPE:-web}"
if [ "$RESTORETYPE" != "mail" ]; then
RESTORETYPE="web"
fi
if [ "$RESTORETYPE" = "mail" ]; then
if [ "$RESTOREMODE" != "location" ] && [ "$RESTOREMODE" != "update" ]; then
RESTOREMODE="update"
fi
else
if [ "$RESTOREMODE" != "location" ] && [ "$RESTOREMODE" != "overwrite" ]; then
RESTOREMODE="location"
fi
fi
RESTOREPATHMODE="${RESTOREPATHMODE:-$RESTORE_PATH_MODE}"
RESTOREPATHMODE="$(echo "$RESTOREPATHMODE" | tr '[:upper:]' '[:lower:]')"
RESTOREPATHMODE_FLAG="full"
if [ "$RESTOREPATHMODE" = "dir" ]; then
RESTOREPATHMODE_FLAG="dir"
fi
TARGETPATH="${TARGETPATH:-}"
RESTORE_FULL="/${USERRESTOREPATH#/}"
WEB_BASE="${WEB_RESTORE_LOCATION//DA_USER/$DA_USER}"
MAIL_BASE="${MAIL_RESTORE_LOCATION//DA_USER/$DA_USER}"
BASE_LOCATION="$WEB_BASE"
if [ "$RESTORETYPE" = "mail" ]; then
BASE_LOCATION="$MAIL_BASE"
fi
if [ -z "$BASE_LOCATION" ]; then
fail "Base restore location is empty"
exit 1
fi
TARGET_DIR="$BASE_LOCATION"
if [ -z "$TARGETPATH" ]; then
TARGETPATH="$TARGET_DIR"
fi
BLOCK_FLAG="$(echo "$BLOCK_RESTORE_OUTSIDE_LOCATION" | tr '[:upper:]' '[:lower:]')"
if [ "$BLOCK_FLAG" = "1" ] || [ "$BLOCK_FLAG" = "true" ] || [ "$BLOCK_FLAG" = "yes" ] || [ "$BLOCK_FLAG" = "on" ]; then
if [ -n "$BASE_LOCATION" ]; then
BASE_LOCATION="${BASE_LOCATION%/}"
if [[ "$RESTORE_FULL" != "$BASE_LOCATION" && "$RESTORE_FULL" != "$BASE_LOCATION/"* ]]; then
fail "Restore path outside base location: ${RESTORE_FULL}"
exit 1
fi
fi
fi
if [[ "$USERRESTOREPATH" == *".."* ]]; then
fail "Invalid restore path (contains ..)"
exit 1
fi
if [[ "$TARGETPATH" == *".."* ]]; then
fail "Invalid target path (contains ..)"
exit 1
fi
if [ "$RESTOREMODE" = "overwrite" ] || [ "$RESTOREMODE" = "update" ]; then
TMP_BASE="/home/${DA_USER}/bb.tmp"
RESTORE_BASE="${RESTORE_FULL##*/}"
RESTORE_PARENT="${RESTORE_FULL%/*}"
if [ -z "$RESTORE_BASE" ] || [ -z "$RESTORE_PARENT" ]; then
fail "Nieprawidłowa ścieżka do przywrócenia."
exit 1
fi
mkdir -p "$TMP_BASE"
chown -R "${DA_USER}:${DA_USER}" "$TMP_BASE"
rm -rf "${TMP_BASE}/${USERRESTOREPATH#/}" "${TMP_BASE}/${RESTORE_BASE}" 2>/dev/null || true
cd "$TMP_BASE"
set +e
"$BORG_PATH" extract --progress "${REPO}::${REPONAME}" -- "${USERRESTOREPATH}" >> "$LOG_FILE" 2>&1
EXIT_CODE=$?
set -e
append_missing_include_explanation
if [ "$EXIT_CODE" -ne 0 ]; then
if ! has_missing_include; then
fail "Borg extract zakończył się błędem. Sprawdź logi w tabeli zdarzeń na stronie pluginu."
fi
else
EXTRACTED_PATH="${TMP_BASE}/${USERRESTOREPATH#/}"
TMP_ITEM="${TMP_BASE}/${RESTORE_BASE}"
if [ ! -e "$EXTRACTED_PATH" ]; then
fail "Nie znaleziono przywróconego katalogu: ${EXTRACTED_PATH}"
EXIT_CODE=1
else
if [ -e "$TMP_ITEM" ]; then
rm -rf "$TMP_ITEM" 2>/dev/null || true
fi
mv "$EXTRACTED_PATH" "$TMP_ITEM"
if [ "$RESTOREMODE" = "update" ]; then
if [ -e "$RESTORE_FULL" ]; then
PRE_SUFFIX="$(date +%d-%m-%Y_%H%M%S)"
PRE_NAME="${RESTORE_BASE}_przed_przywroceniem_${PRE_SUFFIX}"
PRE_PATH="${RESTORE_PARENT}/${PRE_NAME}"
mkdir -p "$PRE_PATH"
set +e
rsync -a "$RESTORE_FULL"/ "$PRE_PATH"/ >> "$LOG_FILE" 2>&1
RSYNC_CODE=$?
set -e
if [ "$RSYNC_CODE" -ne 0 ]; then
fail "Backup katalogu przed aktualizacją zakończył się błędem."
EXIT_CODE=1
fi
else
mkdir -p "$RESTORE_FULL"
fi
if [ "$EXIT_CODE" -eq 0 ]; then
RSYNC_EXCLUDES=()
if [ "$RESTORETYPE" = "mail" ]; then
RSYNC_EXCLUDES+=(--exclude 'dovecot.index*')
RSYNC_EXCLUDES+=(--exclude 'dovecot-uidlist')
RSYNC_EXCLUDES+=(--exclude 'dovecot-uidvalidity')
RSYNC_EXCLUDES+=(--exclude 'dovecot.list.index*')
RSYNC_EXCLUDES+=(--exclude 'dovecot.list.log')
fi
set +e
rsync -a "${RSYNC_EXCLUDES[@]}" "$TMP_ITEM"/ "$RESTORE_FULL"/ >> "$LOG_FILE" 2>&1
RSYNC_CODE=$?
set -e
if [ "$RSYNC_CODE" -ne 0 ]; then
fail "Aktualizacja danych przez rsync zakończyła się błędem."
EXIT_CODE=1
else
if [ "$RESTORETYPE" = "mail" ] && is_true "$CLEAR_DOVECOT_INDEXES_AFTER_MERGE"; then
find "$RESTORE_FULL" -type f \( -name 'dovecot.index*' -o -name 'dovecot-uidlist' -o -name 'dovecot-uidvalidity' -o -name 'dovecot.list.index*' -o -name 'dovecot.list.log' \) -delete >> "$LOG_FILE" 2>&1 || true
fi
set_owner "$RESTORE_FULL"
rm -rf "$TMP_BASE"
RESTORE_OK=1
SUCCESS_MSG="Przywracanie ${USERRESTOREPATH} z ${REPONAME} zakończone. Pliki w ${RESTORE_FULL}."
echo "$SUCCESS_MSG" >> "$LOG_FILE"
fi
fi
else
if [ -e "$RESTORE_FULL" ]; then
PRE_SUFFIX="$(date +%d-%m-%Y_%H%M%S)"
PRE_NAME="${RESTORE_BASE}_przed_przywroceniem_${PRE_SUFFIX}"
PRE_PATH="${RESTORE_PARENT}/${PRE_NAME}"
mv "$RESTORE_FULL" "$PRE_PATH"
fi
mkdir -p "$RESTORE_PARENT"
mv "$TMP_ITEM" "$RESTORE_FULL"
set_owner "$RESTORE_FULL"
rm -rf "$TMP_BASE"
RESTORE_OK=1
SUCCESS_MSG="Przywracanie ${USERRESTOREPATH} z ${REPONAME} zakończone. Pliki w ${RESTORE_FULL}."
echo "$SUCCESS_MSG" >> "$LOG_FILE"
fi
fi
fi
else
if [ ! -d "$TARGETPATH" ]; then
mkdir -p "$TARGETPATH"
fi
if [ -d "$TARGETPATH" ] && [ "$(ls -A "$TARGETPATH" 2>/dev/null)" != "" ]; then
fail "Target directory is not empty: ${TARGETPATH}"
exit 1
fi
set_owner "$TARGETPATH"
TARGET_DIR="$TARGETPATH"
if [ "$RESTOREPATHMODE_FLAG" = "dir" ]; then
IFS='/' read -r -a RESTORE_PARTS <<< "${USERRESTOREPATH#/}"
PARTS_COUNT="${#RESTORE_PARTS[@]}"
STRIP_COUNT=0
if [ "$PARTS_COUNT" -gt 0 ]; then
STRIP_COUNT=$((PARTS_COUNT - 1))
fi
cd "$TARGET_DIR"
set +e
"$BORG_PATH" extract --progress --strip-components "$STRIP_COUNT" "${REPO}::${REPONAME}" -- "${USERRESTOREPATH}" >> "$LOG_FILE" 2>&1
EXIT_CODE=$?
set -e
append_missing_include_explanation
if [ "$EXIT_CODE" -ne 0 ]; then
if ! has_missing_include; then
fail "Borg extract zakończył się błędem. Sprawdź logi w tabeli zdarzeń na stronie pluginu."
fi
else
BASE_NAME="$(basename "${USERRESTOREPATH#/}")"
DEST_PATH="${TARGET_DIR%/}/${BASE_NAME}"
if [ -e "$DEST_PATH" ]; then
set_owner "$DEST_PATH"
fi
RESTORE_OK=1
SUCCESS_MSG="Przywracanie ${USERRESTOREPATH} z ${REPONAME} zakończone. Pliki w ${DEST_PATH}."
echo "$SUCCESS_MSG" >> "$LOG_FILE"
fi
else
cd "$TARGET_DIR"
set +e
"$BORG_PATH" extract --progress "${REPO}::${REPONAME}" -- "${USERRESTOREPATH}" >> "$LOG_FILE" 2>&1
EXIT_CODE=$?
set -e
append_missing_include_explanation
set_owner "$TARGET_DIR"
if [ "$EXIT_CODE" -eq 0 ]; then
RESTORE_OK=1
SUCCESS_MSG="Przywracanie ${USERRESTOREPATH} z ${REPONAME} zakończone. Pliki w ${TARGET_DIR}."
echo "$SUCCESS_MSG" >> "$LOG_FILE"
else
if ! has_missing_include; then
fail "Borg extract zakończył się błędem. Sprawdź logi w tabeli zdarzeń na stronie pluginu."
fi
fi
fi
fi
exit "$EXIT_CODE"
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
set -euo pipefail
PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
# Safety checks to avoid deleting the wrong path.
if [ "$(basename "$PLUGIN_DIR")" != "borg-restore" ]; then
echo "borg-restore: safety check failed (basename is not borg-restore): $PLUGIN_DIR" >&2
exit 1
fi
if [ ! -f "$PLUGIN_DIR/plugin.conf" ]; then
echo "borg-restore: safety check failed (plugin.conf missing): $PLUGIN_DIR" >&2
exit 1
fi
CRON_FILE="/etc/cron.d/borg-restore"
if [ -f "$CRON_FILE" ]; then
rm -f "$CRON_FILE" || true
fi
echo "borg-restore: uninstall complete"
+60
View File
@@ -0,0 +1,60 @@
#!/bin/bash
set -euo pipefail
umask 077
PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
DATA_DIR="$PLUGIN_DIR/data"
JOB_DIR="$DATA_DIR/jobs/pending"
PROCESSING_DIR="$DATA_DIR/jobs/processing"
DONE_DIR="$DATA_DIR/jobs/done"
LOCK_DIR="$DATA_DIR/lock"
PID_DIR="$DATA_DIR/pids"
CANCEL_DIR="$DATA_DIR/cancel"
mkdir -p "$JOB_DIR" "$PROCESSING_DIR" "$DONE_DIR" "$PID_DIR" "$CANCEL_DIR"
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
exit 0
fi
cleanup() {
rmdir "$LOCK_DIR" 2>/dev/null || true
}
trap cleanup EXIT
JOB_FILE=$(ls -1 "$JOB_DIR"/*.env 2>/dev/null | head -n 1)
if [ -z "${JOB_FILE:-}" ]; then
exit 0
fi
BASE_NAME="$(basename "$JOB_FILE")"
RUN_FILE="$PROCESSING_DIR/$BASE_NAME"
chmod 600 "$JOB_FILE" 2>/dev/null || true
mv "$JOB_FILE" "$RUN_FILE"
chmod 600 "$RUN_FILE" 2>/dev/null || true
JOB_ID="${BASE_NAME%.env}"
PID_FILE="$PID_DIR/${JOB_ID}.pid"
set +e
"$PLUGIN_DIR/scripts/restore.sh" "$RUN_FILE" &
RESTORE_PID=$!
echo "PID=${RESTORE_PID}" >> "$RUN_FILE"
chmod 600 "$RUN_FILE" 2>/dev/null || true
echo "${RESTORE_PID}" > "$PID_FILE"
wait "$RESTORE_PID"
EXIT_CODE=$?
set -e
rm -f "$PID_FILE"
CANCEL_FLAG="${CANCEL_DIR}/${JOB_ID}.flag"
if [ -f "$CANCEL_FLAG" ]; then
rm -f "$CANCEL_FLAG"
mv "$RUN_FILE" "$DONE_DIR/${BASE_NAME}.cancel"
elif [ "$EXIT_CODE" -eq 0 ]; then
mv "$RUN_FILE" "$DONE_DIR/${BASE_NAME}.ok"
else
mv "$RUN_FILE" "$DONE_DIR/${BASE_NAME}.fail"
fi
exit 0
Executable
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
set -eu
PLUGIN_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPT="$PLUGIN_DIR/scripts/uninstall.sh"
if [ ! -x "$SCRIPT" ]; then
chmod 755 "$SCRIPT" 2>/dev/null || true
fi
exec "$SCRIPT"
+3239
View File
File diff suppressed because it is too large Load Diff
+450
View File
@@ -0,0 +1,450 @@
:root {
--br-bg: #f4f6f8;
--br-card: #ffffff;
--br-text: #1f2a37;
--br-muted: #6b7280;
--br-border: #d9dee5;
--br-accent: #0ea5a4;
--br-accent-dark: #0f766e;
--br-danger: #dc2626;
--br-warn: #f59e0b;
--br-shadow: 0 8px 24px rgba(31, 41, 55, 0.12);
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--br-bg);
color: var(--br-text);
font-family: "Helvetica Neue", Arial, sans-serif;
}
.br-container {
max-width: 1100px;
margin: 24px auto;
padding: 16px;
}
.br-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.br-title {
font-size: 20px;
font-weight: 700;
letter-spacing: 0.2px;
}
.br-card {
background: var(--br-card);
border: 1px solid var(--br-border);
border-radius: 10px;
padding: 24px;
box-shadow: var(--br-shadow);
margin-bottom: 16px;
}
.br-muted { color: var(--br-muted); }
.br-alert {
border-radius: 8px;
padding: 10px 12px;
margin: 8px 0;
border: 1px solid transparent;
}
.br-alert-error { background: #fff1f2; border-color: #fecdd3; color: #9f1239; }
.br-alert-warn { background: #fffbeb; border-color: #fde68a; color: #92400e; }
.br-alert-success { background: #ecfdf3; border-color: #bbf7d0; color: #166534; }
.br-error-debug { margin: 8px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
.br-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.br-modal {
width: min(560px, 92vw);
background: #ffffff;
border-radius: 12px;
padding: 16px;
border: 2px solid var(--br-border);
box-shadow: var(--br-shadow);
display: flex;
flex-direction: column;
max-height: 90vh;
}
.br-modal-log {
width: 90vw;
background: #ffffff;
}
.br-modal-warning {
background: #f3c8c8;
}
.br-modal-warning .br-btn-ghost {
background: #ffffff;
}
.br-modal-warning .br-btn-ghost:hover {
background: #f8fafc;
}
.br-modal-danger {
border-color: var(--br-danger);
}
.br-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.br-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.br-modal-title {
font-weight: 700;
font-size: 16px;
}
.br-modal-body {
max-height: 60vh;
overflow: auto;
}
.br-modal-scroll {
overflow: auto;
flex: 1;
min-height: 0;
}
.br-log-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.br-log-header-actions {
display: flex;
gap: 8px;
}
.br-log-meta {
display: grid;
gap: 6px;
margin: 10px 0 12px;
padding: 10px 12px;
border: 1px solid var(--br-border);
border-radius: 10px;
background: #f8fafc;
font-size: 13px;
}
.br-log-actions {
margin: 8px 0 10px;
}
.br-picker-path {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.br-picker-list {
list-style: none;
padding: 0;
margin: 0;
border: 1px solid var(--br-border);
border-radius: 10px;
max-height: 45vh;
overflow: auto;
}
.br-picker-create {
margin-top: 10px;
display: flex;
gap: 8px;
}
.br-picker-create input {
flex: 1;
padding: 6px 8px;
border: 1px solid var(--br-border);
border-radius: 8px;
}
.br-picker-list li {
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid var(--br-border);
}
.br-picker-list li:last-child {
border-bottom: none;
}
.br-picker-list li:hover {
background: #f8fafc;
}
.br-picker-list li.active {
background: #e0f2fe;
}
.br-btn-small {
padding: 6px 10px;
font-size: 12px;
}
.br-actions-row {
display: flex;
gap: 8px;
margin-top: 10px;
flex-wrap: wrap;
}
.br-actions-row form {
margin: 0;
}
.br-icon-btn {
border: 1px solid var(--br-border);
background: #fff;
border-radius: 8px;
padding: 4px 8px;
cursor: pointer;
font-size: 16px;
line-height: 1;
}
.br-icon-btn:hover {
background: #f8fafc;
}
.br-table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--br-border);
border-radius: 8px;
overflow: hidden;
background: #fff;
table-layout: fixed;
}
.br-table th {
text-align: left;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--br-muted);
background: #f3f4f6;
padding: 10px;
}
.br-table td {
padding: 10px;
border-top: 1px solid var(--br-border);
vertical-align: top;
}
.br-table tr:nth-child(even) td { background: #f9fafb; }
.br-col-select { width: 50px; text-align: center; }
.br-col-status { width: 120px; }
.br-col-progress { width: 90px; }
.br-col-mode { width: 180px; }
.br-col-user { width: 140px; }
.br-col-backup { width: 220px; overflow-wrap: anywhere; }
.br-col-path { width: 260px; overflow-wrap: anywhere; }
.br-col-target { width: 220px; overflow-wrap: anywhere; }
.br-col-time { width: 150px; }
.br-col-actions { width: 120px; }
.br-table input[type="checkbox"] {
accent-color: var(--br-accent);
width: 16px;
height: 16px;
}
.br-actions { display: flex; gap: 8px; align-items: center; margin: 10px 0; }
.br-btn {
background: var(--br-accent);
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 14px;
cursor: pointer;
font-weight: 600;
}
.br-btn:hover { background: var(--br-accent-dark); }
.br-btn-ghost {
background: transparent;
color: var(--br-text);
border: 1px solid var(--br-border);
}
.br-btn-ghost:hover { background: #eef2f7; }
.br-btn-danger {
background: var(--br-danger);
}
.br-btn-danger:hover { background: #b91c1c; }
.br-link-danger { color: var(--br-danger); text-decoration: none; }
.br-link-danger:hover { text-decoration: underline; }
.br-inline-form { display: inline; }
.br-link-button {
background: none;
border: none;
padding: 0;
margin: 0;
color: inherit;
cursor: pointer;
font: inherit;
}
.br-link-button:hover { text-decoration: underline; }
.br-link-button.br-link-danger { color: var(--br-danger); }
input[type="text"] {
width: 100%;
max-width: 680px;
padding: 8px 10px;
border: 1px solid var(--br-border);
border-radius: 6px;
font-size: 14px;
background: #fff;
}
pre {
background: #0f172a;
color: #e2e8f0;
padding: 12px;
border-radius: 8px;
overflow: auto;
}
a { color: var(--br-accent-dark); text-decoration: none; }
a:hover { text-decoration: underline; }
.br-toast {
position: fixed;
bottom: 18px;
right: 18px;
background: #16a34a;
color: #fff;
padding: 12px 16px;
border-radius: 8px;
box-shadow: 0 12px 28px rgba(17, 24, 39, 0.25);
z-index: 9999;
font-weight: 600;
}
.br-toast-stack {
position: fixed;
top: 18px;
right: 18px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 9999;
}
.br-toast-stack .br-toast {
position: static;
top: auto;
bottom: auto;
}
.br-toast-error {
background: #dc2626;
top: 18px;
bottom: auto;
}
.br-toast-success {
background: #16a34a;
}
.br-status-line {
margin: 6px 0;
font-size: 14px;
}
.br-progress {
height: 8px;
background: #e5e7eb;
border-radius: 999px;
overflow: hidden;
margin: 6px 0 12px;
}
.br-progress-bar {
height: 100%;
background: var(--br-accent);
transition: width 0.3s ease;
}
.br-restore-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin: 12px 0 10px;
}
.br-restore-box {
border: 1px solid var(--br-border);
border-radius: 10px;
background: #f2f4f7;
padding: 12px 14px;
}
.br-restore-box-full {
grid-column: 1 / -1;
}
.br-restore-box-title {
margin: 0 0 10px;
font-size: 14px;
font-weight: 700;
}
.br-radio-row {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 0 0 10px;
}
.br-radio-row:last-child {
margin-bottom: 0;
}
.br-radio-row input[type="radio"] {
margin-top: 2px;
}
.br-target-panel {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #d1d5db;
}
.br-target-label {
display: block;
margin-bottom: 6px;
font-weight: 700;
}
.br-target-input-wrap {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.br-target-input-wrap input[type="text"] {
max-width: none;
flex: 1;
}
.br-usage-box {
border: 1px solid var(--br-border);
border-radius: 10px;
background: #f3f4f6;
padding: 12px 14px;
margin: 8px 0 0;
}
.br-usage-box-title {
margin: 0 0 8px;
font-size: 14px;
font-weight: 700;
}
@media (max-width: 980px) {
.br-restore-grid {
grid-template-columns: 1fr;
}
}
.br-italic {
font-style:italic;
}
.small { font-size:85%; }
.m-0 {margin:0;}
.my-5 {margin: 12px 0px;}
.br-border { border: 1px solid #eaeaea;}
.br-bg-light { background-color: #f9f9f9;}
.br-pad-x { padding-left: 1.25rem; padding-right: 1.25rem; }
.br-strong { font-weight: bold;}
.br-d-inline-block { display: inline-block; }
.br-d-block { display: block; }
.br-d-flex { display: flex; }
.br-g-4 { gap: 4px; }
.br-flex-col { flex-direction: column;}
.br-btn { border-radius: 16px; text-transform: uppercase; font-weight: normal;}
.br-icon-btn { padding: 9px; }
.border-top { border-top: 1px solid #eaeaea; padding-top: 12px; }
.border-bottom { border-bottom: 1px solid #eaeaea; padding-bottom: 12px; }
.align-self-center { align-self: center; }
#job-status {
background: transparent !important;
background-image: none !important;
}
#job-status > img {
display: none !important;
}
.br-jobs-empty {
margin: 0;
color: var(--br-muted);
background: transparent !important;
background-image: none !important;
}
+451
View File
@@ -0,0 +1,451 @@
:root {
--br-bg: #f5f8ff;
--br-card: #ffffff;
--br-text: #111827;
--br-muted: #64748b;
--br-border: #e2e8f0;
--br-accent: #2563eb;
--br-accent-dark: #1d4ed8;
--br-danger: #dc2626;
--br-warn: #f59e0b;
--br-shadow: 0 10px 28px rgba(15, 23, 42, 0.12);
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--br-bg);
color: var(--br-text);
font-family: "IBM Plex Sans", "Segoe UI", Arial, sans-serif;
}
.br-container {
max-width: none;
width: 100%;
margin: 16px auto;
padding: 16px 24px;
}
.br-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.br-title {
font-size: 20px;
font-weight: 700;
letter-spacing: 0.2px;
}
.br-card {
background: var(--br-card);
border: 1px solid var(--br-border);
border-radius: 12px;
padding: 24px;
box-shadow: var(--br-shadow);
margin-bottom: 16px;
}
.br-muted { color: var(--br-muted); }
.br-alert {
border-radius: 10px;
padding: 10px 12px;
margin: 8px 0;
border: 1px solid transparent;
}
.br-alert-error { background: #fff1f2; border-color: #fecdd3; color: #9f1239; }
.br-alert-warn { background: #fffbeb; border-color: #fde68a; color: #92400e; }
.br-alert-success { background: #ecfdf3; border-color: #bbf7d0; color: #166534; }
.br-error-debug { margin: 8px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
.br-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.br-modal {
width: min(560px, 92vw);
background: #ffffff;
border-radius: 12px;
padding: 16px;
border: 2px solid var(--br-border);
box-shadow: var(--br-shadow);
display: flex;
flex-direction: column;
max-height: 90vh;
}
.br-modal-log {
width: 90vw;
background: #ffffff;
}
.br-modal-warning {
background: #f3c8c8;
}
.br-modal-warning .br-btn-ghost {
background: #ffffff;
}
.br-modal-warning .br-btn-ghost:hover {
background: #f8fafc;
}
.br-modal-danger {
border-color: var(--br-danger);
}
.br-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.br-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.br-modal-title {
font-weight: 700;
font-size: 16px;
}
.br-modal-body {
max-height: 60vh;
overflow: auto;
}
.br-modal-scroll {
overflow: auto;
flex: 1;
min-height: 0;
}
.br-log-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.br-log-header-actions {
display: flex;
gap: 8px;
}
.br-log-meta {
display: grid;
gap: 6px;
margin: 10px 0 12px;
padding: 10px 12px;
border: 1px solid var(--br-border);
border-radius: 10px;
background: #f8fafc;
font-size: 13px;
}
.br-log-actions {
margin: 8px 0 10px;
}
.br-picker-path {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.br-picker-list {
list-style: none;
padding: 0;
margin: 0;
border: 1px solid var(--br-border);
border-radius: 10px;
max-height: 45vh;
overflow: auto;
}
.br-picker-create {
margin-top: 10px;
display: flex;
gap: 8px;
}
.br-picker-create input {
flex: 1;
padding: 6px 8px;
border: 1px solid var(--br-border);
border-radius: 8px;
}
.br-picker-list li {
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid var(--br-border);
}
.br-picker-list li:last-child {
border-bottom: none;
}
.br-picker-list li:hover {
background: #f8fafc;
}
.br-picker-list li.active {
background: #d1fae5;
}
.br-btn-small {
padding: 6px 10px;
font-size: 12px;
}
.br-actions-row {
display: flex;
gap: 8px;
margin-top: 10px;
flex-wrap: wrap;
}
.br-actions-row form {
margin: 0;
}
.br-icon-btn {
border: 1px solid var(--br-border);
background: #fff;
border-radius: 8px;
padding: 4px 8px;
cursor: pointer;
font-size: 16px;
line-height: 1;
}
.br-icon-btn:hover {
background: #f8fafc;
}
.br-table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--br-border);
border-radius: 10px;
overflow: hidden;
background: #fff;
table-layout: fixed;
}
.br-table th {
text-align: left;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--br-muted);
background: #f1f5f9;
padding: 10px;
}
.br-table td {
padding: 10px;
border-top: 1px solid var(--br-border);
vertical-align: top;
}
.br-table tr:nth-child(even) td { background: #f8fafc; }
.br-col-select { width: 50px; text-align: center; }
.br-col-status { width: 120px; }
.br-col-progress { width: 90px; }
.br-col-mode { width: 180px; }
.br-col-user { width: 140px; }
.br-col-backup { width: 220px; overflow-wrap: anywhere; }
.br-col-path { width: 260px; overflow-wrap: anywhere; }
.br-col-target { width: 220px; overflow-wrap: anywhere; }
.br-col-time { width: 150px; }
.br-col-actions { width: 120px; }
.br-table input[type="checkbox"] {
accent-color: var(--br-accent);
width: 16px;
height: 16px;
}
.br-actions { display: flex; gap: 8px; align-items: center; margin: 10px 0; }
.br-btn {
background: var(--br-accent);
color: #fff;
border: none;
border-radius: 8px;
padding: 8px 14px;
cursor: pointer;
font-weight: 600;
}
.br-btn:hover { background: var(--br-accent-dark); }
.br-btn-ghost {
background: transparent;
color: var(--br-text);
border: 1px solid var(--br-border);
}
.br-btn-ghost:hover { background: #e2e8f0; }
.br-btn-danger {
background: var(--br-danger);
}
.br-btn-danger:hover { background: #b91c1c; }
.br-link-danger { color: var(--br-danger); text-decoration: none; }
.br-link-danger:hover { text-decoration: underline; }
.br-inline-form { display: inline; }
.br-link-button {
background: none;
border: none;
padding: 0;
margin: 0;
color: inherit;
cursor: pointer;
font: inherit;
}
.br-link-button:hover { text-decoration: underline; }
.br-link-button.br-link-danger { color: var(--br-danger); }
input[type="text"] {
width: 100%;
max-width: 680px;
padding: 8px 10px;
border: 1px solid var(--br-border);
border-radius: 8px;
font-size: 14px;
background: #fff;
}
pre {
background: #0b1020;
color: #e2e8f0;
padding: 12px;
border-radius: 8px;
overflow: auto;
}
a { color: var(--br-accent-dark); text-decoration: none; }
a:hover { text-decoration: underline; }
.br-toast {
position: fixed;
bottom: 18px;
right: 18px;
background: #16a34a;
color: #fff;
padding: 12px 16px;
border-radius: 10px;
box-shadow: 0 12px 28px rgba(16, 24, 40, 0.25);
z-index: 9999;
font-weight: 600;
}
.br-toast-stack {
position: fixed;
top: 18px;
right: 18px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 9999;
}
.br-toast-stack .br-toast {
position: static;
top: auto;
bottom: auto;
}
.br-toast-error {
background: #dc2626;
top: 18px;
bottom: auto;
}
.br-toast-success {
background: #16a34a;
}
.br-status-line {
margin: 6px 0;
font-size: 14px;
}
.br-progress {
height: 8px;
background: #e5e7eb;
border-radius: 999px;
overflow: hidden;
margin: 6px 0 12px;
}
.br-progress-bar {
height: 100%;
background: var(--br-accent);
transition: width 0.3s ease;
}
.br-restore-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin: 12px 0 10px;
}
.br-restore-box {
border: 1px solid var(--br-border);
border-radius: 10px;
background: #f1f5f9;
padding: 12px 14px;
}
.br-restore-box-full {
grid-column: 1 / -1;
}
.br-restore-box-title {
margin: 0 0 10px;
font-size: 14px;
font-weight: 700;
}
.br-radio-row {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 0 0 10px;
}
.br-radio-row:last-child {
margin-bottom: 0;
}
.br-radio-row input[type="radio"] {
margin-top: 2px;
}
.br-target-panel {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #cbd5e1;
}
.br-target-label {
display: block;
margin-bottom: 6px;
font-weight: 700;
}
.br-target-input-wrap {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.br-target-input-wrap input[type="text"] {
max-width: none;
flex: 1;
}
.br-usage-box {
border: 1px solid var(--br-border);
border-radius: 10px;
background: #f1f5f9;
padding: 12px 14px;
margin: 8px 0 0;
}
.br-usage-box-title {
margin: 0 0 8px;
font-size: 14px;
font-weight: 700;
}
@media (max-width: 980px) {
.br-restore-grid {
grid-template-columns: 1fr;
}
}
.br-italic {
font-style:italic;
}
.small { font-size:85%; }
.m-0 {margin:0;}
.my-5 {margin: 12px 0px;}
.br-border { border: 1px solid #eaeaea;}
.br-bg-light { background-color: #f9f9f9;}
.br-pad-x { padding-left: 1.25rem; padding-right: 1.25rem; }
.br-strong { font-weight: bold;}
.br-d-inline-block { display: inline-block; }
.br-d-block { display: block; }
.br-d-flex { display: flex; }
.br-g-4 { gap: 4px; }
.br-flex-col { flex-direction: column;}
.br-btn { border-radius: 16px; text-transform: uppercase; font-weight: normal;}
.br-icon-btn { padding: 8px; }
.border-top { border-top: 1px solid #eaeaea; padding-top: 12px; }
.border-bottom { border-bottom: 1px solid #eaeaea; padding-bottom: 12px; }
.align-self-center { align-self: center; }
#job-status {
background: transparent !important;
background-image: none !important;
}
#job-status > img {
display: none !important;
}
.br-jobs-empty {
margin: 0;
color: var(--br-muted);
background: transparent !important;
background-image: none !important;
}
+3385
View File
File diff suppressed because it is too large Load Diff