Simpl-Resultat/src/services/categoryBackupService.ts
le king fu f377d760af
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m41s
fix(export): preserve import sources and templates across data export/import
Exporting then re-importing data destroyed every import configuration:
dataExportService serialised only categories, suppliers, keywords and
transactions, then ran DELETE FROM import_sources on restore and replaced
them with a synthetic 'Data Import' source. After restoring a backup, every
source had to be reconfigured by hand.

- Serialise import_sources and import_config_templates into the envelope,
  with an explicit format_version; a file without one is the earlier format
  and its missing arrays are treated as empty.
- Wrap wipe + restore in withTransaction, which the service had nowhere:
  a constraint violation mid-restore used to destroy financial history with
  no rollback.
- Restore templates BEFORE sources (template_id is a foreign key), upserting
  by name and remapping template_id through the resolved ids, so restoring
  into a profile that already has templates no longer hits UNIQUE(name).
- Whitelist amount_mode and sign_convention at the import boundary with a
  readable message rather than an SQLite constraint error.

Resolves #331

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 10:58:52 -04:00

339 lines
12 KiB
TypeScript

import { invoke } from "@tauri-apps/api/core";
import { getVersion } from "@tauri-apps/api/app";
import type { Profile } from "./profileService";
import {
getExportCategories,
getExportSuppliers,
getExportKeywords,
getExportTransactions,
getExportImportSources,
getExportImportTemplates,
serializeToJson,
parseImportedJson,
type ExportEnvelope,
} from "./dataExportService";
// -----------------------------------------------------------------------------
// Pre-migration backup service
//
// Wrapper around dataExportService + write_export_file / read_import_file that
// produces and *verifies* a full SREF backup before the v2 -> v1 categories
// migration. Any failure (write, re-read, checksum mismatch) throws with a
// structured error code so the UI can map it to an i18n key and abort the
// migration cleanly.
//
// The service intentionally never writes anything to the DB and never mutates
// the profile; it only reads data via the existing export helpers and emits a
// file. The caller (migration page) is responsible for aborting on throw.
// -----------------------------------------------------------------------------
/** Result of a successful pre-migration backup, returned to the caller. */
export interface BackupResult {
/** Absolute path to the written SREF file on disk. */
path: string;
/** Size of the file on disk in bytes (ciphertext size when encrypted). */
size: number;
/** Hex-encoded SHA-256 of the decrypted JSON payload we wrote. */
checksum: string;
/** ISO-8601 timestamp at which verification succeeded. */
verifiedAt: string;
/** True when the backup is encrypted (profile had a PIN). */
encrypted: boolean;
}
/**
* Minimal profile shape consumed by this service. We accept the full `Profile`
* plus an optional clear-text `password` because the PIN hash alone cannot be
* used to derive the SREF encryption key — the caller (migration page) is
* expected to prompt the user for their PIN right before backup and forward
* the verified plaintext here.
*/
export interface BackupProfileInput {
profile: Profile;
/**
* Clear-text PIN for encrypting the SREF file. Required when
* `profile.pin_hash` is set; ignored otherwise. Never logged.
*/
password?: string;
}
/** Stable error codes — the UI layer maps these to i18n keys. */
export type BackupErrorCode =
| "missing_password"
| "documents_dir_unavailable"
| "permission_denied"
| "disk_space"
| "create_dir_failed"
| "write_failed"
| "read_back_failed"
| "verification_mismatch";
export class BackupError extends Error {
public readonly code: BackupErrorCode;
public readonly detail: string;
constructor(code: BackupErrorCode, detail: string) {
super(`backup_failed:${code}:${detail}`);
this.name = "BackupError";
this.code = code;
this.detail = detail;
}
}
// -----------------------------------------------------------------------------
// Filename helpers
// -----------------------------------------------------------------------------
/**
* Replace filesystem-hostile characters in a profile name so the backup file
* can be written on Windows, macOS and Linux without surprises. Empty or
* all-stripped names fall back to "profile".
*/
export function sanitizeProfileName(name: string): string {
// Normalize whitespace, strip path separators and characters Windows forbids.
// We keep unicode letters/digits, hyphens, dots and underscores.
const stripped = name
.trim()
.replace(/[\\/:*?"<>|\x00-\x1f]/g, "")
.replace(/\s+/g, "-")
.replace(/\.+$/g, "")
.slice(0, 80);
return stripped.length > 0 ? stripped : "profile";
}
/**
* Produce a filesystem-safe ISO-8601 timestamp: `2026-04-19T14-22-05Z`.
* Colons are replaced with dashes (Windows forbids `:` in filenames); the
* fractional seconds and final `Z` are preserved to keep it sortable.
*/
export function filesystemSafeIsoTimestamp(date: Date = new Date()): string {
return date.toISOString().replace(/:/g, "-").replace(/\.\d+/, "");
}
/**
* `<SanitizedProfileName>_avant-migration-<ISO8601>.sref`
*/
export function buildBackupFilename(profileName: string, date: Date = new Date()): string {
return `${sanitizeProfileName(profileName)}_avant-migration-${filesystemSafeIsoTimestamp(date)}.sref`;
}
// -----------------------------------------------------------------------------
// Path joining (platform-agnostic; we only need to append a filename)
// -----------------------------------------------------------------------------
function joinPath(dir: string, filename: string): string {
// Strip any trailing separator (either style), then reattach with the one
// the directory itself appears to be using. Fallback to `/`.
const trimmed = dir.replace(/[\\/]+$/, "");
const sep = trimmed.includes("\\") && !trimmed.includes("/") ? "\\" : "/";
return `${trimmed}${sep}${filename}`;
}
// -----------------------------------------------------------------------------
// SHA-256 via Web Crypto
// -----------------------------------------------------------------------------
/**
* Compute the hex-encoded SHA-256 digest of a UTF-8 string. Uses the Web
* Crypto API exposed by the Tauri WebView (available on Windows, Linux and
* macOS in Tauri v2).
*/
export async function sha256Hex(content: string): Promise<string> {
const bytes = new TextEncoder().encode(content);
const digest = await crypto.subtle.digest("SHA-256", bytes);
const view = new Uint8Array(digest);
let hex = "";
for (let i = 0; i < view.length; i++) {
hex += view[i].toString(16).padStart(2, "0");
}
return hex;
}
// -----------------------------------------------------------------------------
// Tauri command wrappers
// -----------------------------------------------------------------------------
async function ensureBackupDir(): Promise<string> {
try {
return await invoke<string>("ensure_backup_dir");
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
if (message.startsWith("permission_denied")) {
throw new BackupError("permission_denied", message);
}
if (message.startsWith("create_dir_failed")) {
throw new BackupError("create_dir_failed", message);
}
throw new BackupError("documents_dir_unavailable", message);
}
}
async function writeBackupFile(
filePath: string,
content: string,
password: string | null,
): Promise<void> {
try {
await invoke("write_export_file", {
filePath,
content,
password,
});
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
// Tauri's fs error strings surface the underlying io::Error; map the
// common ones to stable codes. We deliberately do not echo `password`.
const lower = message.toLowerCase();
if (lower.includes("no space") || lower.includes("disk full")) {
throw new BackupError("disk_space", message);
}
if (lower.includes("permission denied")) {
throw new BackupError("permission_denied", message);
}
throw new BackupError("write_failed", message);
}
}
async function readBackupFile(filePath: string, password: string | null): Promise<string> {
try {
return await invoke<string>("read_import_file", {
filePath,
password,
});
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
throw new BackupError("read_back_failed", message);
}
}
async function getFileSize(filePath: string): Promise<number> {
try {
return await invoke<number>("get_file_size", { filePath });
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
throw new BackupError("read_back_failed", message);
}
}
// -----------------------------------------------------------------------------
// Public entry point
// -----------------------------------------------------------------------------
/**
* Create and verify a full SREF backup of the currently connected profile,
* to be used as a safety net before the v2 -> v1 categories migration.
*
* Flow:
* 1. Gather categories, suppliers, keywords, and transactions via the
* existing dataExportService helpers.
* 2. Serialize to JSON using the `transactions_with_categories` envelope.
* 3. Resolve `~/Documents/Simpl-Resultat/backups/` (create if missing).
* 4. Write the file via `write_export_file` (encrypted when a PIN password
* is supplied, plaintext otherwise).
* 5. Re-read the file via `read_import_file` and validate:
* - JSON parses and has a well-formed envelope;
* - SHA-256 of the re-read content matches the SHA-256 of what we
* just serialized.
* 6. Return a {@link BackupResult} or throw a {@link BackupError}.
*
* On any failure past step 3, the partially-written file may exist on disk —
* we deliberately leave it alone. The migration page is responsible for
* aborting and surfacing the error to the user; the broken file does not
* affect the profile DB.
*/
export async function createPreMigrationBackup(
input: BackupProfileInput,
): Promise<BackupResult> {
const { profile, password } = input;
const isProtected = profile.pin_hash !== null && profile.pin_hash !== "";
const trimmedPassword = password?.trim() ?? "";
if (isProtected && trimmedPassword.length === 0) {
throw new BackupError(
"missing_password",
"Profile is PIN-protected — a password is required to encrypt the backup",
);
}
// 1. Gather data — same mode as "transactions_with_categories" export.
// Import sources and templates are part of that mode since #331: this
// backup is a safety net, and one that came back without a single import
// configuration would be a poor one.
const appVersion = await getVersion();
const [
categories,
suppliers,
keywords,
transactions,
import_sources,
import_config_templates,
] = await Promise.all([
getExportCategories(),
getExportSuppliers(),
getExportKeywords(),
getExportTransactions(),
getExportImportSources(),
getExportImportTemplates(),
]);
const content = serializeToJson(
"transactions_with_categories",
{
categories,
suppliers,
keywords,
transactions,
import_sources,
import_config_templates,
},
appVersion,
);
// 2. Compute the expected checksum before writing.
const expectedChecksum = await sha256Hex(content);
// 3. Resolve target directory and build the filename.
const dir = await ensureBackupDir();
const filename = buildBackupFilename(profile.name);
const filePath = joinPath(dir, filename);
// 4. Write — encrypted with the PIN when the profile is protected.
const passwordArg = isProtected ? trimmedPassword : null;
await writeBackupFile(filePath, content, passwordArg);
// 5. Read back and verify.
const roundTripped = await readBackupFile(filePath, passwordArg);
let parsedEnvelope: ExportEnvelope;
try {
parsedEnvelope = parseImportedJson(roundTripped).envelope;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
throw new BackupError("verification_mismatch", `envelope_parse: ${message}`);
}
if (parsedEnvelope.export_type !== "transactions_with_categories") {
throw new BackupError(
"verification_mismatch",
`envelope_type: ${parsedEnvelope.export_type}`,
);
}
const actualChecksum = await sha256Hex(roundTripped);
if (actualChecksum !== expectedChecksum) {
throw new BackupError(
"verification_mismatch",
`checksum_diff: expected=${expectedChecksum} actual=${actualChecksum}`,
);
}
const size = await getFileSize(filePath);
return {
path: filePath,
size,
checksum: actualChecksum,
verifiedAt: new Date().toISOString(),
encrypted: isProtected,
};
}