diff options
| -rw-r--r-- | go.mod | 3 | ||||
| -rw-r--r-- | proto/fail.go | 11 | ||||
| -rw-r--r-- | proto/id.go | 28 | ||||
| -rw-r--r-- | proto/protocol.go | 342 | ||||
| -rw-r--r-- | server/channel/channel.go | 148 | ||||
| -rw-r--r-- | server/channel/command.go | 253 | ||||
| -rw-r--r-- | server/channel/membership.go | 87 | ||||
| -rw-r--r-- | server/main.go | 7 | ||||
| -rw-r--r-- | server/object/object.go | 36 | ||||
| -rw-r--r-- | server/server/command.go | 118 | ||||
| -rw-r--r-- | server/server/server.go | 126 | ||||
| -rw-r--r-- | server/session/session.go | 75 | ||||
| -rw-r--r-- | server/user/command.go | 76 | ||||
| -rw-r--r-- | server/user/user.go | 95 |
14 files changed, 1405 insertions, 0 deletions
@@ -0,0 +1,3 @@ +module citrons.xyz/talk + +go 1.23.4 diff --git a/proto/fail.go b/proto/fail.go new file mode 100644 index 0000000..0b6366b --- /dev/null +++ b/proto/fail.go @@ -0,0 +1,11 @@ +package proto + +type Fail Object + +func (f Fail) Cmd() Command { + return NewCmd("fail", "", Object(f)) +} + +func (f Fail) Error() string { + return f.Kind +} diff --git a/proto/id.go b/proto/id.go new file mode 100644 index 0000000..22bb395 --- /dev/null +++ b/proto/id.go @@ -0,0 +1,28 @@ +package proto + +import ( + "time" + "strconv" +) + +var counter = make(chan uint8) +func init() { + go func() { + var i uint8 + for i = 0; true; i++ { + counter <- i + } + }() +} + +var epoch = time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + +func GenId() string { + t := time.Now().UnixMilli() - epoch.UnixMilli() + id := uint64(t << 6) | uint64(<-counter & 63) + return strconv.FormatUint(id, 36) +} + +func Timestamp() string { + return strconv.FormatInt(time.Now().Unix(), 10) +} diff --git a/proto/protocol.go b/proto/protocol.go new file mode 100644 index 0000000..b59cd70 --- /dev/null +++ b/proto/protocol.go @@ -0,0 +1,342 @@ +package proto + +import ( + "bufio" + "strings" + "errors" +) + +type Object struct { + Kind string + Id string + Fields map[string]string +} + +type Command struct { + Kind string + Target string + Args []Object +} + +type Line struct { + Kind rune + RequestId string + Cmd Command +} + +func NewCmd(kind string, target string, args ...Object) Command { + return Command {kind, target, args} +} + +var SyntaxError = errors.New("invalid syntax") + +func ReadLiteral(b *bufio.Reader) (string, error) { + var sb strings.Builder + for { + c, _, err := b.ReadRune() + if strings.IndexRune(",;\n", c) != -1 { + b.UnreadRune() + break + } + if c == '\\' { + c, _, err = b.ReadRune() + } + if err != nil { + return "", err + } + sb.WriteRune(c) + } + return sb.String(), nil +} + +func ReadIdentifier(b *bufio.Reader) (string, error) { + var sb strings.Builder + for { + c, _, err := b.ReadRune() + if strings.IndexRune("?!*<>:\\,#@~;\n\t ", c) != -1 { + b.UnreadRune() + break + } + if err != nil { + return "", err + } + sb.WriteRune(c) + } + return sb.String(), nil +} + +func ReadObject(b *bufio.Reader) (Object, error) { + var o Object + var err error + o.Kind, err = ReadIdentifier(b) + if err != nil { + return o, err + } + + o.Fields = make(map[string]string) + + c, _, err := b.ReadRune() + if err != nil { + return o, err + } + + if c == '#' { + o.Id, err = ReadIdentifier(b) + if err != nil { + return o, err + } + + c, _, err = b.ReadRune() + if err != nil { + return o, err + } + } + + switch c { + case ' ': + for { + key, err := ReadIdentifier(b) + if err != nil { + return o, err + } + c, _, err := b.ReadRune() + if err != nil { + return o, err + } else if c != ':' { + return o, SyntaxError + } + value, err := ReadLiteral(b) + if err != nil { + return o, err + } + o.Fields[key] = value + c, _, err = b.ReadRune(); + if c == ';' || c == '\n' { + b.UnreadRune() + break + } + } + case ';': + b.UnreadRune() + default: + return o, SyntaxError + } + return o, nil +} + +func ReadLine(b *bufio.Reader) (Line, error) { + var l Line + var err error + l.Kind, _, err = b.ReadRune() + if err != nil { + return l, err + } + switch l.Kind { + case '?', '!': + l.RequestId, err = ReadIdentifier(b) + if err != nil { + return l, err + } + c, _, err := b.ReadRune() + if err != nil { + return l, err + } else if c != ' ' { + return l, SyntaxError + } + case '*': + default: + return l, SyntaxError + } + + l.Cmd.Kind, err = ReadIdentifier(b) + if err != nil { + return l, err + } + + c, _, err := b.ReadRune() + if err != nil { + return l, err + } + + if c == '>' { + l.Cmd.Target, err = ReadIdentifier(b) + if err != nil { + return l, err + } + + c, _, err = b.ReadRune() + if err != nil { + return l, err + } + } + + if c == ' ' { + for { + o, err := ReadObject(b) + if err != nil { + return l, err + } + l.Cmd.Args = append(l.Cmd.Args, o) + + c, _, err := b.ReadRune() + if err != nil { + return l, err + } + if c == ';' { + break + } + } + } else if c != ';' { + return l, SyntaxError + } + + c, _, err = b.ReadRune() + if err != nil { + return l, err + } else if c != '\n' { + return l, SyntaxError + } + + return l, nil +} + +func WriteLiteral(b *bufio.Writer, s string) error { + for _, c := range s { + if strings.IndexRune(",;\n\\", c) != -1 { + _, err := b.WriteRune('\\'); + if err != nil { + return err + } + } + _, err := b.WriteRune(c); + if err != nil { + return err + } + } + return nil +} + +func WriteIdentifier(b *bufio.Writer, s string) error { + for _, c := range s { + if strings.IndexRune("?!*<>:\\,#@~;\n\t ", c) != -1 { + panic("invalid character in identifier") + } + _, err := b.WriteRune(c); + if err != nil { + return err + } + } + return nil +} + +func WriteObject(b *bufio.Writer, o Object) error { + err := WriteIdentifier(b, o.Kind) + if err != nil { + return err + } + if o.Id != "" { + b.WriteRune('#') + err := WriteIdentifier(b, o.Id) + if err != nil { + return err + } + } + if len(o.Fields) != 0 { + b.WriteRune(' ') + } + comma := false; + for k, v := range o.Fields { + if comma { + b.WriteRune(',') + } + err := WriteIdentifier(b, k) + if err != nil { + return err + } + b.WriteRune(':') + err = WriteLiteral(b, v) + if err != nil { + return err + } + comma = true + } + return nil +} + +func WriteLine(b *bufio.Writer, l Line) error { + _, err := b.WriteRune(l.Kind) + if err != nil { + return err + } + switch l.Kind { + case '!', '?': + err := WriteIdentifier(b, l.RequestId) + if err != nil { + return err + } + b.WriteRune(' ') + case '*': + } + + err = WriteIdentifier(b, l.Cmd.Kind) + if err != nil { + return err + } + if l.Cmd.Target != "" { + b.WriteRune('>') + err = WriteIdentifier(b, l.Cmd.Target) + if err != nil { + return err + } + } + + if len(l.Cmd.Args) != 0 { + b.WriteRune(' ') + } + for i, o := range l.Cmd.Args { + err := WriteObject(b, o) + if err != nil { + return err + } + if i < len(l.Cmd.Args) - 1 { + b.WriteRune('\n') + } + } + _, err = b.WriteString(";\n") + if err != nil { + return err + } + return b.Flush() +} + +func ReadLines(b *bufio.Reader) (<-chan Line, <-chan error) { + out, errChan := make(chan Line, 1), make(chan error, 1) + go func() { + for { + l, err := ReadLine(b) + if err != nil { + errChan <- err + close(out) + return + } else { + out <- l + } + } + }() + return out, errChan +} + +func WriteLines(b *bufio.Writer, lines <-chan Line) <-chan error { + errChan := make(chan error, 1) + go func() { + for l := range lines { + err := WriteLine(b, l) + if err != nil { + errChan <- err + return + } + } + close(errChan) + }() + return errChan +} diff --git a/server/channel/channel.go b/server/channel/channel.go new file mode 100644 index 0000000..50b43b6 --- /dev/null +++ b/server/channel/channel.go @@ -0,0 +1,148 @@ +package channel + +import ( + "citrons.xyz/talk/proto" + "citrons.xyz/talk/server/object" + "citrons.xyz/talk/server/session" + "citrons.xyz/talk/server/user" +) + +type ChannelStore struct { + world *object.World + byName map[string]*Channel +} + +type Channel struct { + store *ChannelStore + id string + name string + members map[string]Membership + messages []proto.Object + byId map[string]int + defaultMembership Membership + Stream session.Stream +} + +func NewStore(world *object.World) *ChannelStore { + return &ChannelStore {world, make(map[string]*Channel)} +} + +func (cs *ChannelStore) CreateChannel(name string) (*Channel, *proto.Fail) { + if cs.byName[name] != nil { + return nil, &proto.Fail { + "name-taken", "", map[string]string {"": name}, + } + } + var c Channel + c.store = cs + c.name = name + c.members = make(map[string]Membership) + c.byId = make(map[string]int) + c.defaultMembership = DefaultMembership + + cs.byName[name] = &c + c.id = cs.world.NewObject(&c) + return &c, nil +} + +func (cs *ChannelStore) ByName(name string) *Channel { + return cs.byName[name] +} + +func (c *Channel) Name() string { + return c.name +} + +func (c *Channel) Id() string { + return c.id +} + +func (c *Channel) Rename(name string) *proto.Fail { + if c.store.byName[name] != nil { + return &proto.Fail { + "name-taken", "", map[string]string {"": name}, + } + } + c.store.byName[c.name] = nil + c.store.byName[name] = c + c.name = name + return nil +} + +func (c *Channel) Put(m proto.Object) proto.Object { + m.Id = proto.GenId() + m.Fields["t"] = proto.Timestamp() + c.byId[m.Id] = len(c.messages) + c.messages = append(c.messages, m) + for s, _ := range c.Stream.Subscribers() { + if m.Fields["f"] == s.UserId { + continue + } + if c.members[s.UserId].See { + s.Event(proto.NewCmd("p", c.id, m)) + } + } + return m +} + +func (c *Channel) prune() { + for m, _ := range c.members { + switch c.store.world.GetObject(m).(type) { + case *user.User: + default: + delete(c.members, m) + } + } +} + +func (c *Channel) Join(u *user.User) *proto.Fail { + if c.members[u.Id()].Yes { + return nil + } + c.members[u.Id()] = c.defaultMembership + u.Channels[c.id] = true + c.Put(proto.Object{"join", "", map[string]string {"": u.Id()}}) + return nil +} + +func (c *Channel) Leave(u *user.User) *proto.Fail { + if !c.members[u.Id()].Yes { + return nil + } + delete(c.members, u.Id()) + delete(u.Channels, c.id) + c.Put(proto.Object{"leave", "", map[string]string {"": u.Id()}}) + return nil +} + +func (c *Channel) Members() map[string]Membership { + c.prune() + return c.members +} + +func (c *Channel) SetMembership(u *user.User, m Membership) { + if c.members[u.Id()].Yes { + c.members[u.Id()] = m + } +} + +func (c *Channel) Delete() { + c.Stream.Event(proto.NewCmd("delete", c.id)) + c.Stream.UnsubscribeAll() + + for m, _ := range c.members { + switch u := c.store.world.GetObject(m).(type) { + case *user.User: + u.Channels[c.id] = false + default: + } + } + delete(c.store.byName, c.name) + c.store.world.RemoveObject(c.id) +} + +func (c *Channel) GetInfo() proto.Object { + return proto.Object { + "channel", c.id, map[string]string {"": c.name}, + } +} diff --git a/server/channel/command.go b/server/channel/command.go new file mode 100644 index 0000000..94cf38f --- /dev/null +++ b/server/channel/command.go @@ -0,0 +1,253 @@ +package channel + +import ( + "citrons.xyz/talk/server/session" + "citrons.xyz/talk/server/user" + "citrons.xyz/talk/proto" +) + +func (c *Channel) SendRequest(r session.Request) { + switch r.Cmd.Kind { + + case "p": + if len(r.Cmd.Args) != 1 { + r.ReplyInvalid() + return + } + m := r.Cmd.Args[0] + switch m.Kind { + case "m": + for k, _ := range m.Fields { + switch k { + case "": + default: + r.ReplyInvalid() + return + } + } + default: + r.ReplyInvalid() + return + } + + if !c.members[r.From.UserId].Put { + r.Reply(proto.Fail{"forbidden", "", nil}.Cmd()) + return + } + + m.Fields["f"] = r.From.UserId + err := c.Put(m) + if err != nil { + r.Reply(err.Cmd()) + return + } + r.Reply(proto.NewCmd("p", c.id, m)) + + case "s": + r.From.Subscribe(&c.Stream) + r.Reply(proto.NewCmd("i", "", c.GetInfo())) + + case "u": + r.From.Unsubscribe(&c.Stream) + r.Reply(proto.NewCmd("ok", "")) + + case "join": + u := c.store.world.GetObject(r.From.UserId).(*user.User) + err := c.Join(u) + if err != nil { + r.Reply(err.Cmd()) + } else { + r.Reply(proto.NewCmd("ok", "")) + } + + case "leave": + u := c.store.world.GetObject(r.From.UserId).(*user.User) + err := c.Leave(u) + if err != nil { + r.Reply(err.Cmd()) + } else { + r.Reply(proto.NewCmd("ok", "")) + } + + case "delete": + if !c.members[r.From.UserId].Op { + r.Reply(proto.Fail{"forbidden", "", nil}.Cmd()) + return + } + c.Delete() + + case "update": + if len(r.Cmd.Args) != 1 { + r.ReplyInvalid() + return + } + upd := r.Cmd.Args[0] + if upd.Kind != "channel" { + r.ReplyInvalid() + return + } + name := c.name + for k, v := range upd.Fields { + switch k { + case "": + name = v + default: + r.ReplyInvalid() + return + } + } + + if !c.members[r.From.UserId].Update { + r.Reply(proto.Fail{"forbidden", "", nil}.Cmd()) + return + } + if name != c.name { + err := c.Rename(name) + if err != nil { + r.Reply(err.Cmd()) + return + } + } + c.Stream.Event(r.Cmd) + r.Reply(proto.NewCmd("ok", "")) + + case "list": + if !c.members[r.From.UserId].Yes { + r.Reply(proto.Fail{"forbidden", "", nil}.Cmd()) + return + } + cmd := proto.NewCmd("list", c.Id()) + for m, _ := range c.Members() { + u := c.store.world.GetObject(m).(*user.User) + cmd.Args = append(cmd.Args, u.GetInfo()) + } + r.Reply(cmd) + + case "history": + if len(r.Cmd.Args) != 1 { + r.ReplyInvalid() + return + } + h := r.Cmd.Args[0] + var min int + var max int + switch h.Kind { + case "latest": + max = len(c.messages) + min = max - 20 + case "before", "around", "at": + var id string + for k, v := range h.Fields { + switch k { + case "": + id = v + default: + r.ReplyInvalid() + return + } + } + i, ok := c.byId[id] + if !ok { + r.Reply(proto.Fail{"bad-target", "", nil}.Cmd()) + return + } + switch h.Kind { + case "before": + max = i + min = max - 20 + case "around": + min = i - 9 + max = i + 11 + case "at": + min = i + max = i + 1 + } + default: + r.ReplyInvalid() + return + } + if min < 0 { + min = 0 + } + if max > len(c.messages) { + max = len(c.messages) + } + + p := c.members[r.From.UserId] + if !p.History || !p.See { + r.Reply(proto.Fail{"forbidden", "", nil}.Cmd()) + return + } + cmd := proto.NewCmd("history", c.id) + cmd.Args = c.messages[min:max] + r.Reply(cmd) + + case "membership": + if len(r.Cmd.Args) != 1 { + r.ReplyInvalid() + return + } + m := r.Cmd.Args[0] + if m.Kind != "u" { + r.ReplyInvalid() + return + } + + if !c.members[r.From.UserId].Yes { + r.Reply(proto.Fail{"forbidden", "", nil}.Cmd()) + return + } + + if !c.members[m.Id].Yes { + r.Reply(proto.Fail{ + "not-in-channel", "", map[string]string {"": m.Id}, + }.Cmd()) + return + } + i := c.members[m.Id].GetInfo() + i.Fields[""] = m.Id + r.Reply(proto.NewCmd("i", "", i)) + + case "config": + if len(r.Cmd.Args) != 1 { + r.ReplyInvalid() + return + } + o := r.Cmd.Args[0] + switch o.Kind { + case "membership": + id := o.Fields[""] + if id == "" { + r.ReplyInvalid() + return + } + new, err := c.members[id].Change(o) + if err != nil { + r.Reply(err.Cmd()) + return + } + + if !c.members[r.From.UserId].Op { + r.Reply(proto.Fail{"forbidden", "", nil}.Cmd()) + return + } + if !c.members[id].Yes { + r.Reply(proto.Fail{ + "not-in-channel", "", map[string]string {"": id}, + }.Cmd()) + return + } + c.members[id] = new + c.Put(o) + + i := new.GetInfo() + i.Fields[""] = id + r.Reply(proto.NewCmd("i", "", i)) + default: + r.ReplyInvalid() + } + + default: + r.ReplyInvalid() + } +} diff --git a/server/channel/membership.go b/server/channel/membership.go new file mode 100644 index 0000000..3a44517 --- /dev/null +++ b/server/channel/membership.go @@ -0,0 +1,87 @@ +package channel + +import ( + "citrons.xyz/talk/proto" +) + +type Membership struct { + Yes bool + See bool + Put bool + History bool + Moderate bool + Update bool + Invite bool + Op bool +} + +var DefaultMembership = Membership { + Yes: true, + See: true, + Put: true, + History: true, + Moderate: false, + Update: false, + Op: false, +} + +var CreatorMembership = Membership { + Yes: true, + See: true, + Put: true, + History: true, + Moderate: true, + Update: true, + Op: true, +} + +func (m Membership) Change(spec proto.Object) (Membership, *proto.Fail) { + new := m + for k, v := range spec.Fields { + var field *bool + switch k { + case "see": + field = &new.See + case "put": + field = &new.Put + case "history": + field = &new.History + case "moderate": + field = &new.Moderate + case "update": + field = &new.Update + case "": + continue + default: + return new, &proto.Fail{"invalid", "", nil} + } + switch v { + case "yes": + *field = true + case "no": + *field = false + default: + return new, &proto.Fail{"invalid", "", nil} + } + } + return new, nil +} + +func bstr(b bool) string { + if b { + return "yes" + } else { + return "no" + } +} + +func (m Membership) GetInfo() proto.Object { + return proto.Object {"membership", "", map[string]string { + "see": bstr(m.See), + "put": bstr(m.Put), + "history": bstr(m.History), + "moderate": bstr(m.Moderate), + "update": bstr(m.Update), + "op": bstr(m.Op), + }} +} diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..6f75380 --- /dev/null +++ b/server/main.go @@ -0,0 +1,7 @@ +package main + +import "citrons.xyz/talk/server/server" + +func main() { + server.Serve() +} diff --git a/server/object/object.go b/server/object/object.go new file mode 100644 index 0000000..d18507c --- /dev/null +++ b/server/object/object.go @@ -0,0 +1,36 @@ +package object + +import ( + "citrons.xyz/talk/proto" + "citrons.xyz/talk/server/session" +) + +type Object interface { + SendRequest(session.Request) +} + +type World struct { + objects map[string]Object +} + +func NewWorld() *World { + return &World {make(map[string]Object)} +} + +func (w *World) GetObject(id string) Object { + return w.objects[id] +} + +func (w *World) PutObject(id string, o Object) { + w.objects[id] = o +} + +func (w *World) RemoveObject(id string) { + w.objects[id] = nil +} + +func (w *World) NewObject(o Object) string { + id := proto.GenId() + w.PutObject(id, o) + return id +} diff --git a/server/server/command.go b/server/server/command.go new file mode 100644 index 0000000..7be72ca --- /dev/null +++ b/server/server/command.go @@ -0,0 +1,118 @@ +package server + +import ( + "citrons.xyz/talk/proto" + "citrons.xyz/talk/server/session" + "citrons.xyz/talk/server/user" + "citrons.xyz/talk/server/channel" +) + +func (s *server) SendRequest(r session.Request) { + switch (r.Cmd.Kind) { + + case "auth": + if r.From.UserId != "" { + r.ReplyInvalid() + return + } + if len(r.Cmd.Args) != 1 { + r.ReplyInvalid() + return + } + auth := r.Cmd.Args[0] + switch auth.Kind { + case "anonymous": + if (auth.Fields[""] == "") { + r.ReplyInvalid() + return + } + user, err := s.userStore.CreateUser(auth.Fields[""]) + if err != nil { + r.Reply(err.Cmd()) + return + } + user.Anonymous = true + r.Reply(proto.NewCmd("you-are", "", user.GetInfo())) + r.From.UserId = user.Id() + default: + r.ReplyInvalid() + } + + case "lookup": + if len(r.Cmd.Args) != 1 { + r.ReplyInvalid() + return + } + o := r.Cmd.Args[0] + var name string + for k, v := range o.Fields { + switch k { + case "": + name = v + default: + r.ReplyInvalid() + return + } + } + var info proto.Object + switch o.Kind { + case "u": + u := s.userStore.ByName(name) + if u == nil { + r.Reply(proto.Fail{"unknown-name", "", nil}.Cmd()) + return + } + info = u.GetInfo() + case "channel": + c := s.channelStore.ByName(name) + if c == nil { + r.Reply(proto.Fail{"unknown-name", "", nil}.Cmd()) + return + } + info = c.GetInfo() + default: + r.ReplyInvalid() + return + } + r.Reply(proto.NewCmd("i", "", info)) + + case "create": + if len(r.Cmd.Args) != 1 { + r.ReplyInvalid() + return + } + o := r.Cmd.Args[0] + switch o.Kind { + case "channel": + var name string + for k, v := range o.Fields { + switch k { + case "": + name = v + default: + r.ReplyInvalid() + return + } + } + c, err := s.channelStore.CreateChannel(name) + if err != nil { + r.Reply(err.Cmd()) + return + } + u := s.world.GetObject(r.From.UserId).(*user.User) + c.Join(u) + c.SetMembership(u, channel.CreatorMembership) + r.Reply(proto.NewCmd("create", "", c.GetInfo())) + default: + r.ReplyInvalid() + } + + case "meow": + r.Reply(proto.NewCmd("meow", "")) + + default: + r.ReplyInvalid() + } +} + + diff --git a/server/server/server.go b/server/server/server.go new file mode 100644 index 0000000..a5dd510 --- /dev/null +++ b/server/server/server.go @@ -0,0 +1,126 @@ +package server + +import ( + "net" + "log" + "bufio" + "io" + "citrons.xyz/talk/proto" + "citrons.xyz/talk/server/session" + "citrons.xyz/talk/server/object" + "citrons.xyz/talk/server/user" + "citrons.xyz/talk/server/channel" +) + +type server struct { + requests chan session.Request + clients chan *session.Session + disconnects chan *session.Session + world *object.World + userStore *user.UserStore + channelStore *channel.ChannelStore +} + +func (s *server) mainLoop() { + for { + select { + case c := <-s.clients: + s.onConnect(c) + case c := <-s.disconnects: + s.onDisconnect(c) + case rq := <-s.requests: + if rq.From.UserId == "" && rq.Cmd.Target != "" { + rq.ReplyInvalid() + } else { + o := s.world.GetObject(rq.Cmd.Target) + if o == nil { + rq.Reply(proto.Fail{"bad-target", "", nil}.Cmd()) + } else { + o.SendRequest(rq) + } + } + } + } +} + +func (s *server) onConnect(sesh *session.Session) { + sesh.Event(proto.NewCmd("hi", "", + proto.Object{"protocol", "", map[string]string {"": "0"}})) +} + +func (s *server) onDisconnect(sesh *session.Session) { + if sesh.UserId != "" { + u := s.world.GetObject(sesh.UserId).(*user.User) + if u.Anonymous { + for c, _ := range u.Channels { + c := s.world.GetObject(c).(*channel.Channel) + c.Leave(u) + } + u.Delete() + } + } + for st, _ := range sesh.Subscriptions() { + sesh.Unsubscribe(st) + } +} + +func Serve() { + ln, err := net.Listen("tcp", ":27508") + if err != nil { + log.Fatal("Listen: ", err) + } + defer ln.Close() + + var srv server + srv.requests = make(chan session.Request) + srv.clients = make(chan *session.Session) + srv.disconnects = make(chan *session.Session) + srv.world = object.NewWorld() + srv.userStore = user.NewStore(srv.world) + srv.channelStore = channel.NewStore(srv.world) + + srv.world.PutObject("", &srv) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + log.Print("Accept: ", err) + } + srv.clients <- handleConn(conn, srv.requests, srv.disconnects) + } + }() + srv.mainLoop() +} + +func handleConn(conn net.Conn, rq chan<- session.Request, + disconnects chan<- *session.Session) *session.Session { + recv, recvErr := proto.ReadLines(bufio.NewReader(conn)) + send := make(chan proto.Line) + sendErr := proto.WriteLines(bufio.NewWriter(conn), send) + s := session.NewSession(send) + + go func() { + select { + case err := <-recvErr: + if err != io.EOF { + log.Print("client read error: ", err) + } + case err := <-sendErr: + log.Print("client write error: ", err) + } + conn.Close() + close(send) + disconnects <- s + }() + + go func() { + for l := range recv { + if l.Kind == '?' { + rq <- session.Request {s, l.RequestId, l.Cmd} + } + } + }() + + return s +} diff --git a/server/session/session.go b/server/session/session.go new file mode 100644 index 0000000..54b6d14 --- /dev/null +++ b/server/session/session.go @@ -0,0 +1,75 @@ +package session + +import ( + "citrons.xyz/talk/proto" +) + +type Session struct { + send chan<- proto.Line + UserId string + subscribedTo map[*Stream]bool +} + +func NewSession(send chan<- proto.Line) *Session { + var s Session + s.send = send + s.subscribedTo = make(map[*Stream]bool) + return &s +} + +func (s *Session) Event(ev proto.Command) { + s.send <- proto.Line {'*', "", ev} +} + +func (s *Session) Subscriptions() map[*Stream]bool { + return s.subscribedTo +} + +func (s *Session) Subscribe(to *Stream) { + s.subscribedTo[to] = true + if to.subscribers == nil { + to.subscribers = make(map[*Session]bool) + } + to.subscribers[s] = true +} + +func (s *Session) Unsubscribe(to *Stream) { + delete(s.subscribedTo, to) + if to.subscribers != nil { + delete(to.subscribers, s) + } +} + +type Stream struct { + subscribers map[*Session]bool +} + +func (s *Stream) Subscribers() map[*Session]bool { + return s.subscribers +} + +func (s *Stream) Event(ev proto.Command) { + for sub, _ := range s.subscribers { + sub.Event(ev) + } +} + +func (s *Stream) UnsubscribeAll() { + for sub, _ := range s.subscribers { + sub.Unsubscribe(s) + } +} + +type Request struct { + From *Session + RequestId string + Cmd proto.Command +} + +func (r Request) Reply(reply proto.Command) { + r.From.send <- proto.Line {'!', r.RequestId, reply} +} + +func (r Request) ReplyInvalid() { + r.Reply(proto.Fail{"invalid", "", nil}.Cmd()) +} diff --git a/server/user/command.go b/server/user/command.go new file mode 100644 index 0000000..210dede --- /dev/null +++ b/server/user/command.go @@ -0,0 +1,76 @@ +package user + +import ( + "citrons.xyz/talk/server/session" + "citrons.xyz/talk/proto" +) + +func (u *User) SendRequest(r session.Request) { + switch r.Cmd.Kind { + + case "update": + if r.From.UserId != u.Id() { + r.Reply(proto.Fail{"forbidden", "", nil}.Cmd()) + return + } + if len(r.Cmd.Args) != 1 { + r.ReplyInvalid() + return + } + upd := r.Cmd.Args[0] + if upd.Kind != "u" { + r.ReplyInvalid() + return + } + name := u.name + for k, v := range upd.Fields { + switch k { + case "": + name = v + default: + r.ReplyInvalid() + return + } + } + if name != u.name { + err := u.Rename(name) + if err != nil { + r.Reply(err.Cmd()) + return + } + } + u.Stream.Event(r.Cmd) + r.Reply(proto.NewCmd("ok", "")) + + case "i": + r.Reply(proto.NewCmd("i", "", u.GetInfo())) + + case "s": + r.From.Subscribe(&u.Stream) + r.Reply(proto.NewCmd("i", "", u.GetInfo())) + + case "u": + r.From.Unsubscribe(&u.Stream) + r.Reply(proto.NewCmd("ok", "")) + + default: + r.ReplyInvalid() + } +} + +func (t Tombstone) SendRequest(r session.Request) { + switch r.Cmd.Kind { + + case "update": + r.Reply(proto.Fail{"bad-target", "", nil}.Cmd()) + + case "i", "s": + r.Reply(proto.NewCmd("i", "", t.GetInfo())) + + case "u": + r.Reply(proto.NewCmd("ok", "")) + + default: + r.ReplyInvalid() + } +} diff --git a/server/user/user.go b/server/user/user.go new file mode 100644 index 0000000..41a5c4f --- /dev/null +++ b/server/user/user.go @@ -0,0 +1,95 @@ +package user + +import ( + "citrons.xyz/talk/server/object" + "citrons.xyz/talk/server/session" + "citrons.xyz/talk/proto" +) + +type UserStore struct { + world *object.World + byName map[string]*User + gone map[string]Tombstone +} + +type User struct { + store *UserStore + name string + id string + Stream session.Stream + Channels map[string]bool + Anonymous bool +} + +type Tombstone struct { + name string +} + +func NewStore(world *object.World) *UserStore { + return &UserStore { + world, make(map[string]*User), make(map[string]Tombstone), + } +} + +func (us *UserStore) CreateUser(name string) (*User, *proto.Fail) { + if us.byName[name] != nil { + return nil, &proto.Fail { + "name-taken", "", map[string]string {"": name}, + } + } + var u User + u.store = us + u.name = name + us.byName[name] = &u + u.id = us.world.NewObject(&u) + u.Channels = make(map[string]bool) + return &u, nil +} + +func (us *UserStore) ByName(name string) *User { + return us.byName[name] +} + +func (u *User) Name() string { + return u.name +} + +func (u *User) Id() string { + return u.id +} + +func (u *User) Rename(name string) *proto.Fail { + if u.store.byName[name] != nil { + return &proto.Fail { + "name-taken", "", map[string]string {"": name}, + } + } + u.store.byName[u.name] = nil + u.store.byName[name] = u + u.name = name + return nil +} + +func (u *User) Delete() { + u.Stream.Event(proto.NewCmd("delete", u.id)) + u.Stream.UnsubscribeAll() + + delete(u.store.byName, u.name) + u.store.world.RemoveObject(u.id) + + gone := Tombstone {u.name} + u.store.gone[u.id] = gone + u.store.world.PutObject(u.id, gone) +} + +func (u *User) GetInfo() proto.Object { + return proto.Object { + "u", u.id, map[string]string {"": u.name}, + } +} + +func (t Tombstone) GetInfo() proto.Object { + return proto.Object { + "gone", "", map[string]string {"": t.name}, + } +} |
