From 1cba2c4d32eab438eaf2665a0c9e48086b66ba3e Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 9 Nov 2026 22:50:07 +0100 Subject: [PATCH] fix: keep the Do oceny preview under cabana's partial cap WR-04: cut each long text of the review context view model to an escaped-byte budget, so a 20000-character answer of characters that html/template expands to 5 bytes can no longer push the partial over 64 KiB. Ordinary answers stay whole; a cut text gets a note pointing to quizzes_review_show. --- controllers/reviews.go | 78 ++++++++++++++++++++++++- controllers/reviews/_review_context.htm | 5 +- controllers/reviews_test.go | 55 +++++++++++++++++ lang/en/lang.yaml | 2 + lang/pl/lang.yaml | 2 + 5 files changed, 140 insertions(+), 2 deletions(-) diff --git a/controllers/reviews.go b/controllers/reviews.go index a400b0f..88d8df4 100644 --- a/controllers/reviews.go +++ b/controllers/reviews.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "git.golem15.com/golem15/sm-quizzes-plugin/classes" "git.golem15.com/golem15/sm-quizzes-plugin/graders" @@ -421,6 +422,61 @@ type reviewContextView struct { ReviewComment string Reviewed bool ReviewedAt string + // TextCut and Cut tell that the student's text, or another long text, + // was shortened to fit cabana's partial size cap (see fitEscaped). + TextCut bool + Cut bool +} + +// Escaped-byte budgets of the partial's long texts. cabana refuses a +// partial that renders to more than 64 KiB, and html/template turns one +// character into up to 5 bytes, so each text is cut to its budget measured +// as escaped (fitEscaped). Their sum leaves room for the markup and the +// translated labels; a long but ordinary answer (max_length up to 20000) +// fits whole, only pathological texts are cut. +const ( + budgetText = 32 << 10 + budgetQuestionText = 4 << 10 + budgetModelAnswer = 6 << 10 + budgetRubric = 6 << 10 + budgetFeedback = 4 << 10 + budgetComment = 4 << 10 + budgetError = 1 << 10 + budgetShort = 512 +) + +// fitEscaped cuts s so that it takes at most budget bytes once escaped by +// html/template, ending it with "…" when cut, and reports whether it cut. +func fitEscaped(s string, budget int) (string, bool) { + total := 0 + for _, r := range s { + total += escapedLen(r) + } + if total <= budget { + return s, false + } + used := 0 + for i, r := range s { + used += escapedLen(r) + if used > budget-len("…") { + return s[:i] + "…", true + } + } + return s, false +} + +// escapedLen is the most bytes html/template writes for r in text. +func escapedLen(r rune) int { + switch r { + case '"', '&', '\'', '+', '<', '>': + return 5 + case 0: + return len("\uFFFD") + } + if n := utf8.RuneLen(r); n > 0 { + return n + } + return 3 } // PartialData builds the review context of an answer from the review @@ -445,7 +501,8 @@ func (reviewsAdmin) PartialData(ctx context.Context, name string, record any) (a } // newReviewContextView copies a review detail into the partial's view -// model. +// model, each long text cut to its escaped-byte budget so the partial +// always fits cabana's size cap. func newReviewContextView(item *classes.ReviewDetail, reg *graders.Registry) reviewContextView { v := reviewContextView{ QuizTitle: item.QuizTitle, @@ -473,6 +530,25 @@ func newReviewContextView(item *classes.ReviewDetail, reg *graders.Registry) rev v.HasGraderPoints, v.GraderPoints = true, *item.GraderPoints } v.GraderLabel = graderLabel(reg, v.Grader) + v.Text, v.TextCut = fitEscaped(v.Text, budgetText) + for _, f := range []struct { + s *string + budget int + }{ + {&v.QuizTitle, budgetShort}, + {&v.StudentEmail, budgetShort}, + {&v.StudentName, budgetShort}, + {&v.QuestionText, budgetQuestionText}, + {&v.ModelAnswer, budgetModelAnswer}, + {&v.Rubric, budgetRubric}, + {&v.GraderFeedback, budgetFeedback}, + {&v.GradingError, budgetError}, + {&v.ReviewComment, budgetComment}, + } { + var cut bool + *f.s, cut = fitEscaped(*f.s, f.budget) + v.Cut = v.Cut || cut + } return v } diff --git a/controllers/reviews/_review_context.htm b/controllers/reviews/_review_context.htm index 8cd13c2..00d4e9d 100644 --- a/controllers/reviews/_review_context.htm +++ b/controllers/reviews/_review_context.htm @@ -1,5 +1,8 @@
{{with .Data}} +{{if .Cut}} +

{{trans "golem15.quizzes::lang.reviews.cut"}}

+{{end}}
{{trans "golem15.quizzes::lang.reviews.quiz"}}
{{.QuizTitle}}
@@ -16,7 +19,7 @@

