feat(settings): SQL File Size tab with configurable web upload limit

Group SQL file size settings into a dedicated settings tab:
- Editor limit (from previous commit) moves from the Data tab
- New web upload limit for the web Execute SQL File streaming path,
  persisted in storage (default 200 MB, clamped 1-4096 MB), editable
  from the web settings dialog and exposed via
  GET/PUT /api/app-settings/sql-file-upload-max-bytes
- The /sql-file/preview body limit becomes a hard upper bound; the
  effective per-request limit is enforced in the handlers
- Web dialog fetches the effective limit for its client-side pre-check
- i18n across all locales; settings search + transfer category maps
This commit is contained in:
hanhvs
2026-09-08 19:37:56 +08:00
committed by t8y2
parent 9518043913
commit 56572bd255
21 changed files with 329 additions and 37 deletions
@@ -138,6 +138,8 @@ import {
saveMaxAgentTurns,
loadMaxRetries,
saveMaxRetries,
loadSqlFileUploadMaxBytes,
saveSqlFileUploadMaxMb,
saveWebdavSyncSecretsPreference,
saveWebdavSavedPassword,
saveSnippetSavedToken,
@@ -2389,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") },
@@ -2399,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);
@@ -3847,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();
@@ -4154,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);
@@ -6971,26 +7021,6 @@ onUnmounted(() => {
<Switch id="query-result-max-rows-enabled" v-model="editQueryResultMaxRowsEnabled" :aria-label="t('settings.queryResultMaxRowsEnabled')" />
</div>
</div>
<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 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="infinite-scroll">
@@ -7468,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" />
@@ -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
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.",
@@ -6743,6 +6744,9 @@ export default {
queryResultMaxRowsTooSmall: "Must be at least the query page size ({pageSize}).",
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",
+4
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.",
@@ -6323,6 +6324,9 @@ export default withEnglishFallback({
queryResultMaxRowsTooSmall: "Debe ser al menos el tamaño de página de consulta ({pageSize}).",
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",
+4
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.",
@@ -6323,6 +6324,9 @@ export default withEnglishFallback({
queryResultMaxRowsTooSmall: "Deve essere almeno pari alle righe per pagina della query ({pageSize}).",
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",
+4
View File
@@ -6169,6 +6169,7 @@ export default withEnglishFallback({
appearanceTab: "外観",
navigationTab: "ナビゲーション",
dataTab: "データ",
sqlFileSizeTab: "SQLファイルサイズ",
tunnelsTab: "トンネル",
tunnelsDescription: "再利用可能な SSH / プロキシ / HTTP トンネル設定です。一度設定すれば、接続のトンネルタブからプロファイルを選択するだけで使えます。ここでの編集は、このプロファイルを使用するすべての接続に反映されます。",
tunnelsEmpty: "トンネルプロファイルがまだありません。ここで作成し、接続のトンネル設定で選択してください。",
@@ -6357,6 +6358,9 @@ export default withEnglishFallback({
queryResultMaxRowsTooSmall: "クエリのページ行数({pageSize})以上にしてください。",
externalSqlEditorMaxMb: "SQLエディターの最大ファイルサイズ",
externalSqlEditorMaxMbDescription: "このサイズを超えるSQLファイルはエディターではなくストリーミング実行ダイアログに回されます({min}~{max} MB)。",
webSqlFileUploadMaxMb: "Web SQLファイルの最大アップロードサイズ",
webSqlFileUploadMaxMbDescription: "Webサーバーがストリーミング実行に受け付ける最大のSQLファイルサイズ({min}~{max} MB)。",
sqlFileUploadMaxMbSaved: "Web SQLファイルのアップロードサイズを保存しました。",
showColumnCommentsInHeader: "列名の下にコメントを表示",
showColumnCommentsInHeaderDescription: "グリッド列名の直下にテーブル列コメントを表示します。",
showColumnTypesInHeader: "列名の下にデータ型を表示",
+4
View File
@@ -5866,6 +5866,7 @@ export default withEnglishFallback({
appearanceTab: "모양",
navigationTab: "탐색",
dataTab: "데이터",
sqlFileSizeTab: "SQL 파일 크기",
tunnelsTab: "터널",
tunnelsDescription: "재사용 가능한 SSH/프록시/HTTP 터널 설정입니다. 한 번 구성한 후 연결의 터널 탭에서 프로필을 선택하세요. 여기서의 편집은 이 프로필을 사용하는 모든 연결에 적용됩니다.",
tunnelsEmpty: "아직 터널 프로필이 없습니다. 여기서 만든 후 연결의 터널 설정에서 선택하세요.",
@@ -6056,6 +6057,9 @@ export default withEnglishFallback({
queryResultMaxRowsTooSmall: "쿼리 페이지 크기({pageSize}) 이상이어야 합니다.",
externalSqlEditorMaxMb: "SQL 편집기 최대 파일 크기",
externalSqlEditorMaxMbDescription: "이 크기를 초과하는 SQL 파일은 편집기 대신 스트리밍 실행 대화상자로 전달됩니다({min}–{max} MB).",
webSqlFileUploadMaxMb: "웹 SQL 파일 최대 업로드 크기",
webSqlFileUploadMaxMbDescription: "웹 서버가 스트리밍 실행에 허용하는 가장 큰 SQL 파일({min}–{max} MB).",
sqlFileUploadMaxMbSaved: "웹 SQL 파일 업로드 크기가 저장되었습니다.",
showColumnCommentsInHeader: "이름 아래에 컬럼 주석 표시",
showColumnCommentsInHeaderDescription: "그리드 컬럼 이름 바로 아래에 테이블 컬럼 주석을 표시합니다.",
showColumnTypesInHeader: "이름 아래에 컬럼 타입 표시",
+4
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.",
@@ -6325,6 +6326,9 @@ export default withEnglishFallback({
queryResultMaxRowsTooSmall: "Deve ser pelo menos o tamanho da página da consulta ({pageSize}).",
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",
+4
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.",
@@ -6627,6 +6628,9 @@ export default withEnglishFallback({
queryResultMaxRowsTooSmall: "En az sorgu sayfa boyutu ({pageSize}) kadar olmalıdır.",
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",
+4
View File
@@ -6523,6 +6523,7 @@ export default withEnglishFallback({
appearanceTab: "外观",
navigationTab: "导航",
dataTab: "数据",
sqlFileSizeTab: "SQL 文件大小",
tunnelsTab: "隧道维护",
tunnelsDescription: "可复用的 SSH / 代理 / HTTP 隧道配置。一次配置,之后在连接的隧道页签中选择档案即可;此处的修改会对所有使用该档案的连接生效。",
tunnelsEmpty: "还没有隧道档案。先在这里创建,然后在连接的隧道设置中选择使用。",
@@ -6726,6 +6727,9 @@ export default withEnglishFallback({
queryResultMaxRowsTooSmall: "不能小于查询结果默认每页行数({pageSize})。",
externalSqlEditorMaxMb: "SQL 编辑器最大文件大小",
externalSqlEditorMaxMbDescription: "超过此大小的 SQL 文件将转交流式执行 SQL 文件对话框而不是编辑器({min}–{max} MB)。",
webSqlFileUploadMaxMb: "Web SQL 文件最大上传大小",
webSqlFileUploadMaxMbDescription: "Web 服务器接受用于流式执行的最大 SQL 文件({min}–{max} MB)。",
sqlFileUploadMaxMbSaved: "Web SQL 文件上传大小已保存。",
showColumnCommentsInHeader: "在字段名下方显示注释",
showColumnCommentsInHeaderDescription: "把表字段注释直接显示在结果表头字段名下方。",
showColumnTypesInHeader: "在字段名下方显示数据类型",
+4
View File
@@ -5459,6 +5459,7 @@ export default withEnglishFallback({
securityTab: "安全",
aboutTab: "關於我們",
dataTab: "資料",
sqlFileSizeTab: "SQL 檔案大小",
tunnelsTab: "隧道維護",
tunnelsDescription: "可重複使用的 SSH / 代理 / HTTP 隧道設定。一次設定,之後在連線的隧道頁籤中選擇設定檔即可;此處的變更會套用到所有使用該設定檔的連線。",
tunnelsEmpty: "還沒有隧道設定檔。先在這裡建立,然後在連線的隧道設定中選擇使用。",
@@ -5638,6 +5639,9 @@ export default withEnglishFallback({
queryResultMaxRowsTooSmall: "不能小於查詢結果預設每頁列數({pageSize})。",
externalSqlEditorMaxMb: "SQL 編輯器最大檔案大小",
externalSqlEditorMaxMbDescription: "超過此大小的 SQL 檔案將轉交流式執行 SQL 檔案對話方塊而不是編輯器({min}–{max} MB)。",
webSqlFileUploadMaxMb: "Web SQL 檔案最大上傳大小",
webSqlFileUploadMaxMbDescription: "Web 伺服器接受用於串流執行之最大 SQL 檔案({min}–{max} MB)。",
sqlFileUploadMaxMbSaved: "Web SQL 檔案上傳大小已儲存。",
showColumnCommentsInHeader: "在欄位名稱下方顯示註解",
showColumnCommentsInHeaderDescription: "直接在資料表格欄位名稱下方顯示資料表欄位註解。",
showColumnTypesInHeader: "在欄位名稱下方顯示資料類型",
+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");
+13
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",
+8
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 });
}
@@ -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",
+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))
+19
View File
@@ -130,6 +130,25 @@ 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)
}
pub async fn load_sql_file_upload_max_bytes(State(state): State<Arc<WebState>>) -> Result<Json<u32>, AppError> {
let max_mb = state.app.storage.load_sql_file_upload_max_mb().await.map_err(AppError::from)?;
Ok(Json(max_mb.saturating_mul(1024 * 1024)))
}
#[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>,
+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() {