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
gates / gates (push) Successful in 1m33s
This commit is contained in:
1 parent
37b79caad9
commit
8c1e290f63
33 files changed
+1278
-93
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user