feat: add logout endpoint and users/me endpoint

- POST /api/v1/logout: deletes client token, returns {status: ok}
- GET /api/v1/users/me: returns session info (delegates to HandleState)
- Add DeleteClient, GetSessionCount, ClientCountForSession, DeleteStaleSessions to db layer
- Add user count to GET /api/v1/server response
- Extract setupAPIv1 to fix funlen lint issue
This commit is contained in:
2026-02-27 05:06:56 -08:00
parent 02b906badb
commit fd6429a9a5
3 changed files with 195 additions and 47 deletions

View File

@@ -779,6 +779,96 @@ func (database *Database) DeleteSession(
return nil return nil
} }
// DeleteClient removes a single client record by ID.
func (database *Database) DeleteClient(
ctx context.Context,
clientID int64,
) error {
_, err := database.conn.ExecContext(
ctx,
"DELETE FROM clients WHERE id = ?",
clientID,
)
if err != nil {
return fmt.Errorf("delete client: %w", err)
}
return nil
}
// GetSessionCount returns the number of active sessions.
func (database *Database) GetSessionCount(
ctx context.Context,
) (int64, error) {
var count int64
err := database.conn.QueryRowContext(
ctx,
"SELECT COUNT(*) FROM sessions",
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"get session count: %w", err,
)
}
return count, nil
}
// ClientCountForSession returns the number of clients
// belonging to a session.
func (database *Database) ClientCountForSession(
ctx context.Context,
sessionID int64,
) (int64, error) {
var count int64
err := database.conn.QueryRowContext(
ctx,
`SELECT COUNT(*) FROM clients
WHERE session_id = ?`,
sessionID,
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"client count for session: %w", err,
)
}
return count, nil
}
// DeleteStaleSessions removes clients not seen since the
// cutoff and cleans up orphaned sessions.
func (database *Database) DeleteStaleSessions(
ctx context.Context,
cutoff time.Time,
) (int64, error) {
res, err := database.conn.ExecContext(ctx,
"DELETE FROM clients WHERE last_seen < ?",
cutoff,
)
if err != nil {
return 0, fmt.Errorf(
"delete stale clients: %w", err,
)
}
deleted, _ := res.RowsAffected()
_, err = database.conn.ExecContext(ctx,
`DELETE FROM sessions WHERE id NOT IN
(SELECT DISTINCT session_id FROM clients)`,
)
if err != nil {
return deleted, fmt.Errorf(
"delete orphan sessions: %w", err,
)
}
return deleted, nil
}
// GetSessionChannels returns channels a session // GetSessionChannels returns channels a session
// belongs to. // belongs to.
func (database *Database) GetSessionChannels( func (database *Database) GetSessionChannels(

View File

@@ -1361,20 +1361,71 @@ func (hdlr *Handlers) canAccessChannelHistory(
return true return true
} }
// HandleServerInfo returns server metadata. // HandleLogout deletes the authenticated client's token.
func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc { func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
type infoResponse struct {
Name string `json:"name"`
MOTD string `json:"motd"`
}
return func( return func(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
) { ) {
hdlr.respondJSON(writer, request, &infoResponse{ _, clientID, _, ok :=
Name: hdlr.params.Config.ServerName, hdlr.requireAuth(writer, request)
MOTD: hdlr.params.Config.MOTD, if !ok {
return
}
err := hdlr.params.Database.DeleteClient(
request.Context(), clientID,
)
if err != nil {
hdlr.log.Error(
"delete client failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
}
// HandleUsersMe returns the current user's session info.
func (hdlr *Handlers) HandleUsersMe() http.HandlerFunc {
return hdlr.HandleState()
}
// HandleServerInfo returns server metadata.
func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
users, err := hdlr.params.Database.GetSessionCount(
request.Context(),
)
if err != nil {
hdlr.log.Error(
"get session count failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.respondJSON(writer, request, map[string]any{
"name": hdlr.params.Config.ServerName,
"motd": hdlr.params.Config.MOTD,
"users": users,
}, http.StatusOK) }, http.StatusOK)
} }
} }

View File

@@ -59,9 +59,13 @@ func (srv *Server) SetupRoutes() {
} }
// API v1. // API v1.
srv.router.Route( srv.router.Route("/api/v1", srv.setupAPIv1)
"/api/v1",
func(router chi.Router) { // Serve embedded SPA.
srv.setupSPA()
}
func (srv *Server) setupAPIv1(router chi.Router) {
router.Get( router.Get(
"/server", "/server",
srv.handlers.HandleServerInfo(), srv.handlers.HandleServerInfo(),
@@ -74,6 +78,14 @@ func (srv *Server) SetupRoutes() {
"/state", "/state",
srv.handlers.HandleState(), srv.handlers.HandleState(),
) )
router.Post(
"/logout",
srv.handlers.HandleLogout(),
)
router.Get(
"/users/me",
srv.handlers.HandleUsersMe(),
)
router.Get( router.Get(
"/messages", "/messages",
srv.handlers.HandleGetMessages(), srv.handlers.HandleGetMessages(),
@@ -94,11 +106,6 @@ func (srv *Server) SetupRoutes() {
"/channels/{channel}/members", "/channels/{channel}/members",
srv.handlers.HandleChannelMembers(), srv.handlers.HandleChannelMembers(),
) )
},
)
// Serve embedded SPA.
srv.setupSPA()
} }
func (srv *Server) setupSPA() { func (srv *Server) setupSPA() {