aboutsummaryrefslogtreecommitdiff
path: root/internal/server
diff options
context:
space:
mode:
author2025-12-30 19:33:39 +0300
committer2025-12-30 19:33:39 +0300
commitd317e8f6df0e0e16445db606da1d683a6b35f531 (patch)
tree4b80de04e17a137cff2dc309508b5f841f48c994 /internal/server
downloadqchat-d317e8f6df0e0e16445db606da1d683a6b35f531.tar.gz
qchat-d317e8f6df0e0e16445db606da1d683a6b35f531.tar.bz2
qchat-d317e8f6df0e0e16445db606da1d683a6b35f531.tar.xz
qchat-d317e8f6df0e0e16445db606da1d683a6b35f531.zip
начальный коммит
Diffstat (limited to 'internal/server')
-rw-r--r--internal/server/client.go58
-rw-r--r--internal/server/conn.go67
-rw-r--r--internal/server/display.go83
-rw-r--r--internal/server/escapes.go49
-rw-r--r--internal/server/server.go70
5 files changed, 327 insertions, 0 deletions
diff --git a/internal/server/client.go b/internal/server/client.go
new file mode 100644
index 0000000..dbea5a9
--- /dev/null
+++ b/internal/server/client.go
@@ -0,0 +1,58 @@
+package server
+
+import (
+ "context"
+ "io"
+ "log/slog"
+
+ "go.neonxp.ru/qchat/internal/chat"
+ "golang.org/x/term"
+)
+
+func (s *Server) serveClient(ctx context.Context, rw io.ReadWriteCloser, user *chat.User) {
+ t := term.NewTerminal(rw, "[] ")
+
+ go func() {
+ // defer s.bus.Unsubscribe(evCh)
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ }
+ if user.CurrentChan != nil {
+ t.SetPrompt("[" + user.CurrentChan.Name + "] ")
+ }
+ line, err := t.ReadLine()
+ if err != nil {
+ s.chat.RemoveUser(user)
+
+ if err != io.EOF {
+ slog.Error("failed read line", slog.Any("err", err))
+ }
+
+ return
+ }
+ if len(line) == 0 {
+ continue
+ }
+
+ s.chat.Input(ctx, user, line)
+ }
+ }()
+
+ for message := range user.Events {
+ switch message := message.(type) {
+ case chat.Message:
+ displayMessage(t, message, user)
+ case chat.SelfMessage:
+ displaySelfMessage(t, message)
+ case chat.SystemMessage:
+ displaySystemMessage(t, message)
+ case chat.UserJoined:
+ displayUserJoined(t, message)
+ case chat.UserLeft:
+ displayUserLeft(t, message)
+ }
+ }
+}
diff --git a/internal/server/conn.go b/internal/server/conn.go
new file mode 100644
index 0000000..1069b0b
--- /dev/null
+++ b/internal/server/conn.go
@@ -0,0 +1,67 @@
+package server
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "net"
+ "sync"
+
+ "golang.org/x/crypto/ssh"
+)
+
+func (s *Server) serveConn(ctx context.Context, nConn net.Conn, config *ssh.ServerConfig) error {
+ conn, chans, reqs, err := ssh.NewServerConn(nConn, config)
+ if err != nil {
+ return fmt.Errorf("failed to handshake: %w", err)
+ }
+ slog.Info("user connected", slog.Any("user", conn.User()), slog.String("ip", conn.RemoteAddr().String()))
+
+ var wg sync.WaitGroup
+ defer wg.Wait()
+
+ wg.Go(func() {
+ ssh.DiscardRequests(reqs)
+ })
+
+ for newChannel := range chans {
+ if newChannel.ChannelType() != "session" {
+ newChannel.Reject(ssh.UnknownChannelType, "unknown channel type")
+ continue
+ }
+ channel, requests, err := newChannel.Accept()
+ if err != nil {
+ return fmt.Errorf("could not accept channel: %w", err)
+ }
+
+ wg.Go(func() {
+ for req := range requests {
+ switch req.Type {
+ case "pty-req":
+ req.Reply(true, nil)
+ case "shell":
+ req.Reply(true, nil)
+ default:
+ req.Reply(false, nil)
+ }
+ slog.Debug(
+ "req",
+ slog.String("type", req.Type),
+ slog.Bool("want-reply", req.WantReply),
+ slog.String("payload", string(req.Payload)),
+ )
+ }
+ })
+
+ wg.Go(func() {
+ identify := conn.Permissions.ExtraData["identify"].(string)
+ user := s.chat.NewUser(conn.User(), identify)
+ slog.Info("joined", slog.String("user", user.NUsername()))
+ s.serveClient(ctx, channel, user)
+ slog.Info("disconnected", slog.String("user", user.NUsername()))
+ conn.Close()
+ })
+ }
+
+ return nil
+}
diff --git a/internal/server/display.go b/internal/server/display.go
new file mode 100644
index 0000000..90d481a
--- /dev/null
+++ b/internal/server/display.go
@@ -0,0 +1,83 @@
+package server
+
+import (
+ "fmt"
+ "io"
+ "regexp"
+ "strings"
+
+ "go.neonxp.ru/qchat/internal/chat"
+)
+
+var replacements = []replacement{
+ {
+ From: *regexp.MustCompile(`\*(.+?)\*`),
+ To: fmt.Sprintf("%s$1%s", escapeCodes[Bold], escapeCodes[Reset]),
+ },
+ {
+ From: *regexp.MustCompile(`_(.+?)_`),
+ To: fmt.Sprintf("%s$1%s", escapeCodes[Italic], escapeCodes[Reset]),
+ },
+}
+
+func displayMessage(t io.Writer, message chat.Message, user *chat.User) {
+ msg := message.Message
+ mentionFrom := "@" + user.Username
+ mentionTo := string(escapeCodes[Blink]) + string(escapeCodes[Underline]) + "@" + user.Username + string(escapeCodes[Reset])
+ if strings.Contains(msg, mentionFrom) {
+ msg = strings.ReplaceAll(msg, mentionFrom, mentionTo)
+ msg = "\x07\a" + msg
+ }
+
+ for _, r := range replacements {
+ msg = r.From.ReplaceAllString(msg, r.To)
+ }
+
+ fmt.Fprintf(t, "%s%s%s\t[%s]%s: %s\n",
+ escapeCodes[Blue],
+ message.User.NUsername(),
+ escapeCodes[Green],
+ message.Time.Format("15:04:05"),
+ escapeCodes[Reset],
+ msg,
+ )
+}
+
+func displaySystemMessage(t io.Writer, message chat.SystemMessage) {
+ fmt.Fprintf(t, "%s* %s %s\n",
+ escapeCodes[Green],
+ message.Message,
+ escapeCodes[Reset],
+ )
+}
+func displaySelfMessage(t io.Writer, message chat.SelfMessage) {
+ fmt.Fprintf(t, "%s* %s %s%s\n",
+ escapeCodes[Blue],
+ message.User.NUsername(),
+ message.Message,
+ escapeCodes[Reset],
+ )
+}
+
+func displayUserJoined(t io.Writer, presence chat.UserJoined) {
+ fmt.Fprintf(t, "%s* %s joined to %s chan%s\n",
+ escapeCodes[Green],
+ presence.User.NUsername(),
+ presence.Chan.Name,
+ escapeCodes[Reset],
+ )
+}
+
+func displayUserLeft(t io.Writer, presence chat.UserLeft) {
+ fmt.Fprintf(t, "%s* %s left %s chan%s\n",
+ escapeCodes[Red],
+ presence.User.NUsername(),
+ presence.Chan.Name,
+ escapeCodes[Reset],
+ )
+}
+
+type replacement struct {
+ From regexp.Regexp
+ To string
+}
diff --git a/internal/server/escapes.go b/internal/server/escapes.go
new file mode 100644
index 0000000..f495345
--- /dev/null
+++ b/internal/server/escapes.go
@@ -0,0 +1,49 @@
+package server
+
+const (
+ keyEscape = 27
+)
+
+var escapeCodes = map[color][]byte{
+ Black: {keyEscape, '[', '3', '0', 'm'},
+ Red: {keyEscape, '[', '3', '1', 'm'},
+ Green: {keyEscape, '[', '3', '2', 'm'},
+ Yellow: {keyEscape, '[', '3', '3', 'm'},
+ Blue: {keyEscape, '[', '3', '4', 'm'},
+ Magenta: {keyEscape, '[', '3', '5', 'm'},
+ Cyan: {keyEscape, '[', '3', '6', 'm'},
+ White: {keyEscape, '[', '3', '7', 'm'},
+ Reset: {keyEscape, '[', '0', 'm'},
+ Bold: {keyEscape, '[', '1', 'm'},
+ Faint: {keyEscape, '[', '2', 'm'},
+ Italic: {keyEscape, '[', '3', 'm'},
+ Underline: {keyEscape, '[', '4', 'm'},
+ Blink: {keyEscape, '[', '5', 'm'},
+ Rapid: {keyEscape, '[', '6', 'm'},
+ Reverse: {keyEscape, '[', '7', 'm'},
+ Conceal: {keyEscape, '[', '8', 'm'},
+ Strike: {keyEscape, '[', '9', 'm'},
+}
+
+type color int
+
+const (
+ Black color = iota
+ Red
+ Green
+ Yellow
+ Blue
+ Magenta
+ Cyan
+ White
+ Reset
+ Bold
+ Faint
+ Italic
+ Underline
+ Blink
+ Rapid
+ Reverse
+ Conceal
+ Strike
+)
diff --git a/internal/server/server.go b/internal/server/server.go
new file mode 100644
index 0000000..1c28cd5
--- /dev/null
+++ b/internal/server/server.go
@@ -0,0 +1,70 @@
+package server
+
+import (
+ "context"
+ "encoding/hex"
+ "fmt"
+ "log/slog"
+ "net"
+ "sync"
+
+ "go.neonxp.ru/qchat/internal/chat"
+ "go.neonxp.ru/qchat/internal/config"
+ "golang.org/x/crypto/ssh"
+)
+
+type Server struct {
+ cfg *config.Config
+ chat *chat.Chat
+}
+
+func New(cfg *config.Config, ch *chat.Chat) *Server {
+ return &Server{
+ cfg: cfg,
+ chat: ch,
+ }
+}
+
+func (s *Server) Run(ctx context.Context) error {
+ config := &ssh.ServerConfig{
+ PublicKeyCallback: func(c ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) {
+ return &ssh.Permissions{
+ ExtraData: map[any]any{
+ "identify": hex.EncodeToString(pubKey.Marshal()),
+ },
+ }, nil
+ },
+ }
+
+ private, err := ssh.ParsePrivateKey([]byte(s.cfg.Server.PrivateKey))
+ if err != nil {
+ return fmt.Errorf("failed to parse private key: %w", err)
+ }
+ config.AddHostKey(private)
+ listener, err := net.Listen("tcp", s.cfg.Server.Addr)
+ if err != nil {
+ return fmt.Errorf("failed to parse private key: %w", err)
+ }
+ go func() {
+ <-ctx.Done()
+ listener.Close()
+ }()
+
+ wg := sync.WaitGroup{}
+ defer wg.Wait()
+
+ slog.InfoContext(ctx, "started server at", slog.String("addr", s.cfg.Server.Addr))
+
+ for {
+ nConn, err := listener.Accept()
+ if err != nil {
+ slog.ErrorContext(ctx, "failed to accept incoming connection", slog.Any("err", err))
+ continue
+ }
+ wg.Go(func() {
+ if err := s.serveConn(ctx, nConn, config); err != nil {
+ slog.ErrorContext(ctx, "connection error", slog.Any("err", err))
+ }
+ })
+ }
+}