major: added CHATHISTORY, fixed bug in sendmessage

This commit is contained in:
meera 2026-07-16 01:09:25 +03:00
parent e49701af15
commit 44da1e17a6
3 changed files with 196 additions and 39 deletions

50
http.go
View File

@ -3,6 +3,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"net/http" "net/http"
) )
@ -12,6 +13,7 @@ type ModeResponse struct {
Target string `json:"target"` Target string `json:"target"`
Channel string `json:"channel"` Channel string `json:"channel"`
Status string `json:"status"` Status string `json:"status"`
Error string `json:"error"`
} }
type KickResponse struct { type KickResponse struct {
Action string `json:"action"` Action string `json:"action"`
@ -19,24 +21,33 @@ type KickResponse struct {
Channel string `json:"channel"` Channel string `json:"channel"`
Reason string `json:"reason"` Reason string `json:"reason"`
Status string `json:"status"` Status string `json:"status"`
Error string `json:"error"`
} }
func startHTTP(cfg Config) { func startHTTP(cfg Config) {
htport := cfg.Webapiport htport := cfg.Webapiport
http.HandleFunc("/admin/kick", handleKick) addAuthMiddleware := func(path string, next http.HandlerFunc) {
http.HandleFunc("/admin/mode", handleModeAPI) http.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
http.HandleFunc("/admin/list", handleListAPI) guardHandlerAuth(w, r, next)
http.HandleFunc("/admin/reloadmotd", handleMOTDreload) })
fmt.Printf("[web] starting API on port %d\n", htport) }
addAuthMiddleware("/admin/kick", handleKick)
addAuthMiddleware("/admin/mode", handleModeAPI)
addAuthMiddleware("/admin/list", handleListAPI)
addAuthMiddleware("/admin/reloadmotd", handleMOTDreload)
log.Printf("[web] starting API on port %d\n", htport)
http.ListenAndServe(fmt.Sprintf(":%d", htport), nil) http.ListenAndServe(fmt.Sprintf(":%d", htport), nil)
} }
func handleKick(w http.ResponseWriter, r *http.Request) { func guardHandlerAuth(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
user, pass, ok := r.BasicAuth() user, pass, ok := r.BasicAuth()
if !ok || !checkCredentials(user, pass) || !globalOps[user] { if !ok || !checkCredentials(user, pass) || !globalOps[user] {
w.Header().Set("WWW-Authenticate", `Basic realm="API"`) w.Header().Set("WWW-Authenticate", `Basic realm="API"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
next(w, r)
}
func handleKick(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "POST required", http.StatusMethodNotAllowed) http.Error(w, "POST required", http.StatusMethodNotAllowed)
return return
@ -82,17 +93,12 @@ func handleKick(w http.ResponseWriter, r *http.Request) {
Channel: channelName, Channel: channelName,
Reason: reason, Reason: reason,
Status: "ok", Status: "ok",
Error: "",
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp) json.NewEncoder(w).Encode(resp)
} }
func handleModeAPI(w http.ResponseWriter, r *http.Request) { func handleModeAPI(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok || !checkCredentials(user, pass) || !globalOps[user] {
w.Header().Set("WWW-Authenticate", `Basic realm="API"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "POST required", http.StatusMethodNotAllowed) http.Error(w, "POST required", http.StatusMethodNotAllowed)
return return
@ -142,6 +148,7 @@ func handleModeAPI(w http.ResponseWriter, r *http.Request) {
Target: targetNick, Target: targetNick,
Channel: channelName, Channel: channelName,
Status: "ok", Status: "ok",
Error: "",
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w) enc := json.NewEncoder(w)
@ -156,12 +163,6 @@ type ChannelInfo struct {
} }
func handleListAPI(w http.ResponseWriter, r *http.Request) { func handleListAPI(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok || !checkCredentials(user, pass) || !globalOps[user] {
w.Header().Set("WWW-Authenticate", `Basic realm="API"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
chMu.Lock() chMu.Lock()
defer chMu.Unlock() defer chMu.Unlock()
@ -189,18 +190,9 @@ func handleListAPI(w http.ResponseWriter, r *http.Request) {
} }
} }
func handleMOTDreload(w http.ResponseWriter, r *http.Request) { func handleMOTDreload(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok || !checkCredentials(user, pass) || !globalOps[user] {
w.Header().Set("WWW-Authenticate", `Basic realm="API"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
loadMOTD(motdFile) loadMOTD(motdFile)
type htresp struct { resp := map[string]any{
Status string `json:"status"` "status": "ok",
}
resp := htresp{
Status: "ok",
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil { if err := json.NewEncoder(w).Encode(resp); err != nil {

179
main.go
View File

@ -14,6 +14,7 @@ import (
"log" "log"
"net" "net"
"os" "os"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -38,8 +39,18 @@ type Channel struct {
description string description string
members map[string]*Client members map[string]*Client
ops map[string]bool ops map[string]bool
history []HistoryMessage
mu sync.RWMutex mu sync.RWMutex
} }
type HistoryMessage struct {
MsgID string
Time time.Time
Prefix string
Command string
Params []string
Text string
}
type Channelconfig struct { type Channelconfig struct {
Channel string `json:"channel"` Channel string `json:"channel"`
Description string `json:"description"` Description string `json:"description"`
@ -162,6 +173,12 @@ func handleConn(conn net.Conn) {
delete(clients, oldNick) delete(clients, oldNick)
clients[newNick] = client clients[newNick] = client
cliMu.Unlock() cliMu.Unlock()
for _, ch := range client.channels {
ch.mu.Lock()
delete(ch.members, oldNick)
ch.members[newNick] = client
ch.mu.Unlock()
}
client.nick = newNick client.nick = newNick
for _, ch := range client.channels { for _, ch := range client.channels {
broadcastNames(ch) broadcastNames(ch)
@ -188,8 +205,8 @@ func handleConn(conn net.Conn) {
subcmd := strings.ToUpper(msg.Params[0]) subcmd := strings.ToUpper(msg.Params[0])
switch subcmd { switch subcmd {
case "LS": case "LS":
// explicitly tell we only support PLAIN SASL // explicitly tell we only support PLAIN SASL, as well as chathistory
client.fwrite(":server CAP * LS :sasl=PLAIN\r\n") client.fwrite(":server CAP * LS :sasl=PLAIN chathistory\r\n")
case "REQ": case "REQ":
if len(msg.Params) >= 2 && strings.Contains(strings.ToLower(msg.Params[1]), "sasl") { if len(msg.Params) >= 2 && strings.Contains(strings.ToLower(msg.Params[1]), "sasl") {
@ -373,6 +390,15 @@ func handleConn(conn net.Conn) {
} else { } else {
fmt.Fprintf(client.conn, ":server 461 %s NAMES :Not enough parameters\r\n", client.nick) fmt.Fprintf(client.conn, ":server 461 %s NAMES :Not enough parameters\r\n", client.nick)
} }
case "CHATHISTORY":
if len(msg.Params) < 3 {
client.fwrite(":server 461 %s CHATHISTORY :Not enough parameters\r\n", client.nick)
continue
}
subcmd := strings.ToUpper(msg.Params[0])
target := normalizeChannelName(msg.Params[1])
count, _ := strconv.Atoi(msg.Params[2])
client.serveHistory(subcmd, target, count, msg.Params)
default: default:
if client.nick != "" { if client.nick != "" {
fmt.Fprintf(conn, ":server 421 %s %s :Unknown command or command is not implemented\r\n", client.nick, msg.Command) fmt.Fprintf(conn, ":server 421 %s %s :Unknown command or command is not implemented\r\n", client.nick, msg.Command)
@ -398,6 +424,110 @@ func (c *Client) sendNames(ch *Channel) {
fmt.Fprintf(c.conn, ":server 353 %s = %s :%s\r\n", c.nick, ch.name, strings.Join(nicks, " ")) fmt.Fprintf(c.conn, ":server 353 %s = %s :%s\r\n", c.nick, ch.name, strings.Join(nicks, " "))
fmt.Fprintf(c.conn, ":server 366 %s %s :End of /NAMES list.\r\n", c.nick, ch.name) fmt.Fprintf(c.conn, ":server 366 %s %s :End of /NAMES list.\r\n", c.nick, ch.name)
} }
func (c *Client) serveHistory(subcmd, target string, count int, params []string) {
chMu.Lock()
ch, ok := channels[target]
chMu.Unlock()
if !ok {
c.fwrite(":server 403 %s %s :No such channel\r\n", c.nick, target)
return
}
ch.mu.RLock()
defer ch.mu.RUnlock()
var msgs []HistoryMessage
switch subcmd {
case "LATEST":
if count > len(ch.history) {
count = len(ch.history)
}
msgs = ch.history[len(ch.history)-count:]
case "BEFORE":
if len(params) < 4 {
c.fwrite(":server 461 %s CHATHISTORY :Not enough parameters\r\n", c.nick)
return
}
msgid := params[2] // format: msgid=12345
msgid = strings.TrimPrefix(msgid, "msgid=")
idx := findMsgIndex(ch.history, msgid)
if idx == -1 {
c.fwrite(":server 718 %s %s :No history available\r\n", c.nick, target)
return
}
start := idx - count
if start < 0 {
start = 0
}
msgs = ch.history[start:idx]
case "AFTER":
if len(params) < 4 {
c.fwrite(":server 461 %s CHATHISTORY :Not enough parameters\r\n", c.nick)
return
}
msgid := strings.TrimPrefix(params[2], "msgid=")
idx := findMsgIndex(ch.history, msgid)
if idx == -1 {
c.fwrite(":server 718 %s %s :No history available\r\n", c.nick, target)
return
}
end := idx + 1 + count
if end > len(ch.history) {
end = len(ch.history)
}
msgs = ch.history[idx+1 : end]
case "AROUND":
if len(params) < 4 {
c.fwrite(":server 461 %s CHATHISTORY :Not enough parameters\r\n", c.nick)
return
}
msgid := strings.TrimPrefix(params[2], "msgid=")
idx := findMsgIndex(ch.history, msgid)
if idx == -1 {
c.fwrite(":server 718 %s %s :No history available\r\n", c.nick, target)
return
}
half := count / 2
start := idx - half
if start < 0 {
start = 0
}
end := idx + half + 1
if end > len(ch.history) {
end = len(ch.history)
}
msgs = ch.history[start:end]
}
batchID := fmt.Sprintf("hist-%d", time.Now().UnixNano())
c.fwrite(":server BATCH +%s chathistory %s\r\n", batchID, target)
for _, m := range msgs {
tags := fmt.Sprintf("@msgid=%s;time=%s",
m.MsgID,
m.Time.UTC().Format(time.RFC3339))
c.fwrite("%s :%s %s %s :%s\r\n",
tags,
m.Prefix,
m.Command,
strings.Join(m.Params, " "),
m.Text)
}
c.fwrite(":server BATCH -%s\r\n", batchID)
}
func findMsgIndex(history []HistoryMessage, msgid string) int {
for i, m := range history {
if m.MsgID == msgid {
return i
}
}
return -1
}
func kickUser(sender *Client, channelName, targetNick, reason string) { func kickUser(sender *Client, channelName, targetNick, reason string) {
chMu.Lock() chMu.Lock()
ch, ok := channels[channelName] ch, ok := channels[channelName]
@ -482,6 +612,7 @@ func joinChannel(c *Client, name string) {
name: name, name: name,
members: make(map[string]*Client), members: make(map[string]*Client),
ops: make(map[string]bool), ops: make(map[string]bool),
history: nil,
description: "none", description: "none",
} }
channels[name] = ch channels[name] = ch
@ -501,6 +632,12 @@ func joinChannel(c *Client, name string) {
for _, member := range ch.members { for _, member := range ch.members {
fmt.Fprintf(member.conn, ":%s JOIN %s\r\n", c.nick, name) fmt.Fprintf(member.conn, ":%s JOIN %s\r\n", c.nick, name)
} }
if ch.ops[c.nick] {
for _, member := range ch.members {
member.fwrite(":server MODE %s +o %s\r\n", name, c.nick)
}
}
c.sendNames(ch) c.sendNames(ch)
} }
func (c *Client) partChannel(name string) { func (c *Client) partChannel(name string) {
@ -518,7 +655,7 @@ func (c *Client) partChannel(name string) {
fmt.Fprintf(member.conn, ":%s PART %s\r\n", c.nick, name) fmt.Fprintf(member.conn, ":%s PART %s\r\n", c.nick, name)
} }
ch.mu.Unlock() ch.mu.Unlock()
broadcastNames(ch) // broadcastNames(ch)
} }
func addChannel(name string, description string) { func addChannel(name string, description string) {
name = normalizeChannelName(name) name = normalizeChannelName(name)
@ -535,6 +672,7 @@ func addChannel(name string, description string) {
} }
} }
func sendMessage(sender *Client, target, text string) { func sendMessage(sender *Client, target, text string) {
// TODO: handle when a user sends a message to a channel theyre not in
cliMu.Lock() cliMu.Lock()
if c, ok := clients[target]; ok { if c, ok := clients[target]; ok {
c.fwrite(":%s!%s@%s PRIVMSG %s :%s\r\n", sender.nick, sender.user, sender.host, target, text) c.fwrite(":%s!%s@%s PRIVMSG %s :%s\r\n", sender.nick, sender.user, sender.host, target, text)
@ -548,6 +686,13 @@ func sendMessage(sender *Client, target, text string) {
if !ok { if !ok {
return return
} }
_, member := ch.members[sender.nick]
ch.mu.RUnlock()
if !member {
sender.fwrite(":server 404 %s %s :Cannot send to channel\r\n",
sender.nick, target)
return
}
ch.mu.Lock() ch.mu.Lock()
defer ch.mu.Unlock() defer ch.mu.Unlock()
for _, member := range ch.members { for _, member := range ch.members {
@ -555,9 +700,9 @@ func sendMessage(sender *Client, target, text string) {
fmt.Fprintf(member.conn, ":%s!%s@%s PRIVMSG %s :%s\r\n", sender.nick, sender.user, sender.host, target, text) fmt.Fprintf(member.conn, ":%s!%s@%s PRIVMSG %s :%s\r\n", sender.nick, sender.user, sender.host, target, text)
} }
} }
storeMessage(ch, sender, target, text)
} }
func (c *Client) checkRegistration() { func (c *Client) checkRegistration() {
//log.Printf("checkRegistration called: nick=%q user=%q registered=%v", c.nick, c.user, c.registered)
if c.nick != "" && c.user != "" && !c.registered { if c.nick != "" && c.user != "" && !c.registered {
if c.saslRequired && !c.saslComplete { if c.saslRequired && !c.saslComplete {
return return
@ -568,9 +713,13 @@ func (c *Client) checkRegistration() {
c.fwrite(":server 003 %s :This server was created just now\r\n", c.nick) c.fwrite(":server 003 %s :This server was created just now\r\n", c.nick)
c.fwrite(":server 004 %s server irc 0.1\r\n", c.nick) c.fwrite(":server 004 %s server irc 0.1\r\n", c.nick)
fmt.Fprintf(c.conn, ":server 375 %s :- Message of the Day -\r\n", c.nick) fmt.Fprintf(c.conn, ":server 375 %s :- Message of the Day -\r\n", c.nick)
if motdString != nil {
for _, i := range motdString { for _, i := range motdString {
c.fwrite(":server 372 %s :- %s\r\n", c.nick, i) c.fwrite(":server 372 %s :- %s\r\n", c.nick, i)
} }
} else {
c.fwrite("%s :MOTD File is missing", c.nick)
}
fmt.Fprintf(c.conn, ":server 376 %s :End of MOTD\r\n", c.nick) fmt.Fprintf(c.conn, ":server 376 %s :End of MOTD\r\n", c.nick)
} }
} }
@ -633,6 +782,20 @@ func (c *Client) handleList() {
// End of list // End of list
c.fwrite(":server 323 %s :End of /LIST\r\n", c.nick) c.fwrite(":server 323 %s :End of /LIST\r\n", c.nick)
} }
func storeMessage(ch *Channel, sender *Client, target, text string) {
msg := HistoryMessage{
MsgID: fmt.Sprintf("%d", time.Now().UnixNano()),
Time: time.Now(),
Prefix: fmt.Sprintf("%s!%s@%s", sender.nick, sender.user, sender.host),
Command: "PRIVMSG",
Params: []string{target},
Text: text,
}
ch.history = append(ch.history, msg)
if len(ch.history) > 1000 {
ch.history = ch.history[1:] // drop oldest
}
}
func nicknameExists(nick string) bool { func nicknameExists(nick string) bool {
cliMu.RLock() cliMu.RLock()
defer cliMu.RUnlock() defer cliMu.RUnlock()
@ -646,11 +809,13 @@ func (c *Client) cleanup() {
for name, ch := range c.channels { for name, ch := range c.channels {
ch.mu.Lock() ch.mu.Lock()
delete(ch.members, c.nick) delete(ch.members, c.nick)
for _, member := range ch.members { for nick, member := range ch.members {
member.fwrite(":%s PART %s\r\n", c.nick, name) member.fwrite(":%s PART %s\r\n", c.nick, name)
if member == c {
delete(ch.members, nick)
}
} }
ch.mu.Unlock() ch.mu.Unlock()
broadcastNames(ch)
delete(c.channels, name) delete(c.channels, name)
} }
cliMu.RLock() cliMu.RLock()
@ -702,7 +867,7 @@ func normalizeChannelName(name string) string {
func checkCredentials(user, pass string) bool { func checkCredentials(user, pass string) bool {
file, err := os.Open("accounts.txt") file, err := os.Open("accounts.txt")
if err != nil { if err != nil {
log.Printf("checkCredentials: open error: %v", err) log.Printf("open error: %v", err)
return false return false
} }
defer file.Close() defer file.Close()

View File

@ -1,4 +1,4 @@
welcome to thinIRC welcome to Gocirc!
plz follow the rules: plz follow the rules:
no discriminating by religion no discriminating by religion
no racial slurs allowed no racial slurs allowed