fix(editor): bound dangerous SQL preview size
This commit is contained in:
parent
1cf594eac3
commit
f6bba1cb6b
|
|
@ -1,12 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { AlertTriangle, Loader2, TextWrap } from "@lucide/vue";
|
||||
import { AlertTriangle, Check, Copy, Loader2, TextWrap } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { createBoundedTextPreview } from "@/lib/common/boundedTextPreview";
|
||||
|
||||
const DANGER_PREVIEW_MAX_CHARACTERS = 8192;
|
||||
const DANGER_PREVIEW_MAX_LINES = 200;
|
||||
|
||||
const { t } = useI18n();
|
||||
const { highlight } = useSqlHighlighter();
|
||||
|
|
@ -14,6 +19,7 @@ const { highlight } = useSqlHighlighter();
|
|||
const open = defineModel<boolean>("open", { default: false });
|
||||
const suppressFuturePrompts = defineModel<boolean>("suppressFuturePrompts", { default: false });
|
||||
const wrap = ref(false);
|
||||
const copied = ref(false);
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
|
|
@ -47,7 +53,10 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const code = computed(() => props.details || props.sql);
|
||||
const highlightedCode = computed(() => highlight(code.value));
|
||||
// Keep the confirmation payload intact, but never feed an unbounded script to Shiki or the DOM.
|
||||
const preview = computed(() => createBoundedTextPreview(code.value, { maxCharacters: DANGER_PREVIEW_MAX_CHARACTERS, maxLines: DANGER_PREVIEW_MAX_LINES }));
|
||||
const highlightedHead = computed(() => highlight(preview.value.head));
|
||||
const highlightedTail = computed(() => highlight(preview.value.tail));
|
||||
const dialogOpen = computed({
|
||||
get: () => open.value,
|
||||
set: (value) => {
|
||||
|
|
@ -61,6 +70,14 @@ function onConfirm() {
|
|||
if (props.closeOnConfirm) open.value = false;
|
||||
emit("confirm");
|
||||
}
|
||||
|
||||
async function copyFullCode() {
|
||||
await copyToClipboard(code.value);
|
||||
copied.value = true;
|
||||
window.setTimeout(() => {
|
||||
copied.value = false;
|
||||
}, 1500);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -78,10 +95,22 @@ function onConfirm() {
|
|||
<p v-if="detailsText" class="text-xs text-muted-foreground mb-3 whitespace-pre-line">{{ detailsText }}</p>
|
||||
<slot name="options" />
|
||||
<div v-if="code" class="relative">
|
||||
<Button variant="ghost" size="icon-xs" class="absolute top-1 right-1 z-10 h-6 w-6" :class="wrap ? 'text-foreground bg-accent' : 'text-muted-foreground'" :title="t('dangerDialog.wrapLines')" @click="wrap = !wrap">
|
||||
<TextWrap class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<pre class="text-xs bg-muted px-3 pt-3 pb-3 pr-7 rounded overflow-auto max-h-40 min-w-0 font-mono" :class="wrap ? 'whitespace-pre-wrap' : 'whitespace-pre'" v-html="highlightedCode" />
|
||||
<div class="absolute top-1 right-1 z-10 flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="icon-xs" class="h-6 w-6 text-muted-foreground" :title="t('dangerDialog.copyFullText')" @click="copyFullCode">
|
||||
<Check v-if="copied" class="h-3.5 w-3.5 text-emerald-600" />
|
||||
<Copy v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-xs" class="h-6 w-6" :class="wrap ? 'text-foreground bg-accent' : 'text-muted-foreground'" :title="t('dangerDialog.wrapLines')" @click="wrap = !wrap">
|
||||
<TextWrap class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div data-native-clipboard data-testid="danger-code-preview" class="text-xs bg-muted px-3 pt-3 pb-3 pr-14 rounded overflow-auto max-h-40 min-w-0 font-mono" :class="wrap ? 'whitespace-pre-wrap' : 'whitespace-pre'">
|
||||
<pre class="font-inherit whitespace-inherit" v-html="highlightedHead" />
|
||||
<div v-if="preview.truncated" data-testid="danger-preview-truncated" class="my-2 rounded border border-border/70 bg-background/70 px-2 py-1.5 text-center text-[11px] leading-4 text-muted-foreground whitespace-normal">
|
||||
{{ t("dangerDialog.previewTruncated", { lines: preview.omittedLines.toLocaleString(), characters: preview.omittedCharacters.toLocaleString() }) }}
|
||||
</div>
|
||||
<pre v-if="preview.tail" class="font-inherit whitespace-inherit" v-html="highlightedTail" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showSuppressToggle" class="mt-3 flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<Label for="danger-confirm-suppress" class="text-sm leading-5">{{ suppressToggleLabel || t("dangerDialog.suppressFuturePrompts") }}</Label>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
|
||||
const DANGER_PREVIEW_MAX_CHARACTERS = 8192;
|
||||
const DANGER_PREVIEW_MAX_LINES = 200;
|
||||
const highlight = vi.fn((sql: string) => `<span>${sql}</span>`);
|
||||
|
||||
vi.mock("@/composables/useSqlHighlighter", () => ({
|
||||
useSqlHighlighter: () => ({ highlight }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/common/clipboard", () => ({
|
||||
copyToClipboard: vi.fn(),
|
||||
}));
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
async function mountDialog(sql: string) {
|
||||
const state = reactive({ open: true });
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(DangerConfirmDialog, {
|
||||
open: state.open,
|
||||
sql,
|
||||
"onUpdate:open": (value: boolean) => {
|
||||
state.open = value;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
app.use(i18n);
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
highlight.mockClear();
|
||||
vi.mocked(copyToClipboard).mockClear();
|
||||
});
|
||||
|
||||
describe("DangerConfirmDialog SQL preview", () => {
|
||||
it("fully highlights short SQL without a truncation notice", async () => {
|
||||
const sql = "DROP TABLE IF EXISTS users;";
|
||||
|
||||
await mountDialog(sql);
|
||||
|
||||
expect(highlight).toHaveBeenCalledOnce();
|
||||
expect(highlight).toHaveBeenCalledWith(sql);
|
||||
expect(document.body.querySelector('[data-testid="danger-preview-truncated"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("highlights only bounded head and tail fragments for huge SQL", async () => {
|
||||
const sql = Array.from({ length: 40_000 }, (_, index) => `INSERT INTO t VALUES (${index});`).join("\n");
|
||||
|
||||
await mountDialog(sql);
|
||||
|
||||
const highlightedCharacters = highlight.mock.calls.reduce((total, [fragment]) => total + fragment.length, 0);
|
||||
expect(highlight).toHaveBeenCalledTimes(2);
|
||||
expect(highlightedCharacters).toBeLessThanOrEqual(DANGER_PREVIEW_MAX_CHARACTERS);
|
||||
expect(highlight.mock.calls.flatMap(([fragment]) => fragment.split("\n"))).toHaveLength(DANGER_PREVIEW_MAX_LINES);
|
||||
expect(document.body.querySelector('[data-testid="danger-preview-truncated"]')?.textContent).toContain("Preview truncated");
|
||||
});
|
||||
|
||||
it("copies the full SQL instead of the bounded preview", async () => {
|
||||
const sql = Array.from({ length: 40_000 }, (_, index) => `INSERT INTO t VALUES (${index});`).join("\n");
|
||||
|
||||
await mountDialog(sql);
|
||||
const copyButton = Array.from(document.body.querySelectorAll("button")).find((button) => button.title === "Copy full text");
|
||||
copyButton?.click();
|
||||
await nextTick();
|
||||
|
||||
expect(copyToClipboard).toHaveBeenCalledWith(sql);
|
||||
});
|
||||
});
|
||||
|
|
@ -294,6 +294,35 @@ describe("useSqlExecution", () => {
|
|||
expect(addHistory).toHaveBeenCalledWith(expect.objectContaining({ success: false, error: "relation does not exist" }));
|
||||
});
|
||||
|
||||
it("keeps the full dangerous script pending and executes it unchanged after confirmation", async () => {
|
||||
const activeTab = ref<QueryTab | undefined>(queryTab("app"));
|
||||
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
|
||||
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
|
||||
const queryStore = useQueryStore();
|
||||
const sql = Array.from({ length: 40_000 }, (_, index) => `${index === 0 ? "DROP TABLE IF EXISTS t;" : ""} INSERT INTO t VALUES (${index});`).join("\n");
|
||||
const executeCurrentSql = vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => {
|
||||
if (activeTab.value) activeTab.value.result = { columns: [], rows: [], affected_rows: 40_000, execution_time_ms: 1 };
|
||||
});
|
||||
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
|
||||
|
||||
const execution = useSqlExecution({
|
||||
activeTab: computed(() => activeTab.value),
|
||||
activeConnection: computed(() => activeConnection.value),
|
||||
executableSql: computed(() => sql),
|
||||
activeOutputView,
|
||||
});
|
||||
|
||||
await execution.tryExecute();
|
||||
|
||||
expect(execution.showDangerDialog.value).toBe(true);
|
||||
expect(execution.pendingDangerSql.value).toBe(sql);
|
||||
expect(executeCurrentSql).not.toHaveBeenCalled();
|
||||
|
||||
await execution.onDangerConfirm();
|
||||
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(sql, {});
|
||||
});
|
||||
|
||||
it("requires production confirmation even when ordinary danger prompts are disabled", async () => {
|
||||
const activeTab = ref<QueryTab | undefined>(queryTab("prod_app"));
|
||||
const activeConnection = ref<ConnectionConfig | undefined>({ ...connection("mysql"), production_databases: ["prod_app"] });
|
||||
|
|
|
|||
|
|
@ -2860,6 +2860,8 @@ export default {
|
|||
redisCommandMessage: "This Redis command may modify data and cannot be undone automatically. Continue?",
|
||||
suppressFuturePrompts: "Do not ask again for dangerous SQL",
|
||||
wrapLines: "Toggle word wrap",
|
||||
copyFullText: "Copy full text",
|
||||
previewTruncated: "Preview truncated: {characters} characters across {lines} lines omitted. Copy still includes the full text.",
|
||||
deleteMessage: "This delete operation may be irreversible. Continue?",
|
||||
deleteConfirm: "Confirm Delete",
|
||||
deleteRowMessage: "This row will be marked for deletion and removed from the database after saving. Continue?",
|
||||
|
|
|
|||
|
|
@ -2714,6 +2714,8 @@ export default withEnglishFallback({
|
|||
redisCommandMessage: "Este comando de Redis puede modificar datos y no se puede deshacer automáticamente. ¿Continuar?",
|
||||
suppressFuturePrompts: "No volver a preguntar para SQL peligroso",
|
||||
wrapLines: "Alternar ajuste de línea",
|
||||
copyFullText: "Copiar texto completo",
|
||||
previewTruncated: "Vista previa truncada: se omitieron {lines} líneas y {characters} caracteres. La copia incluye el texto completo.",
|
||||
deleteMessage: "Esta operación de eliminación puede ser irreversible. ¿Continuar?",
|
||||
deleteConfirm: "Confirmar eliminación",
|
||||
deleteRowMessage: "Esta fila quedará marcada para eliminación y se borrará de la base de datos al guardar. ¿Continuar?",
|
||||
|
|
|
|||
|
|
@ -2712,6 +2712,8 @@ export default withEnglishFallback({
|
|||
redisCommandMessage: "Questo comando Redis può modificare i dati e non può essere annullato automaticamente. Continuare?",
|
||||
suppressFuturePrompts: "Non chiedere più per SQL pericolosi",
|
||||
wrapLines: "Attiva/disattiva ritorno a capo",
|
||||
copyFullText: "Copia testo completo",
|
||||
previewTruncated: "Anteprima troncata: omesse {lines} righe e {characters} caratteri. La copia include il testo completo.",
|
||||
deleteMessage: "Questa operazione di eliminazione potrebbe essere irreversibile. Continuare?",
|
||||
deleteConfirm: "Conferma Eliminazione",
|
||||
deleteRowMessage: "Questa riga verrà contrassegnata per l'eliminazione e rimossa dal database dopo il salvataggio. Continuare?",
|
||||
|
|
|
|||
|
|
@ -2713,6 +2713,8 @@ export default withEnglishFallback({
|
|||
redisCommandMessage: "このRedisコマンドはデータを変更し、自動的に元に戻せない可能性があります。続行しますか?",
|
||||
suppressFuturePrompts: "危険なSQLの確認を今後表示しない",
|
||||
wrapLines: "折り返し表示を切り替え",
|
||||
copyFullText: "全文をコピー",
|
||||
previewTruncated: "プレビューを省略しました: {lines} 行、{characters} 文字を省略。コピーには全文が含まれます。",
|
||||
deleteMessage: "この削除操作は元に戻せない可能性があります。続行しますか?",
|
||||
deleteConfirm: "削除を確認",
|
||||
deleteRowMessage: "この行は削除対象としてマークされ、保存後にデータベースから削除されます。続行しますか?",
|
||||
|
|
|
|||
|
|
@ -2714,6 +2714,8 @@ export default withEnglishFallback({
|
|||
redisCommandMessage: "Este comando Redis pode modificar dados e não pode ser desfeito automaticamente. Continuar?",
|
||||
suppressFuturePrompts: "Não perguntar novamente para SQL perigoso",
|
||||
wrapLines: "Alternar quebra de linha",
|
||||
copyFullText: "Copiar texto completo",
|
||||
previewTruncated: "Visualização truncada: {lines} linhas e {characters} caracteres omitidos. A cópia inclui o texto completo.",
|
||||
deleteMessage: "Esta operação de exclusão pode ser irreversível. Continuar?",
|
||||
deleteConfirm: "Confirmar exclusão",
|
||||
deleteRowMessage: "Esta linha será marcada para exclusão e removida do banco de dados após salvar. Continuar?",
|
||||
|
|
|
|||
|
|
@ -2850,6 +2850,8 @@ export default withEnglishFallback({
|
|||
redisCommandMessage: "此 Redis 命令可能会修改数据,且无法自动撤销,确认要继续吗?",
|
||||
suppressFuturePrompts: "以后执行危险 SQL 不再提示",
|
||||
wrapLines: "切换自动换行",
|
||||
copyFullText: "复制完整内容",
|
||||
previewTruncated: "预览已截断:省略 {lines} 行、{characters} 个字符。复制仍会包含完整内容。",
|
||||
deleteMessage: "此删除操作可能不可逆,确认要继续吗?",
|
||||
deleteConfirm: "确认删除",
|
||||
deleteRowMessage: "此行将被标记为删除,保存后会从数据库删除,确认要继续吗?",
|
||||
|
|
|
|||
|
|
@ -2531,6 +2531,8 @@ export default withEnglishFallback({
|
|||
redisCommandMessage: "此 Redis 命令可能會修改資料,且無法自動復原,確認要繼續嗎?",
|
||||
suppressFuturePrompts: "之後執行危險 SQL 不再提示",
|
||||
wrapLines: "切換自動換行",
|
||||
copyFullText: "複製完整內容",
|
||||
previewTruncated: "預覽已截斷:省略 {lines} 行、{characters} 個字元。複製仍會包含完整內容。",
|
||||
deleteMessage: "此刪除操作可能不可逆,確認要繼續嗎?",
|
||||
deleteConfirm: "確認刪除",
|
||||
deleteRowMessage: "此列將被標記為刪除,儲存後會從資料庫刪除,確認要繼續嗎?",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { createBoundedTextPreview } from "@/lib/common/boundedTextPreview";
|
||||
|
||||
describe("createBoundedTextPreview", () => {
|
||||
it("keeps short text unchanged", () => {
|
||||
const sql = "DROP TABLE IF EXISTS users;\nSELECT 1;";
|
||||
|
||||
expect(createBoundedTextPreview(sql, { maxCharacters: 8192, maxLines: 200 })).toEqual({
|
||||
head: sql,
|
||||
tail: "",
|
||||
truncated: false,
|
||||
omittedCharacters: 0,
|
||||
omittedLines: 0,
|
||||
totalCharacters: sql.length,
|
||||
totalLines: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds a 40k-line preview by both characters and lines", () => {
|
||||
const sql = Array.from({ length: 40_000 }, (_, index) => `INSERT INTO t VALUES (${index});`).join("\n");
|
||||
const preview = createBoundedTextPreview(sql, { maxCharacters: 8192, maxLines: 200 });
|
||||
|
||||
expect(preview.truncated).toBe(true);
|
||||
expect(preview.head.length + preview.tail.length).toBeLessThanOrEqual(8192);
|
||||
expect(preview.head.split("\n").length + preview.tail.split("\n").length).toBeLessThanOrEqual(200);
|
||||
expect(preview.head).toContain("VALUES (0)");
|
||||
expect(preview.tail).toContain("VALUES (39999)");
|
||||
expect(preview.omittedCharacters).toBeGreaterThan(1_000_000);
|
||||
expect(preview.omittedLines).toBeGreaterThanOrEqual(39_800);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
export interface BoundedTextPreviewOptions {
|
||||
maxCharacters: number;
|
||||
maxLines: number;
|
||||
}
|
||||
|
||||
export interface BoundedTextPreview {
|
||||
head: string;
|
||||
tail: string;
|
||||
truncated: boolean;
|
||||
omittedCharacters: number;
|
||||
omittedLines: number;
|
||||
totalCharacters: number;
|
||||
totalLines: number;
|
||||
}
|
||||
|
||||
function countLines(text: string): number {
|
||||
let lines = 1;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
if (text.charCodeAt(index) === 10) lines += 1;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function clampCodePointEnd(text: string, end: number): number {
|
||||
if (end <= 0 || end >= text.length) return end;
|
||||
const previous = text.charCodeAt(end - 1);
|
||||
const current = text.charCodeAt(end);
|
||||
return previous >= 0xd800 && previous <= 0xdbff && current >= 0xdc00 && current <= 0xdfff ? end - 1 : end;
|
||||
}
|
||||
|
||||
function clampCodePointStart(text: string, start: number): number {
|
||||
if (start <= 0 || start >= text.length) return start;
|
||||
const previous = text.charCodeAt(start - 1);
|
||||
const current = text.charCodeAt(start);
|
||||
return previous >= 0xd800 && previous <= 0xdbff && current >= 0xdc00 && current <= 0xdfff ? start + 1 : start;
|
||||
}
|
||||
|
||||
function headBoundary(text: string, maxCharacters: number, maxLines: number): number {
|
||||
const characterBoundary = Math.min(text.length, maxCharacters);
|
||||
let lines = 1;
|
||||
for (let index = 0; index < characterBoundary; index += 1) {
|
||||
if (text.charCodeAt(index) !== 10) continue;
|
||||
lines += 1;
|
||||
if (lines > maxLines) return index;
|
||||
}
|
||||
return clampCodePointEnd(text, characterBoundary);
|
||||
}
|
||||
|
||||
function tailBoundary(text: string, maxCharacters: number, maxLines: number): number {
|
||||
const characterBoundary = Math.max(0, text.length - maxCharacters);
|
||||
let lines = 1;
|
||||
for (let index = text.length - 1; index >= characterBoundary; index -= 1) {
|
||||
if (text.charCodeAt(index) !== 10) continue;
|
||||
lines += 1;
|
||||
if (lines > maxLines) return index + 1;
|
||||
}
|
||||
return clampCodePointStart(text, characterBoundary);
|
||||
}
|
||||
|
||||
export function createBoundedTextPreview(text: string, options: BoundedTextPreviewOptions): BoundedTextPreview {
|
||||
const maxCharacters = Math.max(2, Math.floor(options.maxCharacters));
|
||||
const maxLines = Math.max(2, Math.floor(options.maxLines));
|
||||
const totalLines = countLines(text);
|
||||
|
||||
if (text.length <= maxCharacters && totalLines <= maxLines) {
|
||||
return {
|
||||
head: text,
|
||||
tail: "",
|
||||
truncated: false,
|
||||
omittedCharacters: 0,
|
||||
omittedLines: 0,
|
||||
totalCharacters: text.length,
|
||||
totalLines,
|
||||
};
|
||||
}
|
||||
|
||||
const headEnd = headBoundary(text, Math.ceil(maxCharacters / 2), Math.ceil(maxLines / 2));
|
||||
const tailStart = Math.max(headEnd, tailBoundary(text, Math.floor(maxCharacters / 2), Math.floor(maxLines / 2)));
|
||||
const head = text.slice(0, headEnd);
|
||||
const tail = text.slice(tailStart);
|
||||
const visibleLines = countLines(head) + (tail ? countLines(tail) : 0);
|
||||
|
||||
return {
|
||||
head,
|
||||
tail,
|
||||
truncated: true,
|
||||
omittedCharacters: tailStart - headEnd,
|
||||
omittedLines: Math.max(0, totalLines - visibleLines),
|
||||
totalCharacters: text.length,
|
||||
totalLines,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue