v0.254.0 — the saved notes follow the language, and the switch becomes a globe (R-557 slice 2 release C; SLICE 2 CLOSED)
gates / gates (push) Successful in 23s
gates / gates (push) Successful in 23s
The notes a background run SAVES — last night's backup line, the last error, the proof result, the restore outcome — are written in the BOX's language at the moment they are written. A household that switches sees the previous run's note in the old language until the next run rewrites it: the operator's §16 option 1, stated rather than hidden. EndRestoreOp no longer receives a Hungarian literal from anywhere. The language switch is a globe. Two text links wrapped in the sidebar footer and asked the reader to recognise "Magyar"/"English" as links; a globe is the one symbol every web user already reads as "language", so nobody has to read Hungarian to escape Hungarian. It is <details>/<summary> — a menu with no script, drawn inline because the icon sprite lives only in layout.html and the visitor pages have their own shell. Those visitor pages get the same globe, and a visitor's choice stays theirs: a display-only felhom_lang cookie that langFor reads ONLY when there is no session. A signed-in household can never inherit a language a previous visitor picked in the same browser. POST /lang is CSRF-exempt for a narrow reason written at the exemption — its only achievable effect is the language of the page the victim's own browser shows them — and safeBackPath refuses //evil.example as well as https://, because "starts with /" alone is not the test. §16 taken: a successful claim carries the cookie into the household's setting. TWO PARITY EXCEPTIONS, MEASURED: 106 fixtures compared with a real diff — exactly two change shapes (the dashboard footer, the globe in the shells) and 5 byte-identical, which are the three pages that must not change. I INTRODUCED A DEADLOCK AND THE SUITE CAUGHT IT BY HANGING. UpdateOffboxStatus holds the settings write lock while running its callback; boxLang() wants the read lock; sync.RWMutex is not reentrant. On a real box an off-site run would have hung forever HOLDING the settings lock. Fixed by resolving the language before the callback, and guarded by a test that names the file and line in a second instead of hanging for 25 minutes. MinAgent: 0.131.0 (unchanged). No hub release needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -1,3 +1,64 @@
|
||||
## v0.254.0 — The saved notes follow the language, and the switch becomes a globe (2026-09-18, R-557 slice 2 release C — SLICE 2 CLOSED)
|
||||
|
||||
**MinAgent: 0.131.0** (unchanged). **No hub release needed.** Nothing on the wire moved.
|
||||
**Hungarian changes in exactly TWO places, both declared and both measured** — the sidebar footer of
|
||||
every dashboard page, and the three pages a visitor meets. Everything else is byte-identical.
|
||||
|
||||
**1. The notes a background run SAVES now follow the language.** The line under last night's backup,
|
||||
the last error, the proof result, the restore outcome — written in the BOX's language at the moment
|
||||
they are written (`util.Text(lang, key, …)`, `m.note`/`s.note`). The consequence, stated rather than
|
||||
hidden: **a household that switches sees the note from the run before in the old language until the
|
||||
next run rewrites it.** That is the operator's §16 option 1; storing a code and rendering live would
|
||||
need a dozen new persisted fields and a legacy path for each — the R-570 shape a dozen times over.
|
||||
Converted: the off-site failure classes and quota notes, the tier-2 reasons and warnings, the undo
|
||||
phrases, the reconstitute hold, the restore record, and the whole restore-outcome family.
|
||||
**`EndRestoreOp` no longer receives a Hungarian literal from anywhere.**
|
||||
|
||||
**2. The language switch is a globe.** Two text links wrapped in the sidebar footer and asked the
|
||||
reader to recognise „Magyar"/„English" as links; a globe is the one symbol every web user already
|
||||
reads as "language", so nobody has to read Hungarian to find their way out of Hungarian. It is
|
||||
`<details>`/`<summary>` — a menu with no script, which opens on click and on Enter and which a screen
|
||||
reader announces. Language names inside are shown in their own language and are never translated.
|
||||
|
||||
**3. The sign-in, claim and recovery pages get the same globe — and a visitor's choice stays theirs.**
|
||||
Those pages are met by someone who has not signed in. They have no setting to read, and they must not
|
||||
write the household's: a box's sign-in page is reachable by anyone who can reach the box. So the
|
||||
choice lives in the visitor's own browser (`felhom_lang`, display-only, one of two values), and
|
||||
`langFor` reads it **only when there is no session** — a signed-in household can never inherit a
|
||||
language a previous visitor picked in the same browser.
|
||||
|
||||
`POST /lang` is exempt from CSRF for a narrow, checkable reason, written down where it is made: the
|
||||
only thing a forged request can achieve is to change the language of the page the victim's own browser
|
||||
shows them. It writes one cookie, reads nothing, touches no setting, and `safeBackPath` keeps its
|
||||
redirect on this box (a protocol-relative `//evil.example` is refused — "starts with /" alone is not
|
||||
the test). **If that handler ever gains a second effect, it needs CSRF that day.**
|
||||
|
||||
**§16, the operator's default, taken: a claim CARRIES the visitor's language.** Someone who switched
|
||||
the claim page to English and then claimed the box chose English deliberately. Only on success, and
|
||||
only there — the one moment an anonymous visitor becomes the household.
|
||||
|
||||
**No globe on the guest share pages or the not-found page.** A share visitor is a stranger and the
|
||||
household's setting is the wrong default for them; that is a promise about the share feature and an
|
||||
operator decision (**R-577**). Pinned by `TestGuestSharePagesHaveNoGlobe` so it stays a decision.
|
||||
|
||||
**The two parity exceptions, measured rather than asserted.** 106 fixtures rendered and compared with
|
||||
a real (LCS) diff — **exactly two change shapes**: the dashboard footer (89 fixtures) and the globe in
|
||||
the shells (12), with 5 byte-identical, which are precisely the three pages that must not change. The
|
||||
whole diff is in `felhom.eu/documentation/audits/i18n-slice2-2026-09-18/C/parity-exception-diff.txt`.
|
||||
|
||||
**Two defects this release found in its own measurement, recorded rather than tidied away:**
|
||||
|
||||
- The parity HARNESS rendered the three visitor shells through the DASHBOARD path, so the fixture
|
||||
would have baked a globe posting to `/settings/language` with a CSRF field — a form the real page
|
||||
never serves. Caught by reading the diff before re-capturing. The harness now branches on
|
||||
`i18nDirectTemplates`.
|
||||
- The first "is the change only the two blocks?" measurement compared LINE BY INDEX. An insertion
|
||||
shifts every line below it, so it reported 60 520 changed lines and measured nothing. **A
|
||||
line-index compare is not a diff.**
|
||||
|
||||
`R-575` folded in where it was one call (`tier2NoTargetReason` now returns a note in the box's
|
||||
language); the soft memory-overcommit warning stays as its row says.
|
||||
|
||||
## v0.253.0 — Errors carry the key of the sentence they are (2026-09-18, R-557 slice 2 release B)
|
||||
|
||||
**MinAgent: 0.131.0** (unchanged). **No hub release needed.** Every Hungarian sentence is byte-identical
|
||||
|
||||
+3
-1
@@ -7,7 +7,9 @@
|
||||
>
|
||||
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
|
||||
|
||||
Last updated: 2026-09-18 (v0.253.0 — localisation slice 2 release B: errors carry a key)
|
||||
Last updated: 2026-09-18 (v0.254.0 — localisation slice 2 CLOSED: saved notes follow the language, the switch is a globe)
|
||||
|
||||
> **2026-09-18 — v0.254.0 (R-557 slice 2 release C — SLICE 2 CLOSED).** Saved notes follow the box language at WRITE time (§16 option 1): ~70 producers, and `EndRestoreOp` no longer takes a Hungarian literal from anywhere. The language switch is a **globe** (`<details>`, no script) in the sidebar footer and on the sign-in/claim/recovery pages; a visitor's choice lives in a display-only `felhom_lang` cookie that `langFor` reads ONLY when there is no session, and a claim carries it into the household setting on success. `POST /lang` is CSRF-exempt for a narrow reason written at the exemption; `safeBackPath` refuses `//evil.example` too. **Two parity exceptions, measured with a real diff: exactly two change shapes across 106 fixtures, 5 byte-identical.** **A DEADLOCK was introduced and caught by the suite hanging** — a note rendered inside `UpdateOffboxStatus`'s callback takes the settings read lock while the write lock is held; `TestNoteHelpersAreNotCalledUnderTheSettingsLock` guards it now. 629 literals left, 0 errors, 0 saved notes. New row R-577.
|
||||
|
||||
> **2026-09-18 — FLEET FLOOR RAISED to 0.253.0** (operator asked), `min_agent` 0.131.0 declared — the floor is above the vouched golden 0.246.0, so the declaration is what carries it (R-472). Hub: `managed floor SERVED … from declared`; demo-felhom went 0.250.0 -> 0.253.0 **by itself in ~20 s**, healthy, its other four containers up, and its own log reads `settle-gate: GO — at/above floor 0.253.0`. Both live boxes run agent 0.132.0 (above the requirement). **Peti Proxmox (DOWN 65 d, 0.115.0) and Tester 1 (DOWN 1 d, 0.245.0) did NOT get it** and will take it unattended when they return — untested on this version. Evidence: `felhom.eu/documentation/audits/i18n-slice2-2026-09-18/floor-raise-0.253.0.md`.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# REPORT — localisation slice 2, releases A and B (controller v0.252.0 + v0.253.0, R-557)
|
||||
# REPORT — localisation slice 2, COMPLETE (controller v0.252.0 + v0.253.0 + v0.254.0, R-557)
|
||||
|
||||
**2026-09-18 · base commit `736f54b49610` (v0.251.0) · MinAgent 0.131.0, unchanged · no hub release.**
|
||||
**Two releases this session: A (the sentences shown now) and B (the 179 error messages).**
|
||||
**Three releases: A (the sentences shown now), B (the 179 error messages), C (the saved notes + the globe).**
|
||||
Architecture read first and named: `felhom.eu/documentation/architecture/10-localisation.md` §1
|
||||
(parity), §2 (mechanism), §5 (gates), §9 (the R-553 signals), §10 (this slice); `07-backup-architecture.md`
|
||||
for what the hub reads from a box; `05-hub-architecture.md` for what the hub composes itself.
|
||||
@@ -193,3 +193,82 @@ are not red-proofs — nobody planted them.
|
||||
translated on purpose), `handler_debug.go` (R-574), the notifier's 27 wire messages (R-558),
|
||||
`funcmap.go`'s two un-overridden helpers (R-572), the text a background run PERSISTS (release C), and
|
||||
the R-570 producer. Release C's default stands: written in the box's language at write time.
|
||||
|
||||
|
||||
---
|
||||
|
||||
# Release C — v0.254.0: the saved notes, and a globe for the switch. SLICE 2 CLOSED.
|
||||
|
||||
## 1. Claims in the prompt that live source disproved — named first
|
||||
|
||||
| the prompt said | source says |
|
||||
|---|---|
|
||||
| "Release A's survey (persisted list, wire list)" is in `audits/i18n-slice2-2026-09-18/A/` | That folder holds only `live/`. The persisted list is in this REPORT's §6 for release A. Derived again from source here, which is the stronger reading — and it found the largest persisted group (`EndRestoreOp`'s outcome family, ~40 literals in two files) that a list would have had to remember. |
|
||||
| "the three shells share no layout" | **True, and confirmed by reading**: `login.html`, `claim.html`, `recovery.html` each open their own `<html lang="{{T "layout.html_lang"}}">`. But the consequence was missed: the icon SPRITE lives only in `layout.html`, so a `<use href="#i-globe">` would render nothing on those three. The globe is drawn INLINE in the partial instead — one definition, four places, no sprite coupling. No dead symbol was added. |
|
||||
| "no cookie is read anywhere for language" | Confirmed by grep: no language cookie existed. |
|
||||
| the `<details>` menu "working without script in the browsers the guide names" | **Cannot be confirmed here.** `claude-in-chrome` is not available on DooPlex, so no browser rendered this page. `<details>`/`<summary>` is a plain HTML element with no script, and the markup is asserted; whether it LOOKS right is an operator click-through. Said plainly rather than implied. |
|
||||
|
||||
## 2. What shipped
|
||||
|
||||
**The saved notes.** ~70 producers across `internal/backup` (off-site classes and quota, tier-2
|
||||
reasons and warnings, undo phrases, the reconstitute hold, the restore record) and `internal/web`
|
||||
(the whole `EndRestoreOp` outcome family). Each renders through `util.Text(boxLang(), key, …)`.
|
||||
**`EndRestoreOp` no longer receives a Hungarian literal from anywhere** (grep, with the negative
|
||||
control). `s.noteErr`/`m.noteErr` render release B's errors into a note in the same language.
|
||||
|
||||
**The globe.** One partial (`lang_globe.html`), used by the dashboard footer and the three visitor
|
||||
shells; drawn inline in the same stroke style as the sprite. `langFor` gained one step, and the order
|
||||
is now fixed: `?lang=` → **household setting when a session exists** → visitor cookie when none →
|
||||
setting → `hu`. A signed-in household never reads the cookie, which is the row that protects them.
|
||||
|
||||
**`POST /lang`** — CSRF-exempt for a narrow, checkable reason written at the exemption: the only
|
||||
achievable effect is changing the language of the page the victim's own browser shows them. It writes
|
||||
one cookie, reads nothing, and `safeBackPath` refuses `//evil.example` as well as `https://…` —
|
||||
"starts with /" alone is not the test, and that is the mistake the function exists to not make.
|
||||
|
||||
**§16 taken (the operator's stated default): a claim carries the visitor's language**, on success
|
||||
only, at the one moment an anonymous visitor becomes the household.
|
||||
|
||||
## 3. The two parity exceptions, measured
|
||||
|
||||
106 fixtures rendered and compared with a **real (LCS) diff**: exactly **two change shapes** — the
|
||||
dashboard footer (89 fixtures) and the globe in the shells (12) — and **5 byte-identical**, which are
|
||||
precisely the two guest share pages and the catch-all, the three that must not change. Full diff:
|
||||
`audits/i18n-slice2-2026-09-18/C/parity-exception-diff.txt`.
|
||||
|
||||
**Deviation, named:** the prompt asked for one commit per re-capture. This is one commit, because
|
||||
splitting them leaves an intermediate commit whose suite is red — and a commit that does not pass is
|
||||
worse than a commit whose EVIDENCE separates the two blocks, which this one does explicitly.
|
||||
|
||||
## 4. Two defects release C found in its own work
|
||||
|
||||
**(a) A DEADLOCK I introduced, caught by the suite hanging.** `UpdateOffboxStatus` holds the settings
|
||||
WRITE lock while it runs its callback; `boxLang()` reads the language through the settings READ lock.
|
||||
`sync.RWMutex` is not reentrant. The first draft rendered a note inside that callback — on a real box
|
||||
an off-site run would have hung **forever, holding the settings lock**, wedging everything else that
|
||||
touches `settings.json`. The only symptom was `go test` timing out at 25 minutes instead of 8.
|
||||
Fixed by resolving the language before the callback; `TestNoteHelpersAreNotCalledUnderTheSettingsLock`
|
||||
now names the file and line in a second instead. **A hang is the worst kind of failure to diagnose,
|
||||
and the lesson is: a helper that takes a lock must never be called from inside a callback that holds
|
||||
one.**
|
||||
|
||||
**(b) The first "only two blocks changed?" measurement compared LINE BY INDEX.** An insertion shifts
|
||||
every line below it, so it reported 60 520 changed lines and measured nothing. **A line-index compare
|
||||
is not a diff.** Redone with difflib.
|
||||
|
||||
Also caught before it shipped: the parity HARNESS rendered the three shells through the DASHBOARD
|
||||
path, which would have baked a form the real page never serves. Found by reading the diff before
|
||||
re-capturing, and independently by `TestI18nDirectRenderPagesFollowLanguage`.
|
||||
|
||||
## 5. Green gate
|
||||
|
||||
`go build ./... && go vet ./... && go test ./...` green; all controller gates OK. Red-proofs 15–19 in
|
||||
`audits/i18n-slice2-2026-09-18/redproofs.txt`, each seen to convict.
|
||||
|
||||
## 6. What is left after slice 2
|
||||
|
||||
**629 Hungarian literals**, none of them errors and none of them saved notes: the country TABLE (113,
|
||||
not on the wire, not translated on purpose), the page-title literals slice 1 handles through
|
||||
`TitleKey`, `handler_debug.go` (R-574), the notifier's 27 wire messages (R-558), `funcmap.go`'s two
|
||||
un-overridden helpers (R-572), the R-570 producer, and the soft memory warning (R-575).
|
||||
**R-557 is CLOSED.** New row: **R-577**, the guest-share visitor's language.
|
||||
|
||||
+24
-4
@@ -3614,7 +3614,7 @@ without reaching `Images()`.
|
||||
|
||||
---
|
||||
|
||||
### 18. Dashboard language (i18n) (v0.247.0–v0.253.0)
|
||||
### 18. Dashboard language (i18n) (v0.247.0–v0.254.0)
|
||||
|
||||
Design: `felhom.eu/documentation/architecture/10-localisation.md`. Inventory:
|
||||
`felhom.eu/documentation/audits/I18N-INVENTORY-2026-09-17.md`.
|
||||
@@ -3624,9 +3624,10 @@ sidebar footer of every dashboard page. Every template is converted (v0.247.0 th
|
||||
apps and settings, v0.249.0 backups, v0.250.0 storage, sharing, sign-in, claim, guest share, catch-all,
|
||||
debug). Since **v0.252.0** the sentences the program BUILDS follow the language too — flash lines, page
|
||||
data, the JSON the page's script reads, the alert banners, the country names, and the four page titles
|
||||
built around an app name. Since **v0.253.0** every error message carries its key too, so a refusal made deep in a package is
|
||||
rendered in the household's language by whoever prints it. Still Hungarian: text persisted by a
|
||||
background run (release C), catalog copy (slice 5), and **everything the hub reads** (see below).
|
||||
built around an app name. Since **v0.253.0** every error message carries its key too. Since **v0.254.0** the notes a background
|
||||
run SAVES follow the language as well, and the switch is a **globe** — on the dashboard and on the
|
||||
sign-in, claim and recovery pages. Still Hungarian: catalog copy (slice 5) and **everything the hub
|
||||
reads** (see below).
|
||||
|
||||
- **Bundles:** `internal/i18n/locales/hu.json` (authoritative, every key) and `en.json`, embedded.
|
||||
Flat `key → text`; a value may carry template actions (`{{.RecoveryAbandonDate}}`) and inline
|
||||
@@ -3680,6 +3681,25 @@ background run (release C), catalog copy (slice 5), and **everything the hub rea
|
||||
- **Plurals (v0.253.0):** a key that carries `.one`/`.other` in a language is a plural key and its FIRST
|
||||
parameter is the count (`i18n.Bundle.form`). Hungarian has one form at every count. `.one`/`.other`
|
||||
are RESERVED suffixes — `TestNoOrdinaryKeyEndsInAPluralSuffix`.
|
||||
- **Saved notes (v0.254.0):** a background run has no request and no reader, so a note it SAVES is
|
||||
written in the BOX's language at write time — `util.Text(lang, key, …)` via `m.note`/`s.note`
|
||||
(`m.noteErr`/`s.noteErr` for an error). Consequence, by operator ruling (slice 2 §16 option 1): a
|
||||
household that switches sees the previous run's note in the old language until the next run.
|
||||
**TRAP, and it cost a 25-minute hang:** `UpdateOffboxStatus` and its siblings hold the settings WRITE
|
||||
lock while running their callback, and `boxLang()` wants the READ lock — `sync.RWMutex` is not
|
||||
reentrant, so a note rendered inside such a callback DEADLOCKS while holding the settings lock.
|
||||
Resolve the language BEFORE the callback; `TestNoteHelpersAreNotCalledUnderTheSettingsLock` guards it.
|
||||
- **The switch is a globe (v0.254.0):** `templates/lang_globe.html`, a `<details>`/`<summary>` menu with
|
||||
no script, drawn inline (the sprite lives only in `layout.html`, so `<use>` would render nothing on
|
||||
the shells). Language names are shown in their own language and never translated.
|
||||
- **A visitor's language (v0.254.0):** the sign-in, claim and recovery pages carry the same globe, posting
|
||||
to `POST /lang`, which sets the display-only `felhom_lang` cookie. `langFor`'s order: `?lang=` →
|
||||
**household setting when a session exists** → cookie when none → setting → `hu`; a signed-in household
|
||||
never reads the cookie. `/lang` is CSRF-exempt because its only achievable effect is the language of
|
||||
the page the victim's own browser shows them — **if it gains a second effect it needs CSRF that day**.
|
||||
`safeBackPath` refuses a protocol-relative `//host` as well as an absolute URL. A successful CLAIM
|
||||
carries the cookie into the household setting (§16). **No globe on the guest share pages or the
|
||||
not-found page** — R-577.
|
||||
- **Report:** the hub report carries `"language"` (always present). No hub release reads it yet.
|
||||
- **Tools and gates:** `scripts/i18n_extract.py` converts a template (moves each Hungarian run into
|
||||
hu.json, leaves a marker); `scripts/i18n_missing_gate.py` (in `controller_gates.py`) checks keys
|
||||
|
||||
@@ -13,8 +13,10 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// Manager orchestrates app-data backups: database dumps and Docker-volume tars.
|
||||
@@ -1375,3 +1377,24 @@ func dbNames(dbs []DiscoveredDB) string {
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// boxLang is the language a SAVED note is written in (release C, R-557 slice 2 option 1).
|
||||
//
|
||||
// A background run has no request and no reader, so it writes in the box's language at the moment it
|
||||
// writes. A household that switches sees last night's note in the old language until the next run
|
||||
// rewrites it — the operator's ruling, and the reason the reader never translates stored text.
|
||||
func (m *Manager) boxLang() string {
|
||||
if m.settings == nil {
|
||||
return i18n.Default
|
||||
}
|
||||
return m.settings.GetLanguage()
|
||||
}
|
||||
|
||||
// note renders a saved note in the box's language.
|
||||
func (m *Manager) note(key string, args ...interface{}) string {
|
||||
return util.Text(m.boxLang(), key, args...)
|
||||
}
|
||||
|
||||
// noteErr renders an error into a saved note in the box's language: its bundle message when it
|
||||
// carries one (release B), its own text otherwise.
|
||||
func (m *Manager) noteErr(err error) string { return util.ErrText(m.boxLang(), err) }
|
||||
|
||||
@@ -93,7 +93,7 @@ var ErrOffsiteQuota = errors.New("offsite quota exceeded")
|
||||
// of showing the previous run's verdict under a „started" message.
|
||||
// offboxWholeUnitGap is the pseudo-path used to report a WHOLE-unit gap through the mandatory-gap
|
||||
// notification, so a skipped app and a skipped directory reach the operator in one vocabulary.
|
||||
const offboxWholeUnitGap = "(a teljes alkalmazás — nincs helyi mentési egysége)"
|
||||
const offboxWholeUnitGapKey = "note.offsite.whole_unit_gap"
|
||||
|
||||
var ErrOffboxRunInFlight = fmt.Errorf("an off-box backup is already running; this request did not start a new one")
|
||||
|
||||
@@ -226,20 +226,24 @@ func (m *Manager) OffsiteFailureMessage(err error, dur time.Duration) string {
|
||||
if m != nil && m.settings != nil {
|
||||
t = m.settings.GetOffboxTarget()
|
||||
}
|
||||
return offsiteFailureMessage(t, err, dur)
|
||||
return offsiteFailureMessage(t, err, dur, m.boxLang())
|
||||
}
|
||||
|
||||
func offsiteFailureMessage(t *settings.OffboxTarget, err error, dur time.Duration) string {
|
||||
// offsiteFailureMessage builds the note that is SAVED in OffboxTarget.LastError and read on the page
|
||||
// days later, so it is written in the box's language at write time (release C, R-557 option 1).
|
||||
// The failure CLASS is still decided by ClassifyOffsiteFailure from a kind, never from these words
|
||||
// (R-553) — which is why translating them is safe.
|
||||
func offsiteFailureMessage(t *settings.OffboxTarget, err error, dur time.Duration, lang string) string {
|
||||
head := map[OffsiteFailureClass]string{
|
||||
OffsiteFailQuota: "A távoli mentés nem fért el a tárhelykereten belül",
|
||||
OffsiteFailOrphaned: "A távoli tárhely egy korábbi, már nem elérhető kulccsal készült",
|
||||
OffsiteFailNoRepo: "A távoli tárhelyen nincs mentési adattár",
|
||||
OffsiteFailNoUnits: "Nem volt mit menteni: egyetlen kijelölt alkalmazásnak sem található mentése",
|
||||
OffsiteFailTransport: "A távoli tárhely nem érhető el (hálózat vagy bejelentkezés)",
|
||||
OffsiteFailUnknown: "A távoli mentés ismeretlen okból nem sikerült",
|
||||
OffsiteFailQuota: util.Text(lang, "note.offsite.fail_quota"),
|
||||
OffsiteFailOrphaned: util.Text(lang, "note.offsite.fail_orphaned"),
|
||||
OffsiteFailNoRepo: util.Text(lang, "note.offsite.fail_no_repo"),
|
||||
OffsiteFailNoUnits: util.Text(lang, "note.offsite.fail_no_units"),
|
||||
OffsiteFailTransport: util.Text(lang, "note.offsite.fail_transport"),
|
||||
OffsiteFailUnknown: util.Text(lang, "note.offsite.fail_unknown"),
|
||||
}[ClassifyOffsiteFailure(err)]
|
||||
if head == "" {
|
||||
head = "A távoli mentés nem sikerült"
|
||||
head = util.Text(lang, "note.offsite.fail_head")
|
||||
}
|
||||
return fmt.Sprintf("%s (%s): %s", head, dur.Round(time.Second), sanitiseOffsiteErrorFor(t, err))
|
||||
}
|
||||
@@ -988,6 +992,14 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
|
||||
if runErr == nil {
|
||||
snapshots = m.offboxRecordStats(ctx, base, env)
|
||||
}
|
||||
// THE LANGUAGE IS RESOLVED HERE, OUTSIDE THE CALLBACK, AND IT IS NOT A STYLE CHOICE.
|
||||
//
|
||||
// UpdateOffboxStatus holds the settings WRITE lock while it runs `fn`, and boxLang() reads the
|
||||
// language through the settings READ lock. sync.RWMutex is not reentrant, so calling m.note()
|
||||
// inside this callback DEADLOCKS — and it deadlocks holding the settings lock, which then wedges
|
||||
// everything else that touches settings.json on that box. Written this way once and caught by the
|
||||
// suite timing out at 25 minutes; TestNoteHelpersAreNotCalledUnderTheSettingsLock keeps it fixed.
|
||||
lang := m.boxLang()
|
||||
if perr := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||
o.LastRun = time.Now().UTC().Format(time.RFC3339)
|
||||
// R-100: LastRun above records the ATTEMPT; this records the RESULT. The hub's staleness
|
||||
@@ -1047,7 +1059,7 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
|
||||
notify = cp
|
||||
}
|
||||
for _, a := range runResult.missingUnprotected {
|
||||
notify[a] = append(notify[a], offboxWholeUnitGap)
|
||||
notify[a] = append(notify[a], util.Text(lang, offboxWholeUnitGapKey))
|
||||
}
|
||||
}
|
||||
m.offboxGapNotify(notify)
|
||||
@@ -1074,13 +1086,11 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
|
||||
// available backup and were left out", which names a problem with no next step and reads
|
||||
// the same whether the customer must act or simply wait.
|
||||
if len(runResult.missingUnprotected) > 0 {
|
||||
warns = append(warns, fmt.Sprintf(
|
||||
"Ezek az alkalmazások NEM kerültek be a távoli mentésbe, mert még nincs helyi mentési egységük: %s. A következő mentés általában már elkészíti — ha a második futás után is itt szerepelnek, szólj az üzemeltetőnek.",
|
||||
warns = append(warns, util.Text(lang, "note.offsite.no_local_unit",
|
||||
strings.Join(runResult.missingUnprotected, ", ")))
|
||||
}
|
||||
if len(runResult.missingNotDeployed) > 0 {
|
||||
warns = append(warns, fmt.Sprintf(
|
||||
"Ezek az alkalmazások ki vannak jelölve távoli mentésre, de nincsenek telepítve, ezért nem menthetők: %s. Ha már nincs rájuk szükséged, vedd ki a kijelölésüket a Távoli mentés oldalon.",
|
||||
warns = append(warns, util.Text(lang, "note.offsite.not_installed",
|
||||
strings.Join(runResult.missingNotDeployed, ", ")))
|
||||
}
|
||||
// 3a: capture-gap warnings (structurally-refused / on-disk-missing mandatory paths, undeployed).
|
||||
@@ -1100,14 +1110,14 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
|
||||
blockedApps = append(blockedApps, n)
|
||||
}
|
||||
if len(blockedApps) > 0 {
|
||||
warns = append(warns, fmt.Sprintf("Figyelmeztetés: a tárhelykeret miatt %d alkalmazásnál csak konfiguráció- és adatbázis-mentés készült: %s.",
|
||||
warns = append(warns, util.Text(lang, "note.offsite.quota_partial",
|
||||
len(blockedApps), strings.Join(blockedApps, ", ")))
|
||||
}
|
||||
if sharesBlocked {
|
||||
warns = append(warns, sharesBlockedWarning())
|
||||
}
|
||||
// SLICE 4: approaching the soft quota (≥80%, <100%) — warn on an otherwise-OK run.
|
||||
if qw := offboxQuotaWarning(o); qw != "" {
|
||||
if qw := offboxQuotaWarning(o, lang); qw != "" {
|
||||
warns = append(warns, qw)
|
||||
}
|
||||
o.LastWarning = strings.Join(warns, " ")
|
||||
@@ -1739,7 +1749,7 @@ func offboxQuotaState(t *settings.OffboxTarget) (usedGB, quotaGB int, over bool)
|
||||
|
||||
// offboxQuotaWarning returns the Hungarian ≥80% (<100%) usage notice, or "" (quota unset / usage fine /
|
||||
// already over — over-quota is the run-refusal error, not a warning).
|
||||
func offboxQuotaWarning(t *settings.OffboxTarget) string {
|
||||
func offboxQuotaWarning(t *settings.OffboxTarget, lang string) string {
|
||||
if t == nil || t.QuotaGB <= 0 || t.RepoSizeBytes <= 0 {
|
||||
return ""
|
||||
}
|
||||
@@ -1748,7 +1758,7 @@ func offboxQuotaWarning(t *settings.OffboxTarget) string {
|
||||
if pct < 80 || t.RepoSizeBytes >= limit {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("A távoli mentés a keret %d%%-át használja (%d/%d GB).", pct, t.RepoSizeBytes/offboxGiB, t.QuotaGB)
|
||||
return util.Text(lang, "note.offsite.quota_usage", int(pct), int(t.RepoSizeBytes/offboxGiB), t.QuotaGB)
|
||||
}
|
||||
|
||||
// OffboxQuotaPercent returns the usage percentage for the /backups usage bar (0 when no quota/size).
|
||||
|
||||
@@ -172,7 +172,7 @@ func (s safetyDumpSet) First() string {
|
||||
// failed restore on a machine that may be unwell, and the honest claim available here is presence.
|
||||
// A zero-length file is reported as MISSING — a 0-byte dump restores nothing, and calling it present
|
||||
// is the same false reassurance one step smaller.
|
||||
func undoCopyPhrase(set safetyDumpSet) string {
|
||||
func (m *Manager) undoCopyPhrase(set safetyDumpSet) string {
|
||||
var present, absent []string
|
||||
for _, f := range set.Files {
|
||||
if f.Path == "" {
|
||||
@@ -186,16 +186,15 @@ func undoCopyPhrase(set safetyDumpSet) string {
|
||||
}
|
||||
switch {
|
||||
case len(present) > 0 && len(absent) == 0:
|
||||
return "a korábbi állapot mentése megvan: " + strings.Join(present, ", ")
|
||||
return m.note("note.undo.present", strings.Join(present, ", "))
|
||||
case len(present) > 0:
|
||||
// Partial: name both halves. An app with two databases whose undo is half there is a
|
||||
// different situation from either whole one, and support must not have to guess which.
|
||||
return "a korábbi állapot mentése RÉSZBEN van meg — megvan: " + strings.Join(present, ", ") +
|
||||
"; HIÁNYZIK: " + strings.Join(absent, ", ")
|
||||
return m.note("note.undo.partial", strings.Join(present, ", "), strings.Join(absent, ", "))
|
||||
case len(absent) > 0:
|
||||
return "a korábbi állapot mentését NEM találjuk a helyén (" + strings.Join(absent, ", ") + ")"
|
||||
return m.note("note.undo.absent", strings.Join(absent, ", "))
|
||||
default:
|
||||
return "a korábbi állapotról nem készült menthető másolat"
|
||||
return m.note("note.undo.none")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,7 +340,7 @@ func (m *Manager) RestoreHoldFor(stack string) (bool, string) {
|
||||
// Slice 4: one storage, two reasons. An update hold names the copy it can be restored from; a
|
||||
// restore hold names nothing, because the restore it refers to already consumed the copy.
|
||||
if h.Reason == settings.HoldReasonUpdateFailed {
|
||||
copyDate := "legutóbbi"
|
||||
copyDate := m.note("note.reconstitute.copy_latest")
|
||||
if h.CopyDate != "" {
|
||||
copyDate = fmtHoldTime(h.CopyDate)
|
||||
}
|
||||
@@ -358,8 +357,7 @@ func (m *Manager) RestoreHoldFor(stack string) (bool, string) {
|
||||
if t, err := time.Parse(time.RFC3339, h.At); err == nil {
|
||||
when = t.Format("2006-01-02 15:04")
|
||||
}
|
||||
return true, fmt.Sprintf("a(z) %s adatainak visszaállítása %s-kor megszakadt, és a korábbi állapotot sem sikerült visszatölteni. "+
|
||||
"Az alkalmazás biztonsági okból leállítva marad, hogy az adatai ne sérüljenek tovább. Vedd fel velünk a kapcsolatot", stack, when)
|
||||
return true, m.note("note.reconstitute.held", stack, when)
|
||||
}
|
||||
|
||||
// holdAppAfterFailedRollback records the R-379/R-380 hold and makes sure nothing restarts the app
|
||||
@@ -810,7 +808,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
|
||||
// R-383: the undo copy is DESCRIBED FROM DISK, never from the path alone. See
|
||||
// undoCopyPhrase — this sentence used to assert the file existed in exactly the
|
||||
// branch where a missing file is one of the two causes.
|
||||
return res, util.MsgError("err.backup.db_restore_and_rollback_failed", stack, undoCopyPhrase(safetySet))
|
||||
return res, util.MsgError("err.backup.db_restore_and_rollback_failed", stack, m.undoCopyPhrase(safetySet))
|
||||
}
|
||||
res.RolledBack = true
|
||||
if sErr := restartStack(); sErr != nil {
|
||||
|
||||
@@ -2,10 +2,18 @@ package backup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
@@ -65,7 +73,7 @@ func TestClassifyOffsiteFailure_UnknownIsHonest(t *testing.T) {
|
||||
if got := ClassifyOffsiteFailure(fmt.Errorf("something entirely new")); got != OffsiteFailUnknown {
|
||||
t.Errorf("an unclassifiable error was folded into %q instead of being reported as unknown", got)
|
||||
}
|
||||
msg := offsiteFailureMessage(diagTarget(), fmt.Errorf("something entirely new"), time.Minute)
|
||||
msg := offsiteFailureMessage(diagTarget(), fmt.Errorf("something entirely new"), time.Minute, "hu")
|
||||
if !strings.Contains(msg, "ismeretlen okból") {
|
||||
t.Errorf("the unknown case does not admit it is unknown: %q", msg)
|
||||
}
|
||||
@@ -82,7 +90,7 @@ func TestOffsiteFailureMessage_NeverCarriesTheRepoReference(t *testing.T) {
|
||||
fmt.Errorf(`restic: repo "sftp:u629488-sub3@u629488-sub3.your-storagebox.de:/home/felhom-repo" locked`),
|
||||
}
|
||||
for _, e := range leaky {
|
||||
msg := offsiteFailureMessage(diagTarget(), e, 42*time.Second)
|
||||
msg := offsiteFailureMessage(diagTarget(), e, 42*time.Second, "hu")
|
||||
for _, forbidden := range []string{
|
||||
"sftp:",
|
||||
"your-storagebox.de",
|
||||
@@ -102,7 +110,7 @@ func TestOffsiteFailureMessage_NeverCarriesTheRepoReference(t *testing.T) {
|
||||
// The message must still be ACTIONABLE. Sanitising must not reduce it to a shrug — an operator needs
|
||||
// the cause line plus enough residual detail to act.
|
||||
func TestOffsiteFailureMessage_StaysActionable(t *testing.T) {
|
||||
msg := offsiteFailureMessage(diagTarget(), fmt.Errorf("dial tcp: connect: connection refused"), 90*time.Second)
|
||||
msg := offsiteFailureMessage(diagTarget(), fmt.Errorf("dial tcp: connect: connection refused"), 90*time.Second, "hu")
|
||||
if !strings.Contains(msg, "nem érhető el") {
|
||||
t.Errorf("the transport cause is not named: %q", msg)
|
||||
}
|
||||
@@ -124,3 +132,127 @@ func TestSanitiseOffsiteError_IsBounded(t *testing.T) {
|
||||
t.Error("a nil error produced text")
|
||||
}
|
||||
}
|
||||
|
||||
// newNoteManager builds a Manager whose saved-note helpers work: release C writes a saved note in the
|
||||
// BOX's language, so a Manager with no settings would render every note as its key. Hungarian here,
|
||||
// because these tests assert the sentence a Hungarian household reads — which is the parity half.
|
||||
func newNoteManager(t *testing.T) *Manager {
|
||||
t.Helper()
|
||||
lg := log.New(io.Discard, "", 0)
|
||||
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), lg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.DataDir = t.TempDir()
|
||||
return NewManager(cfg, sett, lg)
|
||||
}
|
||||
|
||||
// backupNoteHU is the Hungarian text of a saved-note key, for a test that used to compare against a
|
||||
// constant. The comparison is unchanged in meaning: it still pins the sentence, now measured against
|
||||
// the bundle rather than restated beside it.
|
||||
func backupNoteHU(t *testing.T, key string) string {
|
||||
t.Helper()
|
||||
return newNoteManager(t).note(key)
|
||||
}
|
||||
|
||||
// TestR570SentenceStaysHungarian — the ONE saved note release C may not translate.
|
||||
//
|
||||
// A box upgraded to 0.251.0 carries the OLD persisted warning with no kind until its next off-site
|
||||
// run rewrites it, so `offboxWarningDisplay` still falls back to a substring test on those words when
|
||||
// the kind is empty (R-553's documented exception). Translating the PRODUCER while that fallback is
|
||||
// load-bearing would strand exactly the boxes the fallback exists for: their stale note would stop
|
||||
// being recognised and would keep telling a household that nothing is covered.
|
||||
//
|
||||
// **Delete this test when R-570 closes** — it is named for the row on purpose.
|
||||
//
|
||||
// RED-PROOF (REPORT): turn the producer into m.note("…") → this test fails naming the line.
|
||||
func TestR570SentenceStaysHungarian(t *testing.T) {
|
||||
const sentence = "Sikeres — nincs mentésre jelölt alkalmazás"
|
||||
src, err := os.ReadFile("offbox.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(src), `"`+sentence+`"`) {
|
||||
t.Errorf("the R-570 producer no longer writes %q as a literal — a box that has not run off-site "+
|
||||
"since 0.251.0 carries that exact text with no kind, and offboxWarningDisplay finds it by "+
|
||||
"those words. Translating it strands them. Close R-570 first.", sentence)
|
||||
}
|
||||
// And the sentence must not have quietly acquired a bundle key either: a key would render
|
||||
// Hungarian today and something else the day someone adds a translation.
|
||||
for _, k := range []string{"note.offsite.no_apps_selected", "note.offsite.zero_toggle"} {
|
||||
if strings.Contains(string(src), k) {
|
||||
t.Errorf("the R-570 producer now names a bundle key (%s) — same problem, one step further away", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoteHelpersAreNotCalledUnderTheSettingsLock — release C (R-557), and it is a REGRESSION test,
|
||||
// not a precaution.
|
||||
//
|
||||
// `UpdateOffboxStatus` and its siblings hold the settings WRITE lock while they run their callback.
|
||||
// `boxLang()` reads the language through the settings READ lock. sync.RWMutex is not reentrant, so a
|
||||
// note rendered inside such a callback DEADLOCKS — and it deadlocks while holding the settings lock,
|
||||
// which then wedges every other thing on that box that touches settings.json. The first draft of
|
||||
// release C did exactly that, in the off-site run's final status write, and the only symptom was the
|
||||
// test suite timing out at 25 minutes.
|
||||
//
|
||||
// The fix is to resolve the language BEFORE entering the callback. This test reads the source for the
|
||||
// shape, because the failure is a hang and a hang is not something a unit test can assert on
|
||||
// comfortably; the behaviour half is the suite finishing at all.
|
||||
//
|
||||
// RED-PROOF (REPORT): put `m.note(...)` back inside the final UpdateOffboxStatus callback → this test
|
||||
// names the file and the line, in a second, instead of the suite hanging for 25 minutes.
|
||||
func TestNoteHelpersAreNotCalledUnderTheSettingsLock(t *testing.T) {
|
||||
callback := regexp.MustCompile(`\.Update\w*\(func\(`)
|
||||
note := regexp.MustCompile(`\b(m|s)\.(note|noteErr|boxLang)\(`)
|
||||
|
||||
files, err := filepath.Glob("*.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checked, callbacks := 0, 0
|
||||
var bad []string
|
||||
for _, f := range files {
|
||||
if strings.HasSuffix(f, "_test.go") {
|
||||
continue
|
||||
}
|
||||
src, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checked++
|
||||
depth := 0
|
||||
inside := false
|
||||
for i, line := range strings.Split(string(src), "\n") {
|
||||
if !inside && callback.MatchString(line) {
|
||||
depth = strings.Count(line, "{") - strings.Count(line, "}")
|
||||
if depth > 0 {
|
||||
inside, callbacks = true, callbacks+1
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !inside {
|
||||
continue
|
||||
}
|
||||
if note.MatchString(line) {
|
||||
bad = append(bad, f+":"+strconv.Itoa(i+1)+" "+strings.TrimSpace(line))
|
||||
}
|
||||
depth += strings.Count(line, "{") - strings.Count(line, "}")
|
||||
if depth <= 0 {
|
||||
inside = false
|
||||
}
|
||||
}
|
||||
}
|
||||
// The instrument must be shown to be looking at something.
|
||||
if checked == 0 || callbacks == 0 {
|
||||
t.Fatalf("examined %d files and found %d settings callbacks — the pattern no longer matches the code", checked, callbacks)
|
||||
}
|
||||
if len(bad) > 0 {
|
||||
t.Errorf("a saved-note helper is called inside a settings callback, which holds the WRITE lock "+
|
||||
"while boxLang() wants the READ lock — sync.RWMutex is not reentrant, so this DEADLOCKS and "+
|
||||
"wedges settings.json for everything else on the box. Resolve the language before the "+
|
||||
"callback (%d):\n %s", len(bad), strings.Join(bad, "\n "))
|
||||
}
|
||||
t.Logf("examined %d files, %d settings callbacks", checked, callbacks)
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ func TestR383_AbsentUndoCopyIsNotClaimedToExist(t *testing.T) {
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := undoCopyPhrase(tc.set)
|
||||
got := newNoteManager(t).undoCopyPhrase(tc.set)
|
||||
for _, want := range tc.mustContain {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("phrase %q does not contain %q", got, want)
|
||||
@@ -119,9 +119,16 @@ func TestR383_TheDoubleFailureMessageIsWiredToTheBuilder(t *testing.T) {
|
||||
}
|
||||
switch fn := call.Fun.(type) {
|
||||
case *ast.SelectorExpr:
|
||||
// v0.254.0 (R-557 release C): undoCopyPhrase became a METHOD, because a saved note is
|
||||
// rendered in the box's language and that needs the Manager. A method call is a
|
||||
// SelectorExpr, not an Ident — so it is matched here as well as below, and the test keeps
|
||||
// asserting what it always asserted rather than passing because the shape moved.
|
||||
if fn.Sel.Name == "holdAppAfterFailedRollback" {
|
||||
holdCalled = true
|
||||
}
|
||||
if fn.Sel.Name == "undoCopyPhrase" {
|
||||
phraseCalled = true
|
||||
}
|
||||
case *ast.Ident:
|
||||
if fn.Name == "undoCopyPhrase" {
|
||||
phraseCalled = true
|
||||
|
||||
@@ -216,9 +216,12 @@ func TestR403_SkipIsRecordedForTheSurface(t *testing.T) {
|
||||
if !strings.Contains(cd.LastWarning, "hi") { // ASCII fragment of "hiányos" (R-364)
|
||||
t.Errorf("LastWarning does not carry the preserved-copy notice: %q", cd.LastWarning)
|
||||
}
|
||||
if cd.LastWarning != tier2UnitPreservedWarning &&
|
||||
!strings.Contains(cd.LastWarning, tier2UnitPreservedWarning) {
|
||||
t.Errorf("LastWarning is not the named constant: %q", cd.LastWarning)
|
||||
// v0.254.0 (R-557 release C): the notice is SAVED in the box's language, so the comparison is
|
||||
// against the bundle text for its key rather than against a Go constant. Same claim, measured one
|
||||
// step further out — a reworded sentence still fails, and so does a note written from a different key.
|
||||
if want := m.note(tier2UnitPreservedKey); cd.LastWarning != want &&
|
||||
!strings.Contains(cd.LastWarning, want) {
|
||||
t.Errorf("LastWarning is not the named notice: %q", cd.LastWarning)
|
||||
}
|
||||
// And the coverage the restore surface reads agrees, from the ARTIFACT rather than the record.
|
||||
cov, err := m.Tier2RestoreCoverage("app")
|
||||
|
||||
@@ -48,12 +48,12 @@ func TestR553_OffsiteQuota_DecisionSurvivesWordingChange(t *testing.T) {
|
||||
func TestR553_OffsiteQuota_HeadLineSurvivesWordingChange(t *testing.T) {
|
||||
tgt := &settings.OffboxTarget{Host: "nas.local", User: "felhom", RepoPath: "/srv/repo"}
|
||||
translated := util.KindErrorf(ErrOffsiteQuota, "The remote backup is over its storage quota (51/50 GB).")
|
||||
msg := offsiteFailureMessage(tgt, translated, 12*time.Second)
|
||||
msg := offsiteFailureMessage(tgt, translated, 12*time.Second, "hu")
|
||||
if !strings.HasPrefix(msg, "A távoli mentés nem fért el a tárhelykereten belül") {
|
||||
t.Errorf("a translated quota failure is reported with the wrong cause line: %q", msg)
|
||||
}
|
||||
unknown := errors.New("The remote backup is over its storage quota (51/50 GB).")
|
||||
if m := offsiteFailureMessage(tgt, unknown, time.Second); strings.HasPrefix(m, "A távoli mentés nem fért el") {
|
||||
if m := offsiteFailureMessage(tgt, unknown, time.Second, "hu"); strings.HasPrefix(m, "A távoli mentés nem fért el") {
|
||||
t.Errorf("an error WITHOUT the kind must not be guessed into the quota class from its words: %q", m)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,14 +18,16 @@ import (
|
||||
// Shape: one JSON file in the controller's state directory (DataDir, beside settings.json), written
|
||||
// atomically (atomicWrite: tmp + rename) at BOTH ends of an op. At startup a record still marked
|
||||
// running is, by construction, a restore nothing is running any more: it becomes a terminal failure
|
||||
// (Interrupted, RestoreInterruptedMessage) and a per-app notice that stays until that app's next
|
||||
// (Interrupted, RestoreInterruptedKey) and a per-app notice that stays until that app's next
|
||||
// restore. LoadRestoreRecord reports the conversion ONCE, so the caller raises restore_interrupted once.
|
||||
//
|
||||
// No path set (tests that build a bare Manager, a box with backup disabled) = the old in-memory
|
||||
// behaviour, silently: persistence is a property of the wired controller, not of every Manager.
|
||||
|
||||
// RestoreInterruptedMessage is what the household reads when the box stopped mid-restore.
|
||||
const RestoreInterruptedMessage = "A visszaállítás megszakadt (a doboz újraindult) — indítsd el újra."
|
||||
// RestoreInterruptedKey names what the household reads when the box stopped mid-restore. It is
|
||||
// SAVED in the restore record and read on the page afterwards, so it is rendered in the box's
|
||||
// language at the moment it is written (release C, R-557).
|
||||
const RestoreInterruptedKey = "note.restore.interrupted"
|
||||
|
||||
// restoreRecordFile is the on-disk shape.
|
||||
type restoreRecordFile struct {
|
||||
@@ -76,7 +78,7 @@ func (m *Manager) LoadRestoreRecord() *RestoreOpResult {
|
||||
}
|
||||
res := RestoreOpResult{
|
||||
Op: rec.Op, Stack: rec.Stack, OK: false,
|
||||
Message: RestoreInterruptedMessage, FinishedAt: time.Now(), Interrupted: true,
|
||||
Message: m.note(RestoreInterruptedKey), FinishedAt: time.Now(), Interrupted: true,
|
||||
}
|
||||
m.opLast = &res
|
||||
if m.opInterrupted == nil {
|
||||
|
||||
@@ -126,7 +126,7 @@ func (m *Manager) selectTier2TargetFrom(stackName, sourceDrive string, fullSize,
|
||||
return &Tier2Target{
|
||||
NamespaceRoot: NamespaceRoot(sp.Path, true),
|
||||
Label: label,
|
||||
Reason: "kézi választás",
|
||||
Reason: m.note("note.tier2.reason_manual"),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -149,7 +149,7 @@ func (m *Manager) selectTier2TargetFrom(stackName, sourceDrive string, fullSize,
|
||||
return &Tier2Target{
|
||||
NamespaceRoot: NamespaceRoot(sp.Path, true), // Model A: in-guest mount IS the namespace root
|
||||
Label: label,
|
||||
Reason: "másik adatmeghajtó",
|
||||
Reason: m.note("note.tier2.reason_other_drive"),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -171,10 +171,10 @@ func (m *Manager) selectTier2TargetFrom(stackName, sourceDrive string, fullSize,
|
||||
}
|
||||
return &Tier2Target{
|
||||
NamespaceRoot: NamespaceRoot(sys, false), // system path is a real root → felhom-data appended
|
||||
Label: "belső SSD (rendszer)",
|
||||
Label: m.note("note.tier2.label_internal_ssd"),
|
||||
IsSystemDrive: true,
|
||||
StateOnly: true,
|
||||
Reason: "nincs 2. adatmeghajtó — csak az adatbázis/konfiguráció fér a belső SSD-re; a nagy fájlokhoz 2. meghajtó kell",
|
||||
Reason: m.note("note.tier2.reason_no_second"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -321,14 +321,14 @@ func (m *Manager) RunTier2(stackName string) error {
|
||||
|
||||
target, err := m.selectTier2Target(stackName, fullSize, stateOnlySize)
|
||||
if err != nil {
|
||||
reason := tier2NoTargetReason(err)
|
||||
reason := m.tier2NoTargetReason(err)
|
||||
m.recordTier2NoTarget(stackName, reason)
|
||||
m.logger.Printf("[INFO] [backup] Tier 2 for %s: no off-drive target — %s", stackName, reason)
|
||||
return nil
|
||||
}
|
||||
// Defense-in-depth off-drive guard (selection already enforced it).
|
||||
if m.sameDevice(sourceDrive, target.NamespaceRoot) {
|
||||
m.recordTier2NoTarget(stackName, "a kiválasztott cél ugyanazon a fizikai lemezen van")
|
||||
m.recordTier2NoTarget(stackName, m.note("note.tier2.same_disk"))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -345,7 +345,7 @@ func (m *Manager) RunTier2(stackName string) error {
|
||||
}
|
||||
legs = kept
|
||||
if droppedOptional {
|
||||
warns = append(warns, "A belső SSD-n csak a konfiguráció, adatbázis és a kötelező adatok férnek el — a választható tartalom nem került másolásra.")
|
||||
warns = append(warns, m.note("note.tier2.ssd_partial"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,7 +383,7 @@ func (m *Manager) RunTier2(stackName string) error {
|
||||
// compose/app.yaml carries portable secrets and nothing from inside it is logged here.
|
||||
m.logger.Printf("[WARN] [backup] Tier 2 %s: unit leg SKIPPED — the recovery unit on the source drive lists no database dumps and no volume tars, while the existing copy at %s does. The copy was PRESERVED rather than replaced with an empty one (R-403). The other legs continue.",
|
||||
stackName, destUnit)
|
||||
warns = append(warns, tier2UnitPreservedWarning)
|
||||
warns = append(warns, m.note(tier2UnitPreservedKey))
|
||||
} else if err := mirror(unitDir, destUnit); err != nil {
|
||||
m.recordTier2Failure(stackName, target, err)
|
||||
if m.tier2Notify != nil {
|
||||
@@ -549,7 +549,7 @@ func (m *Manager) Tier2Info(stackName string) Tier2Info {
|
||||
target, err := m.selectTier2Target(stackName, fullSize, stateOnlySize)
|
||||
if err != nil {
|
||||
info.NoTarget = true
|
||||
info.NoTargetReason = tier2NoTargetReason(err)
|
||||
info.NoTargetReason = m.tier2NoTargetReason(err)
|
||||
return info
|
||||
}
|
||||
info.EffectiveLabel = target.Label
|
||||
@@ -668,14 +668,17 @@ func (m *Manager) recordTier2NoTarget(stackName, reason string) {
|
||||
})
|
||||
}
|
||||
|
||||
func tier2NoTargetReason(err error) string {
|
||||
// tier2NoTargetReason is SAVED (CrossDriveConfig.LastError / Tier2Info.NoTargetReason) and read on the
|
||||
// per-app card later, so it is written in the box's language at write time (release C, R-557). The
|
||||
// branch is on a SENTINEL, never on these words (R-553) — which is why translating them is safe.
|
||||
func (m *Manager) tier2NoTargetReason(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, errSSDNoHeadroom):
|
||||
return "nincs elég hely a belső SSD-n — a nagy fájlok off-drive mentéséhez 2. meghajtó (vagy távoli tárhely) szükséges"
|
||||
return m.note("note.tier2.no_space_ssd")
|
||||
case errors.Is(err, errNoOffDiskTarget):
|
||||
return "nincs másik fizikai meghajtó — a 2. mentéshez 2. meghajtó szükséges"
|
||||
return m.note("note.tier2.no_other_drive")
|
||||
default:
|
||||
return err.Error()
|
||||
return m.noteErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -700,7 +703,7 @@ func rsyncMirror(src, dst string) error {
|
||||
// tier2UnitPreservedWarning is the customer-facing half of the R-403 skip. It rides in
|
||||
// CrossDriveBackup.LastWarning, which the per-app card already renders, so the refusal reaches the
|
||||
// SURFACE and not only the log — the shape recordTier2NoTarget established.
|
||||
const tier2UnitPreservedWarning = "A fő meghajtón lévő adatcsomag hiányos volt, ezért a másodlagos másolatban meglévő, teljes csomagot megőriztük. A másolat adatcsomagja ezért régebbi, mint ez a mentés."
|
||||
const tier2UnitPreservedKey = "note.tier2.unit_preserved"
|
||||
|
||||
// unitPackageDate returns the `created_at` of a recovery unit's manifest — WHEN the package in that
|
||||
// directory was captured. "" when the manifest is absent or unparseable, which the surface must read
|
||||
|
||||
@@ -170,7 +170,7 @@ func (m *Manager) RunSharesTier2() error {
|
||||
}
|
||||
target, err := m.selectTier2TargetFrom(SharesPseudoStack, g.sourceDrive, fullSize, stateOnlySize)
|
||||
if err != nil {
|
||||
why := tier2NoTargetReason(err)
|
||||
why := m.tier2NoTargetReason(err)
|
||||
noTargetWhy = append(noTargetWhy, fmt.Sprintf("%s: %s", g.sourceDrive, why))
|
||||
m.logger.Printf("[INFO] [shares] tier-2: no off-drive target for shares on %s — %s", g.sourceDrive, why)
|
||||
continue
|
||||
|
||||
@@ -2196,5 +2196,76 @@
|
||||
"err.integrations.onlyoffice_no_subdomain": "the OnlyOffice subdomain is not known",
|
||||
"err.integrations.occ_failed": "the occ command failed (%s): %s (output: %s)",
|
||||
"err.integrations.occ_disable_failed": "occ app:disable failed: %s (output: %s)",
|
||||
"err.notify.hub_error": "hub error (%d): %s"
|
||||
"err.notify.hub_error": "hub error (%d): %s",
|
||||
"note.offsite.fail_quota": "The remote backup did not fit inside its storage limit",
|
||||
"note.offsite.fail_orphaned": "The remote store was made with an earlier key that is no longer available",
|
||||
"note.offsite.fail_no_repo": "There is no backup store at the remote target",
|
||||
"note.offsite.fail_no_units": "There was nothing to back up: none of the selected apps has a backup",
|
||||
"note.offsite.fail_transport": "The remote store cannot be reached (network or sign-in)",
|
||||
"note.offsite.fail_unknown": "The remote backup failed for an unknown reason",
|
||||
"note.offsite.fail_head": "The remote backup failed",
|
||||
"note.offsite.whole_unit_gap": "(the whole app — it has no local backup unit)",
|
||||
"note.offsite.no_local_unit": "These apps did NOT go into the remote backup, because they have no local backup unit yet: %s. The next backup usually makes one — if they are still listed after the second run, tell your operator.",
|
||||
"note.offsite.not_installed": "These apps are selected for remote backup but are not installed, so they cannot be backed up: %s. If you no longer need them, clear their selection on the Remote backup page.",
|
||||
"note.offsite.quota_partial": "Warning: because of the storage limit, %d apps got only a settings and database backup: %s.",
|
||||
"note.offsite.quota_usage": "The remote backup uses %d%% of its limit (%d/%d GB).",
|
||||
"note.tier2.reason_manual": "your own choice",
|
||||
"note.tier2.reason_other_drive": "another data drive",
|
||||
"note.tier2.label_internal_ssd": "internal SSD (system)",
|
||||
"note.tier2.reason_no_second": "no second data drive — only the database and settings fit on the internal SSD; large files need a second drive",
|
||||
"note.tier2.same_disk": "the target you picked is on the same physical disk",
|
||||
"note.tier2.ssd_partial": "Only the settings, the database and the required data fit on the internal SSD — the optional content was not copied.",
|
||||
"note.tier2.no_space_ssd": "not enough space on the internal SSD — backing large files off the drive needs a second drive (or remote storage)",
|
||||
"note.tier2.no_other_drive": "there is no second physical drive — the second backup needs one",
|
||||
"note.tier2.unit_preserved": "The data package on the main drive was incomplete, so the complete one already in the second copy was kept. The copy's data package is therefore older than this backup.",
|
||||
"note.restore.interrupted": "The restore was interrupted (the box restarted) — start it again.",
|
||||
"note.undo.present": "the earlier state is backed up: %s",
|
||||
"note.undo.partial": "the earlier state is only PARTLY backed up — present: %s; MISSING: %s",
|
||||
"note.undo.absent": "the backup of the earlier state is NOT where it should be (%s)",
|
||||
"note.undo.none": "no restorable copy of the earlier state was made",
|
||||
"note.reconstitute.copy_latest": "latest",
|
||||
"note.reconstitute.held": "restoring the data of %s stopped at %s, and the earlier state could not be put back either. The app stays stopped, for safety, so your data cannot be damaged further. Get in touch with us",
|
||||
"layout.nyelv": "Language",
|
||||
"note.restore.failed": "The restore failed: %s",
|
||||
"note.restore.full_failed": "The full restore failed: %s",
|
||||
"note.restore.recover_failed": "The recovery failed: %s",
|
||||
"note.restore.shares_failed": "Restoring the shares failed: %s",
|
||||
"note.restore.shares_recover_failed": "Recovering the shares failed: %s",
|
||||
"note.restore.unit_failed": "Restore failed: %s",
|
||||
"note.restore.file_failed": "File restore failed: %s",
|
||||
"note.restore.full_unit_failed": "Full restore failed: %s",
|
||||
"note.restore.recovered_files": "The missing files of %s are back among your live data.",
|
||||
"note.restore.shares_prepared": "The shares are restored — you can now put them back among your live data.",
|
||||
"note.restore.at_scratch": " into the check folder",
|
||||
"note.restore.at_scratch_named": " into the check folder: %s",
|
||||
"note.restore.scratch_full": "The full backup of %s is restored%s — together with your own files. Your existing data is unchanged.",
|
||||
"note.restore.scratch_state": "The settings and the database of %s are restored%s. Your own files (documents, pictures, uploads) did NOT come back — this check restore brings back only the app's settings and database. If you need your files, start “Prepare a full restore” on this page. Your existing data is unchanged.",
|
||||
"note.restore.files_count": "%d files",
|
||||
"note.restore.and_volumes": " and %d data volumes",
|
||||
"note.restore.and_database": " and the database",
|
||||
"note.restore.volumes_count": "%d data volumes",
|
||||
"note.restore.the_database": "the database",
|
||||
"note.restore.dumps_at": " (backup: %s)",
|
||||
"note.restore.reconstituted": "%s: %s restored%s — the app has restarted.",
|
||||
"note.restore.no_db_in_backup": " WARNING: this app HAS a database, but the backup held no database dump, so the database did NOT come back. The state from before the restore is backed up: %s",
|
||||
"note.restore.no_db_at_all": " This app has no database.",
|
||||
"note.restore.all_files_present": "Every file that was checked is in place.",
|
||||
"note.restore.files_from_second": "%s: %d files restored from the second copy.",
|
||||
"note.restore.scratch_incomplete": "The restore copy is incomplete — the last download did not finish. Start “Prepare a full restore” again.",
|
||||
"note.unit_restore_data": "%s: %s restored — the app has restarted.",
|
||||
"note.unit_restore_settings_only": "%s: the settings are back — the app has restarted. WARNING: this backup held only the settings, no data. The app's data did NOT come back from this backup.",
|
||||
"note.unit_restore_counts_unknown": "The restore of %s ran — the app has restarted. This backup has no backup unit, so we cannot tell you what came back from it. Check inside the app that your data is there.",
|
||||
"note.unit_restore_none_returned": "%s: WARNING — the backup lists %d data volumes and %d database dumps, but none of them came back. Your data is unchanged. Ask for help before you try again.",
|
||||
"note.tier2_no_coverage": "This app's data cannot be restored from this copy — the app was not stopped. Use the Start restore button on the Backup → Restore page.",
|
||||
"note.tier2_unit_available": "This app's data is not in files but in the app's own database and volumes — the app was not stopped. You can bring those back from the same copy with the “Full restore from the copy” button beside this one. Careful: that action OVERWRITES the current data, while this button only fills in the missing files.",
|
||||
"note.tier2_unit_not_covered": "The app's database and internal volumes are not part of this restore.",
|
||||
"note.tier2_unit_restore_source": "The restore came from the copy on the second drive (%s).",
|
||||
"note.tier2_unit_restore_source_unproven": "The restore came from the copy on the second drive (%s — that is the time of the last backup ATTEMPT; we cannot prove it succeeded).",
|
||||
"note.tier2_unit_action_label": "Full restore from the copy",
|
||||
"note.tier2_unit_confirm_base": "This action OVERWRITES the app's current data – its database and its internal volumes too – with the copy on the second drive. Anything made since the copy is lost.",
|
||||
"note.tier2_unit_confirm_date_fmt": " The copy is from: %s.",
|
||||
"note.tier2_unit_confirm_date_unproven_fmt": " The copy is from: %s – that is the time of the last backup attempt; we cannot prove it succeeded.",
|
||||
"note.tier2_unit_confirm_contrast": " The “Restore files” button beside it only fills in the missing files and overwrites nothing. The app stops while either one runs.",
|
||||
"note.tier2_unit_stale_clause": " WARNING: this copy's data package is older than the latest backup — the package on the main drive was incomplete, so the complete one was kept. The restore uses the package named above.",
|
||||
"note.tier2_unit_stale_notice_fmt": "The copy's data package is older than the latest backup (%s): the package on the main drive was incomplete, so the complete one already there was kept."
|
||||
}
|
||||
|
||||
@@ -2185,5 +2185,76 @@
|
||||
"err.integrations.onlyoffice_no_subdomain": "OnlyOffice aldomain nem ismert",
|
||||
"err.integrations.occ_failed": "occ parancs sikertelen (%s): %s (kimenet: %s)",
|
||||
"err.integrations.occ_disable_failed": "occ app:disable sikertelen: %s (kimenet: %s)",
|
||||
"err.notify.hub_error": "hub hiba (%d): %s"
|
||||
"err.notify.hub_error": "hub hiba (%d): %s",
|
||||
"note.offsite.fail_quota": "A távoli mentés nem fért el a tárhelykereten belül",
|
||||
"note.offsite.fail_orphaned": "A távoli tárhely egy korábbi, már nem elérhető kulccsal készült",
|
||||
"note.offsite.fail_no_repo": "A távoli tárhelyen nincs mentési adattár",
|
||||
"note.offsite.fail_no_units": "Nem volt mit menteni: egyetlen kijelölt alkalmazásnak sem található mentése",
|
||||
"note.offsite.fail_transport": "A távoli tárhely nem érhető el (hálózat vagy bejelentkezés)",
|
||||
"note.offsite.fail_unknown": "A távoli mentés ismeretlen okból nem sikerült",
|
||||
"note.offsite.fail_head": "A távoli mentés nem sikerült",
|
||||
"note.offsite.whole_unit_gap": "(a teljes alkalmazás — nincs helyi mentési egysége)",
|
||||
"note.offsite.no_local_unit": "Ezek az alkalmazások NEM kerültek be a távoli mentésbe, mert még nincs helyi mentési egységük: %s. A következő mentés általában már elkészíti — ha a második futás után is itt szerepelnek, szólj az üzemeltetőnek.",
|
||||
"note.offsite.not_installed": "Ezek az alkalmazások ki vannak jelölve távoli mentésre, de nincsenek telepítve, ezért nem menthetők: %s. Ha már nincs rájuk szükséged, vedd ki a kijelölésüket a Távoli mentés oldalon.",
|
||||
"note.offsite.quota_partial": "Figyelmeztetés: a tárhelykeret miatt %d alkalmazásnál csak konfiguráció- és adatbázis-mentés készült: %s.",
|
||||
"note.offsite.quota_usage": "A távoli mentés a keret %d%%-át használja (%d/%d GB).",
|
||||
"note.tier2.reason_manual": "kézi választás",
|
||||
"note.tier2.reason_other_drive": "másik adatmeghajtó",
|
||||
"note.tier2.label_internal_ssd": "belső SSD (rendszer)",
|
||||
"note.tier2.reason_no_second": "nincs 2. adatmeghajtó — csak az adatbázis/konfiguráció fér a belső SSD-re; a nagy fájlokhoz 2. meghajtó kell",
|
||||
"note.tier2.same_disk": "a kiválasztott cél ugyanazon a fizikai lemezen van",
|
||||
"note.tier2.ssd_partial": "A belső SSD-n csak a konfiguráció, adatbázis és a kötelező adatok férnek el — a választható tartalom nem került másolásra.",
|
||||
"note.tier2.no_space_ssd": "nincs elég hely a belső SSD-n — a nagy fájlok off-drive mentéséhez 2. meghajtó (vagy távoli tárhely) szükséges",
|
||||
"note.tier2.no_other_drive": "nincs másik fizikai meghajtó — a 2. mentéshez 2. meghajtó szükséges",
|
||||
"note.tier2.unit_preserved": "A fő meghajtón lévő adatcsomag hiányos volt, ezért a másodlagos másolatban meglévő, teljes csomagot megőriztük. A másolat adatcsomagja ezért régebbi, mint ez a mentés.",
|
||||
"note.restore.interrupted": "A visszaállítás megszakadt (a doboz újraindult) — indítsd el újra.",
|
||||
"note.undo.present": "a korábbi állapot mentése megvan: %s",
|
||||
"note.undo.partial": "a korábbi állapot mentése RÉSZBEN van meg — megvan: %s; HIÁNYZIK: %s",
|
||||
"note.undo.absent": "a korábbi állapot mentését NEM találjuk a helyén (%s)",
|
||||
"note.undo.none": "a korábbi állapotról nem készült menthető másolat",
|
||||
"note.reconstitute.copy_latest": "legutóbbi",
|
||||
"note.reconstitute.held": "a(z) %s adatainak visszaállítása %s-kor megszakadt, és a korábbi állapotot sem sikerült visszatölteni. Az alkalmazás biztonsági okból leállítva marad, hogy az adatai ne sérüljenek tovább. Vedd fel velünk a kapcsolatot",
|
||||
"layout.nyelv": "Nyelv",
|
||||
"note.restore.failed": "A visszaállítás sikertelen: %s",
|
||||
"note.restore.full_failed": "A teljes visszaállítás sikertelen: %s",
|
||||
"note.restore.recover_failed": "A helyreállítás sikertelen: %s",
|
||||
"note.restore.shares_failed": "A megosztások visszaállítása sikertelen: %s",
|
||||
"note.restore.shares_recover_failed": "A megosztások helyreállítása sikertelen: %s",
|
||||
"note.restore.unit_failed": "Visszaállítás sikertelen: %s",
|
||||
"note.restore.file_failed": "Fájl-visszaállítás sikertelen: %s",
|
||||
"note.restore.full_unit_failed": "Teljes visszaállítás sikertelen: %s",
|
||||
"note.restore.recovered_files": "A(z) %s hiányzó fájljai helyreállítva az élő adatok közé.",
|
||||
"note.restore.shares_prepared": "A megosztások visszaállítása elkészült — most helyreállíthatod az élő adatok közé.",
|
||||
"note.restore.at_scratch": " ellenőrző mappába",
|
||||
"note.restore.at_scratch_named": " ellenőrző mappába: %s",
|
||||
"note.restore.scratch_full": "A(z) %s teljes mentése visszaállítva%s — a saját fájljaiddal együtt. A meglévő adatok változatlanok.",
|
||||
"note.restore.scratch_state": "A(z) %s beállításai és adatbázisa visszaállítva%s. A saját fájljaid (dokumentumok, képek, feltöltések) NEM kerültek vissza — ez az ellenőrző visszaállítás csak az alkalmazás beállításait és adatbázisát hozza vissza. Ha a fájljaidra van szükséged, indítsd el a „Teljes visszaállítás előkészítése” lépést ezen az oldalon. A meglévő adatok változatlanok.",
|
||||
"note.restore.files_count": "%d fájl",
|
||||
"note.restore.and_volumes": " és %d adatkötet",
|
||||
"note.restore.and_database": " és az adatbázis",
|
||||
"note.restore.volumes_count": "%d adatkötet",
|
||||
"note.restore.the_database": "az adatbázis",
|
||||
"note.restore.dumps_at": " (mentés: %s)",
|
||||
"note.restore.reconstituted": "A(z) %s: %s visszaállítva%s — az alkalmazás újraindult.",
|
||||
"note.restore.no_db_in_backup": " FIGYELEM: ennek az alkalmazásnak VAN adatbázisa, de a mentés nem tartalmazott adatbázis-mentést, ezért az adatbázis NEM állt vissza. A visszaállítás előtti állapot mentése megvan: %s",
|
||||
"note.restore.no_db_at_all": " Ennek az alkalmazásnak nincs adatbázisa.",
|
||||
"note.restore.all_files_present": "Minden vizsgált fájl megvan a helyén.",
|
||||
"note.restore.files_from_second": "%s: %d fájl visszaállítva a másodlagos másolatból.",
|
||||
"note.restore.scratch_incomplete": "A visszaállítási másolat nem teljes — a legutóbbi letöltés nem fejeződött be. Indítsd újra a teljes visszaállítás előkészítését.",
|
||||
"note.unit_restore_data": "A(z) %s: %s visszaállítva — az alkalmazás újraindult.",
|
||||
"note.unit_restore_settings_only": "A(z) %s: a beállítások visszaálltak — az alkalmazás újraindult. FIGYELEM: ez a mentés csak a beállításokat tartalmazta, adatot nem. Az alkalmazás adatai NEM álltak vissza ebből a mentésből.",
|
||||
"note.unit_restore_counts_unknown": "A(z) %s visszaállítása lefutott — az alkalmazás újraindult. Ehhez a mentéshez nem tartozik mentési egység, ezért nem tudjuk megmondani, mi állt vissza belőle. Ellenőrizd az alkalmazásban, hogy megvannak-e az adataid.",
|
||||
"note.unit_restore_none_returned": "A(z) %s: FIGYELEM — a mentés %d adatkötetet és %d adatbázis-mentést sorol fel, de egyik sem állt vissza. Az adataid változatlanok maradtak. Kérj segítséget, mielőtt újra próbálod.",
|
||||
"note.tier2_no_coverage": "Ennek az alkalmazásnak az adatai nem ebből a másolatból állíthatók vissza — az alkalmazás nem állt le. Használd a Visszaállítás indítása gombot a Biztonsági mentés → Visszaállítás oldalon.",
|
||||
"note.tier2_unit_available": "Ennek az alkalmazásnak az adatai nem fájlokban, hanem az alkalmazás saját adatbázisában és köteteiben vannak — az alkalmazás nem állt le. Ezeket a mellette lévő „Teljes visszaállítás a másolatból” gombbal tudod visszahozni ugyanerről a másolatról. Figyelem: az a művelet FELÜLÍRJA a jelenlegi adatokat, míg ez a gomb csak a hiányzó fájlokat pótolja.",
|
||||
"note.tier2_unit_not_covered": "Az alkalmazás adatbázisa és belső kötetei nem tartoznak ebbe a visszaállításba.",
|
||||
"note.tier2_unit_restore_source": "A visszaállítás forrása a második meghajtón lévő másolat volt (%s).",
|
||||
"note.tier2_unit_restore_source_unproven": "A visszaállítás forrása a második meghajtón lévő másolat volt (%s — ez az utolsó mentési kísérlet ideje; azt nem tudjuk igazolni, hogy az sikeres volt).",
|
||||
"note.tier2_unit_action_label": "Teljes visszaállítás a másolatból",
|
||||
"note.tier2_unit_confirm_base": "Ez a művelet FELÜLÍRJA az alkalmazás jelenlegi adatait – az adatbázisát és a belső köteteit is – a második meghajtón lévő másolattal. Ami a másolat óta keletkezett, elveszik.",
|
||||
"note.tier2_unit_confirm_date_fmt": " A másolat kelte: %s.",
|
||||
"note.tier2_unit_confirm_date_unproven_fmt": " A másolat kelte: %s – ez az utolsó mentési kísérlet ideje, azt nem tudjuk igazolni, hogy sikeres volt.",
|
||||
"note.tier2_unit_confirm_contrast": " A mellette lévő „Fájlok visszaállítása” ezzel szemben csak a hiányzó fájlokat pótolja, és semmit nem ír felül. Az alkalmazás a művelet idejére leáll.",
|
||||
"note.tier2_unit_stale_clause": " FIGYELEM: ennek a másolatnak az adatcsomagja régebbi, mint a legutóbbi mentés — a fő meghajtón lévő csomag hiányos volt, ezért a meglévő, teljes másolatot megőriztük. A visszaállítás a fent megadott csomagot használja.",
|
||||
"note.tier2_unit_stale_notice_fmt": "A másolat adatcsomagja régebbi, mint a legutóbbi mentés (%s): a fő meghajtón lévő csomag hiányos volt, ezért a meglévő, teljes másolatot megőriztük."
|
||||
}
|
||||
|
||||
@@ -149,3 +149,25 @@ func ErrText(lang string, err error) string {
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// Text renders a bundle message in a KNOWN language — for a producer that has no request.
|
||||
//
|
||||
// Release C (v0.254.0) uses it for the sentences a background run SAVES: the note under last night's
|
||||
// backup, the last error, the proof result. Those are written at 03:00 and read days later, so there
|
||||
// is no reader to ask; the operator ruling (slice 2 §16, option 1) is that they are written in the
|
||||
// box's language AT WRITE TIME and shown verbatim afterwards.
|
||||
//
|
||||
// The consequence, stated rather than hidden: a household that switches language sees last night's
|
||||
// note in the old language until the next run rewrites it. The alternative — storing a code and
|
||||
// rendering live — needs a dozen new persisted fields and a legacy path for each, which is the
|
||||
// R-570 shape a dozen times over.
|
||||
func Text(lang, key string, args ...interface{}) string {
|
||||
b, err := i18n.Shared()
|
||||
if err != nil {
|
||||
return key
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return b.Msg(lang, key)
|
||||
}
|
||||
return b.Msgf(lang, key, args...)
|
||||
}
|
||||
|
||||
@@ -82,6 +82,14 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
// The anonymous language switch (v0.254.0, R-557). The sign-in page shows a globe, so the
|
||||
// globe has to work without a session. It writes ONE display-only cookie in the visitor's own
|
||||
// browser and touches nothing the household owns — langCookieHandler carries the reasoning.
|
||||
if r.URL.Path == langCookiePath && r.Method == http.MethodPost {
|
||||
s.langCookieHandler(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Claim/reset routes stay reachable pre-auth even on a claimed box: they are the RESET
|
||||
// entry (code-gated internally). Static assets for the page too. The guest launcher share
|
||||
// (v0.165.0) joins here — /s/<token> is a capability URL with NO admin session; the token
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -246,6 +247,11 @@ func claimPageAllowedPath(path string) bool {
|
||||
switch path {
|
||||
case "/claim", "/claim/request-new-code", "/api/health":
|
||||
return true
|
||||
case langCookiePath:
|
||||
// The claim page carries the language globe, so its switch has to work on an unclaimed box —
|
||||
// which is precisely the box whose first visitor may not read Hungarian (v0.254.0, R-557).
|
||||
// It sets a display-only cookie and can do nothing else; see langCookieHandler.
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(path, "/static/")
|
||||
}
|
||||
@@ -390,6 +396,23 @@ func (s *Server) handleClaimSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
// flips in seconds — the operator sees the customer claim land immediately.
|
||||
s.reportTriggerNow()
|
||||
s.claimClearFailures(ip)
|
||||
|
||||
// §16 (operator default, v0.254.0): the claim CARRIES the visitor's language. Someone who switched
|
||||
// the claim page to English and then claimed the box chose English deliberately; the first
|
||||
// dashboard they see should be in it, and the hub's e-mails will follow once slice 3 lands.
|
||||
//
|
||||
// Only here, and only on SUCCESS: this is the one moment an anonymous visitor becomes the
|
||||
// household, so it is the one moment their display cookie may become the household's setting.
|
||||
// Every other anonymous request leaves settings.json alone (langFor, CsrfProtect).
|
||||
if c, err := r.Cookie(langCookieName); err == nil && i18n.IsSupported(c.Value) {
|
||||
if err := s.settings.SetLanguage(c.Value); err != nil {
|
||||
// Not fatal: the box is claimed either way, and the household can switch on the dashboard.
|
||||
s.logger.Printf("[WARN] [web] claim: could not carry the visitor's language %q: %v", c.Value, err)
|
||||
} else {
|
||||
s.logger.Printf("[INFO] [web] claim: household language set to %q from the claim page", c.Value)
|
||||
}
|
||||
}
|
||||
|
||||
s.invalidateAllSessions() // reset: kill old sessions; first-claim: none exist
|
||||
|
||||
action := "claimed"
|
||||
|
||||
@@ -42,6 +42,17 @@ func (s *Server) CsrfProtect(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
// The anonymous language switch (v0.254.0, R-557). Exempt for a narrow and checkable reason:
|
||||
// the ONLY thing it can achieve is to change the language of the page the victim's own browser
|
||||
// shows them. It writes one display-only cookie, reads nothing, touches no setting, and its
|
||||
// redirect cannot leave this box (safeBackPath). A CSRF token here would also be unobtainable:
|
||||
// the visitor has no session to mint one from. **If this handler ever gains a second effect,
|
||||
// it needs CSRF that day** — the exemption is for what it does, not for where it lives.
|
||||
if r.URL.Path == langCookiePath {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Skip CSRF for Bearer-token authenticated requests.
|
||||
// Validate the token against the configured API key before skipping.
|
||||
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
|
||||
|
||||
@@ -1526,9 +1526,9 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus, lang string
|
||||
// preserved leg those are different dates and the run's is the flattering one.
|
||||
pkgDate, stale := rp.CopyDate, rp.PackagePreserved
|
||||
row.Tier2CopyDate, row.Tier2CopyDateProven = pkgDate, rp.CopyDateProven
|
||||
row.Tier2UnitConfirm = tier2UnitConfirmWithStaleness(pkgDate, row.Tier2CopyDateProven, stale)
|
||||
row.Tier2UnitConfirm = s.tier2UnitConfirmWithStaleness(pkgDate, row.Tier2CopyDateProven, stale)
|
||||
if stale && pkgDate != "" {
|
||||
row.Tier2UnitStaleNotice = fmt.Sprintf(tier2UnitStaleNoticeFmt, fmtRFC3339Local(pkgDate))
|
||||
row.Tier2UnitStaleNotice = s.msgLang(lang, tier2UnitStaleNoticeFmtKey, fmtRFC3339Local(pkgDate))
|
||||
}
|
||||
}
|
||||
switch cd.LastStatus {
|
||||
@@ -1662,7 +1662,7 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
res, err := s.backupMgr.RestoreFromRecoveryUnit(stackName)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] [web] Restore failed (async): stack=%s: %v", stackName, err)
|
||||
s.backupMgr.EndRestoreOp(false, "Visszaállítás sikertelen: "+err.Error())
|
||||
s.backupMgr.EndRestoreOp(false, s.note("note.restore.unit_failed", s.noteErr(err)))
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] Restore completed (async): stack=%s in %s (volumes %d/%d, dbs %d/%d)",
|
||||
@@ -1672,7 +1672,7 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// The customer reads it as "my data is back". The snapshot id is dropped from the sentence
|
||||
// deliberately: it identified WHICH backup ran and told the customer nothing about what came out
|
||||
// of it, which is the question the sentence exists to answer.
|
||||
s.backupMgr.EndRestoreOp(true, unitRestoreOutcomeMsg(stackName, res))
|
||||
s.backupMgr.EndRestoreOp(true, s.unitRestoreOutcomeMsg(stackName, res))
|
||||
}()
|
||||
http.Redirect(w, r, "/backups/restore?"+flashQuery("flash", "flash.restore.started"), http.StatusFound)
|
||||
}
|
||||
@@ -1702,31 +1702,31 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// that have nothing to do with whether the app has a database.
|
||||
//
|
||||
// No filesystem path appears in the message, only counts — same rule as reconstituteOutcomeMsg.
|
||||
func unitRestoreOutcomeMsg(app string, res backup.UnitRestoreResult) string {
|
||||
func (s *Server) unitRestoreOutcomeMsg(app string, res backup.UnitRestoreResult) string {
|
||||
if res.VolumesReplayed > 0 || res.DBsReplayed > 0 {
|
||||
var what string
|
||||
if res.VolumesReplayed > 0 {
|
||||
what = fmt.Sprintf("%d adatkötet", res.VolumesReplayed)
|
||||
what = s.note("note.restore.volumes_count", res.VolumesReplayed)
|
||||
}
|
||||
if res.DBsReplayed > 0 {
|
||||
if what != "" {
|
||||
what += " és az adatbázis"
|
||||
what += s.note("note.restore.and_database")
|
||||
} else {
|
||||
what = "az adatbázis"
|
||||
what = s.note("note.restore.the_database")
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf(unitRestoreDataMsgFmt, app, what)
|
||||
return s.note(unitRestoreDataKey, app, what)
|
||||
}
|
||||
// An unknown must never be drawn as a zero. The no-unit fallback cannot report counts, and the
|
||||
// zero-value shape would otherwise read as „the backup held only settings" over a restore that may
|
||||
// have replayed the app's whole dataset. Same failure direction as R-88: degrade to UNKNOWN.
|
||||
if res.CountsUnknown {
|
||||
return fmt.Sprintf(unitRestoreCountsUnknownMsgFmt, app)
|
||||
return s.note(unitRestoreCountsUnknownKey, app)
|
||||
}
|
||||
if res.ManifestVolumes+res.ManifestDBs > 0 {
|
||||
return fmt.Sprintf(unitRestoreNoneReturnedMsgFmt, app, res.ManifestVolumes, res.ManifestDBs)
|
||||
return s.note(unitRestoreNoneReturnedKey, app, res.ManifestVolumes, res.ManifestDBs)
|
||||
}
|
||||
return fmt.Sprintf(unitRestoreSettingsOnlyMsgFmt, app)
|
||||
return s.note(unitRestoreSettingsOnlyKey, app)
|
||||
}
|
||||
|
||||
// R-353 customer-facing strings. Named constants, not inlined, because each is asserted verbatim by
|
||||
@@ -1735,12 +1735,12 @@ func unitRestoreOutcomeMsg(app string, res backup.UnitRestoreResult) string {
|
||||
const (
|
||||
// unitRestoreDataMsgFmt — data really came back. %s app, %s the "N adatkötet[ és az adatbázis]"
|
||||
// clause built above.
|
||||
unitRestoreDataMsgFmt = "A(z) %s: %s visszaállítva — az alkalmazás újraindult."
|
||||
unitRestoreDataKey = "note.unit_restore_data"
|
||||
|
||||
// unitRestoreSettingsOnlyMsgFmt — nothing came back and the unit listed nothing. The FIGYELEM
|
||||
// sentence is a statement about THE BACKUP; it deliberately says nothing about whether the app has
|
||||
// data of its own, because the manifest cannot answer that (R-355).
|
||||
unitRestoreSettingsOnlyMsgFmt = "A(z) %s: a beállítások visszaálltak — az alkalmazás újraindult. FIGYELEM: ez a mentés csak a beállításokat tartalmazta, adatot nem. Az alkalmazás adatai NEM álltak vissza ebből a mentésből."
|
||||
unitRestoreSettingsOnlyKey = "note.unit_restore_settings_only"
|
||||
|
||||
// unitRestoreNoneReturnedMsgFmt — the unit listed data and none of it returned. %d volumes, %d
|
||||
// database dumps LISTED. It states the data is unchanged because that is true and load-bearing: the
|
||||
@@ -1748,9 +1748,9 @@ const (
|
||||
// who believes otherwise will do something worse than waiting.
|
||||
// unitRestoreCountsUnknownMsgFmt — the no-unit fallback. It claims only what is known: the restore
|
||||
// ran and the app is back. It deliberately does NOT say data returned and does NOT say it did not.
|
||||
unitRestoreCountsUnknownMsgFmt = "A(z) %s visszaállítása lefutott — az alkalmazás újraindult. Ehhez a mentéshez nem tartozik mentési egység, ezért nem tudjuk megmondani, mi állt vissza belőle. Ellenőrizd az alkalmazásban, hogy megvannak-e az adataid."
|
||||
unitRestoreCountsUnknownKey = "note.unit_restore_counts_unknown"
|
||||
|
||||
unitRestoreNoneReturnedMsgFmt = "A(z) %s: FIGYELEM — a mentés %d adatkötetet és %d adatbázis-mentést sorol fel, de egyik sem állt vissza. Az adataid változatlanok maradtak. Kérj segítséget, mielőtt újra próbálod."
|
||||
unitRestoreNoneReturnedKey = "note.unit_restore_none_returned"
|
||||
)
|
||||
|
||||
// monitoringIntegritySchedule (R-359) describes what the off-site integrity job actually does.
|
||||
@@ -1779,13 +1779,13 @@ const (
|
||||
// no file legs but a full unit mirror sitting in the copy — and it sent those customers to a
|
||||
// button on another page for data that is now restorable on the page they are already looking at.
|
||||
// tier2UnitAvailableMsg is that case now.
|
||||
tier2NoCoverageMsg = "Ennek az alkalmazásnak az adatai nem ebből a másolatból állíthatók vissza — az alkalmazás nem állt le. Használd a Visszaállítás indítása gombot a Biztonsági mentés → Visszaállítás oldalon."
|
||||
tier2NoCoverageKey = "note.tier2_no_coverage"
|
||||
|
||||
// tier2UnitAvailableMsg (R-103) — the copy holds no restorable FILES, but it does hold an openable
|
||||
// recovery unit, so the answer is the action beside this one, not a different page. It names the
|
||||
// button by its own label and says why the two differ, because the difference is the whole reason
|
||||
// they are not one button: this one overwrites.
|
||||
tier2UnitAvailableMsg = "Ennek az alkalmazásnak az adatai nem fájlokban, hanem az alkalmazás saját adatbázisában és köteteiben vannak — az alkalmazás nem állt le. Ezeket a mellette lévő „Teljes visszaállítás a másolatból” gombbal tudod visszahozni ugyanerről a másolatról. Figyelem: az a művelet FELÜLÍRJA a jelenlegi adatokat, míg ez a gomb csak a hiányzó fájlokat pótolja."
|
||||
tier2UnitAvailableKey = "note.tier2_unit_available"
|
||||
|
||||
// tier2UnitNotCoveredMsg is appended wherever the FILE restore DID run, so a clean result never
|
||||
// reads as a clean bill of health for data the operation never opened.
|
||||
@@ -1795,17 +1795,17 @@ const (
|
||||
// database or the named volumes. Deleting it would let a clean file-restore result read as a clean
|
||||
// bill of health for data the operation did not look at, which is the sentence it exists to
|
||||
// prevent.
|
||||
tier2UnitNotCoveredMsg = "Az alkalmazás adatbázisa és belső kötetei nem tartoznak ebbe a visszaállításba."
|
||||
tier2UnitNotCoveredKey = "note.tier2_unit_not_covered"
|
||||
|
||||
// The Tier-2 UNIT restore's outcome suffix (R-102, Scenario E). The outcome names WHICH copy was
|
||||
// just written over the app's live data — an action that overwrites must say what it overwrote
|
||||
// with, in the sentence the customer is left holding.
|
||||
tier2UnitRestoreSourceMsgFmt = "A visszaállítás forrása a második meghajtón lévő másolat volt (%s)."
|
||||
tier2UnitRestoreSourceKey = "note.tier2_unit_restore_source"
|
||||
|
||||
// …and the R-101 variant. CopyLastRun is the ATTEMPT clock: it advances on a FAILED Tier-2 run
|
||||
// too. Where no success has ever been recorded for this app, the date shown is evidence that a
|
||||
// copy was attempted and nothing more, and the sentence must not present it as evidence of a copy.
|
||||
tier2UnitRestoreSourceUnprovenMsgFmt = "A visszaállítás forrása a második meghajtón lévő másolat volt (%s — ez az utolsó mentési kísérlet ideje; azt nem tudjuk igazolni, hogy az sikeres volt)."
|
||||
tier2UnitRestoreSourceUnprovenKey = "note.tier2_unit_restore_source_unproven"
|
||||
|
||||
// R-102/R-103 — the DESTRUCTIVE CONFIRM, in pieces, and in Go rather than in the template.
|
||||
//
|
||||
@@ -1822,25 +1822,25 @@ const (
|
||||
// Contrast — how it differs from the additive button beside it. The register's own requirement:
|
||||
// a destructive operation reached from a non-destructive surface must carry the
|
||||
// difference in the confirm, not rely on the customer inferring it from a label.
|
||||
tier2UnitActionLabel = "Teljes visszaállítás a másolatból"
|
||||
tier2UnitActionLabelKey = "note.tier2_unit_action_label"
|
||||
|
||||
tier2UnitConfirmBase = "Ez a művelet FELÜLÍRJA az alkalmazás jelenlegi adatait – az adatbázisát és a belső köteteit is – a második meghajtón lévő másolattal. Ami a másolat óta keletkezett, elveszik."
|
||||
tier2UnitConfirmBaseKey = "note.tier2_unit_confirm_base"
|
||||
|
||||
tier2UnitConfirmDateFmt = " A másolat kelte: %s."
|
||||
tier2UnitConfirmDateFmtKey = "note.tier2_unit_confirm_date_fmt"
|
||||
|
||||
tier2UnitConfirmDateUnprovenFmt = " A másolat kelte: %s – ez az utolsó mentési kísérlet ideje, azt nem tudjuk igazolni, hogy sikeres volt."
|
||||
tier2UnitConfirmDateUnprovenFmtKey = "note.tier2_unit_confirm_date_unproven_fmt"
|
||||
|
||||
tier2UnitConfirmContrast = " A mellette lévő „Fájlok visszaállítása” ezzel szemben csak a hiányzó fájlokat pótolja, és semmit nem ír felül. Az alkalmazás a művelet idejére leáll."
|
||||
tier2UnitConfirmContrastKey = "note.tier2_unit_confirm_contrast"
|
||||
|
||||
// tier2UnitStaleClause (R-403) — the package in this copy is OLDER than the copy's newest run,
|
||||
// because that run PRESERVED it rather than replacing it with an empty one. Without this the
|
||||
// confirm would name a date the customer reads as "last night" over a package from before it.
|
||||
// The whole point of preserving the copy is lost if the surface then misdescribes what it kept.
|
||||
tier2UnitStaleClause = " FIGYELEM: ennek a másolatnak az adatcsomagja régebbi, mint a legutóbbi mentés — a fő meghajtón lévő csomag hiányos volt, ezért a meglévő, teljes másolatot megőriztük. A visszaállítás a fent megadott csomagot használja."
|
||||
tier2UnitStaleClauseKey = "note.tier2_unit_stale_clause"
|
||||
|
||||
// tier2UnitStaleNoticeFmt (R-403) — the same fact on the per-app backup card, where the customer
|
||||
// looks BEFORE deciding anything. %s is the package's own date.
|
||||
tier2UnitStaleNoticeFmt = "A másolat adatcsomagja régebbi, mint a legutóbbi mentés (%s): a fő meghajtón lévő csomag hiányos volt, ezért a meglévő, teljes másolatot megőriztük."
|
||||
tier2UnitStaleNoticeFmtKey = "note.tier2_unit_stale_notice_fmt"
|
||||
)
|
||||
|
||||
// tier2UnitConfirmMsg assembles the destructive confirm for one app's Tier-2 unit restore. Pure, so
|
||||
@@ -1848,26 +1848,26 @@ const (
|
||||
//
|
||||
// A copy with no recorded date at all still gets a confirm — it just cannot name one. Dropping the
|
||||
// whole confirm because a date is missing would remove the warning and keep the destruction.
|
||||
func tier2UnitConfirmMsg(copyDate string, proven bool) string {
|
||||
return tier2UnitConfirmWithStaleness(copyDate, proven, false)
|
||||
func (s *Server) tier2UnitConfirmMsg(copyDate string, proven bool) string {
|
||||
return s.tier2UnitConfirmWithStaleness(copyDate, proven, false)
|
||||
}
|
||||
|
||||
// tier2UnitConfirmWithStaleness is tier2UnitConfirmMsg for a copy whose PACKAGE may be older than its
|
||||
// newest run (R-403). ONE implementation, two callers — the two-argument form above is the ordinary
|
||||
// case where the run really did refresh the package.
|
||||
func tier2UnitConfirmWithStaleness(copyDate string, proven bool, stale bool) string {
|
||||
msg := tier2UnitConfirmBase
|
||||
func (s *Server) tier2UnitConfirmWithStaleness(copyDate string, proven bool, stale bool) string {
|
||||
msg := s.note(tier2UnitConfirmBaseKey)
|
||||
if copyDate != "" {
|
||||
if proven {
|
||||
msg += fmt.Sprintf(tier2UnitConfirmDateFmt, fmtRFC3339Local(copyDate))
|
||||
msg += s.note(tier2UnitConfirmDateFmtKey, fmtRFC3339Local(copyDate))
|
||||
} else {
|
||||
msg += fmt.Sprintf(tier2UnitConfirmDateUnprovenFmt, fmtRFC3339Local(copyDate))
|
||||
msg += s.note(tier2UnitConfirmDateUnprovenFmtKey, fmtRFC3339Local(copyDate))
|
||||
}
|
||||
}
|
||||
msg += tier2UnitConfirmContrast
|
||||
msg += s.note(tier2UnitConfirmContrastKey)
|
||||
// R-403 last, so it is the sentence the customer is left holding before they press.
|
||||
if stale {
|
||||
msg += tier2UnitStaleClause
|
||||
msg += s.note(tier2UnitStaleClauseKey)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
@@ -1876,7 +1876,7 @@ func tier2UnitConfirmWithStaleness(copyDate string, proven bool, stale bool) str
|
||||
// no date is recorded at all. It asks Tier2CopyDate — the SAME resolver the surface uses to pick the
|
||||
// date it puts in the confirm — so the sentence the customer approves and the sentence they are left
|
||||
// with cannot name different copies.
|
||||
func tier2UnitSourceMsg(cov backup.Tier2Coverage) string {
|
||||
func (s *Server) tier2UnitSourceMsg(cov backup.Tier2Coverage) string {
|
||||
// R-403: the OUTCOME names the same date the CONFIRM did — the PACKAGE's, not the copy's newest
|
||||
// run. Live on demo-hp 2026-08-31 these disagreed by two and a half hours (confirm 11:43, outcome
|
||||
// 14:23) after a preserved leg, and a customer reading both would not know which restore they had
|
||||
@@ -1887,9 +1887,9 @@ func tier2UnitSourceMsg(cov backup.Tier2Coverage) string {
|
||||
return ""
|
||||
}
|
||||
if proven {
|
||||
return fmt.Sprintf(tier2UnitRestoreSourceMsgFmt, fmtRFC3339Local(date))
|
||||
return s.note(tier2UnitRestoreSourceKey, fmtRFC3339Local(date))
|
||||
}
|
||||
return fmt.Sprintf(tier2UnitRestoreSourceUnprovenMsgFmt, fmtRFC3339Local(date))
|
||||
return s.note(tier2UnitRestoreSourceUnprovenKey, fmtRFC3339Local(date))
|
||||
}
|
||||
|
||||
// backupTier2RestoreHandler (C2, closes F2) restores an app's MISSING user files in place from its
|
||||
@@ -1932,9 +1932,9 @@ func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Reques
|
||||
// restorable on this one.
|
||||
cov, covErr := s.backupMgr.Tier2RestoreCoverage(stackName)
|
||||
if covErr == nil && !cov.CanRestore() {
|
||||
msg := tier2NoCoverageMsg
|
||||
msg := s.note(tier2NoCoverageKey)
|
||||
if cov.CanRestoreUnit() {
|
||||
msg = tier2UnitAvailableMsg
|
||||
msg = s.note(tier2UnitAvailableKey)
|
||||
}
|
||||
s.logger.Printf("[WARN] [web] Tier-2 file restore refused up front: stack=%s has no restorable subtree in its copy (unit_present=%v unit_restorable=%v) — app NOT stopped",
|
||||
stackName, cov.HasUnit, cov.CanRestoreUnit())
|
||||
@@ -1951,11 +1951,11 @@ func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Reques
|
||||
// apply to this app. Say that, and name the one that does, instead of "sikertelen".
|
||||
if errors.Is(err, backup.ErrTier2NoRestorableData) {
|
||||
s.logger.Printf("[WARN] [web] Tier-2 file restore not applicable: stack=%s", stackName)
|
||||
s.backupMgr.EndRestoreOp(false, tier2NoCoverageMsg)
|
||||
s.backupMgr.EndRestoreOp(false, s.note(tier2NoCoverageKey))
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[ERROR] [web] Tier-2 file restore failed (async): stack=%s: %v", stackName, err)
|
||||
s.backupMgr.EndRestoreOp(false, "Fájl-visszaállítás sikertelen: "+err.Error())
|
||||
s.backupMgr.EndRestoreOp(false, s.note("note.restore.file_failed", s.noteErr(err)))
|
||||
return
|
||||
}
|
||||
// C9-F1 (the quiet half): even where the restore DOES cover something it covers only the
|
||||
@@ -1963,12 +1963,12 @@ func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Reques
|
||||
// copy's recovery-unit/. „minden fájl megvan a helyén" was a blanket claim over data that was
|
||||
// never opened; immich's 1.3 GB Postgres unit is the case that makes it dangerous. Claim only
|
||||
// what was EXAMINED, and disclose the rest.
|
||||
msg := "Minden vizsgált fájl megvan a helyén."
|
||||
msg := s.note("note.restore.all_files_present")
|
||||
if n > 0 {
|
||||
msg = fmt.Sprintf("%s: %d fájl visszaállítva a másodlagos másolatból.", stackName, n)
|
||||
msg = s.note("note.restore.files_from_second", stackName, n)
|
||||
}
|
||||
if cov.HasUnit {
|
||||
msg += " " + tier2UnitNotCoveredMsg
|
||||
msg += " " + s.note(tier2UnitNotCoveredKey)
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] Tier-2 file restore completed (async): stack=%s (%d files, legs=%v)", stackName, n, cov.Legs)
|
||||
s.backupMgr.EndRestoreOp(true, msg)
|
||||
@@ -2024,7 +2024,7 @@ func (s *Server) backupTier2UnitRestoreHandler(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
if !cov.CanRestoreUnit() {
|
||||
s.logger.Printf("[WARN] [web] Tier-2 unit restore refused up front: stack=%s has no openable unit in its copy (unit_present=%v) — app NOT stopped", stackName, cov.HasUnit)
|
||||
http.Redirect(w, r, "/backups/apps?flash_error="+url.QueryEscape(tier2NoCoverageMsg), http.StatusFound)
|
||||
http.Redirect(w, r, "/backups/apps?"+flashQuery("flash_error", tier2NoCoverageKey), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2035,7 +2035,7 @@ func (s *Server) backupTier2UnitRestoreHandler(w http.ResponseWriter, r *http.Re
|
||||
res, err := s.backupMgr.RestoreTier2Unit(stackName)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] [web] Tier-2 unit restore failed (async): stack=%s: %v", stackName, err)
|
||||
s.backupMgr.EndRestoreOp(false, "Teljes visszaállítás sikertelen: "+err.Error())
|
||||
s.backupMgr.EndRestoreOp(false, s.note("note.restore.full_unit_failed", s.noteErr(err)))
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] Tier-2 unit restore completed (async): stack=%s in %s (volumes %d/%d, dbs %d/%d)",
|
||||
@@ -2045,8 +2045,8 @@ func (s *Server) backupTier2UnitRestoreHandler(w http.ResponseWriter, r *http.Re
|
||||
// second thing to keep honest. What IS added is which copy it came from and how old that copy
|
||||
// is: this action overwrote the customer's live data, and the sentence they are left with has
|
||||
// to say what it overwrote it with (Scenario E).
|
||||
msg := unitRestoreOutcomeMsg(stackName, res)
|
||||
if src := tier2UnitSourceMsg(cov); src != "" {
|
||||
msg := s.unitRestoreOutcomeMsg(stackName, res)
|
||||
if src := s.tier2UnitSourceMsg(cov); src != "" {
|
||||
msg += " " + src
|
||||
}
|
||||
s.backupMgr.EndRestoreOp(true, msg)
|
||||
|
||||
@@ -293,15 +293,37 @@ func renderI18nCase(t *testing.T, s *Server, lang string, c i18nCase) string {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
data := c.data()
|
||||
// The production choke point's language step (executeTemplate → addLanguageData), with a request that
|
||||
// carries no ?lang= — so the switch and the translated title behave exactly as on a real page.
|
||||
s.addLanguageData(data, httptest.NewRequest("GET", "/i18n-fixture", nil), lang)
|
||||
r := httptest.NewRequest("GET", "/i18n-fixture", nil)
|
||||
// TWO production paths, and the fixture has to be rendered through the RIGHT one or it is a
|
||||
// picture of something no visitor sees (v0.254.0, R-557 release C).
|
||||
//
|
||||
// * a dashboard page goes through executeTemplate → addLanguageData: session CSRF, and its
|
||||
// language globe posts to the HOUSEHOLD switch;
|
||||
// * the pages outside the dashboard chrome go through executeTemplateLang: no CSRF, and their
|
||||
// globe posts to /lang, the visitor's own display cookie.
|
||||
//
|
||||
// Rendering a shell through addLanguageData would bake a form the real page never serves — which
|
||||
// is exactly what the first attempt at this release did, and what the diff caught.
|
||||
if i18nDirectTemplates[c.tmpl] {
|
||||
data["Lang"] = lang
|
||||
addLangOptions(data, lang, langCookiePath, "/i18n-fixture", "")
|
||||
} else {
|
||||
s.addLanguageData(data, r, lang)
|
||||
}
|
||||
if err := s.templatesFor(lang).ExecuteTemplate(&buf, c.tmpl, data); err != nil {
|
||||
t.Fatalf("%s [%s]: render: %v", c.name, lang, err)
|
||||
}
|
||||
return relativeAgeRe.ReplaceAllString(buf.String(), "# $1")
|
||||
}
|
||||
|
||||
// i18nDirectTemplates are the templates rendered by executeTemplateLang — the pages a request with no
|
||||
// household session meets. Kept beside the harness that has to branch on it; i18nDirectPages (in
|
||||
// i18n_wiring_test.go) names the same set with one case and an English probe each.
|
||||
var i18nDirectTemplates = map[string]bool{
|
||||
"login": true, "claim": true, "recovery": true,
|
||||
"launcher_shared": true, "launcher_share_password": true, "catchall": true,
|
||||
}
|
||||
|
||||
func i18nTestServer(t *testing.T) *Server {
|
||||
s := testServer(t)
|
||||
s.loadTemplates()
|
||||
|
||||
@@ -193,6 +193,10 @@ func (s *Server) addLanguageData(data map[string]interface{}, r *http.Request, l
|
||||
if r != nil {
|
||||
data["LangSwitch"] = true
|
||||
data["LangSwitchBack"] = r.URL.Path
|
||||
// v0.254.0: the dashboard globe posts to the HOUSEHOLD switch — session CSRF, and it writes
|
||||
// settings.json. It never writes the visitor cookie: a signed-in household's choice belongs in
|
||||
// their settings, not in whichever browser they happen to be using.
|
||||
addLangOptions(data, lang, "/settings/language", r.URL.Path, s.csrfField(r))
|
||||
}
|
||||
// The alert banners were stored by a background health cycle and put into the page data by
|
||||
// baseData, which has no request and therefore no language. Re-rendered here, where the language
|
||||
@@ -218,6 +222,38 @@ func (s *Server) addLanguageData(data map[string]interface{}, r *http.Request, l
|
||||
}
|
||||
}
|
||||
|
||||
// LangOption is one entry in the globe menu. Name is the language's OWN name and is never
|
||||
// translated — a person looking for their language looks for the word they know.
|
||||
type LangOption struct {
|
||||
Code string
|
||||
Name string
|
||||
Current bool
|
||||
}
|
||||
|
||||
// langNativeNames — a language's own name, in itself. Not in the bundle on purpose: a bundle entry
|
||||
// would invite a translation, and „Magyar" translated into English is still „Magyar".
|
||||
var langNativeNames = map[string]string{"hu": "Magyar", "en": "English"}
|
||||
|
||||
// addLangOptions puts everything the lang_globe partial needs into a page's data.
|
||||
//
|
||||
// `csrf` is template.HTML and may be empty: the dashboard posts to the household switch and needs a
|
||||
// session CSRF field, the anonymous shells post to /lang and have no session to mint one from (the
|
||||
// exemption and its reasoning are in CsrfProtect).
|
||||
func addLangOptions(data map[string]interface{}, lang, action, back string, csrf template.HTML) {
|
||||
opts := make([]LangOption, 0, len(i18n.Supported))
|
||||
for _, code := range i18n.Supported {
|
||||
name := langNativeNames[code]
|
||||
if name == "" {
|
||||
name = code
|
||||
}
|
||||
opts = append(opts, LangOption{Code: code, Name: name, Current: code == lang})
|
||||
}
|
||||
data["LangOptions"] = opts
|
||||
data["LangAction"] = action
|
||||
data["LangBack"] = back
|
||||
data["LangCSRF"] = csrf
|
||||
}
|
||||
|
||||
// languageSwitchHandler stores the household's language (POST /settings/language) and goes back to
|
||||
// the page it came from. CSRF is enforced by the CsrfProtect middleware wrapping "/" (main.go), as for
|
||||
// every other dashboard form; the switch form carries {{.CSRFField}}.
|
||||
@@ -358,3 +394,94 @@ func stateLabelKey(state stacks.ContainerState) string {
|
||||
return "func.state.unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// ── The visitor's language, for a page with no household signed in (v0.254.0, R-557 release C) ──
|
||||
//
|
||||
// The sign-in, claim and recovery pages are met by someone who has not signed in. They have no
|
||||
// setting to read, and they must not be able to write the household's: a box's sign-in page is
|
||||
// reachable by anyone who can reach the box, and changing what the HOUSEHOLD reads from there would
|
||||
// be an anonymous write to something they own. Changing what THEY THEMSELVES read is not, and that is
|
||||
// the whole of what this cookie does.
|
||||
//
|
||||
// So: display-only, one of two values, their browser, never the household's setting. `langFor` reads
|
||||
// it only when there is no session (server.go), so a signed-in household can never inherit a language
|
||||
// a previous visitor picked in the same browser.
|
||||
|
||||
// langCookiePath is the anonymous switch's route.
|
||||
const langCookiePath = "/lang"
|
||||
|
||||
// langCookieName is the visitor's display-language cookie.
|
||||
const langCookieName = "felhom_lang"
|
||||
|
||||
// langCookieMaxAge — a year. A visitor who set it once should not have to set it again, and there is
|
||||
// nothing here worth expiring.
|
||||
const langCookieMaxAge = 365 * 24 * 60 * 60
|
||||
|
||||
// langCookieHandler serves POST /lang: the anonymous language switch.
|
||||
//
|
||||
// NO CSRF, deliberately and narrowly: the only thing a forged request can achieve is to change the
|
||||
// language of the page the VICTIM'S OWN BROWSER shows them, which is neither a secret nor the
|
||||
// household's. The handler cannot touch settings.json, cannot read anything, and cannot redirect off
|
||||
// this box (see the `back` check). If it ever gains a second effect, it needs CSRF that day.
|
||||
func (s *Server) langCookieHandler(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
lang := r.FormValue("lang")
|
||||
if !i18n.IsSupported(lang) {
|
||||
// Refuse rather than guess: an unsupported value is a bug or a probe, and silently writing
|
||||
// Hungarian would hide both.
|
||||
http.Error(w, "unsupported language", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: langCookieName,
|
||||
Value: lang,
|
||||
Path: "/",
|
||||
HttpOnly: true, // nothing on the page needs to read it; the server does
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
|
||||
MaxAge: langCookieMaxAge,
|
||||
})
|
||||
http.Redirect(w, r, safeBackPath(r.FormValue("back")), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// safeBackPath keeps an open redirect out of the one handler that takes a destination from an
|
||||
// anonymous request.
|
||||
//
|
||||
// Only a same-origin PATH is allowed. `//evil.example` is rejected too: a browser reads a
|
||||
// protocol-relative URL as another origin, so "starts with /" alone is not the test — which is the
|
||||
// mistake this function exists to not make.
|
||||
func safeBackPath(back string) string {
|
||||
if back == "" || !strings.HasPrefix(back, "/") || strings.HasPrefix(back, "//") {
|
||||
return "/"
|
||||
}
|
||||
if strings.Contains(back, "\\") || strings.ContainsAny(back, "\r\n") {
|
||||
return "/"
|
||||
}
|
||||
return back
|
||||
}
|
||||
|
||||
// ── Saved notes (v0.254.0, release C) ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// A restore runs in a goroutine with no request and finishes minutes later; its outcome is SAVED and
|
||||
// read on a page afterwards. There is no reader to ask, so the note is written in the BOX's language
|
||||
// at the moment it is written — the operator's ruling (slice 2 §16, option 1).
|
||||
//
|
||||
// The consequence, stated rather than hidden: a household that switches language sees the note from
|
||||
// the run before in the old language, until the next run rewrites it.
|
||||
|
||||
// boxLang is the language a SAVED note is written in.
|
||||
func (s *Server) boxLang() string {
|
||||
if s.settings == nil {
|
||||
return i18n.Default
|
||||
}
|
||||
return s.settings.GetLanguage()
|
||||
}
|
||||
|
||||
// note renders a saved note in the box's language.
|
||||
func (s *Server) note(key string, args ...interface{}) string {
|
||||
return util.Text(s.boxLang(), key, args...)
|
||||
}
|
||||
|
||||
// noteErr renders an error into a saved note in the box's language: its bundle message when it
|
||||
// carries one (release B), its own text otherwise.
|
||||
func (s *Server) noteErr(err error) string { return util.ErrText(s.boxLang(), err) }
|
||||
|
||||
@@ -303,7 +303,10 @@ func TestI18nDirectRenderPagesFollowLanguage(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var buf strings.Builder
|
||||
if err := s.executeTemplateLang(&buf, httptest.NewRequest(http.MethodGet, "/x", nil), p.tmpl, c.data()); err != nil {
|
||||
// The SAME request path the parity harness renders with. Since v0.254.0 the page carries a
|
||||
// language globe whose `back` is the path the visitor is on, so a different path here would
|
||||
// differ from the fixture in one attribute and say nothing about the language.
|
||||
if err := s.executeTemplateLang(&buf, httptest.NewRequest(http.MethodGet, "/i18n-fixture", nil), p.tmpl, c.data()); err != nil {
|
||||
t.Fatalf("%s [%s]: %v", p.tmpl, lang, err)
|
||||
}
|
||||
got := relativeAgeRe.ReplaceAllString(buf.String(), "# $1")
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
)
|
||||
|
||||
// Localisation slice 2 release C (R-557), scenarios S2–S4.
|
||||
//
|
||||
// The sign-in, claim and recovery pages are met by someone who has not signed in. They have no
|
||||
// setting to read and must not be able to write the household's — a box's sign-in page is reachable
|
||||
// by anyone who can reach the box. So their choice lives in their own browser, and the household's
|
||||
// setting is untouched until the one moment an anonymous visitor BECOMES the household: a successful
|
||||
// claim (§16).
|
||||
|
||||
func langReq(method, path string, cookies ...*http.Cookie) *http.Request {
|
||||
r := httptest.NewRequest(method, path, nil)
|
||||
for _, c := range cookies {
|
||||
r.AddCookie(c)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func langCookie(v string) *http.Cookie { return &http.Cookie{Name: langCookieName, Value: v} }
|
||||
|
||||
// newTestSession mints a real session on a Server built by testServer, whose session map the
|
||||
// production constructor would have made. A REAL session (not a made-up cookie value) because
|
||||
// langFor asks isValidSession, and a test that handed it an invalid one would be testing the
|
||||
// no-session path while claiming to test the session path.
|
||||
func newTestSession(s *Server) string {
|
||||
s.sessionsMu.Lock()
|
||||
if s.sessions == nil {
|
||||
s.sessions = map[string]*session{}
|
||||
}
|
||||
s.sessionsMu.Unlock()
|
||||
return s.createSession()
|
||||
}
|
||||
|
||||
// TestLangForPrecedence — the order is fixed and each step exists for a different reader. A table,
|
||||
// because the interesting failures are the CROSSINGS: a signed-in household inheriting a visitor's
|
||||
// cookie, or a visitor's `?lang=` leaking into the setting.
|
||||
//
|
||||
// RED-PROOF (REPORT): drop the `!s.hasSession(r)` guard in langFor → the "session wins over cookie"
|
||||
// rows fail, which is the row that protects the household.
|
||||
func TestLangForPrecedence(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
query string
|
||||
session bool
|
||||
cookie string
|
||||
saved string
|
||||
want string
|
||||
}{
|
||||
{name: "nothing at all → Hungarian", saved: "hu", want: "hu"},
|
||||
{name: "the household's saved setting", saved: "en", want: "en"},
|
||||
{name: "?lang= overrides the setting (testing door)", query: "en", saved: "hu", want: "en"},
|
||||
{name: "?lang= overrides a session too", query: "hu", session: true, saved: "en", want: "hu"},
|
||||
{name: "?lang= with an unsupported value is ignored", query: "de", saved: "hu", want: "hu"},
|
||||
{name: "no session → the visitor's cookie wins", cookie: "en", saved: "hu", want: "en"},
|
||||
{name: "no session, unsupported cookie → the setting", cookie: "de", saved: "hu", want: "hu"},
|
||||
{name: "no session, no cookie → the setting", saved: "en", want: "en"},
|
||||
// The one that matters: a household that signed in must never read a language a previous
|
||||
// visitor picked in the same browser.
|
||||
{name: "SESSION → the household's setting, cookie NOT read", session: true, cookie: "en", saved: "hu", want: "hu"},
|
||||
{name: "SESSION → the household's setting, the other way round", session: true, cookie: "hu", saved: "en", want: "en"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := testServer(t)
|
||||
if err := s.settings.SetLanguage(tc.saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := "/launcher"
|
||||
if tc.query != "" {
|
||||
path += "?lang=" + tc.query
|
||||
}
|
||||
var cookies []*http.Cookie
|
||||
if tc.cookie != "" {
|
||||
cookies = append(cookies, langCookie(tc.cookie))
|
||||
}
|
||||
if tc.session {
|
||||
cookies = append(cookies, &http.Cookie{Name: sessionCookieName, Value: newTestSession(s)})
|
||||
}
|
||||
if got := s.langFor(langReq(http.MethodGet, path, cookies...)); got != tc.want {
|
||||
t.Errorf("langFor = %q, want %q", got, tc.want)
|
||||
}
|
||||
// Whatever happened, reading a page never writes the household's setting.
|
||||
if got := s.settings.GetLanguage(); got != tc.saved {
|
||||
t.Errorf("the household's saved language changed to %q — reading a page must never write it", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLangCookieHandler — POST /lang. The whole of what it may do, and the whole of what it must
|
||||
// refuse.
|
||||
func TestLangCookieHandler(t *testing.T) {
|
||||
t.Run("a supported language sets the cookie and returns to the page", func(t *testing.T) {
|
||||
s := testServer(t)
|
||||
if err := s.settings.SetLanguage("hu"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodPost, "/lang", strings.NewReader("lang=en&back=%2Flogin"))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
s.langCookieHandler(w, r)
|
||||
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Errorf("status %d, want 303", w.Code)
|
||||
}
|
||||
if loc := w.Header().Get("Location"); loc != "/login" {
|
||||
t.Errorf("Location %q, want /login", loc)
|
||||
}
|
||||
var found *http.Cookie
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == langCookieName {
|
||||
found = c
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("no felhom_lang cookie was set")
|
||||
}
|
||||
if found.Value != "en" || !found.HttpOnly || found.SameSite != http.SameSiteLaxMode || found.Path != "/" {
|
||||
t.Errorf("cookie attributes wrong: %+v", found)
|
||||
}
|
||||
// THE POINT: the household's setting is untouched by an anonymous request.
|
||||
if got := s.settings.GetLanguage(); got != "hu" {
|
||||
t.Errorf("an anonymous POST changed the household's language to %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an unsupported language is refused and sets nothing", func(t *testing.T) {
|
||||
s := testServer(t)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodPost, "/lang", strings.NewReader("lang=de&back=%2Flogin"))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
s.langCookieHandler(w, r)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status %d, want 400 — silently writing Hungarian would hide the bug", w.Code)
|
||||
}
|
||||
if len(w.Result().Cookies()) != 0 {
|
||||
t.Errorf("a cookie was set for an unsupported language: %v", w.Result().Cookies())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("back may only be a same-origin path", func(t *testing.T) {
|
||||
for _, tc := range []struct{ back, want string }{
|
||||
{"/login", "/login"},
|
||||
{"/settings/security", "/settings/security"},
|
||||
{"", "/"},
|
||||
{"https://evil.example/x", "/"},
|
||||
{"//evil.example/x", "/"}, // protocol-relative: a browser reads this as another origin
|
||||
{"http://evil.example", "/"},
|
||||
{"/x\r\nSet-Cookie: a=b", "/"}, // header injection through the redirect
|
||||
{`/x\..\y`, "/"},
|
||||
} {
|
||||
s := testServer(t)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodPost, "/lang", strings.NewReader("lang=en&back="+urlQueryEscape(tc.back)))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
s.langCookieHandler(w, r)
|
||||
if loc := w.Header().Get("Location"); loc != tc.want {
|
||||
t.Errorf("back=%q → Location %q, want %q", tc.back, loc, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestGlobeOnAnonymousShells — the three pages a visitor meets carry the globe, it posts to /lang with
|
||||
// NO CSRF field, and `<html lang>` follows the visitor's cookie rather than the household's setting.
|
||||
func TestGlobeOnAnonymousShells(t *testing.T) {
|
||||
cases := map[string]i18nCase{}
|
||||
for _, c := range i18nCases() {
|
||||
cases[c.name] = c
|
||||
}
|
||||
for _, p := range i18nDirectPages {
|
||||
if p.tmpl == "launcher_shared" || p.tmpl == "launcher_share_password" || p.tmpl == "catchall" {
|
||||
continue // deliberately NO globe — a share visitor is a stranger (R-577), and the
|
||||
// not-found page has nothing to do
|
||||
}
|
||||
c := cases[p.caseName]
|
||||
t.Run(p.tmpl, func(t *testing.T) {
|
||||
s := i18nTestServer(t)
|
||||
if err := s.settings.SetLanguage("hu"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var b strings.Builder
|
||||
r := langReq(http.MethodGet, "/i18n-fixture", langCookie("en"))
|
||||
if err := s.executeTemplateLang(&b, r, p.tmpl, c.data()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := b.String()
|
||||
if !strings.Contains(got, `class="shell-lang"`) {
|
||||
t.Error("the page carries no language globe")
|
||||
}
|
||||
if !strings.Contains(got, `action="/lang"`) {
|
||||
t.Error("the globe does not post to /lang — a visitor must not write the household's setting")
|
||||
}
|
||||
// Scoped to the GLOBE block. The claim page carries its own pre-auth CSRF field for the
|
||||
// claim form itself, so a page-wide search would convict the wrong form — and did, on the
|
||||
// first run.
|
||||
gi := strings.Index(got, `class="shell-lang"`)
|
||||
ge := strings.Index(got[gi:], "</details></div>")
|
||||
if gi < 0 || ge < 0 {
|
||||
t.Fatal("could not isolate the globe block")
|
||||
}
|
||||
globe := got[gi : gi+ge]
|
||||
if strings.Contains(globe, `name="_csrf"`) {
|
||||
t.Error("the anonymous globe carries a CSRF field; there is no session to mint one from")
|
||||
}
|
||||
if !strings.Contains(globe, `action="/lang"`) {
|
||||
t.Error("the globe block does not post to /lang")
|
||||
}
|
||||
// The cookie decided the language, not the household's `hu`.
|
||||
if !strings.Contains(got, `<html lang="en"`) {
|
||||
t.Error("the visitor's cookie did not decide the language")
|
||||
}
|
||||
if got := s.settings.GetLanguage(); got != "hu" {
|
||||
t.Errorf("rendering the page changed the household's language to %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuestSharePagesHaveNoGlobe — the pages a STRANGER meets. Their language is not the household's
|
||||
// to lend and not theirs to keep here; that is R-577, an operator decision about what the share
|
||||
// feature promises. Pinned so it stays a decision rather than an oversight.
|
||||
func TestGuestSharePagesHaveNoGlobe(t *testing.T) {
|
||||
cases := map[string]i18nCase{}
|
||||
for _, c := range i18nCases() {
|
||||
cases[c.name] = c
|
||||
}
|
||||
for _, name := range []string{"launcher_shared_apps", "launcher_share_password", "catchall_unknown"} {
|
||||
c, ok := cases[name]
|
||||
if !ok {
|
||||
t.Fatalf("no parity case %q — this test no longer covers what it names", name)
|
||||
}
|
||||
s := i18nTestServer(t)
|
||||
var b strings.Builder
|
||||
if err := s.executeTemplateLang(&b, langReq(http.MethodGet, "/x"), c.tmpl, c.data()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(b.String(), "lang-globe") {
|
||||
t.Errorf("%s carries a language globe — R-577 is the decision that would put one there", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFooterDoesNotWrap — the sidebar footer holds version, globe and sign-out in ONE flex row, in
|
||||
// that order. The old switch was two text links that wrapped at the sidebar's width; the globe is one
|
||||
// icon, and this pins that the three items stay siblings of a single flex container rather than
|
||||
// drifting into a second line's worth of markup.
|
||||
//
|
||||
// It reads the MARKUP, which is what a test without a browser can read; the visual half is the
|
||||
// operator's click-through.
|
||||
func TestFooterDoesNotWrap(t *testing.T) {
|
||||
s := i18nTestServer(t)
|
||||
cases := map[string]i18nCase{}
|
||||
for _, c := range i18nCases() {
|
||||
cases[c.name] = c
|
||||
}
|
||||
c := cases["launcher_full"]
|
||||
got := renderI18nCase(t, s, "hu", c)
|
||||
|
||||
i := strings.Index(got, `class="sidebar-footer"`)
|
||||
if i < 0 {
|
||||
t.Fatal("no sidebar footer in the rendered page")
|
||||
}
|
||||
foot := got[i:]
|
||||
if j := strings.Index(foot, "</div>\n </div>"); j > 0 {
|
||||
foot = foot[:j]
|
||||
}
|
||||
for _, want := range []string{`class="version"`, `class="lang-globe"`, `class="logout-link"`} {
|
||||
if !strings.Contains(foot, want) {
|
||||
t.Errorf("the footer is missing %s", want)
|
||||
}
|
||||
}
|
||||
// Order: version, then globe, then sign-out.
|
||||
v, g, l := strings.Index(foot, `class="version"`), strings.Index(foot, `class="lang-globe"`), strings.Index(foot, `class="logout-link"`)
|
||||
if !(v < g && g < l) {
|
||||
t.Errorf("footer order is version=%d globe=%d logout=%d, want version < globe < logout", v, g, l)
|
||||
}
|
||||
// And the OLD two-link switch is gone — otherwise both could be present and the test would pass.
|
||||
if strings.Contains(got, "lang-switch-btn") {
|
||||
t.Error("the old two-text-link switch is still rendered beside the globe")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaimCarriesLanguage — §16. A visitor who switched the claim page to English and then claimed
|
||||
// the box gets an English dashboard. Only on SUCCESS, and only here: this is the one moment an
|
||||
// anonymous visitor becomes the household.
|
||||
func TestClaimCarriesLanguage(t *testing.T) {
|
||||
t.Run("a successful claim carries the cookie into the setting", func(t *testing.T) {
|
||||
s := testServer(t)
|
||||
if err := s.settings.SetLanguage("hu"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The carry is one block in handleClaimSubmit, after every gate. Exercised here through the
|
||||
// same two calls it makes, because a full claim needs a live code the fixture cannot mint.
|
||||
r := langReq(http.MethodPost, "/claim", langCookie("en"))
|
||||
if c, err := r.Cookie(langCookieName); err == nil && i18n.IsSupported(c.Value) {
|
||||
if err := s.settings.SetLanguage(c.Value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if got := s.settings.GetLanguage(); got != "en" {
|
||||
t.Errorf("the household's language is %q, want en", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an unsupported cookie is ignored", func(t *testing.T) {
|
||||
s := testServer(t)
|
||||
if err := s.settings.SetLanguage("hu"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := langReq(http.MethodPost, "/claim", langCookie("de"))
|
||||
if c, err := r.Cookie(langCookieName); err == nil && i18n.IsSupported(c.Value) {
|
||||
t.Fatal("an unsupported cookie was accepted")
|
||||
}
|
||||
if got := s.settings.GetLanguage(); got != "hu" {
|
||||
t.Errorf("the household's language changed to %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
// The SOURCE half: the carry is inside handleClaimSubmit, AFTER the failure paths return, so a
|
||||
// failed claim cannot reach it. A behaviour test cannot show that without a live claim code; the
|
||||
// position in the function is what makes it true, and the position is what this reads.
|
||||
t.Run("the carry sits after every refusal", func(t *testing.T) {
|
||||
src := mustReadSource(t, "claim.go")
|
||||
fn := src[strings.Index(src, "func (s *Server) handleClaimSubmit"):]
|
||||
carry := strings.Index(fn, "claim: household language set to")
|
||||
clear := strings.Index(fn, "s.claimClearFailures(ip)")
|
||||
if carry < 0 || clear < 0 {
|
||||
t.Fatal("the claim carry or the success marker moved — this test no longer reads what it names")
|
||||
}
|
||||
if carry < clear {
|
||||
t.Error("the language carry runs BEFORE the failures are cleared — a failed claim could reach it")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// mustReadSource reads a file in this package, for the few tests whose claim is about WHERE code sits
|
||||
// rather than what it returns.
|
||||
func mustReadSource(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// noteServer builds a Server whose saved-note helpers work — a note is written in the BOX's language
|
||||
// (release C), so a Server with no settings would render every note as its key. Hungarian, because
|
||||
// these tests assert the sentence a Hungarian household reads: the parity half.
|
||||
func noteServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
s := i18nTestServer(t)
|
||||
if err := s.settings.SetLanguage("hu"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// noteHU is the Hungarian text of a saved-note key, for a test that used to compare against a Go
|
||||
// constant. Same claim, now measured against the bundle instead of restated beside it.
|
||||
func noteHU(t *testing.T, key string) string {
|
||||
t.Helper()
|
||||
return noteServer(t).note(key)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
@@ -14,6 +13,7 @@ import (
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// Off-box (NAS) restic-SFTP backup handlers (Part B). Form POSTs that redirect to /backups with a flash.
|
||||
@@ -377,7 +377,7 @@ func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
defer cancel()
|
||||
if err := s.backupMgr.RestoreOffboxScratch(ctx, app, full); err != nil {
|
||||
s.logger.Printf("[ERROR] [web] off-box restore %s (full=%v, async): %v", app, full, err)
|
||||
s.backupMgr.EndRestoreOp(false, "A visszaállítás sikertelen: "+err.Error())
|
||||
s.backupMgr.EndRestoreOp(false, s.note("note.restore.failed", s.noteErr(err)))
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] off-box restore %s completed (full=%v, async)", app, full)
|
||||
@@ -386,7 +386,7 @@ func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// customer had no way to look at what they had just asked for. Resolve the real path and say
|
||||
// it. Fall back to the vague wording only if the path can no longer be resolved.
|
||||
where := s.backupMgr.OffsiteRestoreScratchPath(app)
|
||||
s.backupMgr.EndRestoreOp(true, restoreScratchOutcomeMsg(app, where, full))
|
||||
s.backupMgr.EndRestoreOp(true, restoreScratchOutcomeMsg(app, where, full, s.boxLang()))
|
||||
}()
|
||||
offboxRedirectTo(w, r, restoreWizardPath(app), "flash.offbox.restore_started", false)
|
||||
}
|
||||
@@ -406,18 +406,15 @@ func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// So the unit case states three things in order: what came back, what did NOT, and the next step
|
||||
// that gets it. The full case says the files came with it, because otherwise the absence of the
|
||||
// warning would be the only difference and an absence is not a statement.
|
||||
func restoreScratchOutcomeMsg(app, where string, full bool) string {
|
||||
at := " ellenőrző mappába"
|
||||
func restoreScratchOutcomeMsg(app, where string, full bool, lang string) string {
|
||||
at := util.Text(lang, "note.restore.at_scratch")
|
||||
if where != "" {
|
||||
at = " ellenőrző mappába: " + where
|
||||
at = util.Text(lang, "note.restore.at_scratch_named", where)
|
||||
}
|
||||
if full {
|
||||
return "A(z) " + app + " teljes mentése visszaállítva" + at +
|
||||
" — a saját fájljaiddal együtt. A meglévő adatok változatlanok."
|
||||
return util.Text(lang, "note.restore.scratch_full", app, at)
|
||||
}
|
||||
return "A(z) " + app + " beállításai és adatbázisa visszaállítva" + at +
|
||||
". A saját fájljaid (dokumentumok, képek, feltöltések) NEM kerültek vissza — ez az ellenőrző visszaállítás csak az alkalmazás beállításait és adatbázisát hozza vissza. " +
|
||||
"Ha a fájljaidra van szükséged, indítsd el a „Teljes visszaállítás előkészítése” lépést ezen az oldalon. A meglévő adatok változatlanok."
|
||||
return util.Text(lang, "note.restore.scratch_state", app, at)
|
||||
}
|
||||
|
||||
// offboxReconstituteHandler is the TRUE offsite restore (R-43, v0.148.0): files overwritten to the
|
||||
@@ -455,7 +452,7 @@ func (s *Server) offboxReconstituteHandler(w http.ResponseWriter, r *http.Reques
|
||||
// this controls the operation.
|
||||
if !s.backupMgr.OffboxFullScratchReady(app) {
|
||||
s.logger.Printf("[WARN] [web] off-box reconstitute refused for %s: the restore scratch carries no completion marker", app)
|
||||
offboxRedirectTo(w, r, restoreWizardPath(app), offsiteScratchIncompleteMsg, true)
|
||||
offboxRedirectTo(w, r, restoreWizardPath(app), offsiteScratchIncompleteKey, true)
|
||||
return
|
||||
}
|
||||
// R-351: a SEPARATE field from `confirm`. The restore's own confirm answers "overwrite my live
|
||||
@@ -469,12 +466,12 @@ func (s *Server) offboxReconstituteHandler(w http.ResponseWriter, r *http.Reques
|
||||
res, err := s.backupMgr.ReconstituteFromOffsite(ctx, app, ackPlacement)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] [web] off-box reconstitute %s (async): %v", app, err)
|
||||
s.backupMgr.EndRestoreOp(false, "A teljes visszaállítás sikertelen: "+err.Error())
|
||||
s.backupMgr.EndRestoreOp(false, s.note("note.restore.full_failed", s.noteErr(err)))
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] off-box reconstitute %s completed (async): files=%d dbs=%d snapshot=%s",
|
||||
app, res.FilesPlaced, res.DBsReplayed, res.SnapshotID)
|
||||
s.backupMgr.EndRestoreOp(true, reconstituteOutcomeMsg(app, res))
|
||||
s.backupMgr.EndRestoreOp(true, reconstituteOutcomeMsg(app, res, s.boxLang()))
|
||||
}()
|
||||
offboxRedirectTo(w, r, restoreWizardPath(app), "flash.offbox.full_restore_started", false)
|
||||
}
|
||||
@@ -483,16 +480,16 @@ func (s *Server) offboxReconstituteHandler(w http.ResponseWriter, r *http.Reques
|
||||
// attempted over a scratch that carries no completion marker. Named because two handlers assert it and
|
||||
// two tests assert it verbatim. It names the action that works — Lane 1 is customer-owned, so a refusal
|
||||
// that leaves the customer with no next step is not a refusal, it is a dead end.
|
||||
const offsiteScratchIncompleteMsg = "A visszaállítási másolat nem teljes — a legutóbbi letöltés nem fejeződött be. Indítsd újra a teljes visszaállítás előkészítését."
|
||||
const offsiteScratchIncompleteKey = "note.restore.scratch_incomplete"
|
||||
|
||||
// reconstituteOutcomeMsg builds the OUTCOME flash for a completed reconstitution. Pure, so the
|
||||
// wording is unit-testable — this string is the customer's only evidence that the operation did
|
||||
// what its label promised, and the zero-file and no-database cases must each read truthfully rather
|
||||
// than borrowing the confident sentence that belongs to the full case.
|
||||
func reconstituteOutcomeMsg(app string, res backup.OffsiteReconstituteResult) string {
|
||||
func reconstituteOutcomeMsg(app string, res backup.OffsiteReconstituteResult, lang string) string {
|
||||
when := ""
|
||||
if !res.DumpsAt.IsZero() {
|
||||
when = " (mentés: " + res.DumpsAt.In(getTimezone()).Format("2006-01-02 15:04") + ")"
|
||||
when = util.Text(lang, "note.restore.dumps_at", res.DumpsAt.In(getTimezone()).Format("2006-01-02 15:04"))
|
||||
}
|
||||
// R-354 — WHAT ACTUALLY CAME BACK, NAMED. The volume leg is stated whenever it returned anything,
|
||||
// because a restore that replayed an app's entire dataset and mentioned only its file count is
|
||||
@@ -500,14 +497,14 @@ func reconstituteOutcomeMsg(app string, res backup.OffsiteReconstituteResult) st
|
||||
// „5 fájl visszaállítva" over a run that had dropped a 1 422 848-byte volume archive. Every clause
|
||||
// here is conditional on having done the thing, so a snapshot with no volumes produces the exact
|
||||
// sentence it produced before (pinned by TestReconstituteOutcome_NoVolumesWordingUnchanged).
|
||||
what := fmt.Sprintf("%d fájl", res.FilesPlaced)
|
||||
what := util.Text(lang, "note.restore.files_count", res.FilesPlaced)
|
||||
if res.VolumesReplayed > 0 {
|
||||
what += fmt.Sprintf(" és %d adatkötet", res.VolumesReplayed)
|
||||
what += util.Text(lang, "note.restore.and_volumes", res.VolumesReplayed)
|
||||
}
|
||||
if res.DBsReplayed > 0 {
|
||||
what += " és az adatbázis"
|
||||
what += util.Text(lang, "note.restore.and_database")
|
||||
}
|
||||
msg := fmt.Sprintf("A(z) %s: %s visszaállítva%s — az alkalmazás újraindult.", app, what, when)
|
||||
msg := util.Text(lang, "note.restore.reconstituted", app, what, when)
|
||||
|
||||
if res.DBsReplayed == 0 {
|
||||
// A no-database app: saying "és az adatbázis" here would be a lie, and this is precisely the
|
||||
@@ -521,9 +518,9 @@ func reconstituteOutcomeMsg(app string, res backup.OffsiteReconstituteResult) st
|
||||
// path only when a live database for this app was found AND successfully dumped, so a non-empty
|
||||
// value proves the app HAS one. Same counter, two different facts, and now two sentences.
|
||||
if res.SafetyDump != "" {
|
||||
return msg + fmt.Sprintf(" FIGYELEM: ennek az alkalmazásnak VAN adatbázisa, de a mentés nem tartalmazott adatbázis-mentést, ezért az adatbázis NEM állt vissza. A visszaállítás előtti állapot mentése megvan: %s", filepath.Base(res.SafetyDump))
|
||||
return msg + util.Text(lang, "note.restore.no_db_in_backup", filepath.Base(res.SafetyDump))
|
||||
}
|
||||
return msg + " Ennek az alkalmazásnak nincs adatbázisa."
|
||||
return msg + util.Text(lang, "note.restore.no_db_at_all")
|
||||
}
|
||||
return msg
|
||||
}
|
||||
@@ -599,7 +596,7 @@ func (s *Server) offboxPlaceHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// and before v0.226.0 a POST over a failed download was accepted and reported success.
|
||||
if !s.backupMgr.OffboxFullScratchReady(app) {
|
||||
s.logger.Printf("[WARN] [web] off-box place refused for %s: the restore scratch carries no completion marker", app)
|
||||
offboxRedirectTo(w, r, restoreWizardPath(app), offsiteScratchIncompleteMsg, true)
|
||||
offboxRedirectTo(w, r, restoreWizardPath(app), offsiteScratchIncompleteKey, true)
|
||||
return
|
||||
}
|
||||
s.backupMgr.BeginRestoreOp("offbox-place", app)
|
||||
@@ -608,11 +605,11 @@ func (s *Server) offboxPlaceHandler(w http.ResponseWriter, r *http.Request) {
|
||||
defer cancel()
|
||||
if err := s.backupMgr.PlaceOffsiteRestore(ctx, app); err != nil {
|
||||
s.logger.Printf("[ERROR] [web] off-box place %s (async): %v", app, err)
|
||||
s.backupMgr.EndRestoreOp(false, "A helyreállítás sikertelen: "+err.Error())
|
||||
s.backupMgr.EndRestoreOp(false, s.note("note.restore.recover_failed", s.noteErr(err)))
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] off-box place %s completed (async)", app)
|
||||
s.backupMgr.EndRestoreOp(true, "A(z) "+app+" hiányzó fájljai helyreállítva az élő adatok közé.")
|
||||
s.backupMgr.EndRestoreOp(true, s.note("note.restore.recovered_files", app))
|
||||
}()
|
||||
offboxRedirectTo(w, r, restoreWizardPath(app), "flash.offbox.recover_started", false)
|
||||
}
|
||||
@@ -641,11 +638,11 @@ func (s *Server) sharesRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
defer cancel()
|
||||
if err := s.backupMgr.RestoreSharesScratch(ctx); err != nil {
|
||||
s.logger.Printf("[ERROR] [web] shares restore (async): %v", err)
|
||||
s.backupMgr.EndRestoreOp(false, "A megosztások visszaállítása sikertelen: "+err.Error())
|
||||
s.backupMgr.EndRestoreOp(false, s.note("note.restore.shares_failed", s.noteErr(err)))
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] shares restore completed (async)")
|
||||
s.backupMgr.EndRestoreOp(true, "A megosztások visszaállítása elkészült — most helyreállíthatod az élő adatok közé.")
|
||||
s.backupMgr.EndRestoreOp(true, s.note("note.restore.shares_prepared"))
|
||||
}()
|
||||
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.shares_restore_started", false)
|
||||
}
|
||||
@@ -668,7 +665,7 @@ func (s *Server) sharesPlaceHandler(w http.ResponseWriter, r *http.Request) {
|
||||
res, err := s.backupMgr.PlaceSharesRestore(ctx)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] [web] shares place (async): %v", err)
|
||||
s.backupMgr.EndRestoreOp(false, "A megosztások helyreállítása sikertelen: "+err.Error())
|
||||
s.backupMgr.EndRestoreOp(false, s.note("note.restore.shares_recover_failed", s.noteErr(err)))
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] shares place completed (async): %d file(s), %d definition(s)",
|
||||
|
||||
@@ -32,7 +32,7 @@ import (
|
||||
|
||||
// r103Row is a Tier-2-configured row with a successful copy — the shape the template needs before it
|
||||
// will render the actions block at all.
|
||||
func r103Row(unitRestorable bool, date string, proven bool) AppBackupRow {
|
||||
func r103Row(t *testing.T, unitRestorable bool, date string, proven bool) AppBackupRow {
|
||||
row := AppBackupRow{
|
||||
StackName: "docmost", DisplayName: "Docmost",
|
||||
Tier2Configured: true, Tier2Dest: "flash", Tier2Schedule: "Naponta",
|
||||
@@ -42,7 +42,7 @@ func r103Row(unitRestorable bool, date string, proven bool) AppBackupRow {
|
||||
}
|
||||
if unitRestorable {
|
||||
row.Tier2CopyDate, row.Tier2CopyDateProven = date, proven
|
||||
row.Tier2UnitConfirm = tier2UnitConfirmMsg(date, proven)
|
||||
row.Tier2UnitConfirm = noteServer(t).tier2UnitConfirmMsg(date, proven)
|
||||
}
|
||||
return row
|
||||
}
|
||||
@@ -50,13 +50,13 @@ func r103Row(unitRestorable bool, date string, proven bool) AppBackupRow {
|
||||
// D1 — TestR103_UnitActionOfferedWhenTheMirrorExists.
|
||||
func TestR103_UnitActionOfferedWhenTheMirrorExists(t *testing.T) {
|
||||
html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{
|
||||
r103Row(true, "2026-08-25T03:30:00Z", true),
|
||||
r103Row(t, true, "2026-08-25T03:30:00Z", true),
|
||||
}))
|
||||
if !strings.Contains(html, `action="/backup/tier2/unit-restore"`) {
|
||||
t.Error("the unit-restore form is not on the page — the refusal is still a dead end")
|
||||
}
|
||||
if !strings.Contains(html, tier2UnitActionLabel) {
|
||||
t.Errorf("the action is not labelled %q", tier2UnitActionLabel)
|
||||
if !strings.Contains(html, noteHU(t, tier2UnitActionLabelKey)) {
|
||||
t.Errorf("the action is not labelled %q", noteHU(t, tier2UnitActionLabelKey))
|
||||
}
|
||||
// The additive action must still be there, separately. Merging them is forbidden: they are
|
||||
// different promises (one adds, one overwrites).
|
||||
@@ -74,12 +74,12 @@ func TestR103_UnitActionOfferedWhenTheMirrorExists(t *testing.T) {
|
||||
// button, because it is a promise withdrawn at the moment of use.
|
||||
func TestR103_UnitActionAbsentWhenItDoesNot(t *testing.T) {
|
||||
html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{
|
||||
r103Row(false, "2026-08-25T03:30:00Z", true),
|
||||
r103Row(t, false, "2026-08-25T03:30:00Z", true),
|
||||
}))
|
||||
if strings.Contains(html, "/backup/tier2/unit-restore") {
|
||||
t.Error("the unit action was offered for a copy with no openable unit")
|
||||
}
|
||||
if strings.Contains(html, tier2UnitActionLabel) {
|
||||
if strings.Contains(html, noteHU(t, tier2UnitActionLabelKey)) {
|
||||
t.Error("the unit action's label leaked onto a row that must not offer it")
|
||||
}
|
||||
// NEGATIVE CONTROL for D1's assertions: the row itself did render, so D1's positives were not
|
||||
@@ -93,18 +93,18 @@ func TestR103_UnitActionAbsentWhenItDoesNot(t *testing.T) {
|
||||
// requires: a destructive operation reached from a non-destructive surface says so, and says how it
|
||||
// differs from the action beside it.
|
||||
func TestR103_ConfirmStatesTheOverwrite(t *testing.T) {
|
||||
confirm := tier2UnitConfirmMsg("2026-08-25T03:30:00Z", true)
|
||||
confirm := noteServer(t).tier2UnitConfirmMsg("2026-08-25T03:30:00Z", true)
|
||||
|
||||
if !strings.Contains(confirm, tier2UnitConfirmBase) {
|
||||
if !strings.Contains(confirm, noteHU(t, tier2UnitConfirmBaseKey)) {
|
||||
t.Error("the confirm does not state that the operation OVERWRITES live data")
|
||||
}
|
||||
if !strings.Contains(confirm, tier2UnitConfirmContrast) {
|
||||
if !strings.Contains(confirm, noteHU(t, tier2UnitConfirmContrastKey)) {
|
||||
t.Error("the confirm does not state how it differs from the additive restore beside it")
|
||||
}
|
||||
// NEGATIVE CONTROL: the additive restore's own confirm must NOT have acquired this language.
|
||||
// Without it, a test that passed because both buttons warn about overwriting would look green.
|
||||
html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{
|
||||
r103Row(true, "2026-08-25T03:30:00Z", true),
|
||||
r103Row(t, true, "2026-08-25T03:30:00Z", true),
|
||||
}))
|
||||
fileConfirmStart := strings.Index(html, "Visszaállítja a hiányzó fájlokat")
|
||||
if fileConfirmStart < 0 {
|
||||
@@ -136,11 +136,11 @@ func TestR103_ConfirmNamesTheCopyDate(t *testing.T) {
|
||||
t.Fatal("the stamp did not render as a local date — the assertion below would be vacuous")
|
||||
}
|
||||
|
||||
proven := tier2UnitConfirmMsg(stamp, true)
|
||||
proven := noteServer(t).tier2UnitConfirmMsg(stamp, true)
|
||||
if !strings.Contains(proven, rendered) {
|
||||
t.Errorf("the confirm does not name the copy's date: %q", proven)
|
||||
}
|
||||
unproven := tier2UnitConfirmMsg(stamp, false)
|
||||
unproven := noteServer(t).tier2UnitConfirmMsg(stamp, false)
|
||||
if !strings.Contains(unproven, rendered) {
|
||||
t.Errorf("the unproven confirm does not name the date: %q", unproven)
|
||||
}
|
||||
@@ -148,13 +148,13 @@ func TestR103_ConfirmNamesTheCopyDate(t *testing.T) {
|
||||
t.Error("a proven copy and a bare attempt got the SAME sentence — R-101 exactly")
|
||||
}
|
||||
// A copy with no recorded date still gets the warning; it just cannot name one.
|
||||
none := tier2UnitConfirmMsg("", false)
|
||||
if !strings.Contains(none, tier2UnitConfirmBase) || !strings.Contains(none, tier2UnitConfirmContrast) {
|
||||
none := noteServer(t).tier2UnitConfirmMsg("", false)
|
||||
if !strings.Contains(none, noteHU(t, tier2UnitConfirmBaseKey)) || !strings.Contains(none, noteHU(t, tier2UnitConfirmContrastKey)) {
|
||||
t.Errorf("a dateless copy lost its confirm entirely: %q", none)
|
||||
}
|
||||
|
||||
// And it is on the page, not merely constructible.
|
||||
html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{r103Row(true, stamp, true)}))
|
||||
html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{r103Row(t, true, stamp, true)}))
|
||||
if !strings.Contains(html, rendered) {
|
||||
t.Errorf("the copy's date %q is not in the rendered row", rendered)
|
||||
}
|
||||
@@ -163,23 +163,25 @@ func TestR103_ConfirmNamesTheCopyDate(t *testing.T) {
|
||||
// TestR103_AvailableMsgNamesTheButtonByItsLabel — the refusal that now points AT the new action must
|
||||
// name it by the label the button actually carries, or it points at nothing.
|
||||
func TestR103_AvailableMsgNamesTheButtonByItsLabel(t *testing.T) {
|
||||
if !strings.Contains(tier2UnitAvailableMsg, tier2UnitActionLabel) {
|
||||
t.Errorf("tier2UnitAvailableMsg does not name %q", tier2UnitActionLabel)
|
||||
if !strings.Contains(noteHU(t, tier2UnitAvailableKey), noteHU(t, tier2UnitActionLabelKey)) {
|
||||
t.Errorf("noteHU(t, tier2UnitAvailableKey) does not name %q", noteHU(t, tier2UnitActionLabelKey))
|
||||
}
|
||||
// It must NOT send anyone to another page any more; that is the R-103 defect it replaces.
|
||||
if strings.Contains(tier2UnitAvailableMsg, "oldalon") {
|
||||
if strings.Contains(noteHU(t, tier2UnitAvailableKey), "oldalon") {
|
||||
t.Error("the message still routes the customer to another page for a copy restorable here")
|
||||
}
|
||||
// The surviving no-coverage message still names the route that works.
|
||||
if !strings.Contains(tier2NoCoverageMsg, "oldalon") {
|
||||
t.Error("tier2NoCoverageMsg no longer names any route — Scenario G requires one")
|
||||
if !strings.Contains(noteHU(t, tier2NoCoverageKey), "oldalon") {
|
||||
t.Error("noteHU(t, tier2NoCoverageKey) no longer names any route — Scenario G requires one")
|
||||
}
|
||||
// C3 — tier2UnitNotCoveredMsg is NOT deleted: it is still appended where the FILE restore ran.
|
||||
if tier2UnitNotCoveredMsg == "" {
|
||||
t.Fatal("tier2UnitNotCoveredMsg was deleted")
|
||||
// C3 — the not-covered sentence is NOT deleted: it is still appended where the FILE restore ran.
|
||||
// Since v0.254.0 it is a SAVED note written in the box's language, so the key is what the source
|
||||
// names and the bundle is what carries the sentence; both halves are checked.
|
||||
if noteHU(t, tier2UnitNotCoveredKey) == "" {
|
||||
t.Fatal("the not-covered sentence was deleted from the bundle")
|
||||
}
|
||||
if !strings.Contains(readSourceFile(t, "handlers.go"), `msg += " " + tier2UnitNotCoveredMsg`) {
|
||||
t.Error("tier2UnitNotCoveredMsg is no longer appended by the file-restore handler")
|
||||
if !strings.Contains(readSourceFile(t, "handlers.go"), `msg += " " + s.note(tier2UnitNotCoveredKey)`) {
|
||||
t.Error("the not-covered sentence is no longer appended by the file-restore handler")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,8 +347,8 @@ func TestR103_HandlerPublishesTheOutcome(t *testing.T) {
|
||||
}
|
||||
// The sentence is yesterday's honest outcome PLUS the clause naming which copy overwrote the live
|
||||
// data (Scenario E). Asserted as an exact composition so neither half can silently vanish.
|
||||
wantOutcome := unitRestoreOutcomeMsg("app", backup.UnitRestoreResult{})
|
||||
wantSource := tier2UnitSourceMsg(backup.Tier2Coverage{CopyLastSuccess: r103CopyStamp})
|
||||
wantOutcome := noteServer(t).unitRestoreOutcomeMsg("app", backup.UnitRestoreResult{})
|
||||
wantSource := noteServer(t).tier2UnitSourceMsg(backup.Tier2Coverage{CopyLastSuccess: r103CopyStamp})
|
||||
if wantSource == "" {
|
||||
t.Fatal("the fixture recorded no copy date — the assertion below would be vacuous")
|
||||
}
|
||||
@@ -369,8 +371,10 @@ func TestR103_UnitRestoreRefusesAnUnopenableMirrorWithoutStopping(t *testing.T)
|
||||
if !strings.Contains(loc, "flash_error") {
|
||||
t.Fatalf("the restore was accepted over an unopenable mirror: %q", loc)
|
||||
}
|
||||
if !strings.Contains(loc, url.QueryEscape(tier2NoCoverageMsg)) {
|
||||
t.Errorf("refusal flash = %q, want tier2NoCoverageMsg", loc)
|
||||
// The flash carries a KEY since v0.252.0; the sentence is what this test is about, so it is
|
||||
// resolved the way the page resolves it.
|
||||
if got := flashSentence(t, loc); got != noteHU(t, tier2NoCoverageKey) {
|
||||
t.Errorf("refusal flash = %q, want the no-coverage sentence", got)
|
||||
}
|
||||
if st := m.RestoreStatus(); st.Running || st.Last != nil {
|
||||
t.Errorf("an operation was begun despite the refusal: %+v", st)
|
||||
@@ -404,10 +408,10 @@ func TestR103_FileRestoreRefusalPointsAtTheActionThatWorks(t *testing.T) {
|
||||
s, _ := newR103Server(t, true)
|
||||
rec := postTier2Restore(t, s, "app")
|
||||
loc := rec.Header().Get("Location")
|
||||
if !strings.Contains(loc, url.QueryEscape(tier2UnitAvailableMsg)) {
|
||||
t.Errorf("refusal flash = %q, want tier2UnitAvailableMsg", loc)
|
||||
if !strings.Contains(loc, url.QueryEscape(noteHU(t, tier2UnitAvailableKey))) {
|
||||
t.Errorf("refusal flash = %q, want noteHU(t, tier2UnitAvailableKey)", loc)
|
||||
}
|
||||
if strings.Contains(loc, url.QueryEscape(tier2NoCoverageMsg)) {
|
||||
if strings.Contains(loc, url.QueryEscape(noteHU(t, tier2NoCoverageKey))) {
|
||||
t.Error("the old dead-end message is still shown for a copy restorable here")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ import (
|
||||
// whether the app has data (R-361 destroyed apps' canonical .sql files for four months).
|
||||
|
||||
func TestUnitRestoreOutcome_VolumesAndDatabaseNamed(t *testing.T) {
|
||||
msg := unitRestoreOutcomeMsg("kimai", backup.UnitRestoreResult{
|
||||
msg := noteServer(t).unitRestoreOutcomeMsg("kimai", backup.UnitRestoreResult{
|
||||
VolumesReplayed: 2, DBsReplayed: 1, ManifestVolumes: 2, ManifestDBs: 1,
|
||||
})
|
||||
for _, want := range []string{"2 adatkötet", "az adatbázis"} {
|
||||
@@ -48,7 +48,7 @@ func TestUnitRestoreOutcome_VolumesAndDatabaseNamed(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUnitRestoreOutcome_BackupHeldOnlySettings(t *testing.T) {
|
||||
msg := unitRestoreOutcomeMsg("opengist", backup.UnitRestoreResult{})
|
||||
msg := noteServer(t).unitRestoreOutcomeMsg("opengist", backup.UnitRestoreResult{})
|
||||
for _, want := range []string{"csak a beállításokat tartalmazta", "NEM álltak vissza"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("a restore that returned no data must say so plainly; missing %q in %q", want, msg)
|
||||
@@ -64,7 +64,7 @@ func TestUnitRestoreOutcome_BackupHeldOnlySettings(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUnitRestoreOutcome_ManifestListedDataThatDidNotReturn(t *testing.T) {
|
||||
msg := unitRestoreOutcomeMsg("paperless-ngx", backup.UnitRestoreResult{
|
||||
msg := noteServer(t).unitRestoreOutcomeMsg("paperless-ngx", backup.UnitRestoreResult{
|
||||
VolumesReplayed: 0, DBsReplayed: 0, ManifestVolumes: 2, ManifestDBs: 1,
|
||||
})
|
||||
for _, want := range []string{"2 adatkötetet", "1 adatbázis-mentést", "változatlanok maradtak"} {
|
||||
@@ -79,7 +79,7 @@ func TestUnitRestoreOutcome_ManifestListedDataThatDidNotReturn(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUnitRestoreOutcome_DatabaseOnly(t *testing.T) {
|
||||
msg := unitRestoreOutcomeMsg("bookstack", backup.UnitRestoreResult{
|
||||
msg := noteServer(t).unitRestoreOutcomeMsg("bookstack", backup.UnitRestoreResult{
|
||||
VolumesReplayed: 0, DBsReplayed: 1, ManifestVolumes: 0, ManifestDBs: 1,
|
||||
})
|
||||
if !strings.Contains(msg, "az adatbázis visszaállítva") {
|
||||
@@ -199,7 +199,7 @@ func TestR353_HandlerPublishesTheOutcome(t *testing.T) {
|
||||
// entire dataset. **An unknown drawn as a zero is the R-88 failure direction**, and it is exactly what
|
||||
// this whole change exists to remove — so it must not be re-introduced by the fix itself.
|
||||
func TestUnitRestoreOutcome_NoUnitFallbackSaysUnknownNotEmpty(t *testing.T) {
|
||||
msg := unitRestoreOutcomeMsg("legacyapp", backup.UnitRestoreResult{CountsUnknown: true})
|
||||
msg := noteServer(t).unitRestoreOutcomeMsg("legacyapp", backup.UnitRestoreResult{CountsUnknown: true})
|
||||
|
||||
if strings.Contains(msg, "csak a beállításokat tartalmazta") {
|
||||
t.Fatal("FALSE CLAIM: told the customer the backup held no data when the counts were never " +
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
func TestReconstituteOutcome_NoDatabaseOnlyWhenThereIsNone(t *testing.T) {
|
||||
msg := reconstituteOutcomeMsg("opengist", backup.OffsiteReconstituteResult{
|
||||
FilesPlaced: 3, DBsReplayed: 0, SafetyDump: "",
|
||||
})
|
||||
}, "hu")
|
||||
if !strings.Contains(msg, "nincs adatbázisa") {
|
||||
t.Errorf("an app with genuinely no database should still say so; got %q", msg)
|
||||
}
|
||||
@@ -29,7 +29,7 @@ func TestReconstituteOutcome_DatabaseExistsButWasNotRestored(t *testing.T) {
|
||||
msg := reconstituteOutcomeMsg("paperless-ngx", backup.OffsiteReconstituteResult{
|
||||
FilesPlaced: 0, DBsReplayed: 0,
|
||||
SafetyDump: "/mnt/x/db-dumps/pre-restore-20260822T060000Z-paperless-ngx-postgres.sql",
|
||||
})
|
||||
}, "hu")
|
||||
if strings.Contains(msg, "nincs adatbázisa") {
|
||||
t.Fatalf("FALSE CLAIM: told the customer the app has no database while its undo copy proves it does; got %q", msg)
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func TestReconstituteOutcome_DatabaseExistsButWasNotRestored(t *testing.T) {
|
||||
func TestReconstituteOutcome_DatabaseRestoredIsUnchanged(t *testing.T) {
|
||||
msg := reconstituteOutcomeMsg("romm", backup.OffsiteReconstituteResult{
|
||||
FilesPlaced: 4, DBsReplayed: 1, SafetyDump: "/x/pre-restore-romm-mariadb.sql",
|
||||
})
|
||||
}, "hu")
|
||||
if !strings.Contains(msg, "és az adatbázis visszaállítva") {
|
||||
t.Errorf("the full case must keep its wording; got %q", msg)
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func TestReconstituteOutcome_DatabaseRestoredIsUnchanged(t *testing.T) {
|
||||
func TestReconstituteOutcome_VolumesAreNamed(t *testing.T) {
|
||||
msg := reconstituteOutcomeMsg("calibre-web", backup.OffsiteReconstituteResult{
|
||||
FilesPlaced: 5, VolumesReplayed: 1, DBsReplayed: 0, SafetyDump: "",
|
||||
})
|
||||
}, "hu")
|
||||
if !strings.Contains(msg, "5 fájl és 1 adatkötet visszaállítva") {
|
||||
t.Errorf("the message must name the volume that came back; got %q", msg)
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func TestReconstituteOutcome_VolumesAreNamed(t *testing.T) {
|
||||
func TestReconstituteOutcome_VolumeOnlyAppSaysWhatCameBack(t *testing.T) {
|
||||
msg := reconstituteOutcomeMsg("privatebin", backup.OffsiteReconstituteResult{
|
||||
FilesPlaced: 0, VolumesReplayed: 1, DBsReplayed: 0, SafetyDump: "",
|
||||
})
|
||||
}, "hu")
|
||||
if !strings.Contains(msg, "0 fájl és 1 adatkötet visszaállítva") {
|
||||
t.Errorf("a volume-only restore must state the volume; got %q", msg)
|
||||
}
|
||||
@@ -91,13 +91,13 @@ func TestReconstituteOutcome_VolumeOnlyAppSaysWhatCameBack(t *testing.T) {
|
||||
func TestReconstituteOutcome_NoVolumesWordingUnchanged(t *testing.T) {
|
||||
noDB := reconstituteOutcomeMsg("opengist", backup.OffsiteReconstituteResult{
|
||||
FilesPlaced: 3, VolumesReplayed: 0, DBsReplayed: 0, SafetyDump: "",
|
||||
})
|
||||
}, "hu")
|
||||
if noDB != "A(z) opengist: 3 fájl visszaállítva — az alkalmazás újraindult. Ennek az alkalmazásnak nincs adatbázisa." {
|
||||
t.Errorf("the no-volume, no-database wording changed; got %q", noDB)
|
||||
}
|
||||
withDB := reconstituteOutcomeMsg("romm", backup.OffsiteReconstituteResult{
|
||||
FilesPlaced: 4, VolumesReplayed: 0, DBsReplayed: 1, SafetyDump: "/x/pre-restore-romm-mariadb.sql",
|
||||
})
|
||||
}, "hu")
|
||||
if withDB != "A(z) romm: 4 fájl és az adatbázis visszaállítva — az alkalmazás újraindult." {
|
||||
t.Errorf("the no-volume, with-database wording changed; got %q", withDB)
|
||||
}
|
||||
@@ -110,7 +110,7 @@ func TestReconstituteOutcome_NoVolumesWordingUnchanged(t *testing.T) {
|
||||
func TestReconstituteOutcome_AllThreeLegs(t *testing.T) {
|
||||
msg := reconstituteOutcomeMsg("paperless-ngx", backup.OffsiteReconstituteResult{
|
||||
FilesPlaced: 12, VolumesReplayed: 3, DBsReplayed: 1, SafetyDump: "/x/pre-restore-p.sql",
|
||||
})
|
||||
}, "hu")
|
||||
want := "A(z) paperless-ngx: 12 fájl és 3 adatkötet és az adatbázis visszaállítva — az alkalmazás újraindult."
|
||||
if msg != want {
|
||||
t.Errorf("got %q\nwant %q", msg, want)
|
||||
|
||||
@@ -80,8 +80,10 @@ func TestR358_PlaceHandlerRefusesIncompleteScratch(t *testing.T) {
|
||||
s.offboxPlaceHandler(w, req)
|
||||
|
||||
loc := w.Header().Get("Location")
|
||||
if !strings.Contains(loc, "nem+teljes") && !strings.Contains(loc, "nem%20teljes") {
|
||||
t.Fatalf("a direct POST over a part-copy was NOT refused server-side; redirect was %q", loc)
|
||||
// The flash carries a KEY since v0.252.0; resolved here the way the page resolves it, so the
|
||||
// assertion is still about the SENTENCE the customer reads.
|
||||
if !strings.Contains(flashSentence(t, loc), "nem teljes") {
|
||||
t.Fatalf("a direct POST over a part-copy was NOT refused server-side; redirect was %q -> %q", loc, flashSentence(t, loc))
|
||||
}
|
||||
if m.RestoreStatus().Running {
|
||||
t.Fatal("the place operation actually STARTED over an incomplete scratch")
|
||||
@@ -101,8 +103,8 @@ func TestR358_ReconstituteHandlerRefusesIncompleteScratch(t *testing.T) {
|
||||
s.offboxReconstituteHandler(w, req)
|
||||
|
||||
loc := w.Header().Get("Location")
|
||||
if !strings.Contains(loc, "nem+teljes") && !strings.Contains(loc, "nem%20teljes") {
|
||||
t.Fatalf("the DESTRUCTIVE restore was not refused over a part-copy; redirect was %q", loc)
|
||||
if !strings.Contains(flashSentence(t, loc), "nem teljes") {
|
||||
t.Fatalf("the DESTRUCTIVE restore was not refused over a part-copy; redirect was %q -> %q", loc, flashSentence(t, loc))
|
||||
}
|
||||
if m.RestoreStatus().Running {
|
||||
t.Fatal("the destructive restore actually STARTED over an incomplete scratch")
|
||||
|
||||
@@ -22,9 +22,9 @@ func TestR403_SkippedUnitLegIsNotRenderedAsFresh(t *testing.T) {
|
||||
const runDate = "2026-08-31T03:30:00Z"
|
||||
const pkgDate = "2026-08-25T03:30:00Z"
|
||||
|
||||
stale := r103Row(true, pkgDate, true)
|
||||
stale := r103Row(t, true, pkgDate, true)
|
||||
stale.Tier2LastRun, stale.Tier2LastSuccess = runDate, runDate
|
||||
stale.Tier2UnitStaleNotice = staleNoticeFor(pkgDate)
|
||||
stale.Tier2UnitStaleNotice = staleNoticeFor(t, pkgDate)
|
||||
|
||||
html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{stale}))
|
||||
|
||||
@@ -38,9 +38,9 @@ func TestR403_SkippedUnitLegIsNotRenderedAsFresh(t *testing.T) {
|
||||
|
||||
// NEGATIVE CONTROL: an ordinary row must NOT carry the notice, or D1 would pass on a page that
|
||||
// shows the warning to everybody.
|
||||
fresh := r103Row(true, runDate, true)
|
||||
fresh := r103Row(t, true, runDate, true)
|
||||
freshHTML := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{fresh}))
|
||||
if strings.Contains(freshHTML, staleNoticeFor(pkgDate)) {
|
||||
if strings.Contains(freshHTML, staleNoticeFor(t, pkgDate)) {
|
||||
t.Error("an ordinary row carried the preserved-package notice")
|
||||
}
|
||||
if !strings.Contains(freshHTML, "/backup/tier2/unit-restore") {
|
||||
@@ -48,8 +48,8 @@ func TestR403_SkippedUnitLegIsNotRenderedAsFresh(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func staleNoticeFor(pkgDate string) string {
|
||||
return strings.Replace(tier2UnitStaleNoticeFmt, "%s", fmtRFC3339Local(pkgDate), 1)
|
||||
func staleNoticeFor(t *testing.T, pkgDate string) string {
|
||||
return noteServer(t).note(tier2UnitStaleNoticeFmtKey, fmtRFC3339Local(pkgDate))
|
||||
}
|
||||
|
||||
// D2 — TestR403_UnitRestoreOfferNamesTheOlderPackageDate.
|
||||
@@ -58,21 +58,21 @@ func staleNoticeFor(pkgDate string) string {
|
||||
// preserved leg it must name the PACKAGE's date and say why it is older than the copy's newest run.
|
||||
func TestR403_UnitRestoreOfferNamesTheOlderPackageDate(t *testing.T) {
|
||||
const pkgDate = "2026-08-25T03:30:00Z"
|
||||
staleConfirm := tier2UnitConfirmWithStaleness(pkgDate, true, true)
|
||||
freshConfirm := tier2UnitConfirmWithStaleness(pkgDate, true, false)
|
||||
staleConfirm := noteServer(t).tier2UnitConfirmWithStaleness(pkgDate, true, true)
|
||||
freshConfirm := noteServer(t).tier2UnitConfirmWithStaleness(pkgDate, true, false)
|
||||
|
||||
if !strings.Contains(staleConfirm, tier2UnitStaleClause) {
|
||||
if !strings.Contains(staleConfirm, noteHU(t, tier2UnitStaleClauseKey)) {
|
||||
t.Error("the confirm does not say the package is older than the newest run")
|
||||
}
|
||||
if !strings.Contains(staleConfirm, fmtRFC3339Local(pkgDate)) {
|
||||
t.Error("the confirm does not name the package's date")
|
||||
}
|
||||
// Everything the ordinary confirm promised is still promised.
|
||||
if !strings.Contains(staleConfirm, tier2UnitConfirmBase) || !strings.Contains(staleConfirm, tier2UnitConfirmContrast) {
|
||||
if !strings.Contains(staleConfirm, noteHU(t, tier2UnitConfirmBaseKey)) || !strings.Contains(staleConfirm, noteHU(t, tier2UnitConfirmContrastKey)) {
|
||||
t.Error("the stale confirm lost the overwrite warning or the additive contrast")
|
||||
}
|
||||
// NEGATIVE CONTROL: the ordinary confirm must NOT carry the clause.
|
||||
if strings.Contains(freshConfirm, tier2UnitStaleClause) {
|
||||
if strings.Contains(freshConfirm, noteHU(t, tier2UnitStaleClauseKey)) {
|
||||
t.Error("an ordinary confirm carried the preserved-package clause")
|
||||
}
|
||||
if staleConfirm == freshConfirm {
|
||||
@@ -80,14 +80,14 @@ func TestR403_UnitRestoreOfferNamesTheOlderPackageDate(t *testing.T) {
|
||||
}
|
||||
|
||||
// And it reaches the rendered markup, not only the constant.
|
||||
row := r103Row(true, pkgDate, true)
|
||||
row := r103Row(t, true, pkgDate, true)
|
||||
row.Tier2UnitConfirm = staleConfirm
|
||||
html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{row}))
|
||||
if !strings.Contains(html, "FIGYELEM") { // ASCII-only fragment, R-364
|
||||
t.Error("the stale clause never reached the page")
|
||||
}
|
||||
// The ASCII control: the same page WITHOUT the clause must not match.
|
||||
rowFresh := r103Row(true, pkgDate, true)
|
||||
rowFresh := r103Row(t, true, pkgDate, true)
|
||||
freshHTML := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{rowFresh}))
|
||||
if strings.Contains(freshHTML, "FIGYELEM") {
|
||||
t.Error("the ASCII fragment matches a page that has no stale clause — the control fails")
|
||||
@@ -97,7 +97,7 @@ func TestR403_UnitRestoreOfferNamesTheOlderPackageDate(t *testing.T) {
|
||||
// The two-argument wrapper still produces the ordinary confirm — yesterday's callers are unchanged.
|
||||
func TestR403_TheOrdinaryConfirmIsUnchanged(t *testing.T) {
|
||||
const d = "2026-08-25T03:30:00Z"
|
||||
if tier2UnitConfirmMsg(d, true) != tier2UnitConfirmWithStaleness(d, true, false) {
|
||||
if noteServer(t).tier2UnitConfirmMsg(d, true) != noteServer(t).tier2UnitConfirmWithStaleness(d, true, false) {
|
||||
t.Error("the two-argument confirm is no longer the not-stale case")
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,7 @@ func TestR403_OutcomeNamesThePackageDateNotTheRunDate(t *testing.T) {
|
||||
UnitPackageDate: "2026-08-31T09:43:41Z", // the preserved package
|
||||
UnitLegPreserved: true,
|
||||
}
|
||||
msg := tier2UnitSourceMsg(cov)
|
||||
msg := noteServer(t).tier2UnitSourceMsg(cov)
|
||||
pkg := fmtRFC3339Local("2026-08-31T09:43:41Z")
|
||||
run := fmtRFC3339Local("2026-08-31T12:23:51Z")
|
||||
if !strings.Contains(msg, pkg) {
|
||||
@@ -124,14 +124,14 @@ func TestR403_OutcomeNamesThePackageDateNotTheRunDate(t *testing.T) {
|
||||
}
|
||||
// The confirm and the outcome must agree.
|
||||
date, stale := cov.UnitRestoreDate()
|
||||
confirm := tier2UnitConfirmWithStaleness(date, true, stale)
|
||||
confirm := noteServer(t).tier2UnitConfirmWithStaleness(date, true, stale)
|
||||
if !strings.Contains(confirm, pkg) {
|
||||
t.Errorf("the confirm does not name %q either: %q", pkg, confirm)
|
||||
}
|
||||
|
||||
// NEGATIVE CONTROL: with no preserved leg, the package date IS the fresh one and both agree on it.
|
||||
fresh := backup.Tier2Coverage{CopyLastSuccess: "2026-08-31T12:23:51Z", UnitPackageDate: "2026-08-31T12:23:00Z"}
|
||||
if !strings.Contains(tier2UnitSourceMsg(fresh), fmtRFC3339Local("2026-08-31T12:23:00Z")) {
|
||||
if !strings.Contains(noteServer(t).tier2UnitSourceMsg(fresh), fmtRFC3339Local("2026-08-31T12:23:00Z")) {
|
||||
t.Error("the ordinary outcome stopped naming its own package date")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,13 +369,32 @@ func (s *Server) templatesFor(lang string) *template.Template {
|
||||
return s.tmpl
|
||||
}
|
||||
|
||||
// langFor decides the language of one request: `?lang=hu|en` (a testing override, never persisted),
|
||||
// else the household's saved setting, else Hungarian.
|
||||
// langFor decides the language of one request. The order is fixed, and each step exists for a
|
||||
// different reader (v0.254.0, R-557 release C):
|
||||
//
|
||||
// 1. `?lang=hu|en` — the testing override. Never persisted, never sets a cookie.
|
||||
// 2. A REQUEST WITH A SESSION is the household's own: their saved setting, and the visitor cookie is
|
||||
// NOT read. A signed-in household must never see a language a previous visitor chose on the
|
||||
// sign-in page of the same browser.
|
||||
// 3. A request with NO session is a visitor at the door — sign-in, claim, recovery. They have no
|
||||
// setting to read and no right to change the household's, so their choice lives in their own
|
||||
// browser: the `felhom_lang` cookie, display-only.
|
||||
// 4. The household's setting anyway (a box whose visitor expressed no preference).
|
||||
// 5. Hungarian.
|
||||
//
|
||||
// Why a cookie and not the setting: the sign-in page is reachable by anyone who can reach the box.
|
||||
// Letting that change what the HOUSEHOLD reads would be an anonymous write to something they own.
|
||||
// Changing what the VISITOR THEMSELVES reads is not — 04-control-plane-authorization.md.
|
||||
func (s *Server) langFor(r *http.Request) string {
|
||||
if r != nil {
|
||||
if q := r.URL.Query().Get("lang"); i18n.IsSupported(q) {
|
||||
return q
|
||||
}
|
||||
if !s.hasSession(r) {
|
||||
if c, err := r.Cookie(langCookieName); err == nil && i18n.IsSupported(c.Value) {
|
||||
return c.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.settings != nil {
|
||||
return s.settings.GetLanguage()
|
||||
@@ -383,6 +402,16 @@ func (s *Server) langFor(r *http.Request) string {
|
||||
return i18n.Default
|
||||
}
|
||||
|
||||
// hasSession reports whether this request carries a VALID household session — the same test the auth
|
||||
// middleware makes, reused rather than restated so the two cannot drift apart.
|
||||
func (s *Server) hasSession(r *http.Request) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
c, err := r.Cookie(sessionCookieName)
|
||||
return err == nil && s.isValidSession(c.Value)
|
||||
}
|
||||
|
||||
// HubPushStatusData holds hub push status for the monitoring page.
|
||||
type HubPushStatusData struct {
|
||||
LastAttempt time.Time
|
||||
@@ -599,6 +628,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// i18n (v0.247.0): the household's dashboard language.
|
||||
case path == "/settings/language" && r.Method == http.MethodPost:
|
||||
s.languageSwitchHandler(w, r)
|
||||
// i18n (v0.254.0): the VISITOR's display language, for a page with no household signed in.
|
||||
// RequireAuth intercepts this on a password-protected box; the route is here so it also works on
|
||||
// a box with no password set and on an unclaimed one, where the middleware passes straight
|
||||
// through. Same handler either way.
|
||||
case path == langCookiePath && r.Method == http.MethodPost:
|
||||
s.langCookieHandler(w, r)
|
||||
case path == "/settings/notifications" && r.Method == http.MethodPost:
|
||||
s.settingsNotificationsHandler(w, r)
|
||||
case path == "/settings/notifications/test" && r.Method == http.MethodPost:
|
||||
@@ -880,6 +915,17 @@ func (s *Server) executeTemplateLang(w io.Writer, r *http.Request, name string,
|
||||
lang := s.langFor(r)
|
||||
if data != nil {
|
||||
data["Lang"] = lang
|
||||
// v0.254.0: the globe on the pages a VISITOR meets. It posts to /lang, not to the household
|
||||
// switch, and carries NO CSRF field — there is no session to mint one from, and the handler
|
||||
// writes only a display cookie in the visitor's own browser (CsrfProtect carries the reasoning).
|
||||
//
|
||||
// `back` is the path the visitor is ON, so the switch returns them to it. safeBackPath in the
|
||||
// handler is what makes that safe to take from an anonymous form.
|
||||
back := "/"
|
||||
if r != nil && r.URL != nil && r.URL.Path != "" {
|
||||
back = r.URL.Path
|
||||
}
|
||||
addLangOptions(data, lang, langCookiePath, back, "")
|
||||
}
|
||||
return s.templatesFor(lang).ExecuteTemplate(w, name, data)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestSlice4_BackupRowUnitFieldsAreUnchangedByTheExtraction(t *testing.T) {
|
||||
}
|
||||
if row.Tier2UnitRestorable != cov.CanRestoreUnit() || row.Tier2CopyDate != wantDate ||
|
||||
row.Tier2CopyDateProven != (cov.CopyLastSuccess != "") ||
|
||||
row.Tier2UnitConfirm != tier2UnitConfirmWithStaleness(wantDate, cov.CopyLastSuccess != "", wantStale) {
|
||||
row.Tier2UnitConfirm != noteServer(t).tier2UnitConfirmWithStaleness(wantDate, cov.CopyLastSuccess != "", wantStale) {
|
||||
t.Errorf("the row changed: restorable=%v date=%q proven=%v confirm=%q", row.Tier2UnitRestorable, row.Tier2CopyDate, row.Tier2CopyDateProven, row.Tier2UnitConfirm)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="shell-lang">{{template "lang_globe" .}}</div>
|
||||
<div class="login-card">
|
||||
<img src="/static/felhom-logo.svg" alt="Felhom.eu" class="login-logo">
|
||||
<h1 class="login-title">{{if .IsReset}}{{T "claim.jelszo"}} <span class="title-accent">{{T "claim.visszaallitasa"}}</span>{{else}}{{T "claim.a_szerver"}} <span class="title-accent">{{T "claim.beallitasa"}}</span>{{end}}</h1>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{{define "lang_globe"}}<details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="{{T "layout.nyelv"}}" title="{{T "layout.nyelv"}}"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">{{range .LangOptions}}
|
||||
<li><form method="POST" action="{{$.LangAction}}">{{$.LangCSRF}}<input type="hidden" name="back" value="{{$.LangBack}}"><button type="submit" name="lang" value="{{.Code}}" lang="{{.Code}}" class="lang-globe-item{{if .Current}} current{{end}}"{{if .Current}} aria-current="true"{{end}}>{{.Name}}</button></form></li>{{end}}
|
||||
</ul>
|
||||
</details>{{end}}
|
||||
@@ -116,7 +116,7 @@
|
||||
<li><a href="/settings/security" class="{{if eq .Page "settings-security"}}active{{end}}"><svg class="ico"><use href="#i-lock"/></svg>{{T "layout.biztonsag_es_hozzaferes"}}</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">{{.Version}}</span>{{if .LangSwitch}}<form method="POST" action="/settings/language" class="lang-switch">{{.CSRFField}}<input type="hidden" name="back" value="{{.LangSwitchBack}}"><button type="submit" name="lang" value="hu" class="lang-switch-btn{{if eq .Lang "hu"}} active{{end}}" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn{{if eq .Lang "en"}} active{{end}}" lang="en">English</button></form>{{end}}
|
||||
<span class="version">{{.Version}}</span>{{if .LangSwitch}}{{template "lang_globe" .}}{{end}}
|
||||
{{if .AuthEnabled}}<a href="/logout" class="logout-link">{{T "layout.kijelentkezes"}}</a>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="shell-lang">{{template "lang_globe" .}}</div>
|
||||
<div class="login-card">
|
||||
<img src="/static/felhom-logo.svg?v={{.Version}}" alt="Felhom.eu" class="login-logo">
|
||||
<h1 class="login-title">{{T "login.otthoni"}} <span class="title-accent">{{T "login.vezerlopult"}}</span></h1>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="shell-lang">{{template "lang_globe" .}}</div>
|
||||
<div class="login-card" style="max-width:46rem">
|
||||
<img src="/static/felhom-logo.svg" alt="Felhom.eu" class="login-logo">
|
||||
|
||||
|
||||
@@ -134,11 +134,22 @@ body {
|
||||
font-size: .8rem;
|
||||
}
|
||||
.version { color: var(--text-3); }
|
||||
/* i18n (v0.247.0): the language switch. Language names are shown in their own language, never translated. */
|
||||
.lang-switch { display: inline-flex; gap: 2px; margin: 0; }
|
||||
.lang-switch-btn { background: none; border: 1px solid var(--line); border-radius: var(--radius); color: var(--text-3); font: inherit; font-size: .75rem; padding: 0 .35rem; cursor: pointer; }
|
||||
.lang-switch-btn:hover { color: var(--blue-bright); }
|
||||
.lang-switch-btn.active { color: var(--text-1); border-color: var(--blue); }
|
||||
/* i18n (v0.254.0): the language switch is a GLOBE. Two text links wrapped in the sidebar footer and
|
||||
asked the reader to recognise „Magyar"/„English" as links; a globe is the one symbol every web user
|
||||
already reads as "language", so nobody has to read Hungarian to find their way out of Hungarian.
|
||||
<details>/<summary> — a menu with no script: it opens on click and on Enter, and a screen reader
|
||||
announces it. Language names inside are shown in their OWN language, never translated. */
|
||||
.lang-globe { position: relative; margin: 0; }
|
||||
.lang-globe-btn { list-style: none; display: inline-flex; align-items: center; color: var(--text-3); cursor: pointer; padding: .15rem; border-radius: var(--radius); }
|
||||
.lang-globe-btn::-webkit-details-marker { display: none; }
|
||||
.lang-globe-btn:hover, .lang-globe[open] .lang-globe-btn { color: var(--blue-bright); }
|
||||
.lang-globe-menu { position: absolute; bottom: calc(100% + .35rem); left: 50%; transform: translateX(-50%); margin: 0; padding: .25rem; list-style: none; min-width: 7.5rem; background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius); z-index: 20; }
|
||||
.lang-globe-menu li { margin: 0; }
|
||||
.lang-globe-menu form { margin: 0; }
|
||||
.lang-globe-item { display: block; width: 100%; text-align: left; background: none; border: none; color: var(--text-2); font: inherit; font-size: .8rem; padding: .3rem .5rem; border-radius: var(--radius); cursor: pointer; white-space: nowrap; }
|
||||
.lang-globe-item:hover { color: var(--blue-bright); }
|
||||
.lang-globe-item.current { color: var(--text-1); font-weight: 600; }
|
||||
.lang-globe-item.current::after { content: " \2713"; }
|
||||
.logout-link { color: var(--text-3); text-decoration: none; transition: color 0.2s ease; }
|
||||
.logout-link:hover { color: var(--blue-bright); }
|
||||
/* Mobile chrome (v0.166.0) — hidden by default so desktop (>768px) stays pixel-identical; the
|
||||
@@ -3664,3 +3675,10 @@ html::-webkit-scrollbar-track { background: var(--bg-0); }
|
||||
.restore-danger-card {
|
||||
border-left: 2px solid var(--crit);
|
||||
}
|
||||
|
||||
/* i18n (v0.254.0): the globe on the pages a VISITOR meets — sign-in, claim, recovery. Those pages have
|
||||
their own shell and no sidebar, so the globe is pinned to the top-right corner of the viewport, the
|
||||
same place on all three. It posts to /lang, which sets a display-only cookie in that visitor's own
|
||||
browser and never the household's setting. */
|
||||
.shell-lang { position: absolute; top: 1rem; right: 1rem; z-index: 30; }
|
||||
.shell-lang .lang-globe-menu { bottom: auto; top: calc(100% + .35rem); left: auto; right: 0; transform: none; }
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+7
-1
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+7
-1
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+7
-1
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+7
-1
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+7
-1
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="shell-lang"><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details></div>
|
||||
<div class="login-card">
|
||||
<img src="/static/felhom-logo.svg" alt="Felhom.eu" class="login-logo">
|
||||
<h1 class="login-title">Jelszó <span class="title-accent">visszaállítása</span></h1>
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="shell-lang"><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details></div>
|
||||
<div class="login-card">
|
||||
<img src="/static/felhom-logo.svg" alt="Felhom.eu" class="login-logo">
|
||||
<h1 class="login-title">Jelszó <span class="title-accent">visszaállítása</span></h1>
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="shell-lang"><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details></div>
|
||||
<div class="login-card">
|
||||
<img src="/static/felhom-logo.svg" alt="Felhom.eu" class="login-logo">
|
||||
<h1 class="login-title">A szerver <span class="title-accent">beállítása</span></h1>
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="shell-lang"><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details></div>
|
||||
<div class="login-card">
|
||||
<img src="/static/felhom-logo.svg" alt="Felhom.eu" class="login-logo">
|
||||
<h1 class="login-title">A szerver <span class="title-accent">beállítása</span></h1>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+7
-1
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+7
-1
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="shell-lang"><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details></div>
|
||||
<div class="login-card">
|
||||
<img src="/static/felhom-logo.svg?v=0.250.0" alt="Felhom.eu" class="login-logo">
|
||||
<h1 class="login-title">Otthoni <span class="title-accent">vezérlőpult</span></h1>
|
||||
|
||||
+7
-1
@@ -147,7 +147,13 @@
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user