v0.324.0: the database password comes back with the data (R-1106, R-127b, R-926); restic 0.19.1 (R-1107); R-1108, R-1065, R-1017; dashboard hardening (R-983, R-976)
gates / gates (push) Successful in 44s

This commit is contained in:
2026-10-16 22:46:00 +02:00
parent 00a0d91976
commit f75b3aa778
212 changed files with 2414 additions and 398 deletions
+77 -25
View File
@@ -2,6 +2,7 @@ package web
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"net/http"
@@ -164,31 +165,19 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
s.loginAttemptMu.Lock()
attempt := s.loginAttempts[ip]
if attempt != nil && time.Since(attempt.lastFail) > loginWindowDuration {
// Window expired — reset
attempt = nil
delete(s.loginAttempts, ip)
}
if attempt != nil && attempt.count >= loginMaxAttempts {
s.loginAttemptMu.Unlock()
s.logger.Printf("[WARN] [web] Login rate limited for %s (%d attempts)", ip, attempt.count)
// R-983 (v0.324.0): the try is RESERVED before the password is compared, under the one lock — check and count
// are a single step. Before, the count was taken only after the slow compare, so N parallel tries all passed the
// check and the 5-per-minute limit became "as many as arrive at once". A right password clears the counter below,
// so the household loses nothing by its own try being counted first. Pinned by TestR983_ParallelLoginsHitTheLimit.
if n, ok := s.reserveLoginAttempt(ip); !ok {
s.logger.Printf("[WARN] [web] Login rate limited for %s (%d attempts)", ip, n)
s.renderLogin(w, r, s.msg(r, "login.msg.rate_limited"), "")
return
}
s.loginAttemptMu.Unlock()
effectiveHash := s.effectivePasswordHash()
if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(password)); err != nil {
if err := s.comparePassword([]byte(effectiveHash), []byte(password)); err != nil {
s.logger.Printf("[WARN] [web] Failed login from %s", ip)
s.loginAttemptMu.Lock()
if s.loginAttempts[ip] == nil {
s.loginAttempts[ip] = &loginAttempt{}
}
s.loginAttempts[ip].count++
s.loginAttempts[ip].lastFail = time.Now()
s.loginAttemptMu.Unlock()
s.renderLogin(w, r, s.msg(r, "login.msg.wrong_password"), "")
return
}
@@ -221,14 +210,50 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
s.logger.Printf("[INFO] [web] Login from %s", ip)
// Redirect to ?next= target if provided, otherwise to dashboard
redirectTo := "/"
if nextURL != "" && strings.HasPrefix(nextURL, "/") {
redirectTo = nextURL
}
http.Redirect(w, r, redirectTo, http.StatusFound)
// Redirect to ?next= target if provided, otherwise to dashboard. R-983 (v0.324.0): through safeBackPath, the one
// guard for a destination taken from a request — a bare HasPrefix("/") let "//host" and "/\host" send a browser
// that had just signed in to another site. Pinned by TestR983_LoginNextStaysOnTheBox.
http.Redirect(w, r, safeBackPath(nextURL), http.StatusFound)
}
// reserveLoginAttempt counts one sign-in try for this visitor BEFORE the password is compared and reports whether it
// may go ahead (and the count it found). Check and count happen under one lock, so parallel tries cannot all pass a
// check that none of them has counted yet. A window that has passed starts clean.
func (s *Server) reserveLoginAttempt(ip string) (int, bool) {
s.loginAttemptMu.Lock()
defer s.loginAttemptMu.Unlock()
if s.loginAttempts == nil {
s.loginAttempts = map[string]*loginAttempt{}
}
a := s.loginAttempts[ip]
if a != nil && time.Since(a.lastFail) > loginWindowDuration {
a = nil
}
if a == nil {
a = &loginAttempt{}
s.loginAttempts[ip] = a
}
if a.count >= loginMaxAttempts {
return a.count, false
}
a.count++
a.lastFail = time.Now()
return a.count, true
}
// comparePassword is bcrypt's compare; passwordCompare replaces it in tests (a slow compare makes a race measurable).
func (s *Server) comparePassword(hash, pw []byte) error {
if s.passwordCompare != nil {
return s.passwordCompare(hash, pw)
}
return bcrypt.CompareHashAndPassword(hash, pw)
}
// handleLogout ends the presented dashboard session. It runs inside RequireAuth, BEFORE CsrfProtect, so it checks the
// session's CSRF token itself (R-983, v0.324.0): without it any page — another site, or an app on a sibling
// subdomain, which is same-site and so gets the Lax cookie — could sign the household out. A GET changes nothing and
// goes to the dashboard; the dashboard's own sign-out control is a POST form carrying the token (layout.html).
// A POST with no valid session has nothing to end and only clears the cookie. Pinned by TestR983_LogoutNeedsCSRF.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/", http.StatusFound)
@@ -238,6 +263,16 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
s.logger.Printf("[DEBUG] [web] logout from %s", r.RemoteAddr)
}
if cookie, err := r.Cookie(sessionCookieName); err == nil {
if expected := s.csrfTokenForSession(cookie.Value); expected != "" {
submitted := r.FormValue(csrfFormField)
if submitted == "" {
submitted = r.Header.Get(csrfHeaderName)
}
if submitted == "" || subtle.ConstantTimeCompare([]byte(submitted), []byte(expected)) != 1 {
s.csrfReject(w, r, "logout without the session's token")
return
}
}
s.sessionsMu.Lock()
delete(s.sessions, sessionFingerprint(cookie.Value))
s.saveSessionsRevokingLocked() // R-35: a signed-out cookie stays signed out across a restart
@@ -304,6 +339,23 @@ func (s *Server) invalidateAllSessions() {
s.logger.Printf("[INFO] [web] All sessions invalidated (cleared %d)", count)
}
// endEverySession is what a dashboard password change and a claim/reset do (R-976, v0.324.0): every dashboard session
// ends (invalidateAllSessions), every family session — each member's and the household's — ends, and the gate key is
// replaced, so every setup-gate pass and every family app cookie stops opening its app at the next request. Before,
// only the dashboard sessions ended, so a session copied out of a family app kept working for up to 30 days after the
// household had changed its password. Family members keep their own passwords and sign in again; the household's
// browser passes again through its new dashboard session. Pinned by TestR976_PasswordChangeEndsFamilyAndGateSessions
// and TestR976_ClaimResetEndsFamilyAndGateSessions.
func (s *Server) endEverySession(why string) {
s.invalidateAllSessions()
n, err := s.familyStore().EndAll()
if err != nil {
s.logger.Printf("[ERROR] [web] %s: the family sessions were ended but the family list could not be saved: %v", why, err)
}
s.rotateGateKey()
s.logger.Printf("[INFO] [web] %s: every dashboard session, %d family session(s) and every app gate pass ended", why, n)
}
func (s *Server) cleanupSessions() {
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
+51 -2
View File
@@ -397,7 +397,7 @@ func (s *Server) handleClaimSubmit(w http.ResponseWriter, r *http.Request) {
}
}
s.invalidateAllSessions() // reset: kill old sessions; first-claim: none exist
s.endEverySession("claim/reset") // reset: kill old sessions — dashboard, family, gate passes (R-976); first-claim: none exist
action := "claimed"
if wasReset {
@@ -428,10 +428,59 @@ func (s *Server) handleClaimRequestNewCode(w http.ResponseWriter, r *http.Reques
s.handleClaimPage(w, r, s.msg(r, "claim.msg.invalid_form"), "")
return
}
go s.requestHubResetCode() // fire-and-forget; the neutral response never reveals the outcome
// R-983 (v0.324.0): a request reaches the hub only inside the box's own limit. Each call mints a fresh code that
// replaces the one the household may be typing, and mails the household — so without a limit a stranger could do
// both at will. The answer stays the same neutral page either way (it never revealed the outcome).
if ok, why := s.allowResetRequest(rateKey(r)); !ok {
s.logger.Printf("[WARN] [web] claim: a reset-code request was not passed to the hub (%s) — visitor %s", why, rateKey(r))
} else if s.resetRequestFn != nil {
s.resetRequestFn()
} else {
go s.requestHubResetCode() // fire-and-forget; the neutral response never reveals the outcome
}
s.handleClaimPage(w, r, "", s.msg(r, "claim.msg.code_sent"))
}
// The reset-code request's limit (R-983): one hub call per visitor per resetReqVisitorEvery, and at most
// resetReqGlobalMax for the whole box in any resetReqGlobalWindow. The worst a stranger can do is hold the box's
// budget for one window — the household's code already sent stays valid meanwhile, because nothing replaces it.
const (
resetReqVisitorEvery = time.Minute
resetReqGlobalMax = 3
resetReqGlobalWindow = 15 * time.Minute
)
// allowResetRequest decides and counts in one step (under claimMu), so parallel requests cannot all pass.
func (s *Server) allowResetRequest(visitor string) (bool, string) {
s.claimMu.Lock()
defer s.claimMu.Unlock()
now := s.claimNow()
if s.resetReqVisitor == nil {
s.resetReqVisitor = map[string]time.Time{}
}
for k, at := range s.resetReqVisitor {
if now.Sub(at) >= resetReqVisitorEvery {
delete(s.resetReqVisitor, k)
}
}
keep := s.resetReqGlobal[:0]
for _, at := range s.resetReqGlobal {
if now.Sub(at) < resetReqGlobalWindow {
keep = append(keep, at)
}
}
s.resetReqGlobal = keep
if _, seen := s.resetReqVisitor[visitor]; seen {
return false, "this visitor asked less than a minute ago"
}
if len(s.resetReqGlobal) >= resetReqGlobalMax {
return false, "the box's limit for this quarter hour is used"
}
s.resetReqVisitor[visitor] = now
s.resetReqGlobal = append(s.resetReqGlobal, now)
return true, ""
}
// requestHubResetCode calls POST /api/v1/claim/reset-request with the box's own report key.
// v0.123.0 (take-two F-15): a hub ≥0.52.0 returns the freshly rotated code state in the response;
// it is applied through the SAME generation-guarded consumer as the report ACK (ClaimSync), so the
+74 -31
View File
@@ -39,8 +39,10 @@ import (
// every request).
//
// THE RULE: a family cookie never opens the dashboard. RequireAuth reads only `felhom_session`; nothing here ever sets
// it. An app cookie names a store session, so a removed member, a reset password or a logout ends access at the next
// request on every app. Pinned by internal/web/family_gate_test.go.
// it. An app cookie carries a token derived from a store session for that one app host (familyAppToken, R-976), so a
// removed member, a reset password, a logout or a dashboard password change ends access at the next request on every
// app, and what an app's backend sees cannot be turned into a pass for another app. Pinned by
// internal/web/family_gate_test.go and r983_r976_test.go.
const (
familySessionCookie = "felhom_family"
@@ -91,8 +93,10 @@ func (s *Server) familyStore() *family.Store {
return s.fam.store
}
// familyLocked reports whether this visitor or this name is out of tries. Windows slide; a success clears both.
func (s *Server) familyLocked(visitor, name string) bool {
// familyReserve checks whether this visitor or this name is out of tries and, when neither is, counts this try
// against both — one step under one lock (R-983, v0.324.0), so parallel tries cannot all pass a check that none of
// them has counted yet. Windows slide; a success clears both (familyCleared). It reports whether the try may go ahead.
func (s *Server) familyReserve(visitor, name string) bool {
s.familyStore()
now := s.gateNow()
s.fam.mu.Lock()
@@ -116,17 +120,14 @@ func (s *Server) familyLocked(visitor, name string) bool {
if name != "" {
n = prune(s.fam.name, name, familyNameWindow)
}
return v >= familyVisitorMax || n >= familyNameMax
}
func (s *Server) familyFailed(visitor, name string) {
now := s.gateNow()
s.fam.mu.Lock()
defer s.fam.mu.Unlock()
if v >= familyVisitorMax || n >= familyNameMax {
return false
}
s.fam.visitor[visitor] = append(s.fam.visitor[visitor], now)
if name != "" {
s.fam.name[name] = append(s.fam.name[name], now)
}
return true
}
func (s *Server) familyCleared(visitor, name string) {
@@ -152,9 +153,28 @@ func (s *Server) familyRDHost(rd string) (string, bool) {
return host, true
}
// familyAppToken is what an app host ever sees of a family session (R-976, v0.324.0): a token DERIVED from the
// session for that one host, never the session itself. Before, the app cookie carried the session id in clear; every
// family-gated app's backend receives its cookies, and a session id lifted from one app, set as the family session
// cookie on the dashboard host, minted passes into EVERY family app for 30 days. A derived token cannot be turned back
// into the session, and it opens only its own host. Pinned by TestR976_AnAppCookieCannotBeLiftedIntoAnotherApp.
func (s *Server) familyAppToken(host, sid string) string {
return s.gateMAC("famapp", host, sid)
}
// familySessionForAppToken finds the session an app token was derived from (still valid, as Store.Valid judges it).
func (s *Server) familySessionForAppToken(host, at string) (family.Session, bool) {
if at == "" {
return family.Session{}, false
}
return s.familyStore().Find(func(id string) bool {
return hmac.Equal([]byte(s.familyAppToken(host, id)), []byte(at))
})
}
type familyToken struct {
Host string `json:"h"`
Sid string `json:"s"`
App string `json:"a"` // the per-app token (familyAppToken), never the session id
Exp int64 `json:"e"`
Nonce string `json:"n"`
RD string `json:"r"`
@@ -164,14 +184,14 @@ type familyToken struct {
func (s *Server) mintFamilyToken(host, sid, rd string) string {
nb := make([]byte, 12)
_, _ = rand.Read(nb)
t := familyToken{Host: host, Sid: sid, Exp: s.gateNow().Add(gateTokenLife).Unix(), Nonce: hex.EncodeToString(nb), RD: rd}
t.MAC = s.gateMAC("ftoken", t.Host, t.Sid, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD)
t := familyToken{Host: host, App: s.familyAppToken(host, sid), Exp: s.gateNow().Add(gateTokenLife).Unix(), Nonce: hex.EncodeToString(nb), RD: rd}
t.MAC = s.gateMAC("ftoken", t.Host, t.App, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD)
b, _ := json.Marshal(t)
return base64.RawURLEncoding.EncodeToString(b)
}
// takeFamilyToken checks a token for host, uses it up, and returns the session and where the browser was going.
func (s *Server) takeFamilyToken(raw, host string) (sid, rd string, err error) {
// takeFamilyToken checks a token for host, uses it up, and returns the per-app token and where the browser was going.
func (s *Server) takeFamilyToken(raw, host string) (at, rd string, err error) {
b, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return "", "", errors.New("malformed")
@@ -180,7 +200,7 @@ func (s *Server) takeFamilyToken(raw, host string) (sid, rd string, err error) {
if json.Unmarshal(b, &t) != nil {
return "", "", errors.New("malformed")
}
if !hmac.Equal([]byte(t.MAC), []byte(s.gateMAC("ftoken", t.Host, t.Sid, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD))) {
if !hmac.Equal([]byte(t.MAC), []byte(s.gateMAC("ftoken", t.Host, t.App, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD))) {
return "", "", errors.New("bad signature")
}
if t.Host != host {
@@ -202,11 +222,13 @@ func (s *Server) takeFamilyToken(raw, host string) (sid, rd string, err error) {
return "", "", errors.New("already used")
}
s.gate.used["f:"+t.Nonce] = time.Unix(t.Exp, 0).Add(time.Second)
return t.Sid, t.RD, nil
return t.App, t.RD, nil
}
// familyAppCookieSession: "<sid>.<unix expiry>.<mac over host+sid+expiry>" → the session, if the cookie is genuine for
// this host, unexpired, and the store still holds the session (a removed member / reset / logout fails here).
// familyAppCookieSession: "<app token>.<unix expiry>.<mac over host+token+expiry>" → the session, if the cookie is
// genuine for this host, unexpired, and the store still holds the session the token was derived from (a removed
// member / reset / logout / dashboard password change fails here). A cookie of the old "<session id>.…" shape is
// refused: the browser goes through the sign-in start once more and gets the new shape.
func (s *Server) familyAppCookieSession(r *http.Request, host string) (family.Session, bool) {
c, err := r.Cookie(familyAppCookie)
if err != nil {
@@ -223,7 +245,7 @@ func (s *Server) familyAppCookieSession(r *http.Request, host string) (family.Se
if !hmac.Equal([]byte(parts[2]), []byte(s.gateMAC("famcookie", host, parts[0], parts[1]))) {
return family.Session{}, false
}
return s.familyStore().Valid(parts[0])
return s.familySessionForAppToken(host, parts[0])
}
func familyRefuse(w http.ResponseWriter, code int) {
@@ -256,9 +278,9 @@ func (s *Server) ServeFamilyGateAuth(w http.ResponseWriter, r *http.Request) {
return
}
if u, err := url.Parse(uri); err == nil && u.Path == familyCallbackURI {
sid, rd, err := s.takeFamilyToken(u.Query().Get("t"), host)
at, rd, err := s.takeFamilyToken(u.Query().Get("t"), host)
if err == nil {
if _, ok := s.familyStore().Valid(sid); !ok {
if _, ok := s.familySessionForAppToken(host, at); !ok {
err = errors.New("the session ended")
}
}
@@ -269,7 +291,7 @@ func (s *Server) ServeFamilyGateAuth(w http.ResponseWriter, r *http.Request) {
}
exp := strconv.FormatInt(s.gateNow().Add(family.SessionLife).Unix(), 10)
http.SetCookie(w, &http.Cookie{
Name: familyAppCookie, Value: sid + "." + exp + "." + s.gateMAC("famcookie", host, sid, exp), Path: "/",
Name: familyAppCookie, Value: at + "." + exp + "." + s.gateMAC("famcookie", host, at, exp), Path: "/",
MaxAge: int(family.SessionLife.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
})
s.logger.Printf("[INFO] [web] family gate %s: a signed-in browser passed — visitor %s", app, clientIP(r))
@@ -356,14 +378,16 @@ func (s *Server) ServeFamilyLogin(w http.ResponseWriter, r *http.Request) {
}
name := strings.ToLower(strings.TrimSpace(r.FormValue("name")))
visitor := rateKey(r)
if s.familyLocked(visitor, name) {
// R-983 (v0.324.0): the try is counted BEFORE the password is checked, in the same step as the lock check, so
// parallel tries cannot all pass a check none of them has counted yet. A right password clears both counts.
// Pinned by TestR983_ParallelFamilySignInsHitTheLimit.
if !s.familyReserve(visitor, name) {
s.logger.Printf("[WARN] [web] family sign-in: too many wrong tries — visitor %s, name %q", visitor, name)
s.renderFamilyPage(w, r, rd, s.msg(r, "family_gate.msg.locked"), http.StatusTooManyRequests)
return
}
st := s.familyStore()
if st == nil || !st.Verify(name, r.FormValue("password")) {
s.familyFailed(visitor, name)
s.logger.Printf("[WARN] [web] family sign-in failed — visitor %s", visitor)
s.renderFamilyPage(w, r, rd, s.msg(r, "family_gate.msg.wrong"), http.StatusUnauthorized)
return
@@ -385,12 +409,30 @@ func (s *Server) ServeFamilyLogin(w http.ResponseWriter, r *http.Request) {
s.renderFamilyPage(w, r, "", s.msg(r, "family_gate.msg.signed_in"), http.StatusOK)
}
// familyLogoutToken is the sign-out form's token, bound to the family session it ends (R-983, v0.324.0). The sign-in
// form's token (familyFormToken) is the same for every visitor, so a stranger could fetch one and use it; this one
// names the session, which another page never sees.
func (s *Server) familyLogoutToken(sid string) string {
return s.gateMAC("famlogout", sid)
}
// ServeFamilyLogout is /__family/logout: the family session ends, and every app cookie minted from it with it.
// R-983 (v0.324.0): only a POST from the sign-out form (familygate.html), carrying familyLogoutToken. Anything else —
// a GET, or a form another page built — changes nothing and shows the family page. Pinned by
// TestR983_FamilyLogoutNeedsItsToken.
func (s *Server) ServeFamilyLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(familySessionCookie); err == nil {
if err := s.familyStore().EndSession(c.Value); err != nil {
s.logger.Printf("[ERROR] [web] family sign-out: %v", err)
}
c, err := r.Cookie(familySessionCookie)
if r.Method != http.MethodPost || err != nil {
s.renderFamilyPage(w, r, "", "", http.StatusOK)
return
}
if !hmac.Equal([]byte(r.FormValue("_ft")), []byte(s.familyLogoutToken(c.Value))) {
s.logger.Printf("[WARN] [web] family sign-out refused: the form's token does not match the session — visitor %s", clientIP(r))
s.renderFamilyPage(w, r, "", s.msg(r, "family_gate.msg.form_expired"), http.StatusForbidden)
return
}
if err := s.familyStore().EndSession(c.Value); err != nil {
s.logger.Printf("[ERROR] [web] family sign-out: %v", err)
}
http.SetCookie(w, &http.Cookie{Name: familySessionCookie, Value: "", Path: familyCookiePath, MaxAge: -1, HttpOnly: true, Secure: true})
s.renderFamilyPage(w, r, "", s.msg(r, "family_gate.msg.signed_out"), http.StatusOK)
@@ -406,8 +448,9 @@ func (s *Server) renderFamilyPage(w http.ResponseWriter, r *http.Request, rd, no
}
}
}
if _, ok := s.familySessionFromCookie(r); ok {
if se, ok := s.familySessionFromCookie(r); ok {
data["SignedIn"] = true
data["LogoutToken"] = s.familyLogoutToken(se.ID)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
+3 -1
View File
@@ -213,7 +213,9 @@ func TestFamilyGate_ResetRemoveLogoutEndAccessAtOnce(t *testing.T) {
}
sess = login()
app = famPass(t, s, sess)
r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu"+familyLogoutPath, nil)
r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu"+familyLogoutPath,
strings.NewReader(url.Values{"_ft": {s.familyLogoutToken(sess.Value)}}.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(sess)
s.ServeFamilyLogout(httptest.NewRecorder(), r)
if ok(app) {
+7 -5
View File
@@ -2507,6 +2507,7 @@ func (s *Server) securityPageData() map[string]interface{} {
// EXISTS, and the value comes from POST /settings/retrieval-password/reveal, which is an
// explicit authenticated act and is logged as one.
data["HasRetrievalPassword"] = strings.TrimSpace(s.settings.GetRetrievalPassword()) != ""
data["MinPassword"] = claimMinPassword // R-983: the password form's minimum is the claim's
data["HubURL"] = s.cfg.Hub.URL
data["SupportEmail"] = "support@felhom.eu"
data["SupportURL"] = "https://felhom.eu/kapcsolat"
@@ -2848,9 +2849,10 @@ func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request)
return
}
// Validate new password length
if len(newPassword) < 8 {
data["PasswordError"] = s.msg(r, "settings.pw_too_short")
// Validate new password length. R-983 (v0.324.0): the claim's minimum (claim.go claimMinPassword), not 8 — one
// dashboard password, one rule, wherever it is set. Pinned by TestR983_SettingsPasswordMinimumIsTheClaims.
if len(newPassword) < claimMinPassword {
data["PasswordError"] = s.msg(r, "settings.pw_too_short", claimMinPassword)
s.executeTemplate(w, r, "settings_security", data)
return
}
@@ -2881,8 +2883,8 @@ func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request)
s.logger.Printf("[INFO] [web] Password changed via settings page from %s", r.RemoteAddr)
// Invalidate all sessions (force re-login)
s.invalidateAllSessions()
// Invalidate all sessions (force re-login) — the dashboard's, the family gate's and every app gate pass (R-976).
s.endEverySession("password change")
// Redirect to login with flash message
http.Redirect(w, r, "/login?"+flashQuery("flash", "flash.login.password_changed"), http.StatusFound)
@@ -276,6 +276,7 @@ func i18nCasesA() []i18nCase {
d["HasRetrievalPassword"] = true
d["CustomerID"], d["HubURL"] = "test-customer", "https://hub.example"
d["SupportEmail"], d["SupportURL"] = "support@example.hu", "https://example.hu/kapcsolat"
d["MinPassword"] = claimMinPassword // R-983 (v0.324.0): the claim's minimum, as securityPageData sets it
return d
}},
{"settings_security_noauth", "settings_security", func() map[string]interface{} {
+2 -1
View File
@@ -166,7 +166,8 @@ func i18nCasesC() []i18nCase {
return m{"AppName": "Grimmory", "Host": "books.example.hu", "RD": "https://books.example.hu/", "FormToken": "FT"}
}},
{"familygate_signed_in", "familygate", func() map[string]interface{} {
return m{"SignedIn": true, "Notice": "Beléptél. Nyisd meg újra az alkalmazást."}
// R-983 (v0.324.0): the sign-out form carries its session-bound token — re-captured for that line.
return m{"SignedIn": true, "Notice": "Beléptél. Nyisd meg újra az alkalmazást.", "LogoutToken": "LOGOUT-TOKEN"}
}},
{"catchall_unknown", "catchall", func() map[string]interface{} {
return m{"ControllerURL": "https://felhom.example.hu", "Status": "unknown", "StatusText": "Ez a cím nem tartozik alkalmazáshoz", "Host": "x.example.hu"}
+10 -1
View File
@@ -411,13 +411,22 @@ func (s *Server) applyPlexClaim(code string) error {
s.logger.Printf("[WARN] [web] plex: claimed, but opening the setup gate failed: %v", oerr)
}
s.logger.Printf("[INFO] [web] plex: claimed — the setup gate is open (R-1028)")
// R-1065: the libraries are set by the media wiring, which needs the server's own token and so could
// not run while Plex was unclaimed. Trigger it now (debounced, with its retry) instead of leaving the
// household's libraries empty until the next drive event or restart. Pinned by
// TestPlexClaim_TriggersMediaWiring.
s.logger.Printf("[INFO] [web] plex: claimed — media wiring scheduled to set the libraries (R-1065)")
s.RunMediaWiringSoon("plex")
return nil
}
time.Sleep(time.Second)
time.Sleep(plexClaimPoll)
}
return &afterStartError{Status: http.StatusConflict, MsgKey: "err.plex_claim.not_claimed"}
}
// plexClaimPoll is the wait between the „is it claimed yet" probes after a claim (a seam: tests poll without the wait).
var plexClaimPoll = time.Second
// plexHTTP is the client for the box's own calls to Plex (a seam: tests dial a fake that refuses the name „plex").
var plexHTTP = &http.Client{Timeout: 30 * time.Second}
@@ -81,3 +81,47 @@ func TestPlexDonePress_RefusedWhileUnclaimed(t *testing.T) {
t.Fatalf("„Kész” on a claimed Plex: HTTP %d, want 200", c)
}
}
// R-1065: a claim that Plex confirms schedules the media wiring (which sets the libraries — it could
// not while Plex was unclaimed); a claim Plex refuses, or one Plex never reports as claimed, does not.
// The consequence asserted is the armed wiring timer — the same one a media app coming up arms.
// COMPANION RED-PROOF: drop the RunMediaWiringSoon call in applyPlexClaim → the claimed case fails.
func TestPlexClaim_TriggersMediaWiring(t *testing.T) {
oldClaim, oldClaimed, oldPoll := plexClaim, plexClaimed, plexClaimPoll
t.Cleanup(func() { plexClaim, plexClaimed, plexClaimPoll = oldClaim, oldClaimed, oldPoll })
plexClaimPoll = 0
for _, c := range []struct {
name string
status int
claimed bool
wantErr bool
wantWiring bool
}{
{"claimed", http.StatusOK, true, false, true},
{"plex refused the code", http.StatusUnauthorized, true, true, false},
{"plex never says claimed", http.StatusOK, false, true, false},
} {
t.Run(c.name, func(t *testing.T) {
s := gateHarness(t)
t.Cleanup(func() {
s.mediaMu.Lock()
if s.mediaSoonTmr != nil {
s.mediaSoonTmr.Stop()
}
s.mediaMu.Unlock()
})
plexClaim = func(string) (int, error) { return c.status, nil }
plexClaimed = func() bool { return c.claimed }
err := s.applyPlexClaim("claim-AbCdEf123456789xyz")
if (err != nil) != c.wantErr {
t.Fatalf("applyPlexClaim err=%v, want error=%v", err, c.wantErr)
}
s.mediaMu.Lock()
armed := s.mediaSoonTmr != nil
s.mediaMu.Unlock()
if armed != c.wantWiring {
t.Errorf("media wiring scheduled=%v, want %v", armed, c.wantWiring)
}
})
}
}
@@ -48,9 +48,11 @@ import (
var secretNameRe = regexp.MustCompile(`(?i)(pass(word|phrase)|secret|token|api_?key|private_?key|recovery_?code|credential|_key$)`)
// notSecretNames are names the pattern matches that carry no secret: the CSRF tokens a form must carry
// (session-bound, useless without the session) and a password-change ERROR MESSAGE.
// (session-bound, useless without the session) and a password-change ERROR MESSAGE. LogoutToken (R-983, v0.324.0) is
// the family sign-out form's CSRF token: an HMAC naming the family session, useless without that session's cookie.
var notSecretNames = map[string]bool{
"CSRFToken": true, "CSRFField": true, "FormToken": true, "LangCSRF": true, "PasswordError": true,
"LogoutToken": true,
}
const plantedPrefix = "PLANTEDSECRET"
+452
View File
@@ -0,0 +1,452 @@
package web
import (
"encoding/base64"
"errors"
"fmt"
"html"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
)
// R-983 (security review 2026-10-12, six remaining items) and R-976 (family and gate sessions), v0.324.0. Each test
// drives the handler the browser or traefik reaches. Red-proofs are recorded in the REPORT of the release.
// ── R-983 item 1: after sign-in the browser stays on the box ─────────────────────────────────────────────────
func TestR983_LoginNextStaysOnTheBox(t *testing.T) {
s := rateLimitTestServer(t)
login := func(next string) string {
form := url.Values{"password": {"correct-pass"}, "next": {next}}
r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.RemoteAddr = "203.0.113.7:4000"
w := httptest.NewRecorder()
s.handleLogin(w, r)
if w.Code != http.StatusFound {
t.Fatalf("next=%q: sign-in answered %d, want 302", next, w.Code)
}
return w.Header().Get("Location")
}
for _, evil := range []string{"//evil.example/x", "/\\evil.example", "https://evil.example/", "/\\/evil.example"} {
if got := login(evil); got != "/" {
t.Errorf("next=%q sent the signed-in browser to %q — want /", evil, got)
}
}
if got := login("/stacks?tab=1"); got != "/stacks?tab=1" {
t.Errorf("a page on the box must still be the destination: got %q", got)
}
}
// ── R-983 item 2: no page of the dashboard may be framed ─────────────────────────────────────────────────────
func TestR983_DashboardRefusesFraming(t *testing.T) {
s, _ := familyHarness(t)
reached := false
h := s.CatchAllMiddleware(s.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached = true
w.WriteHeader(http.StatusOK)
})))
sess := &http.Cookie{Name: sessionCookieName, Value: newTestSession(s)}
for _, p := range []string{"/launcher", "/login", familyLoginPath, "/settings/security"} {
r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+p, nil)
r.AddCookie(sess)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if got := w.Header().Get("X-Frame-Options"); got != "DENY" {
t.Errorf("%s: X-Frame-Options %q, want DENY", p, got)
}
if got := w.Header().Get("Content-Security-Policy"); got != "frame-ancestors 'none'" {
t.Errorf("%s: Content-Security-Policy %q, want frame-ancestors 'none'", p, got)
}
}
if !reached {
t.Fatal("the signed-in page was never reached — the test proved nothing about a real page")
}
// traefik's forwardAuth answers stay as they were: their headers must not ride onto an app's responses.
r := httptest.NewRequest(http.MethodGet, "http://felhom-controller:8080"+familyAuthPath, nil)
r.Header.Set("X-Forwarded-Host", "fapp.example.hu")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Header().Get("X-Frame-Options") != "" || w.Header().Get("Content-Security-Policy") != "" {
t.Error("the forwardAuth answer must carry no framing header")
}
}
// ── R-983 item 3: parallel sign-in tries are counted ─────────────────────────────────────────────────────────
// The compare is a seam that waits until every try has arrived (or a short deadline): with check-then-count all 20
// tries reach it; with the try reserved first only loginMaxAttempts do.
func TestR983_ParallelLoginsHitTheLimit(t *testing.T) {
s := rateLimitTestServer(t)
const tries = 20
var calls int32
all := make(chan struct{})
var once sync.Once
s.passwordCompare = func(hash, pw []byte) error {
if atomic.AddInt32(&calls, 1) == tries {
once.Do(func() { close(all) })
}
select {
case <-all:
case <-time.After(400 * time.Millisecond):
}
return errors.New("mismatch")
}
var wg sync.WaitGroup
start := make(chan struct{})
for i := 0; i < tries; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
doLogin(s, fmt.Sprintf("198.51.100.9:%d", 6000+i), "", "wrong")
}(i)
}
close(start)
wg.Wait()
if got := atomic.LoadInt32(&calls); got > loginMaxAttempts {
t.Fatalf("%d parallel wrong tries reached the password compare — the limit is %d per visitor", got, loginMaxAttempts)
}
// and the household's right password is still refused only for this visitor, not for everyone
s.passwordCompare = nil
if body := doLogin(s, "203.0.113.50:7000", "", "correct-pass"); body.Code != http.StatusFound {
t.Fatalf("another visitor with the right password must get in: %d", body.Code)
}
}
func TestR983_ParallelFamilySignInsHitTheLimit(t *testing.T) {
s, st := familyHarness(t)
st.SetCost(10) // a real compare takes tens of milliseconds, so the tries overlap
if _, err := st.Add("anna"); err != nil {
t.Fatal(err)
}
const tries = 20
var wrong, locked int32
var wg sync.WaitGroup
start := make(chan struct{})
for i := 0; i < tries; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
switch famLogin(s, "198.51.100.66", "anna", "wrong", "").Code {
case http.StatusUnauthorized:
atomic.AddInt32(&wrong, 1)
case http.StatusTooManyRequests:
atomic.AddInt32(&locked, 1)
}
}()
}
close(start)
wg.Wait()
if wrong > familyVisitorMax {
t.Fatalf("%d parallel tries had their password checked — the limit is %d per visitor (locked: %d)", wrong, familyVisitorMax, locked)
}
if wrong+locked != tries {
t.Fatalf("every try must be answered as wrong or locked: %d + %d of %d", wrong, locked, tries)
}
}
// ── R-983 item 4: the settings page's minimum is the claim's ─────────────────────────────────────────────────
func TestR983_SettingsPasswordMinimumIsTheClaims(t *testing.T) {
if claimMinPassword != 12 {
t.Fatalf("the claim's minimum moved to %d — this test's literals assume 12", claimMinPassword)
}
s, _ := familyHarness(t)
tok := newTestSession(s)
change := func(pw string) *httptest.ResponseRecorder {
form := url.Values{"current_password": {"dashboard-pass"}, "new_password": {pw}, "confirm_password": {pw}}
r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu/settings/password", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tok})
w := httptest.NewRecorder()
s.settingsPasswordHandler(w, r)
return w
}
w := change("eleven-char")
if w.Code == http.StatusFound {
t.Fatal("an 11-character password was accepted — the claim requires 12")
}
if !strings.Contains(w.Body.String(), "legalább 12 karakter") {
t.Errorf("the refusal must name the claim's minimum (Hungarian)")
}
if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("dashboard-pass")) != nil {
t.Fatal("the refused change altered the password")
}
// the page's own form says the same minimum (both languages render it from one value)
page := w.Body.String()
if !strings.Contains(page, `minlength="12"`) || strings.Contains(page, `minlength="8"`) {
t.Errorf("the password form must ask for at least 12 characters")
}
if got := s.msgLang("en", "settings.pw_too_short", claimMinPassword); !strings.Contains(got, "at least 12 characters") {
t.Errorf("English refusal %q must name 12", got)
}
if w := change("twelve-chars"); w.Code != http.StatusFound {
t.Fatalf("a 12-character password must be accepted: %d", w.Code)
}
}
// ── R-983 item 5: reset-code requests are limited on the box ─────────────────────────────────────────────────
func TestR983_ResetCodeRequestsAreLimited(t *testing.T) {
s, _, _ := claimTestServer(t)
now := time.Date(2026, 10, 16, 2, 0, 0, 0, time.UTC)
s.claimClock = func() time.Time { return now }
var calls int
s.resetRequestFn = func() { calls++ }
ask := func(visitor string) {
form := url.Values{"_csrf": {s.claimCSRFToken()}}
r := httptest.NewRequest(http.MethodPost, "/claim/request-new-code", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: s.claimCSRFToken()})
r.RemoteAddr = visitor + ":5000"
w := httptest.NewRecorder()
s.handleClaimRequestNewCode(w, r)
if w.Code != http.StatusOK {
t.Fatalf("the answer must stay the neutral page: %d", w.Code)
}
}
for i := 0; i < 3; i++ {
ask("198.51.100.1")
}
if calls != 1 {
t.Fatalf("3 requests in one minute from one visitor reached the hub %d times — want 1", calls)
}
now = now.Add(resetReqVisitorEvery + time.Second)
ask("198.51.100.1")
if calls != 2 {
t.Fatalf("after a minute the same visitor must reach the hub again: %d calls", calls)
}
ask("198.51.100.2")
ask("198.51.100.3") // the box's budget for the quarter hour is used here
if calls != resetReqGlobalMax {
t.Fatalf("calls %d, want %d", calls, resetReqGlobalMax)
}
ask("198.51.100.4")
if calls != resetReqGlobalMax {
t.Fatalf("a fourth visitor in the same quarter hour reached the hub: %d calls", calls)
}
now = now.Add(resetReqGlobalWindow)
ask("198.51.100.4")
if calls != resetReqGlobalMax+1 {
t.Fatalf("after the window a request must reach the hub again (the household is not locked out for long): %d", calls)
}
}
// ── R-983 item 6: logout cannot be triggered from another page ───────────────────────────────────────────────
func TestR983_LogoutNeedsCSRF(t *testing.T) {
s, _ := familyHarness(t)
tok := newTestSession(s)
h := s.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(299) }))
out := func(method, csrf string) *httptest.ResponseRecorder {
var body *strings.Reader
if csrf != "" {
body = strings.NewReader(url.Values{csrfFormField: {csrf}}.Encode())
} else {
body = strings.NewReader("")
}
r := httptest.NewRequest(method, "https://felhom.example.hu/logout", body)
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tok})
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
if w := out(http.MethodPost, ""); w.Code != http.StatusForbidden || !s.isValidSession(tok) {
t.Fatalf("a POST without the token: %d, session valid %v — want 403 and the session kept", w.Code, s.isValidSession(tok))
}
if w := out(http.MethodPost, "not-the-token"); w.Code != http.StatusForbidden || !s.isValidSession(tok) {
t.Fatalf("a POST with a wrong token: %d — want 403 and the session kept", w.Code)
}
if w := out(http.MethodGet, ""); w.Code != http.StatusFound || w.Header().Get("Location") != "/" || !s.isValidSession(tok) {
t.Fatalf("a GET must change nothing: %d %q", w.Code, w.Header().Get("Location"))
}
// The dashboard's own control: a POST form carrying the session's token.
r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu/settings/security", nil)
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tok})
pw := httptest.NewRecorder()
s.settingsSecurityPageHandler(pw, r)
m := regexp.MustCompile(`<form method="post" action="/logout" class="logout-form"><input type="hidden" name="_csrf" value="([0-9a-f]+)">`).FindStringSubmatch(pw.Body.String())
if m == nil {
t.Fatal("the dashboard's sign-out control must be a POST form carrying the session's token")
}
if w := out(http.MethodPost, m[1]); w.Code != http.StatusFound || w.Header().Get("Location") != "/login" || s.isValidSession(tok) {
t.Fatalf("the dashboard's own sign-out: %d %q, session valid %v — want 302 /login and the session ended", w.Code, w.Header().Get("Location"), s.isValidSession(tok))
}
}
func TestR983_FamilyLogoutNeedsItsToken(t *testing.T) {
s, st := familyHarness(t)
pw, _ := st.Add("anna")
sess := famCookie(famLogin(s, "203.0.113.10", "anna", pw, ""), familySessionCookie)
app := famPass(t, s, sess)
ok := func() bool {
return famAsk(s, "fapp.example.hu", "GET", "/", "application/json", app).Code == http.StatusOK
}
out := func(method, ft string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, "https://felhom.example.hu"+familyLogoutPath, strings.NewReader(url.Values{"_ft": {ft}}.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(sess)
w := httptest.NewRecorder()
s.ServeFamilyLogout(w, r)
return w
}
out(http.MethodGet, "")
out(http.MethodPost, "")
out(http.MethodPost, s.familyFormToken()) // the sign-in form's token: anyone can fetch one
if !ok() {
t.Fatal("a GET, a tokenless POST or a POST with the sign-in form's token ended the family session")
}
// the page's own sign-out form carries the session's token, and that ends it
r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+familyLoginPath, nil)
r.AddCookie(sess)
pg := httptest.NewRecorder()
s.ServeFamilyLogin(pg, r)
m := regexp.MustCompile(`action="/__family/logout">\s*<input type="hidden" name="_ft" value="([^"]+)">`).FindStringSubmatch(pg.Body.String())
if m == nil {
t.Fatal("the family page's sign-out form must carry its token")
}
if w := out(http.MethodPost, html.UnescapeString(m[1])); w.Code != http.StatusOK {
t.Fatalf("the page's own sign-out: %d", w.Code)
}
if ok() {
t.Fatal("the family page's own sign-out must end the session")
}
}
// ── R-976 (b): a family app cookie cannot be lifted into another app ─────────────────────────────────────────
func TestR976_AnAppCookieCannotBeLiftedIntoAnotherApp(t *testing.T) {
s, st := familyHarness(t)
pw, _ := st.Add("anna")
sess := famCookie(famLogin(s, "203.0.113.10", "anna", pw, ""), familySessionCookie)
app := famPass(t, s, sess)
if strings.Contains(app.Value, sess.Value) {
t.Fatal("the app cookie carries the family session itself — an app's backend could lift it")
}
// what an app backend sees, set as the family session on the dashboard host, must mint nothing
lifted := strings.SplitN(app.Value, ".", 2)[0]
rd := "https://fapp.example.hu/"
r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+familyStartPath+"?"+url.Values{"rd": {rd}}.Encode(), nil)
r.AddCookie(&http.Cookie{Name: familySessionCookie, Value: lifted})
w := httptest.NewRecorder()
s.ServeFamilyStart(w, r)
if loc := w.Header().Get("Location"); strings.Contains(loc, familyCallbackURI) {
t.Fatalf("a value lifted from an app cookie minted a pass: %q", loc)
}
// the sign-in token in flight carries no session either
tok := s.mintFamilyToken("fapp.example.hu", sess.Value, rd)
if dec, _ := base64.RawURLEncoding.DecodeString(tok); strings.Contains(string(dec), sess.Value) {
t.Fatal("the sign-in token carries the family session id")
}
// a cookie of the old shape (the session id itself, correctly signed) is refused
exp := strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10)
old := &http.Cookie{Name: familyAppCookie, Value: sess.Value + "." + exp + "." + s.gateMAC("famcookie", "fapp.example.hu", sess.Value, exp)}
if w := famAsk(s, "fapp.example.hu", "GET", "/", "application/json", old); w.Code == http.StatusOK {
t.Fatal("an app cookie carrying the session id must not pass any more")
}
// the genuine cookie still passes its own app
if w := famAsk(s, "fapp.example.hu", "GET", "/", "application/json", app); w.Code != http.StatusOK {
t.Fatalf("the member's own app cookie must pass: %d", w.Code)
}
}
// ── R-976 (a): a password change or reset ends every family and gate session ─────────────────────────────────
// r976World signs anna in (family session + app cookie) and walks the household through the setup gate (gate pass).
func r976World(t *testing.T, s *Server) (famSess, famApp, gatePass *http.Cookie) {
t.Helper()
pw, err := s.familyStore().Add("anna")
if err != nil {
t.Fatal(err)
}
famSess = famCookie(famLogin(s, "203.0.113.10", "anna", pw, ""), familySessionCookie)
famApp = famPass(t, s, famSess)
dash := &http.Cookie{Name: sessionCookieName, Value: newTestSession(s)}
w := gateStart(s, "https://gapp.example.hu/", dash)
cb, _ := url.Parse(w.Header().Get("Location"))
w = gateAsk(s, "gapp.example.hu", "GET", cb.RequestURI(), "text/html")
for _, c := range w.Result().Cookies() {
if c.Name == gateCookieName {
gatePass = c
}
}
if gatePass == nil {
t.Fatal("setup: no gate pass")
}
r976AllOpen(t, s, famSess, famApp, gatePass, true)
return famSess, famApp, gatePass
}
func r976AllOpen(t *testing.T, s *Server, famSess, famApp, gatePass *http.Cookie, want bool) {
t.Helper()
appOK := famAsk(s, "fapp.example.hu", "GET", "/", "application/json", famApp).Code == http.StatusOK
gateOK := gateAsk(s, "gapp.example.hu", "GET", "/", "application/json", gatePass).Code == http.StatusOK
r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+familyStartPath+"?"+url.Values{"rd": {"https://fapp.example.hu/"}}.Encode(), nil)
r.AddCookie(famSess)
w := httptest.NewRecorder()
s.ServeFamilyStart(w, r)
sessOK := strings.Contains(w.Header().Get("Location"), familyCallbackURI)
if appOK != want || gateOK != want || sessOK != want {
t.Fatalf("family app cookie passes %v, setup gate pass passes %v, family session mints %v — want all %v", appOK, gateOK, sessOK, want)
}
}
func TestR976_PasswordChangeEndsFamilyAndGateSessions(t *testing.T) {
s, st := familyHarness(t)
famSess, famApp, gatePass := r976World(t, s)
form := url.Values{"current_password": {"dashboard-pass"}, "new_password": {"a-new-password-12"}, "confirm_password": {"a-new-password-12"}}
r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu/settings/password", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.settingsPasswordHandler(w, r)
if w.Code != http.StatusFound {
t.Fatalf("the password change: %d", w.Code)
}
r976AllOpen(t, s, famSess, famApp, gatePass, false)
// the members stay; anna signs in again with her own password
if names := st.Names(); len(names) != 1 || names[0] != "anna" {
t.Fatalf("the family list must be kept: %v", names)
}
}
func TestR976_ClaimResetEndsFamilyAndGateSessions(t *testing.T) {
s, _ := familyHarness(t)
s.cfg.Web.SessionSecret = "test-session-secret-abcdef"
code := "alma-korte-szilva"
hash, _ := bcrypt.GenerateFromPassword([]byte(code), bcrypt.MinCost)
if err := s.settings.SetClaimCode(string(hash), 1, time.Now().UTC().Format(time.RFC3339)); err != nil {
t.Fatal(err)
}
famSess, famApp, gatePass := r976World(t, s)
form := url.Values{"_csrf": {s.claimCSRFToken()}, "code": {code}, "new_password": {"a-new-password-12"}, "confirm_password": {"a-new-password-12"}}
r := httptest.NewRequest(http.MethodPost, "/claim", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: s.claimCSRFToken()})
w := httptest.NewRecorder()
s.handleClaimSubmit(w, r)
if w.Code != http.StatusFound || w.Header().Get("Location") != "/" {
t.Fatalf("the reset: %d %q", w.Code, w.Header().Get("Location"))
}
r976AllOpen(t, s, famSess, famApp, gatePass, false)
// a restart must not bring the old passes back: the rotated key is what is on disk
s2 := &Server{cfg: s.cfg, settings: s.settings, stackMgr: s.stackMgr, logger: s.logger, version: "test",
sessions: map[string]*session{}, familyStoreOverride: s.familyStoreOverride}
s2.loadTemplates()
if w := gateAsk(s2, "gapp.example.hu", "GET", "/", "application/json", gatePass); w.Code == http.StatusOK {
t.Fatal("after a restart the old gate pass opened the app again")
}
}
+19 -2
View File
@@ -74,8 +74,11 @@ type Server struct {
sessionsMu sync.RWMutex
loginAttempts map[string]*loginAttempt
loginAttemptMu sync.Mutex
done chan struct{}
closeOnce sync.Once
// passwordCompare replaces bcrypt's compare at the dashboard sign-in in tests (R-983: a slow compare makes the
// parallel-try race measurable). nil → bcrypt.
passwordCompare func(hash, pw []byte) error
done chan struct{}
closeOnce sync.Once
// Guest launcher share (v0.165.0): its OWN per-IP brute-force limiter for the optional share
// password gate — deliberately separate from loginAttempts (the admin login), so a guest and the
@@ -89,6 +92,11 @@ type Server struct {
claimAttempts map[string]*claimAttempt
claimGlobal claimAttempt
claimClock func() time.Time
// R-983 (v0.324.0): the reset-code request's own limit, under claimMu and claimClock — when each visitor last
// reached the hub, and when the box last did (a sliding window). resetRequestFn replaces the hub call in tests.
resetReqVisitor map[string]time.Time
resetReqGlobal []time.Time
resetRequestFn func()
// Guard for FileBrowser sync — prevents concurrent file writes (H5 fix)
fileBrowserMu sync.Mutex
@@ -975,6 +983,15 @@ func (s *Server) CatchAllMiddleware(next http.Handler) http.Handler {
s.ServeFamilyGateAuth(w, r)
return
}
// R-983 (v0.324.0): no page of ours may be shown inside another page's frame. Every app on *.<domain> is
// SAME-SITE with the dashboard, so the Lax session cookie goes along into a frame an app page draws — a page
// on any app host could lay the signed-in dashboard under its own buttons. Nothing frames the dashboard
// legitimately (no iframe in the controller, the catalog, the hub or the website, checked 2026-10-16), so
// DENY rather than SAMEORIGIN. The two forwardAuth answers above are left exactly as they were: traefik
// reads them, and their headers must not leak onto an app's own responses. Pinned by
// TestR983_DashboardRefusesFraming.
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Content-Security-Policy", "frame-ancestors 'none'")
if strings.EqualFold(host, controllerHost) {
switch r.URL.Path { // v0.287.0: the family sign-in pages, outside the dashboard's own auth on purpose
case familyStartPath:
@@ -125,6 +125,7 @@ func TestR35_LogoutEndsSessionAcrossRestart(t *testing.T) {
t.Fatal("setup: the session must survive one restart first")
}
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.Header.Set(csrfHeaderName, s2.csrfTokenForSession(tok)) // R-983: the sign-out carries the session's token
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tok})
s2.handleLogout(httptest.NewRecorder(), req)
if s2.isValidSession(tok) {
@@ -235,6 +236,7 @@ func TestR35_FailedLogoutSaveRemovesFile(t *testing.T) {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.Header.Set(csrfHeaderName, s2.csrfTokenForSession(tok)) // R-983: the sign-out carries the session's token
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tok})
s2.handleLogout(httptest.NewRecorder(), req)
s2.Close()
+37 -4
View File
@@ -53,10 +53,11 @@ const (
)
type gateState struct {
once sync.Once
key []byte
mu sync.Mutex
used map[string]time.Time // token nonces already swapped, until they expire
once sync.Once
keyMu sync.RWMutex // guards key: rotateGateKey replaces it while requests read it
key []byte
mu sync.Mutex
used map[string]time.Time // token nonces already swapped, until they expire
}
// gateKey loads the gate's HMAC key from the data dir, or makes one. No data dir → a key for this run only.
@@ -82,9 +83,41 @@ func (s *Server) gateKey() []byte {
}
}
})
s.gate.keyMu.RLock()
defer s.gate.keyMu.RUnlock()
return s.gate.key
}
// rotateGateKey replaces the gate's key (R-976, v0.324.0). Every cookie and token the key signed stops opening
// anything at once: each setup-gate pass, each family app cookie, each sign-in token in flight. The household's
// browser passes again through its dashboard session. The new key is saved tmp+rename; when that fails the old file
// is REMOVED, so a restart makes a fresh key rather than bringing the old one — and every cookie it signed — back.
func (s *Server) rotateGateKey() {
s.gateKey()
k := make([]byte, 32)
_, _ = rand.Read(k)
s.gate.keyMu.Lock()
s.gate.key = k
s.gate.keyMu.Unlock()
if s.cfg == nil || s.cfg.Paths.DataDir == "" {
return
}
p := filepath.Join(s.cfg.Paths.DataDir, "setup-gate.key")
tmp := p + ".tmp"
err := os.WriteFile(tmp, []byte(hex.EncodeToString(k)), 0o600)
if err == nil {
err = os.Rename(tmp, p)
}
if err != nil {
_ = os.Remove(tmp)
if rerr := os.Remove(p); rerr != nil && !os.IsNotExist(rerr) {
s.logger.Printf("[ERROR] [web] gate key: the new key could not be saved (%v) and the old one could not be removed (%v) — a restart would accept passes signed before the password change", err, rerr)
return
}
s.logger.Printf("[WARN] [web] gate key: the new key could not be saved (%v) — the old one was removed, so a restart makes a fresh key", err)
}
}
func (s *Server) gateMAC(parts ...string) string {
m := hmac.New(sha256.New, s.gateKey())
m.Write([]byte(strings.Join(parts, "\x00")))
@@ -20,6 +20,7 @@
{{- if .SignedIn}}
<p id="family-gate-signed-in">{{T "family_gate.signed_in_note"}}</p>
<form method="post" action="/__family/logout">
<input type="hidden" name="_ft" value="{{.LogoutToken}}">
<button type="submit" class="btn btn-outline btn-full">{{T "family_gate.sign_out"}}</button>
</form>
{{- else}}
@@ -125,7 +125,7 @@
</ul>
<div class="sidebar-footer">
<span class="version">{{.Version}}</span>{{if .LangSwitch}}{{template "lang_globe" .}}{{end}}
{{if .AuthEnabled}}<a href="/logout" class="logout-link">{{T "layout.kijelentkezes"}}</a>{{end}}
{{if .AuthEnabled}}<form method="post" action="/logout" class="logout-form">{{.CSRFField}}<button type="submit" class="logout-link">{{T "layout.kijelentkezes"}}</button></form>{{end}}
</div>
</div>
</nav>
@@ -19,12 +19,12 @@
</div>
<div class="form-group">
<label for="new_password">{{T "settings_security.uj_jelszo"}}</label>
<input type="password" id="new_password" name="new_password" required minlength="8"
placeholder="{{T "settings_security.legalabb_8_karakter"}}" class="form-control">
<input type="password" id="new_password" name="new_password" required minlength="{{.MinPassword}}"
placeholder="{{T "settings_security.legalabb_karakter"}}" class="form-control">
</div>
<div class="form-group">
<label for="confirm_password">{{T "settings_security.uj_jelszo_megerositese"}}</label>
<input type="password" id="confirm_password" name="confirm_password" required minlength="8"
<input type="password" id="confirm_password" name="confirm_password" required minlength="{{.MinPassword}}"
placeholder="{{T "settings_security.jelszo_megegyszer"}}" class="form-control">
</div>
<button type="submit" class="btn btn-primary">{{T "settings_security.jelszo_modositasa"}}</button>
+2 -1
View File
@@ -150,7 +150,8 @@ body {
.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-form { display: inline; margin: 0; }
.logout-link { color: var(--text-3); text-decoration: none; transition: color 0.2s ease; background: none; border: 0; padding: 0; font: inherit; cursor: pointer; }
.logout-link:hover { color: var(--blue-bright); }
/* Mobile chrome (v0.166.0) — hidden by default so desktop (>768px) stays pixel-identical; the
≤768px block below turns the top bar on and converts the sidebar into an off-canvas drawer. */
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Alkalmazás importálása — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Alkalmazás importálása — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
+1 -1
View File
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Alkalmazás mentések — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Alkalmazás mentések — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Alkalmazás mentések — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Alkalmazás mentések — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Alkalmazás mentések — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Alkalmazás mentések — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Alkalmazás mentések — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Alkalmazás mentések — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Biztonsági mentés — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Biztonsági mentés — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Biztonsági mentés — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Biztonsági mentés — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Biztonsági mentés — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Távoli mentés beállítása — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Távoli mentés beállítása — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Távoli mentés beállítása — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Távoli mentés beállítása — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Távoli mentés beállítása — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Távoli mentés beállítása — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Távoli mentés beállítása — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Távoli mentés beállítása — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Távoli mentés beállítása — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Távoli mentés beállítása — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Távoli mentés beállítása — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Távoli mentés beállítása — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Távoli mentés beállítása — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Távoli mentés beállítása — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Visszaállítás a mentésből — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Visszaállítás a mentésből — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Visszaállítás a mentésből — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Visszaállítás a mentésből — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Biztonsági mentés — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Vezérlőpult — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Vezérlőpult — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
@@ -8,7 +8,7 @@
<title>Vezérlőpult — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
@@ -159,7 +159,7 @@
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><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>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>

Some files were not shown because too many files have changed in this diff Show More