feat: MVP two-user chat via embedded SPA (#9)
All checks were successful
check / check (push) Successful in 1m51s

Backend:
- Session/client UUID model: sessions table (uuid, nick, signing_key),
  clients table (uuid, session_id, token) with per-client message queues
- MOTD delivery as IRC numeric messages (375/372/376) on connect
- EnqueueToSession fans out to all clients of a session
- EnqueueToClient for targeted delivery (MOTD)
- All queries updated for session/client model

SPA client:
- Long-poll loop (15s timeout) instead of setInterval
- IRC message envelope parsing (command/from/to/body)
- Display JOIN/PART/NICK/TOPIC/QUIT system messages
- Nick change via /nick command
- Topic display in header bar
- Unread count badges on inactive tabs
- Auto-rejoin channels on reconnect (localStorage)
- Connection status indicator
- Message deduplication by UUID
- Channel history loaded on join
- /topic command support

Closes #9
This commit is contained in:
clawbot
2026-02-27 02:21:48 -08:00
parent 2d08a8476f
commit 32419fb1f7
8 changed files with 921 additions and 880 deletions

View File

@@ -1,6 +1,7 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
@@ -37,35 +38,37 @@ func (hdlr *Handlers) maxBodySize() int64 {
return defaultMaxBodySize
}
// authUser extracts the user from the Authorization header.
func (hdlr *Handlers) authUser(
// authSession extracts the session from the client token.
func (hdlr *Handlers) authSession(
request *http.Request,
) (int64, string, error) {
) (int64, int64, string, error) {
auth := request.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
return 0, "", errUnauthorized
return 0, 0, "", errUnauthorized
}
token := strings.TrimPrefix(auth, "Bearer ")
if token == "" {
return 0, "", errUnauthorized
return 0, 0, "", errUnauthorized
}
uid, nick, err := hdlr.params.Database.GetUserByToken(
request.Context(), token,
)
sessionID, clientID, nick, err :=
hdlr.params.Database.GetSessionByToken(
request.Context(), token,
)
if err != nil {
return 0, "", fmt.Errorf("auth: %w", err)
return 0, 0, "", fmt.Errorf("auth: %w", err)
}
return uid, nick, nil
return sessionID, clientID, nick, nil
}
func (hdlr *Handlers) requireAuth(
writer http.ResponseWriter,
request *http.Request,
) (int64, string, bool) {
uid, nick, err := hdlr.authUser(request)
) (int64, int64, string, bool) {
sessionID, clientID, nick, err :=
hdlr.authSession(request)
if err != nil {
hdlr.respondError(
writer, request,
@@ -73,19 +76,19 @@ func (hdlr *Handlers) requireAuth(
http.StatusUnauthorized,
)
return 0, "", false
return 0, 0, "", false
}
return uid, nick, true
return sessionID, clientID, nick, true
}
// fanOut stores a message and enqueues it to all specified
// user IDs, then notifies them.
// session IDs, then notifies them.
func (hdlr *Handlers) fanOut(
request *http.Request,
command, from, target string,
body json.RawMessage,
userIDs []int64,
sessionIDs []int64,
) (string, error) {
dbID, msgUUID, err := hdlr.params.Database.InsertMessage(
request.Context(), command, from, target, body, nil,
@@ -94,16 +97,16 @@ func (hdlr *Handlers) fanOut(
return "", fmt.Errorf("insert message: %w", err)
}
for _, uid := range userIDs {
enqErr := hdlr.params.Database.EnqueueMessage(
request.Context(), uid, dbID,
for _, sid := range sessionIDs {
enqErr := hdlr.params.Database.EnqueueToSession(
request.Context(), sid, dbID,
)
if enqErr != nil {
hdlr.log.Error("enqueue failed",
"error", enqErr, "user_id", uid)
"error", enqErr, "session_id", sid)
}
hdlr.broker.Notify(uid)
hdlr.broker.Notify(sid)
}
return msgUUID, nil
@@ -114,10 +117,10 @@ func (hdlr *Handlers) fanOutSilent(
request *http.Request,
command, from, target string,
body json.RawMessage,
userIDs []int64,
sessionIDs []int64,
) error {
_, err := hdlr.fanOut(
request, command, from, target, body, userIDs,
request, command, from, target, body, sessionIDs,
)
return err
@@ -125,16 +128,6 @@ func (hdlr *Handlers) fanOutSilent(
// HandleCreateSession creates a new user session.
func (hdlr *Handlers) HandleCreateSession() http.HandlerFunc {
type createRequest struct {
Nick string `json:"nick"`
}
type createResponse struct {
ID int64 `json:"id"`
Nick string `json:"nick"`
Token string `json:"token"`
}
return func(
writer http.ResponseWriter,
request *http.Request,
@@ -143,82 +136,174 @@ func (hdlr *Handlers) HandleCreateSession() http.HandlerFunc {
writer, request.Body, hdlr.maxBodySize(),
)
var payload createRequest
err := json.NewDecoder(request.Body).Decode(&payload)
if err != nil {
hdlr.respondError(
writer, request,
"invalid request body",
http.StatusBadRequest,
)
return
}
payload.Nick = strings.TrimSpace(payload.Nick)
if !validNickRe.MatchString(payload.Nick) {
hdlr.respondError(
writer, request,
"invalid nick format",
http.StatusBadRequest,
)
return
}
userID, token, err := hdlr.params.Database.CreateUser(
request.Context(), payload.Nick,
)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
hdlr.respondError(
writer, request,
"nick already taken",
http.StatusConflict,
)
return
}
hdlr.log.Error(
"create user failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.respondJSON(
writer, request,
&createResponse{
ID: userID,
Nick: payload.Nick,
Token: token,
},
http.StatusCreated,
)
hdlr.handleCreateSession(writer, request)
}
}
// HandleState returns the current user's info and channels.
func (hdlr *Handlers) handleCreateSession(
writer http.ResponseWriter,
request *http.Request,
) {
type createRequest struct {
Nick string `json:"nick"`
}
var payload createRequest
err := json.NewDecoder(request.Body).Decode(&payload)
if err != nil {
hdlr.respondError(
writer, request,
"invalid request body",
http.StatusBadRequest,
)
return
}
payload.Nick = strings.TrimSpace(payload.Nick)
if !validNickRe.MatchString(payload.Nick) {
hdlr.respondError(
writer, request,
"invalid nick format",
http.StatusBadRequest,
)
return
}
sessionID, clientID, token, err :=
hdlr.params.Database.CreateSession(
request.Context(), payload.Nick,
)
if err != nil {
hdlr.handleCreateSessionError(
writer, request, err,
)
return
}
hdlr.deliverMOTD(request, clientID, sessionID)
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": payload.Nick,
"token": token,
}, http.StatusCreated)
}
func (hdlr *Handlers) handleCreateSessionError(
writer http.ResponseWriter,
request *http.Request,
err error,
) {
if strings.Contains(err.Error(), "UNIQUE") {
hdlr.respondError(
writer, request,
"nick already taken",
http.StatusConflict,
)
return
}
hdlr.log.Error(
"create session failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
}
// deliverMOTD sends the MOTD as IRC numeric messages to a
// new client.
func (hdlr *Handlers) deliverMOTD(
request *http.Request,
clientID, sessionID int64,
) {
motd := hdlr.params.Config.MOTD
serverName := hdlr.params.Config.ServerName
if serverName == "" {
serverName = "chat"
}
if motd == "" {
return
}
ctx := request.Context()
hdlr.enqueueNumeric(
ctx, clientID, "375", serverName,
"- "+serverName+" Message of the Day -",
)
for line := range strings.SplitSeq(motd, "\n") {
hdlr.enqueueNumeric(
ctx, clientID, "372", serverName,
"- "+line,
)
}
hdlr.enqueueNumeric(
ctx, clientID, "376", serverName,
"End of /MOTD command.",
)
hdlr.broker.Notify(sessionID)
}
func (hdlr *Handlers) enqueueNumeric(
ctx context.Context,
clientID int64,
command, serverName, text string,
) {
body, err := json.Marshal([]string{text})
if err != nil {
hdlr.log.Error(
"marshal numeric body", "error", err,
)
return
}
dbID, _, insertErr := hdlr.params.Database.InsertMessage(
ctx, command, serverName, "",
json.RawMessage(body), nil,
)
if insertErr != nil {
hdlr.log.Error(
"insert numeric message", "error", insertErr,
)
return
}
_ = hdlr.params.Database.EnqueueToClient(
ctx, clientID, dbID,
)
}
// HandleState returns the current session's info and
// channels.
func (hdlr *Handlers) HandleState() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
uid, nick, ok := hdlr.requireAuth(writer, request)
sessionID, _, nick, ok :=
hdlr.requireAuth(writer, request)
if !ok {
return
}
channels, err := hdlr.params.Database.ListChannels(
request.Context(), uid,
request.Context(), sessionID,
)
if err != nil {
hdlr.log.Error(
@@ -234,7 +319,7 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc {
}
hdlr.respondJSON(writer, request, map[string]any{
"id": uid,
"id": sessionID,
"nick": nick,
"channels": channels,
}, http.StatusOK)
@@ -247,7 +332,7 @@ func (hdlr *Handlers) HandleListAllChannels() http.HandlerFunc {
writer http.ResponseWriter,
request *http.Request,
) {
_, _, ok := hdlr.requireAuth(writer, request)
_, _, _, ok := hdlr.requireAuth(writer, request)
if !ok {
return
}
@@ -280,7 +365,7 @@ func (hdlr *Handlers) HandleChannelMembers() http.HandlerFunc {
writer http.ResponseWriter,
request *http.Request,
) {
_, _, ok := hdlr.requireAuth(writer, request)
_, _, _, ok := hdlr.requireAuth(writer, request)
if !ok {
return
}
@@ -328,7 +413,8 @@ func (hdlr *Handlers) HandleGetMessages() http.HandlerFunc {
writer http.ResponseWriter,
request *http.Request,
) {
uid, _, ok := hdlr.requireAuth(writer, request)
sessionID, clientID, _, ok :=
hdlr.requireAuth(writer, request)
if !ok {
return
}
@@ -349,7 +435,7 @@ func (hdlr *Handlers) HandleGetMessages() http.HandlerFunc {
}
msgs, lastQID, err := hdlr.params.Database.PollMessages(
request.Context(), uid,
request.Context(), clientID,
afterID, pollMessageLimit,
)
if err != nil {
@@ -374,17 +460,20 @@ func (hdlr *Handlers) HandleGetMessages() http.HandlerFunc {
return
}
hdlr.longPoll(writer, request, uid, afterID, timeout)
hdlr.longPoll(
writer, request,
sessionID, clientID, afterID, timeout,
)
}
}
func (hdlr *Handlers) longPoll(
writer http.ResponseWriter,
request *http.Request,
uid, afterID int64,
sessionID, clientID, afterID int64,
timeout int,
) {
waitCh := hdlr.broker.Wait(uid)
waitCh := hdlr.broker.Wait(sessionID)
timer := time.NewTimer(
time.Duration(timeout) * time.Second,
@@ -396,15 +485,15 @@ func (hdlr *Handlers) longPoll(
case <-waitCh:
case <-timer.C:
case <-request.Context().Done():
hdlr.broker.Remove(uid, waitCh)
hdlr.broker.Remove(sessionID, waitCh)
return
}
hdlr.broker.Remove(uid, waitCh)
hdlr.broker.Remove(sessionID, waitCh)
msgs, lastQID, err := hdlr.params.Database.PollMessages(
request.Context(), uid,
request.Context(), clientID,
afterID, pollMessageLimit,
)
if err != nil {
@@ -443,7 +532,8 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
writer, request.Body, hdlr.maxBodySize(),
)
uid, nick, ok := hdlr.requireAuth(writer, request)
sessionID, _, nick, ok :=
hdlr.requireAuth(writer, request)
if !ok {
return
}
@@ -492,7 +582,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
}
hdlr.dispatchCommand(
writer, request, uid, nick,
writer, request, sessionID, nick,
payload.Command, payload.To,
payload.Body, bodyLines,
)
@@ -502,7 +592,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
func (hdlr *Handlers) dispatchCommand(
writer http.ResponseWriter,
request *http.Request,
uid int64,
sessionID int64,
nick, command, target string,
body json.RawMessage,
bodyLines func() []string,
@@ -510,20 +600,20 @@ func (hdlr *Handlers) dispatchCommand(
switch command {
case cmdPrivmsg, "NOTICE":
hdlr.handlePrivmsg(
writer, request, uid, nick,
writer, request, sessionID, nick,
command, target, body, bodyLines,
)
case "JOIN":
hdlr.handleJoin(
writer, request, uid, nick, target,
writer, request, sessionID, nick, target,
)
case "PART":
hdlr.handlePart(
writer, request, uid, nick, target, body,
writer, request, sessionID, nick, target, body,
)
case "NICK":
hdlr.handleNick(
writer, request, uid, nick, bodyLines,
writer, request, sessionID, nick, bodyLines,
)
case "TOPIC":
hdlr.handleTopic(
@@ -531,7 +621,7 @@ func (hdlr *Handlers) dispatchCommand(
)
case "QUIT":
hdlr.handleQuit(
writer, request, uid, nick, body,
writer, request, sessionID, nick, body,
)
case "PING":
hdlr.respondJSON(writer, request,
@@ -552,7 +642,7 @@ func (hdlr *Handlers) dispatchCommand(
func (hdlr *Handlers) handlePrivmsg(
writer http.ResponseWriter,
request *http.Request,
uid int64,
sessionID int64,
nick, command, target string,
body json.RawMessage,
bodyLines func() []string,
@@ -580,7 +670,7 @@ func (hdlr *Handlers) handlePrivmsg(
if strings.HasPrefix(target, "#") {
hdlr.handleChannelMsg(
writer, request, uid, nick,
writer, request, sessionID, nick,
command, target, body,
)
@@ -588,7 +678,7 @@ func (hdlr *Handlers) handlePrivmsg(
}
hdlr.handleDirectMsg(
writer, request, uid, nick,
writer, request, sessionID, nick,
command, target, body,
)
}
@@ -596,7 +686,7 @@ func (hdlr *Handlers) handlePrivmsg(
func (hdlr *Handlers) handleChannelMsg(
writer http.ResponseWriter,
request *http.Request,
uid int64,
sessionID int64,
nick, command, target string,
body json.RawMessage,
) {
@@ -614,7 +704,7 @@ func (hdlr *Handlers) handleChannelMsg(
}
isMember, err := hdlr.params.Database.IsChannelMember(
request.Context(), chID, uid,
request.Context(), chID, sessionID,
)
if err != nil {
hdlr.log.Error(
@@ -677,11 +767,11 @@ func (hdlr *Handlers) handleChannelMsg(
func (hdlr *Handlers) handleDirectMsg(
writer http.ResponseWriter,
request *http.Request,
uid int64,
sessionID int64,
nick, command, target string,
body json.RawMessage,
) {
targetUID, err := hdlr.params.Database.GetUserByNick(
targetSID, err := hdlr.params.Database.GetSessionByNick(
request.Context(), target,
)
if err != nil {
@@ -694,9 +784,9 @@ func (hdlr *Handlers) handleDirectMsg(
return
}
recipients := []int64{targetUID}
if targetUID != uid {
recipients = append(recipients, uid)
recipients := []int64{targetSID}
if targetSID != sessionID {
recipients = append(recipients, sessionID)
}
msgUUID, err := hdlr.fanOut(
@@ -721,7 +811,7 @@ func (hdlr *Handlers) handleDirectMsg(
func (hdlr *Handlers) handleJoin(
writer http.ResponseWriter,
request *http.Request,
uid int64,
sessionID int64,
nick, target string,
) {
if target == "" {
@@ -766,7 +856,7 @@ func (hdlr *Handlers) handleJoin(
}
err = hdlr.params.Database.JoinChannel(
request.Context(), chID, uid,
request.Context(), chID, sessionID,
)
if err != nil {
hdlr.log.Error(
@@ -800,7 +890,7 @@ func (hdlr *Handlers) handleJoin(
func (hdlr *Handlers) handlePart(
writer http.ResponseWriter,
request *http.Request,
uid int64,
sessionID int64,
nick, target string,
body json.RawMessage,
) {
@@ -841,7 +931,7 @@ func (hdlr *Handlers) handlePart(
)
err = hdlr.params.Database.PartChannel(
request.Context(), chID, uid,
request.Context(), chID, sessionID,
)
if err != nil {
hdlr.log.Error(
@@ -871,7 +961,7 @@ func (hdlr *Handlers) handlePart(
func (hdlr *Handlers) handleNick(
writer http.ResponseWriter,
request *http.Request,
uid int64,
sessionID int64,
nick string,
bodyLines func() []string,
) {
@@ -909,7 +999,7 @@ func (hdlr *Handlers) handleNick(
}
err := hdlr.params.Database.ChangeNick(
request.Context(), uid, newNick,
request.Context(), sessionID, newNick,
)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
@@ -934,7 +1024,7 @@ func (hdlr *Handlers) handleNick(
return
}
hdlr.broadcastNick(request, uid, nick, newNick)
hdlr.broadcastNick(request, sessionID, nick, newNick)
hdlr.respondJSON(writer, request,
map[string]string{
@@ -945,15 +1035,15 @@ func (hdlr *Handlers) handleNick(
func (hdlr *Handlers) broadcastNick(
request *http.Request,
uid int64,
sessionID int64,
oldNick, newNick string,
) {
channels, _ := hdlr.params.Database.
GetAllChannelMembershipsForUser(
request.Context(), uid,
GetSessionChannels(
request.Context(), sessionID,
)
notified := map[int64]bool{uid: true}
notified := map[int64]bool{sessionID: true}
nickBody, err := json.Marshal([]string{newNick})
if err != nil {
@@ -969,11 +1059,11 @@ func (hdlr *Handlers) broadcastNick(
json.RawMessage(nickBody), nil,
)
_ = hdlr.params.Database.EnqueueMessage(
request.Context(), uid, dbID,
_ = hdlr.params.Database.EnqueueToSession(
request.Context(), sessionID, dbID,
)
hdlr.broker.Notify(uid)
hdlr.broker.Notify(sessionID)
for _, chanInfo := range channels {
memberIDs, _ := hdlr.params.Database.
@@ -985,7 +1075,7 @@ func (hdlr *Handlers) broadcastNick(
if !notified[mid] {
notified[mid] = true
_ = hdlr.params.Database.EnqueueMessage(
_ = hdlr.params.Database.EnqueueToSession(
request.Context(), mid, dbID,
)
@@ -1077,13 +1167,13 @@ func (hdlr *Handlers) handleTopic(
func (hdlr *Handlers) handleQuit(
writer http.ResponseWriter,
request *http.Request,
uid int64,
sessionID int64,
nick string,
body json.RawMessage,
) {
channels, _ := hdlr.params.Database.
GetAllChannelMembershipsForUser(
request.Context(), uid,
GetSessionChannels(
request.Context(), sessionID,
)
notified := map[int64]bool{}
@@ -1103,10 +1193,10 @@ func (hdlr *Handlers) handleQuit(
)
for _, mid := range memberIDs {
if mid != uid && !notified[mid] {
if mid != sessionID && !notified[mid] {
notified[mid] = true
_ = hdlr.params.Database.EnqueueMessage(
_ = hdlr.params.Database.EnqueueToSession(
request.Context(), mid, dbID,
)
@@ -1115,7 +1205,7 @@ func (hdlr *Handlers) handleQuit(
}
_ = hdlr.params.Database.PartChannel(
request.Context(), chanInfo.ID, uid,
request.Context(), chanInfo.ID, sessionID,
)
_ = hdlr.params.Database.DeleteChannelIfEmpty(
@@ -1123,8 +1213,8 @@ func (hdlr *Handlers) handleQuit(
)
}
_ = hdlr.params.Database.DeleteUser(
request.Context(), uid,
_ = hdlr.params.Database.DeleteSession(
request.Context(), sessionID,
)
hdlr.respondJSON(writer, request,
@@ -1138,7 +1228,8 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc {
writer http.ResponseWriter,
request *http.Request,
) {
uid, nick, ok := hdlr.requireAuth(writer, request)
sessionID, _, nick, ok :=
hdlr.requireAuth(writer, request)
if !ok {
return
}
@@ -1155,7 +1246,7 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc {
}
if !hdlr.canAccessHistory(
writer, request, uid, nick, target,
writer, request, sessionID, nick, target,
) {
return
}
@@ -1198,12 +1289,12 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc {
func (hdlr *Handlers) canAccessHistory(
writer http.ResponseWriter,
request *http.Request,
uid int64,
sessionID int64,
nick, target string,
) bool {
if strings.HasPrefix(target, "#") {
return hdlr.canAccessChannelHistory(
writer, request, uid, target,
writer, request, sessionID, target,
)
}
@@ -1225,7 +1316,7 @@ func (hdlr *Handlers) canAccessHistory(
func (hdlr *Handlers) canAccessChannelHistory(
writer http.ResponseWriter,
request *http.Request,
uid int64,
sessionID int64,
target string,
) bool {
chID, err := hdlr.params.Database.GetChannelByName(
@@ -1242,7 +1333,7 @@ func (hdlr *Handlers) canAccessChannelHistory(
}
isMember, err := hdlr.params.Database.IsChannelMember(
request.Context(), chID, uid,
request.Context(), chID, sessionID,
)
if err != nil {
hdlr.log.Error(