Add comprehensive authentication and authorization enhancements: - tokens.go: New token management system for public task access and cloning * SHA-256 hashed token storage for security * Token generation, validation, and automatic cleanup * Support for public access and clone permissions - api_key.go: Extend User struct with Groups field * Lab group membership (ml-lab, nlp-group) * Integration with permission system for group-based access - flags.go: Security hardening - migrate to structured logging * Replace log.Printf with log/slog to prevent log injection attacks * Consistent structured output for all auth warnings * Safe handling of file paths and errors in logs - permissions.go: Add task sharing permission constants * PermissionTasksReadOwn: Access own tasks * PermissionTasksReadLab: Access lab group tasks * PermissionTasksReadAll: Admin/institution-wide access * PermissionTasksShare: Grant access to other users * PermissionTasksClone: Create copies of shared tasks * CanAccessTask() method with visibility checks - database.go: Improve error handling * Add structured error logging on row close failures
692 lines
21 KiB
Go
692 lines
21 KiB
Go
// Package groups provides HTTP handlers for lab group management
|
|
package groups
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
"github.com/jfraeys/fetch_ml/internal/auth"
|
|
"github.com/jfraeys/fetch_ml/internal/logging"
|
|
"github.com/jfraeys/fetch_ml/internal/storage"
|
|
)
|
|
|
|
// Handler provides group management HTTP handlers
|
|
type Handler struct {
|
|
db *storage.DB
|
|
logger *logging.Logger
|
|
}
|
|
|
|
// NewHandler creates a new groups handler
|
|
func NewHandler(db *storage.DB, logger *logging.Logger) *Handler {
|
|
return &Handler{
|
|
db: db,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// CreateGroupRequest represents a request to create a new group
|
|
type CreateGroupRequest struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
}
|
|
|
|
// CreateGroup handles POST /api/groups
|
|
func (h *Handler) CreateGroup(w http.ResponseWriter, r *http.Request) {
|
|
user := auth.GetUserFromContext(r.Context())
|
|
if user == nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Only admins can create groups
|
|
if !user.Admin {
|
|
http.Error(w, "forbidden: admin required", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
var req CreateGroupRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Name == "" {
|
|
http.Error(w, "name is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
group, err := h.db.CreateGroup(req.Name, req.Description, user.Name)
|
|
if err != nil {
|
|
h.logger.Error("failed to create group", "error", err)
|
|
http.Error(w, "failed to create group", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
h.logger.Info("group created", "group_id", group.ID, "name", group.Name, "created_by", user.Name)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
if err := json.NewEncoder(w).Encode(group); err != nil {
|
|
h.logger.Error("failed to encode response", "error", err)
|
|
}
|
|
}
|
|
|
|
// ListGroups handles GET /api/groups
|
|
func (h *Handler) ListGroups(w http.ResponseWriter, r *http.Request) {
|
|
user := auth.GetUserFromContext(r.Context())
|
|
if user == nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
groups, err := h.db.ListGroupsForUser(user.Name)
|
|
if err != nil {
|
|
h.logger.Error("failed to list groups", "error", err)
|
|
http.Error(w, "failed to list groups", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"groups": groups,
|
|
"count": len(groups),
|
|
}); err != nil {
|
|
h.logger.Error("failed to encode response", "error", err)
|
|
}
|
|
}
|
|
|
|
// CreateInvitation handles POST /api/groups/{id}/invitations
|
|
func (h *Handler) CreateInvitation(w http.ResponseWriter, r *http.Request) {
|
|
user := auth.GetUserFromContext(r.Context())
|
|
if user == nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
groupID := r.PathValue("id")
|
|
if groupID == "" {
|
|
http.Error(w, "group id required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check if user is group admin
|
|
isAdmin, err := h.db.IsGroupAdmin(user.Name, groupID)
|
|
if err != nil || !isAdmin {
|
|
http.Error(w, "forbidden: group admin required", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
InvitedUserID string `json:"invited_user_id"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.InvitedUserID == "" {
|
|
http.Error(w, "invited_user_id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
invitation, err := h.db.CreateGroupInvitation(groupID, req.InvitedUserID, user.Name)
|
|
if err != nil {
|
|
h.logger.Error("failed to create invitation", "error", err)
|
|
http.Error(w, "failed to create invitation", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
h.logger.Info("invitation created", "invitation_id", invitation.ID, "group_id", groupID, "invited_user", req.InvitedUserID)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
if err := json.NewEncoder(w).Encode(invitation); err != nil {
|
|
h.logger.Error("failed to encode response", "error", err)
|
|
}
|
|
}
|
|
|
|
// ListInvitations handles GET /api/invitations
|
|
func (h *Handler) ListInvitations(w http.ResponseWriter, r *http.Request) {
|
|
user := auth.GetUserFromContext(r.Context())
|
|
if user == nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
invitations, err := h.db.ListPendingInvitationsForUser(user.Name)
|
|
if err != nil {
|
|
h.logger.Error("failed to list invitations", "error", err)
|
|
http.Error(w, "failed to list invitations", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"invitations": invitations,
|
|
"count": len(invitations),
|
|
}); err != nil {
|
|
h.logger.Error("failed to encode response", "error", err)
|
|
}
|
|
}
|
|
|
|
// AcceptInvitation handles POST /api/invitations/{id}/accept
|
|
func (h *Handler) AcceptInvitation(w http.ResponseWriter, r *http.Request) {
|
|
user := auth.GetUserFromContext(r.Context())
|
|
if user == nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
invitationID := r.PathValue("id")
|
|
if invitationID == "" {
|
|
http.Error(w, "invitation id required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Verify invitation belongs to this user and is pending
|
|
invitation, err := h.db.GetInvitation(invitationID)
|
|
if err != nil {
|
|
http.Error(w, "invitation not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if invitation.InvitedUserID != user.Name {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if invitation.Status != "pending" {
|
|
http.Error(w, "invitation already processed", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
// Check if expired (7 days default)
|
|
if invitation.ExpiresAt != nil && time.Now().After(*invitation.ExpiresAt) {
|
|
http.Error(w, "invitation expired", http.StatusGone)
|
|
return
|
|
}
|
|
|
|
if err := h.db.AcceptInvitation(invitationID, user.Name); err != nil {
|
|
h.logger.Error("failed to accept invitation", "error", err)
|
|
http.Error(w, "failed to accept invitation", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
h.logger.Info("invitation accepted", "invitation_id", invitationID, "user", user.Name, "group_id", invitation.GroupID)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(map[string]string{
|
|
"status": "accepted",
|
|
"group_id": invitation.GroupID,
|
|
}); err != nil {
|
|
h.logger.Error("failed to encode response", "error", err)
|
|
}
|
|
}
|
|
|
|
// DeclineInvitation handles POST /api/invitations/{id}/decline
|
|
func (h *Handler) DeclineInvitation(w http.ResponseWriter, r *http.Request) {
|
|
user := auth.GetUserFromContext(r.Context())
|
|
if user == nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
invitationID := r.PathValue("id")
|
|
if invitationID == "" {
|
|
http.Error(w, "invitation id required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
invitation, err := h.db.GetInvitation(invitationID)
|
|
if err != nil {
|
|
http.Error(w, "invitation not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if invitation.InvitedUserID != user.Name {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if err := h.db.DeclineInvitation(invitationID, user.Name); err != nil {
|
|
h.logger.Error("failed to decline invitation", "error", err)
|
|
http.Error(w, "failed to decline invitation", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
h.logger.Info("invitation declined", "invitation_id", invitationID, "user", user.Name)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(map[string]string{
|
|
"status": "declined",
|
|
}); err != nil {
|
|
h.logger.Error("failed to encode response", "error", err)
|
|
}
|
|
}
|
|
|
|
// RemoveMember handles DELETE /api/groups/{id}/members/{user}
|
|
func (h *Handler) RemoveMember(w http.ResponseWriter, r *http.Request) {
|
|
user := auth.GetUserFromContext(r.Context())
|
|
if user == nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
groupID := r.PathValue("id")
|
|
memberID := r.PathValue("user")
|
|
if groupID == "" || memberID == "" {
|
|
http.Error(w, "group id and user id required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check if user is group admin
|
|
isAdmin, err := h.db.IsGroupAdmin(user.Name, groupID)
|
|
if err != nil || !isAdmin {
|
|
http.Error(w, "forbidden: group admin required", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
// Cannot remove yourself (use leave group endpoint instead)
|
|
if memberID == user.Name {
|
|
http.Error(w, "cannot remove yourself; use leave group endpoint", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := h.db.RemoveGroupMember(groupID, memberID); err != nil {
|
|
h.logger.Error("failed to remove member", "error", err)
|
|
http.Error(w, "failed to remove member", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
h.logger.Info("member removed", "group_id", groupID, "member", memberID, "removed_by", user.Name)
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// ListGroupTasks handles GET /api/groups/{id}/tasks
|
|
func (h *Handler) ListGroupTasks(w http.ResponseWriter, r *http.Request) {
|
|
user := auth.GetUserFromContext(r.Context())
|
|
if user == nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
groupID := r.PathValue("id")
|
|
if groupID == "" {
|
|
http.Error(w, "group id required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check if user is member of group
|
|
isMember, err := h.db.IsGroupMember(user.Name, groupID)
|
|
if err != nil || !isMember {
|
|
http.Error(w, "forbidden: group membership required", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
// Parse pagination options
|
|
limit := 100
|
|
if l := r.URL.Query().Get("limit"); l != "" {
|
|
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
|
|
limit = parsed
|
|
}
|
|
}
|
|
cursor := r.URL.Query().Get("cursor")
|
|
|
|
opts := storage.ListTasksOptions{
|
|
Limit: limit,
|
|
Cursor: cursor,
|
|
}
|
|
|
|
tasks, nextCursor, err := h.db.ListTasksForGroup(groupID, opts)
|
|
if err != nil {
|
|
h.logger.Error("failed to list group tasks", "error", err)
|
|
http.Error(w, "failed to list tasks", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"tasks": tasks,
|
|
"next_cursor": nextCursor,
|
|
"count": len(tasks),
|
|
}); err != nil {
|
|
h.logger.Error("failed to encode response", "error", err)
|
|
}
|
|
}
|
|
|
|
// sendErrorPacket sends an error response packet to the client
|
|
func (h *Handler) sendErrorPacket(conn *websocket.Conn, code byte, message, details string) error {
|
|
err := map[string]interface{}{
|
|
"error": true,
|
|
"code": code,
|
|
"message": message,
|
|
"details": details,
|
|
}
|
|
return conn.WriteJSON(err)
|
|
}
|
|
|
|
// sendSuccessPacket sends a success response packet
|
|
func (h *Handler) sendSuccessPacket(conn *websocket.Conn, data map[string]interface{}) error {
|
|
return conn.WriteJSON(data)
|
|
}
|
|
|
|
// HandleCreateGroup handles WebSocket group creation.
|
|
// Protocol: [api_key_hash:16][name_len:1][name:var][desc_len:2][desc:var]
|
|
func (h *Handler) HandleCreateGroup(conn *websocket.Conn, payload []byte, user *auth.User) error {
|
|
if len(payload) < 16+1+2 {
|
|
return h.sendErrorPacket(conn, 0x01, "payload too short", "")
|
|
}
|
|
|
|
offset := 16
|
|
|
|
nameLen := int(payload[offset])
|
|
offset++
|
|
if nameLen <= 0 || len(payload) < offset+nameLen+2 {
|
|
return h.sendErrorPacket(conn, 0x01, "invalid name length", "")
|
|
}
|
|
name := string(payload[offset : offset+nameLen])
|
|
offset += nameLen
|
|
|
|
descLen := int(binary.BigEndian.Uint16(payload[offset : offset+2]))
|
|
offset += 2
|
|
if descLen < 0 || len(payload) < offset+descLen {
|
|
return h.sendErrorPacket(conn, 0x01, "invalid description length", "")
|
|
}
|
|
description := string(payload[offset : offset+descLen])
|
|
|
|
// Only admins can create groups
|
|
if !user.Admin {
|
|
return h.sendErrorPacket(conn, 0x03, "forbidden: admin required", "")
|
|
}
|
|
|
|
if name == "" {
|
|
return h.sendErrorPacket(conn, 0x01, "name is required", "")
|
|
}
|
|
|
|
group, err := h.db.CreateGroup(name, description, user.Name)
|
|
if err != nil {
|
|
h.logger.Error("failed to create group", "error", err)
|
|
return h.sendErrorPacket(conn, 0x11, "failed to create group", err.Error())
|
|
}
|
|
|
|
h.logger.Info("group created", "group_id", group.ID, "name", group.Name, "created_by", user.Name)
|
|
|
|
return h.sendSuccessPacket(conn, map[string]interface{}{
|
|
"success": true,
|
|
"group": group,
|
|
})
|
|
}
|
|
|
|
// HandleListGroups handles WebSocket group listing.
|
|
// Protocol: [api_key_hash:16]
|
|
func (h *Handler) HandleListGroups(conn *websocket.Conn, payload []byte, user *auth.User) error {
|
|
groups, err := h.db.ListGroupsForUser(user.Name)
|
|
if err != nil {
|
|
h.logger.Error("failed to list groups", "error", err)
|
|
return h.sendErrorPacket(conn, 0x11, "failed to list groups", err.Error())
|
|
}
|
|
|
|
return h.sendSuccessPacket(conn, map[string]interface{}{
|
|
"success": true,
|
|
"groups": groups,
|
|
"count": len(groups),
|
|
})
|
|
}
|
|
|
|
// HandleCreateInvitation handles WebSocket invitation creation.
|
|
// Protocol: [api_key_hash:16][group_id_len:2][group_id:var][user_id_len:2][user_id:var]
|
|
func (h *Handler) HandleCreateInvitation(conn *websocket.Conn, payload []byte, user *auth.User) error {
|
|
if len(payload) < 16+2+2 {
|
|
return h.sendErrorPacket(conn, 0x01, "payload too short", "")
|
|
}
|
|
|
|
offset := 16
|
|
|
|
groupIDLen := int(binary.BigEndian.Uint16(payload[offset : offset+2]))
|
|
offset += 2
|
|
if groupIDLen <= 0 || len(payload) < offset+groupIDLen+2 {
|
|
return h.sendErrorPacket(conn, 0x01, "invalid group_id length", "")
|
|
}
|
|
groupID := string(payload[offset : offset+groupIDLen])
|
|
offset += groupIDLen
|
|
|
|
userIDLen := int(binary.BigEndian.Uint16(payload[offset : offset+2]))
|
|
offset += 2
|
|
if userIDLen <= 0 || len(payload) < offset+userIDLen {
|
|
return h.sendErrorPacket(conn, 0x01, "invalid user_id length", "")
|
|
}
|
|
invitedUserID := string(payload[offset : offset+userIDLen])
|
|
|
|
// Check if user is group admin
|
|
isAdmin, err := h.db.IsGroupAdmin(user.Name, groupID)
|
|
if err != nil || !isAdmin {
|
|
return h.sendErrorPacket(conn, 0x03, "forbidden: group admin required", "")
|
|
}
|
|
|
|
invitation, err := h.db.CreateGroupInvitation(groupID, invitedUserID, user.Name)
|
|
if err != nil {
|
|
h.logger.Error("failed to create invitation", "error", err)
|
|
return h.sendErrorPacket(conn, 0x11, "failed to create invitation", err.Error())
|
|
}
|
|
|
|
h.logger.Info("invitation created", "invitation_id", invitation.ID, "group_id", groupID, "invited_user", invitedUserID)
|
|
|
|
return h.sendSuccessPacket(conn, map[string]interface{}{
|
|
"success": true,
|
|
"invitation": invitation,
|
|
})
|
|
}
|
|
|
|
// HandleListInvitations handles WebSocket invitation listing.
|
|
// Protocol: [api_key_hash:16]
|
|
func (h *Handler) HandleListInvitations(conn *websocket.Conn, payload []byte, user *auth.User) error {
|
|
invitations, err := h.db.ListPendingInvitationsForUser(user.Name)
|
|
if err != nil {
|
|
h.logger.Error("failed to list invitations", "error", err)
|
|
return h.sendErrorPacket(conn, 0x11, "failed to list invitations", err.Error())
|
|
}
|
|
|
|
return h.sendSuccessPacket(conn, map[string]interface{}{
|
|
"success": true,
|
|
"invitations": invitations,
|
|
"count": len(invitations),
|
|
})
|
|
}
|
|
|
|
// HandleAcceptInvitation handles WebSocket invitation acceptance.
|
|
// Protocol: [api_key_hash:16][invitation_id_len:2][invitation_id:var]
|
|
func (h *Handler) HandleAcceptInvitation(conn *websocket.Conn, payload []byte, user *auth.User) error {
|
|
if len(payload) < 16+2 {
|
|
return h.sendErrorPacket(conn, 0x01, "payload too short", "")
|
|
}
|
|
|
|
offset := 16
|
|
|
|
invIDLen := int(binary.BigEndian.Uint16(payload[offset : offset+2]))
|
|
offset += 2
|
|
if invIDLen <= 0 || len(payload) < offset+invIDLen {
|
|
return h.sendErrorPacket(conn, 0x01, "invalid invitation_id length", "")
|
|
}
|
|
invitationID := string(payload[offset : offset+invIDLen])
|
|
|
|
// Verify invitation belongs to this user and is pending
|
|
invitation, err := h.db.GetInvitation(invitationID)
|
|
if err != nil {
|
|
return h.sendErrorPacket(conn, 0x04, "invitation not found", "")
|
|
}
|
|
|
|
if invitation.InvitedUserID != user.Name {
|
|
return h.sendErrorPacket(conn, 0x03, "forbidden", "")
|
|
}
|
|
|
|
if invitation.Status != "pending" {
|
|
return h.sendErrorPacket(conn, 0x05, "invitation already processed", "")
|
|
}
|
|
|
|
// Check if expired (7 days default)
|
|
if invitation.ExpiresAt != nil && time.Now().After(*invitation.ExpiresAt) {
|
|
return h.sendErrorPacket(conn, 0x14, "invitation expired", "")
|
|
}
|
|
|
|
if err := h.db.AcceptInvitation(invitationID, user.Name); err != nil {
|
|
h.logger.Error("failed to accept invitation", "error", err)
|
|
return h.sendErrorPacket(conn, 0x11, "failed to accept invitation", err.Error())
|
|
}
|
|
|
|
h.logger.Info("invitation accepted", "invitation_id", invitationID, "user", user.Name, "group_id", invitation.GroupID)
|
|
|
|
return h.sendSuccessPacket(conn, map[string]interface{}{
|
|
"success": true,
|
|
"status": "accepted",
|
|
"group_id": invitation.GroupID,
|
|
})
|
|
}
|
|
|
|
// HandleDeclineInvitation handles WebSocket invitation decline.
|
|
// Protocol: [api_key_hash:16][invitation_id_len:2][invitation_id:var]
|
|
func (h *Handler) HandleDeclineInvitation(conn *websocket.Conn, payload []byte, user *auth.User) error {
|
|
if len(payload) < 16+2 {
|
|
return h.sendErrorPacket(conn, 0x01, "payload too short", "")
|
|
}
|
|
|
|
offset := 16
|
|
|
|
invIDLen := int(binary.BigEndian.Uint16(payload[offset : offset+2]))
|
|
offset += 2
|
|
if invIDLen <= 0 || len(payload) < offset+invIDLen {
|
|
return h.sendErrorPacket(conn, 0x01, "invalid invitation_id length", "")
|
|
}
|
|
invitationID := string(payload[offset : offset+invIDLen])
|
|
|
|
invitation, err := h.db.GetInvitation(invitationID)
|
|
if err != nil {
|
|
return h.sendErrorPacket(conn, 0x04, "invitation not found", "")
|
|
}
|
|
|
|
if invitation.InvitedUserID != user.Name {
|
|
return h.sendErrorPacket(conn, 0x03, "forbidden", "")
|
|
}
|
|
|
|
if err := h.db.DeclineInvitation(invitationID, user.Name); err != nil {
|
|
h.logger.Error("failed to decline invitation", "error", err)
|
|
return h.sendErrorPacket(conn, 0x11, "failed to decline invitation", err.Error())
|
|
}
|
|
|
|
h.logger.Info("invitation declined", "invitation_id", invitationID, "user", user.Name)
|
|
|
|
return h.sendSuccessPacket(conn, map[string]interface{}{
|
|
"success": true,
|
|
"status": "declined",
|
|
})
|
|
}
|
|
|
|
// HandleRemoveMember handles WebSocket member removal.
|
|
// Protocol: [api_key_hash:16][group_id_len:2][group_id:var][user_id_len:2][user_id:var]
|
|
func (h *Handler) HandleRemoveMember(conn *websocket.Conn, payload []byte, user *auth.User) error {
|
|
if len(payload) < 16+2+2 {
|
|
return h.sendErrorPacket(conn, 0x01, "payload too short", "")
|
|
}
|
|
|
|
offset := 16
|
|
|
|
groupIDLen := int(binary.BigEndian.Uint16(payload[offset : offset+2]))
|
|
offset += 2
|
|
if groupIDLen <= 0 || len(payload) < offset+groupIDLen+2 {
|
|
return h.sendErrorPacket(conn, 0x01, "invalid group_id length", "")
|
|
}
|
|
groupID := string(payload[offset : offset+groupIDLen])
|
|
offset += groupIDLen
|
|
|
|
userIDLen := int(binary.BigEndian.Uint16(payload[offset : offset+2]))
|
|
offset += 2
|
|
if userIDLen <= 0 || len(payload) < offset+userIDLen {
|
|
return h.sendErrorPacket(conn, 0x01, "invalid user_id length", "")
|
|
}
|
|
memberID := string(payload[offset : offset+userIDLen])
|
|
|
|
// Check if user is group admin
|
|
isAdmin, err := h.db.IsGroupAdmin(user.Name, groupID)
|
|
if err != nil || !isAdmin {
|
|
return h.sendErrorPacket(conn, 0x03, "forbidden: group admin required", "")
|
|
}
|
|
|
|
// Cannot remove yourself
|
|
if memberID == user.Name {
|
|
return h.sendErrorPacket(conn, 0x01, "cannot remove yourself; use leave group endpoint", "")
|
|
}
|
|
|
|
if err := h.db.RemoveGroupMember(groupID, memberID); err != nil {
|
|
h.logger.Error("failed to remove member", "error", err)
|
|
return h.sendErrorPacket(conn, 0x11, "failed to remove member", err.Error())
|
|
}
|
|
|
|
h.logger.Info("member removed", "group_id", groupID, "member", memberID, "removed_by", user.Name)
|
|
|
|
return h.sendSuccessPacket(conn, map[string]interface{}{
|
|
"success": true,
|
|
"message": "Member removed",
|
|
})
|
|
}
|
|
|
|
// HandleListGroupTasks handles WebSocket group task listing.
|
|
// Protocol: [api_key_hash:16][group_id_len:2][group_id:var][limit:1][cursor_len:2][cursor:var]
|
|
func (h *Handler) HandleListGroupTasks(conn *websocket.Conn, payload []byte, user *auth.User) error {
|
|
if len(payload) < 16+2+1+2 {
|
|
return h.sendErrorPacket(conn, 0x01, "payload too short", "")
|
|
}
|
|
|
|
offset := 16
|
|
|
|
groupIDLen := int(binary.BigEndian.Uint16(payload[offset : offset+2]))
|
|
offset += 2
|
|
if groupIDLen <= 0 || len(payload) < offset+groupIDLen+1+2 {
|
|
return h.sendErrorPacket(conn, 0x01, "invalid group_id length", "")
|
|
}
|
|
groupID := string(payload[offset : offset+groupIDLen])
|
|
offset += groupIDLen
|
|
|
|
// Check if user is member of group
|
|
isMember, err := h.db.IsGroupMember(user.Name, groupID)
|
|
if err != nil || !isMember {
|
|
return h.sendErrorPacket(conn, 0x03, "forbidden: group membership required", "")
|
|
}
|
|
|
|
limit := int(payload[offset])
|
|
offset++
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 100
|
|
}
|
|
|
|
cursorLen := int(binary.BigEndian.Uint16(payload[offset : offset+2]))
|
|
offset += 2
|
|
var cursor string
|
|
if cursorLen > 0 {
|
|
if len(payload) < offset+cursorLen {
|
|
return h.sendErrorPacket(conn, 0x01, "invalid cursor length", "")
|
|
}
|
|
cursor = string(payload[offset : offset+cursorLen])
|
|
}
|
|
|
|
opts := storage.ListTasksOptions{
|
|
Limit: limit,
|
|
Cursor: cursor,
|
|
}
|
|
|
|
tasks, nextCursor, err := h.db.ListTasksForGroup(groupID, opts)
|
|
if err != nil {
|
|
h.logger.Error("failed to list group tasks", "error", err)
|
|
return h.sendErrorPacket(conn, 0x11, "failed to list tasks", err.Error())
|
|
}
|
|
|
|
return h.sendSuccessPacket(conn, map[string]interface{}{
|
|
"success": true,
|
|
"tasks": tasks,
|
|
"next_cursor": nextCursor,
|
|
"count": len(tasks),
|
|
})
|
|
}
|