Merge branch 'bcrooker-main'
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
This commit is contained in:
commit
e8ae8812ff
14 changed files with 2362 additions and 1 deletions
|
|
@ -0,0 +1,35 @@
|
|||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
||||
import { Textarea } from "~/client/components/ui/textarea";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import type { InternalFormValues } from "./types";
|
||||
|
||||
type AdvancedSectionProps = {
|
||||
form: UseFormReturn<InternalFormValues>;
|
||||
};
|
||||
|
||||
export const AdvancedSection = ({ form }: AdvancedSectionProps) => {
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customResticParamsText"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Custom restic parameters</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder="--exclude-larger-than 500M --no-scan --read-concurrency 8"
|
||||
className="font-mono text-sm min-h-24"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Advanced: enter one restic flag per line (e.g.{" "}
|
||||
<code className="bg-muted px-1 rounded">--iexclude-larger-than 500M</code>). These are appended to the
|
||||
backup command as-is.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -4,7 +4,9 @@ import { useForm } from "react-hook-form";
|
|||
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
|
||||
import { Form } from "~/client/components/ui/form";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/client/components/ui/collapsible";
|
||||
import type { BackupSchedule, Volume } from "~/client/lib/types";
|
||||
import { AdvancedSection } from "./advanced-section";
|
||||
import { BasicInfoSection } from "./basic-info-section";
|
||||
import { ExcludeSection } from "./exclude-section";
|
||||
import { FrequencySection } from "./frequency-section";
|
||||
|
|
@ -39,6 +41,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||
excludePatternsText,
|
||||
excludeIfPresentText,
|
||||
includePatternsText,
|
||||
customResticParamsText,
|
||||
includePatterns: fileBrowserPatterns,
|
||||
cronExpression,
|
||||
...rest
|
||||
|
|
@ -65,12 +68,20 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||
: [];
|
||||
const includePatterns = [...(fileBrowserPatterns || []), ...textPatterns];
|
||||
|
||||
const customResticParams = customResticParamsText
|
||||
? customResticParamsText
|
||||
.split("\n")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
onSubmit({
|
||||
...rest,
|
||||
cronExpression,
|
||||
includePatterns: includePatterns.length > 0 ? includePatterns : [],
|
||||
excludePatterns,
|
||||
excludeIfPresent,
|
||||
customResticParams,
|
||||
});
|
||||
},
|
||||
[onSubmit],
|
||||
|
|
@ -164,6 +175,17 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||
<RetentionSection form={form} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="min-w-0 @container">
|
||||
<CardContent>
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger>Advanced</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pb-4 space-y-4">
|
||||
<AdvancedSection form={form} />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="xl:sticky xl:top-6 xl:self-start">
|
||||
<SummarySection volume={volume} frequency={frequency} formValues={formValues} />
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export const internalFormSchema = type({
|
|||
keepMonthly: "number?",
|
||||
keepYearly: "number?",
|
||||
oneFileSystem: "boolean?",
|
||||
customResticParamsText: "string?",
|
||||
});
|
||||
|
||||
export const cleanSchema = type.pipe((d) => internalFormSchema(deepClean(d)));
|
||||
|
|
@ -38,8 +39,9 @@ export type InternalFormValues = typeof internalFormSchema.infer;
|
|||
|
||||
export type BackupScheduleFormValues = Omit<
|
||||
InternalFormValues,
|
||||
"excludePatternsText" | "excludeIfPresentText" | "includePatternsText"
|
||||
"excludePatternsText" | "excludeIfPresentText" | "includePatternsText" | "customResticParamsText"
|
||||
> & {
|
||||
excludePatterns?: string[];
|
||||
excludeIfPresent?: string[];
|
||||
customResticParams?: string[];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalF
|
|||
excludePatternsText: schedule.excludePatterns?.join("\n") || undefined,
|
||||
excludeIfPresentText: schedule.excludeIfPresent?.join("\n") || undefined,
|
||||
oneFileSystem: schedule.oneFileSystem ?? false,
|
||||
customResticParamsText: schedule.customResticParams?.join("\n") ?? "",
|
||||
...cronValues,
|
||||
...schedule.retentionPolicy,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -183,6 +183,7 @@ export function ScheduleDetailsPage(props: Props) {
|
|||
excludePatterns: formValues.excludePatterns,
|
||||
excludeIfPresent: formValues.excludeIfPresent,
|
||||
oneFileSystem: formValues.oneFileSystem,
|
||||
customResticParams: formValues.customResticParams,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -191,6 +192,7 @@ export function ScheduleDetailsPage(props: Props) {
|
|||
updateSchedule.mutate({
|
||||
path: { shortId: schedule.shortId },
|
||||
body: {
|
||||
name: schedule.name,
|
||||
repositoryId: schedule.repositoryId,
|
||||
enabled,
|
||||
cronExpression: schedule.cronExpression,
|
||||
|
|
@ -199,6 +201,7 @@ export function ScheduleDetailsPage(props: Props) {
|
|||
excludePatterns: schedule.excludePatterns || [],
|
||||
excludeIfPresent: schedule.excludeIfPresent || [],
|
||||
oneFileSystem: schedule.oneFileSystem,
|
||||
customResticParams: schedule.customResticParams || [],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export function CreateBackupPage() {
|
|||
excludePatterns: formValues.excludePatterns,
|
||||
excludeIfPresent: formValues.excludeIfPresent,
|
||||
oneFileSystem: formValues.oneFileSystem,
|
||||
customResticParams: formValues.customResticParams,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
1
app/drizzle/20260304175134_woozy_zemo/migration.sql
Normal file
1
app/drizzle/20260304175134_woozy_zemo/migration.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE `backup_schedules_table` ADD `custom_restic_params` text DEFAULT '[]';
|
||||
2195
app/drizzle/20260304175134_woozy_zemo/snapshot.json
Normal file
2195
app/drizzle/20260304175134_woozy_zemo/snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -292,6 +292,7 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
|
|||
lastBackupError: text("last_backup_error"),
|
||||
nextBackupAt: int("next_backup_at", { mode: "number" }),
|
||||
oneFileSystem: int("one_file_system", { mode: "boolean" }).notNull().default(false),
|
||||
customResticParams: text("custom_restic_params", { mode: "json" }).$type<string[]>().default([]),
|
||||
sortOrder: int("sort_order", { mode: "number" }).notNull().default(0),
|
||||
createdAt: int("created_at", { mode: "number" })
|
||||
.notNull()
|
||||
|
|
|
|||
|
|
@ -42,4 +42,5 @@ export const createBackupOptions = (schedule: BackupSchedule, volumePath: string
|
|||
include: schedule.includePatterns
|
||||
? schedule.includePatterns.map((p) => processPattern(p, volumePath, true))
|
||||
: undefined,
|
||||
customResticParams: schedule.customResticParams ?? undefined,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ const backupScheduleSchema = type({
|
|||
excludeIfPresent: "string[] | null",
|
||||
includePatterns: "string[] | null",
|
||||
oneFileSystem: "boolean",
|
||||
customResticParams: "string[] | null",
|
||||
lastBackupAt: "number | null",
|
||||
lastBackupStatus: "'success' | 'error' | 'in_progress' | 'warning' | null",
|
||||
lastBackupError: "string | null",
|
||||
|
|
@ -136,6 +137,7 @@ export const createBackupScheduleBody = type({
|
|||
includePatterns: "string[]?",
|
||||
oneFileSystem: "boolean?",
|
||||
tags: "string[]?",
|
||||
customResticParams: "string[]?",
|
||||
});
|
||||
|
||||
export type CreateBackupScheduleBody = typeof createBackupScheduleBody.infer;
|
||||
|
|
@ -174,6 +176,7 @@ export const updateBackupScheduleBody = type({
|
|||
includePatterns: "string[]?",
|
||||
oneFileSystem: "boolean?",
|
||||
tags: "string[]?",
|
||||
customResticParams: "string[]?",
|
||||
});
|
||||
|
||||
export type UpdateBackupScheduleBody = typeof updateBackupScheduleBody.infer;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { generateShortId } from "~/server/utils/id";
|
|||
import { getOrganizationId } from "~/server/core/request-context";
|
||||
import { calculateNextRun } from "./backup.helpers";
|
||||
import { asShortId, type ShortId } from "~/server/utils/branded";
|
||||
import { validateCustomResticParams } from "~/server/utils/restic/helpers/validate-custom-params";
|
||||
|
||||
const listSchedules = async () => {
|
||||
const organizationId = getOrganizationId();
|
||||
|
|
@ -119,6 +120,11 @@ const createSchedule = async (data: CreateBackupScheduleBody) => {
|
|||
throw new NotFoundError("Repository not found");
|
||||
}
|
||||
|
||||
if (data.customResticParams && data.customResticParams.length > 0) {
|
||||
const paramError = validateCustomResticParams(data.customResticParams);
|
||||
if (paramError) throw new BadRequestError(paramError);
|
||||
}
|
||||
|
||||
const nextBackupAt = calculateNextRun(data.cronExpression);
|
||||
|
||||
const [newSchedule] = await db
|
||||
|
|
@ -134,6 +140,7 @@ const createSchedule = async (data: CreateBackupScheduleBody) => {
|
|||
excludeIfPresent: data.excludeIfPresent ?? [],
|
||||
includePatterns: data.includePatterns ?? [],
|
||||
oneFileSystem: data.oneFileSystem,
|
||||
customResticParams: data.customResticParams ?? [],
|
||||
nextBackupAt: nextBackupAt,
|
||||
shortId: generateShortId(),
|
||||
organizationId,
|
||||
|
|
@ -166,6 +173,11 @@ const updateSchedule = async (scheduleIdOrShortId: number | string, data: Update
|
|||
throw new BadRequestError("Invalid cron expression");
|
||||
}
|
||||
|
||||
if (data.customResticParams && data.customResticParams.length > 0) {
|
||||
const paramError = validateCustomResticParams(data.customResticParams);
|
||||
if (paramError) throw new BadRequestError(paramError);
|
||||
}
|
||||
|
||||
if (data.name) {
|
||||
const existingName = await db.query.backupSchedulesTable.findFirst({
|
||||
where: {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { addCommonArgs } from "../helpers/add-common-args";
|
|||
import { buildEnv } from "../helpers/build-env";
|
||||
import { buildRepoUrl } from "../helpers/build-repo-url";
|
||||
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
|
||||
import { validateCustomResticParams } from "../helpers/validate-custom-params";
|
||||
|
||||
export const backup = async (
|
||||
config: RepositoryConfig,
|
||||
|
|
@ -32,6 +33,7 @@ export const backup = async (
|
|||
compressionMode?: CompressionMode;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (progress: ResticBackupProgressDto) => void;
|
||||
customResticParams?: string[];
|
||||
},
|
||||
) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
|
|
@ -85,6 +87,17 @@ export const backup = async (
|
|||
}
|
||||
}
|
||||
|
||||
if (options.customResticParams && options.customResticParams.length > 0) {
|
||||
const validationError = validateCustomResticParams(options.customResticParams);
|
||||
if (validationError) {
|
||||
throw new Error(`Invalid customResticParams: ${validationError}`);
|
||||
}
|
||||
for (const param of options.customResticParams) {
|
||||
const tokens = param.trim().split(/\s+/).filter(Boolean);
|
||||
args.push(...tokens);
|
||||
}
|
||||
}
|
||||
|
||||
addCommonArgs(args, env, config);
|
||||
|
||||
const logData = throttle((data: string) => {
|
||||
|
|
|
|||
71
app/server/utils/restic/helpers/validate-custom-params.ts
Normal file
71
app/server/utils/restic/helpers/validate-custom-params.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
const DENIED_FLAGS = new Set<string>([
|
||||
"--password-command",
|
||||
"--password-file",
|
||||
"--password",
|
||||
"-p",
|
||||
"--repository",
|
||||
"--repository-file",
|
||||
"-r",
|
||||
"--option",
|
||||
"-o",
|
||||
"--key-hint",
|
||||
"--tls-client-cert",
|
||||
"--cacert",
|
||||
"--repo",
|
||||
]);
|
||||
|
||||
const ALLOWED_FLAGS = new Set<string>([
|
||||
"--verbose",
|
||||
"-v",
|
||||
"--no-scan",
|
||||
"--exclude-larger-than",
|
||||
"--skip-if-unchanged",
|
||||
"--exclude-caches",
|
||||
"--force",
|
||||
"--use-fs-snapshot",
|
||||
"--read-concurrency",
|
||||
"--ignore-ctime",
|
||||
"--ignore-inode",
|
||||
"--with-atime",
|
||||
"--no-cache",
|
||||
"--cleanup-cache",
|
||||
"--cache-dir",
|
||||
"--limit-upload",
|
||||
"--limit-download",
|
||||
"--pack-size",
|
||||
"--dry-run",
|
||||
"-n",
|
||||
"--no-lock",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
function extractFlagName(token: string): string | null {
|
||||
if (!token.startsWith("-")) {
|
||||
return null;
|
||||
}
|
||||
const eqIdx = token.indexOf("=");
|
||||
return eqIdx === -1 ? token : token.slice(0, eqIdx);
|
||||
}
|
||||
|
||||
export function validateCustomResticParams(params: string[]): string | null {
|
||||
for (const param of params) {
|
||||
const tokens = param.trim().split(/\s+/).filter(Boolean);
|
||||
|
||||
for (const token of tokens) {
|
||||
const flag = extractFlagName(token);
|
||||
if (flag === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (DENIED_FLAGS.has(flag)) {
|
||||
return `Flag "${flag}" is not permitted in customResticParams`;
|
||||
}
|
||||
|
||||
if (!ALLOWED_FLAGS.has(flag)) {
|
||||
return `Unknown or unsupported flag "${flag}" in customResticParams. Permitted flags: ${[...ALLOWED_FLAGS].join(", ")}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Loading…
Reference in a new issue