Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api-docs/openapi.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: DBackup API
version: 2.3.2
version: 2.3.3
description: |
REST API for DBackup - a self-hosted database backup automation platform with encryption, compression, and smart retention.

Expand Down
30 changes: 30 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,36 @@

All notable changes to DBackup are documented here.

## v2.3.3 - Multiple Bug Fixes across MSSQL, Redis, Email, and Storage Adapters
*Released: May 19, 2026*

### 🐛 Bug Fixes

- **MSSQL**: Fixed Database Explorer showing "No user databases found" on production instances. Databases in non-ONLINE states (e.g. RESTORING, Availability Group replicas) are now included, and connection errors are surfaced to the UI instead of silently returning an empty list.
- **Redis**: Fixed `A credential profile is required but none is assigned` error when connecting to a Redis instance without authentication. The credential profile is now optional for Redis - when no profile is assigned, the structural config fields (inline password if any) are used directly, allowing Redis instances without ACL/password to work without a credential profile. ([#86](https://github.com/Skyfay/DBackup/issues/86))
- **email**: Fixed automated email (SMTP) notifications failing with `A credential profile is required but none is assigned` during backup/restore job runs and system health checks. The v2.3.2 fix only covered the test-connection path; the runner pipeline still used a stricter resolver that threw unconditionally when no profile was assigned. The credential profile is now consistently optional in all code paths - when no profile is assigned, the structural config (host, port, from, to, inline user/password if any) is used directly. ([#87](https://github.com/Skyfay/DBackup/issues/87))
- **Storage**: Fixed orphaned `.connection-test-*` / `.dbackup-test-*` files accumulating on remote storage destinations. All 10 storage adapters (FTP/FTPS, SMB, SFTP, WebDAV, S3/R2/Hetzner/AWS, Local, Rsync, Dropbox, Google Drive, OneDrive) placed the remote file deletion inside the `try` block without a `finally` guard. If the delete call threw (network hiccup, server-side error, permission edge case), the test file was left behind permanently. Every adapter now uses a `remoteFileCreated` flag and a `finally` block to guarantee a best-effort cleanup even when the delete itself fails.
- **Storage**: Fixed false "-100% change" spike notifications still triggering for users with a **Local Filesystem** destination. The v2.3.2 fix updated all 9 cloud/network adapters but missed the Local adapter: its `list()` catch block returned `[]` on any I/O error (e.g. a temporarily unmounted fstab disk) instead of throwing. This caused a 0-byte snapshot to be saved and a spike alert to fire, identical to the original bug. The inner `fs.access` wrapper is removed so the original error code propagates; the outer catch now throws on all errors except ENOENT on non-root sub-paths (legitimate "no backups for this job yet" scenario). ([#82](https://github.com/Skyfay/DBackup/issues/82))
- **Storage**: Fixed the same class of bug in the **SMB** and **FTP** adapters. Both use an inner `walk()` helper that silently swallowed listing errors via `catch { return; }`. Because the SMB share connection is not established until the first `client.list()` call (unlike FTP/cloud adapters which connect upfront), any SMB authentication or network failure was silently turned into an empty list. The inner catch now re-throws when `currentDir === startDir` (root listing = connection/auth failure) and continues silently only for sub-directory errors (e.g. one folder with restricted permissions). ([#82](https://github.com/Skyfay/DBackup/issues/82))

### 🎨 Improvements

- **Storage**: Improved UX for S3 Glacier and Deep Archive storage classes. The file list now surfaces the storage class of each object from the AWS ListObjectsV2 response. In the Storage Explorer, Glacier and Deep Archive objects are labeled with an orange "Glacier" or "Deep Archive" badge. Download and Restore action buttons are disabled for archived objects with a tooltip explaining that the object must be restored via the AWS Console first. The S3 download function now throws a descriptive error (instead of returning a generic failure) when AWS returns `InvalidObjectState`, so the message is surfaced to the user in the UI. The "Storage Class" field description in the adapter configuration form now includes a warning that GLACIER and DEEP_ARCHIVE prevent direct download and restore. ([#88](https://github.com/Skyfay/DBackup/issues/88))

### 🧪 Tests

- Updated Local Filesystem adapter `list()` tests: replaced "returns empty array on unexpected error" with three new assertions - throws on unexpected readdir errors, throws when the root path (`remotePath = ""`) is inaccessible (ENOENT), and throws on non-ENOENT access errors on sub-paths (EACCES).
- Updated **SMB** adapter `list()` tests: replaced the incorrect "returns empty array on list error" assertion (expected `[]`, now expects throw) with "throws when root directory listing fails"; added "continues when a subdirectory listing fails" to document the intentional silent-skip behavior for non-root walk errors.
- Updated **FTP** adapter `list()` tests: added "throws when initial directory listing fails after connection" covering the case where `connectFTP` succeeds but the first `client.list()` call fails (e.g. path does not exist or permission denied). The existing subdirectory-continue test is unchanged.

### 🐳 Docker

- **Image**: `skyfay/dbackup:v2.3.3`
- **Also tagged as**: `latest`, `v2`
- **CI Image**: `skyfay/dbackup:ci`
- **Platforms**: linux/amd64, linux/arm64


## v2.3.2 - Backup Trigger Metadata, Job Trigger Locking, and Notification Improvements
*Released: May 17, 2026*

Expand Down
2 changes: 1 addition & 1 deletion docs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dbackup-docs",
"version": "2.3.2",
"version": "2.3.3",
"private": true,
"scripts": {
"dev": "vitepress dev",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dbackup",
"version": "2.3.2",
"version": "2.3.3",
"private": true,
"scripts": {
"dev": "next dev",
Expand Down
2 changes: 1 addition & 1 deletion public/openapi.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: DBackup API
version: 2.3.2
version: 2.3.3
description: |
REST API for DBackup - a self-hosted database backup automation platform with encryption, compression, and smart retention.

Expand Down
21 changes: 19 additions & 2 deletions src/app/dashboard/storage/columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type FileInfo = {
compression?: string;
locked?: boolean;
trigger?: { type: string; actor?: string };
storageClass?: string;
};

interface ColumnsProps {
Expand Down Expand Up @@ -123,8 +124,24 @@ export const getColumns = ({ onRestore, onDownload, onDelete, onToggleLock, onGe
header: "Compression",
cell: ({ row }) => {
const comp = row.original.compression;
if (!comp || comp === "NONE") return <span className="text-muted-foreground text-xs">-</span>;
return <Badge variant="outline" className="text-[10px] h-5 px-1.5 border-blue-200 text-blue-700 dark:text-blue-400 dark:border-blue-900">{comp}</Badge>;
const storageClass = row.original.storageClass;
const isArchived = storageClass === "GLACIER" || storageClass === "DEEP_ARCHIVE";
const hasContent = isArchived || (comp && comp !== "NONE");

if (!hasContent) return <span className="text-muted-foreground text-xs">-</span>;

return (
<div className="flex items-center gap-1 flex-wrap">
{isArchived && (
<Badge variant="outline" className="text-[10px] h-5 px-1.5 border-orange-200 text-orange-700 dark:text-orange-400 dark:border-orange-900">
{storageClass === "DEEP_ARCHIVE" ? "Deep Archive" : "Glacier"}
</Badge>
)}
{comp && comp !== "NONE" && (
<Badge variant="outline" className="text-[10px] h-5 px-1.5 border-blue-200 text-blue-700 dark:text-blue-400 dark:border-blue-900">{comp}</Badge>
)}
</div>
);
}
},
{
Expand Down
19 changes: 17 additions & 2 deletions src/components/adapter/form-sections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { Check, FolderOpen, Loader2 } from "lucide-react";
import { AlertTriangle, Check, FolderOpen, Loader2 } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { AdapterDefinition } from "@/lib/adapters/definitions";
import { SchemaField } from "./schema-field";
import { EmailTagField } from "./email-tag-field";
Expand Down Expand Up @@ -603,6 +604,8 @@ export function StorageFormContent({
}: { adapter: AdapterDefinition; initialData?: AdapterConfig; healthNotificationsDisabled?: boolean; onHealthNotificationsDisabledChange?: (disabled: boolean) => void } & CredentialPickerHostProps) {
const { watch } = useFormContext();
const authType = watch("config.authType");
const storageClass = watch("config.storageClass");
const isArchivedStorageClass = storageClass === "GLACIER" || storageClass === "DEEP_ARCHIVE";
const hasRealConfigKeys = hasFields(adapter, STORAGE_CONFIG_KEYS);
// Always show Configuration tab for storage adapters (health check switch lives there)
const hasConfigKeys = hasRealConfigKeys || !!onHealthNotificationsDisabledChange;
Expand Down Expand Up @@ -720,7 +723,19 @@ export function StorageFormContent({
hasRefreshToken={hasRefreshToken}
/>
) : hasRealConfigKeys ? (
<FieldList keys={configKeys} adapter={adapter} />
<>
<FieldList keys={configKeys} adapter={adapter} />
{isArchivedStorageClass && (
<Alert className="border-orange-200 bg-orange-50 dark:bg-orange-950/30 dark:border-orange-900">
<AlertTriangle className="h-4 w-4 text-orange-600 dark:text-orange-400" />
<AlertDescription className="text-orange-700 dark:text-orange-300 text-sm">
<strong>{storageClass === "DEEP_ARCHIVE" ? "Deep Archive" : "Glacier"}</strong> is an archived storage class.
Backups stored with this class cannot be downloaded or restored directly through DBackup.
You must first restore the object via the AWS Console (S3 - select object - Actions - Initiate restore) before accessing it.
</AlertDescription>
</Alert>
)}
</>
) : null}
{onHealthNotificationsDisabledChange && (
<HealthCheckNotificationSwitch
Expand Down
55 changes: 44 additions & 11 deletions src/components/dashboard/storage/cells/actions-cell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSepara
import { Download, RotateCcw, Trash2, Lock, FileLock2, FileCheck, Terminal } from "lucide-react";
import { FileInfo } from "@/app/dashboard/storage/columns";

const ARCHIVED_STORAGE_CLASSES = ["GLACIER", "DEEP_ARCHIVE"];

interface ActionsCellProps {
file: FileInfo;
onDownload: (file: FileInfo, decrypt?: boolean) => void;
Expand All @@ -27,6 +29,9 @@ export function ActionsCell({
canRestore,
canDelete
}: ActionsCellProps) {
const isArchived = ARCHIVED_STORAGE_CLASSES.includes(file.storageClass ?? "");
const archivedTooltip = "This backup is archived in S3 Glacier or Deep Archive. Restore it via the AWS Console first (S3 - select object - Actions - Initiate restore).";

return (
<div className="flex items-center justify-end gap-2">
{onToggleLock && canDelete && (
Expand All @@ -50,7 +55,20 @@ export function ActionsCell({
)}

{canDownload && (
file.isEncrypted ? (
isArchived ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button variant="ghost" size="icon" className="h-8 w-8 opacity-40 cursor-not-allowed" disabled>
<Download className="h-4 w-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent className="max-w-xs">{archivedTooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : file.isEncrypted ? (
<DropdownMenu>
<TooltipProvider>
<Tooltip>
Expand Down Expand Up @@ -118,16 +136,31 @@ export function ActionsCell({
)}

{canRestore && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onRestore(file)}>
<RotateCcw className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Restore</TooltipContent>
</Tooltip>
</TooltipProvider>
isArchived ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button variant="ghost" size="icon" className="h-8 w-8 opacity-40 cursor-not-allowed" disabled>
<RotateCcw className="h-4 w-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent className="max-w-xs">{archivedTooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onRestore(file)}>
<RotateCcw className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Restore</TooltipContent>
</Tooltip>
</TooltipProvider>
)
)}

{canDelete && (
Expand Down
26 changes: 16 additions & 10 deletions src/lib/adapters/config-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,21 +68,27 @@ export async function resolveAdapterConfig(adapter: AdapterConfigInput): Promise
}

// --- Primary slot ---
// When the adapter declares a required primary credential, a profile must be assigned.
// When the adapter declares a required primary credential, a profile must be assigned
// unless the adapter marks the primary slot as optional (e.g. Redis without auth,
// SMTP unauthenticated relay). Optional adapters fall back to the structural config.
if (requirements.primary) {
if (!adapter.primaryCredentialId) {
throw new ConfigurationError(
if (!requirements.primaryOptional) {
throw new ConfigurationError(
adapter.adapterId,
"A credential profile is required but none is assigned"
);
}
// Optional and no profile assigned - use structural config fields as-is
} else {
const profile = await loadAndValidate(
adapter.primaryCredentialId,
requirements.primary,
adapter.adapterId,
"A credential profile is required but none is assigned"
"primary"
);
applyPrimaryOverlay(parsed, profile, requirements.primary);
}
const profile = await loadAndValidate(
adapter.primaryCredentialId,
requirements.primary,
adapter.adapterId,
"primary"
);
applyPrimaryOverlay(parsed, profile, requirements.primary);
}

// --- SSH slot (always optional at runtime - SSH mode is opt-in per adapter) ---
Expand Down
10 changes: 6 additions & 4 deletions src/lib/adapters/database/mssql/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,16 +151,18 @@ export async function getDatabasesWithStats(config: MSSQLConfig): Promise<Databa
pool = new sql.ConnectionPool(connConfig);
await pool.connect();

// Get database names and sizes from master catalog views
// Get database names and sizes from master catalog views.
// Include all user databases regardless of state so offline/restoring DBs
// are still visible. state_desc is included for display purposes.
const sizeResult = await pool.request().query(`
SELECT
d.name,
d.state_desc,
SUM(mf.size) * 8 * 1024 AS size_bytes
FROM sys.databases d
LEFT JOIN sys.master_files mf ON d.database_id = mf.database_id
WHERE d.database_id > 4
AND d.state = 0
GROUP BY d.name
GROUP BY d.name, d.state_desc
ORDER BY d.name
`);

Expand Down Expand Up @@ -191,7 +193,7 @@ export async function getDatabasesWithStats(config: MSSQLConfig): Promise<Databa
return databases;
} catch (error: unknown) {
log.error("Failed to get databases with stats", {}, wrapError(error));
return [];
throw wrapError(error);
} finally {
if (pool) {
await pool.close();
Expand Down
2 changes: 1 addition & 1 deletion src/lib/adapters/database/redis/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const RedisAdapter: DatabaseAdapter = {
type: "database",
name: "Redis",
configSchema: RedisSchema,
credentials: { primary: "USERNAME_PASSWORD", ssh: "SSH_KEY" },
credentials: { primary: "USERNAME_PASSWORD", primaryOptional: true, ssh: "SSH_KEY" },
dump,
restore,
prepareRestore,
Expand Down
9 changes: 8 additions & 1 deletion src/lib/adapters/definitions/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ export type AdapterDefinition = {
name: string;
group?: string;
configSchema: z.ZodObject<any>;
credentials?: { primary?: CredentialType; ssh?: CredentialType };
/**
* `primary` declares the credential type for the primary slot.
* `primaryOptional: true` means the adapter can work without a credential
* profile (e.g. Redis without auth, SMTP with an unauthenticated relay).
* When no profile is assigned and `primaryOptional` is true, the structural
* config fields (inline user/password) are used as-is.
*/
credentials?: { primary?: CredentialType; ssh?: CredentialType; primaryOptional?: boolean };
}

// Validation: Reject paths with null bytes or obvious shell injection patterns
Expand Down
2 changes: 1 addition & 1 deletion src/lib/adapters/definitions/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const S3AWSSchema = z.object({
accessKeyId: z.string().min(1, "Access Key is required"),
secretAccessKey: z.string().min(1, "Secret Key is required"),
pathPrefix: z.string().optional().describe("Optional folder prefix"),
storageClass: z.enum(["STANDARD", "STANDARD_IA", "GLACIER", "DEEP_ARCHIVE"]).default("STANDARD").describe("Storage Class for uploaded files"),
storageClass: z.enum(["STANDARD", "STANDARD_IA", "GLACIER", "DEEP_ARCHIVE"]).default("STANDARD").describe("Storage Class for uploaded files. Warning: GLACIER and DEEP_ARCHIVE are archived storage classes. Backups stored with these classes cannot be downloaded or restored directly through DBackup."),
});

export const S3R2Schema = z.object({
Expand Down
2 changes: 1 addition & 1 deletion src/lib/adapters/notification/email.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export const EmailAdapter: NotificationAdapter = {
type: "notification",
name: "Email (SMTP)",
configSchema: EmailSchema,
credentials: { primary: "SMTP" },
credentials: { primary: "SMTP", primaryOptional: true },

async test(config: any): Promise<{ success: boolean; message: string }> {
try {
Expand Down
Loading
Loading