W12 family names keep their accents, login made without them; W9 banner link colour; W10 recovery-code page one plain line; W11/W24 remote page hides provider login and key fields behind Haladó; unreleased
gates / gates (push) Successful in 1m33s

This commit is contained in:
admin committed 2026-10-21 22:35:24 +02:00
1 parent 37b79caad9
commit 8c1e290f63
33 files changed
+1278 -93

No files matched your search

+139 -1
View File
@@ -28,8 +28,12 @@ import (
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
"golang.org/x/crypto/bcrypt"
)
@@ -39,7 +43,8 @@ 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"`
Name string `json:"name"` // the LOGIN: 1-32 of [a-z0-9._-] (nameRE); the key everywhere
Display string `json:"display,omitempty"` // the name as the household typed it (W12); "" for a member made before
Hash string `json:"hash"`
Gen int `json:"gen"`
Created string `json:"created"`
@@ -231,6 +236,139 @@ func (s *Store) Add(name string) (string, error) {
return pw, nil
}
// MaxDisplayRunes is the longest name the household may type for a family member (W12).
const MaxDisplayRunes = 64
// Person is one member as the dashboard's card shows them: the name the household typed and the login made from it.
type Person struct {
Login string `json:"login"`
Display string `json:"display"`
}
// AddPerson makes a member from the name the household typed (W12, 2026-10-21): the display name is kept exactly as
// typed; the LOGIN is made from it by LoginFrom (accents removed, lower case) and must still pass nameRE — the login's
// own check is unchanged. When the login is taken by someone with another name, the next free numbered login is used
// („Ödön" → odon, then „Odon" → odon2). The same name again (letter case aside) is ErrExists, never a second login.
// Returns the login and the generated password (once). Pinned by TestW12_AccentedNamesGetAPlainLogin.
func (s *Store) AddPerson(display string) (login, pw string, err error) {
display = strings.TrimSpace(display)
base := LoginFrom(display)
if display == "" || utf8.RuneCountInString(display) > MaxDisplayRunes || !nameRE.MatchString(base) {
return "", "", ErrBadName
}
for _, r := range display {
if unicode.IsControl(r) {
return "", "", ErrBadName
}
}
pw = newPassword()
h, err := bcrypt.GenerateFromPassword([]byte(pw), s.cost)
if err != nil {
return "", "", err
}
s.mu.Lock()
defer s.mu.Unlock()
for _, m := range s.members {
if strings.EqualFold(m.displayName(), display) {
return "", "", ErrExists
}
}
login = base
for n := 2; s.members[login] != nil; n++ {
suffix := strconv.Itoa(n)
stem := base
if len(stem)+len(suffix) > 32 {
stem = stem[:32-len(suffix)]
}
login = stem + suffix
}
if !nameRE.MatchString(login) {
return "", "", ErrBadName
}
stored := display
if stored == login {
stored = "" // nothing to remember: the login IS the name
}
s.members[login] = &Member{Name: login, Display: stored, Hash: string(h), Gen: 1, Created: s.now().UTC().Format(time.RFC3339)}
if err := s.saveLocked(); err != nil {
delete(s.members, login)
return "", "", err
}
return login, pw, nil
}
func (m *Member) displayName() string {
if m.Display != "" {
return m.Display
}
return m.Name
}
// foldMap removes the accents of the letters a Hungarian (and a European) household types; sharp s becomes "ss".
// The letters are written as escapes: several of them are the mojibake gate's cp1250 signatures.
var foldMap = func() map[rune]string {
out := map[rune]string{'\u00df': "ss", '\u00e6': "ae", '\u0153': "oe", '\u00f8': "o", '\u0142': "l", '\u0111': "d", '\u00f0': "d", '\u00fe': "th"}
for base, letters := range map[string]string{
"a": "\u00e1\u00e0\u00e2\u00e4\u00e3\u00e5\u0101\u0103\u0105", "c": "\u00e7\u0107\u010d\u0109\u010b", "d": "\u010f", "e": "\u00e9\u00e8\u00ea\u00eb\u0113\u0117\u0119\u011b\u0115", "g": "\u011f\u011d\u0121\u0123", "h": "\u0125\u0127", "i": "\u00ed\u00ec\u00ee\u00ef\u012b\u012f\u0129\u0131",
"j": "\u0135", "k": "\u0137", "l": "\u013a\u013c\u013e\u0140", "n": "\u00f1\u0144\u0148\u0146", "o": "\u00f3\u00f2\u00f4\u00f6\u00f5\u0151\u014d\u014f", "r": "\u0155\u0159\u0157", "s": "\u015b\u0161\u015f\u015d\u0219", "t": "\u0165\u0163\u021b",
"u": "\u00fa\u00f9\u00fb\u00fc\u0171\u016b\u016f\u0173\u0169\u016d", "y": "\u00fd\u00ff\u0177", "z": "\u017a\u017c\u017e",
} {
for _, r := range letters {
out[r] = base
}
}
return out
}()
// LoginFrom makes a login from a typed name (W12): lower case, accents removed („Ödön" → odon, „Írisz" → irisz),
// a run of spaces becomes one dot („Kis Anna" → kis.anna), and every other sign outside a-z 0-9 . _ - is left out.
// Leading signs are dropped, and the result is cut to 32 characters. The caller still checks it with nameRE.
func LoginFrom(display string) string {
var b strings.Builder
space := false
for _, r := range strings.ToLower(strings.TrimSpace(display)) {
if unicode.IsSpace(r) {
space = true
continue
}
var part string
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
part = string(r)
default:
part = foldMap[r]
}
if part == "" {
continue
}
if space && b.Len() > 0 {
b.WriteByte('.')
}
space = false
b.WriteString(part)
}
out := strings.TrimLeft(b.String(), "._-")
if len(out) > 32 {
out = out[:32]
}
return out
}
// People lists the members with their typed names, sorted by login.
func (s *Store) People() []Person {
if s == nil {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Person, 0, len(s.members))
for _, m := range s.members {
out = append(out, Person{Login: m.Name, Display: m.displayName()})
}
sort.Slice(out, func(i, j int) bool { return out[i].Login < out[j].Login })
return out
}
// 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()
+76
View File
@@ -0,0 +1,76 @@
package family
import (
"strings"
"testing"
)
// W12 (rehearsal 2026-10-20): a Hungarian family's own names („Ödön", „Írisz") were refused. The name is now kept as
// typed and the LOGIN is made from it — accents removed, lower case — and still passes the login's own check.
func TestW12_AccentedNamesGetAPlainLogin(t *testing.T) {
for in, want := range map[string]string{
"Ödön": "odon", "Írisz": "irisz", "ŐRSÉG": "orseg", "Kis Anna": "kis.anna", "Anna-Mária": "anna-maria",
"anna": "anna", "odon2": "odon2", "O'Brien": "obrien", " Ünő ": "uno", "Анна": "", "!!!": "", ".x": "x",
} {
if got := LoginFrom(in); got != want {
t.Errorf("LoginFrom(%q) = %q, want %q", in, got, want)
}
}
if got := LoginFrom(strings.Repeat("é", 40)); got != strings.Repeat("e", 32) {
t.Errorf("a long name is not cut to 32: %q", got)
}
s, dir := testStore(t)
login, pw, err := s.AddPerson("Ödön")
if err != nil || login != "odon" || !s.Verify("odon", pw) {
t.Fatalf("Ödön: %q %v", login, err)
}
if login, _, err := s.AddPerson("Írisz"); err != nil || login != "irisz" {
t.Fatalf("Írisz: %q %v", login, err)
}
// a second person whose name makes the same login gets the next number
login2, pw2, err := s.AddPerson("Odon")
if err != nil || login2 != "odon2" || !s.Verify("odon2", pw2) || s.Verify("odon", pw2) {
t.Fatalf("the second odon: %q %v", login2, err)
}
// the same name again is the same person: refused, never a third login
if _, _, err := s.AddPerson("ödön"); err != ErrExists {
t.Fatalf("the same name twice: %v", err)
}
for _, bad := range []string{"", " ", "Анна", "!!!", strings.Repeat("a", MaxDisplayRunes+1), "an\x00na"} {
if _, _, err := s.AddPerson(bad); err != ErrBadName {
t.Fatalf("name %q accepted: %v", bad, err)
}
}
// the display name is kept exactly as typed, and survives a reopen
s2, err := Open(dir)
if err != nil {
t.Fatal(err)
}
got := map[string]string{}
for _, p := range s2.People() {
got[p.Login] = p.Display
}
if got["odon"] != "Ödön" || got["irisz"] != "Írisz" || got["odon2"] != "Odon" || len(got) != 3 {
t.Fatalf("people: %v", got)
}
// the login's own check is unchanged
for _, bad := range []string{"Ödön", "a b", "Anna"} {
if _, err := s.Add(bad); err != ErrBadName {
t.Fatalf("Add(%q) accepted a login outside the safe set", bad)
}
}
}
// A long name whose login is taken: the numbered login still fits 32 characters.
func TestW12_NumberedLoginStaysInsideTheLimit(t *testing.T) {
s, _ := testStore(t)
long := strings.Repeat("a", 32)
if l, _, err := s.AddPerson(long); err != nil || l != long {
t.Fatalf("%q %v", l, err)
}
l, _, err := s.AddPerson(strings.Repeat("á", 32))
if err != nil || l != strings.Repeat("a", 31)+"2" {
t.Fatalf("%q %v", l, err)
}
}