{{.Rubric}}

{{end}}
{{trans "golem15.quizzes::lang.reviews.text"}} {{trans "golem15.quizzes::lang.reviews.max_length"}}: {{.MaxLength}}
-
{{.Text}}
+
{{.Text}}
{{if .TextCut}}

{{trans "golem15.quizzes::lang.reviews.text_cut"}}

{{end}}
{{trans "golem15.quizzes::lang.reviews.status"}}
{{trans .StatusLabel}}
{{if .Grader}} diff --git a/controllers/reviews_test.go b/controllers/reviews_test.go index d0fc518..e00e9c2 100644 --- a/controllers/reviews_test.go +++ b/controllers/reviews_test.go @@ -209,3 +209,58 @@ func TestReviewContextEscapes(t *testing.T) { t.Errorf("student text not escaped: %s", html) } } + +func TestFitEscaped(t *testing.T) { + if s, cut := fitEscaped("zażółć", 12); s != "zażółć" || cut { + t.Errorf("a text within budget = %q, %v", s, cut) + } + s, cut := fitEscaped(strings.Repeat(`"`, 10), 23) + if !cut || s != `""""`+"…" { + t.Errorf("quotes cut = %q, %v; want four quotes and an ellipsis", s, cut) + } + if s, cut := fitEscaped("abc", 0); !cut || s != "…" { + t.Errorf("zero budget = %q, %v", s, cut) + } +} + +// TestReviewContextFitsPartialCap renders the partial with every text at +// its worst case (characters html/template turns into 5 bytes) and checks +// the output stays under cabana's 64 KiB partial cap. +func TestReviewContextFitsPartialCap(t *testing.T) { + src, err := os.ReadFile("reviews/_review_context.htm") + if err != nil { + t.Fatal(err) + } + // A translation longer than any real label. + long := strings.Repeat("ą", 100) + tpl, err := template.New("review_context").Funcs(template.FuncMap{"trans": func(string) string { return long }}).Parse(string(src)) + if err != nil { + t.Fatalf("parse: %v", err) + } + worst := func(n int) *string { s := strings.Repeat(`"&'`, n); return &s } + grader := "ai" + gp := 1.5 + v := newReviewContextView(&classes.ReviewDetail{ + ReviewRow: classes.ReviewRow{ + Status: "reviewed", QuizTitle: *worst(300), Email: *worst(300), Name: worst(300), Surname: worst(300), + QuestionText: *worst(10000), Text: *worst(20000), GraderPoints: &gp, + }, + AnswerText: worst(10000), Rubric: worst(10000), Grader: &grader, + GraderFeedback: worst(2000), GradingError: worst(500), ReviewComment: worst(2000), + }, nil) + if !v.TextCut || !v.Cut { + t.Fatalf("worst-case texts not cut: TextCut=%v Cut=%v", v.TextCut, v.Cut) + } + var out bytes.Buffer + if err := tpl.Execute(&out, map[string]any{"Data": v}); err != nil { + t.Fatalf("execute: %v", err) + } + if n := out.Len(); n > 64<<10 { + t.Errorf("partial renders to %d bytes, over the 64 KiB cap", n) + } + + plain := strings.Repeat("Długa, zwykła odpowiedź studenta. ", 580) + if v := newReviewContextView(&classes.ReviewDetail{ReviewRow: classes.ReviewRow{Text: plain}}, nil); v.TextCut || v.Text != plain { + t.Error("an ordinary answer near 20000 characters was cut") + } +} diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index f03319a..599265a 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -129,5 +129,7 @@ reviews: already_reviewed: This answer is already verified. grader_manual: This quiz is graded manually; nothing can grade the answer again. attempt_open: The test is still in progress; review this answer once it ends. + text_cut: The answer is too long to show here in full; the quizzes_review_show tool shows all of it. + cut: Some long texts are shortened on this screen; the quizzes_review_show tool shows them in full. not_found: This answer was not found. not_created: Open answers come from students; they cannot be added or deleted here. diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index a467153..c56695f 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -129,5 +129,7 @@ reviews: already_reviewed: Ta odpowiedź jest już zweryfikowana. grader_manual: Ten quiz jest oceniany ręcznie; nie ma kto ocenić odpowiedzi ponownie. attempt_open: Test wciąż trwa; oceń tę odpowiedź po jego zakończeniu. + text_cut: Odpowiedź jest zbyt długa, by pokazać ją tu w całości; całą pokazuje narzędzie quizzes_review_show. + cut: Część długich tekstów jest tu skrócona; w całości pokazuje je narzędzie quizzes_review_show. not_found: Nie znaleziono tej odpowiedzi. not_created: Odpowiedzi otwarte pochodzą od studentów; tutaj nie można ich dodawać ani usuwać.