v0.308.0: controller batch 2026-10-12 — clear-address warning (R-922), secret sweep over every page (R-255), read_failed (R-266), no NVMe heat band (R-333), share globe (R-577), Hungarian dates and numbers (R-562), lost-drive option pinned (R-521)
Operator rulings 2026-10-12, 09 §3 decisions 196, 204-207, 223, 227. MinAgent 0.131.0 unchanged. R-255's sweep found one leak, fixed here: an optional-config secret_input was rendered into its settings page; it is now revealed on demand. Hungarian parity fixtures re-captured once for R-562. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -567,7 +567,7 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
|
||||
dp.NotAllowedNote = s.msg(r, "deploy.path_not_allowed")
|
||||
}
|
||||
if di := system.GetDiskUsage(sp.Path); di != nil {
|
||||
dp.FreeHuman = formatFreeSpace(di.AvailGB)
|
||||
dp.FreeHuman = formatFreeSpace(s.langFor(r), di.AvailGB)
|
||||
if di.TotalGB > 0 {
|
||||
dp.FreePercent = 100 - system.DFUsedPercent(di.UsedGB, di.AvailGB) // R-889: df's number
|
||||
}
|
||||
@@ -601,8 +601,8 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
|
||||
if !alreadyDeployed {
|
||||
if hr := system.GetDockerVolumeHeadroom(); hr.OK {
|
||||
data["DockerBelowReserve"] = hr.BelowReserve
|
||||
data["DockerFreeHuman"] = formatFreeSpace(hr.AvailGB)
|
||||
data["DockerReserveHuman"] = formatFreeSpace(hr.ReserveGB)
|
||||
data["DockerFreeHuman"] = formatFreeSpace(s.langFor(r), hr.AvailGB)
|
||||
data["DockerReserveHuman"] = formatFreeSpace(s.langFor(r), hr.ReserveGB)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -889,7 +889,7 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
|
||||
// in the REQUEST's language (a finished sentence passed as page data must be rendered per request,
|
||||
// never taken from a Hungarian store — the composed-sentence class, R-573/R-590/R-596/R-598).
|
||||
if found.Deployed && found.AppConfig != nil && found.AppConfig.LastUpdateUndone != nil {
|
||||
data["UpdateUndoneLine"] = s.msg(r, "app_info.update_undone", found.Name, undoneWhen(found.AppConfig.LastUpdateUndone.At))
|
||||
data["UpdateUndoneLine"] = s.msg(r, "app_info.update_undone", found.Name, undoneWhen(s.langFor(r), found.AppConfig.LastUpdateUndone.At))
|
||||
}
|
||||
// v0.271.0 (`09` §6.4 part 7): a SUCCESSFUL automatic update is told on the page only — no mail.
|
||||
// An undone or held automatic step keeps the lines above (they already say it), so this line is for
|
||||
@@ -897,7 +897,7 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
|
||||
// a restore makes it history, and the page stops saying it).
|
||||
if found.Deployed && found.AppConfig != nil && found.AppConfig.LastAutoUpdate != nil &&
|
||||
found.AppConfig.LastAutoUpdate.Outcome == stacks.LegOutcomeDone && sameImageMap(found.AppConfig.LastAutoUpdate.To, found.AppConfig.PinnedImages) {
|
||||
data["AutoUpdateLine"] = s.msg(r, "app_info.auto_update_done", undoneWhen(found.AppConfig.LastAutoUpdate.At))
|
||||
data["AutoUpdateLine"] = s.msg(r, "app_info.auto_update_done", undoneWhen(s.langFor(r), found.AppConfig.LastAutoUpdate.At))
|
||||
}
|
||||
|
||||
// R-621: a held app's own log, kept by the hold before its containers were removed, is reachable
|
||||
@@ -1037,7 +1037,7 @@ func (s *Server) backupsOffboxData(data map[string]interface{}, lang string) {
|
||||
}
|
||||
if s.backupMgr != nil && offboxTgt != nil && offboxTgt.QuotaGB > 0 && s.backupMgr.OffboxConfigured() {
|
||||
if f := s.backupMgr.OffsiteFitForPage(); !f.At.IsZero() && !f.Fits {
|
||||
data["OffsiteFitTotal"] = appbackup.HumanizeBytes(f.TotalBytes)
|
||||
data["OffsiteFitTotal"] = i18n.LocalizeDecimals(lang, appbackup.HumanizeBytes(f.TotalBytes))
|
||||
data["OffsiteFitQuota"] = offboxTgt.QuotaGB
|
||||
var top []string
|
||||
for i, a := range f.Apps {
|
||||
@@ -1048,7 +1048,7 @@ func (s *Server) backupsOffboxData(data map[string]interface{}, lang string) {
|
||||
if st, ok := s.stackMgr.GetStack(a.Stack); ok && st.Meta.DisplayName != "" {
|
||||
name = st.Meta.DisplayName
|
||||
}
|
||||
top = append(top, name+" ("+appbackup.HumanizeBytes(a.Bytes)+")")
|
||||
top = append(top, name+" ("+i18n.LocalizeDecimals(lang, appbackup.HumanizeBytes(a.Bytes))+")")
|
||||
}
|
||||
data["OffsiteFitTop"] = strings.Join(top, ", ")
|
||||
}
|
||||
@@ -1164,7 +1164,7 @@ func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if st := s.backupMgr.AbandonStatus(); st.Active {
|
||||
data["AbandonActive"] = true
|
||||
data["AbandonDaysLeft"] = st.DaysLeft
|
||||
data["AbandonDate"] = st.DueAt.Format("2006-01-02")
|
||||
data["AbandonDate"] = i18n.FormatDate(s.langFor(r), st.DueAt)
|
||||
// R-365: DaysLeft is a ceiling, so 0 means the due moment has PASSED (the terminal step waits
|
||||
// for the next daily sweep, up to ~29 h). The future-tense „… napján véglegesen töröljük" then
|
||||
// named a date in the past; the overdue sentence says the deletion is due and when it runs.
|
||||
@@ -1685,7 +1685,7 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus, lang string
|
||||
row.Tier2CopyDate, row.Tier2CopyDateProven = pkgDate, rp.CopyDateProven
|
||||
row.Tier2UnitConfirm = s.tier2UnitConfirmWithStaleness(pkgDate, row.Tier2CopyDateProven, stale)
|
||||
if stale && pkgDate != "" {
|
||||
row.Tier2UnitStaleNotice = s.msgLang(lang, tier2UnitStaleNoticeFmtKey, fmtRFC3339Local(pkgDate))
|
||||
row.Tier2UnitStaleNotice = s.msgLang(lang, tier2UnitStaleNoticeFmtKey, fmtRFC3339Local(lang, pkgDate))
|
||||
}
|
||||
}
|
||||
switch cd.LastStatus {
|
||||
@@ -1927,7 +1927,7 @@ func (s *Server) restoredVersionPrefix(app string, changed bool, pins []string,
|
||||
if !climbable {
|
||||
key = restoredOlderVersionNoStepKey
|
||||
}
|
||||
return s.note(key, app, at.In(getTimezone()).Format("2006-01-02 15:04"), backup.PinsVersion(pins)) + " "
|
||||
return s.note(key, app, fmtLocalTime(s.boxLang(), at), backup.PinsVersion(pins)) + " "
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -2065,9 +2065,9 @@ func (s *Server) tier2UnitConfirmWithStaleness(copyDate string, proven bool, sta
|
||||
msg := s.note(tier2UnitConfirmBaseKey)
|
||||
if copyDate != "" {
|
||||
if proven {
|
||||
msg += s.note(tier2UnitConfirmDateFmtKey, fmtRFC3339Local(copyDate))
|
||||
msg += s.note(tier2UnitConfirmDateFmtKey, fmtRFC3339Local(s.boxLang(), copyDate))
|
||||
} else {
|
||||
msg += s.note(tier2UnitConfirmDateUnprovenFmtKey, fmtRFC3339Local(copyDate))
|
||||
msg += s.note(tier2UnitConfirmDateUnprovenFmtKey, fmtRFC3339Local(s.boxLang(), copyDate))
|
||||
}
|
||||
}
|
||||
msg += s.note(tier2UnitConfirmContrastKey)
|
||||
@@ -2093,9 +2093,9 @@ func (s *Server) tier2UnitSourceMsg(cov backup.Tier2Coverage) string {
|
||||
return ""
|
||||
}
|
||||
if proven {
|
||||
return s.note(tier2UnitRestoreSourceKey, fmtRFC3339Local(date))
|
||||
return s.note(tier2UnitRestoreSourceKey, fmtRFC3339Local(s.boxLang(), date))
|
||||
}
|
||||
return s.note(tier2UnitRestoreSourceUnprovenKey, fmtRFC3339Local(date))
|
||||
return s.note(tier2UnitRestoreSourceUnprovenKey, fmtRFC3339Local(s.boxLang(), date))
|
||||
}
|
||||
|
||||
// backupTier2RestoreHandler (C2, closes F2) restores an app's MISSING user files in place from its
|
||||
@@ -2330,7 +2330,7 @@ func (s *Server) systemPageData() map[string]interface{} {
|
||||
// R-724: the box's local time, not a raw RFC3339 stamp.
|
||||
data["LastCheckTime"] = status.LastCheck.CheckedAt
|
||||
if t, err := time.Parse(time.RFC3339, status.LastCheck.CheckedAt); err == nil {
|
||||
data["LastCheckTime"] = t.In(getTimezone()).Format("2006-01-02 15:04")
|
||||
data["LastCheckTime"] = fmtLocalTime(s.boxLang(), t)
|
||||
}
|
||||
data["LastCheckError"] = status.LastCheck.Error
|
||||
}
|
||||
@@ -2593,6 +2593,18 @@ func (s *Server) appAutoFieldRevealHandler(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
}
|
||||
}
|
||||
// R-255 (v0.308.0): an optional-config `secret_input` (a key the household typed in for the app, e.g. a
|
||||
// metadata provider's API key) is no longer rendered into the settings page; it is revealed here on demand,
|
||||
// behind the same session + CSRF as the auto-generated secrets.
|
||||
if !allowed {
|
||||
for _, g := range stack.Meta.OptionalConfig {
|
||||
for _, f := range g.Fields {
|
||||
if f.EnvVar == envVar && f.Type == "secret_input" {
|
||||
allowed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
s.logger.Printf("[WARN] [web] auto-field reveal refused for %s/%s: not an auto-generated secret field", stackName, envVar)
|
||||
escrowJSON(w, http.StatusForbidden, nil, s.msg(r, "escrow.field_not_revealable"))
|
||||
@@ -2945,6 +2957,24 @@ func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
// R-922 warning (operator ruling 2026-10-12, S1, `09` §3 decision 196): clearing a STORED address is said
|
||||
// first — „you will get no more notification mails" — and nothing is saved or sent until the household
|
||||
// confirms with the page's own button (confirm_clear=1). Server-side, so it holds without JavaScript and
|
||||
// for any client. A box whose address is already empty has nothing to warn about. Pinned by
|
||||
// TestR922_ClearWarnsFirst.
|
||||
if cur := s.settings.GetNotificationPrefs(); email == "" && cur != nil && cur.Email != "" && r.FormValue("confirm_clear") != "1" {
|
||||
s.logger.Printf("[INFO] [web] Notification address clear: asking the household to confirm first (nothing saved)")
|
||||
data := s.notificationsPageData()
|
||||
data["NotificationPrefs"] = &settings.NotificationPrefs{
|
||||
Email: email,
|
||||
EnabledEvents: enabledEvents,
|
||||
CooldownHours: cooldownHours,
|
||||
}
|
||||
data["NotificationClearConfirm"] = true
|
||||
s.executeTemplate(w, r, "settings_notifications", data)
|
||||
return
|
||||
}
|
||||
|
||||
// R-922 (operator ruling 2026-10-09, option A): an empty address where one was stored is the household's
|
||||
// deliberate clear — persisted, so this push AND every later one carry `email_cleared` while the address
|
||||
// stays empty, and the hub deletes the address it holds. A box that never had one does not set it.
|
||||
@@ -3278,7 +3308,7 @@ func (s *Server) appDetailsForPath(storagePath string) []StorageAppDetail {
|
||||
}
|
||||
}
|
||||
if any {
|
||||
detail.SizeHuman = humanizeDirBytes(total)
|
||||
detail.SizeHuman = fmtSizeBytes(s.boxLang(), total)
|
||||
}
|
||||
details = append(details, detail)
|
||||
}
|
||||
@@ -3322,11 +3352,11 @@ func dirSizeHuman(path string) string {
|
||||
return humanizeDirBytes(dirSizeBytesWalk(path))
|
||||
}
|
||||
|
||||
func formatFreeSpace(gb float64) string {
|
||||
func formatFreeSpace(lang string, gb float64) string {
|
||||
if gb >= 1000 {
|
||||
return fmt.Sprintf("%.1f TB", gb/1024)
|
||||
return i18n.FormatDecimal(lang, gb/1024, 1) + " TB"
|
||||
}
|
||||
return fmt.Sprintf("%.1f GB", gb)
|
||||
return i18n.FormatDecimal(lang, gb, 1) + " GB"
|
||||
}
|
||||
|
||||
func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -3819,10 +3849,6 @@ func sameImageMap(a, b map[string]string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func undoneWhen(rfc3339 string) string {
|
||||
t, err := time.Parse(time.RFC3339, rfc3339)
|
||||
if err != nil {
|
||||
return rfc3339
|
||||
}
|
||||
return t.In(getTimezone()).Format("2006-01-02 15:04")
|
||||
func undoneWhen(lang, rfc3339 string) string {
|
||||
return fmtRFC3339Local(lang, rfc3339)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user