package main import ( "crypto/sha256" "encoding/hex" "encoding/json" "os" "path/filepath" "sync" "time" ) // Login sessions (the control page, the chat, the notepad) are kept in a file, so a server restart (every deploy used // to log everybody out) does not end them. Only a SHA-256 of each cookie token is stored, never the token itself, and // the file is readable by the server's user only. Any number of sessions can exist at once (several browsers, several // devices, several windows); logging in somewhere never ends another session. // uiSessionTTL is how long an idle control-page session lasts; every visit that finds it more than six hours old // renews it, so it only ends after this long without use. const uiSessionTTL = 14 * 24 * time.Hour type sessionStore struct { mu sync.Mutex path string // "" = memory only sets map[string]map[string]time.Time // kind -> sha256(token) -> expiry } // sessionSet is one kind of session ("ui", "chat", "np") inside the store. A nil set accepts nothing. type sessionSet struct { st *sessionStore kind string } func hashToken(tok string) string { sum := sha256.Sum256([]byte(tok)) return hex.EncodeToString(sum[:]) } func newSessionStore(path string) *sessionStore { st := &sessionStore{path: path, sets: map[string]map[string]time.Time{}} if path == "" { return st } if b, err := os.ReadFile(path); err == nil { var raw map[string]map[string]int64 if json.Unmarshal(b, &raw) == nil { now := time.Now() for kind, m := range raw { st.sets[kind] = map[string]time.Time{} for h, exp := range m { if t := time.Unix(exp, 0); t.After(now) { st.sets[kind][h] = t } } } } } return st } func (st *sessionStore) set(kind string) *sessionSet { st.mu.Lock() defer st.mu.Unlock() if st.sets[kind] == nil { st.sets[kind] = map[string]time.Time{} } return &sessionSet{st: st, kind: kind} } // flush writes the file (expired sessions are dropped). The caller holds st.mu. func (st *sessionStore) flush() { if st.path == "" { return } now := time.Now() raw := map[string]map[string]int64{} for kind, m := range st.sets { raw[kind] = map[string]int64{} for h, exp := range m { if exp.After(now) { raw[kind][h] = exp.Unix() } else { delete(m, h) } } } b, _ := json.Marshal(raw) tmp := filepath.Join(filepath.Dir(st.path), ".sessions.tmp") if err := os.WriteFile(tmp, b, 0600); err == nil { os.Rename(tmp, st.path) } } func (set *sessionSet) add(tok string, ttl time.Duration) { if set == nil { return } set.st.mu.Lock() defer set.st.mu.Unlock() set.st.sets[set.kind][hashToken(tok)] = time.Now().Add(ttl) set.st.flush() } func (set *sessionSet) valid(tok string) bool { if set == nil || tok == "" { return false } set.st.mu.Lock() defer set.st.mu.Unlock() exp, ok := set.st.sets[set.kind][hashToken(tok)] return ok && time.Now().Before(exp) } // renew gives a valid session a fresh lifetime when it has used up more than six hours of it, and says whether it did. // (Not on every call: that would rewrite the file on every page load.) func (set *sessionSet) renew(tok string, ttl time.Duration) bool { if set == nil || tok == "" { return false } set.st.mu.Lock() defer set.st.mu.Unlock() h := hashToken(tok) exp, ok := set.st.sets[set.kind][h] if !ok || !time.Now().Before(exp) || time.Until(exp) > ttl-6*time.Hour { return false } set.st.sets[set.kind][h] = time.Now().Add(ttl) set.st.flush() return true } func (set *sessionSet) remove(tok string) { if set == nil { return } set.st.mu.Lock() defer set.st.mu.Unlock() delete(set.st.sets[set.kind], hashToken(tok)) set.st.flush() }