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.
This commit is contained in:
Jakub Zych
2026-11-09 22:50:07 +01:00
parent bb18242af9
commit 1cba2c4d32
5 changed files with 140 additions and 2 deletions

View File

@@ -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
}

View File

@@ -1,5 +1,8 @@
<div class="quizzes-review">
{{with .Data}}
{{if .Cut}}
<p class="help-block">{{trans "golem15.quizzes::lang.reviews.cut"}}</p>
{{end}}
<dl>
<dt>{{trans "golem15.quizzes::lang.reviews.quiz"}}</dt>
<dd>{{.QuizTitle}}</dd>
@@ -16,7 +19,7 @@
<dd><p class="whitespace-pre-wrap">{{.Rubric}}</p></dd>
{{end}}
<dt>{{trans "golem15.quizzes::lang.reviews.text"}} <span class="help-block">{{trans "golem15.quizzes::lang.reviews.max_length"}}: {{.MaxLength}}</span></dt>
<dd><blockquote class="whitespace-pre-wrap">{{.Text}}</blockquote></dd>
<dd><blockquote class="whitespace-pre-wrap">{{.Text}}</blockquote>{{if .TextCut}}<p class="help-block">{{trans "golem15.quizzes::lang.reviews.text_cut"}}</p>{{end}}</dd>
<dt>{{trans "golem15.quizzes::lang.reviews.status"}}</dt>
<dd><strong>{{trans .StatusLabel}}</strong></dd>
{{if .Grader}}

View File

@@ -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")
}
}

View File

@@ -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.

View File

@@ -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ć.