The family gate (decisions 63/64, R-780): family members with their own logins, a permanent forwardAuth door per family app, anchored exceptions, min_controller
gates / gates (push) Successful in 27s
gates / gates (push) Successful in 27s
- internal/family: the family list (bcrypt, generated 4x4 passwords shown once) + 30-day sessions in family.json (0600, atomic); a reset (generation), a removal or a logout ends sessions at the next request. - internal/stacks/family_gate.go: family_gate / family_gate_except / min_controller in .felhom.yml; the door is written BEFORE the first start (install and a removed app's restore), a life record in app.yaml, reconciled by the gate loop; priority below the install hold, setup gate and sign-up block; every exception anchored ^/prefix(/|$) (finding F1). - internal/web/family_gate.go: forwardAuth /__felhom_gate/family (app cookie felhom_famgate, host-only, names a store session); /__family/start|login|logout on the dashboard host (session cookie felhom_family, Path=/__family); sign-in counted per visitor (clientIP) AND per name, short windows; the household's dashboard session vouches. RequireAuth never reads a family cookie. The "Család" card on the security page: add / new password / remove. Red-proofs RP-F1..RP-F7 (felhom.eu audits/family-gate-2026-10-02/A/). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
// Package family is the household's family list for the permanent family gate (`09` §3 decisions 63, 64; R-780).
|
||||
//
|
||||
// Each family member has their OWN name and password (never the dashboard's); the household's dashboard admin adds,
|
||||
// resets and removes them. A member who signs in gets a SESSION (30 days). Every app cookie the gate mints names the
|
||||
// session, so the gate asks this store on every request: a removed member, a reset password (the member's generation
|
||||
// moves on) or a logout (the session is deleted) ends every app's access at the next request.
|
||||
//
|
||||
// A session with Member "" is the HOUSEHOLD's: minted when the dashboard's own session vouched for the browser (the
|
||||
// setup gate's rule, decision 46). It is never a dashboard session and never opens the dashboard.
|
||||
//
|
||||
// Persisted as family.json (0600, tmp+fsync+rename) in the controller's data dir, so the controller's own backup and
|
||||
// restore carry it and a restart keeps every session. Hashes only — a password is shown once, when it is made.
|
||||
// Pinned by internal/family/family_test.go.
|
||||
package family
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// SessionLife is how long a family sign-in lasts.
|
||||
const SessionLife = 30 * 24 * time.Hour
|
||||
|
||||
// Member is one family member. Gen moves on at every password reset, ending the member's earlier sessions.
|
||||
type Member struct {
|
||||
Name string `json:"name"`
|
||||
Hash string `json:"hash"`
|
||||
Gen int `json:"gen"`
|
||||
Created string `json:"created"`
|
||||
}
|
||||
|
||||
// Session is one sign-in. Member "" = the household (a dashboard session vouched for the browser).
|
||||
type Session struct {
|
||||
ID string `json:"id"`
|
||||
Member string `json:"member"`
|
||||
Gen int `json:"gen"`
|
||||
Exp time.Time `json:"exp"`
|
||||
}
|
||||
|
||||
type file struct {
|
||||
Members []Member `json:"members"`
|
||||
Sessions []Session `json:"sessions"`
|
||||
}
|
||||
|
||||
// Store is the family list and its sessions. A nil *Store answers "nobody" to every question.
|
||||
type Store struct {
|
||||
path string
|
||||
now func() time.Time
|
||||
cost int
|
||||
|
||||
dummyOnce sync.Once
|
||||
dummy []byte
|
||||
|
||||
mu sync.Mutex
|
||||
members map[string]*Member
|
||||
sessions map[string]Session
|
||||
}
|
||||
|
||||
var (
|
||||
ErrBadName = errors.New("a name is 1-32 characters: lower-case letters, digits, dot, dash, underscore; starting with a letter or digit")
|
||||
ErrExists = errors.New("a member with that name already exists")
|
||||
ErrNoMember = errors.New("no member with that name")
|
||||
)
|
||||
|
||||
var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`)
|
||||
|
||||
// Open loads (or starts) the store at dir/family.json. A file that cannot be parsed is an error, never an empty list:
|
||||
// an empty list would silently lock every member out, and a rewrite would destroy the household's list.
|
||||
func Open(dir string) (*Store, error) {
|
||||
s := &Store{path: filepath.Join(dir, "family.json"), now: time.Now, cost: bcrypt.DefaultCost,
|
||||
members: map[string]*Member{}, sessions: map[string]Session{}}
|
||||
b, err := os.ReadFile(s.path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return s, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var f file
|
||||
if err := json.Unmarshal(b, &f); err != nil {
|
||||
return nil, fmt.Errorf("family.json unreadable: %w", err)
|
||||
}
|
||||
for i := range f.Members {
|
||||
m := f.Members[i]
|
||||
s.members[m.Name] = &m
|
||||
}
|
||||
for _, se := range f.Sessions {
|
||||
s.sessions[se.ID] = se
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// SetClock and SetCost are test seams.
|
||||
func (s *Store) SetClock(f func() time.Time) { s.now = f }
|
||||
func (s *Store) SetCost(c int) { s.cost = c }
|
||||
|
||||
func (s *Store) saveLocked() error {
|
||||
f := file{Members: []Member{}, Sessions: []Session{}}
|
||||
for _, m := range s.members {
|
||||
f.Members = append(f.Members, *m)
|
||||
}
|
||||
sort.Slice(f.Members, func(i, j int) bool { return f.Members[i].Name < f.Members[j].Name })
|
||||
now := s.now()
|
||||
for id, se := range s.sessions {
|
||||
if now.After(se.Exp) {
|
||||
delete(s.sessions, id)
|
||||
continue
|
||||
}
|
||||
f.Sessions = append(f.Sessions, se)
|
||||
}
|
||||
sort.Slice(f.Sessions, func(i, j int) bool { return f.Sessions[i].ID < f.Sessions[j].ID })
|
||||
b, err := json.MarshalIndent(f, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
fh, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fh.Write(b); err != nil {
|
||||
fh.Close()
|
||||
return err
|
||||
}
|
||||
if err := fh.Sync(); err != nil {
|
||||
fh.Close()
|
||||
return err
|
||||
}
|
||||
if err := fh.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, s.path)
|
||||
}
|
||||
|
||||
// pwAlphabet leaves out look-alikes (0/o, 1/l/i).
|
||||
const pwAlphabet = "abcdefghjkmnpqrstuvwxyz23456789"
|
||||
|
||||
// newPassword is 4 groups of 4 from pwAlphabet (~79 bits), easy to read aloud and type on a phone.
|
||||
func newPassword() string {
|
||||
out := make([]byte, 0, 19)
|
||||
for g := 0; g < 4; g++ {
|
||||
if g > 0 {
|
||||
out = append(out, '-')
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(pwAlphabet))))
|
||||
out = append(out, pwAlphabet[n.Int64()])
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// Add makes a member with a generated password, returned once.
|
||||
func (s *Store) Add(name string) (string, error) {
|
||||
if !nameRE.MatchString(name) {
|
||||
return "", ErrBadName
|
||||
}
|
||||
pw := newPassword()
|
||||
h, err := bcrypt.GenerateFromPassword([]byte(pw), s.cost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.members[name]; ok {
|
||||
return "", ErrExists
|
||||
}
|
||||
s.members[name] = &Member{Name: name, Hash: string(h), Gen: 1, Created: s.now().UTC().Format(time.RFC3339)}
|
||||
if err := s.saveLocked(); err != nil {
|
||||
delete(s.members, name)
|
||||
return "", err
|
||||
}
|
||||
return pw, nil
|
||||
}
|
||||
|
||||
// Reset gives a member a new generated password (returned once) and ends every earlier session of theirs.
|
||||
func (s *Store) Reset(name string) (string, error) {
|
||||
pw := newPassword()
|
||||
h, err := bcrypt.GenerateFromPassword([]byte(pw), s.cost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
m, ok := s.members[name]
|
||||
if !ok {
|
||||
return "", ErrNoMember
|
||||
}
|
||||
old := *m
|
||||
m.Hash, m.Gen = string(h), m.Gen+1
|
||||
s.dropSessionsLocked(name)
|
||||
if err := s.saveLocked(); err != nil {
|
||||
*m = old
|
||||
return "", err
|
||||
}
|
||||
return pw, nil
|
||||
}
|
||||
|
||||
// Remove deletes a member and every session of theirs.
|
||||
func (s *Store) Remove(name string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.members[name]; !ok {
|
||||
return ErrNoMember
|
||||
}
|
||||
delete(s.members, name)
|
||||
s.dropSessionsLocked(name)
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
func (s *Store) dropSessionsLocked(name string) {
|
||||
for id, se := range s.sessions {
|
||||
if se.Member == name {
|
||||
delete(s.sessions, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Names lists the members, sorted.
|
||||
func (s *Store) Names() []string {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]string, 0, len(s.members))
|
||||
for n := range s.members {
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Verify checks a member's password. An unknown name costs a bcrypt compare too, so timing does not tell names apart.
|
||||
func (s *Store) Verify(name, password string) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
s.mu.Lock()
|
||||
m, ok := s.members[name]
|
||||
hash := ""
|
||||
if ok {
|
||||
hash = m.Hash
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
s.dummyOnce.Do(func() { s.dummy, _ = bcrypt.GenerateFromPassword([]byte(newPassword()), s.cost) })
|
||||
_ = bcrypt.CompareHashAndPassword(s.dummy, []byte(password))
|
||||
return false
|
||||
}
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
// NewSession starts a session for member ("" = the household). It refuses a member who does not exist.
|
||||
func (s *Store) NewSession(member string) (string, error) {
|
||||
if s == nil {
|
||||
return "", ErrNoMember
|
||||
}
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
id := hex.EncodeToString(b)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
gen := 0
|
||||
if member != "" {
|
||||
m, ok := s.members[member]
|
||||
if !ok {
|
||||
return "", ErrNoMember
|
||||
}
|
||||
gen = m.Gen
|
||||
}
|
||||
s.sessions[id] = Session{ID: id, Member: member, Gen: gen, Exp: s.now().Add(SessionLife)}
|
||||
if err := s.saveLocked(); err != nil {
|
||||
delete(s.sessions, id)
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Valid reports whether a session still holds: it exists, has not expired, and — for a member — the member still
|
||||
// exists at the generation the session was made in.
|
||||
func (s *Store) Valid(id string) (Session, bool) {
|
||||
if s == nil || id == "" {
|
||||
return Session{}, false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
se, ok := s.sessions[id]
|
||||
if !ok || s.now().After(se.Exp) {
|
||||
return Session{}, false
|
||||
}
|
||||
if se.Member != "" {
|
||||
m, ok := s.members[se.Member]
|
||||
if !ok || m.Gen != se.Gen {
|
||||
return Session{}, false
|
||||
}
|
||||
}
|
||||
return se, true
|
||||
}
|
||||
|
||||
// EndSession deletes a session (logout). Unknown ids are not an error.
|
||||
func (s *Store) EndSession(id string) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.sessions[id]; !ok {
|
||||
return nil
|
||||
}
|
||||
delete(s.sessions, id)
|
||||
return s.saveLocked()
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package family
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func testStore(t *testing.T) (*Store, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
s, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetCost(bcrypt.MinCost)
|
||||
return s, dir
|
||||
}
|
||||
|
||||
// A member signs in with their own password; the password is never stored; the list survives a reopen.
|
||||
func TestFamily_AddVerifyPersist(t *testing.T) {
|
||||
s, dir := testStore(t)
|
||||
pw, err := s.Add("anna")
|
||||
if err != nil || len(pw) != 19 {
|
||||
t.Fatalf("add: %v %q", err, pw)
|
||||
}
|
||||
if !s.Verify("anna", pw) || s.Verify("anna", pw+"x") || s.Verify("bela", pw) {
|
||||
t.Fatal("verify wrong")
|
||||
}
|
||||
b, _ := os.ReadFile(filepath.Join(dir, "family.json"))
|
||||
if strings.Contains(string(b), pw) {
|
||||
t.Fatal("the plain password reached family.json")
|
||||
}
|
||||
if st, _ := os.Stat(filepath.Join(dir, "family.json")); st.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("family.json mode %v", st.Mode().Perm())
|
||||
}
|
||||
s2, err := Open(dir)
|
||||
if err != nil || !s2.Verify("anna", pw) {
|
||||
t.Fatalf("the list did not survive a reopen: %v", err)
|
||||
}
|
||||
if _, err := s.Add("anna"); err != ErrExists {
|
||||
t.Fatalf("duplicate: %v", err)
|
||||
}
|
||||
for _, bad := range []string{"", "Anna", "a b", "../x", strings.Repeat("a", 33), "-x"} {
|
||||
if _, err := s.Add(bad); err != ErrBadName {
|
||||
t.Fatalf("name %q accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// THE CONSEQUENCE: a reset or a removal ends the member's sessions at once; a logout ends one session; an expired
|
||||
// session is refused; another member's session is untouched.
|
||||
func TestFamily_SessionsEndOnResetRemoveLogout(t *testing.T) {
|
||||
s, _ := testStore(t)
|
||||
now := time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC)
|
||||
s.SetClock(func() time.Time { return now })
|
||||
s.Add("anna")
|
||||
s.Add("bela")
|
||||
a1, _ := s.NewSession("anna")
|
||||
a2, _ := s.NewSession("anna")
|
||||
b1, _ := s.NewSession("bela")
|
||||
h1, _ := s.NewSession("")
|
||||
for _, id := range []string{a1, a2, b1, h1} {
|
||||
if _, ok := s.Valid(id); !ok {
|
||||
t.Fatalf("fresh session %s refused", id)
|
||||
}
|
||||
}
|
||||
if _, err := s.Reset("anna"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := s.Valid(a1); ok {
|
||||
t.Fatal("a reset password must end the member's earlier sessions")
|
||||
}
|
||||
if _, ok := s.Valid(b1); !ok {
|
||||
t.Fatal("another member's session must survive")
|
||||
}
|
||||
s.EndSession(b1)
|
||||
if _, ok := s.Valid(b1); ok {
|
||||
t.Fatal("logout must end the session")
|
||||
}
|
||||
b2, _ := s.NewSession("bela")
|
||||
s.Remove("bela")
|
||||
if _, ok := s.Valid(b2); ok {
|
||||
t.Fatal("a removed member's session must end")
|
||||
}
|
||||
if _, err := s.NewSession("bela"); err != ErrNoMember {
|
||||
t.Fatal("no session for a removed member")
|
||||
}
|
||||
now = now.Add(SessionLife + time.Minute)
|
||||
if _, ok := s.Valid(h1); ok {
|
||||
t.Fatal("an expired session must be refused")
|
||||
}
|
||||
}
|
||||
|
||||
// A gen bump survives a reopen: an OLD session read back from disk after a reset is still refused.
|
||||
func TestFamily_ResetHoldsAcrossReopen(t *testing.T) {
|
||||
s, dir := testStore(t)
|
||||
s.Add("anna")
|
||||
id, _ := s.NewSession("anna")
|
||||
s.Reset("anna")
|
||||
s2, _ := Open(dir)
|
||||
if _, ok := s2.Valid(id); ok {
|
||||
t.Fatal("after a reopen the pre-reset session must still be refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFamily_UnreadableFileIsAnError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "family.json"), []byte("{not json"), 0o600)
|
||||
if _, err := Open(dir); err == nil {
|
||||
t.Fatal("an unreadable list must be an error, never an empty list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFamily_NilStoreAnswersNobody(t *testing.T) {
|
||||
var s *Store
|
||||
if s.Verify("a", "b") || len(s.Names()) != 0 {
|
||||
t.Fatal("nil store")
|
||||
}
|
||||
if _, ok := s.Valid("x"); ok {
|
||||
t.Fatal("nil store valid")
|
||||
}
|
||||
}
|
||||
@@ -2508,5 +2508,34 @@
|
||||
"flash.offbox.enabled_all": "Off-site backup is on for every app.",
|
||||
"login.msg.empty_password": "Enter your password.",
|
||||
"login.msg.rate_limited": "Too many wrong tries from this address. Try again in a minute.",
|
||||
"login.msg.wrong_password": "Wrong password."
|
||||
"login.msg.wrong_password": "Wrong password.",
|
||||
"err.stacks.family_gate_failed": "The family gate could not be prepared, so the app was not installed: %s",
|
||||
"err.stacks.needs_newer_controller": "This app needs newer box software, so it was not installed: %s",
|
||||
"family_gate.page_title": "Family sign-in",
|
||||
"family_gate.page_body": "Open this app with your family name and password.",
|
||||
"family_gate.name": "Your name",
|
||||
"family_gate.password": "Your password",
|
||||
"family_gate.sign_in": "Sign in",
|
||||
"family_gate.sign_out": "Sign out",
|
||||
"family_gate.signed_in_note": "You are signed in with your family account.",
|
||||
"family_gate.msg.form_expired": "The form expired. Reload the page.",
|
||||
"family_gate.msg.locked": "Too many wrong tries. Try again in a few minutes.",
|
||||
"family_gate.msg.wrong": "Wrong name or password.",
|
||||
"family_gate.msg.signed_in": "You are signed in. Open the app again.",
|
||||
"family_gate.msg.signed_out": "You are signed out. The family apps ask you to sign in again.",
|
||||
"family_gate.msg.store_unreadable": "The family list cannot be read right now.",
|
||||
"family_gate.msg.bad_name": "A name is 1–32 characters: lower-case letters, digits, dot, dash or underscore.",
|
||||
"family_gate.msg.exists": "A family member with that name already exists.",
|
||||
"family_gate.msg.no_member": "No family member with that name.",
|
||||
"family_gate.card_title": "Family",
|
||||
"family_gate.card_desc": "Family members sign in to the family-gated apps with their own name and password. It does not open this dashboard.",
|
||||
"family_gate.add": "Add a family member",
|
||||
"family_gate.name_placeholder": "e.g. anna",
|
||||
"family_gate.reset": "New password",
|
||||
"family_gate.remove": "Remove",
|
||||
"family_gate.none": "No family members yet.",
|
||||
"family_gate.pw_once": "The password shows only now. Write it down, or give it to the family member:",
|
||||
"family_gate.confirm_remove": "Remove them? Their sign-ins end at once.",
|
||||
"family_gate.confirm_reset": "Give a new password? The old one stops at once.",
|
||||
"family_gate.error": "That did not work. Try again."
|
||||
}
|
||||
|
||||
@@ -2496,5 +2496,34 @@
|
||||
"flash.offbox.enabled_all": "A távoli mentés be van kapcsolva minden alkalmazásra.",
|
||||
"login.msg.empty_password": "Add meg a jelszavad.",
|
||||
"login.msg.rate_limited": "Túl sok hibás próbálkozás erről a címről. Próbáld újra egy perc múlva.",
|
||||
"login.msg.wrong_password": "Hibás jelszó."
|
||||
"login.msg.wrong_password": "Hibás jelszó.",
|
||||
"err.stacks.family_gate_failed": "A családi kapu nem készült el, ezért nem telepítettük az alkalmazást: %s",
|
||||
"err.stacks.needs_newer_controller": "Ehhez az alkalmazáshoz újabb doboz-szoftver kell, ezért most nem telepítettük: %s",
|
||||
"family_gate.page_title": "Családi belépés",
|
||||
"family_gate.page_body": "Ezt az alkalmazást a családi neveddel és jelszavaddal nyithatod meg.",
|
||||
"family_gate.name": "Neved",
|
||||
"family_gate.password": "Jelszavad",
|
||||
"family_gate.sign_in": "Belépés",
|
||||
"family_gate.sign_out": "Kilépés",
|
||||
"family_gate.signed_in_note": "Be vagy lépve a családi fiókoddal.",
|
||||
"family_gate.msg.form_expired": "Az űrlap lejárt. Töltsd be újra az oldalt.",
|
||||
"family_gate.msg.locked": "Túl sok hibás próbálkozás. Próbáld újra pár perc múlva.",
|
||||
"family_gate.msg.wrong": "Hibás név vagy jelszó.",
|
||||
"family_gate.msg.signed_in": "Beléptél. Nyisd meg újra az alkalmazást.",
|
||||
"family_gate.msg.signed_out": "Kiléptél. A családi alkalmazások újra belépést kérnek.",
|
||||
"family_gate.msg.store_unreadable": "A családi lista most nem olvasható.",
|
||||
"family_gate.msg.bad_name": "A név 1–32 karakter: kisbetű, szám, pont, kötőjel vagy aláhúzás.",
|
||||
"family_gate.msg.exists": "Már van ilyen nevű családtag.",
|
||||
"family_gate.msg.no_member": "Nincs ilyen nevű családtag.",
|
||||
"family_gate.card_title": "Család",
|
||||
"family_gate.card_desc": "A családtagok a saját nevükkel és jelszavukkal lépnek be a családi kapus alkalmazásokba. Ezzel a vezérlőpultot nem érik el.",
|
||||
"family_gate.add": "Családtag hozzáadása",
|
||||
"family_gate.name_placeholder": "pl. anna",
|
||||
"family_gate.reset": "Új jelszó",
|
||||
"family_gate.remove": "Eltávolítás",
|
||||
"family_gate.none": "Még nincs családtag.",
|
||||
"family_gate.pw_once": "A jelszó csak most látszik. Írd fel, vagy add át a családtagnak:",
|
||||
"family_gate.confirm_remove": "Eltávolítod? A belépései azonnal megszűnnek.",
|
||||
"family_gate.confirm_reset": "Új jelszót adsz? A régi azonnal megszűnik.",
|
||||
"family_gate.error": "Nem sikerült. Próbáld újra."
|
||||
}
|
||||
|
||||
@@ -184,6 +184,8 @@ type AppConfig struct {
|
||||
// SetupGate (v0.280.0, decision 46) is the app's setup gate: closed from a fresh install until the first
|
||||
// setup is done. A life record (carried across a restore). See setup_gate.go.
|
||||
SetupGate *SetupGateRecord `yaml:"setup_gate,omitempty" json:"setup_gate,omitempty"`
|
||||
// FamilyGate (v0.287.0, decisions 63/64): the app's permanent family gate, on since its install. A life record.
|
||||
FamilyGate *FamilyGateRecord `yaml:"family_gate,omitempty" json:"family_gate,omitempty"`
|
||||
// InstallHold (R-741, decision 45): an after_install app is held (the gate's door) from its fresh install until
|
||||
// its known first login is replaced. Same record shape as SetupGate. See install_hold.go.
|
||||
InstallHold *SetupGateRecord `yaml:"install_hold,omitempty" json:"install_hold,omitempty"`
|
||||
@@ -428,6 +430,12 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// v0.287.0: a template that needs a newer controller is refused before anything is written.
|
||||
if err := checkMinController(&meta); err != nil {
|
||||
clearDeploying()
|
||||
m.logger.Printf("[ERROR] [stacks] Deploy %s REFUSED: %v", req.StackName, err)
|
||||
return "", util.MsgError("err.stacks.needs_newer_controller", err.Error())
|
||||
}
|
||||
// `09` §3 decision 46: a gated template is installed CLOSED, and the gate's traefik file is written BEFORE
|
||||
// the first start (spike F2). Cannot write it → the install is refused: never published open.
|
||||
var gate *SetupGateRecord
|
||||
@@ -455,6 +463,24 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
|
||||
hold = h
|
||||
}
|
||||
|
||||
// v0.287.0 (decisions 63/64): a family app is never published open — its door is written before the first start.
|
||||
var family *FamilyGateRecord
|
||||
if meta.FamilyGate {
|
||||
f, err := m.prepareFamilyGate(req.StackName, stack.ComposePath, env, &meta)
|
||||
if err != nil {
|
||||
clearDeploying()
|
||||
if gate != nil {
|
||||
_ = m.removeSetupGateFile(req.StackName)
|
||||
}
|
||||
if hold != nil {
|
||||
_ = os.Remove(m.installHoldPath(req.StackName))
|
||||
}
|
||||
m.logger.Printf("[ERROR] [stacks] Deploy %s REFUSED: the family gate could not be prepared: %v", req.StackName, err)
|
||||
return "", util.MsgError("err.stacks.family_gate_failed", err.Error())
|
||||
}
|
||||
family = f
|
||||
}
|
||||
|
||||
// Save app.yaml.
|
||||
// CTRL-T2-1: persist the env now, but mark the ON-DISK state Deployed:false
|
||||
// until `docker compose up -d` actually succeeds (done in runComposeDeploy).
|
||||
@@ -476,6 +502,7 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
|
||||
DesiredState: DesiredStateRunning,
|
||||
SetupGate: gate,
|
||||
InstallHold: hold,
|
||||
FamilyGate: family,
|
||||
}
|
||||
|
||||
diskCfg := *appCfg
|
||||
@@ -772,6 +799,14 @@ func (m *Manager) PersistUnitRedeployConfig(name string, env map[string]string)
|
||||
// its setup: the lock record and its block, written HERE — before anything starts. An app that was never removed
|
||||
// keeps exactly the record it had (carryLifeRecords): a restore never adds a lock to an installed app that the
|
||||
// household has not closed (decision 49). Pinned by TestR773_*.
|
||||
// v0.287.0: a removed family app restored from its backup gets its door before anything starts (the R-773 lesson).
|
||||
if priorRaw == nil && cfg.FamilyGate == nil && meta.FamilyGate {
|
||||
rec, err := m.prepareFamilyGate(name, stack.ComposePath, env, &meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the family gate could not be prepared (the app was not started): %w", err)
|
||||
}
|
||||
cfg.FamilyGate = rec
|
||||
}
|
||||
if priorRaw == nil && cfg.SetupGate == nil {
|
||||
rec, err := m.restoreSignupLock(name, stack.ComposePath, env, &meta)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// ── The family gate (v0.287.0, `09` §3 decisions 63 and 64; R-780) ───────────────────────────────────────
|
||||
//
|
||||
// A PERMANENT gate in front of an app whose template says `family_gate: true`: only a member of the household's family
|
||||
// list (internal/family — each with their OWN password) or the household itself (a dashboard session vouching) gets
|
||||
// through. Same mechanism as the setup gate (decision 46): a traefik file-provider file puts a forwardAuth door in
|
||||
// front of every router the app publishes; internal/web/family_gate.go answers it. Differences, each deliberate:
|
||||
//
|
||||
// - it never opens: the file stays while the app is installed;
|
||||
// - its priority is BELOW the install hold, the setup gate and the sign-up block (each is stricter), ABOVE the app's
|
||||
// own docker routers;
|
||||
// - `family_gate_except:` lists path prefixes a phone or e-reader app calls (Grimmory's OPDS/Kobo/KOReader). Each
|
||||
// gets a router WITHOUT the door — the app's OWN login decides there. Every exception is ANCHORED at a path-segment
|
||||
// boundary (`^/prefix(/|$)`): traefik's PathPrefix is a plain string prefix, and the spike measured
|
||||
// `/api/v1/opdsx` walking past an unanchored `/api/v1/opds` (finding F1, audits/permanent-gate-2026-10-01/).
|
||||
//
|
||||
// The record (`family_gate:` in app.yaml) is written at install (and at the restore of a removed app — the R-773
|
||||
// lesson), so a catalog change never gates or un-gates an installed app; the exceptions follow the current template.
|
||||
// Pinned by internal/stacks/family_gate_test.go.
|
||||
|
||||
const (
|
||||
familyGateAuthURL = "http://felhom-controller:8080/__felhom_gate/family"
|
||||
familyGatePriority = 40000 // < setupGatePriority (100000): the setup gate, sign-up block, install hold outrank it
|
||||
familyExceptBoost = 20000 // an exception router outranks the family door, never the setup gate
|
||||
)
|
||||
|
||||
// FamilyGateRecord is the app's family gate: on since its install. A life record (carried across a restore).
|
||||
type FamilyGateRecord struct {
|
||||
Since string `yaml:"since" json:"since"`
|
||||
Hosts []string `yaml:"hosts,omitempty" json:"hosts,omitempty"`
|
||||
}
|
||||
|
||||
// exceptPathRE: a literal path prefix — no traefik matcher, no regex. Anything else is refused, never escaped into
|
||||
// something it did not say.
|
||||
var exceptPathRE = regexp.MustCompile(`^/[A-Za-z0-9._~/-]*$`)
|
||||
|
||||
// FamilyExceptRegexp turns one exception prefix into the anchored regexp the router uses: the prefix itself, or the
|
||||
// prefix followed by "/". A trailing "/" in the template is the same prefix.
|
||||
func FamilyExceptRegexp(p string) (string, error) {
|
||||
if !exceptPathRE.MatchString(p) || strings.Contains(p, "//") || strings.Contains(p, "/../") || strings.HasSuffix(p, "/..") {
|
||||
return "", fmt.Errorf("family_gate_except %q: a literal path prefix starting with /", p)
|
||||
}
|
||||
p = strings.TrimRight(p, "/")
|
||||
if p == "" {
|
||||
return "", fmt.Errorf("family_gate_except %q would except the whole app", "/")
|
||||
}
|
||||
return "^" + regexp.QuoteMeta(p) + "(/|$)", nil
|
||||
}
|
||||
|
||||
func renderFamilyGate(name string, rs []gateRouter, except []string) (string, error) {
|
||||
var res []string
|
||||
for _, p := range except {
|
||||
re, err := FamilyExceptRegexp(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
res = append(res, re)
|
||||
}
|
||||
var b strings.Builder
|
||||
mw := "felhom-family-gate-" + name
|
||||
fmt.Fprintf(&b, "# Family gate for %s — managed by felhom-controller (`09` §3 decisions 63-64).\n", name)
|
||||
b.WriteString("# Only the household's family members (and the household) reach the app; the listed paths keep the app's own login.\n")
|
||||
b.WriteString("http:\n middlewares:\n")
|
||||
fmt.Fprintf(&b, " %s:\n forwardAuth:\n address: %q\n", mw, familyGateAuthURL)
|
||||
b.WriteString(" routers:\n")
|
||||
tls := func(r gateRouter) {
|
||||
if r.CertResolver != "" {
|
||||
fmt.Fprintf(&b, " tls:\n certResolver: %s\n", r.CertResolver)
|
||||
} else {
|
||||
b.WriteString(" tls: {}\n")
|
||||
}
|
||||
}
|
||||
for _, r := range rs {
|
||||
fmt.Fprintf(&b, " %s-%s:\n", mw, r.Name)
|
||||
fmt.Fprintf(&b, " rule: %q\n", r.Rule)
|
||||
fmt.Fprintf(&b, " priority: %d\n", familyGatePriority+len(r.Rule))
|
||||
b.WriteString(" entryPoints:\n - websecure\n")
|
||||
tls(r)
|
||||
fmt.Fprintf(&b, " middlewares:\n - %s@file\n", mw)
|
||||
fmt.Fprintf(&b, " service: %q\n", r.Service+"@docker")
|
||||
for i, re := range res {
|
||||
rule := fmt.Sprintf("(%s) && PathRegexp(`%s`)", r.Rule, re)
|
||||
fmt.Fprintf(&b, " %s-%s-except-%d:\n", mw, r.Name, i)
|
||||
fmt.Fprintf(&b, " rule: %q\n", rule)
|
||||
fmt.Fprintf(&b, " priority: %d\n", familyGatePriority+familyExceptBoost+len(rule))
|
||||
b.WriteString(" entryPoints:\n - websecure\n")
|
||||
tls(r)
|
||||
fmt.Fprintf(&b, " service: %q\n", r.Service+"@docker")
|
||||
}
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func (m *Manager) familyGatePath(name string) string {
|
||||
return filepath.Join(m.setupGateDir(), "family-gate-"+name+".yml")
|
||||
}
|
||||
|
||||
// writeFamilyGate writes (or refreshes) the app's family-gate file. Returns the hosts it covers.
|
||||
func (m *Manager) writeFamilyGate(name, composePath string, env map[string]string, except []string) ([]string, error) {
|
||||
rs, err := gateRoutersFromCompose(composePath, env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
want, err := renderFamilyGate(name, rs, except)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(m.setupGateDir(), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := m.familyGatePath(name)
|
||||
if cur, err := os.ReadFile(p); err == nil && string(cur) == want {
|
||||
return gateHosts(rs), nil
|
||||
}
|
||||
tmp := p + ".tmp"
|
||||
if err := os.WriteFile(tmp, []byte(want), 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.Rename(tmp, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gateHosts(rs), nil
|
||||
}
|
||||
|
||||
// prepareFamilyGate is the install's (and a removed app's restore's) step: the file BEFORE the first start, then the
|
||||
// record the caller saves. Cannot write it → the caller refuses: a family app is never published open.
|
||||
func (m *Manager) prepareFamilyGate(name, composePath string, env map[string]string, meta *Metadata) (*FamilyGateRecord, error) {
|
||||
hosts, err := m.writeFamilyGate(name, composePath, env, meta.FamilyGateExcept)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.logger.Printf("[INFO] [stacks] %s: family gate ON before the first start — only family members reach %v (exceptions: %v)", name, hosts, meta.FamilyGateExcept)
|
||||
return &FamilyGateRecord{Since: m.now().UTC().Format(time.RFC3339), Hosts: hosts}, nil
|
||||
}
|
||||
|
||||
// FamilyGateHost maps a host to the family-gated app that owns it.
|
||||
func (m *Manager) FamilyGateHost(host string) (name string, found bool) {
|
||||
host = strings.ToLower(host)
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for n, st := range m.stacks {
|
||||
if st.Deployed && st.AppConfig != nil && st.AppConfig.FamilyGate != nil && containsStr(st.AppConfig.FamilyGate.Hosts, host) {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// familyGateTick: every installed family app has its file (rewritten from the current template's exceptions); every
|
||||
// other family-gate file goes.
|
||||
func (m *Manager) familyGateTick() {
|
||||
type item struct {
|
||||
name, dir, compose string
|
||||
except []string
|
||||
}
|
||||
var items []item
|
||||
keep := map[string]bool{}
|
||||
m.mu.RLock()
|
||||
for n, st := range m.stacks {
|
||||
if !st.Deployed || st.AppConfig == nil || st.AppConfig.FamilyGate == nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, item{name: n, dir: filepath.Dir(st.ComposePath), compose: st.ComposePath,
|
||||
except: append([]string(nil), st.Meta.FamilyGateExcept...)})
|
||||
keep[n] = true
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
if ents, err := os.ReadDir(m.setupGateDir()); err == nil {
|
||||
for _, e := range ents {
|
||||
n := e.Name()
|
||||
if !strings.HasPrefix(n, "family-gate-") || !strings.HasSuffix(n, ".yml") {
|
||||
continue
|
||||
}
|
||||
app := strings.TrimSuffix(strings.TrimPrefix(n, "family-gate-"), ".yml")
|
||||
if !keep[app] {
|
||||
if err := os.Remove(m.familyGatePath(app)); err == nil {
|
||||
m.logger.Printf("[INFO] [stacks] %s: removed the family-gate file of an app that is not installed", app)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].name < items[j].name })
|
||||
for _, it := range items {
|
||||
cfg := LoadAppConfigDecrypted(it.dir, m.encKey)
|
||||
if cfg == nil {
|
||||
continue
|
||||
}
|
||||
if _, err := m.writeFamilyGate(it.name, it.compose, cfg.Env, it.except); err != nil {
|
||||
m.logger.Printf("[ERROR] [stacks] %s: the family gate's traefik file could not be (re)written: %v", it.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── the template's minimum controller (v0.287.0) ─────────────────────────────────────────────────────────
|
||||
|
||||
var controllerVersion string
|
||||
|
||||
// SetControllerVersion tells the stacks package which controller it runs in (main.go). Empty = unknown (a dev build):
|
||||
// min_controller is then not enforced, and that is logged.
|
||||
func SetControllerVersion(v string) { controllerVersion = v }
|
||||
|
||||
// ErrNeedsNewerController: the template needs a newer controller than this one.
|
||||
var ErrNeedsNewerController = fmt.Errorf("the app needs a newer box software")
|
||||
|
||||
// checkMinController refuses a template whose `min_controller` is above this controller. A family-gated app on a
|
||||
// controller that does not know the field would be installed OPEN — this is the field a NEWER template uses to say so.
|
||||
func checkMinController(meta *Metadata) error {
|
||||
if strings.TrimSpace(meta.MinController) == "" {
|
||||
return nil
|
||||
}
|
||||
need, err := util.ParseVersion(meta.MinController)
|
||||
if err != nil {
|
||||
return fmt.Errorf("min_controller %q unreadable: %w", meta.MinController, err)
|
||||
}
|
||||
have, err := util.ParseVersion(controllerVersion)
|
||||
if err != nil {
|
||||
return nil // a dev build: no version to compare (logged at the caller)
|
||||
}
|
||||
if have.Compare(need) < 0 {
|
||||
return fmt.Errorf("%w (needs %s, this box runs %s)", ErrNeedsNewerController, need, have)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// v0.287.0 (`09` §3 decisions 63/64) — the family gate's traefik side.
|
||||
|
||||
const familyYml = "display_name: Family App\nfamily_gate: true\n" +
|
||||
"family_gate_except: [\"/api/v1/opds\", \"/api/kobo/\"]\n" +
|
||||
"deploy_fields:\n - env_var: DOMAIN\n type: domain\n - env_var: SUBDOMAIN\n type: subdomain\n default: gapp\n"
|
||||
|
||||
// Rule 5 (finding F1): every exception is anchored at a path-segment boundary — `/api/v1/opds` must not match
|
||||
// `/api/v1/opdsx` or `/api/v1/opds-evil`; a regex or matcher in the template is refused, never escaped into meaning.
|
||||
// COMPANION RED-PROOF: return "^"+QuoteMeta(p) (no boundary) → the look-alikes match and this fails.
|
||||
func TestFamilyExceptRegexp_Anchored(t *testing.T) {
|
||||
re, err := FamilyExceptRegexp("/api/v1/opds")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rx := regexp.MustCompile(re)
|
||||
for p, want := range map[string]bool{
|
||||
"/api/v1/opds": true, "/api/v1/opds/": true, "/api/v1/opds/catalog": true,
|
||||
"/api/v1/opdsx": false, "/api/v1/opds-evil": false, "/api/v1/opds.json": false, "/x/api/v1/opds": false, "/api/v1/opd": false,
|
||||
} {
|
||||
if rx.MatchString(p) != want {
|
||||
t.Errorf("%q: match=%v want %v (regexp %s)", p, !want, want, re)
|
||||
}
|
||||
}
|
||||
if re2, _ := FamilyExceptRegexp("/api/kobo/"); re2 != re2 || !regexp.MustCompile(re2).MatchString("/api/kobo/tok/v1/x") || regexp.MustCompile(re2).MatchString("/api/koboz") {
|
||||
t.Errorf("a trailing slash is the same prefix: %s", re2)
|
||||
}
|
||||
for _, bad := range []string{"", "/", "api", "/api/(.*)", "/api/v1/opds|/", "PathPrefix(`/x`)", "/a//b", "/a/../b", "/a/..", "/a b"} {
|
||||
if _, err := FamilyExceptRegexp(bad); err == nil {
|
||||
t.Errorf("%q must be refused", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The install writes the family door BEFORE the first start; exceptions get routers WITHOUT the door, above the family
|
||||
// router and below the setup gate; the record is saved.
|
||||
// COMPANION RED-PROOF: drop the prepareFamilyGate block in DeployStack → "the family-gate file did not exist" fails.
|
||||
func TestFamilyGate_WrittenBeforeTheFirstStart(t *testing.T) {
|
||||
m := gateManager(t, familyYml)
|
||||
p := m.familyGatePath("gapp")
|
||||
var atUp string
|
||||
existed := false
|
||||
m.composeExecFn = func(_ string, _ map[string]string, args ...string) (string, error) {
|
||||
if len(args) > 0 && args[0] == "up" {
|
||||
b, err := os.ReadFile(p)
|
||||
existed, atUp = err == nil, string(b)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
done := make(chan bool, 1)
|
||||
m.SetDeployDoneHook(func(_ string, ok bool, _ string) { done <- ok })
|
||||
if _, err := m.DeployStack(DeployRequest{StackName: "gapp"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(20 * time.Second):
|
||||
t.Fatal("the deploy never ended")
|
||||
}
|
||||
if !existed {
|
||||
t.Fatal("the family-gate file did not exist when the app was first started — it was published open")
|
||||
}
|
||||
for _, want := range []string{"http://felhom-controller:8080/__felhom_gate/family", "felhom-family-gate-gapp@file",
|
||||
"PathRegexp(`^/api/v1/opds(/|$)`)", "PathRegexp(`^/api/kobo(/|$)`)"} {
|
||||
if !strings.Contains(atUp, want) {
|
||||
t.Errorf("the file lacks %q:\n%s", want, atUp)
|
||||
}
|
||||
}
|
||||
// each except router has NO middleware; each family router has one
|
||||
blocks := strings.Split(atUp, "\n felhom-family-gate-gapp-")
|
||||
nExcept, nDoor := 0, 0
|
||||
for _, b := range blocks[1:] {
|
||||
isExcept := strings.Contains(strings.SplitN(b, "\n", 2)[0], "-except-")
|
||||
hasMW := strings.Contains(b, "middlewares:")
|
||||
if isExcept && hasMW {
|
||||
t.Errorf("an exception router carries the door:\n%s", b)
|
||||
}
|
||||
if !isExcept && !hasMW {
|
||||
t.Errorf("a family router lacks the door:\n%s", b)
|
||||
}
|
||||
if isExcept {
|
||||
nExcept++
|
||||
} else {
|
||||
nDoor++
|
||||
}
|
||||
for _, line := range strings.Split(b, "\n") {
|
||||
var pr int
|
||||
if _, err := fmt.Sscanf(strings.TrimSpace(line), "priority: %d", &pr); err == nil && pr >= setupGatePriority {
|
||||
t.Errorf("a family router outranks the setup gate (%d)", pr)
|
||||
}
|
||||
}
|
||||
}
|
||||
if nDoor != 2 || nExcept != 4 {
|
||||
t.Errorf("want 2 door routers and 4 exception routers (2 app routers × 2 exceptions), got %d/%d", nDoor, nExcept)
|
||||
}
|
||||
cfg := LoadAppConfig(filepath.Join(m.cfg.Paths.StacksDir, "gapp"))
|
||||
if cfg == nil || cfg.FamilyGate == nil || strings.Join(cfg.FamilyGate.Hosts, ",") != "gapp.example.hu" {
|
||||
t.Fatalf("record: %+v", cfg)
|
||||
}
|
||||
if n, ok := m.FamilyGateHost("GAPP.example.hu"); !ok || n != "gapp" {
|
||||
t.Fatalf("FamilyGateHost: %q %v", n, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// An unanchorable exception in the template refuses the install — never published open.
|
||||
func TestFamilyGate_BadExceptionRefusesTheInstall(t *testing.T) {
|
||||
m := gateManager(t, strings.Replace(familyYml, `"/api/kobo/"`, `"/api/(.*)"`, 1))
|
||||
m.composeExecFn = func(_ string, _ map[string]string, _ ...string) (string, error) { return "", nil }
|
||||
if _, err := m.DeployStack(DeployRequest{StackName: "gapp"}); err == nil {
|
||||
t.Fatal("a template with an unanchorable exception must be refused")
|
||||
}
|
||||
if _, err := os.Stat(m.familyGatePath("gapp")); !os.IsNotExist(err) {
|
||||
t.Fatal("no file may be left behind")
|
||||
}
|
||||
}
|
||||
|
||||
// A REMOVED family app restored from its backup gets its door before anything starts; the loop keeps it; a stale
|
||||
// file of an uninstalled app goes.
|
||||
func TestFamilyGate_RestoreOfARemovedAppAndTheLoop(t *testing.T) {
|
||||
m := gateManager(t, familyYml)
|
||||
must(t, m.PersistUnitRedeployConfig("gapp", map[string]string{"DOMAIN": "example.hu", "SUBDOMAIN": "gapp"}))
|
||||
cfg := LoadAppConfig(filepath.Join(m.cfg.Paths.StacksDir, "gapp"))
|
||||
if cfg == nil || cfg.FamilyGate == nil {
|
||||
t.Fatal("the restore of a removed family app must record its door")
|
||||
}
|
||||
if _, err := os.Stat(m.familyGatePath("gapp")); err != nil {
|
||||
t.Fatal("the restore must write the door before the start")
|
||||
}
|
||||
must(t, os.Remove(m.familyGatePath("gapp")))
|
||||
stale := m.familyGatePath("ghost")
|
||||
must(t, os.WriteFile(stale, []byte("x"), 0o644))
|
||||
m.SetupGateTick()
|
||||
if _, err := os.Stat(m.familyGatePath("gapp")); err != nil {
|
||||
t.Fatal("the loop must put the door back")
|
||||
}
|
||||
if _, err := os.Stat(stale); !os.IsNotExist(err) {
|
||||
t.Fatal("the loop must remove a stale family-gate file")
|
||||
}
|
||||
}
|
||||
|
||||
// min_controller: a template that needs a newer box is refused before anything is written.
|
||||
func TestMinController(t *testing.T) {
|
||||
old := controllerVersion
|
||||
t.Cleanup(func() { controllerVersion = old })
|
||||
controllerVersion = "0.286.1"
|
||||
if err := checkMinController(&Metadata{MinController: "0.287.0"}); err == nil {
|
||||
t.Fatal("0.286.1 must refuse a template needing 0.287.0")
|
||||
}
|
||||
controllerVersion = "0.287.0"
|
||||
if err := checkMinController(&Metadata{MinController: "0.287.0"}); err != nil {
|
||||
t.Fatalf("equal version must pass: %v", err)
|
||||
}
|
||||
if err := checkMinController(&Metadata{}); err != nil {
|
||||
t.Fatal("no field must pass")
|
||||
}
|
||||
if err := checkMinController(&Metadata{MinController: "garbage"}); err == nil {
|
||||
t.Fatal("an unreadable min_controller must refuse")
|
||||
}
|
||||
m := gateManager(t, familyYml+"min_controller: \"9.9.9\"\n")
|
||||
controllerVersion = "0.287.0"
|
||||
if _, err := m.DeployStack(DeployRequest{StackName: "gapp"}); err == nil {
|
||||
t.Fatal("DeployStack must refuse a template needing a newer controller")
|
||||
}
|
||||
if _, err := os.Stat(m.familyGatePath("gapp")); !os.IsNotExist(err) {
|
||||
t.Fatal("nothing may be written for a refused template")
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ func carryLifeRecords(logger *log.Logger, name string, prior, cfg *AppConfig) {
|
||||
// opened; a gate that was still closed stays closed (its probe opens it if the restored data is set up).
|
||||
// No prior record (a removed app, kept data, a rebuilt guest) = no gate: the data comes back with its admin.
|
||||
cfg.SetupGate = prior.SetupGate
|
||||
cfg.FamilyGate = prior.FamilyGate // v0.287.0: a restore never un-gates a family app
|
||||
cfg.InstallHold = prior.InstallHold // R-741: the loop opens it when the restored record says the login was replaced
|
||||
cfg.DefaultLogin = prior.DefaultLogin
|
||||
cfg.AfterSetup = prior.AfterSetup
|
||||
|
||||
@@ -65,8 +65,15 @@ type Metadata struct {
|
||||
// setup gate opens. See signup_block.go.
|
||||
SignupBlock string `yaml:"signup_block,omitempty" json:"signup_block,omitempty"`
|
||||
// AfterSetup (v0.282.0, decisions 47/49): the app's OWN sign-up switch, set when the gate opens. See after_setup.go.
|
||||
AfterSetup *AfterSetupSpec `yaml:"after_setup,omitempty" json:"after_setup,omitempty"`
|
||||
Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,omitempty"`
|
||||
AfterSetup *AfterSetupSpec `yaml:"after_setup,omitempty" json:"after_setup,omitempty"`
|
||||
// FamilyGate (v0.287.0, `09` §3 decisions 63/64): a PERMANENT gate — only family members (and the household) reach
|
||||
// the app; FamilyGateExcept are literal path prefixes a phone/e-reader app calls, left to the app's own login
|
||||
// (anchored at a segment boundary). See family_gate.go.
|
||||
FamilyGate bool `yaml:"family_gate,omitempty" json:"family_gate,omitempty"`
|
||||
FamilyGateExcept []string `yaml:"family_gate_except,omitempty" json:"family_gate_except,omitempty"`
|
||||
// MinController (v0.287.0): the lowest box software that installs this template correctly; a lower one refuses.
|
||||
MinController string `yaml:"min_controller,omitempty" json:"min_controller,omitempty"`
|
||||
Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,omitempty"`
|
||||
// InitialCreds: for apps that auto-generate a first-login credential into a file inside the
|
||||
// container (e.g. Crafty's default-creds.txt). The controller reads + parses that file live and
|
||||
// surfaces it on the app page, so the customer never has to dig through logs. Optional.
|
||||
|
||||
@@ -499,6 +499,7 @@ func (m *Manager) SetupGateTick() {
|
||||
}
|
||||
m.reconcileSignupBlocks()
|
||||
m.installHoldTick()
|
||||
m.familyGateTick()
|
||||
}
|
||||
|
||||
// RunSetupGateLoop runs SetupGateTick every interval until ctx ends.
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/family"
|
||||
)
|
||||
|
||||
// ── The family gate's answerer (v0.287.0, `09` §3 decisions 63 and 64; R-780) ─────────────────────────────
|
||||
//
|
||||
// traefik asks GET /__felhom_gate/family (forwardAuth) for every request to a family-gated app (except the anchored
|
||||
// paths the template leaves to the app's own login — internal/stacks/family_gate.go writes that file). The answer:
|
||||
//
|
||||
// - a valid FAMILY APP COOKIE (`felhom_famgate`, host-only on the app host) → 200;
|
||||
// - /__felhom_gate/fcb?t=<token> → the token (60 s, one use, bound to the host and to a family session) becomes the
|
||||
// app cookie, and the browser goes back where it was going;
|
||||
// - a browser GET without one → 302 to https://felhom.<domain>/__family/start?rd=<where it was going>;
|
||||
// - anything else → 401 JSON.
|
||||
//
|
||||
// On the DASHBOARD host, outside the dashboard's own auth (CatchAllMiddleware answers them first):
|
||||
//
|
||||
// - /__family/start — with a valid FAMILY SESSION (`felhom_family`, Path=/__family, so the browser never even sends
|
||||
// it to a dashboard page) or the household's dashboard session: a token and a 302 to the app; without: the family
|
||||
// sign-in page;
|
||||
// - /__family/login — a member's OWN name and password; counted per VISITOR (clientIP, R-753) and per NAME, short
|
||||
// windows, never "everyone";
|
||||
// - /__family/logout — ends the family session, and with it every app cookie minted from it (the store is asked on
|
||||
// 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.
|
||||
|
||||
const (
|
||||
familySessionCookie = "felhom_family"
|
||||
familyAppCookie = "felhom_famgate"
|
||||
familyAuthPath = "/__felhom_gate/family"
|
||||
familyCallbackURI = "/__felhom_gate/fcb"
|
||||
familyStartPath = "/__family/start"
|
||||
familyLoginPath = "/__family/login"
|
||||
familyLogoutPath = "/__family/logout"
|
||||
familyCookiePath = "/__family"
|
||||
familyFormLife = time.Hour
|
||||
|
||||
familyVisitorMax = 5
|
||||
familyVisitorWindow = time.Minute
|
||||
familyNameMax = 10
|
||||
familyNameWindow = 10 * time.Minute
|
||||
)
|
||||
|
||||
type familyState struct {
|
||||
once sync.Once
|
||||
store *family.Store
|
||||
|
||||
mu sync.Mutex
|
||||
visitor map[string][]time.Time
|
||||
name map[string][]time.Time
|
||||
}
|
||||
|
||||
// familyStore opens the family list once. An unreadable list is logged and answers "nobody" (fail closed — the gate
|
||||
// stays shut; the household still passes with its dashboard session).
|
||||
func (s *Server) familyStore() *family.Store {
|
||||
s.fam.once.Do(func() {
|
||||
s.fam.visitor = map[string][]time.Time{}
|
||||
s.fam.name = map[string][]time.Time{}
|
||||
if s.familyStoreOverride != nil {
|
||||
s.fam.store = s.familyStoreOverride
|
||||
return
|
||||
}
|
||||
if s.cfg == nil || s.cfg.Paths.DataDir == "" {
|
||||
return
|
||||
}
|
||||
st, err := family.Open(s.cfg.Paths.DataDir)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] [web] family gate: the family list could not be read (%v) — only the household passes until it is fixed", err)
|
||||
return
|
||||
}
|
||||
s.fam.store = st
|
||||
})
|
||||
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 {
|
||||
s.familyStore()
|
||||
now := s.gateNow()
|
||||
s.fam.mu.Lock()
|
||||
defer s.fam.mu.Unlock()
|
||||
prune := func(m map[string][]time.Time, k string, win time.Duration) int {
|
||||
var keep []time.Time
|
||||
for _, t := range m[k] {
|
||||
if now.Sub(t) < win {
|
||||
keep = append(keep, t)
|
||||
}
|
||||
}
|
||||
if len(keep) == 0 {
|
||||
delete(m, k)
|
||||
} else {
|
||||
m[k] = keep
|
||||
}
|
||||
return len(keep)
|
||||
}
|
||||
v := prune(s.fam.visitor, visitor, familyVisitorWindow)
|
||||
n := 0
|
||||
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()
|
||||
s.fam.visitor[visitor] = append(s.fam.visitor[visitor], now)
|
||||
if name != "" {
|
||||
s.fam.name[name] = append(s.fam.name[name], now)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) familyCleared(visitor, name string) {
|
||||
s.fam.mu.Lock()
|
||||
defer s.fam.mu.Unlock()
|
||||
delete(s.fam.visitor, visitor)
|
||||
delete(s.fam.name, name)
|
||||
}
|
||||
|
||||
// familyRDHost: the host of a return address that may be used — https, on this household's domain, a family-gated app.
|
||||
func (s *Server) familyRDHost(rd string) (string, bool) {
|
||||
u, err := url.Parse(rd)
|
||||
if err != nil || u.Scheme != "https" || u.User != nil || u.Host == "" || s.stackMgr == nil || s.cfg == nil {
|
||||
return "", false
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
if u.Port() != "" || !strings.HasSuffix(host, "."+strings.ToLower(s.cfg.Customer.Domain)) {
|
||||
return "", false
|
||||
}
|
||||
if _, found := s.stackMgr.FamilyGateHost(host); !found {
|
||||
return "", false
|
||||
}
|
||||
return host, true
|
||||
}
|
||||
|
||||
type familyToken struct {
|
||||
Host string `json:"h"`
|
||||
Sid string `json:"s"`
|
||||
Exp int64 `json:"e"`
|
||||
Nonce string `json:"n"`
|
||||
RD string `json:"r"`
|
||||
MAC string `json:"m"`
|
||||
}
|
||||
|
||||
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)
|
||||
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) {
|
||||
b, err := base64.RawURLEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return "", "", errors.New("malformed")
|
||||
}
|
||||
var t familyToken
|
||||
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))) {
|
||||
return "", "", errors.New("bad signature")
|
||||
}
|
||||
if t.Host != host {
|
||||
return "", "", errors.New("for another app")
|
||||
}
|
||||
now := s.gateNow()
|
||||
if now.Unix() > t.Exp {
|
||||
return "", "", errors.New("expired")
|
||||
}
|
||||
s.gateKey()
|
||||
s.gate.mu.Lock()
|
||||
defer s.gate.mu.Unlock()
|
||||
for n, until := range s.gate.used {
|
||||
if now.After(until) {
|
||||
delete(s.gate.used, n)
|
||||
}
|
||||
}
|
||||
if _, seen := s.gate.used["f:"+t.Nonce]; seen {
|
||||
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
|
||||
}
|
||||
|
||||
// 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).
|
||||
func (s *Server) familyAppCookieSession(r *http.Request, host string) (family.Session, bool) {
|
||||
c, err := r.Cookie(familyAppCookie)
|
||||
if err != nil {
|
||||
return family.Session{}, false
|
||||
}
|
||||
parts := strings.Split(c.Value, ".")
|
||||
if len(parts) != 3 {
|
||||
return family.Session{}, false
|
||||
}
|
||||
n, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil || s.gateNow().Unix() > n {
|
||||
return family.Session{}, false
|
||||
}
|
||||
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])
|
||||
}
|
||||
|
||||
func familyRefuse(w http.ResponseWriter, code int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(code)
|
||||
_, _ = w.Write([]byte(`{"error":"sign in with your family login"}`))
|
||||
}
|
||||
|
||||
// ServeFamilyGateAuth is traefik's forwardAuth answer for a family-gated app. Like the setup gate it trusts only the
|
||||
// X-Forwarded-Host/-Uri/-Method traefik writes from the request it forwards (forwardAuth's own, never the client's).
|
||||
func (s *Server) ServeFamilyGateAuth(w http.ResponseWriter, r *http.Request) {
|
||||
host := strings.ToLower(r.Header.Get("X-Forwarded-Host"))
|
||||
if i := strings.LastIndex(host, ":"); i != -1 {
|
||||
host = host[:i]
|
||||
}
|
||||
uri := r.Header.Get("X-Forwarded-Uri")
|
||||
if uri == "" {
|
||||
uri = "/"
|
||||
}
|
||||
method := r.Header.Get("X-Forwarded-Method")
|
||||
if s.stackMgr == nil {
|
||||
familyRefuse(w, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
app, found := s.stackMgr.FamilyGateHost(host)
|
||||
if !found {
|
||||
s.logger.Printf("[WARN] [web] family gate: asked about %q, which no family app owns — refused", host)
|
||||
familyRefuse(w, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if u, err := url.Parse(uri); err == nil && u.Path == familyCallbackURI {
|
||||
sid, rd, err := s.takeFamilyToken(u.Query().Get("t"), host)
|
||||
if err == nil {
|
||||
if _, ok := s.familyStore().Valid(sid); !ok {
|
||||
err = errors.New("the session ended")
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Printf("[WARN] [web] family gate %s: a sign-in token was refused (%v) — visitor %s", app, err, clientIP(r))
|
||||
familyRefuse(w, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
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: "/",
|
||||
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))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
http.Redirect(w, r, rd, http.StatusFound)
|
||||
return
|
||||
}
|
||||
if _, ok := s.familyAppCookieSession(r, host); ok {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if (method == "" || method == http.MethodGet) && strings.Contains(r.Header.Get("Accept"), "text/html") {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
http.Redirect(w, r, "https://felhom."+s.cfg.Customer.Domain+familyStartPath+"?"+url.Values{"rd": {"https://" + host + uri}}.Encode(), http.StatusFound)
|
||||
return
|
||||
}
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] family gate %s: %s %s without a pass — 401 (visitor %s)", app, method, uri, clientIP(r))
|
||||
}
|
||||
familyRefuse(w, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
// familySessionFromCookie: the family session this browser holds on the dashboard host.
|
||||
func (s *Server) familySessionFromCookie(r *http.Request) (family.Session, bool) {
|
||||
c, err := r.Cookie(familySessionCookie)
|
||||
if err != nil {
|
||||
return family.Session{}, false
|
||||
}
|
||||
return s.familyStore().Valid(c.Value)
|
||||
}
|
||||
|
||||
// ServeFamilyStart is /__family/start on the dashboard host.
|
||||
func (s *Server) ServeFamilyStart(w http.ResponseWriter, r *http.Request) {
|
||||
rd := r.URL.Query().Get("rd")
|
||||
host, ok := s.familyRDHost(rd)
|
||||
if !ok {
|
||||
s.renderFamilyPage(w, r, "", "", http.StatusOK)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
se, ok := s.familySessionFromCookie(r)
|
||||
if !ok && s.hasSession(r) {
|
||||
// The household's own dashboard session vouches (decision 46's rule) — a household session in the family store,
|
||||
// never the dashboard session itself.
|
||||
if st := s.familyStore(); st != nil {
|
||||
if sid, err := st.NewSession(""); err == nil {
|
||||
se, ok = family.Session{ID: sid}, true
|
||||
}
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
http.Redirect(w, r, "https://"+host+familyCallbackURI+"?"+url.Values{"t": {s.mintFamilyToken(host, se.ID, rd)}}.Encode(), http.StatusFound)
|
||||
return
|
||||
}
|
||||
s.renderFamilyPage(w, r, rd, "", http.StatusOK)
|
||||
}
|
||||
|
||||
// familyFormToken is the sign-in form's own CSRF (the visitor has no session): an HMAC over its expiry.
|
||||
func (s *Server) familyFormToken() string {
|
||||
exp := strconv.FormatInt(s.gateNow().Add(familyFormLife).Unix(), 10)
|
||||
return exp + "." + s.gateMAC("famform", exp)
|
||||
}
|
||||
|
||||
func (s *Server) familyFormTokenValid(v string) bool {
|
||||
exp, mac, ok := strings.Cut(v, ".")
|
||||
n, err := strconv.ParseInt(exp, 10, 64)
|
||||
if !ok || err != nil || s.gateNow().Unix() > n {
|
||||
return false
|
||||
}
|
||||
return hmac.Equal([]byte(mac), []byte(s.gateMAC("famform", exp)))
|
||||
}
|
||||
|
||||
// ServeFamilyLogin is /__family/login: GET = the page, POST = a member's own name and password.
|
||||
func (s *Server) ServeFamilyLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
s.renderFamilyPage(w, r, r.URL.Query().Get("rd"), "", http.StatusOK)
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
rd := r.FormValue("rd")
|
||||
if !s.familyFormTokenValid(r.FormValue("_ft")) {
|
||||
s.renderFamilyPage(w, r, rd, s.msg(r, "family_gate.msg.form_expired"), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSpace(r.FormValue("name")))
|
||||
visitor := rateKey(r)
|
||||
if s.familyLocked(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
|
||||
}
|
||||
sid, err := st.NewSession(name)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] [web] family sign-in: the session could not be saved: %v", err)
|
||||
s.renderFamilyPage(w, r, rd, s.msg(r, "family_gate.msg.wrong"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.familyCleared(visitor, name)
|
||||
http.SetCookie(w, &http.Cookie{Name: familySessionCookie, Value: sid, Path: familyCookiePath,
|
||||
MaxAge: int(family.SessionLife.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode})
|
||||
s.logger.Printf("[INFO] [web] family sign-in: %s — visitor %s", name, visitor)
|
||||
if _, ok := s.familyRDHost(rd); ok {
|
||||
http.Redirect(w, r, familyStartPath+"?"+url.Values{"rd": {rd}}.Encode(), http.StatusFound)
|
||||
return
|
||||
}
|
||||
s.renderFamilyPage(w, r, "", s.msg(r, "family_gate.msg.signed_in"), http.StatusOK)
|
||||
}
|
||||
|
||||
// ServeFamilyLogout is /__family/logout: the family session ends, and every app cookie minted from it with it.
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
func (s *Server) renderFamilyPage(w http.ResponseWriter, r *http.Request, rd, notice string, code int) {
|
||||
data := map[string]interface{}{"RD": rd, "Notice": notice, "FormToken": s.familyFormToken(), "Version": s.version}
|
||||
if host, ok := s.familyRDHost(rd); ok {
|
||||
data["Host"] = host
|
||||
if app, found := s.stackMgr.FamilyGateHost(host); found {
|
||||
if st, ok := s.stackMgr.GetStack(app); ok {
|
||||
data["AppName"] = st.Meta.DisplayName
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, ok := s.familySessionFromCookie(r); ok {
|
||||
data["SignedIn"] = true
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(code)
|
||||
if err := s.executeTemplateLang(w, r, "familygate", data); err != nil {
|
||||
s.logger.Printf("[ERROR] [web] family page: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── the dashboard's „Család" card (authenticated, CSRF-protected) ───────────────────────────────────────────
|
||||
|
||||
func (s *Server) familyMembersHandler(w http.ResponseWriter, r *http.Request) {
|
||||
st := s.familyStore()
|
||||
if st == nil {
|
||||
escrowJSON(w, http.StatusServiceUnavailable, nil, s.msg(r, "family_gate.msg.store_unreadable"))
|
||||
return
|
||||
}
|
||||
escrowJSON(w, http.StatusOK, map[string]any{"members": st.Names()}, "")
|
||||
}
|
||||
|
||||
// familyMemberActionHandler: POST /family/members/{add,reset,remove} with `name`. add/reset answer the new password
|
||||
// ONCE (never logged, never in a page render).
|
||||
func (s *Server) familyMemberActionHandler(w http.ResponseWriter, r *http.Request, action string) {
|
||||
st := s.familyStore()
|
||||
if st == nil {
|
||||
escrowJSON(w, http.StatusServiceUnavailable, nil, s.msg(r, "family_gate.msg.store_unreadable"))
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
name := strings.ToLower(strings.TrimSpace(r.FormValue("name")))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
var pw string
|
||||
var err error
|
||||
switch action {
|
||||
case "add":
|
||||
pw, err = st.Add(name)
|
||||
case "reset":
|
||||
pw, err = st.Reset(name)
|
||||
case "remove":
|
||||
err = st.Remove(name)
|
||||
default:
|
||||
escrowJSON(w, http.StatusNotFound, nil, "")
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, family.ErrBadName):
|
||||
escrowJSON(w, http.StatusBadRequest, nil, s.msg(r, "family_gate.msg.bad_name"))
|
||||
return
|
||||
case errors.Is(err, family.ErrExists):
|
||||
escrowJSON(w, http.StatusConflict, nil, s.msg(r, "family_gate.msg.exists"))
|
||||
return
|
||||
case errors.Is(err, family.ErrNoMember):
|
||||
escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "family_gate.msg.no_member"))
|
||||
return
|
||||
case err != nil:
|
||||
s.logger.Printf("[ERROR] [web] family %s %q: %v", action, name, err)
|
||||
escrowJSON(w, http.StatusInternalServerError, nil, s.msg(r, "family_gate.msg.store_unreadable"))
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] family: the household's %s of %q from %s (password never logged)", action, name, clientIP(r))
|
||||
out := map[string]any{"name": name, "members": st.Names()}
|
||||
if pw != "" {
|
||||
out["password"] = pw
|
||||
}
|
||||
escrowJSON(w, http.StatusOK, out, "")
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"html"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/family"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// v0.287.0 (`09` §3 decisions 63/64, R-780) — the family gate's answerer and its sign-in pages, driven through the
|
||||
// handlers traefik and the browser reach (ServeFamilyGateAuth, ServeFamilyStart/Login/Logout) and through the
|
||||
// dashboard's own RequireAuth. Docker is a stub on PATH.
|
||||
|
||||
func familyHarness(t *testing.T) (*Server, *family.Store) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "bin")
|
||||
for _, d := range []string{bin, filepath.Join(dir, "data"), filepath.Join(dir, "stacks", "fapp"), filepath.Join(dir, "stacks", "gapp")} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(bin, "docker"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", bin)
|
||||
write := func(app, name, body string) {
|
||||
if err := os.WriteFile(filepath.Join(dir, "stacks", app, name), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("fapp", "docker-compose.yml", "services:\n fapp:\n image: busybox\n")
|
||||
write("fapp", ".felhom.yml", "display_name: Family App\nslug: fapp\nfamily_gate: true\n")
|
||||
write("fapp", "app.yaml", "deployed: true\nfamily_gate:\n since: \"2026-10-02T00:00:00Z\"\n hosts: [fapp.example.hu]\n")
|
||||
write("gapp", "docker-compose.yml", "services:\n gapp:\n image: busybox\n")
|
||||
write("gapp", ".felhom.yml", "display_name: Gated App\nslug: gapp\nsetup_gate: true\n")
|
||||
write("gapp", "app.yaml", "deployed: true\nsetup_gate:\n state: closed\n since: \"2026-09-29T00:00:00Z\"\n hosts: [gapp.example.hu]\n")
|
||||
lg := log.New(io.Discard, "", 0)
|
||||
cfg := config.Default()
|
||||
cfg.Customer.Domain = "example.hu"
|
||||
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
|
||||
cfg.Paths.DataDir = filepath.Join(dir, "data")
|
||||
h, _ := bcrypt.GenerateFromPassword([]byte("dashboard-pass"), bcrypt.MinCost)
|
||||
cfg.Web.PasswordHash = string(h)
|
||||
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mgr, err := stacks.NewManager(cfg, lg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mgr.ScanStacks(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := family.Open(cfg.Paths.DataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.SetCost(bcrypt.MinCost)
|
||||
s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test", sessions: map[string]*session{},
|
||||
loginAttempts: map[string]*loginAttempt{}, familyStoreOverride: st}
|
||||
s.loadTemplates()
|
||||
return s, st
|
||||
}
|
||||
|
||||
func famAsk(s *Server, host, method, uri, accept string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(http.MethodGet, "http://felhom-controller:8080"+familyAuthPath, nil)
|
||||
r.Header.Set("X-Forwarded-Host", host)
|
||||
r.Header.Set("X-Forwarded-Method", method)
|
||||
r.Header.Set("X-Forwarded-Uri", uri)
|
||||
r.Header.Set("Accept", accept)
|
||||
for _, c := range cookies {
|
||||
r.AddCookie(c)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeFamilyGateAuth(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
func famCookie(w *httptest.ResponseRecorder, name string) *http.Cookie {
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == name {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// famLogin posts the sign-in form as visitor `remote` (a direct peer — clientIP is the peer).
|
||||
func famLogin(s *Server, remote, name, pw, rd string) *httptest.ResponseRecorder {
|
||||
form := url.Values{"_ft": {s.familyFormToken()}, "name": {name}, "password": {pw}, "rd": {rd}}
|
||||
r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu"+familyLoginPath, strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.RemoteAddr = remote + ":5000"
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeFamilyLogin(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
// famPass walks a signed-in browser (its family session cookie) through start → callback → app cookie.
|
||||
func famPass(t *testing.T, s *Server, sess *http.Cookie) *http.Cookie {
|
||||
t.Helper()
|
||||
rd := "https://fapp.example.hu/books"
|
||||
r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+familyStartPath+"?"+url.Values{"rd": {rd}}.Encode(), nil)
|
||||
r.AddCookie(sess)
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeFamilyStart(w, r)
|
||||
loc := w.Header().Get("Location")
|
||||
if w.Code != http.StatusFound || !strings.HasPrefix(loc, "https://fapp.example.hu"+familyCallbackURI+"?t=") {
|
||||
t.Fatalf("start with a family session: %d %q", w.Code, loc)
|
||||
}
|
||||
u, _ := url.Parse(loc)
|
||||
cb := famAsk(s, "fapp.example.hu", "GET", u.RequestURI(), "text/html")
|
||||
app := famCookie(cb, familyAppCookie)
|
||||
if cb.Code != http.StatusFound || cb.Header().Get("Location") != rd || app == nil {
|
||||
t.Fatalf("callback: %d %q cookie %v", cb.Code, cb.Header().Get("Location"), app)
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
// Exit item 1: a stranger reaches nothing — a browser is sent to the family sign-in, anything else is refused.
|
||||
func TestFamilyGate_StrangerReachesNothing(t *testing.T) {
|
||||
s, _ := familyHarness(t)
|
||||
w := famAsk(s, "fapp.example.hu", "GET", "/", "text/html")
|
||||
if w.Code != http.StatusFound || !strings.HasPrefix(w.Header().Get("Location"), "https://felhom.example.hu"+familyStartPath+"?rd=") {
|
||||
t.Fatalf("a browser must be sent to the family sign-in: %d %q", w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
for _, m := range []string{"GET", "POST"} {
|
||||
if w := famAsk(s, "fapp.example.hu", m, "/api/x", "application/json"); w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("%s API without a pass: %d", m, w.Code)
|
||||
}
|
||||
}
|
||||
if w := famAsk(s, "other.example.hu", "GET", "/", "text/html"); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("a host no family app owns must be refused: %d", w.Code)
|
||||
}
|
||||
forged := &http.Cookie{Name: familyAppCookie, Value: "abc." + "99999999999" + ".deadbeef"}
|
||||
if w := famAsk(s, "fapp.example.hu", "GET", "/", "application/json", forged); w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("a forged app cookie must be refused: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Exit item 2 + rule 1: a member's OWN login opens the app; the family session never opens the dashboard and the
|
||||
// family pages never set the dashboard cookie.
|
||||
// COMPANION RED-PROOF: make RequireAuth accept felhom_family → the dashboard assertion fails.
|
||||
func TestFamilyGate_MemberPassesButNeverTheDashboard(t *testing.T) {
|
||||
s, st := familyHarness(t)
|
||||
pw, _ := st.Add("anna")
|
||||
w := famLogin(s, "203.0.113.10", "anna", pw, "https://fapp.example.hu/books")
|
||||
sess := famCookie(w, familySessionCookie)
|
||||
if w.Code != http.StatusFound || sess == nil || sess.Path != familyCookiePath || sess.MaxAge < 7*24*3600 {
|
||||
t.Fatalf("sign-in: %d cookie %+v", w.Code, sess)
|
||||
}
|
||||
if famCookie(w, sessionCookieName) != nil {
|
||||
t.Fatal("the family sign-in must never set the dashboard cookie")
|
||||
}
|
||||
app := famPass(t, s, sess)
|
||||
if app.MaxAge < 7*24*3600 || app.Domain != "" {
|
||||
t.Fatalf("the app cookie must last days and be host-only: %+v", app)
|
||||
}
|
||||
if w := famAsk(s, "fapp.example.hu", "GET", "/books", "text/html", app); w.Code != http.StatusOK {
|
||||
t.Fatalf("the member's app cookie must pass: %d", w.Code)
|
||||
}
|
||||
// the same cookies at the dashboard: refused
|
||||
h := s.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(299) }))
|
||||
for _, p := range []string{"/launcher", "/api/stacks", "/settings/security"} {
|
||||
r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+p, nil)
|
||||
r.AddCookie(&http.Cookie{Name: familySessionCookie, Value: sess.Value})
|
||||
r.AddCookie(&http.Cookie{Name: familyAppCookie, Value: app.Value})
|
||||
rw := httptest.NewRecorder()
|
||||
h.ServeHTTP(rw, r)
|
||||
if rw.Code == 299 {
|
||||
t.Fatalf("a family cookie opened the dashboard at %s", p)
|
||||
}
|
||||
}
|
||||
// a cookie for another app host does not pass
|
||||
if w := famAsk(s, "fapp2.example.hu", "GET", "/", "application/json", app); w.Code == http.StatusOK {
|
||||
t.Fatal("an app cookie must not pass another host")
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 3 + exit item 2's logout: a reset, a removal and a logout each end access on the NEXT request.
|
||||
func TestFamilyGate_ResetRemoveLogoutEndAccessAtOnce(t *testing.T) {
|
||||
s, st := familyHarness(t)
|
||||
pw, _ := st.Add("anna")
|
||||
login := func() *http.Cookie {
|
||||
w := famLogin(s, "203.0.113.10", "anna", pw, "")
|
||||
return famCookie(w, familySessionCookie)
|
||||
}
|
||||
ok := func(app *http.Cookie) bool {
|
||||
return famAsk(s, "fapp.example.hu", "GET", "/", "application/json", app).Code == http.StatusOK
|
||||
}
|
||||
sess := login()
|
||||
app := famPass(t, s, sess)
|
||||
pw, _ = st.Reset("anna")
|
||||
if ok(app) {
|
||||
t.Fatal("a reset password must end the member's app access at once")
|
||||
}
|
||||
sess = login()
|
||||
app = famPass(t, s, sess)
|
||||
r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu"+familyLogoutPath, nil)
|
||||
r.AddCookie(sess)
|
||||
s.ServeFamilyLogout(httptest.NewRecorder(), r)
|
||||
if ok(app) {
|
||||
t.Fatal("logout must end the app access minted from that session")
|
||||
}
|
||||
sess = login()
|
||||
app = famPass(t, s, sess)
|
||||
st.Remove("anna")
|
||||
if ok(app) {
|
||||
t.Fatal("a removed member must lose access at once")
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 2: the sign-in is counted per VISITOR and per NAME — a stranger locks only himself, and a name under attack is
|
||||
// locked for minutes, never the household.
|
||||
func TestFamilyGate_LockPerVisitorAndPerName(t *testing.T) {
|
||||
s, st := familyHarness(t)
|
||||
now := time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC)
|
||||
s.gateClock = func() time.Time { return now }
|
||||
pa, _ := st.Add("anna")
|
||||
pb, _ := st.Add("bela")
|
||||
for i := 0; i < familyVisitorMax; i++ {
|
||||
famLogin(s, "198.51.100.66", "anna", "wrong", "")
|
||||
}
|
||||
if w := famLogin(s, "198.51.100.66", "anna", pa, ""); w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("the stranger's own address must be locked even with the right password: %d", w.Code)
|
||||
}
|
||||
if w := famLogin(s, "203.0.113.10", "anna", pa, ""); w.Code != http.StatusOK && w.Code != http.StatusFound {
|
||||
t.Fatalf("anna from her own address must get in at once: %d", w.Code)
|
||||
}
|
||||
// a name under a spread attack (many addresses)
|
||||
for i := 0; i < familyNameMax; i++ {
|
||||
famLogin(s, "198.51.100."+string(rune('a'+i)), "bela", "wrong", "")
|
||||
}
|
||||
if w := famLogin(s, "203.0.113.20", "bela", pb, ""); w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("a name under a spread attack must be locked: %d", w.Code)
|
||||
}
|
||||
if w := famLogin(s, "203.0.113.20", "anna", pa, ""); w.Code == http.StatusTooManyRequests {
|
||||
t.Fatal("another member must not be locked by bela's attack")
|
||||
}
|
||||
now = now.Add(familyNameWindow + time.Second)
|
||||
if w := famLogin(s, "203.0.113.20", "bela", pb, ""); w.Code == http.StatusTooManyRequests {
|
||||
t.Fatal("the name lock must pass after its window (minutes, not forever)")
|
||||
}
|
||||
}
|
||||
|
||||
// The household's dashboard session vouches (decision 46's rule) — as a household session in the family store.
|
||||
func TestFamilyGate_HouseholdPassesWithItsDashboardSession(t *testing.T) {
|
||||
s, _ := familyHarness(t)
|
||||
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: sessionCookieName, Value: newTestSession(s)})
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeFamilyStart(w, r)
|
||||
if w.Code != http.StatusFound || !strings.Contains(w.Header().Get("Location"), familyCallbackURI) {
|
||||
t.Fatalf("the household must pass: %d %q", w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
// no session at all: the sign-in page, never a token
|
||||
r2 := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+familyStartPath+"?"+url.Values{"rd": {rd}}.Encode(), nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
s.ServeFamilyStart(w2, r2)
|
||||
if w2.Code != http.StatusOK || strings.Contains(w2.Header().Get("Location"), "t=") || !strings.Contains(w2.Body.String(), `name="password"`) {
|
||||
t.Fatalf("no session → the sign-in page: %d", w2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A token is one-use, bound to its host, and refused once its session ended.
|
||||
func TestFamilyGate_TokenOneUseBoundToHost(t *testing.T) {
|
||||
s, st := familyHarness(t)
|
||||
st.Add("anna")
|
||||
sid, _ := st.NewSession("anna")
|
||||
tok := s.mintFamilyToken("fapp.example.hu", sid, "https://fapp.example.hu/")
|
||||
if _, _, err := s.takeFamilyToken(tok, "other.example.hu"); err == nil {
|
||||
t.Fatal("a token for another host must be refused")
|
||||
}
|
||||
if _, _, err := s.takeFamilyToken(tok, "fapp.example.hu"); err != nil {
|
||||
t.Fatalf("first use: %v", err)
|
||||
}
|
||||
if _, _, err := s.takeFamilyToken(tok, "fapp.example.hu"); err == nil {
|
||||
t.Fatal("a token must be one-use")
|
||||
}
|
||||
tok2 := s.mintFamilyToken("fapp.example.hu", sid, "https://fapp.example.hu/")
|
||||
st.EndSession(sid)
|
||||
if w := famAsk(s, "fapp.example.hu", "GET", familyCallbackURI+"?t="+tok2, "text/html"); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("a token whose session ended must be refused: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 4: the family gate leaves the setup gate as it was — the setup-gated app answers the setup gate's handler, the
|
||||
// family handler knows nothing of it.
|
||||
func TestFamilyGate_SetupGateUntouched(t *testing.T) {
|
||||
s, _ := familyHarness(t)
|
||||
if w := famAsk(s, "gapp.example.hu", "GET", "/", "text/html"); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("the family handler must not answer for a setup-gated app: %d", w.Code)
|
||||
}
|
||||
if w := gateAsk(s, "gapp.example.hu", "GET", "/", "text/html"); w.Code != http.StatusFound {
|
||||
t.Fatalf("the setup gate must still send a browser to its start: %d", w.Code)
|
||||
}
|
||||
if w := gateAsk(s, "fapp.example.hu", "GET", "/", "application/json"); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("the setup gate must not answer for a family app: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The family sign-in's messages follow the reader (it has no session, so the language cookie decides).
|
||||
func TestFamilyGate_MessagesFollowTheReader(t *testing.T) {
|
||||
s, st := familyHarness(t)
|
||||
st.Add("anna")
|
||||
for _, c := range []struct{ lang, want, not string }{{"en", "Wrong name or password.", "Hibás név"}, {"hu", "Hibás név vagy jelszó.", "Wrong name"}} {
|
||||
form := url.Values{"_ft": {s.familyFormToken()}, "name": {"anna"}, "password": {"x"}}
|
||||
r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu"+familyLoginPath, strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.RemoteAddr = "192.168.0.7:1"
|
||||
r.AddCookie(&http.Cookie{Name: langCookieName, Value: c.lang})
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeFamilyLogin(w, r)
|
||||
body := html.UnescapeString(w.Body.String())
|
||||
if !strings.Contains(body, c.want) || strings.Contains(body, c.not) {
|
||||
t.Errorf("%s: want %q not %q", c.lang, c.want, c.not)
|
||||
}
|
||||
}
|
||||
// an expired or forged form token is refused before any password check
|
||||
form := url.Values{"_ft": {"1.deadbeef"}, "name": {"anna"}, "password": {"x"}}
|
||||
r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu"+familyLoginPath, strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeFamilyLogin(w, r)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("a forged form token: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The dashboard card's acts: the password is in the answer ONCE (JSON), never in the page.
|
||||
func TestFamilyGate_CardActsAndNoPasswordInThePage(t *testing.T) {
|
||||
s, st := familyHarness(t)
|
||||
act := func(a, name string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(http.MethodPost, "/family/members/"+a, strings.NewReader(url.Values{"name": {name}}.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
s.familyMemberActionHandler(w, r, a)
|
||||
return w
|
||||
}
|
||||
w := act("add", "anna")
|
||||
m := regexp.MustCompile(`"password":"([a-z0-9-]{19})"`).FindStringSubmatch(w.Body.String())
|
||||
if w.Code != http.StatusOK || m == nil || !st.Verify("anna", m[1]) || w.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("add: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if w := act("add", "anna"); w.Code != http.StatusConflict {
|
||||
t.Fatalf("duplicate: %d", w.Code)
|
||||
}
|
||||
if w := act("add", "Not Valid"); w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bad name: %d", w.Code)
|
||||
}
|
||||
if w := act("remove", "anna"); w.Code != http.StatusOK || strings.Contains(w.Body.String(), "password") {
|
||||
t.Fatalf("remove: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if w := act("reset", "anna"); w.Code != http.StatusNotFound {
|
||||
t.Fatalf("reset of a removed member: %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,12 @@ func i18nCasesC() []i18nCase {
|
||||
{"setupgate", "setupgate", func() map[string]interface{} {
|
||||
return m{"AppName": "Immich", "Host": "photos.example.hu", "LoginURL": "/login?next=%2F__gate%2Fstart%3Frd%3Dhttps%253A%252F%252Fphotos.example.hu%252F"}
|
||||
}},
|
||||
{"familygate", "familygate", func() map[string]interface{} {
|
||||
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."}
|
||||
}},
|
||||
{"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"}
|
||||
}},
|
||||
|
||||
@@ -477,6 +477,7 @@ var i18nDirectTemplates = map[string]bool{
|
||||
"launcher_shared": true, "launcher_share_password": true, "catchall": true,
|
||||
"setupgate": true, // v0.280.0 (decision 46): the gate page a stranger meets
|
||||
"signupclosed": true, // v0.281.0 (decision 47): an app's sign-up address once closed
|
||||
"familygate": true, // v0.287.0 (decisions 63/64): the family sign-in page
|
||||
}
|
||||
|
||||
func i18nTestServer(t *testing.T) *Server {
|
||||
|
||||
@@ -315,6 +315,7 @@ var i18nDirectPages = []struct{ tmpl, caseName, enProbe string }{
|
||||
{"catchall", "catchall_app", "Manage app"},
|
||||
{"setupgate", "setupgate", "waiting for its first setup"},
|
||||
{"signupclosed", "signupclosed", "You cannot sign up on this app"},
|
||||
{"familygate", "familygate", "Open this app with your family name and password."},
|
||||
}
|
||||
|
||||
// TestI18nDirectRenderPagesFollowLanguage — with the household language saved as English the page is
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/family"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
@@ -48,11 +49,13 @@ type Server struct {
|
||||
version string
|
||||
encKey []byte // AES-256 key for decrypting app.yaml values
|
||||
// gate / gateClock (v0.280.0, decision 46): the setup gate's key + used tokens; the clock is a test seam.
|
||||
gate gateState
|
||||
gateClock func() time.Time
|
||||
tmpl *template.Template // the Hungarian set (i18n.Default) — every pre-i18n caller renders this
|
||||
tmplByLang map[string]*template.Template
|
||||
i18n *i18n.Bundle
|
||||
gate gateState
|
||||
fam familyState // v0.287.0: the family gate (family_gate.go)
|
||||
familyStoreOverride *family.Store // test seam: the family list without a data dir
|
||||
gateClock func() time.Time
|
||||
tmpl *template.Template // the Hungarian set (i18n.Default) — every pre-i18n caller renders this
|
||||
tmplByLang map[string]*template.Template
|
||||
i18n *i18n.Bundle
|
||||
|
||||
// versionPosition (v0.275.0) — where a just-restored app stands against the catalog; nil → the stack
|
||||
// manager's RestoredVersionPosition. A seam so the restore sentence is testable without a catalog.
|
||||
@@ -673,6 +676,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.sharingShareOffsiteHandler(w, r)
|
||||
case path == "/settings/notifications" && r.Method == http.MethodGet:
|
||||
s.settingsNotificationsPageHandler(w, r)
|
||||
// v0.287.0 (decisions 63/64): the „Család" card's member list and its three acts (session + CSRF like every POST).
|
||||
case path == "/family/members" && r.Method == http.MethodGet:
|
||||
s.familyMembersHandler(w, r)
|
||||
case strings.HasPrefix(path, "/family/members/") && r.Method == http.MethodPost:
|
||||
s.familyMemberActionHandler(w, r, strings.TrimPrefix(path, "/family/members/"))
|
||||
case path == "/settings/security" && r.Method == http.MethodGet:
|
||||
s.settingsSecurityPageHandler(w, r)
|
||||
case path == "/settings/password" && r.Method == http.MethodPost:
|
||||
@@ -862,6 +870,23 @@ func (s *Server) CatchAllMiddleware(next http.Handler) http.Handler {
|
||||
s.ServeGateAuth(w, r)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == familyAuthPath { // v0.287.0 (decisions 63/64): the family gate's forwardAuth, any host
|
||||
s.ServeFamilyGateAuth(w, r)
|
||||
return
|
||||
}
|
||||
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:
|
||||
s.ServeFamilyStart(w, r)
|
||||
return
|
||||
case familyLoginPath:
|
||||
s.ServeFamilyLogin(w, r)
|
||||
return
|
||||
case familyLogoutPath:
|
||||
s.ServeFamilyLogout(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
if r.URL.Path == signupClosedPath { // v0.281.0 (decision 47): an app's own sign-up address while closed
|
||||
s.ServeSignupClosed(w, r)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
{{define "familygate"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{T "layout.html_lang"}}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>{{if .AppName}}{{.AppName}} — {{end}}{{T "family_gate.page_title"}}</title>
|
||||
<link rel="stylesheet" href="/static/style.css?v={{.Version}}">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="login-card">
|
||||
<img src="/static/felhom-logo.svg?v={{.Version}}" alt="Felhom.eu" class="login-logo">
|
||||
{{- if .AppName}}
|
||||
<h1 class="login-title">{{.AppName}}</h1>
|
||||
{{- end}}
|
||||
{{- if .Notice}}
|
||||
<p class="alert alert-info" id="family-gate-notice">{{.Notice}}</p>
|
||||
{{- end}}
|
||||
{{- if .SignedIn}}
|
||||
<p id="family-gate-signed-in">{{T "family_gate.signed_in_note"}}</p>
|
||||
<form method="post" action="/__family/logout">
|
||||
<button type="submit" class="btn btn-outline btn-full">{{T "family_gate.sign_out"}}</button>
|
||||
</form>
|
||||
{{- else}}
|
||||
<p id="family-gate-text">{{T "family_gate.page_body"}}</p>
|
||||
<form method="post" action="/__family/login" class="login-form">
|
||||
<input type="hidden" name="_ft" value="{{.FormToken}}">
|
||||
<input type="hidden" name="rd" value="{{.RD}}">
|
||||
<div class="form-group">
|
||||
<label for="family-name">{{T "family_gate.name"}}</label>
|
||||
<input type="text" id="family-name" name="name" autocomplete="username" autocapitalize="none" required autofocus class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="family-password">{{T "family_gate.password"}}</label>
|
||||
<input type="password" id="family-password" name="password" autocomplete="current-password" required class="form-control">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-full">{{T "family_gate.sign_in"}}</button>
|
||||
</form>
|
||||
{{- end}}
|
||||
{{- if .Host}}
|
||||
<p class="login-footer">{{.Host}}</p>
|
||||
{{- end}}
|
||||
<div class="shell-lang">{{template "lang_globe" .}}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -403,6 +403,65 @@ function openDialog(opts){
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Section: Family (v0.287.0, decisions 63/64) — the family gate's members. Passwords come from the server ONCE,
|
||||
in the answer to the add/reset press; the page never contains one. -->
|
||||
<div class="settings-card" id="family-card">
|
||||
<h3>{{T "family_gate.card_title"}}</h3>
|
||||
<p class="settings-card-desc">{{T "family_gate.card_desc"}}</p>
|
||||
<div id="family-list" class="settings-grid"></div>
|
||||
<p id="family-none" class="form-hint" style="display:none">{{T "family_gate.none"}}</p>
|
||||
<div id="family-pw" class="alert alert-info" style="display:none">
|
||||
{{T "family_gate.pw_once"}} <strong id="family-pw-name"></strong> — <span id="family-pw-value" class="mono"></span>
|
||||
</div>
|
||||
<form id="family-add" class="inline-form" onsubmit="familyAct(event, 'add', document.getElementById('family-new').value)">
|
||||
<input type="text" id="family-new" class="form-control" placeholder="{{T "family_gate.name_placeholder"}}" autocapitalize="none" required>
|
||||
<button type="submit" class="btn btn-sm btn-primary">{{T "family_gate.add"}}</button>
|
||||
</form>
|
||||
<span id="family-err" class="form-hint" style="display:none;color:var(--red)"></span>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var T = {reset: '{{T "family_gate.reset"}}', remove: '{{T "family_gate.remove"}}',
|
||||
cReset: '{{T "family_gate.confirm_reset"}}', cRemove: '{{T "family_gate.confirm_remove"}}', err: '{{T "family_gate.error"}}'};
|
||||
function render(members) {
|
||||
var list = document.getElementById('family-list');
|
||||
list.textContent = '';
|
||||
document.getElementById('family-none').style.display = members.length ? 'none' : '';
|
||||
members.forEach(function (n) {
|
||||
var row = document.createElement('div'); row.className = 'settings-row'; row.setAttribute('data-family-member', n);
|
||||
var label = document.createElement('span'); label.className = 'settings-label mono'; label.textContent = n;
|
||||
var val = document.createElement('span'); val.className = 'settings-value';
|
||||
[['reset', T.reset, T.cReset, 'btn-outline'], ['remove', T.remove, T.cRemove, 'btn-danger']].forEach(function (a) {
|
||||
var b = document.createElement('button'); b.type = 'button'; b.className = 'btn btn-xs ' + a[3]; b.textContent = a[1];
|
||||
b.addEventListener('click', function () { felhomConfirm(b, a[2], function () { familyAct(null, a[0], n); }); });
|
||||
val.appendChild(b);
|
||||
});
|
||||
row.appendChild(label); row.appendChild(val); list.appendChild(row);
|
||||
});
|
||||
}
|
||||
window.familyAct = function (e, action, name) {
|
||||
if (e) e.preventDefault();
|
||||
var err = document.getElementById('family-err'); err.style.display = 'none';
|
||||
var body = new URLSearchParams(); body.set('name', name);
|
||||
fetch('/family/members/' + action, {method: 'POST', credentials: 'same-origin',
|
||||
headers: {'X-CSRF-Token': '{{.CSRFToken}}', 'Content-Type': 'application/x-www-form-urlencoded'}, body: body})
|
||||
.then(function (r) { return r.json(); }).then(function (j) {
|
||||
if (!j.ok) { err.textContent = j.error || T.err; err.style.display = 'inline'; return; }
|
||||
render(j.data.members || []);
|
||||
var box = document.getElementById('family-pw');
|
||||
if (j.data.password) {
|
||||
document.getElementById('family-pw-name').textContent = j.data.name;
|
||||
document.getElementById('family-pw-value').textContent = j.data.password;
|
||||
box.style.display = '';
|
||||
} else { box.style.display = 'none'; }
|
||||
if (action === 'add') document.getElementById('family-new').value = '';
|
||||
}).catch(function () { err.textContent = T.err; err.style.display = 'inline'; });
|
||||
};
|
||||
fetch('/family/members', {credentials: 'same-origin'}).then(function (r) { return r.json(); })
|
||||
.then(function (j) { if (j.ok) render(j.data.members || []); });
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Section: Recovery Info -->
|
||||
{{if .HasRetrievalPassword}}
|
||||
<div class="settings-card">
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Grimmory — Családi belépés</title>
|
||||
<link rel="stylesheet" href="/static/style.css?v=test">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="login-card">
|
||||
<img src="/static/felhom-logo.svg?v=test" alt="Felhom.eu" class="login-logo">
|
||||
<h1 class="login-title">Grimmory</h1>
|
||||
<p id="family-gate-text">Ezt az alkalmazást a családi neveddel és jelszavaddal nyithatod meg.</p>
|
||||
<form method="post" action="/__family/login" class="login-form">
|
||||
<input type="hidden" name="_ft" value="FT">
|
||||
<input type="hidden" name="rd" value="https://books.example.hu/">
|
||||
<div class="form-group">
|
||||
<label for="family-name">Neved</label>
|
||||
<input type="text" id="family-name" name="name" autocomplete="username" autocapitalize="none" required autofocus class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="family-password">Jelszavad</label>
|
||||
<input type="password" id="family-password" name="password" autocomplete="current-password" required class="form-control">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-full">Belépés</button>
|
||||
</form>
|
||||
<p class="login-footer">books.example.hu</p>
|
||||
<div class="shell-lang"><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Családi belépés</title>
|
||||
<link rel="stylesheet" href="/static/style.css?v=test">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="login-card">
|
||||
<img src="/static/felhom-logo.svg?v=test" alt="Felhom.eu" class="login-logo">
|
||||
<p class="alert alert-info" id="family-gate-notice">Beléptél. Nyisd meg újra az alkalmazást.</p>
|
||||
<p id="family-gate-signed-in">Be vagy lépve a családi fiókoddal.</p>
|
||||
<form method="post" action="/__family/logout">
|
||||
<button type="submit" class="btn btn-outline btn-full">Kilépés</button>
|
||||
</form>
|
||||
<div class="shell-lang"><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -562,6 +562,64 @@ function openDialog(opts){
|
||||
</script>
|
||||
|
||||
|
||||
<div class="settings-card" id="family-card">
|
||||
<h3>Család</h3>
|
||||
<p class="settings-card-desc">A családtagok a saját nevükkel és jelszavukkal lépnek be a családi kapus alkalmazásokba. Ezzel a vezérlőpultot nem érik el.</p>
|
||||
<div id="family-list" class="settings-grid"></div>
|
||||
<p id="family-none" class="form-hint" style="display:none">Még nincs családtag.</p>
|
||||
<div id="family-pw" class="alert alert-info" style="display:none">
|
||||
A jelszó csak most látszik. Írd fel, vagy add át a családtagnak: <strong id="family-pw-name"></strong> — <span id="family-pw-value" class="mono"></span>
|
||||
</div>
|
||||
<form id="family-add" class="inline-form" onsubmit="familyAct(event, 'add', document.getElementById('family-new').value)">
|
||||
<input type="text" id="family-new" class="form-control" placeholder="pl. anna" autocapitalize="none" required>
|
||||
<button type="submit" class="btn btn-sm btn-primary">Családtag hozzáadása</button>
|
||||
</form>
|
||||
<span id="family-err" class="form-hint" style="display:none;color:var(--red)"></span>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var T = {reset: 'Új jelszó', remove: 'Eltávolítás',
|
||||
cReset: 'Új jelszót adsz? A régi azonnal megszűnik.', cRemove: 'Eltávolítod? A belépései azonnal megszűnnek.', err: 'Nem sikerült. Próbáld újra.'};
|
||||
function render(members) {
|
||||
var list = document.getElementById('family-list');
|
||||
list.textContent = '';
|
||||
document.getElementById('family-none').style.display = members.length ? 'none' : '';
|
||||
members.forEach(function (n) {
|
||||
var row = document.createElement('div'); row.className = 'settings-row'; row.setAttribute('data-family-member', n);
|
||||
var label = document.createElement('span'); label.className = 'settings-label mono'; label.textContent = n;
|
||||
var val = document.createElement('span'); val.className = 'settings-value';
|
||||
[['reset', T.reset, T.cReset, 'btn-outline'], ['remove', T.remove, T.cRemove, 'btn-danger']].forEach(function (a) {
|
||||
var b = document.createElement('button'); b.type = 'button'; b.className = 'btn btn-xs ' + a[3]; b.textContent = a[1];
|
||||
b.addEventListener('click', function () { felhomConfirm(b, a[2], function () { familyAct(null, a[0], n); }); });
|
||||
val.appendChild(b);
|
||||
});
|
||||
row.appendChild(label); row.appendChild(val); list.appendChild(row);
|
||||
});
|
||||
}
|
||||
window.familyAct = function (e, action, name) {
|
||||
if (e) e.preventDefault();
|
||||
var err = document.getElementById('family-err'); err.style.display = 'none';
|
||||
var body = new URLSearchParams(); body.set('name', name);
|
||||
fetch('/family/members/' + action, {method: 'POST', credentials: 'same-origin',
|
||||
headers: {'X-CSRF-Token': 'tok', 'Content-Type': 'application/x-www-form-urlencoded'}, body: body})
|
||||
.then(function (r) { return r.json(); }).then(function (j) {
|
||||
if (!j.ok) { err.textContent = j.error || T.err; err.style.display = 'inline'; return; }
|
||||
render(j.data.members || []);
|
||||
var box = document.getElementById('family-pw');
|
||||
if (j.data.password) {
|
||||
document.getElementById('family-pw-name').textContent = j.data.name;
|
||||
document.getElementById('family-pw-value').textContent = j.data.password;
|
||||
box.style.display = '';
|
||||
} else { box.style.display = 'none'; }
|
||||
if (action === 'add') document.getElementById('family-new').value = '';
|
||||
}).catch(function () { err.textContent = T.err; err.style.display = 'inline'; });
|
||||
};
|
||||
fetch('/family/members', {credentials: 'same-origin'}).then(function (r) { return r.json(); })
|
||||
.then(function (j) { if (j.ok) render(j.data.members || []); });
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<div class="settings-card">
|
||||
<h3>Vészhelyzeti információk</h3>
|
||||
|
||||
@@ -493,6 +493,64 @@ function openDialog(opts){
|
||||
</script>
|
||||
|
||||
|
||||
<div class="settings-card" id="family-card">
|
||||
<h3>Család</h3>
|
||||
<p class="settings-card-desc">A családtagok a saját nevükkel és jelszavukkal lépnek be a családi kapus alkalmazásokba. Ezzel a vezérlőpultot nem érik el.</p>
|
||||
<div id="family-list" class="settings-grid"></div>
|
||||
<p id="family-none" class="form-hint" style="display:none">Még nincs családtag.</p>
|
||||
<div id="family-pw" class="alert alert-info" style="display:none">
|
||||
A jelszó csak most látszik. Írd fel, vagy add át a családtagnak: <strong id="family-pw-name"></strong> — <span id="family-pw-value" class="mono"></span>
|
||||
</div>
|
||||
<form id="family-add" class="inline-form" onsubmit="familyAct(event, 'add', document.getElementById('family-new').value)">
|
||||
<input type="text" id="family-new" class="form-control" placeholder="pl. anna" autocapitalize="none" required>
|
||||
<button type="submit" class="btn btn-sm btn-primary">Családtag hozzáadása</button>
|
||||
</form>
|
||||
<span id="family-err" class="form-hint" style="display:none;color:var(--red)"></span>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var T = {reset: 'Új jelszó', remove: 'Eltávolítás',
|
||||
cReset: 'Új jelszót adsz? A régi azonnal megszűnik.', cRemove: 'Eltávolítod? A belépései azonnal megszűnnek.', err: 'Nem sikerült. Próbáld újra.'};
|
||||
function render(members) {
|
||||
var list = document.getElementById('family-list');
|
||||
list.textContent = '';
|
||||
document.getElementById('family-none').style.display = members.length ? 'none' : '';
|
||||
members.forEach(function (n) {
|
||||
var row = document.createElement('div'); row.className = 'settings-row'; row.setAttribute('data-family-member', n);
|
||||
var label = document.createElement('span'); label.className = 'settings-label mono'; label.textContent = n;
|
||||
var val = document.createElement('span'); val.className = 'settings-value';
|
||||
[['reset', T.reset, T.cReset, 'btn-outline'], ['remove', T.remove, T.cRemove, 'btn-danger']].forEach(function (a) {
|
||||
var b = document.createElement('button'); b.type = 'button'; b.className = 'btn btn-xs ' + a[3]; b.textContent = a[1];
|
||||
b.addEventListener('click', function () { felhomConfirm(b, a[2], function () { familyAct(null, a[0], n); }); });
|
||||
val.appendChild(b);
|
||||
});
|
||||
row.appendChild(label); row.appendChild(val); list.appendChild(row);
|
||||
});
|
||||
}
|
||||
window.familyAct = function (e, action, name) {
|
||||
if (e) e.preventDefault();
|
||||
var err = document.getElementById('family-err'); err.style.display = 'none';
|
||||
var body = new URLSearchParams(); body.set('name', name);
|
||||
fetch('/family/members/' + action, {method: 'POST', credentials: 'same-origin',
|
||||
headers: {'X-CSRF-Token': 'tok', 'Content-Type': 'application/x-www-form-urlencoded'}, body: body})
|
||||
.then(function (r) { return r.json(); }).then(function (j) {
|
||||
if (!j.ok) { err.textContent = j.error || T.err; err.style.display = 'inline'; return; }
|
||||
render(j.data.members || []);
|
||||
var box = document.getElementById('family-pw');
|
||||
if (j.data.password) {
|
||||
document.getElementById('family-pw-name').textContent = j.data.name;
|
||||
document.getElementById('family-pw-value').textContent = j.data.password;
|
||||
box.style.display = '';
|
||||
} else { box.style.display = 'none'; }
|
||||
if (action === 'add') document.getElementById('family-new').value = '';
|
||||
}).catch(function () { err.textContent = T.err; err.style.display = 'inline'; });
|
||||
};
|
||||
fetch('/family/members', {credentials: 'same-origin'}).then(function (r) { return r.json(); })
|
||||
.then(function (j) { if (j.ok) render(j.data.members || []); });
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user