Merge 7b3818acc1cf13cf9fce74bc51a7997482cf8ac8 into 227dad7492377557dfc9d395d47255cf9f96ff2f

This commit is contained in:
Andrey Petrov 2017-05-07 23:41:17 +00:00 committed by GitHub
commit ce50c4043c
25 changed files with 781 additions and 659 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
/build
/vendor
host_key
host_key.pub
ssh-chat

View File

@ -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
}

23
chat/member.go Normal file
View File

@ -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)
}

24
chat/message/config.go Normal file
View File

@ -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?
}

View File

@ -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()
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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, "")
}

208
chat/message/screen.go Normal file
View File

@ -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)
}
}
}

View File

@ -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

View File

@ -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 {

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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()

45
client.go Normal file
View File

@ -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
}

188
host.go
View File

@ -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
},
})
}

View File

@ -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{}{}

View File

@ -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)
}

10
sanitize.go Normal file
View File

@ -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, "")
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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 {

39
whois.go Normal file
View File

@ -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())
}