feat: add username/hostname support with IRC hostmask format
All checks were successful
check / check (push) Successful in 2m11s

- Add username and hostname columns to sessions table (001_initial.sql)
- Accept optional username field in session creation and registration
  endpoints; defaults to nick if not provided
- Resolve hostname via reverse DNS of connecting client IP at session
  creation time (supports X-Forwarded-For and X-Real-IP headers)
- Display real username and hostname in WHOIS (311 RPL_WHOISUSER) and
  WHO (352 RPL_WHOREPLY) responses instead of nick/servername
- Add FormatHostmask helper for nick!user@host format
- Add SessionHostInfo type and GetSessionHostInfo query
- Include username/hostname in MemberInfo and ChannelMembers results
- Extract validateHashcash and resolveUsername helpers to stay under
  funlen limits
- Add comprehensive unit tests for all new DB functions, hostmask
  formatting, and integration tests for WHOIS/WHO responses
- Update README with hostmask documentation, new API fields, and
  updated schema reference
This commit is contained in:
user
2026-03-17 05:34:57 -07:00
parent e36bd99ef6
commit e42c6c1868
9 changed files with 804 additions and 65 deletions

View File

@@ -74,14 +74,40 @@ type ChannelInfo struct {
type MemberInfo struct {
ID int64 `json:"id"`
Nick string `json:"nick"`
Username string `json:"username"`
Hostname string `json:"hostname"`
LastSeen time.Time `json:"lastSeen"`
}
// Hostmask returns the IRC hostmask in
// nick!user@host format.
func (m *MemberInfo) Hostmask() string {
return FormatHostmask(m.Nick, m.Username, m.Hostname)
}
// FormatHostmask formats a nick, username, and hostname
// into a standard IRC hostmask string (nick!user@host).
func FormatHostmask(nick, username, hostname string) string {
if username == "" {
username = nick
}
if hostname == "" {
hostname = "*"
}
return nick + "!" + username + "@" + hostname
}
// CreateSession registers a new session and its first client.
func (database *Database) CreateSession(
ctx context.Context,
nick string,
nick, username, hostname string,
) (int64, int64, string, error) {
if username == "" {
username = nick
}
sessionUUID := uuid.New().String()
clientUUID := uuid.New().String()
@@ -101,9 +127,10 @@ func (database *Database) CreateSession(
res, err := transaction.ExecContext(ctx,
`INSERT INTO sessions
(uuid, nick, created_at, last_seen)
VALUES (?, ?, ?, ?)`,
sessionUUID, nick, now, now)
(uuid, nick, username, hostname,
created_at, last_seen)
VALUES (?, ?, ?, ?, ?, ?)`,
sessionUUID, nick, username, hostname, now, now)
if err != nil {
_ = transaction.Rollback()
@@ -209,6 +236,35 @@ func (database *Database) GetSessionByNick(
return sessionID, nil
}
// SessionHostInfo holds the username and hostname for a session.
type SessionHostInfo struct {
Username string
Hostname string
}
// GetSessionHostInfo returns the username and hostname
// for a session.
func (database *Database) GetSessionHostInfo(
ctx context.Context,
sessionID int64,
) (*SessionHostInfo, error) {
var info SessionHostInfo
err := database.conn.QueryRowContext(
ctx,
`SELECT username, hostname
FROM sessions WHERE id = ?`,
sessionID,
).Scan(&info.Username, &info.Hostname)
if err != nil {
return nil, fmt.Errorf(
"get session host info: %w", err,
)
}
return &info, nil
}
// GetChannelByName returns the channel ID for a name.
func (database *Database) GetChannelByName(
ctx context.Context,
@@ -388,7 +444,8 @@ func (database *Database) ChannelMembers(
channelID int64,
) ([]MemberInfo, error) {
rows, err := database.conn.QueryContext(ctx,
`SELECT s.id, s.nick, s.last_seen
`SELECT s.id, s.nick, s.username,
s.hostname, s.last_seen
FROM sessions s
INNER JOIN channel_members cm
ON cm.session_id = s.id
@@ -408,7 +465,9 @@ func (database *Database) ChannelMembers(
var member MemberInfo
err = rows.Scan(
&member.ID, &member.Nick, &member.LastSeen,
&member.ID, &member.Nick,
&member.Username, &member.Hostname,
&member.LastSeen,
)
if err != nil {
return nil, fmt.Errorf(