From 22c0317c713f1cc04f72d8864dfcd01d16782053 Mon Sep 17 00:00:00 2001 From: Mathias Date: Fri, 2 Oct 2026 17:22:41 +0200 Subject: [PATCH] fix(web): a body cut off mid-file is a 400; bidirectional controls are stripped from a title (second review) Co-Authored-By: Claude Sonnet 5.5 --- internal/web/uploads.go | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/internal/web/uploads.go b/internal/web/uploads.go index a54d29b..1d59e14 100644 --- a/internal/web/uploads.go +++ b/internal/web/uploads.go @@ -152,6 +152,10 @@ func (a *App) uploadFailure(w http.ResponseWriter, r *http.Request, userID strin a.logger().Info("upload rejected", "reason", string(uploads.ReasonTooLarge)) a.renderUploadsPage(w, r, userID, http.StatusRequestEntityTooLarge, "This file is larger than the upload limit.") + case errors.Is(err, io.ErrUnexpectedEOF): + // The sender hung up mid-file: theirs to retry, not a server fault. + a.logger().Info("upload rejected", "reason", "truncated") + a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "The upload was cut off before it finished. Please try again.") default: // Not err: it can carry the file's name, a spool path or a database value. a.logger().Error("upload failed", "kind", errkind.Of(err)) @@ -218,6 +222,12 @@ func titleFromFilename(name string) string { // uuidShape is the form of an upload id. Anything else cannot be one, so it is a 404. var uuidShape = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) +// isBidiControl reports the invisible characters that change text direction. Emoji joiners +// (ZWJ, ZWNJ) and variation selectors are not among them and are kept. +func isBidiControl(r rune) bool { + return r >= 0x202A && r <= 0x202E || r >= 0x2066 && r <= 0x2069 || r == 0x200E || r == 0x200F || r == 0x061C +} + // cleanTitle makes a title safe to store and show: invalid UTF-8 dropped, control // characters (NUL, newlines, escapes) turned into spaces, runs of space collapsed, trimmed // and clipped. A title is cosmetic; bad bytes in it must never fail an upload, and Postgres @@ -225,7 +235,10 @@ var uuidShape = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4 func cleanTitle(s string) string { s = strings.ToValidUTF8(s, "") s = strings.Map(func(r rune) rune { - if unicode.IsControl(r) { + switch { + case isBidiControl(r): + return -1 // dropped: U+202E would reverse the rest of the title on its owner's page + case unicode.IsControl(r): return ' ' } return r