From bccfd596b8d7f95b5fdf47938dfebd7304696843 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Thu, 6 Nov 2025 11:21:21 +0800 Subject: [PATCH 1/7] fix: Resolve database encryption atomicity issues and enhance debugging (#430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Resolve database encryption atomicity issues and enhance debugging This commit addresses critical data corruption issues caused by non-atomic file writes during database encryption, and adds comprehensive diagnostic logging to help debug encryption-related failures. **Problem:** Users reported "Unsupported state or unable to authenticate data" errors when starting the application after system crashes or Docker container restarts. The root cause was non-atomic writes of encrypted database files: 1. Encrypted data file written (step 1) 2. Metadata file written (step 2) → If process crashes between steps 1 and 2, files become inconsistent → New IV/tag in data file, old IV/tag in metadata → GCM authentication fails on next startup → User data permanently inaccessible **Solution - Atomic Writes:** 1. Write-to-temp + atomic-rename pattern: - Write to temporary files (*.tmp-timestamp-pid) - Perform atomic rename operations - Clean up temp files on failure 2. Data integrity validation: - Add dataSize field to metadata - Verify file size before decryption - Early detection of corrupted writes 3. Enhanced error diagnostics: - Key fingerprints (SHA256 prefix) for verification - File modification timestamps - Detailed GCM auth failure messages - Automatic diagnostic info generation **Changes:** database-file-encryption.ts: - Implement atomic write pattern in encryptDatabaseFromBuffer - Implement atomic write pattern in encryptDatabaseFile - Add dataSize field to EncryptedFileMetadata interface - Validate file size before decryption in decryptDatabaseToBuffer - Enhanced error messages for GCM auth failures - Add getDiagnosticInfo() function for comprehensive debugging - Add debug logging for all encryption/decryption operations system-crypto.ts: - Add detailed logging for DATABASE_KEY initialization - Log key source (env var vs .env file) - Add key fingerprints to all log messages - Better error messages when key loading fails db/index.ts: - Automatically generate diagnostic info on decryption failure - Log detailed debugging information to help users troubleshoot **Debugging Info Added:** - Key initialization: source, fingerprint, length, path - Encryption: original size, encrypted size, IV/tag prefixes, temp paths - Decryption: file timestamps, metadata content, key fingerprint matching - Auth failures: .env file status, key availability, file consistency - File diagnostics: existence, readability, size validation, mtime comparison **Backward Compatibility:** - dataSize field is optional (metadata.dataSize?: number) - Old encrypted files without dataSize continue to work - No migration required **Testing:** - Compiled successfully - No breaking changes to existing APIs - Graceful handling of legacy v1 encrypted files Fixes data loss issues reported by users experiencing container restarts and system crashes during database saves. * fix: Cleanup PR * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: LukeGus Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/electron.yml | 1 - src/backend/database/db/index.ts | 26 ++ src/backend/utils/database-file-encryption.ts | 295 +++++++++++++++++- src/backend/utils/system-crypto.ts | 34 +- src/ui/desktop/authentication/Auth.tsx | 46 ++- 5 files changed, 366 insertions(+), 36 deletions(-) diff --git a/.github/workflows/electron.yml b/.github/workflows/electron.yml index 62bf5769..bd1a5b4c 100644 --- a/.github/workflows/electron.yml +++ b/.github/workflows/electron.yml @@ -746,7 +746,6 @@ jobs: mkdir -p homebrew-submission/Casks/t cp homebrew/termix.rb homebrew-submission/Casks/t/termix.rb - cp homebrew/README.md homebrew-submission/ sed -i '' "s/VERSION_PLACEHOLDER/$VERSION/g" homebrew-submission/Casks/t/termix.rb sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-submission/Casks/t/termix.rb diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 7b3b6138..8310ed6c 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -95,6 +95,32 @@ async function initializeDatabaseAsync(): Promise { databaseKeyLength: process.env.DATABASE_KEY?.length || 0, }); + try { + databaseLogger.info( + "Generating diagnostic information for database encryption failure", + { + operation: "db_encryption_diagnostic", + }, + ); + const diagnosticInfo = + DatabaseFileEncryption.getDiagnosticInfo(encryptedDbPath); + databaseLogger.error( + "Database encryption diagnostic completed - check logs above for details", + null, + { + operation: "db_encryption_diagnostic_completed", + filesConsistent: diagnosticInfo.validation.filesConsistent, + sizeMismatch: diagnosticInfo.validation.sizeMismatch, + }, + ); + } catch (diagError) { + databaseLogger.warn("Failed to generate diagnostic information", { + operation: "db_diagnostic_failed", + error: + diagError instanceof Error ? diagError.message : "Unknown error", + }); + } + throw new Error( `Database decryption failed: ${error instanceof Error ? error.message : "Unknown error"}. This prevents data loss.`, ); diff --git a/src/backend/utils/database-file-encryption.ts b/src/backend/utils/database-file-encryption.ts index 002464b0..7bbb406b 100644 --- a/src/backend/utils/database-file-encryption.ts +++ b/src/backend/utils/database-file-encryption.ts @@ -12,6 +12,7 @@ interface EncryptedFileMetadata { algorithm: string; keySource?: string; salt?: string; + dataSize?: number; } class DatabaseFileEncryption { @@ -25,6 +26,10 @@ class DatabaseFileEncryption { buffer: Buffer, targetPath: string, ): Promise { + const tmpPath = `${targetPath}.tmp-${Date.now()}-${process.pid}`; + const tmpMetadataPath = `${tmpPath}${this.METADATA_FILE_SUFFIX}`; + const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`; + try { const key = await this.systemCrypto.getDatabaseKey(); @@ -38,6 +43,12 @@ class DatabaseFileEncryption { const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]); const tag = cipher.getAuthTag(); + const keyFingerprint = crypto + .createHash("sha256") + .update(key) + .digest("hex") + .substring(0, 16); + const metadata: EncryptedFileMetadata = { iv: iv.toString("hex"), tag: tag.toString("hex"), @@ -45,14 +56,35 @@ class DatabaseFileEncryption { fingerprint: "termix-v2-systemcrypto", algorithm: this.ALGORITHM, keySource: "SystemCrypto", + dataSize: encrypted.length, }; - const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`; - fs.writeFileSync(targetPath, encrypted); - fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); + fs.writeFileSync(tmpPath, encrypted); + fs.writeFileSync(tmpMetadataPath, JSON.stringify(metadata, null, 2)); + + fs.renameSync(tmpPath, targetPath); + fs.renameSync(tmpMetadataPath, metadataPath); return targetPath; } catch (error) { + try { + if (fs.existsSync(tmpPath)) { + fs.unlinkSync(tmpPath); + } + if (fs.existsSync(tmpMetadataPath)) { + fs.unlinkSync(tmpMetadataPath); + } + } catch (cleanupError) { + databaseLogger.warn("Failed to cleanup temporary files", { + operation: "temp_file_cleanup_failed", + tmpPath, + error: + cleanupError instanceof Error + ? cleanupError.message + : "Unknown error", + }); + } + databaseLogger.error("Failed to encrypt database buffer", error, { operation: "database_buffer_encryption_failed", targetPath, @@ -74,6 +106,8 @@ class DatabaseFileEncryption { const encryptedPath = targetPath || `${sourcePath}${this.ENCRYPTED_FILE_SUFFIX}`; const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`; + const tmpPath = `${encryptedPath}.tmp-${Date.now()}-${process.pid}`; + const tmpMetadataPath = `${tmpPath}${this.METADATA_FILE_SUFFIX}`; try { const sourceData = fs.readFileSync(sourcePath); @@ -93,6 +127,12 @@ class DatabaseFileEncryption { ]); const tag = cipher.getAuthTag(); + const keyFingerprint = crypto + .createHash("sha256") + .update(key) + .digest("hex") + .substring(0, 16); + const metadata: EncryptedFileMetadata = { iv: iv.toString("hex"), tag: tag.toString("hex"), @@ -100,10 +140,14 @@ class DatabaseFileEncryption { fingerprint: "termix-v2-systemcrypto", algorithm: this.ALGORITHM, keySource: "SystemCrypto", + dataSize: encrypted.length, }; - fs.writeFileSync(encryptedPath, encrypted); - fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); + fs.writeFileSync(tmpPath, encrypted); + fs.writeFileSync(tmpMetadataPath, JSON.stringify(metadata, null, 2)); + + fs.renameSync(tmpPath, encryptedPath); + fs.renameSync(tmpMetadataPath, metadataPath); databaseLogger.info("Database file encrypted successfully", { operation: "database_file_encryption", @@ -111,11 +155,30 @@ class DatabaseFileEncryption { encryptedPath, fileSize: sourceData.length, encryptedSize: encrypted.length, + keyFingerprint, fingerprintPrefix: metadata.fingerprint, }); return encryptedPath; } catch (error) { + try { + if (fs.existsSync(tmpPath)) { + fs.unlinkSync(tmpPath); + } + if (fs.existsSync(tmpMetadataPath)) { + fs.unlinkSync(tmpMetadataPath); + } + } catch (cleanupError) { + databaseLogger.warn("Failed to cleanup temporary files", { + operation: "temp_file_cleanup_failed", + tmpPath, + error: + cleanupError instanceof Error + ? cleanupError.message + : "Unknown error", + }); + } + databaseLogger.error("Failed to encrypt database file", error, { operation: "database_file_encryption_failed", sourcePath, @@ -140,11 +203,34 @@ class DatabaseFileEncryption { } try { + const dataFileStats = fs.statSync(encryptedPath); + const metaFileStats = fs.statSync(metadataPath); + const metadataContent = fs.readFileSync(metadataPath, "utf8"); const metadata: EncryptedFileMetadata = JSON.parse(metadataContent); const encryptedData = fs.readFileSync(encryptedPath); + if (metadata.dataSize !== undefined && encryptedData.length !== metadata.dataSize) { + databaseLogger.error( + "Encrypted file size mismatch - possible corrupted write or mismatched metadata", + null, + { + operation: "database_file_size_mismatch", + encryptedPath, + actualSize: encryptedData.length, + expectedSize: metadata.dataSize, + difference: encryptedData.length - metadata.dataSize, + dataFileMtime: dataFileStats.mtime.toISOString(), + metaFileMtime: metaFileStats.mtime.toISOString(), + }, + ); + throw new Error( + `Encrypted file size mismatch: expected ${metadata.dataSize} bytes but got ${encryptedData.length} bytes. ` + + `This indicates corrupted files or interrupted write operation.`, + ); + } + let key: Buffer; if (metadata.version === "v2") { key = await this.systemCrypto.getDatabaseKey(); @@ -167,6 +253,12 @@ class DatabaseFileEncryption { throw new Error(`Unsupported encryption version: ${metadata.version}`); } + const keyFingerprint = crypto + .createHash("sha256") + .update(key) + .digest("hex") + .substring(0, 16); + const decipher = crypto.createDecipheriv( metadata.algorithm, key, @@ -181,13 +273,64 @@ class DatabaseFileEncryption { return decryptedBuffer; } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + const isAuthError = + errorMessage.includes("Unsupported state") || + errorMessage.includes("authenticate data") || + errorMessage.includes("auth"); + + if (isAuthError) { + const dataDir = process.env.DATA_DIR || "./db/data"; + const envPath = path.join(dataDir, ".env"); + + let envFileExists = false; + let envFileReadable = false; + try { + envFileExists = fs.existsSync(envPath); + if (envFileExists) { + fs.accessSync(envPath, fs.constants.R_OK); + envFileReadable = true; + } + } catch {} + + databaseLogger.error( + "Database decryption authentication failed - possible causes: wrong DATABASE_KEY, corrupted files, or interrupted write", + error, + { + operation: "database_buffer_decryption_auth_failed", + encryptedPath, + metadataPath, + dataDir, + envPath, + envFileExists, + envFileReadable, + hasEnvKey: !!process.env.DATABASE_KEY, + envKeyLength: process.env.DATABASE_KEY?.length || 0, + suggestion: + "Check if DATABASE_KEY in .env matches the key used for encryption", + }, + ); + throw new Error( + `Database decryption authentication failed. This usually means:\n` + + `1. DATABASE_KEY has changed or is missing from ${dataDir}/.env\n` + + `2. Encrypted file was corrupted during write (system crash/restart)\n` + + `3. Metadata file does not match encrypted data\n` + + `\nDebug info:\n` + + `- DATA_DIR: ${dataDir}\n` + + `- .env file exists: ${envFileExists}\n` + + `- .env file readable: ${envFileReadable}\n` + + `- DATABASE_KEY in environment: ${!!process.env.DATABASE_KEY}\n` + + `Original error: ${errorMessage}`, + ); + } + databaseLogger.error("Failed to decrypt database to buffer", error, { operation: "database_buffer_decryption_failed", encryptedPath, + errorMessage, }); - throw new Error( - `Database buffer decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`, - ); + throw new Error(`Database buffer decryption failed: ${errorMessage}`); } } @@ -215,6 +358,23 @@ class DatabaseFileEncryption { const encryptedData = fs.readFileSync(encryptedPath); + if (metadata.dataSize !== undefined && encryptedData.length !== metadata.dataSize) { + databaseLogger.error( + "Encrypted file size mismatch - possible corrupted write or mismatched metadata", + null, + { + operation: "database_file_size_mismatch", + encryptedPath, + actualSize: encryptedData.length, + expectedSize: metadata.dataSize, + }, + ); + throw new Error( + `Encrypted file size mismatch: expected ${metadata.dataSize} bytes but got ${encryptedData.length} bytes. ` + + `This indicates corrupted files or interrupted write operation.`, + ); + } + let key: Buffer; if (metadata.version === "v2") { key = await this.systemCrypto.getDatabaseKey(); @@ -322,6 +482,125 @@ class DatabaseFileEncryption { } } + static getDiagnosticInfo(encryptedPath: string): { + dataFile: { + exists: boolean; + size?: number; + mtime?: string; + readable?: boolean; + }; + metadataFile: { + exists: boolean; + size?: number; + mtime?: string; + readable?: boolean; + content?: EncryptedFileMetadata; + }; + environment: { + dataDir: string; + envPath: string; + envFileExists: boolean; + envFileReadable: boolean; + hasEnvKey: boolean; + envKeyLength: number; + }; + validation: { + filesConsistent: boolean; + sizeMismatch?: boolean; + expectedSize?: number; + actualSize?: number; + }; + } { + const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`; + const dataDir = process.env.DATA_DIR || "./db/data"; + const envPath = path.join(dataDir, ".env"); + + const result: ReturnType = { + dataFile: { exists: false }, + metadataFile: { exists: false }, + environment: { + dataDir, + envPath, + envFileExists: false, + envFileReadable: false, + hasEnvKey: !!process.env.DATABASE_KEY, + envKeyLength: process.env.DATABASE_KEY?.length || 0, + }, + validation: { + filesConsistent: false, + }, + }; + + try { + result.dataFile.exists = fs.existsSync(encryptedPath); + if (result.dataFile.exists) { + try { + fs.accessSync(encryptedPath, fs.constants.R_OK); + result.dataFile.readable = true; + const stats = fs.statSync(encryptedPath); + result.dataFile.size = stats.size; + result.dataFile.mtime = stats.mtime.toISOString(); + } catch { + result.dataFile.readable = false; + } + } + + result.metadataFile.exists = fs.existsSync(metadataPath); + if (result.metadataFile.exists) { + try { + fs.accessSync(metadataPath, fs.constants.R_OK); + result.metadataFile.readable = true; + const stats = fs.statSync(metadataPath); + result.metadataFile.size = stats.size; + result.metadataFile.mtime = stats.mtime.toISOString(); + + const content = fs.readFileSync(metadataPath, "utf8"); + result.metadataFile.content = JSON.parse(content); + } catch { + result.metadataFile.readable = false; + } + } + + result.environment.envFileExists = fs.existsSync(envPath); + if (result.environment.envFileExists) { + try { + fs.accessSync(envPath, fs.constants.R_OK); + result.environment.envFileReadable = true; + } catch {} + } + + if ( + result.dataFile.exists && + result.metadataFile.exists && + result.metadataFile.content + ) { + result.validation.filesConsistent = true; + + if (result.metadataFile.content.dataSize !== undefined) { + result.validation.expectedSize = result.metadataFile.content.dataSize; + result.validation.actualSize = result.dataFile.size; + result.validation.sizeMismatch = + result.metadataFile.content.dataSize !== result.dataFile.size; + if (result.validation.sizeMismatch) { + result.validation.filesConsistent = false; + } + } + } + } catch (error) { + databaseLogger.error("Failed to generate diagnostic info", error, { + operation: "diagnostic_info_failed", + encryptedPath, + }); + } + + databaseLogger.info("Database encryption diagnostic info", { + operation: "diagnostic_info_generated", + ...result, + }); + + return result; + } + static async createEncryptedBackup( databasePath: string, backupDir: string, diff --git a/src/backend/utils/system-crypto.ts b/src/backend/utils/system-crypto.ts index bd6aa727..156d0b33 100644 --- a/src/backend/utils/system-crypto.ts +++ b/src/backend/utils/system-crypto.ts @@ -51,17 +51,8 @@ class SystemCrypto { }, ); } - } catch (fileError) { - databaseLogger.warn("Failed to read .env file for JWT secret", { - operation: "jwt_init_file_read_failed", - error: - fileError instanceof Error ? fileError.message : "Unknown error", - }); - } + } catch (fileError) {} - databaseLogger.warn("Generating new JWT secret", { - operation: "jwt_generating_new_secret", - }); await this.generateAndGuideUser(); } catch (error) { databaseLogger.error("Failed to initialize JWT secret", error, { @@ -80,29 +71,44 @@ class SystemCrypto { async initializeDatabaseKey(): Promise { try { + const dataDir = process.env.DATA_DIR || "./db/data"; + const envPath = path.join(dataDir, ".env"); + const envKey = process.env.DATABASE_KEY; if (envKey && envKey.length >= 64) { this.databaseKey = Buffer.from(envKey, "hex"); + const keyFingerprint = crypto + .createHash("sha256") + .update(this.databaseKey) + .digest("hex") + .substring(0, 16); + return; } - const dataDir = process.env.DATA_DIR || "./db/data"; - const envPath = path.join(dataDir, ".env"); - try { const envContent = await fs.readFile(envPath, "utf8"); const dbKeyMatch = envContent.match(/^DATABASE_KEY=(.+)$/m); if (dbKeyMatch && dbKeyMatch[1] && dbKeyMatch[1].length >= 64) { this.databaseKey = Buffer.from(dbKeyMatch[1], "hex"); process.env.DATABASE_KEY = dbKeyMatch[1]; + + const keyFingerprint = crypto + .createHash("sha256") + .update(this.databaseKey) + .digest("hex") + .substring(0, 16); + return; + } else { } - } catch {} + } catch (fileError) {} await this.generateAndGuideDatabaseKey(); } catch (error) { databaseLogger.error("Failed to initialize database key", error, { operation: "db_key_init_failed", + dataDir: process.env.DATA_DIR || "./db/data", }); throw new Error("Database key initialization failed"); } diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/desktop/authentication/Auth.tsx index fb894adc..997d712b 100644 --- a/src/ui/desktop/authentication/Auth.tsx +++ b/src/ui/desktop/authentication/Auth.tsx @@ -24,11 +24,20 @@ import { verifyTOTPLogin, getServerConfig, isElectron, - logoutUser, } from "../../main-axios.ts"; import { ElectronServerConfig as ServerConfigComponent } from "@/ui/desktop/authentication/ElectronServerConfig.tsx"; import { ElectronLoginForm } from "@/ui/desktop/authentication/ElectronLoginForm.tsx"; +function getCookie(name: string): string | undefined { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop()?.split(";").shift(); +} + +interface ExtendedWindow extends Window { + IS_ELECTRON_WEBVIEW?: boolean; +} + interface AuthProps extends React.ComponentProps<"div"> { setLoggedIn: (loggedIn: boolean) => void; setIsAdmin: (isAdmin: boolean) => void; @@ -37,7 +46,6 @@ interface AuthProps extends React.ComponentProps<"div"> { loggedIn: boolean; authLoading: boolean; setDbError: (error: string | null) => void; - dbError?: string | null; onAuthSuccess: (authData: { isAdmin: boolean; username: string | null; @@ -54,21 +62,20 @@ export function Auth({ loggedIn, authLoading, setDbError, - dbError, onAuthSuccess, ...props }: AuthProps) { const { t } = useTranslation(); const isInElectronWebView = () => { - if ((window as any).IS_ELECTRON_WEBVIEW) { + if ((window as ExtendedWindow).IS_ELECTRON_WEBVIEW) { return true; } try { if (window.self !== window.top) { return true; } - } catch (e) { + } catch (_e) { return false; } return false; @@ -126,7 +133,7 @@ export function Auth({ userId: meRes.userId || null, }); toast.success(t("messages.loginSuccess")); - } catch (err) { + } catch (_err) { toast.error(t("errors.failedUserInfo")); } }, [ @@ -206,7 +213,7 @@ export function Auth({ .finally(() => { setDbHealthChecking(false); }); - }, [setDbError, firstUserToastShown, showServerConfig]); + }, [setDbError, firstUserToastShown, showServerConfig, t]); useEffect(() => { if (!registrationAllowed && !internalLoggedIn) { @@ -282,9 +289,9 @@ export function Auth({ ); setWebviewAuthSuccess(true); setTimeout(() => window.location.reload(), 100); - setLoading(false); - return; - } catch (e) {} + } catch (e) { + console.error("Error posting auth success message:", e); + } } const [meRes] = await Promise.all([getUserInfo()]); @@ -461,7 +468,9 @@ export function Auth({ setTimeout(() => window.location.reload(), 100); setTotpLoading(false); return; - } catch (e) {} + } catch (e) { + console.error("Error posting auth success message:", e); + } } setInternalLoggedIn(true); @@ -569,7 +578,9 @@ export function Auth({ setTimeout(() => window.location.reload(), 100); setOidcLoading(false); return; - } catch (e) {} + } catch (e) { + console.error("Error posting auth success message:", e); + } } } @@ -607,7 +618,16 @@ export function Auth({ setOidcLoading(false); }); } - }, []); + }, [ + onAuthSuccess, + setDbError, + setIsAdmin, + setLoggedIn, + setUserId, + setUsername, + t, + isInElectronWebView, + ]); const Spinner = ( Date: Wed, 5 Nov 2025 21:32:07 -0600 Subject: [PATCH 2/7] fix: Merge metadata and DB into 1 file --- src/backend/utils/database-file-encryption.ts | 150 +++++++++++++----- 1 file changed, 106 insertions(+), 44 deletions(-) diff --git a/src/backend/utils/database-file-encryption.ts b/src/backend/utils/database-file-encryption.ts index 7bbb406b..b4afbe7a 100644 --- a/src/backend/utils/database-file-encryption.ts +++ b/src/backend/utils/database-file-encryption.ts @@ -27,14 +27,11 @@ class DatabaseFileEncryption { targetPath: string, ): Promise { const tmpPath = `${targetPath}.tmp-${Date.now()}-${process.pid}`; - const tmpMetadataPath = `${tmpPath}${this.METADATA_FILE_SUFFIX}`; const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`; try { const key = await this.systemCrypto.getDatabaseKey(); - const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv( this.ALGORITHM, key, @@ -43,12 +40,6 @@ class DatabaseFileEncryption { const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]); const tag = cipher.getAuthTag(); - const keyFingerprint = crypto - .createHash("sha256") - .update(key) - .digest("hex") - .substring(0, 16); - const metadata: EncryptedFileMetadata = { iv: iv.toString("hex"), tag: tag.toString("hex"), @@ -59,11 +50,34 @@ class DatabaseFileEncryption { dataSize: encrypted.length, }; - fs.writeFileSync(tmpPath, encrypted); - fs.writeFileSync(tmpMetadataPath, JSON.stringify(metadata, null, 2)); + const metadataJson = JSON.stringify(metadata, null, 2); + const metadataBuffer = Buffer.from(metadataJson, "utf8"); + const metadataLengthBuffer = Buffer.alloc(4); + metadataLengthBuffer.writeUInt32BE(metadataBuffer.length, 0); + const finalBuffer = Buffer.concat([ + metadataLengthBuffer, + metadataBuffer, + encrypted, + ]); + + fs.writeFileSync(tmpPath, finalBuffer); fs.renameSync(tmpPath, targetPath); - fs.renameSync(tmpMetadataPath, metadataPath); + + try { + if (fs.existsSync(metadataPath)) { + fs.unlinkSync(metadataPath); + } + } catch (cleanupError) { + databaseLogger.warn("Failed to cleanup old metadata file", { + operation: "old_meta_cleanup_failed", + path: metadataPath, + error: + cleanupError instanceof Error + ? cleanupError.message + : "Unknown error", + }); + } return targetPath; } catch (error) { @@ -71,9 +85,6 @@ class DatabaseFileEncryption { if (fs.existsSync(tmpPath)) { fs.unlinkSync(tmpPath); } - if (fs.existsSync(tmpMetadataPath)) { - fs.unlinkSync(tmpMetadataPath); - } } catch (cleanupError) { databaseLogger.warn("Failed to cleanup temporary files", { operation: "temp_file_cleanup_failed", @@ -197,21 +208,54 @@ class DatabaseFileEncryption { ); } - const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`; - if (!fs.existsSync(metadataPath)) { - throw new Error(`Metadata file does not exist: ${metadataPath}`); + let metadata: EncryptedFileMetadata; + let encryptedData: Buffer; + + const fileBuffer = fs.readFileSync(encryptedPath); + + try { + const metadataLength = fileBuffer.readUInt32BE(0); + const metadataEnd = 4 + metadataLength; + + if ( + metadataLength <= 0 || + metadataEnd > fileBuffer.length || + metadataEnd <= 4 + ) { + throw new Error("Invalid metadata length in single-file format"); + } + + const metadataJson = fileBuffer.slice(4, metadataEnd).toString("utf8"); + metadata = JSON.parse(metadataJson); + encryptedData = fileBuffer.slice(metadataEnd); + + if (!metadata.iv || !metadata.tag || !metadata.version) { + throw new Error("Invalid metadata structure in single-file format"); + } + } catch (singleFileError) { + const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`; + if (!fs.existsSync(metadataPath)) { + throw new Error( + `Could not read database: Not a valid single-file format and metadata file is missing: ${metadataPath}. Error: ${singleFileError.message}`, + ); + } + + try { + const metadataContent = fs.readFileSync(metadataPath, "utf8"); + metadata = JSON.parse(metadataContent); + encryptedData = fileBuffer; + } catch (twoFileError) { + throw new Error( + `Failed to read database using both single-file and two-file formats. Error: ${twoFileError.message}`, + ); + } } try { - const dataFileStats = fs.statSync(encryptedPath); - const metaFileStats = fs.statSync(metadataPath); - - const metadataContent = fs.readFileSync(metadataPath, "utf8"); - const metadata: EncryptedFileMetadata = JSON.parse(metadataContent); - - const encryptedData = fs.readFileSync(encryptedPath); - - if (metadata.dataSize !== undefined && encryptedData.length !== metadata.dataSize) { + if ( + metadata.dataSize !== undefined && + encryptedData.length !== metadata.dataSize + ) { databaseLogger.error( "Encrypted file size mismatch - possible corrupted write or mismatched metadata", null, @@ -220,9 +264,6 @@ class DatabaseFileEncryption { encryptedPath, actualSize: encryptedData.length, expectedSize: metadata.dataSize, - difference: encryptedData.length - metadata.dataSize, - dataFileMtime: dataFileStats.mtime.toISOString(), - metaFileMtime: metaFileStats.mtime.toISOString(), }, ); throw new Error( @@ -253,12 +294,6 @@ class DatabaseFileEncryption { throw new Error(`Unsupported encryption version: ${metadata.version}`); } - const keyFingerprint = crypto - .createHash("sha256") - .update(key) - .digest("hex") - .substring(0, 16); - const decipher = crypto.createDecipheriv( metadata.algorithm, key, @@ -300,7 +335,6 @@ class DatabaseFileEncryption { { operation: "database_buffer_decryption_auth_failed", encryptedPath, - metadataPath, dataDir, envPath, envFileExists, @@ -358,7 +392,10 @@ class DatabaseFileEncryption { const encryptedData = fs.readFileSync(encryptedPath); - if (metadata.dataSize !== undefined && encryptedData.length !== metadata.dataSize) { + if ( + metadata.dataSize !== undefined && + encryptedData.length !== metadata.dataSize + ) { databaseLogger.error( "Encrypted file size mismatch - possible corrupted write or mismatched metadata", null, @@ -434,18 +471,43 @@ class DatabaseFileEncryption { } static isEncryptedDatabaseFile(filePath: string): boolean { - const metadataPath = `${filePath}${this.METADATA_FILE_SUFFIX}`; - - if (!fs.existsSync(filePath) || !fs.existsSync(metadataPath)) { + if (!fs.existsSync(filePath)) { return false; } + const metadataPath = `${filePath}${this.METADATA_FILE_SUFFIX}`; + if (fs.existsSync(metadataPath)) { + try { + const metadataContent = fs.readFileSync(metadataPath, "utf8"); + const metadata: EncryptedFileMetadata = JSON.parse(metadataContent); + return ( + metadata.version === this.VERSION && + metadata.algorithm === this.ALGORITHM + ); + } catch { + return false; + } + } + try { - const metadataContent = fs.readFileSync(metadataPath, "utf8"); - const metadata: EncryptedFileMetadata = JSON.parse(metadataContent); + const fileBuffer = fs.readFileSync(filePath); + if (fileBuffer.length < 4) return false; + + const metadataLength = fileBuffer.readUInt32BE(0); + const metadataEnd = 4 + metadataLength; + + if (metadataLength <= 0 || metadataEnd > fileBuffer.length) { + return false; + } + + const metadataJson = fileBuffer.slice(4, metadataEnd).toString("utf8"); + const metadata: EncryptedFileMetadata = JSON.parse(metadataJson); + return ( metadata.version === this.VERSION && - metadata.algorithm === this.ALGORITHM + metadata.algorithm === this.ALGORITHM && + !!metadata.iv && + !!metadata.tag ); } catch { return false; -- 2.49.1 From 7d69a4b5a5ea86685de8b67bbe92f4b603ff6a74 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Thu, 6 Nov 2025 00:20:38 -0600 Subject: [PATCH 3/7] fix: Add initial command palette --- package-lock.json | 17 ++ package.json | 1 + src/components/ui/command.tsx | 184 ++++++++++++++++++ src/components/ui/dialog.tsx | 141 ++++++++++++++ src/ui/desktop/DesktopApp.tsx | 30 ++- .../apps/command-palette/CommandPalette.tsx | 88 +++++++++ 6 files changed, 460 insertions(+), 1 deletion(-) create mode 100644 src/components/ui/command.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/ui/desktop/apps/command-palette/CommandPalette.tsx diff --git a/package-lock.json b/package-lock.json index 011b4f07..61e5b7c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "chalk": "^4.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^17.2.0", @@ -6936,6 +6937,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/codem-isoboxer": { "version": "0.3.10", "resolved": "https://registry.npmjs.org/codem-isoboxer/-/codem-isoboxer-0.3.10.tgz", diff --git a/package.json b/package.json index bd71b467..81d6971f 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "chalk": "^4.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^17.2.0", diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx new file mode 100644 index 00000000..ee7450af --- /dev/null +++ b/src/components/ui/command.tsx @@ -0,0 +1,184 @@ +"use client"; + +import * as React from "react"; +import { Command as CommandPrimitive } from "cmdk"; +import { SearchIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +function Command({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + className, + showCloseButton = true, + ...props +}: React.ComponentProps & { + title?: string; + description?: string; + className?: string; + showCloseButton?: boolean; +}) { + return ( + + + {title} + {description} + + + + {children} + + + + ); +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + +
+ ); +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandEmpty({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ); +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 00000000..9ff99906 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,141 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { XIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function Dialog({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index 1bedd2c1..39aa9540 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import { LeftSidebar } from "@/ui/desktop/navigation/LeftSidebar.tsx"; import { Dashboard } from "@/ui/desktop/apps/dashboard/Dashboard.tsx"; import { AppView } from "@/ui/desktop/navigation/AppView.tsx"; @@ -11,6 +11,7 @@ import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx"; import { AdminSettings } from "@/ui/desktop/admin/AdminSettings.tsx"; import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx"; import { Toaster } from "@/components/ui/sonner.tsx"; +import { CommandPalette } from "@/ui/desktop/apps/command-palette/CommandPalette.tsx"; import { getUserInfo } from "@/ui/main-axios.ts"; function AppContent() { @@ -23,6 +24,29 @@ function AppContent() { return saved !== null ? JSON.parse(saved) : true; }); const { currentTab, tabs } = useTabs(); + const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); + + const lastShiftPressTime = useRef(0); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.code === "ShiftLeft") { + const now = Date.now(); + if (now - lastShiftPressTime.current < 300) { + setIsCommandPaletteOpen((isOpen) => !isOpen); + } + lastShiftPressTime.current = now; + } + if (event.key === "Escape") { + setIsCommandPaletteOpen(false); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, []); useEffect(() => { const checkAuth = () => { @@ -93,6 +117,10 @@ function AppContent() { return (
+ {!isAuthenticated && !authLoading && (
void; +}) { + const inputRef = useRef(null); + + useEffect(() => { + if (isOpen) { + inputRef.current?.focus(); + } + }, [isOpen]); + return ( +
setIsOpen(false)} + > + e.stopPropagation()} + > + + + + + + Calendar + + + + Search Emoji + + + + Calculator + + + + + + + Profile + ⌘P + + + + Billing + ⌘B + + + + Settings + ⌘S + + + + +
+ ); +} -- 2.49.1 From 72290993b0718152e742cbaaa94bb310ebc494ef Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 6 Nov 2025 16:11:39 +0100 Subject: [PATCH 4/7] Update translation.json Fixed some translation issues for German, made it more user friendly and common. --- src/locales/de/translation.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index c407191b..64d81408 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -355,7 +355,7 @@ "tabNavigation": "Registerkarte Navigation" }, "admin": { - "title": "Administratoreinstellungen", + "title": "Admin-Einstellungen", "oidc": "OIDC", "users": "Benutzer", "userManagement": "Benutzerverwaltung", @@ -390,11 +390,11 @@ "actions": "Aktionen", "external": "Extern", "local": "Lokal", - "adminManagement": "Verwaltung von Administratoren", + "adminManagement": "Admin Verwaltung", "makeUserAdmin": "Benutzer zum Administrator machen", "adding": "Hinzufügen...", "currentAdmins": "Aktuelle Administratoren", - "adminBadge": "Administrator", + "adminBadge": "Admin", "removeAdminButton": "Administrator entfernen", "general": "Allgemein", "userRegistration": "Benutzerregistrierung", @@ -1072,7 +1072,7 @@ "used": "Gebraucht", "percentage": "Prozentsatz", "refreshStatusAndMetrics": "Aktualisierungsstatus und Metriken", - "refreshStatus": "Aktualisierungsstatus", + "refreshStatus": "Aktualisieren", "fileManagerAlreadyOpen": "Der Dateimanager ist für diesen Host bereits geöffnet", "openFileManager": "Dateimanager öffnen", "cpuCores_one": "{{count}} CPU", @@ -1372,7 +1372,7 @@ "disconnected": "Getrennt", "maxRetriesExhausted": "Maximale Wiederholungsversuche ausgeschöpft", "endpointHostNotFound": "Endpunkthost nicht gefunden", - "administrator": "Administrator", + "administrator": "Admin", "user": "Benutzer", "external": "Extern", "local": "Lokal", -- 2.49.1 From 76b8f1f2eaa727fa3942c52de3a9f0aed9eda235 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 6 Nov 2025 16:28:50 +0100 Subject: [PATCH 5/7] Update translation.json added updated block for serverStats --- src/locales/de/translation.json | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 64d81408..6820f169 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -1081,9 +1081,10 @@ "loadAverageNA": "Durchschnitt: N\/A", "cpuUsage": "CPU-Auslastung", "memoryUsage": "Speicherauslastung", + "diskUsage": "Festplattennutzung", "rootStorageSpace": "Root-Speicherplatz", "of": "von", - "feedbackMessage": "Haben Sie Ideen für die nächsten Schritte im Bereich der Serververwaltung? Teilen Sie diese mit uns", + "feedbackMessage": "Haben Sie Ideen, wie es bei der Serververwaltung weitergehen könnte? Dann teilen Sie diese gerne mit uns auf", "failedToFetchHostConfig": "Abrufen der Hostkonfiguration fehlgeschlagen", "failedToFetchStatus": "Abrufen des Serverstatus fehlgeschlagen", "failedToFetchMetrics": "Abrufen der Servermetriken fehlgeschlagen", @@ -1092,7 +1093,26 @@ "refreshing": "Aktualisieren...", "serverOffline": "Server offline", "cannotFetchMetrics": "Metriken können nicht vom Offline-Server abgerufen werden", - "load": "Laden" + "load": "Last" + "available": "Verfügbar", + "editLayout": "Layout anpassen", + "cancelEdit": "Abbrechen", + "addWidget": "Widget hinzufügen", + "saveLayout": "Layout speichern", + "unsavedChanges": "Ungespeicherte Änderungen", + "layoutSaved": "Layout erfolgreich gespeichert", + "failedToSaveLayout": "Speichern des Layout fehlgeschlagen", + "systemInfo": "System Information", + "hostname": "Hostname", + "operatingSystem": "Betriebssystem", + "kernel": "Kernel", + "totalUptime": "Gesamte Betriebszeit", + "seconds": "Sekunden", + "networkInterfaces": "Netzwerkschnittstellen", + "noInterfacesFound": "Keine Netzwerkschnittstellen gefunden", + "totalProcesses": "Gesamtprozesse", + "running": "läuft", + "noProcessesFound": "Keine Prozesse gefunden" }, "auth": { "loginTitle": "Melden Sie sich bei Termix an", -- 2.49.1 From 6e2e858575e4baffa9a34d49452606a41f3b0c5e Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 6 Nov 2025 16:43:49 +0100 Subject: [PATCH 6/7] Update translation.json Added translations --- src/locales/de/translation.json | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 6820f169..ed288f3e 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -187,7 +187,7 @@ "commandsWillBeSent": "Befehle werden an {{count}} ausgewählte Terminals gesendet.", "settings": "Einstellungen", "enableRightClickCopyPaste": "Rechtsklick-Kopieren\/Einfügen aktivieren", - "shareIdeas": "Haben Sie Ideen, was als nächstes für SSH-Tools kommen sollte? Teilen Sie diese mit uns" + "shareIdeas": "Haben Sie Vorschläge welche weiteren SSH-Tools ergänzt werden sollen? Dann teilen Sie diese gerne mit uns auf" }, "homepage": { "loggedInTitle": "Eingeloggt!", @@ -416,6 +416,13 @@ "userDeletedSuccessfully": "Benutzer {{username}} wurde erfolgreich gelöscht", "failedToDeleteUser": "Benutzer konnte nicht gelöscht werden", "overrideUserInfoUrl": "URL für Benutzerinformationen überschreiben (nicht erforderlich)", + "failedToFetchSessions": "Fehler beim Abrufen der Sitzungen", + "sessionRevokedSuccessfully": "Sitzung erfolgreich widerrufen", + "failedToRevokeSession": "Sitzung konnte nicht widerrufen werden", + "confirmRevokeSession": "Möchten Sie diese Sitzung wirklich beenden?", + "confirmRevokeAllSessions": "Möchten Sie wirklich alle Sitzungen dieses Benutzers beenden?", + "failedToRevokeSessions": "Sitzungen konnten nicht widerrufen werden", + "sessionsRevokedSuccessfully": "Sitzungen erfolgreich beendet", "databaseSecurity": "Datenbanksicherheit", "encryptionStatus": "Verschlüsselungsstatus", "encryptionEnabled": "Verschlüsselung aktiviert", @@ -620,7 +627,7 @@ "autoStartContainer": "Automatischer Start beim Container-Start", "autoStartDesc": "Diesen Tunnel beim Start des Containers automatisch starten", "addConnection": "Tunnelverbindung hinzufügen", - "sshpassRequired": "Sshpass erforderlich für die Passwort-Authentifizierung", + "sshpassRequired": "sshpass erforderlich für die Passwort-Authentifizierung", "sshpassRequiredDesc": "Für die Passwortauthentifizierung in Tunneln muss sshpass auf dem System installiert sein.", "otherInstallMethods": "Andere Installationsmethoden:", "debianUbuntuEquivalent": "(Debian\/Ubuntu) oder das entsprechende Pendant für Ihr Betriebssystem.", -- 2.49.1 From 7d64aa19f4c511261d87e44dd66a629dee8a4d36 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 6 Nov 2025 16:46:06 +0100 Subject: [PATCH 7/7] Update translation.json Removed duplicate of "free":"Free" --- src/locales/en/translation.json | 1 - 1 file changed, 1 deletion(-) diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 3de6628c..8012ad05 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1210,7 +1210,6 @@ "totpRequired": "TOTP Authentication Required", "totpUnavailable": "Server Stats unavailable for TOTP-enabled servers", "load": "Load", - "free": "Free", "available": "Available", "editLayout": "Edit Layout", "cancelEdit": "Cancel", -- 2.49.1