Hotfix: strip invalid XML control chars from field values (FR-16)
ผู้ใช้ paste ข้อความที่มี vertical tab (0x0B) เข้าฟอร์ม — XML 1.0 ห้าม อักขระนี้ ทำให้ XmlWriter โยน ArgumentException ตอน save package และ หลุดเป็น 500 stack เต็มจอ (catch เดิมครอบแค่ OpenXmlPackageException) - DocxGenerationService.SanitizeForXml: ตัดอักขระที่ XML 1.0 ห้าม (0x00–0x1F ยกเว้น tab/LF/CR, lone surrogate, 0xFFFE/0xFFFF — คู่ surrogate ครบ เช่น อีโมจิ คงไว้) ที่จุดเขียนค่าทั้งสองของ ReplaceSdtContent → ครอบทั้ง generate และ generate-draft - Controllers: broad catch (ยกเว้น cancellation) → 502 ข้อความอ่านออก แทน 500 ดิบ สำหรับ exception ที่ไม่ได้ตั้งใจจาก OpenXML - Test: Draft_ValueWithInvalidXmlControlChars_IsStrippedNot500 (draft + generate v0) — reviewer พิสูจน์แล้วว่า fail จริงบนโค้ดก่อนแก้ - backend 85/85; QA reviewer PASS (1 round, minors 3: แก้ 1 ปฏิเสธ 2 พร้อมเหตุผล — บันทึกใน acceptance-review.md) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
70f71f4c5c
commit
b9b227926d
8 changed files with 96 additions and 7 deletions
|
|
@ -89,7 +89,8 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
|
|||
}
|
||||
|
||||
/// <summary>สร้าง template จาก BuildThaiTemplateDocx พร้อมตั้ง field defs (detail1 required, remark1 ทางเลือก)</summary>
|
||||
private async Task<Guid> CreateThaiTemplateAsync() {
|
||||
private async Task<Guid> CreateThaiTemplateAsync()
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
using var create = await client.PostAsync(
|
||||
"api/templates",
|
||||
|
|
@ -191,6 +192,35 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
|
|||
Assert.Equal(templateId.ToString(), TestDocx.ReadCustomProperty(fileBytes, AddInMetadata.TemplateIdPropertyName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Draft_ValueWithInvalidXmlControlChars_IsStrippedNot500()
|
||||
{
|
||||
// ผู้ใช้ paste ข้อความที่มีอักขระควบคุม (vertical tab 0x0B / form feed 0x0C) มาในค่า field —
|
||||
// XML 1.0 ห้ามอักขระพวกนี้ เดิม XmlWriter โยน ArgumentException ตอน save ทั้ง package กลายเป็น 500
|
||||
// ต้องตัดออกเงียบๆ แล้วสร้างไฟล์สำเร็จ (อักขระมองไม่เห็นอยู่แล้ว) — ครอบทั้ง draft และ generate v0
|
||||
using var client = fixture.CreateClient();
|
||||
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "paste control chars");
|
||||
var values = new Dictionary<string, string>(TestFlow.DefaultValues)
|
||||
{
|
||||
["position"] = "วิศวกร\u000Bซอฟต์\u000Cแวร์", // VT/FF ฝังเป็น escape กันอักขระล่องหนหายระหว่างแก้ไขไฟล์
|
||||
};
|
||||
|
||||
// ไฟล์ร่าง
|
||||
using var draft = await client.PostAsJsonAsync(
|
||||
$"api/templates/{templateId}/generate-draft", new { values }, IntegrationTestFixture.Json);
|
||||
Assert.Equal(HttpStatusCode.OK, draft.StatusCode);
|
||||
var link = (await draft.Content.ReadFromJsonAsync<DraftLinkResult>(IntegrationTestFixture.Json))!;
|
||||
using var browser = new HttpClient();
|
||||
var draftBytes = await (await browser.GetAsync(link.Url)).Content.ReadAsByteArrayAsync();
|
||||
Assert.Equal("วิศวกรซอฟต์แวร์", TestDocx.ReadSdtText(draftBytes, "position"));
|
||||
|
||||
// generate v0 ใช้ ReplaceSdtContent ตัวเดียวกัน — ตรวจซ้ำจุดเดียวกันผ่านอีก endpoint
|
||||
var doc = await TestFlow.GenerateDocumentAsync(client, templateId, values);
|
||||
using var download = await client.GetAsync(doc.DownloadUrl);
|
||||
var docBytes = await download.Content.ReadAsByteArrayAsync();
|
||||
Assert.Equal("วิศวกรซอฟต์แวร์", TestDocx.ReadSdtText(docBytes, "position"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Draft_LifecycleRule_Idempotent_AndSyncsRetentionDays()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -307,6 +307,13 @@ public class DocumentsController(
|
|||
template.Id, template.ObjectKey);
|
||||
return StatusCode(StatusCodes.Status502BadGateway, "ไฟล์ template เสียหาย (เปิดด้วย OpenXML ไม่ได้)");
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
// กันหน้า: OpenXML โยนอะไรก็ตามที่เราไม่ได้เจอมาก่อน — ให้เป็น 502 ที่อ่านออกแทน 500 stack เต็มจอ
|
||||
logger.LogError(ex, "Generate failed templateId={TemplateId} userId={UserId}",
|
||||
template.Id, currentUser.UserId);
|
||||
return StatusCode(StatusCodes.Status502BadGateway, "สร้างเอกสารไม่สำเร็จ (ไฟล์ template หรือค่าที่กรอกมีปัญหา)");
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
using (var hashStream = new MemoryStream(fileBytes))
|
||||
|
|
|
|||
|
|
@ -366,6 +366,14 @@ public class TemplatesController(
|
|||
template.Id, template.ObjectKey);
|
||||
return StatusCode(StatusCodes.Status502BadGateway, "ไฟล์ template เสียหาย (เปิดด้วย OpenXML ไม่ได้)");
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
// กันหน้า: OpenXML โยนอะไรก็ตามที่เราไม่ได้เจอมาก่อน (เช่น ArgumentException จากอักขระที่ XML ใช้ไม่ได้
|
||||
// ตอน v9 แรกๆ หลุดเป็น 500 stack เต็มจอ) — ให้เป็น 502 ที่อ่านออกแทน
|
||||
logger.LogError(ex, "Draft generation failed templateId={TemplateId} userId={UserId}",
|
||||
template.Id, currentUser.UserId);
|
||||
return StatusCode(StatusCodes.Status502BadGateway, "สร้างไฟล์ร่างไม่สำเร็จ (ไฟล์ template หรือค่าที่กรอกมีปัญหา)");
|
||||
}
|
||||
|
||||
// เก็บชั่วคราวใต้ drafts/ — lifecycle ลบให้เอง (Program.cs ตั้ง rule ตอน startup)
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
|
|
|||
|
|
@ -108,6 +108,47 @@ public class DocxGenerationService : IDocxGenerationService
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>ตัดอักขระที่ XML 1.0 ห้าม (เช่น vertical tab 0x0B จากการ paste ข้อความ) ออกจากค่าก่อนเขียนลงไฟล์ —
|
||||
/// XmlWriter โยน ArgumentException ตอน save ทั้ง package ถ้าปล่อยไว้ (XML รับได้เฉพาะ #x9/#xA/#xD/
|
||||
/// [#x20-#xD7FF]/[#xE000-#xFFFD]/คู่ surrogate ครบ) — ค่าที่ตัดไปเป็นอักขระที่มองไม่เห็นอยู่แล้ว</summary>
|
||||
private static string SanitizeForXml(string value)
|
||||
{
|
||||
// ทางเลือกให้เร็ว: ไม่มีอักขระต้องตัดก็คืนของเดิม (กรณีทั่วไป)
|
||||
var hasInvalid = false;
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (IsInvalidXmlChar(c))
|
||||
{
|
||||
hasInvalid = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasInvalid)
|
||||
return value;
|
||||
|
||||
var sb = new System.Text.StringBuilder(value.Length);
|
||||
for (var i = 0; i < value.Length; i++)
|
||||
{
|
||||
var c = value[i];
|
||||
if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1]))
|
||||
{
|
||||
sb.Append(c).Append(value[i + 1]); // คู่ surrogate ครบ = XML ใช้ได้ (เช่น อีโมจิ)
|
||||
i++;
|
||||
}
|
||||
else if (!IsInvalidXmlChar(c))
|
||||
{
|
||||
sb.Append(c);
|
||||
}
|
||||
}
|
||||
return sb.ToString();
|
||||
|
||||
// lone surrogate (คู่ไม่ครบ) จัดการเป็นคู่ที่ loop ด้านบน — ที่นี่ surrogate ทุกตัวถือ invalid
|
||||
static bool IsInvalidXmlChar(char c) =>
|
||||
(c < 0x20 && c is not '\t' and not '\n' and not '\r') // 0x00–0x1F ยกเว้น tab/LF/CR
|
||||
|| char.IsSurrogate(c)
|
||||
|| c == '\uFFFE' || c == '\uFFFF';
|
||||
}
|
||||
|
||||
/// <summary>แทนที่ข้อความใน SDT ที่ <b>run level</b> — เขียนค่าที่ Run ของ "ข้อความ placeholder จริง"
|
||||
/// (run ที่มีข้อความยาวสุด) เพื่อสืบทอด rPr ของมัน แล้วลบ Text ที่เหลือ ไม่ทับทั้ง paragraph
|
||||
/// (spec ส่วน 9 เพิ่มเติม v2 · v9: template ไทยมักแตก placeholder เป็น [ / ข้อความ / ] หลาย run —
|
||||
|
|
@ -127,7 +168,7 @@ public class DocxGenerationService : IDocxGenerationService
|
|||
// SDT ไม่มีข้อความเลย (เช่น ที่ว่างมา) — สร้าง Run ใหม่ใน paragraph แรก (block) หรือใน content ตรงๆ (run)
|
||||
// โดยสืบทอด rPr จาก sdtPr ถ้ามี (คือ formatting ที่ผู้แต่ง template กำหนดให้เนื้อหาของ control นี้ —
|
||||
// เช่นเดียวกับที่ Word ใช้ตอนผู้ใช้พิมพ์ลง control ไม่ใช่ปล่อยเป็น default font)
|
||||
var run = new Run(new Text(value) { Space = SpaceProcessingModeValues.Preserve });
|
||||
var run = new Run(new Text(SanitizeForXml(value)) { Space = SpaceProcessingModeValues.Preserve });
|
||||
if (sdtPr?.Elements<RunProperties>().FirstOrDefault() is { } sdtRunProperties)
|
||||
run.RunProperties = (RunProperties)sdtRunProperties.CloneNode(true);
|
||||
if (content is SdtContentBlock)
|
||||
|
|
@ -152,7 +193,7 @@ public class DocxGenerationService : IDocxGenerationService
|
|||
var donorText = texts.OrderByDescending(t => t.Text?.Length ?? 0).First();
|
||||
|
||||
// เขียนที่ Text ของ donor แล้วเก็บ space ไว้กรณีค่ามีช่องว่างหัว/ท้าย
|
||||
donorText.Text = value;
|
||||
donorText.Text = SanitizeForXml(value);
|
||||
donorText.Space = SpaceProcessingModeValues.Preserve;
|
||||
|
||||
// Text อื่นทั้งหมด (รวม donor ถ้ามีหลาย Text ใน run เดียวกัน ไม่เกิด) ลบทิ้ง — field = ค่าเดียว
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue