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
gates / gates (push) Successful in 44s
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{} {
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+1
-1
@@ -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
@@ -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>
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+1
-1
@@ -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
@@ -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>
|
||||
|
||||
+1
-1
@@ -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
@@ -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>
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+2
-2
@@ -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
Reference in New Issue
Block a user