summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcitrons <citrons@mondecitronne.com>2025-01-26 01:56:53 -0600
committercitrons <citrons@mondecitronne.com>2025-01-26 01:56:53 -0600
commit10b8a79389e7073f6bd65695c3d05c77b825bc33 (patch)
treeb7e6dbd84b5b2c960ab8aafc1f99c3950d679e44
initial commit
-rw-r--r--go.mod3
-rw-r--r--proto/fail.go11
-rw-r--r--proto/id.go28
-rw-r--r--proto/protocol.go342
-rw-r--r--server/channel/channel.go148
-rw-r--r--server/channel/command.go253
-rw-r--r--server/channel/membership.go87
-rw-r--r--server/main.go7
-rw-r--r--server/object/object.go36
-rw-r--r--server/server/command.go118
-rw-r--r--server/server/server.go126
-rw-r--r--server/session/session.go75
-rw-r--r--server/user/command.go76
-rw-r--r--server/user/user.go95
14 files changed, 1405 insertions, 0 deletions
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..d356d1a
--- /dev/null
+++ b/go.mod
@@ -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},
+ }
+}