diff --git a/.gitignore b/.gitignore index 593c721..10fc9fc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /build +/vendor host_key host_key.pub ssh-chat diff --git a/chat/command.go b/chat/command.go index 49c8b7f..a8d7cfe 100644 --- a/chat/command.go +++ b/chat/command.go @@ -6,6 +6,7 @@ import ( "bytes" "errors" "fmt" + "io" "strings" "github.com/shazow/ssh-chat/chat/message" @@ -25,6 +26,10 @@ var ErrMissingArg = errors.New("missing argument") // The error returned when a command is added without a prefix. var ErrMissingPrefix = errors.New("command missing prefix") +// The error returned when we fail to find a corresponding userMember struct +// for an ID. This should not happen, probably a bug somewhere if encountered. +var ErrMissingMember = errors.New("failed to find member") + // Command is a definition of a handler for a command. type Command struct { // The command's key, such as /foo @@ -132,8 +137,7 @@ func InitCommands(c *Commands) { Prefix: "/exit", Help: "Exit the chat.", Handler: func(room *Room, msg message.CommandMsg) error { - msg.From().Close() - return nil + return msg.From().(io.Closer).Close() }, }) c.Alias("/exit", "/quit") @@ -147,18 +151,16 @@ func InitCommands(c *Commands) { if len(args) != 1 { return ErrMissingArg } - u := msg.From() - - member, ok := room.MemberByID(u.ID()) + member, ok := room.MemberByID(msg.From().ID()) if !ok { - return errors.New("failed to find member") + return ErrMissingMember } oldID := member.ID() - member.SetID(SanitizeName(args[0])) + member.SetName(args[0]) err := room.Rename(oldID, member) if err != nil { - member.SetID(oldID) + member.SetName(oldID) return err } return nil @@ -183,7 +185,7 @@ func InitCommands(c *Commands) { PrefixHelp: "[colors|...]", Help: "Set your color theme.", Handler: func(room *Room, msg message.CommandMsg) error { - user := msg.From() + user := msg.From().(Member) args := msg.Args() cfg := user.Config() if len(args) == 0 { @@ -222,7 +224,7 @@ func InitCommands(c *Commands) { Prefix: "/quiet", Help: "Silence room announcements.", Handler: func(room *Room, msg message.CommandMsg) error { - u := msg.From() + u := msg.From().(Member) cfg := u.Config() cfg.Quiet = !cfg.Quiet u.SetConfig(cfg) @@ -260,11 +262,16 @@ func InitCommands(c *Commands) { PrefixHelp: "[USER]", Help: "Hide messages from USER, /unignore USER to stop hiding.", Handler: func(room *Room, msg message.CommandMsg) error { + from, ok := room.Member(msg.From()) + if !ok { + return ErrMissingMember + } + id := strings.TrimSpace(strings.TrimLeft(msg.Body(), "/ignore")) if id == "" { // Print ignored names, if any. var names []string - msg.From().Ignored.Each(func(_ string, item set.Item) error { + from.Ignored.Each(func(_ string, item set.Item) error { names = append(names, item.Key()) return nil }) @@ -288,7 +295,7 @@ func InitCommands(c *Commands) { return fmt.Errorf("user not found: %s", id) } - err := msg.From().Ignored.Add(set.Itemize(id, target)) + err := from.Ignored.Add(set.Itemize(id, target)) if err == set.ErrCollision { return fmt.Errorf("user already ignored: %s", id) } else if err != nil { @@ -304,12 +311,16 @@ func InitCommands(c *Commands) { Prefix: "/unignore", PrefixHelp: "USER", Handler: func(room *Room, msg message.CommandMsg) error { + from, ok := room.Member(msg.From()) + if !ok { + return ErrMissingMember + } id := strings.TrimSpace(strings.TrimLeft(msg.Body(), "/unignore")) if id == "" { return errors.New("must specify user") } - if err := msg.From().Ignored.Remove(id); err != nil { + if err := from.Ignored.Remove(id); err != nil { return err } diff --git a/chat/member.go b/chat/member.go new file mode 100644 index 0000000..1c27ee6 --- /dev/null +++ b/chat/member.go @@ -0,0 +1,23 @@ +package chat + +import ( + "github.com/shazow/ssh-chat/chat/message" + "github.com/shazow/ssh-chat/set" +) + +// Member is a User with per-Room metadata attached to it. +type roomMember struct { + Member + Ignored *set.Set +} + +type Member interface { + message.Author + + Config() message.UserConfig + SetConfig(message.UserConfig) + + Send(message.Message) error + + SetName(string) +} diff --git a/chat/message/config.go b/chat/message/config.go new file mode 100644 index 0000000..9cb8af2 --- /dev/null +++ b/chat/message/config.go @@ -0,0 +1,24 @@ +package message + +import "regexp" + +// Container for per-user configurations. +type UserConfig struct { + Highlight *regexp.Regexp + Bell bool + Quiet bool + Theme *Theme + Seed int +} + +// Default user configuration to use +var DefaultUserConfig UserConfig + +func init() { + DefaultUserConfig = UserConfig{ + Bell: true, + Quiet: false, + } + + // TODO: Seed random? +} diff --git a/chat/message/identity.go b/chat/message/identity.go deleted file mode 100644 index 8edef77..0000000 --- a/chat/message/identity.go +++ /dev/null @@ -1,26 +0,0 @@ -package message - -// Identifier is an interface that can uniquely identify itself. -type Identifier interface { - ID() string - SetID(string) - Name() string -} - -// SimpleID is a simple Identifier implementation used for testing. -type SimpleID string - -// ID returns the ID as a string. -func (i SimpleID) ID() string { - return string(i) -} - -// SetID is a no-op -func (i SimpleID) SetID(s string) { - // no-op -} - -// Name returns the ID -func (i SimpleID) Name() string { - return i.ID() -} diff --git a/chat/message/message.go b/chat/message/message.go index 5b4f76a..e7cac39 100644 --- a/chat/message/message.go +++ b/chat/message/message.go @@ -6,6 +6,12 @@ import ( "time" ) +type Author interface { + ID() string + Name() string + Color() int +} + // Message is an interface for messages. type Message interface { Render(*Theme) string @@ -16,15 +22,15 @@ type Message interface { type MessageTo interface { Message - To() *User + To() Author } type MessageFrom interface { Message - From() *User + From() Author } -func ParseInput(body string, from *User) Message { +func ParseInput(body string, from Author) Message { m := NewPublicMsg(body, from) cmd, isCmd := m.ParseCommand() if isCmd { @@ -69,10 +75,10 @@ func (m Msg) Timestamp() time.Time { // PublicMsg is any message from a user sent to the room. type PublicMsg struct { Msg - from *User + from Author } -func NewPublicMsg(body string, from *User) PublicMsg { +func NewPublicMsg(body string, from Author) PublicMsg { return PublicMsg{ Msg: Msg{ body: body, @@ -82,7 +88,7 @@ func NewPublicMsg(body string, from *User) PublicMsg { } } -func (m PublicMsg) From() *User { +func (m PublicMsg) From() Author { return m.from } @@ -137,10 +143,10 @@ func (m PublicMsg) String() string { // sender to see the emote. type EmoteMsg struct { Msg - from *User + from Author } -func NewEmoteMsg(body string, from *User) *EmoteMsg { +func NewEmoteMsg(body string, from Author) *EmoteMsg { return &EmoteMsg{ Msg: Msg{ body: body, @@ -161,17 +167,17 @@ func (m EmoteMsg) String() string { // PrivateMsg is a message sent to another user, not shown to anyone else. type PrivateMsg struct { PublicMsg - to *User + to Author } -func NewPrivateMsg(body string, from *User, to *User) PrivateMsg { +func NewPrivateMsg(body string, from Author, to Author) PrivateMsg { return PrivateMsg{ PublicMsg: NewPublicMsg(body, from), to: to, } } -func (m PrivateMsg) To() *User { +func (m PrivateMsg) To() Author { return m.to } @@ -191,10 +197,10 @@ func (m PrivateMsg) String() string { // to anyone else. Usually in response to something, like /help. type SystemMsg struct { Msg - to *User + to Author } -func NewSystemMsg(body string, to *User) *SystemMsg { +func NewSystemMsg(body string, to Author) *SystemMsg { return &SystemMsg{ Msg: Msg{ body: body, @@ -215,7 +221,7 @@ func (m *SystemMsg) String() string { return fmt.Sprintf("-> %s", m.body) } -func (m *SystemMsg) To() *User { +func (m *SystemMsg) To() Author { return m.to } diff --git a/chat/message/message_test.go b/chat/message/message_test.go index d7ef285..cd0e3a3 100644 --- a/chat/message/message_test.go +++ b/chat/message/message_test.go @@ -11,7 +11,7 @@ func TestMessage(t *testing.T) { t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) } - u := NewUser(SimpleID("foo")) + u := NewUser("foo") expected = "foo: hello" actual = NewPublicMsg("hello", u).String() if actual != expected { diff --git a/chat/sanitize.go b/chat/message/sanitize.go similarity index 59% rename from chat/sanitize.go rename to chat/message/sanitize.go index b567825..fc13873 100644 --- a/chat/sanitize.go +++ b/chat/message/sanitize.go @@ -1,8 +1,9 @@ -package chat +package message import "regexp" var reStripName = regexp.MustCompile("[^\\w.-]") + const maxLength = 16 // SanitizeName returns a name with only allowed characters and a reasonable length @@ -15,10 +16,3 @@ func SanitizeName(s string) string { s = s[:nameLength] return s } - -var reStripData = regexp.MustCompile("[^[:ascii:]]") - -// SanitizeData returns a string with only allowed characters for client-provided metadata inputs. -func SanitizeData(s string) string { - return reStripData.ReplaceAllString(s, "") -} diff --git a/chat/message/screen.go b/chat/message/screen.go new file mode 100644 index 0000000..d073469 --- /dev/null +++ b/chat/message/screen.go @@ -0,0 +1,208 @@ +package message + +import ( + "errors" + "fmt" + "io" + "math/rand" + "regexp" + "sync" + "time" +) + +var ErrUserClosed = errors.New("user closed") + +const messageBuffer = 5 +const messageTimeout = 5 * time.Second + +func BufferedScreen(name string, screen io.WriteCloser) *bufferedScreen { + return &bufferedScreen{ + pipedScreen: PipedScreen(name, screen), + msg: make(chan Message, messageBuffer), + done: make(chan struct{}), + } +} + +func PipedScreen(name string, screen io.WriteCloser) *pipedScreen { + return &pipedScreen{ + baseScreen: Screen(name), + WriteCloser: screen, + } +} + +func HandledScreen(name string, handler func(Message) error) *handledScreen { + return &handledScreen{ + baseScreen: Screen(name), + handler: handler, + } +} + +func Screen(name string) *baseScreen { + return &baseScreen{ + user: NewUser(name), + } +} + +type handledScreen struct { + *baseScreen + handler func(Message) error +} + +func (u *handledScreen) Send(m Message) error { + return u.handler(m) +} + +// Screen that pipes messages to an io.WriteCloser +type pipedScreen struct { + *baseScreen + io.WriteCloser +} + +func (u *pipedScreen) Send(m Message) error { + r := u.render(m) + _, err := u.Write([]byte(r)) + if err != nil { + logger.Printf("Write failed to %s, closing: %s", u.Name(), err) + u.Close() + } + return err +} + +// User container that knows about writing to an IO screen. +type baseScreen struct { + sync.Mutex + *user +} + +func (u *baseScreen) Config() UserConfig { + u.Lock() + defer u.Unlock() + return u.config +} + +func (u *baseScreen) SetConfig(cfg UserConfig) { + u.Lock() + u.config = cfg + u.Unlock() +} + +func (u *baseScreen) ID() string { + u.Lock() + defer u.Unlock() + return SanitizeName(u.name) +} + +func (u *baseScreen) Name() string { + u.Lock() + defer u.Unlock() + return u.name +} + +func (u *baseScreen) Joined() time.Time { + return u.joined +} + +// Rename the user with a new Identifier. +func (u *baseScreen) SetName(name string) { + u.Lock() + u.name = name + u.config.Seed = rand.Int() + u.Unlock() +} + +// ReplyTo returns the last user that messaged this user. +func (u *baseScreen) ReplyTo() Author { + u.Lock() + defer u.Unlock() + return u.replyTo +} + +// SetReplyTo sets the last user to message this user. +func (u *baseScreen) SetReplyTo(user Author) { + // TODO: Use UserConfig.ReplyTo string + u.Lock() + defer u.Unlock() + u.replyTo = user +} + +// SetHighlight sets the highlighting regular expression to match string. +func (u *baseScreen) SetHighlight(s string) error { + re, err := regexp.Compile(fmt.Sprintf(reHighlight, s)) + if err != nil { + return err + } + u.Lock() + u.config.Highlight = re + u.Unlock() + return nil +} + +func (u *baseScreen) render(m Message) string { + cfg := u.Config() + switch m := m.(type) { + case PublicMsg: + return m.RenderFor(cfg) + Newline + case PrivateMsg: + u.SetReplyTo(m.From()) + return m.Render(cfg.Theme) + Newline + default: + return m.Render(cfg.Theme) + Newline + } +} + +// Prompt renders a theme-colorized prompt string. +func (u *baseScreen) Prompt() string { + name := u.Name() + cfg := u.Config() + if cfg.Theme != nil { + name = cfg.Theme.ColorName(u) + } + return fmt.Sprintf("[%s] ", name) +} + +// bufferedScreen is a screen that buffers messages on Send using a channel and a consuming goroutine. +type bufferedScreen struct { + *pipedScreen + closeOnce sync.Once + msg chan Message + done chan struct{} +} + +func (u *bufferedScreen) Close() error { + u.closeOnce.Do(func() { + close(u.done) + }) + + return u.pipedScreen.Close() +} + +// Add message to consume by user +func (u *bufferedScreen) Send(m Message) error { + select { + case <-u.done: + return ErrUserClosed + case u.msg <- m: + case <-time.After(messageTimeout): + logger.Printf("Message buffer full, closing: %s", u.Name()) + u.Close() + return ErrUserClosed + } + return nil +} + +// Consume message buffer into the handler. Will block, should be called in a +// goroutine. +func (u *bufferedScreen) Consume() { + for { + select { + case <-u.done: + return + case m, ok := <-u.msg: + if !ok { + return + } + // Pass on to unbuffered screen. + u.pipedScreen.Send(m) + } + } +} diff --git a/chat/message/theme.go b/chat/message/theme.go index d2585b7..7ff8a72 100644 --- a/chat/message/theme.go +++ b/chat/message/theme.go @@ -127,12 +127,12 @@ func (t Theme) ID() string { } // Colorize name string given some index -func (t Theme) ColorName(u *User) string { +func (t Theme) ColorName(u Author) string { if t.names == nil { return u.Name() } - return t.names.Get(u.colorIdx).Format(u.Name()) + return t.names.Get(u.Color()).Format(u.Name()) } // Colorize the PM string diff --git a/chat/message/theme_test.go b/chat/message/theme_test.go index a62c20e..c16222f 100644 --- a/chat/message/theme_test.go +++ b/chat/message/theme_test.go @@ -51,8 +51,8 @@ func TestTheme(t *testing.T) { t.Errorf("Got: %q; Expected: %q", actual, expected) } - u := NewUser(SimpleID("foo")) - u.colorIdx = 4 + u := NewUser("foo") + u.config.Seed = 4 actual = colorTheme.ColorName(u) expected = "\033[38;05;5mfoo\033[0m" if actual != expected { diff --git a/chat/message/user.go b/chat/message/user.go index 0cd700c..30b2388 100644 --- a/chat/message/user.go +++ b/chat/message/user.go @@ -1,208 +1,44 @@ package message import ( - "errors" - "fmt" - "io" "math/rand" - "regexp" - "sync" "time" - - "github.com/shazow/ssh-chat/set" ) -const messageBuffer = 5 -const messageTimeout = 5 * time.Second const reHighlight = `\b(%s)\b` -var ErrUserClosed = errors.New("user closed") - // User definition, implemented set Item interface and io.Writer -type User struct { - Identifier - Ignored *set.Set - colorIdx int - joined time.Time - msg chan Message - done chan struct{} +type user struct { + joined time.Time - screen io.WriteCloser - closeOnce sync.Once - - mu sync.Mutex + name string config UserConfig - replyTo *User // Set when user gets a /msg, for replying. + replyTo Author // Set when user gets a /msg, for replying. } -func NewUser(identity Identifier) *User { - u := User{ - Identifier: identity, - config: DefaultUserConfig, - joined: time.Now(), - msg: make(chan Message, messageBuffer), - done: make(chan struct{}), - Ignored: set.New(), +func NewUser(name string) *user { + u := user{ + name: name, + config: DefaultUserConfig, + joined: time.Now(), } - u.setColorIdx(rand.Int()) + u.config.Seed = rand.Int() return &u } -func NewUserScreen(identity Identifier, screen io.WriteCloser) *User { - u := NewUser(identity) - u.screen = screen - - return u +func (u *user) Name() string { + return u.name } -func (u *User) Config() UserConfig { - u.mu.Lock() - defer u.mu.Unlock() - return u.config +func (u *user) Color() int { + return u.config.Seed } -func (u *User) SetConfig(cfg UserConfig) { - u.mu.Lock() - u.config = cfg - u.mu.Unlock() +func (u *user) ID() string { + return SanitizeName(u.name) } -// Rename the user with a new Identifier. -func (u *User) SetID(id string) { - u.Identifier.SetID(id) - u.setColorIdx(rand.Int()) -} - -// ReplyTo returns the last user that messaged this user. -func (u *User) ReplyTo() *User { - u.mu.Lock() - defer u.mu.Unlock() - return u.replyTo -} - -// SetReplyTo sets the last user to message this user. -func (u *User) SetReplyTo(user *User) { - u.mu.Lock() - defer u.mu.Unlock() - u.replyTo = user -} - -// setColorIdx will set the colorIdx to a specific value, primarily used for -// testing. -func (u *User) setColorIdx(idx int) { - u.colorIdx = idx -} - -// Disconnect user, stop accepting messages -func (u *User) Close() { - u.closeOnce.Do(func() { - if u.screen != nil { - u.screen.Close() - } - // close(u.msg) TODO: Close? - close(u.done) - }) -} - -// Consume message buffer into the handler. Will block, should be called in a -// goroutine. -func (u *User) Consume() { - for { - select { - case <-u.done: - return - case m, ok := <-u.msg: - if !ok { - return - } - u.HandleMsg(m) - } - } -} - -// Consume one message and stop, mostly for testing -func (u *User) ConsumeOne() Message { - return <-u.msg -} - -// Check if there are pending messages, used for testing -func (u *User) HasMessages() bool { - select { - case msg := <-u.msg: - u.msg <- msg - return true - default: - return false - } -} - -// SetHighlight sets the highlighting regular expression to match string. -func (u *User) SetHighlight(s string) error { - re, err := regexp.Compile(fmt.Sprintf(reHighlight, s)) - if err != nil { - return err - } - u.mu.Lock() - u.config.Highlight = re - u.mu.Unlock() - return nil -} - -func (u *User) render(m Message) string { - cfg := u.Config() - switch m := m.(type) { - case PublicMsg: - return m.RenderFor(cfg) + Newline - case PrivateMsg: - u.SetReplyTo(m.From()) - return m.Render(cfg.Theme) + Newline - default: - return m.Render(cfg.Theme) + Newline - } -} - -// HandleMsg will render the message to the screen, blocking. -func (u *User) HandleMsg(m Message) error { - r := u.render(m) - _, err := u.screen.Write([]byte(r)) - if err != nil { - logger.Printf("Write failed to %s, closing: %s", u.Name(), err) - u.Close() - } - return err -} - -// Add message to consume by user -func (u *User) Send(m Message) error { - select { - case <-u.done: - return ErrUserClosed - case u.msg <- m: - case <-time.After(messageTimeout): - logger.Printf("Message buffer full, closing: %s", u.Name()) - u.Close() - return ErrUserClosed - } - return nil -} - -// Container for per-user configurations. -type UserConfig struct { - Highlight *regexp.Regexp - Bell bool - Quiet bool - Theme *Theme -} - -// Default user configuration to use -var DefaultUserConfig UserConfig - -func init() { - DefaultUserConfig = UserConfig{ - Bell: true, - Quiet: false, - } - - // TODO: Seed random? +func (u *user) Joined() time.Time { + return u.joined } diff --git a/chat/message/user_test.go b/chat/message/user_test.go index f70e674..6e7d8b0 100644 --- a/chat/message/user_test.go +++ b/chat/message/user_test.go @@ -9,16 +9,19 @@ func TestMakeUser(t *testing.T) { var actual, expected []byte s := &MockScreen{} - u := NewUserScreen(SimpleID("foo"), s) + u := PipedScreen("foo", s) + m := NewAnnounceMsg("hello") defer u.Close() - u.Send(m) - u.HandleMsg(u.ConsumeOne()) + err := u.Send(m) + if err != nil { + t.Fatalf("failed to send: %s", err) + } s.Read(&actual) expected = []byte(m.String() + Newline) if !reflect.DeepEqual(actual, expected) { - t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) + t.Errorf("Got: %q; Expected: %q", actual, expected) } } diff --git a/chat/room.go b/chat/room.go index 7ce6de1..1b920d6 100644 --- a/chat/room.go +++ b/chat/room.go @@ -21,11 +21,6 @@ var ErrRoomClosed = errors.New("room closed") // as empty string. var ErrInvalidName = errors.New("invalid name") -// Member is a User with per-Room metadata attached to it. -type Member struct { - *message.User -} - // Room definition, also a Set of User Items type Room struct { topic string @@ -58,14 +53,10 @@ func (r *Room) SetCommands(commands Commands) { r.commands = commands } -// Close the room and all the users it contains. +// Close the room func (r *Room) Close() { r.closeOnce.Do(func() { r.closed = true - r.Members.Each(func(_ string, item set.Item) error { - item.Value().(*Member).Close() - return nil - }) r.Members.Clear() close(r.broadcast) }) @@ -87,20 +78,21 @@ func (r *Room) HandleMsg(m message.Message) { go r.HandleMsg(m) } case message.MessageTo: - user := m.To() + user := m.To().(Member) user.Send(m) default: fromMsg, skip := m.(message.MessageFrom) - var skipUser *message.User + var skipUser Member if skip { - skipUser = fromMsg.From() + skipUser = fromMsg.From().(Member) } r.history.Add(m) - r.Members.Each(func(_ string, item set.Item) (err error) { - user := item.Value().(*Member).User + r.Members.Each(func(k string, item set.Item) (err error) { + roomMember := item.Value().(*roomMember) + user := roomMember.Member - if fromMsg != nil && user.Ignored.In(fromMsg.From().ID()) { + if fromMsg != nil && fromMsg.From() != nil && roomMember.Ignored.In(fromMsg.From().ID()) { // Skip because ignored return } @@ -135,31 +127,34 @@ func (r *Room) Send(m message.Message) { } // History feeds the room's recent message history to the user's handler. -func (r *Room) History(u *message.User) { - for _, m := range r.history.Get(historyLen) { - u.Send(m) +func (r *Room) History(m Member) { + for _, msg := range r.history.Get(historyLen) { + m.Send(msg) } } // Join the room as a user, will announce. -func (r *Room) Join(u *message.User) (*Member, error) { +func (r *Room) Join(m Member) (*roomMember, error) { // TODO: Check if closed - if u.ID() == "" { + if m.ID() == "" { return nil, ErrInvalidName } - member := &Member{u} - err := r.Members.Add(set.Itemize(u.ID(), member)) + member := &roomMember{ + Member: m, + Ignored: set.New(), + } + err := r.Members.AddNew(set.Itemize(m.ID(), member)) if err != nil { return nil, err } - r.History(u) - s := fmt.Sprintf("%s joined. (Connected: %d)", u.Name(), r.Members.Len()) + r.History(m) + s := fmt.Sprintf("%s joined. (Connected: %d)", m.Name(), r.Members.Len()) r.Send(message.NewAnnounceMsg(s)) return member, nil } // Leave the room as a user, will announce. Mostly used during setup. -func (r *Room) Leave(u message.Identifier) error { +func (r *Room) Leave(u Member) error { err := r.Members.Remove(u.ID()) if err != nil { return err @@ -171,7 +166,7 @@ func (r *Room) Leave(u message.Identifier) error { } // Rename member with a new identity. This will not call rename on the member. -func (r *Room) Rename(oldID string, u message.Identifier) error { +func (r *Room) Rename(oldID string, u Member) error { if u.ID() == "" { return ErrInvalidName } @@ -187,28 +182,29 @@ func (r *Room) Rename(oldID string, u message.Identifier) error { // Member returns a corresponding Member object to a User if the Member is // present in this room. -func (r *Room) Member(u *message.User) (*Member, bool) { +func (r *Room) Member(u message.Author) (*roomMember, bool) { m, ok := r.MemberByID(u.ID()) if !ok { return nil, false } // Check that it's the same user - if m.User != u { + if m.Member != u { return nil, false } return m, true } -func (r *Room) MemberByID(id string) (*Member, bool) { +func (r *Room) MemberByID(id string) (*roomMember, bool) { m, err := r.Members.Get(id) if err != nil { return nil, false } - return m.Value().(*Member), true + rm, ok := m.Value().(*roomMember) + return rm, ok } // IsOp returns whether a user is an operator in this room. -func (r *Room) IsOp(u *message.User) bool { +func (r *Room) IsOp(u message.Author) bool { return r.Ops.In(u.ID()) } @@ -228,7 +224,7 @@ func (r *Room) NamesPrefix(prefix string) []string { items := r.Members.ListPrefix(prefix) names := make([]string, len(items)) for i, item := range items { - names[i] = item.Value().(*Member).User.Name() + names[i] = item.Value().(*roomMember).Name() } return names } diff --git a/chat/room_test.go b/chat/room_test.go index e30c170..2a0c877 100644 --- a/chat/room_test.go +++ b/chat/room_test.go @@ -1,32 +1,23 @@ package chat import ( - "errors" - "fmt" "reflect" "testing" - "time" "github.com/shazow/ssh-chat/chat/message" ) -// Used for testing -type MockScreen struct { - buffer []byte +type ChannelWriter struct { + Chan chan []byte } -func (s *MockScreen) Write(data []byte) (n int, err error) { - s.buffer = append(s.buffer, data...) +func (w *ChannelWriter) Write(data []byte) (n int, err error) { + w.Chan <- data return len(data), nil } -func (s *MockScreen) Read(p *[]byte) (n int, err error) { - *p = s.buffer - s.buffer = []byte{} - return len(*p), nil -} - -func (s *MockScreen) Close() error { +func (w *ChannelWriter) Close() error { + close(w.Chan) return nil } @@ -43,115 +34,6 @@ func TestRoomServe(t *testing.T) { } } -type ScreenedUser struct { - user *message.User - screen *MockScreen -} - -func TestIgnore(t *testing.T) { - var buffer []byte - - ch := NewRoom() - go ch.Serve() - defer ch.Close() - - // Create 3 users, join the room and clear their screen buffers - users := make([]ScreenedUser, 3) - for i := 0; i < 3; i++ { - screen := &MockScreen{} - user := message.NewUserScreen(message.SimpleID(fmt.Sprintf("user%d", i)), screen) - users[i] = ScreenedUser{ - user: user, - screen: screen, - } - - _, err := ch.Join(user) - if err != nil { - t.Fatal(err) - } - } - - for _, u := range users { - for i := 0; i < 3; i++ { - u.user.HandleMsg(u.user.ConsumeOne()) - u.screen.Read(&buffer) - } - } - - // Use some handy variable names for distinguish between roles - ignorer := users[0] - ignored := users[1] - other := users[2] - - // test ignoring unexisting user - if err := sendCommand("/ignore test", ignorer, ch, &buffer); err != nil { - t.Fatal(err) - } - expectOutput(t, buffer, "-> Err: user not found: test"+message.Newline) - - // test ignoring existing user - if err := sendCommand("/ignore "+ignored.user.Name(), ignorer, ch, &buffer); err != nil { - t.Fatal(err) - } - expectOutput(t, buffer, "-> Ignoring: "+ignored.user.Name()+message.Newline) - - // ignoring the same user twice returns an error message and doesn't add the user twice - if err := sendCommand("/ignore "+ignored.user.Name(), ignorer, ch, &buffer); err != nil { - t.Fatal(err) - } - expectOutput(t, buffer, "-> Err: user already ignored: user1"+message.Newline) - if ignoredList := ignorer.user.Ignored.ListPrefix(""); len(ignoredList) != 1 { - t.Fatalf("should have %d ignored users, has %d", 1, len(ignoredList)) - } - - // when a message is sent from the ignored user, it is delivered to non-ignoring users - ch.Send(message.NewPublicMsg("hello", ignored.user)) - other.user.HandleMsg(other.user.ConsumeOne()) - other.screen.Read(&buffer) - expectOutput(t, buffer, ignored.user.Name()+": hello"+message.Newline) - - // ensure ignorer doesn't have received any message - if ignorer.user.HasMessages() { - t.Fatal("should not have messages") - } - - // `/ignore` returns a list of ignored users - if err := sendCommand("/ignore", ignorer, ch, &buffer); err != nil { - t.Fatal(err) - } - expectOutput(t, buffer, "-> 1 ignored: "+ignored.user.Name()+message.Newline) - - // `/unignore [USER]` removes the user from ignored ones - if err := sendCommand("/unignore "+ignored.user.Name(), users[0], ch, &buffer); err != nil { - t.Fatal(err) - } - expectOutput(t, buffer, "-> No longer ignoring: user1"+message.Newline) - - if err := sendCommand("/ignore", users[0], ch, &buffer); err != nil { - t.Fatal(err) - } - expectOutput(t, buffer, "-> 0 users ignored."+message.Newline) - - if ignoredList := ignorer.user.Ignored.ListPrefix(""); len(ignoredList) != 0 { - t.Fatalf("should have %d ignored users, has %d", 0, len(ignoredList)) - } - - // after unignoring a user, its messages can be received again - ch.Send(message.NewPublicMsg("hello again!", ignored.user)) - - // give some time for the channel to get the message - time.Sleep(100) - - // ensure ignorer has received the message - if !ignorer.user.HasMessages() { - // FIXME: This is flaky :/ - t.Fatal("should have messages") - } - ignorer.user.HandleMsg(ignorer.user.ConsumeOne()) - ignorer.screen.Read(&buffer) - expectOutput(t, buffer, ignored.user.Name()+": hello again!"+message.Newline) -} - func expectOutput(t *testing.T, buffer []byte, expected string) { bytes := []byte(expected) if !reflect.DeepEqual(buffer, bytes) { @@ -159,24 +41,13 @@ func expectOutput(t *testing.T, buffer []byte, expected string) { } } -func sendCommand(cmd string, mock ScreenedUser, room *Room, buffer *[]byte) error { - msg, ok := message.NewPublicMsg(cmd, mock.user).ParseCommand() - if !ok { - return errors.New("cannot parse command message") - } - - room.Send(msg) - mock.user.HandleMsg(mock.user.ConsumeOne()) - mock.screen.Read(buffer) - - return nil -} - func TestRoomJoin(t *testing.T) { var expected, actual []byte - s := &MockScreen{} - u := message.NewUserScreen(message.SimpleID("foo"), s) + s := &ChannelWriter{ + Chan: make(chan []byte), + } + u := message.PipedScreen("foo", s) ch := NewRoom() go ch.Serve() @@ -187,32 +58,121 @@ func TestRoomJoin(t *testing.T) { t.Fatal(err) } - u.HandleMsg(u.ConsumeOne()) expected = []byte(" * foo joined. (Connected: 1)" + message.Newline) - s.Read(&actual) + actual = <-s.Chan if !reflect.DeepEqual(actual, expected) { t.Errorf("Got: %q; Expected: %q", actual, expected) } ch.Send(message.NewSystemMsg("hello", u)) - u.HandleMsg(u.ConsumeOne()) expected = []byte("-> hello" + message.Newline) - s.Read(&actual) + actual = <-s.Chan if !reflect.DeepEqual(actual, expected) { t.Errorf("Got: %q; Expected: %q", actual, expected) } ch.Send(message.ParseInput("/me says hello.", u)) - u.HandleMsg(u.ConsumeOne()) expected = []byte("** foo says hello." + message.Newline) - s.Read(&actual) + actual = <-s.Chan + if !reflect.DeepEqual(actual, expected) { + t.Errorf("Got: %q; Expected: %q", actual, expected) + } +} + +func TestIgnore(t *testing.T) { + ch := NewRoom() + go ch.Serve() + defer ch.Close() + + addUser := func(name string) (message.Author, <-chan []byte) { + s := &ChannelWriter{ + Chan: make(chan []byte, 3), + } + u := message.PipedScreen(name, s) + u.SetConfig(message.UserConfig{ + Quiet: true, + }) + ch.Join(u) + return u, s.Chan + } + + u_foo, m_foo := addUser("foo") + u_bar, m_bar := addUser("bar") + u_quux, m_quux := addUser("quux") + + var expected, actual []byte + + // foo ignores bar, quux hears both + ch.Send(message.ParseInput("/ignore bar", u_foo)) + expected = []byte("-> Ignoring: bar" + message.Newline) + actual = <-m_foo + if !reflect.DeepEqual(actual, expected) { + t.Errorf("Got: %q; Expected: %q", actual, expected) + } + + // bar and quux sends a message, quux hears bar, foo only hears quux + ch.Send(message.ParseInput("i am bar", u_bar)) + ch.Send(message.ParseInput("i am quux", u_quux)) + + expected = []byte("bar: i am bar" + message.Newline) + actual = <-m_quux + if !reflect.DeepEqual(actual, expected) { + t.Errorf("Got: %q; Expected: %q", actual, expected) + } + + expected = []byte("quux: i am quux" + message.Newline) + actual = <-m_bar + if !reflect.DeepEqual(actual, expected) { + t.Errorf("Got: %q; Expected: %q", actual, expected) + } + actual = <-m_foo + if !reflect.DeepEqual(actual, expected) { + t.Errorf("Got: %q; Expected: %q", actual, expected) + } + + // foo sends a message, both quux and bar hear it + ch.Send(message.ParseInput("i am foo", u_foo)) + expected = []byte("foo: i am foo" + message.Newline) + + actual = <-m_quux + if !reflect.DeepEqual(actual, expected) { + t.Errorf("Got: %q; Expected: %q", actual, expected) + } + actual = <-m_bar + if !reflect.DeepEqual(actual, expected) { + t.Errorf("Got: %q; Expected: %q", actual, expected) + } + + // Confirm foo's message queue is still empty + select { + case actual = <-m_foo: + t.Errorf("foo's message queue is not empty: %q", actual) + default: + // Pass. + } + + // Unignore and listen to bar again. + ch.Send(message.ParseInput("/unignore bar", u_foo)) + expected = []byte("-> No longer ignoring: bar" + message.Newline) + actual = <-m_foo + if !reflect.DeepEqual(actual, expected) { + t.Errorf("Got: %q; Expected: %q", actual, expected) + } + + ch.Send(message.ParseInput("i am bar again", u_bar)) + expected = []byte("bar: i am bar again" + message.Newline) + actual = <-m_foo if !reflect.DeepEqual(actual, expected) { t.Errorf("Got: %q; Expected: %q", actual, expected) } } func TestRoomDoesntBroadcastAnnounceMessagesWhenQuiet(t *testing.T) { - u := message.NewUser(message.SimpleID("foo")) + msgs := make(chan message.Message) + u := message.HandledScreen("foo", func(m message.Message) error { + msgs <- m + return nil + }) u.SetConfig(message.UserConfig{ Quiet: true, }) @@ -225,19 +185,12 @@ func TestRoomDoesntBroadcastAnnounceMessagesWhenQuiet(t *testing.T) { t.Fatal(err) } - // Drain the initial Join message - <-ch.broadcast - go func() { - /* - for { - msg := u.ConsumeChan() - if _, ok := msg.(*message.AnnounceMsg); ok { - t.Errorf("Got unexpected `%T`", msg) - } + for msg := range msgs { + if _, ok := msg.(*message.AnnounceMsg); ok { + t.Errorf("Got unexpected `%T`", msg) } - */ - // XXX: Fix this + } }() // Call with an AnnounceMsg and all the other types @@ -248,10 +201,16 @@ func TestRoomDoesntBroadcastAnnounceMessagesWhenQuiet(t *testing.T) { ch.HandleMsg(message.NewSystemMsg("hello", u)) ch.HandleMsg(message.NewPrivateMsg("hello", u, u)) ch.HandleMsg(message.NewPublicMsg("hello", u)) + // Try an ignored one again just in case + ch.HandleMsg(message.NewAnnounceMsg("Once more for fun")) } func TestRoomQuietToggleBroadcasts(t *testing.T) { - u := message.NewUser(message.SimpleID("foo")) + msgs := make(chan message.Message) + u := message.HandledScreen("foo", func(m message.Message) error { + msgs <- m + return nil + }) u.SetConfig(message.UserConfig{ Quiet: true, }) @@ -264,16 +223,13 @@ func TestRoomQuietToggleBroadcasts(t *testing.T) { t.Fatal(err) } - // Drain the initial Join message - <-ch.broadcast - u.SetConfig(message.UserConfig{ Quiet: false, }) expectedMsg := message.NewAnnounceMsg("Ignored") - ch.HandleMsg(expectedMsg) - msg := u.ConsumeOne() + go ch.HandleMsg(expectedMsg) + msg := <-msgs if _, ok := msg.(*message.AnnounceMsg); !ok { t.Errorf("Got: `%T`; Expected: `%T`", msg, expectedMsg) } @@ -282,9 +238,11 @@ func TestRoomQuietToggleBroadcasts(t *testing.T) { Quiet: true, }) - ch.HandleMsg(message.NewAnnounceMsg("Ignored")) - ch.HandleMsg(message.NewSystemMsg("hello", u)) - msg = u.ConsumeOne() + go func() { + ch.HandleMsg(message.NewAnnounceMsg("Ignored")) + ch.HandleMsg(message.NewSystemMsg("hello", u)) + }() + msg = <-msgs if _, ok := msg.(*message.AnnounceMsg); ok { t.Errorf("Got unexpected `%T`", msg) } @@ -293,8 +251,10 @@ func TestRoomQuietToggleBroadcasts(t *testing.T) { func TestQuietToggleDisplayState(t *testing.T) { var expected, actual []byte - s := &MockScreen{} - u := message.NewUserScreen(message.SimpleID("foo"), s) + s := &ChannelWriter{ + Chan: make(chan []byte), + } + u := message.PipedScreen("foo", s) ch := NewRoom() go ch.Serve() @@ -305,27 +265,24 @@ func TestQuietToggleDisplayState(t *testing.T) { t.Fatal(err) } - u.HandleMsg(u.ConsumeOne()) expected = []byte(" * foo joined. (Connected: 1)" + message.Newline) - s.Read(&actual) + actual = <-s.Chan if !reflect.DeepEqual(actual, expected) { t.Errorf("Got: %q; Expected: %q", actual, expected) } ch.Send(message.ParseInput("/quiet", u)) - u.HandleMsg(u.ConsumeOne()) expected = []byte("-> Quiet mode is toggled ON" + message.Newline) - s.Read(&actual) + actual = <-s.Chan if !reflect.DeepEqual(actual, expected) { t.Errorf("Got: %q; Expected: %q", actual, expected) } ch.Send(message.ParseInput("/quiet", u)) - u.HandleMsg(u.ConsumeOne()) expected = []byte("-> Quiet mode is toggled OFF" + message.Newline) - s.Read(&actual) + actual = <-s.Chan if !reflect.DeepEqual(actual, expected) { t.Errorf("Got: %q; Expected: %q", actual, expected) } @@ -334,8 +291,10 @@ func TestQuietToggleDisplayState(t *testing.T) { func TestRoomNames(t *testing.T) { var expected, actual []byte - s := &MockScreen{} - u := message.NewUserScreen(message.SimpleID("foo"), s) + s := &ChannelWriter{ + Chan: make(chan []byte), + } + u := message.PipedScreen("foo", s) ch := NewRoom() go ch.Serve() @@ -346,18 +305,16 @@ func TestRoomNames(t *testing.T) { t.Fatal(err) } - u.HandleMsg(u.ConsumeOne()) expected = []byte(" * foo joined. (Connected: 1)" + message.Newline) - s.Read(&actual) + actual = <-s.Chan if !reflect.DeepEqual(actual, expected) { t.Errorf("Got: %q; Expected: %q", actual, expected) } ch.Send(message.ParseInput("/names", u)) - u.HandleMsg(u.ConsumeOne()) expected = []byte("-> 1 connected: foo" + message.Newline) - s.Read(&actual) + actual = <-s.Chan if !reflect.DeepEqual(actual, expected) { t.Errorf("Got: %q; Expected: %q", actual, expected) } diff --git a/chat/set_test.go b/chat/set_test.go index 54955f6..754eb60 100644 --- a/chat/set_test.go +++ b/chat/set_test.go @@ -10,7 +10,7 @@ import ( func TestSet(t *testing.T) { var err error s := set.New() - u := message.NewUser(message.SimpleID("foo")) + u := message.NewUser("foo") if s.In(u.ID()) { t.Errorf("Set should be empty.") @@ -25,15 +25,15 @@ func TestSet(t *testing.T) { t.Errorf("Set should contain user.") } - u2 := message.NewUser(message.SimpleID("bar")) + u2 := message.NewUser("bar") err = s.Add(set.Itemize(u2.ID(), u2)) if err != nil { t.Error(err) } - err = s.Add(set.Itemize(u2.ID(), u2)) + err = s.AddNew(set.Itemize(u2.ID(), u2)) if err != set.ErrCollision { - t.Error(err) + t.Errorf("expected ErrCollision, got: %s", err) } size := s.Len() diff --git a/client.go b/client.go new file mode 100644 index 0000000..996171a --- /dev/null +++ b/client.go @@ -0,0 +1,45 @@ +package sshchat + +import ( + "sync" + "time" + + "github.com/shazow/ssh-chat/chat" + "github.com/shazow/ssh-chat/chat/message" + "github.com/shazow/ssh-chat/sshd" +) + +type client struct { + Member + sync.Mutex + conns []sshd.Connection +} + +func (cl *client) Connections() []sshd.Connection { + return cl.conns +} + +func (cl *client) Close() error { + // TODO: Stack errors? + for _, conn := range cl.conns { + conn.Close() + } + return nil +} + +type Member interface { + chat.Member + + Joined() time.Time + Prompt() string + ReplyTo() message.Author + SetHighlight(string) error + SetReplyTo(message.Author) +} + +type User interface { + Member + + Connections() []sshd.Connection + Close() error +} diff --git a/host.go b/host.go index 752cf2f..9c43599 100644 --- a/host.go +++ b/host.go @@ -18,16 +18,6 @@ import ( const maxInputLength int = 1024 -// GetPrompt will render the terminal prompt string based on the user. -func GetPrompt(user *message.User) string { - name := user.Name() - cfg := user.Config() - if cfg.Theme != nil { - name = cfg.Theme.ColorName(user) - } - return fmt.Sprintf("[%s] ", name) -} - // Host is the bridge between sshd and chat modules // TODO: Should be easy to add support for multiple rooms, if we want. type Host struct { @@ -42,9 +32,10 @@ type Host struct { // Default theme theme message.Theme - mu sync.Mutex - motd string - count int + mu sync.Mutex + motd string + count int + clients map[chat.Member][]client } // NewHost creates a Host on top of an existing listener. @@ -55,6 +46,7 @@ func NewHost(listener *sshd.SSHListener, auth *Auth) *Host { listener: listener, commands: chat.Commands{}, auth: auth, + clients: map[chat.Member][]client{}, } // Make our own commands registry instance. @@ -80,26 +72,14 @@ func (h *Host) SetMotd(motd string) { h.mu.Unlock() } -func (h *Host) isOp(conn sshd.Connection) bool { - key := conn.PublicKey() - if key == nil { - return false - } - return h.auth.IsOp(key) -} - // Connect a specific Terminal to this host and its room. func (h *Host) Connect(term *sshd.Terminal) { - id := NewIdentity(term.Conn) - user := message.NewUserScreen(id, term) - cfg := user.Config() - cfg.Theme = &h.theme - user.SetConfig(cfg) - go user.Consume() - - // Close term once user is closed. - defer user.Close() - defer term.Close() + requestedName := term.Conn.Name() + screen := message.BufferedScreen(requestedName, term) + user := &client{ + Member: screen, + conns: []sshd.Connection{term.Conn}, + } h.mu.Lock() motd := h.motd @@ -107,6 +87,16 @@ func (h *Host) Connect(term *sshd.Terminal) { h.count++ h.mu.Unlock() + cfg := user.Config() + cfg.Theme = &h.theme + user.SetConfig(cfg) + + // Close term once user is closed. + defer screen.Close() + defer term.Close() + + go screen.Consume() + // Send MOTD if motd != "" { user.Send(message.NewAnnounceMsg(motd)) @@ -115,7 +105,7 @@ func (h *Host) Connect(term *sshd.Terminal) { member, err := h.Join(user) if err != nil { // Try again... - id.SetName(fmt.Sprintf("Guest%d", count)) + user.SetName(fmt.Sprintf("Guest%d", count)) member, err = h.Join(user) } if err != nil { @@ -124,16 +114,22 @@ func (h *Host) Connect(term *sshd.Terminal) { } // Successfully joined. - term.SetPrompt(GetPrompt(user)) + term.SetPrompt(user.Prompt()) term.AutoCompleteCallback = h.AutoCompleteFunction(user) user.SetHighlight(user.Name()) // Should the user be op'd on join? - if h.isOp(term.Conn) { - h.Room.Ops.Add(set.Itemize(member.ID(), member)) + if key := term.Conn.PublicKey(); key != nil { + authItem, err := h.auth.ops.Get(newAuthKey(key)) + if err == nil { + err = h.Room.Ops.Add(set.Rename(authItem, member.ID())) + } + } + if err != nil { + logger.Warningf("[%s] Failed to op: %s", term.Conn.RemoteAddr(), err) } - ratelimit := rateio.NewSimpleLimiter(3, time.Second*3) + ratelimit := rateio.NewSimpleLimiter(3, time.Second*3) logger.Debugf("[%s] Joined: %s", term.Conn.RemoteAddr(), user.Name()) for { @@ -172,7 +168,7 @@ func (h *Host) Connect(term *sshd.Terminal) { // // FIXME: This is hacky, how do we improve the API to allow for // this? Chat module shouldn't know about terminals. - term.SetPrompt(GetPrompt(user)) + term.SetPrompt(user.Prompt()) user.SetHighlight(user.Name()) } } @@ -211,7 +207,7 @@ func (h *Host) completeCommand(partial string) string { } // AutoCompleteFunction returns a callback for terminal autocompletion -func (h *Host) AutoCompleteFunction(u *message.User) func(line string, pos int, key rune) (newLine string, newPos int, ok bool) { +func (h *Host) AutoCompleteFunction(u User) func(line string, pos int, key rune) (newLine string, newPos int, ok bool) { return func(line string, pos int, key rune) (newLine string, newPos int, ok bool) { if key != 9 { return @@ -268,12 +264,13 @@ func (h *Host) AutoCompleteFunction(u *message.User) func(line string, pos int, } // GetUser returns a message.User based on a name. -func (h *Host) GetUser(name string) (*message.User, bool) { +func (h *Host) GetUser(name string) (User, bool) { m, ok := h.MemberByID(name) if !ok { return nil, false } - return m.User, true + u, ok := m.Member.(User) + return u, ok } // InitCommands adds host-specific commands to a Commands container. These will @@ -319,7 +316,7 @@ func (h *Host) InitCommands(c *chat.Commands) { return errors.New("must specify message") } - target := msg.From().ReplyTo() + target := msg.From().(Member).ReplyTo() if target == nil { return errors.New("no message to reply to") } @@ -355,13 +352,12 @@ func (h *Host) InitCommands(c *chat.Commands) { return errors.New("user not found") } - id := target.Identifier.(*Identity) var whois string switch room.IsOp(msg.From()) { case true: - whois = id.WhoisAdmin() + whois = whoisAdmin(target) case false: - whois = id.Whois() + whois = whoisPublic(target) } room.Send(message.NewSystemMsg(whois, msg.From())) @@ -387,12 +383,11 @@ func (h *Host) InitCommands(c *chat.Commands) { }, }) - // Op commands c.Add(chat.Command{ Op: true, - Prefix: "/kick", - PrefixHelp: "USER", - Help: "Kick USER from the server.", + Prefix: "/op", + PrefixHelp: "USER [DURATION]", + Help: "Set USER as admin.", Handler: func(room *chat.Room, msg message.CommandMsg) error { if !room.IsOp(msg.From()) { return errors.New("must be op") @@ -403,18 +398,36 @@ func (h *Host) InitCommands(c *chat.Commands) { return errors.New("must specify user") } - target, ok := h.GetUser(args[0]) + var until time.Duration = 0 + if len(args) > 1 { + until, _ = time.ParseDuration(args[1]) + } + + user, ok := h.GetUser(args[0]) if !ok { return errors.New("user not found") } + if until != 0 { + room.Ops.Add(set.Expire(set.Keyize(user.ID()), until)) + } else { + room.Ops.Add(set.Keyize(user.ID())) + } + + // TODO: Add pubkeys to op + /* + for _, conn := range user.Connections() { + h.auth.Op(conn.PublicKey(), until) + } + */ + + body := fmt.Sprintf("Made op by %s.", msg.From().Name()) + room.Send(message.NewSystemMsg(body, user)) - body := fmt.Sprintf("%s was kicked by %s.", target.Name(), msg.From().Name()) - room.Send(message.NewAnnounceMsg(body)) - target.Close() return nil }, }) + // Op commands c.Add(chat.Command{ Op: true, Prefix: "/ban", @@ -441,20 +454,48 @@ func (h *Host) InitCommands(c *chat.Commands) { until, _ = time.ParseDuration(args[1]) } - id := target.Identifier.(*Identity) - h.auth.Ban(id.PublicKey(), until) - h.auth.BanAddr(id.RemoteAddr(), until) + for _, conn := range target.Connections() { + h.auth.Ban(conn.PublicKey(), until) + h.auth.BanAddr(conn.RemoteAddr(), until) + } body := fmt.Sprintf("%s was banned by %s.", target.Name(), msg.From().Name()) room.Send(message.NewAnnounceMsg(body)) target.Close() - logger.Debugf("Banned: \n-> %s", id.Whois()) + logger.Debugf("Banned: \n-> %s", whoisAdmin(target)) return nil }, }) + c.Add(chat.Command{ + Op: true, + Prefix: "/kick", + PrefixHelp: "USER", + Help: "Kick USER from the server.", + Handler: func(room *chat.Room, msg message.CommandMsg) error { + if !room.IsOp(msg.From()) { + return errors.New("must be op") + } + + args := msg.Args() + if len(args) == 0 { + return errors.New("must specify user") + } + + target, ok := h.GetUser(args[0]) + if !ok { + return errors.New("user not found") + } + + body := fmt.Sprintf("%s was kicked by %s.", target.Name(), msg.From().Name()) + room.Send(message.NewAnnounceMsg(body)) + target.(io.Closer).Close() + return nil + }, + }) + c.Add(chat.Command{ Op: true, Prefix: "/motd", @@ -485,39 +526,4 @@ func (h *Host) InitCommands(c *chat.Commands) { }, }) - c.Add(chat.Command{ - Op: true, - Prefix: "/op", - PrefixHelp: "USER [DURATION]", - Help: "Set USER as admin.", - Handler: func(room *chat.Room, msg message.CommandMsg) error { - if !room.IsOp(msg.From()) { - return errors.New("must be op") - } - - args := msg.Args() - if len(args) == 0 { - return errors.New("must specify user") - } - - var until time.Duration = 0 - if len(args) > 1 { - until, _ = time.ParseDuration(args[1]) - } - - member, ok := room.MemberByID(args[0]) - if !ok { - return errors.New("user not found") - } - room.Ops.Add(set.Itemize(member.ID(), member)) - - id := member.Identifier.(*Identity) - h.auth.Op(id.PublicKey(), until) - - body := fmt.Sprintf("Made op by %s.", msg.From().Name()) - room.Send(message.NewSystemMsg(body, member.User)) - - return nil - }, - }) } diff --git a/host_test.go b/host_test.go index 7c2f976..187e724 100644 --- a/host_test.go +++ b/host_test.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "crypto/rsa" "errors" + "fmt" "io" "io/ioutil" "strings" @@ -27,9 +28,9 @@ func stripPrompt(s string) string { func TestHostGetPrompt(t *testing.T) { var expected, actual string - u := message.NewUser(&Identity{id: "foo"}) + u := message.Screen("foo") - actual = GetPrompt(u) + actual = u.Prompt() expected = "[foo] " if actual != expected { t.Errorf("Got: %q; Expected: %q", actual, expected) @@ -38,8 +39,8 @@ func TestHostGetPrompt(t *testing.T) { u.SetConfig(message.UserConfig{ Theme: &message.Themes[0], }) - actual = GetPrompt(u) - expected = "[\033[38;05;88mfoo\033[0m] " + actual = u.Prompt() + expected = "[\033[38;05;1mfoo\033[0m] " if actual != expected { t.Errorf("Got: %q; Expected: %q", actual, expected) } @@ -63,6 +64,8 @@ func TestHostNameCollision(t *testing.T) { done := make(chan struct{}, 1) + canary := "canarystring" + // First client go func() { err := sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) error { @@ -92,8 +95,10 @@ func TestHostNameCollision(t *testing.T) { t.Errorf("Got %q; expected %q", actual, expected) } + fmt.Fprintf(w, canary+message.Newline) + + <-done // Wrap it up. - close(done) return nil }) if err != nil { @@ -108,15 +113,23 @@ func TestHostNameCollision(t *testing.T) { err = sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) error { scanner := bufio.NewScanner(r) - // Consume the initial buffer - scanner.Scan() - scanner.Scan() - scanner.Scan() + // Scan until we see our canarystring + for scanner.Scan() { + s := scanner.Text() + if strings.HasSuffix(s, canary) { + break + } + } + // Send an empty prompt to allow for a full line scan with EOL. + fmt.Fprintf(w, message.Newline) + + scanner.Scan() actual := scanner.Text() if !strings.HasPrefix(actual, "[Guest1] ") { t.Errorf("Second client did not get Guest1 name: %q", actual) } + close(done) return nil }) if err != nil { @@ -195,7 +208,7 @@ func TestHostKick(t *testing.T) { if member == nil { return errors.New("failed to load MemberByID") } - host.Room.Ops.Add(set.Itemize(member.ID(), member)) + host.Room.Ops.Add(set.Keyize(member.ID())) // Block until second client is here connected <- struct{}{} diff --git a/identity.go b/identity.go deleted file mode 100644 index 50cee7e..0000000 --- a/identity.go +++ /dev/null @@ -1,69 +0,0 @@ -package sshchat - -import ( - "net" - "time" - - "github.com/dustin/go-humanize" - "github.com/shazow/ssh-chat/chat" - "github.com/shazow/ssh-chat/chat/message" - "github.com/shazow/ssh-chat/sshd" -) - -// Identity is a container for everything that identifies a client. -type Identity struct { - sshd.Connection - id string - created time.Time -} - -// NewIdentity returns a new identity object from an sshd.Connection. -func NewIdentity(conn sshd.Connection) *Identity { - return &Identity{ - Connection: conn, - id: chat.SanitizeName(conn.Name()), - created: time.Now(), - } -} - -func (i Identity) ID() string { - return i.id -} - -func (i *Identity) SetID(id string) { - i.id = id -} - -func (i *Identity) SetName(name string) { - i.SetID(name) -} - -func (i Identity) Name() string { - return i.id -} - -// Whois returns a whois description for non-admin users. -func (i Identity) Whois() string { - fingerprint := "(no public key)" - if i.PublicKey() != nil { - fingerprint = sshd.Fingerprint(i.PublicKey()) - } - return "name: " + i.Name() + message.Newline + - " > fingerprint: " + fingerprint + message.Newline + - " > client: " + chat.SanitizeData(string(i.ClientVersion())) + message.Newline + - " > joined: " + humanize.Time(i.created) -} - -// WhoisAdmin returns a whois description for admin users. -func (i Identity) WhoisAdmin() string { - ip, _, _ := net.SplitHostPort(i.RemoteAddr().String()) - fingerprint := "(no public key)" - if i.PublicKey() != nil { - fingerprint = sshd.Fingerprint(i.PublicKey()) - } - return "name: " + i.Name() + message.Newline + - " > ip: " + ip + message.Newline + - " > fingerprint: " + fingerprint + message.Newline + - " > client: " + chat.SanitizeData(string(i.ClientVersion())) + message.Newline + - " > joined: " + humanize.Time(i.created) -} diff --git a/sanitize.go b/sanitize.go new file mode 100644 index 0000000..130caa3 --- /dev/null +++ b/sanitize.go @@ -0,0 +1,10 @@ +package sshchat + +import "regexp" + +var reStripData = regexp.MustCompile("[^[:ascii:]]") + +// SanitizeData returns a string with only allowed characters for client-provided metadata inputs. +func SanitizeData(s string) string { + return reStripData.ReplaceAllString(s, "") +} diff --git a/set/item.go b/set/item.go index 25edf66..d50f6c2 100644 --- a/set/item.go +++ b/set/item.go @@ -25,6 +25,24 @@ func Itemize(key string, value interface{}) Item { return &item{key, value} } +func Keyize(key string) Item { + return &item{key, struct{}{}} +} + +type renamedItem struct { + Item + key string +} + +func (item *renamedItem) Key() string { + return item.key +} + +// Rename item to a new key, same underlying value. +func Rename(item Item, key string) Item { + return &renamedItem{Item: item, key: key} +} + type StringItem string func (item StringItem) Key() string { diff --git a/set/set.go b/set/set.go index 1b489ad..42ed515 100644 --- a/set/set.go +++ b/set/set.go @@ -55,6 +55,7 @@ func (s *Set) In(key string) bool { s.RUnlock() if ok && item.Value() == nil { s.cleanup(key) + ok = false } return ok } @@ -66,12 +67,13 @@ func (s *Set) Get(key string) (Item, error) { item, ok := s.lookup[key] s.RUnlock() + if ok && item.Value() == nil { + s.cleanup(key) + ok = false + } if !ok { return nil, ErrMissing } - if item.Value() == nil { - s.cleanup(key) - } return item, nil } @@ -87,10 +89,7 @@ func (s *Set) cleanup(key string) { } // Add item to this set if it does not exist already. -func (s *Set) Add(item Item) error { - if item.Value() == nil { - return ErrNil - } +func (s *Set) AddNew(item Item) error { key := s.normalize(item.Key()) s.Lock() @@ -101,7 +100,26 @@ func (s *Set) Add(item Item) error { return ErrCollision } - s.lookup[key] = item + if item.Value() == nil { + delete(s.lookup, key) + } else { + s.lookup[key] = item + } + return nil +} + +// Add to set, replacing if item already exists. +func (s *Set) Add(item Item) error { + key := s.normalize(item.Key()) + + s.Lock() + defer s.Unlock() + + if item.Value() == nil { + delete(s.lookup, key) + } else { + s.lookup[key] = item + } return nil } @@ -123,9 +141,6 @@ func (s *Set) Remove(key string) error { // Replace oldKey with a new item, which might be a new key. // Can be used to rename items. func (s *Set) Replace(oldKey string, item Item) error { - if item.Value() == nil { - return ErrNil - } newKey := s.normalize(item.Key()) oldKey = s.normalize(oldKey) @@ -140,15 +155,15 @@ func (s *Set) Replace(oldKey string, item Item) error { } // Remove oldKey - _, found = s.lookup[oldKey] - if !found { - return ErrMissing - } delete(s.lookup, oldKey) } - // Add new item - s.lookup[newKey] = item + if item.Value() == nil { + delete(s.lookup, newKey) + } else { + // Add new item + s.lookup[newKey] = item + } return nil } diff --git a/set/set_test.go b/set/set_test.go index f75192d..13147b2 100644 --- a/set/set_test.go +++ b/set/set_test.go @@ -1,6 +1,7 @@ package set import ( + "strings" "testing" "time" ) @@ -21,10 +22,16 @@ func TestSetExpiring(t *testing.T) { t.Error("not len 1 after set") } - item := &ExpiringItem{nil, time.Now().Add(-time.Nanosecond * 1)} + item := &ExpiringItem{StringItem("expired"), time.Now().Add(-time.Nanosecond * 1)} if !item.Expired() { t.Errorf("ExpiringItem a nanosec ago is not expiring") } + if err := s.Add(item); err != nil { + t.Fatalf("failed to add item: %s", err) + } + if s.In("expired") { + t.Errorf("expired item is present") + } item = &ExpiringItem{nil, time.Now().Add(time.Minute * 5)} if item.Expired() { @@ -74,8 +81,13 @@ func TestSetExpiring(t *testing.T) { t.Errorf("failed to get barbar: %s", err) } b := s.ListPrefix("b") - if len(b) != 2 || b[0].Key() != "bar" || b[1].Key() != "barbar" { - t.Errorf("b-prefix incorrect: %q", b) + if len(b) != 2 { + t.Errorf("b-prefix incorrect number of results: %d", len(b)) + } + for i, item := range b { + if !strings.HasPrefix(item.Key(), "b") { + t.Errorf("item %d does not have b prefix: %s", i, item.Key()) + } } if err := s.Remove("bar"); err != nil { diff --git a/whois.go b/whois.go new file mode 100644 index 0000000..af496cc --- /dev/null +++ b/whois.go @@ -0,0 +1,39 @@ +package sshchat + +import ( + "net" + + humanize "github.com/dustin/go-humanize" + "github.com/shazow/ssh-chat/chat/message" + "github.com/shazow/ssh-chat/sshd" +) + +// Helpers for printing whois messages + +func whoisPublic(u User) string { + fingerprint := "(no public key)" + // FIXME: Use all connections? + conn := u.Connections()[0] + if conn.PublicKey() != nil { + fingerprint = sshd.Fingerprint(conn.PublicKey()) + } + return "name: " + u.Name() + message.Newline + + " > fingerprint: " + fingerprint + message.Newline + + " > client: " + SanitizeData(string(conn.ClientVersion())) + message.Newline + + " > joined: " + humanize.Time(u.Joined()) +} + +func whoisAdmin(u User) string { + // FIXME: Use all connections? + conn := u.Connections()[0] + ip, _, _ := net.SplitHostPort(conn.RemoteAddr().String()) + fingerprint := "(no public key)" + if conn.PublicKey() != nil { + fingerprint = sshd.Fingerprint(conn.PublicKey()) + } + return "name: " + u.Name() + message.Newline + + " > ip: " + ip + message.Newline + + " > fingerprint: " + fingerprint + message.Newline + + " > client: " + SanitizeData(string(conn.ClientVersion())) + message.Newline + + " > joined: " + humanize.Time(u.Joined()) +}