diff --git a/Server/api/channel_handler.go b/Server/api/channel_handler.go index 201c14f6..e8c2e5d4 100644 --- a/Server/api/channel_handler.go +++ b/Server/api/channel_handler.go @@ -6,6 +6,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/owncord/server/db" + "github.com/owncord/server/permissions" ) const ( @@ -24,9 +25,28 @@ func MountChannelRoutes(r chi.Router, database *db.DB) { r.With(AuthMiddleware(database)).Get("/api/v1/search", handleSearch(database)) } +// hasChannelPermREST checks whether the role has the given permission on the channel, +// accounting for Administrator bypass and channel overrides. +func hasChannelPermREST(database *db.DB, role *db.Role, channelID, perm int64) bool { + if role == nil { + return false + } + if permissions.HasAdmin(role.Permissions) { + return true + } + allow, deny, err := database.GetChannelPermissions(channelID, role.ID) + if err != nil { + return false + } + effective := permissions.EffectivePerms(role.Permissions, allow, deny) + return effective&perm == perm +} + // handleListChannels returns all channels the authenticated user can see. func handleListChannels(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + role, _ := r.Context().Value(RoleKey).(*db.Role) + channels, err := database.ListChannels() if err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ @@ -35,7 +55,18 @@ func handleListChannels(database *db.DB) http.HandlerFunc { }) return } - writeJSON(w, http.StatusOK, channels) + + // Filter channels by READ_MESSAGES permission. + var visible []db.Channel + for _, ch := range channels { + if hasChannelPermREST(database, role, ch.ID, permissions.ReadMessages) { + visible = append(visible, ch) + } + } + if visible == nil { + visible = []db.Channel{} + } + writeJSON(w, http.StatusOK, visible) } } @@ -64,6 +95,16 @@ func handleGetMessages(database *db.DB) http.HandlerFunc { return } + // Permission check: user must have READ_MESSAGES on this channel. + role, _ := r.Context().Value(RoleKey).(*db.Role) + if !hasChannelPermREST(database, role, channelID, permissions.ReadMessages) { + writeJSON(w, http.StatusForbidden, errorResponse{ + Error: "FORBIDDEN", + Message: "no permission to view this channel", + }) + return + } + // Parse query params. before := int64(0) if raw := r.URL.Query().Get("before"); raw != "" { @@ -169,10 +210,22 @@ func handleSearch(database *db.DB) http.HandlerFunc { return } + // Post-filter results by READ_MESSAGES permission on each channel. + role, _ := r.Context().Value(RoleKey).(*db.Role) + var filtered []db.MessageSearchResult + for _, res := range results { + if hasChannelPermREST(database, role, res.ChannelID, permissions.ReadMessages) { + filtered = append(filtered, res) + } + } + if filtered == nil { + filtered = []db.MessageSearchResult{} + } + type response struct { Results []db.MessageSearchResult `json:"results"` } - writeJSON(w, http.StatusOK, response{Results: results}) + writeJSON(w, http.StatusOK, response{Results: filtered}) } } diff --git a/Server/api/router.go b/Server/api/router.go index 0d75e317..56882176 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -38,6 +38,7 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string) http.Handler { // Versioned API routes. r.Route("/api/v1", func(r chi.Router) { + r.Get("/health", handleHealth(ver)) r.Get("/info", handleInfo(cfg, ver)) }) diff --git a/Server/db/auth_queries.go b/Server/db/auth_queries.go index f96b27a8..9eb3a2bd 100644 --- a/Server/db/auth_queries.go +++ b/Server/db/auth_queries.go @@ -293,13 +293,17 @@ type MemberSummary struct { Username string `json:"username"` Avatar *string `json:"avatar"` Status string `json:"status"` - RoleID int64 `json:"role_id"` + Role string `json:"role"` } // ListMembers returns all non-banned users as lightweight summaries. func (d *DB) ListMembers() ([]MemberSummary, error) { rows, err := d.sqlDB.Query( - `SELECT id, username, avatar, status, role_id FROM users WHERE banned = 0 ORDER BY username ASC`, + `SELECT u.id, u.username, u.avatar, u.status, r.name + FROM users u + JOIN roles r ON u.role_id = r.id + WHERE u.banned = 0 + ORDER BY u.username ASC`, ) if err != nil { return nil, fmt.Errorf("ListMembers: %w", err) @@ -309,7 +313,7 @@ func (d *DB) ListMembers() ([]MemberSummary, error) { var members []MemberSummary for rows.Next() { var m MemberSummary - if err := rows.Scan(&m.ID, &m.Username, &m.Avatar, &m.Status, &m.RoleID); err != nil { + if err := rows.Scan(&m.ID, &m.Username, &m.Avatar, &m.Status, &m.Role); err != nil { return nil, fmt.Errorf("ListMembers scan: %w", err) } members = append(members, m) diff --git a/Server/ws/handlers.go b/Server/ws/handlers.go index 3ff7bd52..14f72122 100644 --- a/Server/ws/handlers.go +++ b/Server/ws/handlers.go @@ -105,6 +105,8 @@ func (h *Hub) handleMessage(c *Client, raw []byte) { h.handleVoiceICE(c, env.Payload) case "soundboard_play": h.handleSoundboard(c, env.Payload) + case "ping": + c.sendMsg(buildJSON(map[string]any{"type": "pong"})) default: slog.Warn("ws handleMessage unknown type", "type", env.Type, "user_id", c.userID) c.sendMsg(buildErrorMsg("UNKNOWN_TYPE", fmt.Sprintf("unknown message type: %s", env.Type))) @@ -198,7 +200,7 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) { c.sendMsg(buildChatSendOK(reqID, msgID, msg.Timestamp)) // Broadcast to channel. - broadcast := buildChatMessage(msgID, channelID, c.userID, username, avatar, content, msg.Timestamp, p.ReplyTo) + broadcast := buildChatMessage(msgID, channelID, c.userID, username, avatar, content, msg.Timestamp, p.ReplyTo, nil) h.BroadcastToChannel(channelID, broadcast) } @@ -430,6 +432,13 @@ func (h *Hub) handleChannelFocus(c *Client, payload json.RawMessage) { if err != nil || chID <= 0 { return } + + // Permission check: user must have READ_MESSAGES on the target channel. + if !h.hasChannelPerm(c, chID, permissions.ReadMessages) { + c.sendMsg(buildErrorMsg("FORBIDDEN", "no permission to view this channel")) + return + } + c.mu.Lock() c.channelID = chID c.mu.Unlock() diff --git a/Server/ws/messages.go b/Server/ws/messages.go index 017b4030..deeff321 100644 --- a/Server/ws/messages.go +++ b/Server/ws/messages.go @@ -35,6 +35,17 @@ func buildErrorMsg(code, message string) []byte { }) } +// buildAuthError produces an auth_error envelope per PROTOCOL.md. +// The client treats this type as non-recoverable and stops reconnecting. +func buildAuthError(message string) []byte { + return buildJSON(map[string]any{ + "type": "auth_error", + "payload": map[string]string{ + "message": message, + }, + }) +} + // buildPresenceMsg constructs a presence broadcast payload. func buildPresenceMsg(userID int64, status string) []byte { return buildJSON(map[string]any{ @@ -47,7 +58,7 @@ func buildPresenceMsg(userID int64, status string) []byte { } // buildMemberJoin constructs a member_join broadcast for when a user comes online. -func buildMemberJoin(user *db.User) []byte { +func buildMemberJoin(user *db.User, roleName string) []byte { var avatarVal any if user.Avatar != nil { avatarVal = *user.Avatar @@ -55,21 +66,25 @@ func buildMemberJoin(user *db.User) []byte { return buildJSON(map[string]any{ "type": "member_join", "payload": map[string]any{ - "id": user.ID, - "username": user.Username, - "avatar": avatarVal, - "status": "online", - "role_id": user.RoleID, + "user": map[string]any{ + "id": user.ID, + "username": user.Username, + "avatar": avatarVal, + "role": roleName, + }, }, }) } // buildChatMessage constructs a chat_message broadcast envelope. -func buildChatMessage(msgID, channelID, userID int64, username string, avatar *string, content string, timestamp string, replyTo *int64) []byte { +func buildChatMessage(msgID, channelID, userID int64, username string, avatar *string, content string, timestamp string, replyTo *int64, attachments []map[string]any) []byte { var avatarVal any if avatar != nil { avatarVal = *avatar } + if attachments == nil { + attachments = []map[string]any{} + } return buildJSON(map[string]any{ "type": "chat_message", "payload": map[string]any{ @@ -80,9 +95,10 @@ func buildChatMessage(msgID, channelID, userID int64, username string, avatar *s "username": username, "avatar": avatarVal, }, - "content": content, - "reply_to": replyTo, - "timestamp": timestamp, + "content": content, + "reply_to": replyTo, + "timestamp": timestamp, + "attachments": attachments, }, }) } diff --git a/Server/ws/serve.go b/Server/ws/serve.go index 3028e40e..657af64d 100644 --- a/Server/ws/serve.go +++ b/Server/ws/serve.go @@ -43,6 +43,12 @@ func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string) http.HandlerFun c := newClient(hub, conn, user, tokenHash) hub.Register(c) + // Look up role name for protocol-compliant payloads. + roleName := "member" + if role, roleErr := database.GetRoleByID(user.RoleID); roleErr == nil && role != nil { + roleName = role.Name + } + slog.Info("websocket connected", "username", user.Username, "user_id", user.ID, "remote", r.RemoteAddr) _ = database.LogAudit(user.ID, "ws_connect", "user", user.ID, "WebSocket connected from "+r.RemoteAddr) @@ -53,12 +59,12 @@ func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string) http.HandlerFun // Send auth_ok followed by the ready payload. ctx := r.Context() - _ = conn.Write(ctx, websocket.MessageText, buildAuthOK(database, user)) + _ = conn.Write(ctx, websocket.MessageText, buildAuthOK(database, user, roleName)) if ready, readyErr := buildReady(database); readyErr == nil { _ = conn.Write(ctx, websocket.MessageText, ready) } - hub.BroadcastToAll(buildMemberJoin(user)) + hub.BroadcastToAll(buildMemberJoin(user, roleName)) hub.BroadcastToAll(buildPresenceMsg(user.ID, "online")) // writePump runs in background; readPump blocks. @@ -126,11 +132,11 @@ func authenticateConn(conn *websocket.Conn, database *db.DB) (*db.User, string, var env envelope if err := json.Unmarshal(raw, &env); err != nil { - _ = conn.Write(ctx, websocket.MessageText, buildErrorMsg("AUTH_ERROR", "invalid message")) + _ = conn.Write(ctx, websocket.MessageText, buildAuthError( "invalid message")) return nil, "", fmt.Errorf("auth: invalid JSON: %w", err) } if env.Type != "auth" { - _ = conn.Write(ctx, websocket.MessageText, buildErrorMsg("AUTH_ERROR", "first message must be auth")) + _ = conn.Write(ctx, websocket.MessageText, buildAuthError( "first message must be auth")) return nil, "", fmt.Errorf("auth: unexpected type %q", env.Type) } @@ -138,25 +144,25 @@ func authenticateConn(conn *websocket.Conn, database *db.DB) (*db.User, string, Token string `json:"token"` } if err := json.Unmarshal(env.Payload, &p); err != nil || p.Token == "" { - _ = conn.Write(ctx, websocket.MessageText, buildErrorMsg("AUTH_ERROR", "missing token")) + _ = conn.Write(ctx, websocket.MessageText, buildAuthError( "missing token")) return nil, "", fmt.Errorf("auth: missing token") } hash := auth.HashToken(p.Token) sess, err := database.GetSessionByTokenHash(hash) if err != nil || sess == nil { - _ = conn.Write(ctx, websocket.MessageText, buildErrorMsg("AUTH_ERROR", "invalid token")) + _ = conn.Write(ctx, websocket.MessageText, buildAuthError( "invalid token")) return nil, "", fmt.Errorf("auth: invalid session") } if auth.IsSessionExpired(sess.ExpiresAt) { - _ = conn.Write(ctx, websocket.MessageText, buildErrorMsg("AUTH_ERROR", "session expired")) + _ = conn.Write(ctx, websocket.MessageText, buildAuthError( "session expired")) return nil, "", fmt.Errorf("auth: session expired") } user, err := database.GetUserByID(sess.UserID) if err != nil || user == nil { - _ = conn.Write(ctx, websocket.MessageText, buildErrorMsg("AUTH_ERROR", "user not found")) + _ = conn.Write(ctx, websocket.MessageText, buildAuthError( "user not found")) return nil, "", fmt.Errorf("auth: user not found") } @@ -169,7 +175,7 @@ func authenticateConn(conn *websocket.Conn, database *db.DB) (*db.User, string, } // buildAuthOK constructs the auth_ok server→client message. -func buildAuthOK(database *db.DB, user *db.User) []byte { +func buildAuthOK(database *db.DB, user *db.User, roleName string) []byte { serverName := "OwnCord Server" motd := "Welcome!" _ = database.QueryRow("SELECT value FROM settings WHERE key='server_name'").Scan(&serverName) @@ -188,6 +194,7 @@ func buildAuthOK(database *db.DB, user *db.User) []byte { "username": user.Username, "avatar": avatarVal, "status": user.Status, + "role": roleName, }, "server_name": serverName, "motd": motd,