Idle sessions sign themselves out; a KVM's login is remembered; the compose editor knows YAML

A session unused for seven days is dropped, the Sessions list marks a day's idleness and
can sign all of those out at once. The hub keeps a KVM's own cookies from any browser that
has them and lends them to one that hasn't, on the wire and back to the page. The stack
editor and the new-stack dialog use CodeEditor: line numbers, YAML and .env colour under a
real textarea, Tab/Shift+Tab/Enter indentation, and a key bar on touch screens.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-10-06 01:20:27 -07:00
co-authored by Claude Fable 5.1
parent faaa6aaf9d
commit f3ca3d8f73
13 changed files with 531 additions and 20 deletions
+42 -4
View File
@@ -28,6 +28,10 @@ const (
argonKeyLen = 32
SessionTTL = 30 * 24 * time.Hour
// SessionIdle is how long a session may go unused before it is dropped, whatever its
// expiry says. Every script, curl and headless browser that signs in leaves a session;
// a phone that is used keeps its own alive by being used.
SessionIdle = 7 * 24 * time.Hour
)
func HashPassword(pw string) (string, error) {
@@ -189,12 +193,26 @@ func NewSessions(st *store.Store) *Sessions {
func pruneExpired(state *store.State) {
now := time.Now()
for tok, ses := range state.Sessions {
if ses.Expires.Before(now) {
if !live(ses, now) {
delete(state.Sessions, tok)
}
}
}
// live is whether a session still counts: unexpired, and used within SessionIdle. A
// session from before visits were recorded is judged by its creation, or failing that by
// its expiry alone.
func live(ses store.Session, now time.Time) bool {
if !ses.Expires.After(now) {
return false
}
seen := ses.LastSeen
if seen.IsZero() {
seen = ses.Created
}
return seen.IsZero() || now.Sub(seen) <= SessionIdle
}
func randomHex(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
@@ -234,7 +252,7 @@ func (s *Sessions) Lookup(tok, ip string) (store.Session, bool) {
ok, touch := false, false
s.st.View(func(state *store.State) {
found, have := state.Sessions[tok]
if have && found.Expires.After(time.Now()) {
if have && live(found, time.Now()) {
ses, ok = found, true
touch = time.Until(found.Expires) < SessionTTL-time.Hour || time.Since(found.LastSeen) > time.Hour
}
@@ -264,7 +282,7 @@ func (s *Sessions) List(user string) []store.Session {
s.st.View(func(state *store.State) {
now := time.Now()
for _, ses := range state.Sessions {
if ses.User == user && ses.Expires.After(now) {
if ses.User == user && live(ses, now) {
out = append(out, ses)
}
}
@@ -279,7 +297,7 @@ func (s *Sessions) Count() map[string]int {
s.st.View(func(state *store.State) {
now := time.Now()
for _, ses := range state.Sessions {
if ses.Expires.After(now) {
if live(ses, now) {
n[ses.User]++
}
}
@@ -315,6 +333,26 @@ func (s *Sessions) RevokeOthers(user, keep string) int {
return n
}
// RevokeIdle ends every session of user not seen for longer than idle, except the one
// holding keep. Returns how many.
func (s *Sessions) RevokeIdle(user, keep string, idle time.Duration) int {
n := 0
now := time.Now()
_ = s.st.Update(func(state *store.State) {
for tok, ses := range state.Sessions {
seen := ses.LastSeen
if seen.IsZero() {
seen = ses.Created
}
if ses.User == user && tok != keep && now.Sub(seen) > idle {
delete(state.Sessions, tok)
n++
}
}
})
return n
}
// RevokeUser ends every session of user (the account is going away).
func (s *Sessions) RevokeUser(user string) { s.RevokeOthers(user, "") }