aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd/mobius-hotline-server/main.go9
-rw-r--r--hotline/client_conn.go140
-rw-r--r--hotline/client_conn_test.go57
-rw-r--r--hotline/server.go103
-rw-r--r--hotline/server_test.go42
-rw-r--r--internal/mobius/api.go4
-rw-r--r--internal/mobius/handlers_accounts.go10
-rw-r--r--internal/mobius/handlers_chat.go57
-rw-r--r--internal/mobius/handlers_news.go4
-rw-r--r--internal/mobius/handlers_session.go63
-rw-r--r--internal/mobius/handlers_transfers.go2
-rw-r--r--internal/mobius/handlers_transfers_test.go10
12 files changed, 368 insertions, 133 deletions
diff --git a/cmd/mobius-hotline-server/main.go b/cmd/mobius-hotline-server/main.go
index 1c298a2..117cb2a 100644
--- a/cmd/mobius-hotline-server/main.go
+++ b/cmd/mobius-hotline-server/main.go
@@ -159,11 +159,12 @@ func main() {
}
bannerPath := path.Join(*configDir, config.BannerFile)
- srv.Banner, err = os.ReadFile(bannerPath)
+ banner, err := os.ReadFile(bannerPath)
if err != nil {
slogger.Error("Error loading banner", "err", err)
os.Exit(1)
}
+ srv.SetBanner(banner)
reloadFunc := func() {
if err := srv.MessageBoard.(*mobius.FlatNews).Reload(); err != nil {
@@ -185,11 +186,13 @@ func main() {
slogger.Error("Error reloading agreement", "err", err)
}
- // Let's try to reload the banner
+ // Let's try to reload the banner. On failure, keep serving the previous banner.
bannerPath := path.Join(*configDir, config.BannerFile)
- srv.Banner, err = os.ReadFile(bannerPath)
+ banner, err := os.ReadFile(bannerPath)
if err != nil {
slogger.Error("Error reloading banner", "err", err)
+ } else {
+ srv.SetBanner(banner)
}
}
diff --git a/hotline/client_conn.go b/hotline/client_conn.go
index 363376e..3419e0a 100644
--- a/hotline/client_conn.go
+++ b/hotline/client_conn.go
@@ -30,23 +30,26 @@ type ClientConn struct {
Connection io.ReadWriteCloser
RemoteAddr string
ID ClientID
- Icon []byte // TODO: make fixed size of 2
Version []byte // TODO: make fixed size of 2
- FlagsMU sync.Mutex // TODO: move into UserFlags struct
- Flags UserFlags
+ Account *Account
+ Server *Server // TODO: consider adding methods to interact with server
+ // The following fields hold mutable session state guarded by mu. They are read and written
+ // by multiple goroutines (the client's own transaction loop, other clients' handlers, and the
+ // server keepalive loop), so production code must use the accessor methods below. Direct
+ // field access is only safe before the connection is shared, e.g. in tests.
+ Flags UserFlags
UserName []byte
- Account *Account
+ Icon []byte // TODO: make fixed size of 2
IdleTime int
- Server *Server // TODO: consider adding methods to interact with server
AutoReply []byte
ClientFileTransferMgr ClientFileTransferMgr
Logger *slog.Logger
- mu sync.RWMutex
+ mu sync.RWMutex // guards the mutable session state fields above
sendCh chan Transaction
sendInit sync.Once
@@ -120,6 +123,109 @@ func (cc *ClientConn) writeLoop() {
func (cc *ClientConn) TextDecoder() *encoding.Decoder { return cc.Server.TextDecoder }
func (cc *ClientConn) TextEncoder() *encoding.Encoder { return cc.Server.TextEncoder }
+// SetFlag sets the user flag at position flag to v.
+func (cc *ClientConn) SetFlag(flag int, v uint) {
+ cc.mu.Lock()
+ defer cc.mu.Unlock()
+
+ cc.Flags.Set(flag, v)
+}
+
+// IsFlagSet reports whether the user flag at position flag is set.
+func (cc *ClientConn) IsFlagSet(flag int) bool {
+ cc.mu.RLock()
+ defer cc.mu.RUnlock()
+
+ return cc.Flags.IsSet(flag)
+}
+
+// FlagBytes returns a copy of the user flags bitmap, suitable for use as a transaction field.
+func (cc *ClientConn) FlagBytes() []byte {
+ cc.mu.RLock()
+ defer cc.mu.RUnlock()
+
+ flags := cc.Flags
+ return flags[:]
+}
+
+// SetUserName sets the client's display name.
+func (cc *ClientConn) SetUserName(name []byte) {
+ cc.mu.Lock()
+ defer cc.mu.Unlock()
+
+ cc.UserName = name
+}
+
+// GetUserName returns the client's display name. Callers must not modify the returned slice.
+func (cc *ClientConn) GetUserName() []byte {
+ cc.mu.RLock()
+ defer cc.mu.RUnlock()
+
+ return cc.UserName
+}
+
+// SetIcon sets the client's icon ID bytes.
+func (cc *ClientConn) SetIcon(icon []byte) {
+ cc.mu.Lock()
+ defer cc.mu.Unlock()
+
+ cc.Icon = icon
+}
+
+// GetIcon returns the client's icon ID bytes. Callers must not modify the returned slice.
+func (cc *ClientConn) GetIcon() []byte {
+ cc.mu.RLock()
+ defer cc.mu.RUnlock()
+
+ return cc.Icon
+}
+
+// SetAutoReply sets the client's away auto-reply message.
+func (cc *ClientConn) SetAutoReply(msg []byte) {
+ cc.mu.Lock()
+ defer cc.mu.Unlock()
+
+ cc.AutoReply = msg
+}
+
+// GetAutoReply returns the client's away auto-reply message. Callers must not modify the
+// returned slice.
+func (cc *ClientConn) GetAutoReply() []byte {
+ cc.mu.RLock()
+ defer cc.mu.RUnlock()
+
+ return cc.AutoReply
+}
+
+// incrementIdleTime adds interval seconds to the client's idle time. It returns true if this
+// crossed the idle threshold and marked the client as away, in which case the caller should
+// notify other clients of the change.
+func (cc *ClientConn) incrementIdleTime(interval int) bool {
+ cc.mu.Lock()
+ defer cc.mu.Unlock()
+
+ cc.IdleTime += interval
+ if cc.IdleTime > userIdleSeconds && !cc.Flags.IsSet(UserFlagAway) {
+ cc.Flags.Set(UserFlagAway, 1)
+ return true
+ }
+ return false
+}
+
+// clearIdleAndAway resets the client's idle timer. It returns true if the client was marked as
+// away and is no longer, in which case the caller should notify other clients of the change.
+func (cc *ClientConn) clearIdleAndAway() bool {
+ cc.mu.Lock()
+ defer cc.mu.Unlock()
+
+ cc.IdleTime = 0
+ if cc.Flags.IsSet(UserFlagAway) {
+ cc.Flags.Set(UserFlagAway, 0)
+ return true
+ }
+ return false
+}
+
func (cc *ClientConn) FileRoot() string {
if cc.Account.FileRoot != "" {
return cc.Account.FileRoot
@@ -201,23 +307,15 @@ func (cc *ClientConn) handleTransaction(transaction Transaction) {
}
if transaction.Type != TranKeepAlive {
- cc.mu.Lock()
- defer cc.mu.Unlock()
-
- // reset the user idle timer
- cc.IdleTime = 0
-
- // if user was previously idle, mark as not idle and notify other connected clients that
- // the user is no longer away
- if cc.Flags.IsSet(UserFlagAway) {
- cc.Flags.Set(UserFlagAway, 0)
-
+ // Reset the user idle timer. If the user was previously marked as away, notify other
+ // connected clients that the user is no longer away.
+ if cc.clearIdleAndAway() {
cc.SendAll(
TranNotifyChangeUser,
NewField(FieldUserID, cc.ID[:]),
- NewField(FieldUserFlags, cc.Flags[:]),
- NewField(FieldUserName, cc.UserName),
- NewField(FieldUserIconID, cc.Icon),
+ NewField(FieldUserFlags, cc.FlagBytes()),
+ NewField(FieldUserName, cc.GetUserName()),
+ NewField(FieldUserIconID, cc.GetIcon()),
)
}
}
@@ -328,7 +426,7 @@ func formatDownloadList(fts []FileTransfer) (s string) {
func (cc *ClientConn) String() string {
template := fmt.Sprintf(
userInfoTemplate,
- cc.UserName,
+ cc.GetUserName(),
cc.Account.Name,
cc.Account.Login,
cc.RemoteAddr,
diff --git a/hotline/client_conn_test.go b/hotline/client_conn_test.go
index 5c5463d..3312954 100644
--- a/hotline/client_conn_test.go
+++ b/hotline/client_conn_test.go
@@ -660,3 +660,60 @@ func TestClientConn_SendDisconnectRace(t *testing.T) {
wg.Wait()
}
+
+func TestClientConn_incrementIdleTime(t *testing.T) {
+ cc := &ClientConn{}
+
+ // Increment until just below the idle threshold: not yet away.
+ for i := 0; i < userIdleSeconds/idleCheckInterval; i++ {
+ assert.False(t, cc.incrementIdleTime(idleCheckInterval))
+ }
+ assert.False(t, cc.IsFlagSet(UserFlagAway))
+
+ // The increment that crosses the threshold marks the client away exactly once.
+ assert.True(t, cc.incrementIdleTime(idleCheckInterval))
+ assert.True(t, cc.IsFlagSet(UserFlagAway))
+ assert.False(t, cc.incrementIdleTime(idleCheckInterval), "already-away client should not be marked away again")
+}
+
+func TestClientConn_clearIdleAndAway(t *testing.T) {
+ cc := &ClientConn{IdleTime: 500}
+
+ // Not away: idle timer resets, no notification needed.
+ assert.False(t, cc.clearIdleAndAway())
+ assert.Equal(t, 0, cc.IdleTime)
+
+ // Away: flag clears and the caller is told to notify.
+ cc.SetFlag(UserFlagAway, 1)
+ assert.True(t, cc.clearIdleAndAway())
+ assert.False(t, cc.IsFlagSet(UserFlagAway))
+ assert.False(t, cc.clearIdleAndAway(), "second clear should report no change")
+}
+
+// TestClientConn_sessionStateRace exercises concurrent access to the mutable session state through
+// the accessor methods. Run with -race.
+func TestClientConn_sessionStateRace(t *testing.T) {
+ cc := &ClientConn{}
+
+ var wg sync.WaitGroup
+ for range 4 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for i := range 100 {
+ cc.SetUserName(fmt.Appendf(nil, "user-%d", i))
+ _ = cc.GetUserName()
+ cc.SetIcon([]byte{0, byte(i)})
+ _ = cc.GetIcon()
+ cc.SetAutoReply([]byte("brb"))
+ _ = cc.GetAutoReply()
+ cc.SetFlag(UserFlagRefusePM, uint(i%2))
+ _ = cc.IsFlagSet(UserFlagRefusePM)
+ _ = cc.FlagBytes()
+ _ = cc.incrementIdleTime(idleCheckInterval)
+ _ = cc.clearIdleAndAway()
+ }
+ }()
+ }
+ wg.Wait()
+}
diff --git a/hotline/server.go b/hotline/server.go
index 3459346..8a530c2 100644
--- a/hotline/server.go
+++ b/hotline/server.go
@@ -34,7 +34,7 @@ type Server struct {
NetInterface string
Port int
- rateLimiters map[string]*rate.Limiter
+ rateLimiters map[string]*rateLimiterEntry
rateLimitersMu sync.Mutex
handlers map[TranType]HandlerFunc
@@ -49,7 +49,9 @@ type Server struct {
FS FileStore // Storage backend to use for File storage
Agreement io.ReadSeeker
- Banner []byte
+
+ banner []byte // server banner image; guarded by bannerMu as it is replaced on config reload
+ bannerMu sync.RWMutex
FileTransferMgr FileTransferMgr
ChatMgr ChatManager
@@ -80,6 +82,22 @@ func (s *Server) initShutdownCh() {
s.shutdownInit.Do(func() { s.shutdownCh = make(chan struct{}) })
}
+// Banner returns the server banner image. Callers must not modify the returned slice.
+func (s *Server) Banner() []byte {
+ s.bannerMu.RLock()
+ defer s.bannerMu.RUnlock()
+
+ return s.banner
+}
+
+// SetBanner replaces the server banner image.
+func (s *Server) SetBanner(banner []byte) {
+ s.bannerMu.Lock()
+ defer s.bannerMu.Unlock()
+
+ s.banner = banner
+}
+
type Option = func(s *Server)
func WithConfig(config Config) func(s *Server) {
@@ -129,7 +147,7 @@ type ServerConfig struct {
func NewServer(options ...Option) (*Server, error) {
server := Server{
handlers: make(map[TranType]HandlerFunc),
- rateLimiters: make(map[string]*rate.Limiter),
+ rateLimiters: make(map[string]*rateLimiterEntry),
FS: &OSFileStore{},
ChatMgr: NewMemChatManager(),
ClientMgr: NewMemClientMgr(),
@@ -274,6 +292,29 @@ func (s *Server) Send(t Transaction) {
// 0.5 = 1 connection every 2 seconds
const perIPRateLimit = rate.Limit(0.5)
+// rateLimiterTTL is how long the rate limiter for an idle IP address is retained before eviction.
+const rateLimiterTTL = 7 * 24 * time.Hour
+
+type rateLimiterEntry struct {
+ limiter *rate.Limiter
+ lastSeen time.Time
+}
+
+// sweepRateLimiters evicts rate limiters for IP addresses that have not connected recently, so
+// the rate limiter map does not grow unbounded over the lifetime of the server.
+func (s *Server) sweepRateLimiters() {
+ cutoff := time.Now().Add(-rateLimiterTTL)
+
+ s.rateLimitersMu.Lock()
+ defer s.rateLimitersMu.Unlock()
+
+ for ip, entry := range s.rateLimiters {
+ if entry.lastSeen.Before(cutoff) {
+ delete(s.rateLimiters, ip)
+ }
+ }
+}
+
func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
for {
conn, err := ln.Accept()
@@ -302,11 +343,13 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
// Check if we have an existing rate limit for the IP and create one if we do not.
s.rateLimitersMu.Lock()
- rl, ok := s.rateLimiters[ipAddr]
+ entry, ok := s.rateLimiters[ipAddr]
if !ok {
- rl = rate.NewLimiter(perIPRateLimit, 1)
- s.rateLimiters[ipAddr] = rl
+ entry = &rateLimiterEntry{limiter: rate.NewLimiter(perIPRateLimit, 1)}
+ s.rateLimiters[ipAddr] = entry
}
+ entry.lastSeen = time.Now()
+ rl := entry.limiter
s.rateLimitersMu.Unlock()
// Check if the rate limit is exceeded and close the connection if so.
@@ -417,6 +460,7 @@ const (
// keepaliveHandler runs every idleCheckInterval seconds and increments a user's idle time by idleCheckInterval seconds.
// If the updated idle time exceeds userIdleSeconds and the user was not previously idle, we notify all connected clients
// that the user has gone idle. For most clients, this turns the user grey in the user list.
+// It also sweeps stale per-IP rate limiters on each tick.
func (s *Server) keepaliveHandler(ctx context.Context) {
ticker := time.NewTicker(idleCheckInterval * time.Second)
defer ticker.Stop()
@@ -427,23 +471,18 @@ func (s *Server) keepaliveHandler(ctx context.Context) {
return
case <-ticker.C:
for _, c := range s.ClientMgr.List() {
- c.mu.Lock()
- c.IdleTime += idleCheckInterval
-
- // Check if the user
- if c.IdleTime > userIdleSeconds && !c.Flags.IsSet(UserFlagAway) {
- c.Flags.Set(UserFlagAway, 1)
-
+ if c.incrementIdleTime(idleCheckInterval) {
c.SendAll(
TranNotifyChangeUser,
NewField(FieldUserID, c.ID[:]),
- NewField(FieldUserFlags, c.Flags[:]),
- NewField(FieldUserName, c.UserName),
- NewField(FieldUserIconID, c.Icon),
+ NewField(FieldUserFlags, c.FlagBytes()),
+ NewField(FieldUserName, c.GetUserName()),
+ NewField(FieldUserIconID, c.GetIcon()),
)
}
- c.mu.Unlock()
}
+
+ s.sweepRateLimiters()
}
}
}
@@ -547,8 +586,8 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser
defer func() {
if s.Redis != nil {
s.Redis.SRem(context.Background(), RedisKeyOnline, login+"::"+ipAddr)
- if len(c.UserName) != 0 {
- s.Redis.SRem(context.Background(), RedisKeyOnline, login+":"+string(c.UserName)+":"+ipAddr)
+ if userName := c.GetUserName(); len(userName) != 0 {
+ s.Redis.SRem(context.Background(), RedisKeyOnline, login+":"+string(userName)+":"+ipAddr)
}
}
c.Disconnect()
@@ -574,7 +613,7 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser
}
if clientLogin.GetField(FieldUserIconID).Data != nil {
- c.Icon = clientLogin.GetField(FieldUserIconID).Data
+ c.SetIcon(clientLogin.GetField(FieldUserIconID).Data)
}
c.Account = c.Server.AccountManager.Get(login)
@@ -584,14 +623,14 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser
if clientLogin.GetField(FieldUserName).Data != nil {
if c.Authorize(AccessAnyName) {
- c.UserName = clientLogin.GetField(FieldUserName).Data
+ c.SetUserName(clientLogin.GetField(FieldUserName).Data)
} else {
- c.UserName = []byte(c.Account.Name)
+ c.SetUserName([]byte(c.Account.Name))
}
}
if c.Authorize(AccessDisconUser) {
- c.Flags.Set(UserFlagAdmin, 1)
+ c.SetFlag(UserFlagAdmin, 1)
}
c.Send(c.NewReply(&clientLogin,
@@ -620,18 +659,18 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser
// If the client has provided a username as part of the login, we can infer that it is using the 1.2.3 login
// flow and not the 1.5+ flow.
- if len(c.UserName) != 0 {
+ if userName := c.GetUserName(); len(userName) != 0 {
// Add the client username to the logger. For 1.5+ clients, we don't have this information yet as it comes as
// part of TranAgreed
- c.Logger = c.Logger.With("name", string(c.UserName))
+ c.Logger = c.Logger.With("name", string(userName))
c.Logger.Info("Login successful")
// Update the Redis set with the new information
- if s.Redis != nil && len(c.UserName) != 0 {
+ if s.Redis != nil {
// Remove old entry (login::ip)
s.Redis.SRem(context.Background(), RedisKeyOnline, login+"::"+ipAddr)
// Add new entry with login, nickname, ip
- s.Redis.SAdd(context.Background(), RedisKeyOnline, login+":"+string(c.UserName)+":"+ipAddr)
+ s.Redis.SAdd(context.Background(), RedisKeyOnline, login+":"+string(userName)+":"+ipAddr)
}
// Notify other clients on the server that the new user has logged in. For 1.5+ clients we don't have this
@@ -639,10 +678,10 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser
for _, t := range c.NotifyOthers(
NewTransaction(
TranNotifyChangeUser, [2]byte{0, 0},
- NewField(FieldUserName, c.UserName),
+ NewField(FieldUserName, userName),
NewField(FieldUserID, c.ID[:]),
- NewField(FieldUserIconID, c.Icon),
- NewField(FieldUserFlags, c.Flags[:]),
+ NewField(FieldUserIconID, c.GetIcon()),
+ NewField(FieldUserFlags, c.FlagBytes()),
),
) {
c.Server.Send(t)
@@ -701,7 +740,7 @@ func (s *Server) handleFileTransfer(ctx context.Context, rwc io.ReadWriter) erro
rLogger := s.Logger.With(
"remoteAddr", remoteAddr,
"login", fileTransfer.ClientConn.Account.Login,
- "Name", string(fileTransfer.ClientConn.UserName),
+ "Name", string(fileTransfer.ClientConn.GetUserName()),
)
fullPath, err := ReadPath(fileTransfer.FileRoot, fileTransfer.FilePath, fileTransfer.FileName, s.TextDecoder)
@@ -711,7 +750,7 @@ func (s *Server) handleFileTransfer(ctx context.Context, rwc io.ReadWriter) erro
switch fileTransfer.Type {
case BannerDownload:
- if _, err := io.Copy(rwc, bytes.NewBuffer(s.Banner)); err != nil {
+ if _, err := io.Copy(rwc, bytes.NewReader(s.Banner())); err != nil {
return fmt.Errorf("banner download: %w", err)
}
case FileDownload:
diff --git a/hotline/server_test.go b/hotline/server_test.go
index a6c5e19..ebcd073 100644
--- a/hotline/server_test.go
+++ b/hotline/server_test.go
@@ -10,6 +10,7 @@ import (
"net"
"os"
"strings"
+ "sync"
"testing"
"time"
@@ -18,6 +19,7 @@ import (
"github.com/stretchr/testify/require"
"golang.org/x/text/encoding"
"golang.org/x/text/encoding/charmap"
+ "golang.org/x/time/rate"
)
type mockReadWriter struct {
@@ -1036,3 +1038,43 @@ func TestServer_Shutdown_stopsListenAndServe(t *testing.T) {
t.Fatal("ListenAndServe did not return after Shutdown")
}
}
+
+func TestServer_Banner(t *testing.T) {
+ srv := &Server{}
+ assert.Nil(t, srv.Banner())
+
+ srv.SetBanner([]byte("banner-data"))
+ assert.Equal(t, []byte("banner-data"), srv.Banner())
+
+ // Concurrent reads and writes; run with -race.
+ var wg sync.WaitGroup
+ for range 4 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for i := range 100 {
+ srv.SetBanner([]byte{byte(i)})
+ _ = srv.Banner()
+ }
+ }()
+ }
+ wg.Wait()
+}
+
+func TestServer_sweepRateLimiters(t *testing.T) {
+ srv := &Server{rateLimiters: make(map[string]*rateLimiterEntry)}
+
+ srv.rateLimiters["10.0.0.1"] = &rateLimiterEntry{
+ limiter: rate.NewLimiter(perIPRateLimit, 1),
+ lastSeen: time.Now().Add(-rateLimiterTTL - time.Minute), // stale
+ }
+ srv.rateLimiters["10.0.0.2"] = &rateLimiterEntry{
+ limiter: rate.NewLimiter(perIPRateLimit, 1),
+ lastSeen: time.Now(), // fresh
+ }
+
+ srv.sweepRateLimiters()
+
+ assert.NotContains(t, srv.rateLimiters, "10.0.0.1", "stale rate limiter should be evicted")
+ assert.Contains(t, srv.rateLimiters, "10.0.0.2", "fresh rate limiter should be retained")
+}
diff --git a/internal/mobius/api.go b/internal/mobius/api.go
index a574bf8..cf0a46f 100644
--- a/internal/mobius/api.go
+++ b/internal/mobius/api.go
@@ -115,7 +115,7 @@ func (srv *APIServer) OnlineHandler(w http.ResponseWriter, r *http.Request) {
for _, c := range srv.hlServer.ClientMgr.List() {
users = append(users, map[string]string{
"login": string(c.Account.Login),
- "nickname": string(c.UserName),
+ "nickname": string(c.GetUserName()),
"ip": c.RemoteAddr,
})
}
@@ -169,7 +169,7 @@ func (srv *APIServer) BanHandler(w http.ResponseWriter, r *http.Request) {
// Disconnect user if online
for _, c := range srv.hlServer.ClientMgr.List() {
if (req.Username != "" && c.Account.Login == req.Username) ||
- (req.Nickname != "" && string(c.UserName) == req.Nickname) ||
+ (req.Nickname != "" && string(c.GetUserName()) == req.Nickname) ||
(req.IP != "" && c.IP() == req.IP) {
c.Disconnect()
}
diff --git a/internal/mobius/handlers_accounts.go b/internal/mobius/handlers_accounts.go
index 871e632..02b7c77 100644
--- a/internal/mobius/handlers_accounts.go
+++ b/internal/mobius/handlers_accounts.go
@@ -60,9 +60,9 @@ func HandleSetUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotlin
res = append(res, newT)
if c.Authorize(hotline.AccessDisconUser) {
- c.Flags.Set(hotline.UserFlagAdmin, 1)
+ c.SetFlag(hotline.UserFlagAdmin, 1)
} else {
- c.Flags.Set(hotline.UserFlagAdmin, 0)
+ c.SetFlag(hotline.UserFlagAdmin, 0)
}
c.Account.Access = account.Access
@@ -70,9 +70,9 @@ func HandleSetUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotlin
cc.SendAll(
hotline.TranNotifyChangeUser,
hotline.NewField(hotline.FieldUserID, c.ID[:]),
- hotline.NewField(hotline.FieldUserFlags, c.Flags[:]),
- hotline.NewField(hotline.FieldUserName, c.UserName),
- hotline.NewField(hotline.FieldUserIconID, c.Icon),
+ hotline.NewField(hotline.FieldUserFlags, c.FlagBytes()),
+ hotline.NewField(hotline.FieldUserName, c.GetUserName()),
+ hotline.NewField(hotline.FieldUserIconID, c.GetIcon()),
)
}
}
diff --git a/internal/mobius/handlers_chat.go b/internal/mobius/handlers_chat.go
index bda4e58..b81cfa5 100644
--- a/internal/mobius/handlers_chat.go
+++ b/internal/mobius/handlers_chat.go
@@ -2,10 +2,8 @@ package mobius
import (
"bytes"
- "encoding/binary"
"fmt"
"io"
- "math/big"
"github.com/jhalter/mobius/hotline"
)
@@ -28,14 +26,14 @@ func HandleChatSend(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotli
// Truncate long usernames
// %13.13s: This means a string that is right-aligned in a field of 13 characters.
// If the string is longer than 13 characters, it will be truncated to 13 characters.
- formattedMsg := fmt.Sprintf("\r%13.13s: %s", cc.UserName, t.GetField(hotline.FieldData).Data)
+ formattedMsg := fmt.Sprintf("\r%13.13s: %s", cc.GetUserName(), t.GetField(hotline.FieldData).Data)
// By holding the option key, Hotline chat allows users to send /me formatted messages like:
// *** Halcyon does stuff
// This is indicated by the presence of the optional field FieldChatOptions set to a value of 1.
// Most clients do not send this option for normal chat messages.
if t.GetField(hotline.FieldChatOptions).Data != nil && bytes.Equal(t.GetField(hotline.FieldChatOptions).Data, []byte{0, 1}) {
- formattedMsg = fmt.Sprintf("\r*** %s %s", cc.UserName, t.GetField(hotline.FieldData).Data)
+ formattedMsg = fmt.Sprintf("\r*** %s %s", cc.GetUserName(), t.GetField(hotline.FieldData).Data)
}
// Truncate the message to the limit. This does not handle the edge case of a string ending on multibyte character.
@@ -103,7 +101,7 @@ func HandleSendInstantMsg(cc *hotline.ClientConn, t *hotline.Transaction) (res [
hotline.TranServerMsg,
targetID,
hotline.NewField(hotline.FieldData, msg.Data),
- hotline.NewField(hotline.FieldUserName, cc.UserName),
+ hotline.NewField(hotline.FieldUserName, cc.GetUserName()),
hotline.NewField(hotline.FieldUserID, cc.ID[:]),
hotline.NewField(hotline.FieldOptions, []byte{0, 1}),
)
@@ -122,13 +120,13 @@ func HandleSendInstantMsg(cc *hotline.ClientConn, t *hotline.Transaction) (res [
}
// Check if target user has "Refuse private messages" flag
- if otherClient.Flags.IsSet(hotline.UserFlagRefusePM) {
+ if otherClient.IsFlagSet(hotline.UserFlagRefusePM) {
res = append(res,
hotline.NewTransaction(
hotline.TranServerMsg,
cc.ID,
- hotline.NewField(hotline.FieldData, []byte(fmt.Sprintf(ErrMsgDoesNotAcceptTemplate, string(otherClient.UserName), "private messages"))),
- hotline.NewField(hotline.FieldUserName, otherClient.UserName),
+ hotline.NewField(hotline.FieldData, []byte(fmt.Sprintf(ErrMsgDoesNotAcceptTemplate, string(otherClient.GetUserName()), "private messages"))),
+ hotline.NewField(hotline.FieldUserName, otherClient.GetUserName()),
hotline.NewField(hotline.FieldUserID, otherClient.ID[:]),
hotline.NewField(hotline.FieldOptions, []byte{0, 2}),
),
@@ -138,13 +136,13 @@ func HandleSendInstantMsg(cc *hotline.ClientConn, t *hotline.Transaction) (res [
}
// Respond with auto reply if other client has it enabled
- if len(otherClient.AutoReply) > 0 {
+ if len(otherClient.GetAutoReply()) > 0 {
res = append(res,
hotline.NewTransaction(
hotline.TranServerMsg,
cc.ID,
- hotline.NewField(hotline.FieldData, otherClient.AutoReply),
- hotline.NewField(hotline.FieldUserName, otherClient.UserName),
+ hotline.NewField(hotline.FieldData, otherClient.GetAutoReply()),
+ hotline.NewField(hotline.FieldUserName, otherClient.GetUserName()),
hotline.NewField(hotline.FieldUserID, otherClient.ID[:]),
hotline.NewField(hotline.FieldOptions, []byte{0, 1}),
),
@@ -185,14 +183,13 @@ func HandleInviteNewChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []
// Create a new chat with self as initial member.
newChatID := cc.Server.ChatMgr.New(cc)
- flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(targetClient.Flags[:])))
- if flagBitmap.Bit(hotline.UserFlagRefusePChat) == 1 {
+ if targetClient.IsFlagSet(hotline.UserFlagRefusePChat) {
res = append(res,
hotline.NewTransaction(
hotline.TranServerMsg,
cc.ID,
- hotline.NewField(hotline.FieldData, []byte(fmt.Sprintf(ErrMsgDoesNotAcceptTemplate, string(targetClient.UserName), "private chats"))),
- hotline.NewField(hotline.FieldUserName, targetClient.UserName),
+ hotline.NewField(hotline.FieldData, []byte(fmt.Sprintf(ErrMsgDoesNotAcceptTemplate, string(targetClient.GetUserName()), "private chats"))),
+ hotline.NewField(hotline.FieldUserName, targetClient.GetUserName()),
hotline.NewField(hotline.FieldUserID, targetClient.ID[:]),
hotline.NewField(hotline.FieldOptions, []byte{0, 2}),
),
@@ -203,7 +200,7 @@ func HandleInviteNewChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []
hotline.TranInviteToChat,
targetID,
hotline.NewField(hotline.FieldChatID, newChatID[:]),
- hotline.NewField(hotline.FieldUserName, cc.UserName),
+ hotline.NewField(hotline.FieldUserName, cc.GetUserName()),
hotline.NewField(hotline.FieldUserID, cc.ID[:]),
),
)
@@ -213,10 +210,10 @@ func HandleInviteNewChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []
res,
cc.NewReply(t,
hotline.NewField(hotline.FieldChatID, newChatID[:]),
- hotline.NewField(hotline.FieldUserName, cc.UserName),
+ hotline.NewField(hotline.FieldUserName, cc.GetUserName()),
hotline.NewField(hotline.FieldUserID, cc.ID[:]),
- hotline.NewField(hotline.FieldUserIconID, cc.Icon),
- hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]),
+ hotline.NewField(hotline.FieldUserIconID, cc.GetIcon()),
+ hotline.NewField(hotline.FieldUserFlags, cc.FlagBytes()),
),
)
}
@@ -245,16 +242,16 @@ func HandleInviteToChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []h
hotline.TranInviteToChat,
targetID,
hotline.NewField(hotline.FieldChatID, chatID),
- hotline.NewField(hotline.FieldUserName, cc.UserName),
+ hotline.NewField(hotline.FieldUserName, cc.GetUserName()),
hotline.NewField(hotline.FieldUserID, cc.ID[:]),
),
cc.NewReply(
t,
hotline.NewField(hotline.FieldChatID, chatID),
- hotline.NewField(hotline.FieldUserName, cc.UserName),
+ hotline.NewField(hotline.FieldUserName, cc.GetUserName()),
hotline.NewField(hotline.FieldUserID, cc.ID[:]),
- hotline.NewField(hotline.FieldUserIconID, cc.Icon),
- hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]),
+ hotline.NewField(hotline.FieldUserIconID, cc.GetIcon()),
+ hotline.NewField(hotline.FieldUserFlags, cc.FlagBytes()),
),
}
}
@@ -278,7 +275,7 @@ func HandleRejectChatInvite(cc *hotline.ClientConn, t *hotline.Transaction) (res
hotline.TranChatMsg,
c.ID,
hotline.NewField(hotline.FieldChatID, chatID[:]),
- hotline.NewField(hotline.FieldData, append(cc.UserName, []byte(" declined invitation to chat")...)),
+ hotline.NewField(hotline.FieldData, fmt.Appendf(nil, "%s declined invitation to chat", cc.GetUserName())),
),
)
}
@@ -307,10 +304,10 @@ func HandleJoinChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotli
hotline.TranNotifyChatChangeUser,
c.ID,
hotline.NewField(hotline.FieldChatID, chatID[:]),
- hotline.NewField(hotline.FieldUserName, cc.UserName),
+ hotline.NewField(hotline.FieldUserName, cc.GetUserName()),
hotline.NewField(hotline.FieldUserID, cc.ID[:]),
- hotline.NewField(hotline.FieldUserIconID, cc.Icon),
- hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]),
+ hotline.NewField(hotline.FieldUserIconID, cc.GetIcon()),
+ hotline.NewField(hotline.FieldUserFlags, cc.FlagBytes()),
),
)
}
@@ -323,9 +320,9 @@ func HandleJoinChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotli
for _, c := range cc.Server.ChatMgr.Members(chatID) {
b, err := io.ReadAll(&hotline.User{
ID: c.ID,
- Icon: c.Icon,
- Flags: c.Flags[:],
- Name: string(c.UserName),
+ Icon: c.GetIcon(),
+ Flags: c.FlagBytes(),
+ Name: string(c.GetUserName()),
})
if err != nil {
cc.Logger.Error("join chat: read member info", "err", err)
diff --git a/internal/mobius/handlers_news.go b/internal/mobius/handlers_news.go
index 5946fa5..29b0ae2 100644
--- a/internal/mobius/handlers_news.go
+++ b/internal/mobius/handlers_news.go
@@ -33,7 +33,7 @@ func HandleTranOldPostNews(cc *hotline.ClientConn, t *hotline.Transaction) (res
newsTemplate = cc.Server.Config.NewsDelimiter
}
- newsPost := fmt.Sprintf(newsTemplate+"\r", cc.UserName, time.Now().Format(newsDateTemplate), t.GetField(hotline.FieldData).Data)
+ newsPost := fmt.Sprintf(newsTemplate+"\r", cc.GetUserName(), time.Now().Format(newsDateTemplate), t.GetField(hotline.FieldData).Data)
newsPost = strings.ReplaceAll(newsPost, "\n", "\r")
_, err := cc.Server.MessageBoard.Write([]byte(newsPost))
@@ -341,7 +341,7 @@ func HandlePostNewsArt(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho
uint32(parentArticleID),
hotline.NewsArtData{
Title: string(t.GetField(hotline.FieldNewsArtTitle).Data),
- Poster: string(cc.UserName),
+ Poster: string(cc.GetUserName()),
Date: hotline.NewTime(time.Now()),
DataFlav: hotline.NewsFlavor,
Data: string(t.GetField(hotline.FieldNewsArtData).Data),
diff --git a/internal/mobius/handlers_session.go b/internal/mobius/handlers_session.go
index ad6773b..f17ffa3 100644
--- a/internal/mobius/handlers_session.go
+++ b/internal/mobius/handlers_session.go
@@ -59,7 +59,7 @@ func HandleGetClientInfoText(cc *hotline.ClientConn, t *hotline.Transaction) (re
return append(res, cc.NewReply(t,
hotline.NewField(hotline.FieldData, []byte(clientConn.String())),
- hotline.NewField(hotline.FieldUserName, clientConn.UserName),
+ hotline.NewField(hotline.FieldUserName, clientConn.GetUserName()),
))
}
@@ -74,9 +74,9 @@ func HandleGetUserNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res
for _, c := range cc.Server.ClientMgr.List() {
b, err := io.ReadAll(&hotline.User{
ID: c.ID,
- Icon: c.Icon,
- Flags: c.Flags[:],
- Name: string(c.UserName),
+ Icon: c.GetIcon(),
+ Flags: c.FlagBytes(),
+ Name: string(c.GetUserName()),
})
if err != nil {
cc.Logger.Error("get user name list: read user info", "err", err)
@@ -101,9 +101,9 @@ func HandleGetUserNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res
func HandleTranAgreed(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if t.GetField(hotline.FieldUserName).Data != nil {
if cc.Authorize(hotline.AccessAnyName) {
- cc.UserName = t.GetField(hotline.FieldUserName).Data
+ cc.SetUserName(t.GetField(hotline.FieldUserName).Data)
} else {
- cc.UserName = []byte(cc.Account.Name)
+ cc.SetUserName([]byte(cc.Account.Name))
}
}
@@ -114,15 +114,15 @@ func HandleTranAgreed(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
// Remove old entry (login::ip)
cc.Server.Redis.SRem(context.Background(), hotline.RedisKeyOnline, login+"::"+ip)
// Add new entry with login, nickname, ip
- cc.Server.Redis.SAdd(context.Background(), hotline.RedisKeyOnline, login+":"+string(cc.UserName)+":"+ip)
+ cc.Server.Redis.SAdd(context.Background(), hotline.RedisKeyOnline, login+":"+string(cc.GetUserName())+":"+ip)
}
// Ban check for nickname
- if cc.Server.BanList != nil && cc.Server.BanList.IsNicknameBanned(string(cc.UserName)) {
+ if cc.Server.BanList != nil && cc.Server.BanList.IsNicknameBanned(string(cc.GetUserName())) {
if cc.Server.Redis != nil {
// Remove all possible online entries for this login and IP
cc.Server.Redis.SRem(context.Background(), hotline.RedisKeyOnline, login+"::"+ip)
- cc.Server.Redis.SRem(context.Background(), hotline.RedisKeyOnline, login+":"+string(cc.UserName)+":"+ip)
+ cc.Server.Redis.SRem(context.Background(), hotline.RedisKeyOnline, login+":"+string(cc.GetUserName())+":"+ip)
}
if err := cc.Server.BanList.Add(ip, nil); err != nil {
cc.Logger.Error("Failed to ban IP for banned nickname", "ip", ip, "err", err)
@@ -132,35 +132,32 @@ func HandleTranAgreed(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
return res
}
- cc.Icon = t.GetField(hotline.FieldUserIconID).Data
+ cc.SetIcon(t.GetField(hotline.FieldUserIconID).Data)
- cc.Logger = cc.Logger.With("Name", string(cc.UserName))
+ cc.Logger = cc.Logger.With("Name", string(cc.GetUserName()))
cc.Logger.Info("Login successful")
options := t.GetField(hotline.FieldOptions).Data
optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options)))
// Check refuse private PM option
-
- cc.FlagsMU.Lock()
- defer cc.FlagsMU.Unlock()
- cc.Flags.Set(hotline.UserFlagRefusePM, optBitmap.Bit(hotline.UserOptRefusePM))
+ cc.SetFlag(hotline.UserFlagRefusePM, optBitmap.Bit(hotline.UserOptRefusePM))
// Check refuse private chat option
- cc.Flags.Set(hotline.UserFlagRefusePChat, optBitmap.Bit(hotline.UserOptRefuseChat))
+ cc.SetFlag(hotline.UserFlagRefusePChat, optBitmap.Bit(hotline.UserOptRefuseChat))
// Check auto response
if optBitmap.Bit(hotline.UserOptAutoResponse) == 1 {
- cc.AutoReply = t.GetField(hotline.FieldAutomaticResponse).Data
+ cc.SetAutoReply(t.GetField(hotline.FieldAutomaticResponse).Data)
}
trans := cc.NotifyOthers(
hotline.NewTransaction(
hotline.TranNotifyChangeUser, [2]byte{0, 0},
- hotline.NewField(hotline.FieldUserName, cc.UserName),
+ hotline.NewField(hotline.FieldUserName, cc.GetUserName()),
hotline.NewField(hotline.FieldUserID, cc.ID[:]),
- hotline.NewField(hotline.FieldUserIconID, cc.Icon),
- hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]),
+ hotline.NewField(hotline.FieldUserIconID, cc.GetIcon()),
+ hotline.NewField(hotline.FieldUserFlags, cc.FlagBytes()),
),
)
res = append(res, trans...)
@@ -210,7 +207,7 @@ func HandleDisconnectUser(cc *hotline.ClientConn, t *hotline.Transaction) (res [
switch options[1] {
case 1:
// send message: "You are temporarily banned on this server"
- cc.Logger.Info("Disconnect & temporarily ban user", "username", string(clientConn.UserName))
+ cc.Logger.Info("Disconnect & temporarily ban user", "username", string(clientConn.GetUserName()))
res = append(res, hotline.NewTransaction(
hotline.TranServerMsg,
@@ -229,7 +226,7 @@ func HandleDisconnectUser(cc *hotline.ClientConn, t *hotline.Transaction) (res [
}
case 2:
// send message: "You are permanently banned on this server"
- cc.Logger.Info("Disconnect & ban user", "username", string(clientConn.UserName))
+ cc.Logger.Info("Disconnect & ban user", "username", string(clientConn.GetUserName()))
res = append(res, hotline.NewTransaction(
hotline.TranServerMsg,
@@ -266,14 +263,14 @@ func HandleDisconnectUser(cc *hotline.ClientConn, t *hotline.Transaction) (res [
// Reply is not expected.
func HandleSetClientUserInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if len(t.GetField(hotline.FieldUserIconID).Data) == 4 {
- cc.Icon = t.GetField(hotline.FieldUserIconID).Data[2:]
+ cc.SetIcon(t.GetField(hotline.FieldUserIconID).Data[2:])
} else {
- cc.Icon = t.GetField(hotline.FieldUserIconID).Data
+ cc.SetIcon(t.GetField(hotline.FieldUserIconID).Data)
}
if cc.Authorize(hotline.AccessAnyName) {
- oldNickname := string(cc.UserName)
+ oldNickname := string(cc.GetUserName())
newNickname := string(t.GetField(hotline.FieldUserName).Data)
- cc.UserName = t.GetField(hotline.FieldUserName).Data
+ cc.SetUserName(t.GetField(hotline.FieldUserName).Data)
login := cc.Account.Login
ip := cc.IP()
@@ -312,14 +309,14 @@ func HandleSetClientUserInfo(cc *hotline.ClientConn, t *hotline.Transaction) (re
if options != nil {
optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options)))
- cc.Flags.Set(hotline.UserFlagRefusePM, optBitmap.Bit(hotline.UserOptRefusePM))
- cc.Flags.Set(hotline.UserFlagRefusePChat, optBitmap.Bit(hotline.UserOptRefuseChat))
+ cc.SetFlag(hotline.UserFlagRefusePM, optBitmap.Bit(hotline.UserOptRefusePM))
+ cc.SetFlag(hotline.UserFlagRefusePChat, optBitmap.Bit(hotline.UserOptRefuseChat))
// Check auto response
if optBitmap.Bit(hotline.UserOptAutoResponse) == 1 {
- cc.AutoReply = t.GetField(hotline.FieldAutomaticResponse).Data
+ cc.SetAutoReply(t.GetField(hotline.FieldAutomaticResponse).Data)
} else {
- cc.AutoReply = []byte{}
+ cc.SetAutoReply([]byte{})
}
}
@@ -328,9 +325,9 @@ func HandleSetClientUserInfo(cc *hotline.ClientConn, t *hotline.Transaction) (re
hotline.TranNotifyChangeUser,
c.ID,
hotline.NewField(hotline.FieldUserID, cc.ID[:]),
- hotline.NewField(hotline.FieldUserIconID, cc.Icon),
- hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]),
- hotline.NewField(hotline.FieldUserName, cc.UserName),
+ hotline.NewField(hotline.FieldUserIconID, cc.GetIcon()),
+ hotline.NewField(hotline.FieldUserFlags, cc.FlagBytes()),
+ hotline.NewField(hotline.FieldUserName, cc.GetUserName()),
))
}
diff --git a/internal/mobius/handlers_transfers.go b/internal/mobius/handlers_transfers.go
index dfb2389..315135e 100644
--- a/internal/mobius/handlers_transfers.go
+++ b/internal/mobius/handlers_transfers.go
@@ -277,7 +277,7 @@ func HandleUploadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
// - 108 Transfer size Size of data to be downloaded
func HandleDownloadBanner(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
ft := cc.NewFileTransfer(hotline.BannerDownload, "", []byte{}, []byte{}, make([]byte, 4))
- binary.BigEndian.PutUint32(ft.TransferSize, uint32(len(cc.Server.Banner)))
+ binary.BigEndian.PutUint32(ft.TransferSize, uint32(len(cc.Server.Banner())))
return append(res, cc.NewReply(t,
hotline.NewField(hotline.FieldRefNum, ft.RefNum[:]),
diff --git a/internal/mobius/handlers_transfers_test.go b/internal/mobius/handlers_transfers_test.go
index 90eea2d..28c2768 100644
--- a/internal/mobius/handlers_transfers_test.go
+++ b/internal/mobius/handlers_transfers_test.go
@@ -318,12 +318,14 @@ func TestHandleDownloadFile(t *testing.T) {
func TestHandleDownloadBanner(t *testing.T) {
t.Run("returns banner transfer info", func(t *testing.T) {
+ srv := &hotline.Server{
+ FileTransferMgr: hotline.NewMemFileTransferMgr(),
+ }
+ srv.SetBanner([]byte("test-banner-data"))
+
cc := &hotline.ClientConn{
ClientFileTransferMgr: hotline.NewClientFileTransferMgr(),
- Server: &hotline.Server{
- Banner: []byte("test-banner-data"),
- FileTransferMgr: hotline.NewMemFileTransferMgr(),
- },
+ Server: srv,
}
tran := hotline.NewTransaction(hotline.TranDownloadBanner, [2]byte{0, 1})