feat(settings): configure SQL file size limits

This commit is contained in:
t8y2
2026-09-08 19:42:32 +08:00
committed by GitHub
parent 9a0e3e744c
commit 39e34d44f7
34 changed files with 554 additions and 79 deletions
+5 -5
View File
@@ -66,7 +66,7 @@ import { isMacOS, isWindows } from "@/lib/backend/platform";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { openQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile";
import { rememberExternalSqlFileTarget, resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
import { externalSqlFileOpenErrorMessage, isSqlFilePath, readBrowserSqlFile, sqlFileTitleFromPath } from "@/lib/sql/sqlFileOpen";
import { externalSqlFileOpenErrorMessage, externalSqlEditorMaxBytes, isSqlFilePath, readBrowserSqlFile, sqlFileTitleFromPath } from "@/lib/sql/sqlFileOpen";
import type { ConnectionConfig, DatabaseType, ObjectSourceKind, QueryTab, TreeNode } from "@/types/database";
import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink";
import { parseAiConfigDeepLink, type AiConfigDeepLinkDraft } from "@/lib/ai/aiConfigDeepLink";
@@ -2001,7 +2001,7 @@ async function openSqlFile() {
if (path) {
const sqlPath = path as string;
openedSqlPath = sqlPath;
const snapshot = await api.readExternalSqlFileSnapshot(sqlPath);
const snapshot = await api.readExternalSqlFileSnapshot(sqlPath, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb));
queryStore.updateSql(tab.id, snapshot.content);
queryStore.linkExternalSqlPath(tab.id, sqlPath, sqlFileTitleFromPath(sqlPath), snapshot.version);
applyExternalSqlFileTarget(tab, sqlPath);
@@ -2014,7 +2014,7 @@ async function openSqlFile() {
const file = input.files?.[0];
if (!file) return;
try {
queryStore.updateSql(tab.id, await readBrowserSqlFile(file));
queryStore.updateSql(tab.id, await readBrowserSqlFile(file, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb)));
} catch (e: any) {
toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000);
}
@@ -2057,7 +2057,7 @@ async function openSqlFilePath(path: string) {
if (!isTauriRuntime()) return;
try {
await desktopOpenTabsRestorationBarrier?.settled;
const snapshot = await api.readExternalSqlFileSnapshot(path);
const snapshot = await api.readExternalSqlFileSnapshot(path, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb));
const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget());
queryStore.openExternalSqlFile(target.connectionId, target.database, path, snapshot.content, snapshot.version, target.catalog, target.schema);
} catch (e: any) {
@@ -2703,7 +2703,7 @@ async function handleQuickOpenSelect(item: any) {
// Handle SQL file types first — they don't require a database connection
if (item.type === "sql_file" && item.filePath) {
try {
const snapshot = await api.readExternalSqlFileSnapshot(item.filePath);
const snapshot = await api.readExternalSqlFileSnapshot(item.filePath, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb));
const target = resolveExternalSqlFileTarget(item.filePath, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget());
queryStore.openExternalSqlFile(target.connectionId, target.database, item.filePath, snapshot.content, snapshot.version, target.catalog, target.schema);
} catch (e: any) {
@@ -138,6 +138,8 @@ import {
saveMaxAgentTurns,
loadMaxRetries,
saveMaxRetries,
loadSqlFileUploadMaxBytes,
saveSqlFileUploadMaxMb,
saveWebdavSyncSecretsPreference,
saveWebdavSavedPassword,
saveSnippetSavedToken,
@@ -226,6 +228,7 @@ import { useUiFontFamilyPreview } from "@/composables/useUiFontFamilyPreview";
import { DateTimePatterns, normalizeSupportedDateTimePattern } from "@/lib/dataGrid/columnFormatter";
import { MAX_RESULT_PAGE_SIZE, MIN_RESULT_PAGE_SIZE } from "@/lib/dataGrid/paginationPageSize";
import { MAX_QUERY_RESULT_MAX_ROWS } from "@/lib/dataGrid/queryResultRowLimit";
import { MAX_EXTERNAL_SQL_EDITOR_FILE_MB, MIN_EXTERNAL_SQL_EDITOR_FILE_MB, clampExternalSqlEditorMaxMbInput } from "@/lib/sql/sqlFileOpen";
import type { PromptTemplate } from "@/types/promptTemplate";
import { GLOBAL_INSTRUCTIONS_MAX, PROMPT_TEMPLATE_CONTENT_MAX, PROMPT_TEMPLATE_NAME_MAX, promptTemplateCharacterCount } from "@/types/promptTemplate";
import { METADATA_CACHE_HARD_MAX_MEMORY_MB, METADATA_CACHE_MIN_MEMORY_MB, normalizeMetadataCacheMemoryMb } from "@/lib/metadata/metadataRuntimeCache";
@@ -512,6 +515,7 @@ const editPageSize = ref(settingsStore.editorSettings.pageSize);
const editTableOpenPageSize = ref(settingsStore.editorSettings.tableOpenPageSize);
const editQueryResultMaxRowsEnabled = ref(settingsStore.editorSettings.queryResultMaxRowsEnabled);
const editQueryResultMaxRows = ref(settingsStore.editorSettings.queryResultMaxRows);
const editExternalSqlEditorMaxMb = ref(settingsStore.editorSettings.externalSqlEditorMaxMb);
const editInfiniteScroll = ref(settingsStore.editorSettings.infiniteScroll);
const editRegexMaxMatchCount = ref(settingsStore.editorSettings.regexMaxMatchCount);
const editAutoCalculateTotalRows = ref(settingsStore.editorSettings.autoCalculateTotalRows);
@@ -542,6 +546,18 @@ function updateQueryResultMaxRowsInput(event: Event) {
editQueryResultMaxRows.value = normalized;
}
function updateExternalSqlEditorMaxMbInput(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const parsed = Number(input.value);
if (!Number.isFinite(parsed) || parsed > MAX_EXTERNAL_SQL_EDITOR_FILE_MB) {
input.value = String(editExternalSqlEditorMaxMb.value);
return;
}
const normalized = clampExternalSqlEditorMaxMbInput(input.value);
if (input.value !== String(normalized)) input.value = String(normalized);
editExternalSqlEditorMaxMb.value = normalized;
}
function sqlVariableSyntaxToggle(key: keyof SqlVariableSyntaxToggles): boolean {
return editSqlVariableSyntaxOverrides.value[editSqlVariableSyntaxDatabaseType.value]?.[key] ?? true;
}
@@ -725,6 +741,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
tableOpenPageSize: editTableOpenPageSize.value,
queryResultMaxRowsEnabled: editQueryResultMaxRowsEnabled.value,
queryResultMaxRows: editQueryResultMaxRows.value,
externalSqlEditorMaxMb: editExternalSqlEditorMaxMb.value,
infiniteScroll: editInfiniteScroll.value,
regexMaxMatchCount: editRegexMaxMatchCount.value,
autoCalculateTotalRows: editAutoCalculateTotalRows.value,
@@ -1221,6 +1238,7 @@ function syncEditorSettingsDraftFromStore() {
editTableOpenPageSize.value = settingsStore.editorSettings.tableOpenPageSize;
editQueryResultMaxRowsEnabled.value = settingsStore.editorSettings.queryResultMaxRowsEnabled;
editQueryResultMaxRows.value = settingsStore.editorSettings.queryResultMaxRows;
editExternalSqlEditorMaxMb.value = settingsStore.editorSettings.externalSqlEditorMaxMb;
editInfiniteScroll.value = settingsStore.editorSettings.infiniteScroll;
editRegexMaxMatchCount.value = settingsStore.editorSettings.regexMaxMatchCount;
editAutoCalculateTotalRows.value = settingsStore.editorSettings.autoCalculateTotalRows;
@@ -1441,6 +1459,9 @@ function applyEditorSettingsKeysToRefs(draft: EditorSettingsDraft, keys: readonl
case "queryResultMaxRows":
editQueryResultMaxRows.value = draft.queryResultMaxRows;
break;
case "externalSqlEditorMaxMb":
editExternalSqlEditorMaxMb.value = draft.externalSqlEditorMaxMb;
break;
case "infiniteScroll":
editInfiniteScroll.value = draft.infiniteScroll;
break;
@@ -1900,6 +1921,7 @@ function resetDefaultsForTab(tab: SettingsCategory) {
editTableOpenPageSize.value = DEFAULT_EDITOR_SETTINGS.tableOpenPageSize;
editQueryResultMaxRowsEnabled.value = DEFAULT_EDITOR_SETTINGS.queryResultMaxRowsEnabled;
editQueryResultMaxRows.value = DEFAULT_EDITOR_SETTINGS.queryResultMaxRows;
editExternalSqlEditorMaxMb.value = DEFAULT_EDITOR_SETTINGS.externalSqlEditorMaxMb;
editInfiniteScroll.value = DEFAULT_EDITOR_SETTINGS.infiniteScroll;
editRegexMaxMatchCount.value = DEFAULT_EDITOR_SETTINGS.regexMaxMatchCount;
editAutoCalculateTotalRows.value = DEFAULT_EDITOR_SETTINGS.autoCalculateTotalRows;
@@ -1987,6 +2009,7 @@ function resetAllDefaults() {
editTableOpenPageSize.value = DEFAULT_EDITOR_SETTINGS.tableOpenPageSize;
editQueryResultMaxRowsEnabled.value = DEFAULT_EDITOR_SETTINGS.queryResultMaxRowsEnabled;
editQueryResultMaxRows.value = DEFAULT_EDITOR_SETTINGS.queryResultMaxRows;
editExternalSqlEditorMaxMb.value = DEFAULT_EDITOR_SETTINGS.externalSqlEditorMaxMb;
editInfiniteScroll.value = DEFAULT_EDITOR_SETTINGS.infiniteScroll;
editRegexMaxMatchCount.value = DEFAULT_EDITOR_SETTINGS.regexMaxMatchCount;
editAutoCalculateTotalRows.value = DEFAULT_EDITOR_SETTINGS.autoCalculateTotalRows;
@@ -2368,6 +2391,7 @@ const settingsCategoryNav = computed<{ value: SettingsCategory; label: string }[
{ value: "formatter", label: t("settings.sqlFormatterTab") },
{ value: "navigation", label: t("settings.navigationTab") },
{ value: "data", label: t("settings.dataTab") },
{ value: "sqlFile", label: t("settings.sqlFileSizeTab") },
...(isWeb ? [] : [{ value: "backups" as const, label: t("databaseBackup.title") }]),
{ value: "tunnels", label: t("settings.tunnelsTab") },
{ value: "shortcuts", label: t("settings.shortcutsTab") },
@@ -2378,7 +2402,7 @@ const settingsCategoryNav = computed<{ value: SettingsCategory; label: string }[
...(isWeb ? [{ value: "security" as const, label: t("settings.securityTab") }] : []),
{ value: "about", label: t("settings.aboutTab") },
]);
const settingsTabsWithApplyFooter = new Set<SettingsCategory>(["editor", "formatter", "appearance", "navigation", "data", "shortcuts", "snippets"]);
const settingsTabsWithApplyFooter = new Set<SettingsCategory>(["editor", "formatter", "appearance", "navigation", "data", "sqlFile", "shortcuts", "snippets"]);
function hasSettingsApplyFooter(value: SettingsCategory): boolean {
return settingsTabsWithApplyFooter.has(value);
@@ -3826,6 +3850,7 @@ watch(activeSettingsTab, async (tab) => {
await promptTemplateStore.ensureLoaded();
editGlobalInstructions.value = promptTemplateStore.globalInstructions;
}
if (tab === "sqlFile" && isWeb) void loadWebSqlFileUploadMaxMbSetting();
if (tab === "about" && !appSupportInfo.value) void refreshAppSupportInfo();
if (tab === "appearance") {
checkLayoutDescTruncation();
@@ -4133,6 +4158,52 @@ function normalizeMaxRetries(value: number | undefined): number {
return Math.min(10, Math.max(0, rounded));
}
const editWebSqlFileUploadMaxMb = ref<number | undefined>(undefined);
const webSqlFileUploadMaxMbSaving = ref(false);
const webSqlFileUploadMaxMbLoaded = ref(false);
const webSqlFileUploadMaxMbLoading = ref(false);
const webSqlFileUploadMaxMbLoadError = ref("");
async function loadWebSqlFileUploadMaxMbSetting() {
if (webSqlFileUploadMaxMbLoaded.value || webSqlFileUploadMaxMbLoading.value) return;
webSqlFileUploadMaxMbLoading.value = true;
webSqlFileUploadMaxMbLoadError.value = "";
try {
const bytes = await loadSqlFileUploadMaxBytes();
editWebSqlFileUploadMaxMb.value = Math.round(bytes / (1024 * 1024));
webSqlFileUploadMaxMbLoaded.value = true;
} catch (e: any) {
webSqlFileUploadMaxMbLoadError.value = e?.message || String(e);
toast(webSqlFileUploadMaxMbLoadError.value, 5000);
} finally {
webSqlFileUploadMaxMbLoading.value = false;
}
}
function normalizeWebSqlFileUploadMaxMb(value: number | undefined): number {
const rounded = typeof value === "number" && Number.isFinite(value) ? Math.round(value) : 200;
return Math.min(MAX_EXTERNAL_SQL_EDITOR_FILE_MB, Math.max(MIN_EXTERNAL_SQL_EDITOR_FILE_MB, rounded));
}
function webSqlFileUploadMaxMbOutOfRange(value: number | undefined): boolean {
return typeof value === "number" && (value < MIN_EXTERNAL_SQL_EDITOR_FILE_MB || value > MAX_EXTERNAL_SQL_EDITOR_FILE_MB);
}
async function saveWebSqlFileUploadMaxMbSetting() {
if (!webSqlFileUploadMaxMbLoaded.value) return;
const clamped = normalizeWebSqlFileUploadMaxMb(editWebSqlFileUploadMaxMb.value);
webSqlFileUploadMaxMbSaving.value = true;
try {
await saveSqlFileUploadMaxMb(clamped);
editWebSqlFileUploadMaxMb.value = clamped;
toast(t("settings.sqlFileUploadMaxMbSaved"));
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
webSqlFileUploadMaxMbSaving.value = false;
}
}
// AI Config Delete Confirmation
const aiDeleteConfirmOpen = ref(false);
const aiDeleteConfigId = ref<string | null>(null);
@@ -7427,6 +7498,55 @@ onUnmounted(() => {
</div>
</section>
<section v-else-if="activeSettingsTab === 'sqlFile'" data-settings-search-id="sqlFile" :class="['flex flex-col gap-5 py-2', settingsSearchTargetClass('sqlFile')]">
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="external-sql-editor-max-mb">
{{ t("settings.externalSqlEditorMaxMb") }}
</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.externalSqlEditorMaxMbDescription", { min: MIN_EXTERNAL_SQL_EDITOR_FILE_MB, max: MAX_EXTERNAL_SQL_EDITOR_FILE_MB }) }}
</p>
</div>
<Input
id="external-sql-editor-max-mb"
type="number"
inputmode="numeric"
class="h-7 w-[130px] px-2 text-left text-xs tabular-nums"
:min="MIN_EXTERNAL_SQL_EDITOR_FILE_MB"
:max="MAX_EXTERNAL_SQL_EDITOR_FILE_MB"
:model-value="editExternalSqlEditorMaxMb"
@input="updateExternalSqlEditorMaxMbInput"
/>
</div>
<div v-if="isWeb" class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="web-sql-file-upload-max-mb">
{{ t("settings.webSqlFileUploadMaxMb") }}
</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.webSqlFileUploadMaxMbDescription", { min: MIN_EXTERNAL_SQL_EDITOR_FILE_MB, max: MAX_EXTERNAL_SQL_EDITOR_FILE_MB }) }}
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<Input
id="web-sql-file-upload-max-mb"
v-model.number="editWebSqlFileUploadMaxMb"
type="number"
inputmode="numeric"
class="h-7 w-[130px] px-2 text-left text-xs tabular-nums"
:min="MIN_EXTERNAL_SQL_EDITOR_FILE_MB"
:max="MAX_EXTERNAL_SQL_EDITOR_FILE_MB"
:disabled="!webSqlFileUploadMaxMbLoaded || webSqlFileUploadMaxMbLoading"
:aria-invalid="webSqlFileUploadMaxMbOutOfRange(editWebSqlFileUploadMaxMb)"
/>
<Button type="button" size="sm" variant="outline" :disabled="!webSqlFileUploadMaxMbLoaded || webSqlFileUploadMaxMbSaving || webSqlFileUploadMaxMbOutOfRange(editWebSqlFileUploadMaxMb)" @click="saveWebSqlFileUploadMaxMbSetting">
{{ t("settings.save") }}
</Button>
</div>
</div>
</section>
<section v-else-if="activeSettingsTab === 'shortcuts'" data-settings-search-id="shortcuts" :class="['flex flex-col gap-2 py-2', settingsSearchTargetClass('shortcuts')]">
<div class="relative">
<Search class="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
@@ -10,12 +10,13 @@ import LightTooltip from "@/components/ui/LightTooltip.vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { useQueryStore } from "@/stores/queryStore";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { translateBackendError } from "@/i18n/backend-errors";
import { copyToClipboard } from "@/lib/common/clipboard";
import { forgetExternalSqlFileTarget, moveExternalSqlFileTarget, resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
import { externalSqlFileOpenErrorMessage, formatSqlFileSize, isExternalSqlFileTooLargeError, isSqlFilePath } from "@/lib/sql/sqlFileOpen";
import { externalSqlFileOpenErrorMessage, externalSqlEditorMaxBytes, formatSqlFileSize, isExternalSqlFileTooLargeError, isSqlFilePath } from "@/lib/sql/sqlFileOpen";
import * as api from "@/lib/backend/api";
import type { SqlFileEntry } from "@/lib/backend/api";
import { getSqlFileFilter, getSqlFileFolderPaths, saveSqlFileFilter, saveSqlFileFolderPaths, notifySqlFileFoldersChanged } from "@/lib/sqlFile/sqlFileFolders";
@@ -28,6 +29,7 @@ const emit = defineEmits<{
const { t } = useI18n();
const queryStore = useQueryStore();
const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
const { toast } = useToast();
interface FolderState {
@@ -253,7 +255,7 @@ function collectDirPaths(entries: SqlFileEntry[], into: Set<string>) {
async function openFile(path: string) {
if (!isTauriRuntime()) return;
try {
const snapshot = await api.readExternalSqlFileSnapshot(path);
const snapshot = await api.readExternalSqlFileSnapshot(path, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb));
const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget());
queryStore.openExternalSqlFile(target.connectionId, target.database, path, snapshot.content, snapshot.version, target.catalog, target.schema);
} catch (e: any) {
@@ -16,6 +16,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { externalSqlEditorMaxBytes } from "@/lib/sql/sqlFileOpen";
import { focusSidebarRenameInput } from "@/lib/sidebar/sidebarRenameFocus";
import { savedSqlFolderBranchFileCount } from "@/lib/savedSql/savedSqlFolderCounts";
import { collectSavedSqlDirectoryImportFiles } from "@/lib/savedSql/savedSqlDirectoryImport";
@@ -231,7 +232,7 @@ async function importDirectoryIntoLibrary(targetFolder?: SavedSqlFolder) {
takenNamesByFolder.set(folderKey, takenNames);
}
const path = file.path;
const content = await api.readExternalSqlFile(path);
const content = await api.readExternalSqlFile(path, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb));
const displayName = uniqueImportedName(file.name, takenNames);
await savedSqlStore.saveFile({
connectionId: importTarget.connectionId,
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { uuid } from "@/lib/common/utils";
import { useI18n } from "vue-i18n";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
@@ -97,7 +97,21 @@ const terminalError = ref("");
const activeExecutionTask = ref<ExportTask | null>(null);
const failureDetailsExpanded = ref(false);
const refreshedTarget = ref(false);
const MAX_WEB_SQL_FILE_BYTES = 200 * 1024 * 1024;
const DEFAULT_WEB_SQL_FILE_BYTES = 200 * 1024 * 1024;
const webSqlFileUploadMaxBytes = ref(DEFAULT_WEB_SQL_FILE_BYTES);
// The web server caps SQL file uploads via DBX_SQL_FILE_UPLOAD_MAX_MB; fetch the
// effective limit once so the client-side pre-check matches the server rule.
onMounted(async () => {
if (isDesktopRuntime) return;
try {
const { loadSqlFileUploadMaxBytes } = await import("@/lib/backend/http");
const value = await loadSqlFileUploadMaxBytes();
if (Number.isFinite(value) && value > 0) webSqlFileUploadMaxBytes.value = value;
} catch {
// Fall back to the built-in default; the server still rejects oversized uploads.
}
});
// Per-file results accumulated from backend file-boundary events during
// multi-file execution. Populated only when previews.length > 1.
@@ -285,8 +299,9 @@ async function previewSelectedSqlFile(fileOrPath: string | File) {
return previewSqlFile(fileOrPath as string);
}
const file = fileOrPath as File;
if (file.size > MAX_WEB_SQL_FILE_BYTES) {
throw new Error(`File too large: ${file.size} bytes (max ${MAX_WEB_SQL_FILE_BYTES} bytes)`);
const uploadLimit = webSqlFileUploadMaxBytes.value;
if (file.size > uploadLimit) {
throw new Error(`File too large: ${file.size} bytes (max ${uploadLimit} bytes)`);
}
const { previewSqlFile: previewWebSqlFile } = await import("@/lib/backend/http");
return previewWebSqlFile(file);
@@ -4,6 +4,8 @@ import * as api from "@/lib/backend/api";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { externalSqlFileContentMatchesBaseline, externalSqlFileMetadataMatches, externalSqlFileVersionWasIgnored } from "@/lib/sql/externalSqlFileChanges";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { externalSqlEditorMaxBytes } from "@/lib/sql/sqlFileOpen";
import type { ExternalSqlFileSnapshot } from "@/lib/backend/tauri";
import type { QueryTab } from "@/types/database";
@@ -64,6 +66,7 @@ function delay(ms: number) {
export function useExternalSqlFileChanges(options: UseExternalSqlFileChangesOptions) {
const { t } = useI18n();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const pendingPrompt = ref<ExternalSqlFilePrompt | null>(null);
const checkingTabIds = new Set<string>();
let promptResolver: ((decision: ExternalSqlFilePromptDecision) => void) | null = null;
@@ -83,12 +86,13 @@ export function useExternalSqlFileChanges(options: UseExternalSqlFileChangesOpti
}
async function readStableSnapshot(path: string) {
const maxSizeBytes = externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb);
try {
return await api.readExternalSqlFileSnapshot(path);
return await api.readExternalSqlFileSnapshot(path, maxSizeBytes);
} catch (firstError) {
await delay(MISSING_FILE_RECHECK_DELAY_MS);
try {
return await api.readExternalSqlFileSnapshot(path);
return await api.readExternalSqlFileSnapshot(path, maxSizeBytes);
} catch {
throw firstError;
}
+5 -3
View File
@@ -3,12 +3,13 @@ import { uuid } from "@/lib/common/utils";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import { useLargeSqlFileStreamingFallback } from "@/composables/useLargeSqlFileFallback";
import * as api from "@/lib/backend/api";
import type { ConnectionConfig, ExternalSqlFileVersion } from "@/types/database";
import { detectDatabaseFileType } from "@/lib/database/databaseFileDetection";
import { externalSqlFileOpenErrorMessage, readBrowserSqlFile } from "@/lib/sql/sqlFileOpen";
import { externalSqlEditorMaxBytes, externalSqlFileOpenErrorMessage, readBrowserSqlFile } from "@/lib/sql/sqlFileOpen";
import { resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
function isSqlFilePath(path: string): boolean {
@@ -23,6 +24,7 @@ export function useFileDrop() {
const { t } = useI18n();
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const { toast } = useToast();
const { openInStreamingExecutorOnTooLarge } = useLargeSqlFileStreamingFallback();
@@ -87,7 +89,7 @@ export function useFileDrop() {
if (isSqlFilePath(path)) {
try {
const snapshot = await api.readExternalSqlFileSnapshot(path);
const snapshot = await api.readExternalSqlFileSnapshot(path, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb));
await openDroppedSqlFile(name, snapshot.content, path, snapshot.version);
} catch (e: any) {
if (!openInStreamingExecutorOnTooLarge(path, e)) {
@@ -128,7 +130,7 @@ export function useFileDrop() {
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!isSqlFilePath(file.name)) continue;
void readBrowserSqlFile(file)
void readBrowserSqlFile(file, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb))
.then((content) => openDroppedSqlFile(file.name, content))
.catch((e: any) => {
toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000);
+6 -1
View File
@@ -6540,6 +6540,7 @@ export default {
appearanceTab: "Appearance",
navigationTab: "Navigation",
dataTab: "Data",
sqlFileSizeTab: "SQL File Size",
tunnelsTab: "Tunnels",
tunnelsDescription: "Reusable SSH / proxy / HTTP tunnel configurations. Configure once, then select the profile from a connection's tunnel tab; edits here apply to every connection using the profile.",
tunnelsEmpty: "No tunnel profiles yet. Create one here, then select it in a connection's tunnel settings.",
@@ -6741,7 +6742,11 @@ export default {
queryResultMaxRowsEnabled: "Limit query result rows",
queryResultMaxRowsDescription: "Caps rows returned by queries, table data, stored procedures, and continuous loading.",
queryResultMaxRowsTooSmall: "Must be at least the query page size ({pageSize}).",
queryResultMaxRowsUnlimitedDescription: "No total row or continuous loading limit. Large results can use substantial memory.",
externalSqlEditorMaxMb: "Maximum SQL editor file size",
externalSqlEditorMaxMbDescription: "SQL files larger than this are routed to the streaming Execute SQL File dialog instead of the editor ({min}–{max} MB).",
webSqlFileUploadMaxMb: "Maximum web SQL file upload size",
webSqlFileUploadMaxMbDescription: "Largest SQL file the web server accepts for streaming execution ({min}–{max} MB).",
sqlFileUploadMaxMbSaved: "Web SQL file upload size saved.",
showColumnCommentsInHeader: "Show column comments under names",
showColumnCommentsInHeaderDescription: "Display table column comments directly below grid column names.",
showColumnTypesInHeader: "Show column types under names",
+6 -1
View File
@@ -6131,6 +6131,7 @@ export default withEnglishFallback({
appearanceTab: "Apariencia",
navigationTab: "Navegación",
dataTab: "Datos",
sqlFileSizeTab: "Tamaño de archivo SQL",
tunnelsTab: "Túneles",
tunnelsDescription: "Configuraciones reutilizables de túneles SSH / proxy / HTTP. Configura una vez y selecciona el perfil desde la pestaña de túnel de una conexión; los cambios aquí se aplican a todas las conexiones que usan el perfil.",
tunnelsEmpty: "Aún no hay perfiles de túnel. Crea uno aquí y selecciónalo en la configuración de túnel de una conexión.",
@@ -6321,7 +6322,11 @@ export default withEnglishFallback({
queryResultMaxRowsEnabled: "Limitar filas del resultado",
queryResultMaxRowsDescription: "Limita las filas conservadas por consultas, datos de tablas, procedimientos almacenados y carga continua.",
queryResultMaxRowsTooSmall: "Debe ser al menos el tamaño de página de consulta ({pageSize}).",
queryResultMaxRowsUnlimitedDescription: "Sin límite total de filas ni de carga continua. Los resultados grandes pueden usar mucha memoria.",
externalSqlEditorMaxMb: "Tamaño máximo de archivo del editor SQL",
externalSqlEditorMaxMbDescription: "Los archivos SQL mayores que esto se envían al diálogo de ejecución por streaming en lugar del editor ({min}–{max} MB).",
webSqlFileUploadMaxMb: "Tamaño máximo de carga de archivo SQL web",
webSqlFileUploadMaxMbDescription: "Archivo SQL más grande que el servidor web acepta para ejecución en streaming ({min}–{max} MB).",
sqlFileUploadMaxMbSaved: "Tamaño de carga de archivo SQL web guardado.",
showColumnCommentsInHeader: "Mostrar comentarios bajo los nombres",
showColumnCommentsInHeaderDescription: "Muestra los comentarios de columnas directamente debajo del nombre de la columna.",
showColumnTypesInHeader: "Mostrar tipos de columna bajo los nombres",
+6 -1
View File
@@ -6131,6 +6131,7 @@ export default withEnglishFallback({
appearanceTab: "Aspetto",
navigationTab: "Navigazione",
dataTab: "Dati",
sqlFileSizeTab: "Dimensione file SQL",
tunnelsTab: "Tunnel",
tunnelsDescription: "Configurazioni riutilizzabili di tunnel SSH / proxy / HTTP. Configura una volta, poi seleziona il profilo dalla scheda tunnel di una connessione; le modifiche qui si applicano a tutte le connessioni che usano il profilo.",
tunnelsEmpty: "Nessun profilo tunnel. Creane uno qui, poi selezionalo nelle impostazioni tunnel di una connessione.",
@@ -6321,7 +6322,11 @@ export default withEnglishFallback({
queryResultMaxRowsEnabled: "Limita le righe del risultato",
queryResultMaxRowsDescription: "Limita le righe conservate da query, dati tabella, stored procedure e caricamento continuo.",
queryResultMaxRowsTooSmall: "Deve essere almeno pari alle righe per pagina della query ({pageSize}).",
queryResultMaxRowsUnlimitedDescription: "Nessun limite totale di righe o di caricamento continuo. I risultati grandi possono usare molta memoria.",
externalSqlEditorMaxMb: "Dimensione massima file dell'editor SQL",
externalSqlEditorMaxMbDescription: "I file SQL più grandi vengono inviati alla finestra di esecuzione in streaming invece che all'editor ({min}–{max} MB).",
webSqlFileUploadMaxMb: "Dimensione massima di caricamento file SQL web",
webSqlFileUploadMaxMbDescription: "File SQL più grande che il server web accetta per l'esecuzione in streaming ({min}–{max} MB).",
sqlFileUploadMaxMbSaved: "Dimensione di caricamento file SQL web salvata.",
showColumnCommentsInHeader: "Mostra i commenti delle colonne sotto i nomi",
showColumnCommentsInHeaderDescription: "Visualizza i commenti delle colonne della tabella direttamente sotto i nomi delle colonne nella griglia.",
showColumnTypesInHeader: "Mostra i tipi di colonna sotto i nomi",
+6 -1
View File
@@ -6169,6 +6169,7 @@ export default withEnglishFallback({
appearanceTab: "外観",
navigationTab: "ナビゲーション",
dataTab: "データ",
sqlFileSizeTab: "SQLファイルサイズ",
tunnelsTab: "トンネル",
tunnelsDescription: "再利用可能な SSH / プロキシ / HTTP トンネル設定です。一度設定すれば、接続のトンネルタブからプロファイルを選択するだけで使えます。ここでの編集は、このプロファイルを使用するすべての接続に反映されます。",
tunnelsEmpty: "トンネルプロファイルがまだありません。ここで作成し、接続のトンネル設定で選択してください。",
@@ -6355,7 +6356,11 @@ export default withEnglishFallback({
queryResultMaxRowsEnabled: "クエリ結果の行数を制限",
queryResultMaxRowsDescription: "クエリ、テーブルデータ、ストアドプロシージャ、および連続読み込みで保持する合計行数を制限します。",
queryResultMaxRowsTooSmall: "クエリのページ行数({pageSize})以上にしてください。",
queryResultMaxRowsUnlimitedDescription: "合計行数または連続読み込み数を制限しません。大きな結果は大量のメモリを使用する可能性があります。",
externalSqlEditorMaxMb: "SQLエディターの最大ファイルサイズ",
externalSqlEditorMaxMbDescription: "このサイズを超えるSQLファイルはエディターではなくストリーミング実行ダイアログに回されます({min}~{max} MB)。",
webSqlFileUploadMaxMb: "Web SQLファイルの最大アップロードサイズ",
webSqlFileUploadMaxMbDescription: "Webサーバーがストリーミング実行に受け付ける最大のSQLファイルサイズ({min}~{max} MB)。",
sqlFileUploadMaxMbSaved: "Web SQLファイルのアップロードサイズを保存しました。",
showColumnCommentsInHeader: "列名の下にコメントを表示",
showColumnCommentsInHeaderDescription: "グリッド列名の直下にテーブル列コメントを表示します。",
showColumnTypesInHeader: "列名の下にデータ型を表示",
+6 -1
View File
@@ -5866,6 +5866,7 @@ export default withEnglishFallback({
appearanceTab: "모양",
navigationTab: "탐색",
dataTab: "데이터",
sqlFileSizeTab: "SQL 파일 크기",
tunnelsTab: "터널",
tunnelsDescription: "재사용 가능한 SSH/프록시/HTTP 터널 설정입니다. 한 번 구성한 후 연결의 터널 탭에서 프로필을 선택하세요. 여기서의 편집은 이 프로필을 사용하는 모든 연결에 적용됩니다.",
tunnelsEmpty: "아직 터널 프로필이 없습니다. 여기서 만든 후 연결의 터널 설정에서 선택하세요.",
@@ -6054,7 +6055,11 @@ export default withEnglishFallback({
queryResultMaxRowsEnabled: "쿼리 결과 행 수 제한",
queryResultMaxRowsDescription: "쿼리, 테이블 데이터, 저장 프로시저 및 연속 로딩에서 유지할 총 행 수를 제한합니다.",
queryResultMaxRowsTooSmall: "쿼리 페이지 크기({pageSize}) 이상이어야 합니다.",
queryResultMaxRowsUnlimitedDescription: "총 행 수 또는 연속 로딩 수를 제한하지 않습니다. 큰 결과는 많은 메모리를 사용할 수 있습니다.",
externalSqlEditorMaxMb: "SQL 편집기 최대 파일 크기",
externalSqlEditorMaxMbDescription: "이 크기를 초과하는 SQL 파일은 편집기 대신 스트리밍 실행 대화상자로 전달됩니다({min}–{max} MB).",
webSqlFileUploadMaxMb: "웹 SQL 파일 최대 업로드 크기",
webSqlFileUploadMaxMbDescription: "웹 서버가 스트리밍 실행에 허용하는 가장 큰 SQL 파일({min}–{max} MB).",
sqlFileUploadMaxMbSaved: "웹 SQL 파일 업로드 크기가 저장되었습니다.",
showColumnCommentsInHeader: "이름 아래에 컬럼 주석 표시",
showColumnCommentsInHeaderDescription: "그리드 컬럼 이름 바로 아래에 테이블 컬럼 주석을 표시합니다.",
showColumnTypesInHeader: "이름 아래에 컬럼 타입 표시",
+6 -1
View File
@@ -6133,6 +6133,7 @@ export default withEnglishFallback({
appearanceTab: "Aparência",
navigationTab: "Navegação",
dataTab: "Dados",
sqlFileSizeTab: "Tamanho de arquivo SQL",
tunnelsTab: "Túneis",
tunnelsDescription: "Configurações reutilizáveis de túneis SSH / proxy / HTTP. Configure uma vez e selecione o perfil na aba de túnel de uma conexão; as edições aqui se aplicam a todas as conexões que usam o perfil.",
tunnelsEmpty: "Ainda não há perfis de túnel. Crie um aqui e selecione-o nas configurações de túnel de uma conexão.",
@@ -6323,7 +6324,11 @@ export default withEnglishFallback({
queryResultMaxRowsEnabled: "Limitar linhas do resultado",
queryResultMaxRowsDescription: "Limita as linhas mantidas por consultas, dados de tabelas, procedimentos armazenados e carregamento contínuo.",
queryResultMaxRowsTooSmall: "Deve ser pelo menos o tamanho da página da consulta ({pageSize}).",
queryResultMaxRowsUnlimitedDescription: "Sem limite total de linhas ou de carregamento contínuo. Resultados grandes podem usar muita memória.",
externalSqlEditorMaxMb: "Tamanho máximo de arquivo do editor SQL",
externalSqlEditorMaxMbDescription: "Arquivos SQL maiores são enviados para o diálogo de execução em streaming em vez do editor ({min}–{max} MB).",
webSqlFileUploadMaxMb: "Tamanho máximo de upload de arquivo SQL web",
webSqlFileUploadMaxMbDescription: "Maior arquivo SQL que o servidor web aceita para execução em streaming ({min}–{max} MB).",
sqlFileUploadMaxMbSaved: "Tamanho de upload de arquivo SQL web salvo.",
showColumnCommentsInHeader: "Mostrar comentários de coluna sob os nomes",
showColumnCommentsInHeaderDescription: "Exibir comentários de colunas da tabela diretamente abaixo dos nomes das colunas da grade.",
showColumnTypesInHeader: "Mostrar tipos de coluna sob os nomes",
+6 -1
View File
@@ -6426,6 +6426,7 @@ export default withEnglishFallback({
appearanceTab: "Görünüm",
navigationTab: "Gezinme",
dataTab: "Veri",
sqlFileSizeTab: "SQL Dosya Boyutu",
tunnelsTab: "Tüneller",
tunnelsDescription: "Yeniden kullanılabilir SSH / proxy / HTTP tünel yapılandırmaları. Bir kez yapılandırın, sonra bağlantının tünel sekmesinden profili seçin; buradaki değişiklikler profili kullanan her bağlantıda geçerli olur.",
tunnelsEmpty: "Henüz tünel profili yok. Burada bir tane oluşturun, ardından bir bağlantının tünel ayarlarından seçin.",
@@ -6625,7 +6626,11 @@ export default withEnglishFallback({
queryResultMaxRowsEnabled: "Sorgu sonucu satırlarını sınırla",
queryResultMaxRowsDescription: "Sorgular, tablo verisi, saklı yordamlar ve sürekli yükleme tarafından döndürülen satırlara üst sınır koyar.",
queryResultMaxRowsTooSmall: "En az sorgu sayfa boyutu ({pageSize}) kadar olmalıdır.",
queryResultMaxRowsUnlimitedDescription: "Toplam satır veya sürekli yükleme sınırı yok. Büyük sonuçlar çok fazla bellek kullanabilir.",
externalSqlEditorMaxMb: "SQL düzenleyici maksimum dosya boyutu",
externalSqlEditorMaxMbDescription: "Bu boyuttan büyük SQL dosyaları düzenleyici yerine akış yürütme iletişim kutusuna yönlendirilir ({min}–{max} MB).",
webSqlFileUploadMaxMb: "Web SQL dosyası maksimum yükleme boyutu",
webSqlFileUploadMaxMbDescription: "Web sunucusunun akış yürütme için kabul ettiği en büyük SQL dosyası ({min}–{max} MB).",
sqlFileUploadMaxMbSaved: "Web SQL dosyası yükleme boyutu kaydedildi.",
showColumnCommentsInHeader: "Sütun açıklamalarını adların altında göster",
showColumnCommentsInHeaderDescription: "Tablo sütun açıklamalarını doğrudan ızgara sütun adlarının altında göster.",
showColumnTypesInHeader: "Sütun türlerini adların altında göster",
+6 -1
View File
@@ -6523,6 +6523,7 @@ export default withEnglishFallback({
appearanceTab: "外观",
navigationTab: "导航",
dataTab: "数据",
sqlFileSizeTab: "SQL 文件大小",
tunnelsTab: "隧道维护",
tunnelsDescription: "可复用的 SSH / 代理 / HTTP 隧道配置。一次配置,之后在连接的隧道页签中选择档案即可;此处的修改会对所有使用该档案的连接生效。",
tunnelsEmpty: "还没有隧道档案。先在这里创建,然后在连接的隧道设置中选择使用。",
@@ -6724,7 +6725,11 @@ export default withEnglishFallback({
queryResultMaxRowsEnabled: "限制查询结果行数",
queryResultMaxRowsDescription: "限制查询、表数据、存储过程及连续滚动累计保留的总行数。",
queryResultMaxRowsTooSmall: "不能小于查询结果默认每页行数({pageSize})。",
queryResultMaxRowsUnlimitedDescription: "不限制返回总行数或连续滚动累计数量,大结果集可能占用较多内存。",
externalSqlEditorMaxMb: "SQL 编辑器最大文件大小",
externalSqlEditorMaxMbDescription: "超过此大小的 SQL 文件将转交流式执行 SQL 文件对话框而不是编辑器({min}–{max} MB)。",
webSqlFileUploadMaxMb: "Web SQL 文件最大上传大小",
webSqlFileUploadMaxMbDescription: "Web 服务器接受用于流式执行的最大 SQL 文件({min}–{max} MB)。",
sqlFileUploadMaxMbSaved: "Web SQL 文件上传大小已保存。",
showColumnCommentsInHeader: "在字段名下方显示注释",
showColumnCommentsInHeaderDescription: "把表字段注释直接显示在结果表头字段名下方。",
showColumnTypesInHeader: "在字段名下方显示数据类型",
+6 -1
View File
@@ -5459,6 +5459,7 @@ export default withEnglishFallback({
securityTab: "安全",
aboutTab: "關於我們",
dataTab: "資料",
sqlFileSizeTab: "SQL 檔案大小",
tunnelsTab: "隧道維護",
tunnelsDescription: "可重複使用的 SSH / 代理 / HTTP 隧道設定。一次設定,之後在連線的隧道頁籤中選擇設定檔即可;此處的變更會套用到所有使用該設定檔的連線。",
tunnelsEmpty: "還沒有隧道設定檔。先在這裡建立,然後在連線的隧道設定中選擇使用。",
@@ -5636,7 +5637,11 @@ export default withEnglishFallback({
queryResultMaxRowsEnabled: "限制查詢結果列數",
queryResultMaxRowsDescription: "限制查詢、資料表資料、預存程序及連續捲動累計保留的總列數。",
queryResultMaxRowsTooSmall: "不能小於查詢結果預設每頁列數({pageSize})。",
queryResultMaxRowsUnlimitedDescription: "不限制傳回總列數或連續捲動累計數量,大型結果集可能占用大量記憶體。",
externalSqlEditorMaxMb: "SQL 編輯器最大檔案大小",
externalSqlEditorMaxMbDescription: "超過此大小的 SQL 檔案將轉交流式執行 SQL 檔案對話方塊而不是編輯器({min}–{max} MB)。",
webSqlFileUploadMaxMb: "Web SQL 檔案最大上傳大小",
webSqlFileUploadMaxMbDescription: "Web 伺服器接受用於串流執行之最大 SQL 檔案({min}–{max} MB)。",
sqlFileUploadMaxMbSaved: "Web SQL 檔案上傳大小已儲存。",
showColumnCommentsInHeader: "在欄位名稱下方顯示註解",
showColumnCommentsInHeaderDescription: "直接在資料表格欄位名稱下方顯示資料表欄位註解。",
showColumnTypesInHeader: "在欄位名稱下方顯示資料類型",
+45 -1
View File
@@ -1,7 +1,21 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { ExternalSqlFileTooLargeError, externalSqlFileDisplayTitles, externalSqlFileOpenErrorMessage, formatSqlFileSize, isSqlFilePath, MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES, normalizeExternalSqlPath, readBrowserSqlFile } from "@/lib/sql/sqlFileOpen";
import {
ExternalSqlFileTooLargeError,
clampExternalSqlEditorMaxMbInput,
externalSqlEditorMaxBytes,
externalSqlFileDisplayTitles,
externalSqlFileOpenErrorMessage,
formatSqlFileSize,
isSqlFilePath,
MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES,
MAX_EXTERNAL_SQL_EDITOR_FILE_MB,
MIN_EXTERNAL_SQL_EDITOR_FILE_MB,
normalizeExternalSqlEditorMaxMb,
normalizeExternalSqlPath,
readBrowserSqlFile,
} from "@/lib/sql/sqlFileOpen";
describe("external SQL file paths", () => {
it("normalizes Windows separators for identity checks", () => {
@@ -41,6 +55,36 @@ describe("external SQL file editor limit", () => {
});
});
it("honors a custom browser editor limit", async () => {
const file = new Blob(["select 1;"]);
Object.defineProperty(file, "size", { value: 8 });
await expect(readBrowserSqlFile(file, 4)).rejects.toMatchObject({
name: "ExternalSqlFileTooLargeError",
sizeBytes: 8,
maxSizeBytes: 4,
});
});
it("normalizes the configurable editor size limit in MB", () => {
expect(normalizeExternalSqlEditorMaxMb(undefined)).toBe(64);
expect(normalizeExternalSqlEditorMaxMb(0)).toBe(MIN_EXTERNAL_SQL_EDITOR_FILE_MB);
expect(normalizeExternalSqlEditorMaxMb(128)).toBe(128);
expect(normalizeExternalSqlEditorMaxMb(99999)).toBe(MAX_EXTERNAL_SQL_EDITOR_FILE_MB);
expect(normalizeExternalSqlEditorMaxMb(12.6)).toBe(13);
});
it("clamps the editor size limit input", () => {
expect(clampExternalSqlEditorMaxMbInput("abc")).toBe(MIN_EXTERNAL_SQL_EDITOR_FILE_MB);
expect(clampExternalSqlEditorMaxMbInput(0)).toBe(MIN_EXTERNAL_SQL_EDITOR_FILE_MB);
expect(clampExternalSqlEditorMaxMbInput(256)).toBe(256);
expect(clampExternalSqlEditorMaxMbInput(99999)).toBe(MAX_EXTERNAL_SQL_EDITOR_FILE_MB);
});
it("converts the editor size limit to bytes", () => {
expect(externalSqlEditorMaxBytes(64)).toBe(64 * 1024 * 1024);
});
it("formats large file sizes", () => {
expect(formatSqlFileSize(64 * 1024 * 1024)).toBe("64.0 MB");
expect(formatSqlFileSize(50 * 1024 * 1024 * 1024)).toBe("50.0 GB");
+2
View File
@@ -313,6 +313,8 @@ export const saveDesktopSettings = forward("saveDesktopSettings");
export const loadMcpGlobalPolicy = forward("loadMcpGlobalPolicy");
export const saveMcpGlobalPolicy = forward("saveMcpGlobalPolicy");
export const loadMaxAgentTurns = forward("loadMaxAgentTurns");
export const loadSqlFileUploadMaxBytes = forward("loadSqlFileUploadMaxBytes");
export const saveSqlFileUploadMaxMb = forward("saveSqlFileUploadMaxMb");
export const saveMaxAgentTurns = forward("saveMaxAgentTurns");
export const loadMaxRetries = forward("loadMaxRetries");
export const saveMaxRetries = forward("saveMaxRetries");
+15 -2
View File
@@ -1848,6 +1848,19 @@ export async function loadMaxAgentTurns(): Promise<number> {
return get("/api/app-settings/max-agent-turns");
}
export async function loadSqlFileUploadMaxBytes(): Promise<number> {
return get("/api/app-settings/sql-file-upload-max-bytes");
}
export async function saveSqlFileUploadMaxMb(sqlFileUploadMaxMb: number): Promise<void> {
const res = await fetch(apiUrl("/api/app-settings/sql-file-upload-max-bytes"), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sqlFileUploadMaxMb }),
});
if (!res.ok) throw await backendResponseError(res);
}
export async function saveMaxAgentTurns(maxAgentTurns: number): Promise<void> {
const res = await fetch(apiUrl("/api/app-settings/max-agent-turns"), {
method: "PUT",
@@ -2270,11 +2283,11 @@ export async function pendingOpenAiConfigLinks(): Promise<string[]> {
return [];
}
export async function readExternalSqlFile(_path: string): Promise<string> {
export async function readExternalSqlFile(_path: string, _maxSizeBytes?: number): Promise<string> {
throw new Error("Opening external SQL file paths is only available in the desktop app");
}
export async function readExternalSqlFileSnapshot(_path: string): Promise<import("@/lib/backend/tauri").ExternalSqlFileSnapshot> {
export async function readExternalSqlFileSnapshot(_path: string, _maxSizeBytes?: number): Promise<import("@/lib/backend/tauri").ExternalSqlFileSnapshot> {
throw new Error("Opening external SQL file paths is only available in the desktop app");
}
+12 -4
View File
@@ -717,6 +717,14 @@ export async function loadMaxAgentTurns(): Promise<number> {
return invoke("load_max_agent_turns");
}
export async function loadSqlFileUploadMaxBytes(): Promise<number> {
return 200 * 1024 * 1024;
}
export async function saveSqlFileUploadMaxMb(_sqlFileUploadMaxMb: number): Promise<void> {
// No-op on desktop: SQL files are streamed directly from disk, no server upload cap applies.
}
export async function saveMaxAgentTurns(maxAgentTurns: number): Promise<void> {
return invoke("save_max_agent_turns", { maxAgentTurns });
}
@@ -970,16 +978,16 @@ export type ExternalSqlFileStatus = { kind: "present"; sizeBytes: number; modifi
export type ExternalSqlFileWriteResult = { kind: "written"; version: ExternalSqlFileVersion } | { kind: "conflict"; currentVersion: ExternalSqlFileVersion } | { kind: "missing" };
export async function readExternalSqlFileSnapshot(path: string): Promise<ExternalSqlFileSnapshot> {
const result = await invoke<{ kind: "content"; content: string; version: ExternalSqlFileVersion } | { kind: "tooLarge"; sizeBytes: number; maxSizeBytes: number }>("read_external_sql_file", { path });
export async function readExternalSqlFileSnapshot(path: string, maxSizeBytes?: number): Promise<ExternalSqlFileSnapshot> {
const result = await invoke<{ kind: "content"; content: string; version: ExternalSqlFileVersion } | { kind: "tooLarge"; sizeBytes: number; maxSizeBytes: number }>("read_external_sql_file", { path, maxSizeBytes });
if (result.kind === "tooLarge") {
throw new ExternalSqlFileTooLargeError(result.sizeBytes, result.maxSizeBytes);
}
return { content: result.content, version: result.version };
}
export async function readExternalSqlFile(path: string): Promise<string> {
return (await readExternalSqlFileSnapshot(path)).content;
export async function readExternalSqlFile(path: string, maxSizeBytes?: number): Promise<string> {
return (await readExternalSqlFileSnapshot(path, maxSizeBytes)).content;
}
export async function inspectExternalSqlFile(path: string): Promise<ExternalSqlFileStatus> {
@@ -2,6 +2,7 @@ import type { EditorSettings } from "@/stores/settingsStore";
import { normalizeBackgroundImageSettings } from "@/lib/app/appBackgroundImage";
import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
import { normalizeQueryResultMaxRows } from "@/lib/dataGrid/queryResultRowLimit";
import { normalizeExternalSqlEditorMaxMb } from "@/lib/sql/sqlFileOpen";
import { normalizeCompletionTriggerMode } from "@/lib/sql/sqlCompletionTriggerPolicy";
import { normalizeRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates";
@@ -59,6 +60,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
"tableOpenPageSize",
"queryResultMaxRowsEnabled",
"queryResultMaxRows",
"externalSqlEditorMaxMb",
"infiniteScroll",
"regexMaxMatchCount",
"autoCalculateTotalRows",
@@ -131,6 +133,7 @@ export function normalizeQueryResultMaxRowsDraft(value: unknown): number {
function normalizedDraftValue(key: EditorSettingsDraftKey, value: unknown): unknown {
if (key === "pageSize" || key === "tableOpenPageSize") return normalizeTableOpenPageSizeDraft(value);
if (key === "queryResultMaxRows") return normalizeQueryResultMaxRowsDraft(value);
if (key === "externalSqlEditorMaxMb") return normalizeExternalSqlEditorMaxMb(value);
if (key === "completionTriggerMode") return normalizeCompletionTriggerMode(value);
if (key === "redisKeyTemplates") return normalizeRedisKeyTemplates(value);
if (key === "backgroundImage") return normalizeBackgroundImageSettings(value);
@@ -1,4 +1,4 @@
export type SettingsCategory = "editor" | "formatter" | "appearance" | "navigation" | "data" | "backups" | "tunnels" | "shortcuts" | "snippets" | "sync" | "ai" | "mcp" | "security" | "about";
export type SettingsCategory = "editor" | "formatter" | "appearance" | "navigation" | "data" | "sqlFile" | "backups" | "tunnels" | "shortcuts" | "snippets" | "sync" | "ai" | "mcp" | "security" | "about";
export interface SettingsSearchContext {
isWeb: boolean;
@@ -199,6 +199,8 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] =
{ id: "data-grid-quick-entry", category: "data", titleKey: "settings.dataGridQuickEntry", descriptionKey: "settings.dataGridQuickEntryDescription", targetId: "data" },
{ id: "data-grid-filter-view", category: "data", titleKey: "settings.dataGridFilterView", descriptionKey: "settings.dataGridFilterViewDescription", targetId: "data-grid-filter-view" },
{ id: "data-grid-flattening-multi-line", category: "data", titleKey: "settings.flatteningMultiLineText", descriptionKey: "settings.flatteningMultiLineTextDescription", targetId: "data" },
{ id: "sql-file-editor-max-mb", category: "sqlFile", titleKey: "settings.externalSqlEditorMaxMb", descriptionKey: "settings.externalSqlEditorMaxMbDescription", targetId: "sqlFile" },
{ id: "sql-file-web-upload-max-mb", category: "sqlFile", titleKey: "settings.webSqlFileUploadMaxMb", descriptionKey: "settings.webSqlFileUploadMaxMbDescription", targetId: "sqlFile", visible: (context) => context.isWeb },
{ id: "appearance-toolbar", category: "appearance", titleKey: "settings.toolbarTitle", descriptionKey: "settings.toolbarHiddenHint", targetId: "appearance" },
{ id: "appearance-exclusive-sidebar-panels", category: "appearance", titleKey: "settings.exclusiveRightSidebarPanels", descriptionKey: "settings.exclusiveRightSidebarPanelsDescription", targetId: "appearance" },
...createToolbarVisibilitySettingsSearchDefinitions(),
@@ -152,6 +152,7 @@ const SETTINGS_TRANSFER_CATEGORY_KEYS: Record<SettingsTransferCategoryId, readon
"tableOpenPageSize",
"queryResultMaxRowsEnabled",
"queryResultMaxRows",
"externalSqlEditorMaxMb",
"infiniteScroll",
"regexMaxMatchCount",
"autoCalculateTotalRows",
+22 -3
View File
@@ -49,6 +49,25 @@ export function externalSqlFilePaths(paths: string[]): string[] {
export const MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES = 64 * 1024 * 1024;
export const MIN_EXTERNAL_SQL_EDITOR_FILE_MB = 1;
export const MAX_EXTERNAL_SQL_EDITOR_FILE_MB = 4096;
export function normalizeExternalSqlEditorMaxMb(value: unknown): number {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES / (1024 * 1024);
return Math.min(MAX_EXTERNAL_SQL_EDITOR_FILE_MB, Math.max(MIN_EXTERNAL_SQL_EDITOR_FILE_MB, Math.round(parsed)));
}
export function externalSqlEditorMaxBytes(maxMb: number): number {
return Math.round(maxMb * 1024 * 1024);
}
export function clampExternalSqlEditorMaxMbInput(value: unknown): number {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return MIN_EXTERNAL_SQL_EDITOR_FILE_MB;
return Math.min(MAX_EXTERNAL_SQL_EDITOR_FILE_MB, Math.max(MIN_EXTERNAL_SQL_EDITOR_FILE_MB, Math.round(parsed)));
}
export class ExternalSqlFileTooLargeError extends Error {
constructor(
readonly sizeBytes: number,
@@ -63,9 +82,9 @@ export function isExternalSqlFileTooLargeError(error: unknown): error is Externa
return error instanceof ExternalSqlFileTooLargeError;
}
export function readBrowserSqlFile(file: Blob): Promise<string> {
if (file.size > MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES) {
return Promise.reject(new ExternalSqlFileTooLargeError(file.size, MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES));
export function readBrowserSqlFile(file: Blob, maxSizeBytes = MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES): Promise<string> {
if (file.size > maxSizeBytes) {
return Promise.reject(new ExternalSqlFileTooLargeError(file.size, maxSizeBytes));
}
return new Promise((resolve, reject) => {
@@ -436,6 +436,14 @@ describe("normalizeEditorSettings", () => {
expect(normalizeEditorSettings({ timeoutInheritanceMigrationVersion: 2 }).timeoutInheritanceMigrationVersion).toBe(2);
});
it("normalizes the external SQL editor size limit", () => {
expect(normalizeEditorSettings({}).externalSqlEditorMaxMb).toBe(64);
expect(normalizeEditorSettings({ externalSqlEditorMaxMb: 0 }).externalSqlEditorMaxMb).toBe(1);
expect(normalizeEditorSettings({ externalSqlEditorMaxMb: 256 }).externalSqlEditorMaxMb).toBe(256);
expect(normalizeEditorSettings({ externalSqlEditorMaxMb: 99999 }).externalSqlEditorMaxMb).toBe(4096);
expect(normalizeEditorSettings({ externalSqlEditorMaxMb: "128" } as any).externalSqlEditorMaxMb).toBe(128);
});
it("normalizes toolbar item settings from older saved settings", () => {
const settings = normalizeEditorSettings({
toolbarItems: {
+5
View File
@@ -12,6 +12,7 @@ import { DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_DEFAULT, normalizeDataGridTextFilter
import { DATA_GRID_TYPE_COLOR_SCHEME_AUTO_ID, type DataGridTypeColorScheme, normalizeActiveDataGridTypeColorSchemeId, normalizeDataGridTypeColorSchemes } from "@/lib/dataGrid/dataGridTypeColorScheme";
import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
import { DEFAULT_QUERY_RESULT_MAX_ROWS, normalizeQueryResultMaxRows } from "@/lib/dataGrid/queryResultRowLimit";
import { normalizeExternalSqlEditorMaxMb } from "@/lib/sql/sqlFileOpen";
import { normalizeConnectTimeoutSecs, normalizeQueryTimeoutSecs } from "@/lib/connection/timeoutLimits";
import { needsTabNavigationHistoryShortcutMigration, normalizeShortcutSettings, type ShortcutSettings } from "@/lib/editor/shortcutRegistry";
import type { SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget";
@@ -768,6 +769,7 @@ export interface EditorSettings {
tableOpenPageSize: number;
queryResultMaxRowsEnabled: boolean;
queryResultMaxRows: number;
externalSqlEditorMaxMb: number;
infiniteScroll: boolean;
/** Preserved for downgrade compatibility; current clients use queryResultMaxRows. */
infiniteScrollMaxRows: number;
@@ -1004,6 +1006,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
tableOpenPageSize: 100,
queryResultMaxRowsEnabled: true,
queryResultMaxRows: DEFAULT_QUERY_RESULT_MAX_ROWS,
externalSqlEditorMaxMb: 64,
infiniteScroll: false,
infiniteScrollMaxRows: 5000,
flatteningMultiLineText: false,
@@ -1458,6 +1461,7 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
tableOpenPageSize: normalizeResultPageSize(settings.tableOpenPageSize, DEFAULT_EDITOR_SETTINGS.tableOpenPageSize),
queryResultMaxRowsEnabled: settings.queryResultMaxRowsEnabled !== false,
queryResultMaxRows: normalizeQueryResultMaxRows(settings.queryResultMaxRows),
externalSqlEditorMaxMb: normalizeExternalSqlEditorMaxMb(settings.externalSqlEditorMaxMb),
infiniteScroll: settings.infiniteScroll ?? DEFAULT_EDITOR_SETTINGS.infiniteScroll,
infiniteScrollMaxRows: typeof settings.infiniteScrollMaxRows === "number" && settings.infiniteScrollMaxRows >= 1000 && settings.infiniteScrollMaxRows <= 50000 ? Math.round(settings.infiniteScrollMaxRows) : DEFAULT_EDITOR_SETTINGS.infiniteScrollMaxRows,
flatteningMultiLineText: settings.flatteningMultiLineText ?? DEFAULT_EDITOR_SETTINGS.flatteningMultiLineText,
@@ -2184,6 +2188,7 @@ export const useSettingsStore = defineStore("settings", () => {
if (partial.tableOpenPageSize !== undefined) editorSettings.value.tableOpenPageSize = normalizeResultPageSize(partial.tableOpenPageSize, DEFAULT_EDITOR_SETTINGS.tableOpenPageSize);
if (partial.queryResultMaxRowsEnabled !== undefined) editorSettings.value.queryResultMaxRowsEnabled = Boolean(partial.queryResultMaxRowsEnabled);
if (partial.queryResultMaxRows !== undefined) editorSettings.value.queryResultMaxRows = normalizeQueryResultMaxRows(partial.queryResultMaxRows, editorSettings.value.queryResultMaxRows);
if (partial.externalSqlEditorMaxMb !== undefined) editorSettings.value.externalSqlEditorMaxMb = normalizeExternalSqlEditorMaxMb(partial.externalSqlEditorMaxMb);
if (partial.infiniteScroll !== undefined) editorSettings.value.infiniteScroll = partial.infiniteScroll;
if (partial.infiniteScrollMaxRows !== undefined)
editorSettings.value.infiniteScrollMaxRows = typeof partial.infiniteScrollMaxRows === "number" && partial.infiniteScrollMaxRows >= 1000 && partial.infiniteScrollMaxRows <= 50000 ? Math.round(partial.infiniteScrollMaxRows) : DEFAULT_EDITOR_SETTINGS.infiniteScrollMaxRows;
+7
View File
@@ -9,6 +9,13 @@ use tokio_util::sync::CancellationToken;
use crate::connection::{AppState, PoolKind};
use crate::db;
use crate::models::connection::DatabaseType;
pub const DEFAULT_SQL_FILE_UPLOAD_MAX_MB: u32 = 200;
pub const MAX_SQL_FILE_UPLOAD_MAX_MB: u32 = 4096;
pub fn clamp_sql_file_upload_max_mb(value: u32) -> u32 {
value.clamp(1, MAX_SQL_FILE_UPLOAD_MAX_MB)
}
use crate::query::{
execute_sql_statement_with_options, pool_error_action, wait_for_query_opt, DbOperationBudget, PoolErrorAction,
QueryExecutionOptions,
+68 -1
View File
@@ -38,6 +38,7 @@ const APP_STATE_TRANSFER_TASK_LIBRARY_KEY: &str = "transfer_task_library";
const MCP_GLOBAL_POLICY_KEY: &str = "mcp_global_policy";
const MCP_HTTP_SERVER_SETTINGS_KEY: &str = "mcp_http_server_settings";
const MAX_RETRIES_KEY: &str = "max_retries";
const SQL_FILE_UPLOAD_MAX_MB_KEY: &str = "sql_file_upload_max_mb";
const APP_STATE_AI_GLOBAL_INSTRUCTIONS_KEY: &str = "ai_global_custom_instructions";
const APP_STATE_AI_CHAT_SELECTION_KEY: &str = "ai_chat_selection_v1";
const SNIPPET_SYNC_IDS_KEY: &str = "snippet_sync_ids";
@@ -2050,7 +2051,7 @@ impl Storage {
.query_row("SELECT settings_json FROM app_settings WHERE id = 1", [], |row| row.get(0))
.optional()
.map_err(|e| e.to_string())?;
let dedicated_keys = [MCP_GLOBAL_POLICY_KEY, MAX_RETRIES_KEY];
let dedicated_keys = [MCP_GLOBAL_POLICY_KEY, MAX_RETRIES_KEY, SQL_FILE_UPLOAD_MAX_MB_KEY];
for key in dedicated_keys {
settings.remove(key);
}
@@ -2663,6 +2664,39 @@ impl Storage {
.map(|value| crate::ai::clamp_max_retries(value.min(u32::MAX as u64) as u32))
.unwrap_or(crate::ai::DEFAULT_MAX_RETRIES))
}
pub async fn save_sql_file_upload_max_mb(&self, max_mb: u32) -> Result<(), String> {
let max_mb = crate::sql_file_import::clamp_sql_file_upload_max_mb(max_mb);
self.with_conn(move |conn| {
let current: Option<String> = conn
.query_row("SELECT settings_json FROM app_settings WHERE id = 1", [], |row| row.get(0))
.optional()
.map_err(|e| e.to_string())?;
let mut settings = match current {
Some(json) => serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&json)
.map_err(|e| format!("invalid app settings JSON: {e}"))?,
None => serde_json::Map::new(),
};
settings.insert(
SQL_FILE_UPLOAD_MAX_MB_KEY.to_string(),
serde_json::Value::Number(serde_json::Number::from(max_mb)),
);
let json = serde_json::Value::Object(settings).to_string();
conn.execute("INSERT OR REPLACE INTO app_settings (id, settings_json) VALUES (1, ?1)", [json])
.map(|_| ())
.map_err(|e| e.to_string())
})
.await
}
pub async fn load_sql_file_upload_max_mb(&self) -> Result<u32, String> {
let settings = self.load_app_settings_json().await?;
Ok(settings
.get(SQL_FILE_UPLOAD_MAX_MB_KEY)
.and_then(serde_json::Value::as_u64)
.map(|value| crate::sql_file_import::clamp_sql_file_upload_max_mb(value.min(u32::MAX as u64) as u32))
.unwrap_or(crate::sql_file_import::DEFAULT_SQL_FILE_UPLOAD_MAX_MB))
}
}
// AI Conversations
@@ -7197,6 +7231,39 @@ mod tests {
assert_eq!(storage.load_max_retries().await.unwrap(), 7);
}
#[tokio::test]
async fn sql_file_upload_max_mb_defaults_and_persists_clamped() {
let path = temp_db_path("sql-file-upload-max-mb");
let storage = Storage::open(&path).await.unwrap();
assert_eq!(
storage.load_sql_file_upload_max_mb().await.unwrap(),
crate::sql_file_import::DEFAULT_SQL_FILE_UPLOAD_MAX_MB
);
storage.save_sql_file_upload_max_mb(512).await.unwrap();
assert_eq!(storage.load_sql_file_upload_max_mb().await.unwrap(), 512);
// Values above the cap are clamped so raw DB edits cannot bypass the limit.
storage.save_sql_file_upload_max_mb(u32::MAX).await.unwrap();
assert_eq!(
storage.load_sql_file_upload_max_mb().await.unwrap(),
crate::sql_file_import::MAX_SQL_FILE_UPLOAD_MAX_MB
);
}
#[tokio::test]
async fn sql_file_upload_max_mb_survives_stale_app_settings_save() {
let path = temp_db_path("sql-file-upload-max-mb-stale-save");
let storage = Storage::open(&path).await.unwrap();
let stale_settings = storage.load_app_settings_json().await.unwrap();
storage.save_sql_file_upload_max_mb(256).await.unwrap();
storage.save_app_settings_json(&stale_settings).await.unwrap();
assert_eq!(storage.load_sql_file_upload_max_mb().await.unwrap(), 256);
}
#[tokio::test]
async fn password_hash_preserves_existing_desktop_settings() {
let path = temp_db_path("password-preserve-desktop-settings");
+8 -1
View File
@@ -1016,7 +1016,9 @@ async fn main() {
.route(
"/sql-file/preview",
post(routes::sql_file::preview_sql_file)
.layer(DefaultBodyLimit::max(routes::sql_file::SQL_FILE_UPLOAD_MAX_BYTES.saturating_add(1024 * 1024))),
// Upper bound only; the effective (possibly lower) limit configured via
// Settings > SQL File Size is enforced inside the handler at request time.
.layer(DefaultBodyLimit::max(routes::sql_file::sql_file_upload_hard_cap_bytes())),
)
.route("/sql-file/execute", post(routes::sql_file::execute_sql_file))
.route("/sql-file/progress/{executionId}", get(routes::sql_file::sql_file_progress))
@@ -1057,6 +1059,11 @@ async fn main() {
"/app-settings/max-retries",
get(routes::app_settings::load_max_retries).put(routes::app_settings::save_max_retries),
)
.route(
"/app-settings/sql-file-upload-max-bytes",
get(routes::app_settings::load_sql_file_upload_max_bytes)
.put(routes::app_settings::save_sql_file_upload_max_mb),
)
.route("/app-settings/config/decrypt", post(routes::app_settings::decrypt_config))
// Cloud sync
.route("/cloud-sync/webdav/test", post(routes::cloud_sync::webdav_sync_test))
+29 -1
View File
@@ -130,6 +130,29 @@ pub async fn load_max_retries(State(state): State<Arc<WebState>>) -> Result<Json
state.app.storage.load_max_retries().await.map(Json).map_err(AppError::from)
}
fn sql_file_upload_max_bytes_from_mb(max_mb: u32) -> u64 {
u64::from(max_mb).saturating_mul(1024 * 1024)
}
pub async fn load_sql_file_upload_max_bytes(State(state): State<Arc<WebState>>) -> Result<Json<u64>, AppError> {
let max_mb = state.app.storage.load_sql_file_upload_max_mb().await.map_err(AppError::from)?;
Ok(Json(sql_file_upload_max_bytes_from_mb(max_mb)))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveSqlFileUploadMaxMbRequest {
pub sql_file_upload_max_mb: u32,
}
pub async fn save_sql_file_upload_max_mb(
State(state): State<Arc<WebState>>,
Json(body): Json<SaveSqlFileUploadMaxMbRequest>,
) -> Result<Json<()>, AppError> {
state.app.storage.save_sql_file_upload_max_mb(body.sql_file_upload_max_mb).await.map_err(AppError::from)?;
Ok(Json(()))
}
pub async fn save_max_retries(
State(state): State<Arc<WebState>>,
Json(body): Json<SaveMaxRetriesRequest>,
@@ -163,7 +186,12 @@ fn decrypt_config_payload(payload: &EncryptedConfigPayload, passphrase: &str) ->
#[cfg(test)]
mod tests {
use super::{decrypt_config_payload, EncryptedConfigPayload};
use super::{decrypt_config_payload, sql_file_upload_max_bytes_from_mb, EncryptedConfigPayload};
#[test]
fn preserves_four_gib_sql_file_upload_limit() {
assert_eq!(sql_file_upload_max_bytes_from_mb(4096), 4096_u64 * 1024 * 1024);
}
fn exported_browser_payload() -> EncryptedConfigPayload {
EncryptedConfigPayload {
+45 -9
View File
@@ -23,6 +23,31 @@ use crate::state::WebState;
const PENDING_SQL_FILE_PROGRESS_CHANNEL_TTL: Duration = Duration::from_secs(30);
const SQL_FILE_UPLOAD_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
pub const SQL_FILE_UPLOAD_MAX_BYTES: usize = 200 * 1024 * 1024;
const SQL_FILE_UPLOAD_MIN_BYTES: usize = 1024 * 1024;
const SQL_FILE_UPLOAD_MAX_BYTES_LIMIT: usize = 4096 * 1024 * 1024;
pub fn sql_file_upload_max_bytes_from_value(value: Option<&str>) -> usize {
let mb = value
.and_then(|value| value.trim().parse::<usize>().ok())
.filter(|mb| *mb > 0)
.map(|mb| mb.saturating_mul(1024 * 1024))
.unwrap_or(SQL_FILE_UPLOAD_MAX_BYTES);
mb.clamp(SQL_FILE_UPLOAD_MIN_BYTES, SQL_FILE_UPLOAD_MAX_BYTES_LIMIT)
}
pub fn sql_file_upload_hard_cap_bytes() -> usize {
SQL_FILE_UPLOAD_MAX_BYTES_LIMIT.saturating_add(1024 * 1024)
}
async fn sql_file_upload_limit(state: &WebState) -> usize {
let max_mb = state
.app
.storage
.load_sql_file_upload_max_mb()
.await
.unwrap_or(dbx_core::sql_file_import::DEFAULT_SQL_FILE_UPLOAD_MAX_MB);
sql_file_upload_max_bytes_from_value(Some(&max_mb.to_string()))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -49,12 +74,9 @@ pub async fn preview_sql_file(
if let Some(field) = multipart.next_field().await.map_err(|e| AppError::from(e.to_string()))? {
let file_name = field.file_name().unwrap_or("upload.sql").to_string();
let data = field.bytes().await.map_err(|e| AppError::from(e.to_string()))?;
if data.len() > SQL_FILE_UPLOAD_MAX_BYTES {
return Err(AppError::from(format!(
"File too large: {} bytes (max {} bytes)",
data.len(),
SQL_FILE_UPLOAD_MAX_BYTES
)));
let upload_limit = sql_file_upload_limit(&state).await;
if data.len() > upload_limit {
return Err(AppError::from(format!("File too large: {} bytes (max {} bytes)", data.len(), upload_limit)));
}
let file_path = safe_uploaded_sql_path(&tmp_dir, &file_name)?;
@@ -135,12 +157,13 @@ pub async fn execute_sql_file(
None,
));
for file_path in &file_paths {
let upload_limit = sql_file_upload_limit(&state_clone).await;
match std::fs::metadata(file_path) {
Ok(meta) if meta.len() > SQL_FILE_UPLOAD_MAX_BYTES as u64 => {
Ok(meta) if meta.len() > upload_limit as u64 => {
progress_emitter.emit(sql_file_error_progress(
&req.execution_id,
started_at,
format!("File too large: {} bytes (max {} bytes)", meta.len(), SQL_FILE_UPLOAD_MAX_BYTES),
format!("File too large: {} bytes (max {} bytes)", meta.len(), upload_limit),
));
cleanup_sql_file_execution(&state_clone, &req.execution_id).await;
return;
@@ -278,7 +301,20 @@ pub async fn cancel_sql_file(
mod tests {
use std::time::Duration;
use super::{cleanup_sql_file_uploads_older_than, safe_uploaded_sql_path, validated_uploaded_sql_path};
use super::{
cleanup_sql_file_uploads_older_than, safe_uploaded_sql_path, sql_file_upload_max_bytes_from_value,
validated_uploaded_sql_path, SQL_FILE_UPLOAD_MAX_BYTES,
};
#[test]
fn sql_file_upload_limit_defaults_and_clamps() {
assert_eq!(sql_file_upload_max_bytes_from_value(None), SQL_FILE_UPLOAD_MAX_BYTES);
assert_eq!(sql_file_upload_max_bytes_from_value(Some("")), SQL_FILE_UPLOAD_MAX_BYTES);
assert_eq!(sql_file_upload_max_bytes_from_value(Some("0")), SQL_FILE_UPLOAD_MAX_BYTES);
assert_eq!(sql_file_upload_max_bytes_from_value(Some("512")), 512 * 1024 * 1024);
assert_eq!(sql_file_upload_max_bytes_from_value(Some("1")), 1024 * 1024);
assert_eq!(sql_file_upload_max_bytes_from_value(Some("99999")), 4096 * 1024 * 1024);
}
#[test]
fn uploaded_sql_paths_are_unique_and_keep_the_extension() {
+1 -1
View File
@@ -128,7 +128,7 @@ test("cold-start SQL files wait for restored tabs before opening", () => {
const openPathSource = appSource.slice(openPathStart, openPathEnd);
const initializationWait = openPathSource.indexOf("await desktopOpenTabsRestorationBarrier?.settled");
const fileRead = openPathSource.indexOf("api.readExternalSqlFileSnapshot(path)");
const fileRead = openPathSource.indexOf("api.readExternalSqlFileSnapshot(path, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb))");
const tabOpen = openPathSource.indexOf("queryStore.openExternalSqlFile");
assert.ok(initializationWait >= 0);
assert.ok(initializationWait < fileRead);
+1 -1
View File
@@ -9,7 +9,7 @@ test("SQL library imports use the charset-aware external SQL reader", () => {
const end = source.indexOf("async function chooseSyncDirectory", start);
const handler = start >= 0 && end > start ? source.slice(start, end) : "";
assert.match(handler, /await api\.readExternalSqlFile\(path\)/);
assert.match(handler, /await api\.readExternalSqlFile\(path, externalSqlEditorMaxBytes\(settingsStore\.editorSettings\.externalSqlEditorMaxMb\)\)/);
assert.doesNotMatch(handler, /readTextFile/);
});
+63 -27
View File
@@ -8,9 +8,18 @@ use sha2::{Digest, Sha256};
use tokio::io::AsyncReadExt;
const MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES: u64 = 64 * 1024 * 1024;
const MIN_EXTERNAL_SQL_EDITOR_FILE_BYTES: u64 = 1024 * 1024;
const MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES_LIMIT: u64 = 4096 * 1024 * 1024;
fn exceeds_external_sql_editor_limit(size_bytes: u64) -> bool {
size_bytes > MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES
fn clamp_external_sql_editor_limit(max_size_bytes: Option<u64>) -> u64 {
match max_size_bytes {
Some(value) => value.clamp(MIN_EXTERNAL_SQL_EDITOR_FILE_BYTES, MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES_LIMIT),
None => MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES,
}
}
fn exceeds_external_sql_editor_limit(size_bytes: u64, max_size_bytes: u64) -> bool {
size_bytes > max_size_bytes
}
#[derive(Debug, Serialize, PartialEq, Eq)]
@@ -60,8 +69,11 @@ pub fn pending_open_sql_files(state: tauri::State<'_, ExternalSqlOpenState>) ->
}
#[tauri::command]
pub async fn read_external_sql_file(path: String) -> Result<ExternalSqlFileReadResult, String> {
read_external_sql_file_content_async(PathBuf::from(path)).await
pub async fn read_external_sql_file(
path: String,
max_size_bytes: Option<u64>,
) -> Result<ExternalSqlFileReadResult, String> {
read_external_sql_file_content_async(PathBuf::from(path), clamp_external_sql_editor_limit(max_size_bytes)).await
}
#[tauri::command]
@@ -181,43 +193,42 @@ fn external_sql_file_version(metadata: &std::fs::Metadata, bytes: &[u8]) -> Exte
#[cfg(test)]
fn read_external_sql_file_content(path: &Path) -> Result<ExternalSqlFileReadResult, String> {
read_external_sql_file_content_with_limit(path, MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES)
}
#[cfg(test)]
fn read_external_sql_file_content_with_limit(
path: &Path,
max_size_bytes: u64,
) -> Result<ExternalSqlFileReadResult, String> {
let metadata = std::fs::metadata(path).map_err(|e| format!("Failed to inspect SQL file: {e}"))?;
if !metadata.is_file() {
return Err("Only files can be opened this way".to_string());
}
if exceeds_external_sql_editor_limit(metadata.len()) {
return Ok(ExternalSqlFileReadResult::TooLarge {
size_bytes: metadata.len(),
max_size_bytes: MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES,
});
if exceeds_external_sql_editor_limit(metadata.len(), max_size_bytes) {
return Ok(ExternalSqlFileReadResult::TooLarge { size_bytes: metadata.len(), max_size_bytes });
}
let bytes = std::fs::read(path).map_err(|e| format!("Failed to read SQL file: {e}"))?;
let version = external_sql_file_version(&metadata, &bytes);
decode_sql_file_bytes(&bytes).map(|content| ExternalSqlFileReadResult::Content { content, version })
}
async fn read_external_sql_file_content_async(path: PathBuf) -> Result<ExternalSqlFileReadResult, String> {
async fn read_external_sql_file_content_async(
path: PathBuf,
max_size_bytes: u64,
) -> Result<ExternalSqlFileReadResult, String> {
let file = tokio::fs::File::open(&path).await.map_err(|e| format!("Failed to read SQL file: {e}"))?;
let metadata = file.metadata().await.map_err(|e| format!("Failed to inspect SQL file: {e}"))?;
if !metadata.is_file() {
return Err("Only files can be opened this way".to_string());
}
if exceeds_external_sql_editor_limit(metadata.len()) {
return Ok(ExternalSqlFileReadResult::TooLarge {
size_bytes: metadata.len(),
max_size_bytes: MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES,
});
if exceeds_external_sql_editor_limit(metadata.len(), max_size_bytes) {
return Ok(ExternalSqlFileReadResult::TooLarge { size_bytes: metadata.len(), max_size_bytes });
}
let mut bytes = Vec::with_capacity(metadata.len() as usize);
file.take(MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES + 1)
.read_to_end(&mut bytes)
.await
.map_err(|e| format!("Failed to read SQL file: {e}"))?;
if exceeds_external_sql_editor_limit(bytes.len() as u64) {
return Ok(ExternalSqlFileReadResult::TooLarge {
size_bytes: bytes.len() as u64,
max_size_bytes: MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES,
});
file.take(max_size_bytes + 1).read_to_end(&mut bytes).await.map_err(|e| format!("Failed to read SQL file: {e}"))?;
if exceeds_external_sql_editor_limit(bytes.len() as u64, max_size_bytes) {
return Ok(ExternalSqlFileReadResult::TooLarge { size_bytes: bytes.len() as u64, max_size_bytes });
}
let version = external_sql_file_version(&metadata, &bytes);
decode_sql_file_bytes(&bytes).map(|content| ExternalSqlFileReadResult::Content { content, version })
@@ -413,8 +424,22 @@ mod tests {
#[test]
fn external_sql_editor_limit_is_inclusive() {
assert!(!exceeds_external_sql_editor_limit(MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES));
assert!(exceeds_external_sql_editor_limit(MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES + 1));
assert!(!exceeds_external_sql_editor_limit(
MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES,
MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES
));
assert!(exceeds_external_sql_editor_limit(
MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES + 1,
MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES
));
}
#[test]
fn clamps_external_sql_editor_limit_from_frontend() {
assert_eq!(clamp_external_sql_editor_limit(None), MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES);
assert_eq!(clamp_external_sql_editor_limit(Some(0)), MIN_EXTERNAL_SQL_EDITOR_FILE_BYTES);
assert_eq!(clamp_external_sql_editor_limit(Some(16 * 1024 * 1024)), 16 * 1024 * 1024);
assert_eq!(clamp_external_sql_editor_limit(Some(u64::MAX)), MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES_LIMIT);
}
#[test]
@@ -436,6 +461,17 @@ mod tests {
);
}
#[test]
fn rejects_oversized_external_sql_with_custom_limit() {
let path = std::env::temp_dir().join(format!("dbx-test-{}.sql", uuid::Uuid::new_v4()));
std::fs::write(&path, "select 1;").unwrap();
let result = read_external_sql_file_content_with_limit(&path, 4);
let _ = std::fs::remove_file(&path);
assert_eq!(result.unwrap(), ExternalSqlFileReadResult::TooLarge { size_bytes: 9, max_size_bytes: 4 });
}
#[tokio::test]
async fn rejects_oversized_external_sql_in_async_command_path() {
let path = std::env::temp_dir().join(format!("dbx-test-{}.sql", uuid::Uuid::new_v4()));
@@ -443,7 +479,7 @@ mod tests {
let file_size = MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES + 1;
file.set_len(file_size).unwrap();
let result = read_external_sql_file_content_async(path.clone()).await;
let result = read_external_sql_file_content_async(path.clone(), MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES).await;
let _ = std::fs::remove_file(&path);
assert_eq!(