commit 2aa412afee29eb1d7552762d9cc6f965f9f09809 Author: a Date: Tue May 14 00:51:42 2024 -0500 noot diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/cmd/lain/.gitignore b/cmd/lain/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/cmd/lain/.gitignore @@ -0,0 +1 @@ +.env diff --git a/cmd/lain/main.go b/cmd/lain/main.go new file mode 100644 index 0000000..2377ff7 --- /dev/null +++ b/cmd/lain/main.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "crypto/tls" + "log/slog" + "os" + "time" + + _ "github.com/joho/godotenv/autoload" + "github.com/lmittmann/tint" + "go.uber.org/fx" + "go.uber.org/fx/fxevent" + "tuxpa.in/a/irc/pkg/ircconn" + "tuxpa.in/a/irc/pkg/ircmw" + "tuxpa.in/a/irc/pkg/ircv3" + "tuxpa.in/a/irc/plugins/auth" + "tuxpa.in/a/irc/plugins/useful" +) + +func exec(log *slog.Logger) error { + tlsConfig := &tls.Config{} + conn, err := tls.Dial("tcp", "irc.libera.chat:6697", tlsConfig) + //conn, err := net.Dial("tcp", "irc.libera.chat:6667") + if err != nil { + return err + } + irc := ircconn.New(log, conn, conn) + if err != nil { + return err + } + ctx := context.Background() + name := os.Getenv("LAIN_NICKNAME") + saslPassword := os.Getenv("LAIN_PASSWORD") + c := &ircmw.Capabilities{} + handler := ircv3.Chain( + func(next ircv3.Handler) ircv3.Handler { + return ircv3.HandlerFunc(func(ctx context.Context, w ircv3.MessageWriter, m *ircv3.Message) { + log.Info("in <<", "msg", m.String()) + next.Handle(ctx, w, m) + }) + }, + c.Middleware, + (&auth.SaslPlain{ + Username: name, + Password: saslPassword, + }).Middleware, + (&auth.Nick{Nick: name}).Middleware, + (&auth.User{Username: "lain", Realname: "lain a", Hostname: "wired", Server: "wired"}).Middleware, + ircmw.CapabilityServerTime, + (&useful.Autojoin{Channels: []string{"#lainmaxxing"}}).Middleware, + ).Handler(ircv3.HandlerFunc(func(ctx context.Context, w ircv3.MessageWriter, m *ircv3.Message) { + })) + + err = irc.Serve(ctx, handler) + if err != nil { + return err + } + return nil + +} + +func main() { + lain := fx.New( + fx.Provide(func() *slog.Logger { + return slog.New( + tint.NewHandler(os.Stderr, &tint.Options{ + Level: slog.LevelDebug, + TimeFormat: time.Kitchen, + })) + }), + fx.WithLogger(func(s *slog.Logger) fxevent.Logger { + return &fxevent.SlogLogger{Logger: s} + }), + fx.Invoke(exec), + ) + lain.Run() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6d75632 --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module tuxpa.in/a/irc + +go 1.22.0 + +require ( + github.com/joho/godotenv v1.5.1 + github.com/lmittmann/tint v1.0.4 + github.com/stretchr/testify v1.9.0 + github.com/valyala/bytebufferpool v1.0.0 + go.uber.org/fx v1.21.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + go.uber.org/dig v1.17.1 // indirect + go.uber.org/multierr v1.10.0 // indirect + go.uber.org/zap v1.26.0 // indirect + golang.org/x/sys v0.0.0-20220412211240-33da011f77ad // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..441efe4 --- /dev/null +++ b/go.sum @@ -0,0 +1,28 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lmittmann/tint v1.0.4 h1:LeYihpJ9hyGvE0w+K2okPTGUdVLfng1+nDNVR4vWISc= +github.com/lmittmann/tint v1.0.4/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +go.uber.org/dig v1.17.1 h1:Tga8Lz8PcYNsWsyHMZ1Vm0OQOUaJNDyvPImgbAu9YSc= +go.uber.org/dig v1.17.1/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +go.uber.org/fx v1.21.1 h1:RqBh3cYdzZS0uqwVeEjOX2p73dddLpym315myy/Bpb0= +go.uber.org/fx v1.21.1/go.mod h1:HT2M7d7RHo+ebKGh9NRcrsrHHfpZ60nW3QRubMRfv48= +go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= +go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad h1:ntjMns5wyP/fN65tdBD4g8J5w8n015+iIIs9rtjXkY0= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/ircconn/conn.go b/pkg/ircconn/conn.go new file mode 100644 index 0000000..422af46 --- /dev/null +++ b/pkg/ircconn/conn.go @@ -0,0 +1,69 @@ +package ircconn + +import ( + "bufio" + "context" + "io" + "log/slog" + "sync" + + "github.com/valyala/bytebufferpool" + "tuxpa.in/a/irc/pkg/ircdecoder" + "tuxpa.in/a/irc/pkg/ircv3" +) + +type Conn struct { + w io.Writer + r io.Reader + log *slog.Logger + muWrite sync.Mutex +} + +func New(log *slog.Logger, w io.Writer, r io.Reader) *Conn { + return &Conn{ + log: log, + w: w, + r: r, + } +} + +// while serve is running, the conn owns the reader. +func (c *Conn) Serve(ctx context.Context, h ircv3.Handler) error { + // once serve is called, we call with an empty message. + h.Handle(ctx, c, &ircv3.Message{}) + dec := &ircdecoder.Decoder{} + r := c.r + r = bufio.NewReaderSize(c.r, 10240) + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + msg := &ircv3.Message{} + r = io.LimitReader(r, 8191+512) + err := dec.Decode(r, msg) + if err != nil { + return err + } + h.Handle(ctx, c, msg) + } +} + +func (c *Conn) WriteMessage(msg *ircv3.Message) error { + b := bytebufferpool.Get() + defer bytebufferpool.Put(b) + err := msg.Encode(b) + if err != nil { + return err + } + b.WriteString("\r\n") + c.muWrite.Lock() + defer c.muWrite.Unlock() + c.log.Info("out >", "msg", msg.String()) + _, err = b.WriteTo(c.w) + if err != nil { + return err + } + return nil +} diff --git a/pkg/ircdecoder/decoder.go b/pkg/ircdecoder/decoder.go new file mode 100644 index 0000000..c43249a --- /dev/null +++ b/pkg/ircdecoder/decoder.go @@ -0,0 +1,197 @@ +package ircdecoder + +import ( + "errors" + "fmt" + "io" + "strings" + "unicode/utf8" + + "tuxpa.in/a/irc/pkg/ircv3" +) + +type Decoder struct { +} + +func (d *Decoder) readByte(r io.Reader) (byte, error) { + var o [1]byte + if c, ok := r.(io.ByteReader); ok { + return c.ReadByte() + } + _, err := io.ReadFull(r, o[:]) + if err != nil { + return 0, err + } + return o[0], nil +} + +// read a message from the stream +func (d *Decoder) Decode(r io.Reader, msg *ircv3.Message) error { + return d.decode(r, msg) +} + +func (d *Decoder) decodeTags(r io.Reader, msg *ircv3.Message) error { + // we assume we have already read the @ + if msg.Tags == nil { + msg.Tags = make(ircv3.Tags) + } + + kb := new(strings.Builder) + vb := new(strings.Builder) + readingValue := false + for { + // keep reading until space + b, err := d.readByte(r) + if err != nil { + return err + } + if b == ';' { + msg.Tags.Set(kb.String(), vb.String()) + readingValue = false + continue + } else if b == '=' { + readingValue = true + continue + } + if b == 0x20 { + kstr := kb.String() + if !utf8.ValidString(kstr) { + return fmt.Errorf("non utf-8 tag key") + } + msg.Tags.Set(kstr, ircv3.UnescapeTagValue(vb.String())) + readingValue = false + break + } + if readingValue { + vb.WriteByte(b) + } else { + // TODO: technically we should check the validity of key bytes, not scan for utf8 at the end + // ::= + kb.WriteByte(b) + } + } + return nil +} + +func (d *Decoder) decodeSource(r io.Reader, msg *ircv3.Message) error { + // we assume we have already read the : + buf := new(strings.Builder) + for { + // keep reading until space + b, err := d.readByte(r) + if err != nil { + return err + } + if b == 0x20 { + break + } + buf.WriteByte(b) + } + nuh, err := ircv3.ParseNUH(buf.String()) + if err != nil { + return err + } + msg.Source = &nuh + return nil +} + +// read a message from the stream +func (d *Decoder) decode(r io.Reader, msg *ircv3.Message) error { + b, err := d.readByte(r) + if err != nil { + return err + } + + switch b { + case '@': + if err := d.decodeTags(r, msg); err != nil { + return err + } + b, err = d.readByte(r) + if err != nil { + return err + } + if b == ':' { + if err := d.decodeSource(r, msg); err != nil { + return err + } + b, err = d.readByte(r) + if err != nil { + return err + } + } + case ':': + if err := d.decodeSource(r, msg); err != nil { + return err + } + b, err = d.readByte(r) + if err != nil { + return err + } + default: + } + cb := new(strings.Builder) + // at this point we've no matter waht read the first byte of the command in b + cb.WriteByte(b) + // add a limit reader for the irc size limit + r = io.LimitReader(r, 511) + // read until first space + for { + b, err := d.readByte(r) + if err != nil { + if errors.Is(err, io.EOF) { + msg.Command = cb.String() + return nil + } + return err + } + if b == 0x20 { + break + } + cb.WriteByte(b) + } + msg.Command = cb.String() + cb.Reset() + // now read the params + + var trailing bool + var lastCr bool + for { + b, err := d.readByte(r) + if err != nil { + if errors.Is(err, io.EOF) { + if cb.Len() > 0 { + msg.Params = append(msg.Params, cb.String()) + } + return nil + } + return err + } + if cb.Len() == 0 { + if b == ':' { + trailing = true + continue + } + } + if !trailing { + if b == 0x20 { + msg.Params = append(msg.Params, cb.String()) + cb.Reset() + continue + } + } + if b == '\r' { + lastCr = true + continue + } + if lastCr { + if b == '\n' { + msg.Params = append(msg.Params, cb.String()) + return nil + } else { + cb.WriteByte('\r') + } + } + cb.WriteByte(b) + } +} diff --git a/pkg/ircdecoder/decoder_test.go b/pkg/ircdecoder/decoder_test.go new file mode 100644 index 0000000..da6a305 --- /dev/null +++ b/pkg/ircdecoder/decoder_test.go @@ -0,0 +1,23 @@ +package ircdecoder_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + "tuxpa.in/a/irc/pkg/ircdecoder" + "tuxpa.in/a/irc/pkg/ircv3" +) + +func TestDecoder(t *testing.T) { + dec := ircdecoder.Decoder{} + + buf := strings.NewReader("@some :source HELLO") + msg := new(ircv3.Message) + err := dec.Decode(buf, msg) + + require.NoError(t, err) + + require.EqualValues(t, "HELLO", msg.Command) + require.EqualValues(t, "", msg.Tags["some"]) +} diff --git a/pkg/ircmw/cap.go b/pkg/ircmw/cap.go new file mode 100644 index 0000000..18fbe41 --- /dev/null +++ b/pkg/ircmw/cap.go @@ -0,0 +1,83 @@ +package ircmw + +import ( + "context" + "strings" + "time" + + "tuxpa.in/a/irc/pkg/ircv3" +) + +type Capabilities struct { + pending int +} + +var capabilitiesKey struct{} + +func AddPending(ctx context.Context, i int) { + val, ok := ctx.Value(capabilitiesKey).(*Capabilities) + if !ok { + return + } + val.pending += i +} + +func (c *Capabilities) Middleware(next ircv3.Handler) ircv3.Handler { + return ircv3.HandlerFunc(func(ctx context.Context, w ircv3.MessageWriter, m *ircv3.Message) { + ctx = context.WithValue(ctx, capabilitiesKey, c) + if m.Command == "" { + c.pending++ + w.WriteMessage(ircv3.NewMessage("CAP", "LS", "302")) + } + next.Handle(ctx, w, m) + if m.Command == "CAP" && m.Param(0) == "*" && m.Param(1) == "LS" { + c.pending-- + } + if c.pending == 0 { + c.pending = -1 + w.WriteMessage(ircv3.NewMessage("CAP", "END")) + } + }) +} + +func CapabilityExchange(next ircv3.Handler) ircv3.Handler { + return ircv3.HandlerFunc(func(ctx context.Context, w ircv3.MessageWriter, m *ircv3.Message) { + next.Handle(ctx, w, m) + }) +} + +var keyServerTime struct{} + +func ServerTime(ctx context.Context) time.Time { + val, ok := ctx.Value(keyServerTime).(time.Time) + if !ok { + return time.Time{} + } + return val +} + +func CapabilityServerTime(next ircv3.Handler) ircv3.Handler { + enabled := false + return ircv3.HandlerFunc(func(ctx context.Context, w ircv3.MessageWriter, m *ircv3.Message) { + if m.Command == "CAP" && m.Param(0) == "*" && m.Param(1) == "LS" { + if strings.Contains(m.Param(2), "server-time") { + AddPending(ctx, 1) + w.WriteMessage(ircv3.NewMessage("CAP", "REQ", "server-time")) + } + } + if m.Command == "CAP" && m.Param(1) == "ACK" && m.Param(2) == "server-time" { + AddPending(ctx, -1) + enabled = true + } + if enabled { + tString := m.Tags.Get("time") + if tString != "" { + parsedTime, err := time.Parse("2006-01-02T15:04:05.000Z", tString) + if err == nil { + ctx = context.WithValue(ctx, keyServerTime, parsedTime) + } + } + } + next.Handle(ctx, w, m) + }) +} diff --git a/pkg/ircv3/handler.go b/pkg/ircv3/handler.go new file mode 100644 index 0000000..e4455f7 --- /dev/null +++ b/pkg/ircv3/handler.go @@ -0,0 +1,68 @@ +package ircv3 + +import ( + "context" +) + +type Handler interface { + Handle(ctx context.Context, w MessageWriter, m *Message) +} +type HandlerFunc func(ctx context.Context, w MessageWriter, m *Message) + +func (h HandlerFunc) Handle(ctx context.Context, w MessageWriter, m *Message) { + h(ctx, w, m) +} + +type MessageWriter interface { + WriteMessage(msg *Message) error +} + +type Middleware func(next Handler) Handler + +type Middlewares []func(Handler) Handler + +// Chain returns a Middlewares type from a slice of middleware handlers. +func Chain(middlewares ...func(Handler) Handler) Middlewares { + return Middlewares(middlewares) +} + +// Handler builds and returns a http.Handler from the chain of middlewares, +// with `h http.Handler` as the final handler. +func (mws Middlewares) Handler(h Handler) Handler { + return &ChainHandler{h, chain(mws, h), mws} +} + +// HandlerFunc builds and returns a http.Handler from the chain of middlewares, +// with `h http.Handler` as the final handler. +func (mws Middlewares) HandlerFunc(h HandlerFunc) Handler { + return &ChainHandler{h, chain(mws, h), mws} +} + +// ChainHandler is a http.Handler with support for handler composition and +// execution. +type ChainHandler struct { + Endpoint Handler + chain Handler + Middlewares Middlewares +} + +func (c *ChainHandler) Handle(ctx context.Context, w MessageWriter, m *Message) { + c.chain.Handle(ctx, w, m) +} + +// chain builds a http.Handler composed of an inline middleware stack and endpoint +// handler in the order they are passed. +func chain(middlewares []func(Handler) Handler, endpoint Handler) Handler { + // Return ahead of time if there aren't any middlewares for the chain + if len(middlewares) == 0 { + return endpoint + } + + // Wrap the end handler with the middleware chain + h := middlewares[len(middlewares)-1](endpoint) + for i := len(middlewares) - 2; i >= 0; i-- { + h = middlewares[i](h) + } + + return h +} diff --git a/pkg/ircv3/message.go b/pkg/ircv3/message.go new file mode 100644 index 0000000..fc600ec --- /dev/null +++ b/pkg/ircv3/message.go @@ -0,0 +1,133 @@ +package ircv3 + +import ( + "errors" + "io" + "strings" +) + +type Message struct { + Source *NUH + Command string + Params []string + Tags Tags +} + +func NewMessage(command string, params ...string) *Message { + return &Message{ + Source: nil, + Command: command, + Params: params, + Tags: make(Tags), + } +} +func (m *Message) SetSource(nuh *NUH) *Message { + m.Source = nuh + return m +} +func (m *Message) Param(i int) string { + if len(m.Params) > i { + return m.Params[i] + } + return "" +} +func (msg *Message) String() string { + sb := new(strings.Builder) + msg.Encode(sb) + return sb.String() +} + +func (msg *Message) Encode(w io.Writer) error { + if msg.Command == "" { + return nil + } + if msg.Tags != nil && len(msg.Tags) > 0 { + _, err := msg.Tags.WriteTo(w) + if err != nil { + return err + } + _, err = w.Write([]byte(" ")) + if err != nil { + return err + } + } + if msg.Source != nil { + if _, err := w.Write([]byte(msg.Source.String())); err != nil { + return err + } + if _, err := w.Write([]byte(" ")); err != nil { + return err + } + } + // now write command + if _, err := w.Write([]byte(msg.Command)); err != nil { + return err + } + if _, err := w.Write([]byte(" ")); err != nil { + return err + } + for idx, v := range msg.Params { + if idx != 0 { + if _, err := w.Write([]byte(" ")); err != nil { + return err + } + } + // now write any params + if len(msg.Params)-1 == idx && strings.Contains(v, " ") { + if _, err := w.Write([]byte(":")); err != nil { + return err + } + } + if _, err := w.Write([]byte(v)); err != nil { + return err + } + } + return nil +} + +type NUH struct { + Name string + User string + Host string +} + +var ( + MalformedNUH = errors.New("NUH is malformed") +) + +// ParseNUH parses a NUH source of an IRC message into its constituent parts; +// name (nickname or server name), username, and hostname. +func ParseNUH(in string) (out NUH, err error) { + if len(in) == 0 { + return out, MalformedNUH + } + hostStart := strings.IndexByte(in, '@') + if hostStart != -1 { + out.Host = in[hostStart+1:] + in = in[:hostStart] + } + userStart := strings.IndexByte(in, '!') + if userStart != -1 { + out.User = in[userStart+1:] + in = in[:userStart] + } + out.Name = in + + return +} + +// Canonical returns the canonical string representation of the NUH. +func (nuh *NUH) String() (result string) { + var out strings.Builder + out.Grow(len(nuh.Name) + len(nuh.User) + len(nuh.Host) + 2) + out.WriteString(nuh.Name) + if len(nuh.User) != 0 { + out.WriteByte('!') + out.WriteString(nuh.User) + } + if len(nuh.Host) != 0 { + out.WriteByte('@') + out.WriteString(nuh.Host) + } + return out.String() +} diff --git a/pkg/ircv3/tag_validation.go b/pkg/ircv3/tag_validation.go new file mode 100644 index 0000000..e96a4a3 --- /dev/null +++ b/pkg/ircv3/tag_validation.go @@ -0,0 +1,94 @@ +package ircv3 + +import ( + "strings" + "unicode/utf8" +) + +// taken from https://github.com/ergochat/irc-go/blob/master/ircmsg/tags.go + +var ( + // valtoescape replaces real characters with message tag escapes. + valtoescape = strings.NewReplacer("\\", "\\\\", ";", "\\:", " ", "\\s", "\r", "\\r", "\n", "\\n") + + escapedCharLookupTable [256]byte +) + +func init() { + for i := 0; i < 256; i += 1 { + escapedCharLookupTable[i] = byte(i) + } + escapedCharLookupTable[':'] = ';' + escapedCharLookupTable['s'] = ' ' + escapedCharLookupTable['r'] = '\r' + escapedCharLookupTable['n'] = '\n' +} + +// EscapeTagValue takes a value, and returns an escaped message tag value. +func EscapeTagValue(inString string) string { + return valtoescape.Replace(inString) +} + +// UnescapeTagValue takes an escaped message tag value, and returns the raw value. +func UnescapeTagValue(inString string) string { + // buf.Len() == 0 is the fastpath where we have not needed to unescape any chars + var buf strings.Builder + remainder := inString + for { + backslashPos := strings.IndexByte(remainder, '\\') + + if backslashPos == -1 { + if buf.Len() == 0 { + return inString + } else { + buf.WriteString(remainder) + break + } + } else if backslashPos == len(remainder)-1 { + // trailing backslash, which we strip + if buf.Len() == 0 { + return inString[:len(inString)-1] + } else { + buf.WriteString(remainder[:len(remainder)-1]) + break + } + } + + // non-trailing backslash detected; we're now on the slowpath + // where we modify the string + if buf.Len() == 0 { + buf.Grow(len(inString)) // just an optimization + } + buf.WriteString(remainder[:backslashPos]) + buf.WriteByte(escapedCharLookupTable[remainder[backslashPos+1]]) + remainder = remainder[backslashPos+2:] + } + + return buf.String() +} + +// https://ircv3.net/specs/extensions/message-tags.html#rules-for-naming-message-tags +func validateTagName(name string) bool { + if len(name) == 0 { + return false + } + if name[0] == '+' { + name = name[1:] + } + if len(name) == 0 { + return false + } + // let's err on the side of leniency here; allow -./ (45-47) in any position + for i := 0; i < len(name); i++ { + c := name[i] + if !(('-' <= c && c <= '/') || ('0' <= c && c <= '9') || ('A' <= c && c <= 'Z') || ('a' <= c && c <= 'z')) { + return false + } + } + return true +} + +// "Tag values MUST be encoded as UTF8." +func ValidateTagValue(value string) bool { + return utf8.ValidString(value) +} diff --git a/pkg/ircv3/tags.go b/pkg/ircv3/tags.go new file mode 100644 index 0000000..7fe5099 --- /dev/null +++ b/pkg/ircv3/tags.go @@ -0,0 +1,76 @@ +package ircv3 + +import ( + "io" + "strings" +) + +// tags are stored as strings +type Tags map[string]string + +func (h Tags) Set(key, value string) { + h[key] = value +} + +func (h Tags) Get(key string) string { + val, ok := h[key] + if !ok { + return "" + } + return val +} + +func (h Tags) Keys() []string { + o := make([]string, 0, len(h)) + for k := range h { + o = append(o, k) + } + return o +} +func (h Tags) ClientOnlyKeys() []string { + o := make([]string, 0) + for k := range h { + if IsClientOnly(k) { + o = append(o, k) + } + } + return o +} + +func IsClientOnly(xs string) bool { + return strings.HasPrefix(xs, "+") +} + +func (h Tags) WriteTo(w io.Writer) (int64, error) { + var nn int64 + n, err := w.Write([]byte{'@'}) + nn += int64(n) + if err != nil { + return nn, err + } + firstTag := true + for k, v := range h { + if !firstTag { + n, err = w.Write([]byte(";")) + nn += int64(n) + if err != nil { + return nn, err + } + } + firstTag = false + n, err := w.Write([]byte(k)) + nn += int64(n) + if err != nil { + return nn, err + } + if len(v) > 0 { + n, err = w.Write([]byte("=" + EscapeTagValue(v))) + nn += int64(n) + if err != nil { + return nn, err + } + } + } + return nn, nil + +} diff --git a/plugins/auth/auth.go b/plugins/auth/auth.go new file mode 100644 index 0000000..656fad3 --- /dev/null +++ b/plugins/auth/auth.go @@ -0,0 +1,67 @@ +package auth + +import ( + "context" + "encoding/base64" + + "tuxpa.in/a/irc/pkg/ircmw" + "tuxpa.in/a/irc/pkg/ircv3" +) + +type SaslPlain struct { + Username string + Password string +} + +func (saslplain *SaslPlain) Middleware(next ircv3.Handler) ircv3.Handler { + return ircv3.HandlerFunc(func(ctx context.Context, w ircv3.MessageWriter, m *ircv3.Message) { + if m.Command == "" { + ircmw.AddPending(ctx, 1) + w.WriteMessage(ircv3.NewMessage("CAP", "REQ", "sasl")) + } + if m.Command == "CAP" && m.Param(0) == "*" && m.Param(1) == "ACK" && m.Param(2) == "sasl" { + w.WriteMessage(ircv3.NewMessage("AUTHENTICATE", "PLAIN")) + } + if m.Command == "AUTHENTICATE" && m.Param(0) == "+" { + w.WriteMessage(ircv3.NewMessage("AUTHENTICATE", base64.StdEncoding.EncodeToString([]byte( + saslplain.Username+string([]byte{0})+ + saslplain.Username+string([]byte{0})+ + saslplain.Password, + )))) + } + switch m.Command { + case "903", "904", "905", "906", "907": + ircmw.AddPending(ctx, -1) + } + next.Handle(ctx, w, m) + }) +} + +type User struct { + Username string + Realname string + Hostname string + Server string +} + +func (u *User) Middleware(next ircv3.Handler) ircv3.Handler { + return ircv3.HandlerFunc(func(ctx context.Context, w ircv3.MessageWriter, m *ircv3.Message) { + if m.Command == "" { + w.WriteMessage(ircv3.NewMessage("USER", u.Username, u.Hostname, u.Server, u.Realname)) + } + next.Handle(ctx, w, m) + }) +} + +type Nick struct { + Nick string +} + +func (u *Nick) Middleware(next ircv3.Handler) ircv3.Handler { + return ircv3.HandlerFunc(func(ctx context.Context, w ircv3.MessageWriter, m *ircv3.Message) { + if m.Command == "" { + w.WriteMessage(ircv3.NewMessage("NICK", u.Nick)) + } + next.Handle(ctx, w, m) + }) +} diff --git a/plugins/useful/channels.go b/plugins/useful/channels.go new file mode 100644 index 0000000..fb248ce --- /dev/null +++ b/plugins/useful/channels.go @@ -0,0 +1,21 @@ +package useful + +import ( + "context" + "strings" + + "tuxpa.in/a/irc/pkg/ircv3" +) + +type Autojoin struct { + Channels []string +} + +func (u *Autojoin) Middleware(next ircv3.Handler) ircv3.Handler { + return ircv3.HandlerFunc(func(ctx context.Context, w ircv3.MessageWriter, m *ircv3.Message) { + if m.Command == "005" { + w.WriteMessage(ircv3.NewMessage("JOIN", strings.Join(u.Channels, ","))) + } + next.Handle(ctx, w, m) + }) +}