diff --git a/.env.example b/.env.example index e46572c..05d4774 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ APP_ENV=production -APP_PUBLIC_URL=https://app.example.com.br +APP_PUBLIC_URL=http://localhost:5173 APP_TIMEZONE=America/Sao_Paulo HTTP_ADDR=:8080 LOG_LEVEL=info @@ -15,7 +15,7 @@ POSTGRES_PASSWORD=change-me-secure-password DATABASE_URL=postgres://mira:change-me-secure-password@postgres:5432/mira?sslmode=disable JWT_SECRET=change-me-with-a-long-random-secret -JWT_ACCESS_TOKEN_TTL_MINUTES=15 +JWT_ACCESS_TOKEN_TTL_MINUTES=480 JWT_REFRESH_TOKEN_TTL_DAYS=7 CORS_ALLOWED_ORIGINS=https://app.example.com.br @@ -25,15 +25,15 @@ CALENDAR_API_BASE_URL=https://brasilapi.com.br CALENDAR_API_KEY= CALENDAR_CACHE_TTL_HOURS=24 -SMTP_HOST=smtp.example.com.br -SMTP_PORT=587 +SMTP_HOST= +SMTP_PORT= SMTP_USER= -SMTP_PASSWORD= -SMTP_FROM_EMAIL=no-reply@example.com.br +SMTP_PASS= +MAIL_FROM= SMTP_FROM_NAME=Mira UPLOAD_MAX_SIZE_MB=10 -UPLOAD_ALLOWED_MIME_TYPES=image/jpeg,image/png,image/webp,application/pdf +UPLOAD_ALLOWED_MIME_TYPES=image/jpeg,image/png,image/webp,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document STORAGE_DRIVER=local STORAGE_LOCAL_PATH=/var/lib/mira/uploads diff --git a/backend/internal/auth/rate_limit.go b/backend/internal/auth/rate_limit.go new file mode 100644 index 0000000..518e0f4 --- /dev/null +++ b/backend/internal/auth/rate_limit.go @@ -0,0 +1,68 @@ +package auth + +import ( + "strings" + "sync" + "time" +) + +type LoginRateLimiter struct { + mu sync.Mutex + attempts map[string]loginAttempt + limit int + window time.Duration +} + +type loginAttempt struct { + Count int + ExpiresAt time.Time +} + +func NewLoginRateLimiter(limit int, window time.Duration) *LoginRateLimiter { + return &LoginRateLimiter{ + attempts: map[string]loginAttempt{}, + limit: limit, + window: window, + } +} + +func (l *LoginRateLimiter) IsBlocked(key string) bool { + l.mu.Lock() + defer l.mu.Unlock() + + attempt, ok := l.attempts[key] + if !ok || time.Now().After(attempt.ExpiresAt) { + delete(l.attempts, key) + return false + } + + return attempt.Count >= l.limit +} + +func (l *LoginRateLimiter) RecordFailure(key string) { + l.mu.Lock() + defer l.mu.Unlock() + + now := time.Now() + attempt, ok := l.attempts[key] + if !ok || now.After(attempt.ExpiresAt) { + l.attempts[key] = loginAttempt{ + Count: 1, + ExpiresAt: now.Add(l.window), + } + return + } + + attempt.Count++ + l.attempts[key] = attempt +} + +func (l *LoginRateLimiter) Clear(key string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.attempts, key) +} + +func LoginRateLimitKey(ip string, email string) string { + return strings.TrimSpace(ip) + ":" + strings.ToLower(strings.TrimSpace(email)) +} diff --git a/backend/internal/auth/routes.go b/backend/internal/auth/routes.go index 1fd94ed..8ae9853 100644 --- a/backend/internal/auth/routes.go +++ b/backend/internal/auth/routes.go @@ -4,35 +4,40 @@ import ( "errors" "net/http" "strings" + "time" "mira/backend/internal/users" "github.com/gin-gonic/gin" ) +const refreshCookieName = "mira_refresh_token" + type Routes struct { - users users.Store - tokens TokenService + users users.Store + sessions SessionStore + tokens TokenService + refreshTTL time.Duration + secureCookies bool + loginRateLimiter *LoginRateLimiter } -func NewRoutes(users users.Store, tokens TokenService) Routes { +func NewRoutes(users users.Store, sessions SessionStore, tokens TokenService, refreshTTL time.Duration, secureCookies bool) Routes { return Routes{ - users: users, - tokens: tokens, + users: users, + sessions: sessions, + tokens: tokens, + refreshTTL: refreshTTL, + secureCookies: secureCookies, + loginRateLimiter: NewLoginRateLimiter(5, 15*time.Minute), } } func (r Routes) Register(router *gin.RouterGroup) { router.POST("/login", r.login) router.POST("/invitations/accept", r.acceptInvitation) - - router.POST("/logout", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"message": "Sessao encerrada."}) - }) - - router.POST("/refresh", func(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"message": "Renovacao de sessao ainda nao implementada."}) - }) + router.POST("/logout", r.logout) + router.POST("/refresh", r.refresh) } type loginRequest struct { @@ -47,8 +52,16 @@ func (r Routes) login(c *gin.Context) { return } - user, err := r.users.FindByEmail(c.Request.Context(), strings.TrimSpace(input.Email)) + email := strings.TrimSpace(input.Email) + rateLimitKey := LoginRateLimitKey(c.ClientIP(), email) + if r.loginRateLimiter.IsBlocked(rateLimitKey) { + c.JSON(http.StatusTooManyRequests, gin.H{"message": "Muitas tentativas de login. Tente novamente em alguns minutos."}) + return + } + + user, err := r.users.FindByEmail(c.Request.Context(), email) if errors.Is(err, users.ErrNotFound) { + r.loginRateLimiter.RecordFailure(rateLimitKey) c.JSON(http.StatusUnauthorized, gin.H{"message": "E-mail ou senha invalidos."}) return } @@ -57,15 +70,17 @@ func (r Routes) login(c *gin.Context) { return } if !CheckPassword(input.Password, user.PasswordHash) || user.Status != "active" { + r.loginRateLimiter.RecordFailure(rateLimitKey) c.JSON(http.StatusUnauthorized, gin.H{"message": "E-mail ou senha invalidos."}) return } - token, err := r.tokens.IssueAccessToken(user.ID, user.Email, user.Role) + token, err := r.createSession(c, user) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel criar a sessao."}) return } + r.loginRateLimiter.Clear(rateLimitKey) c.JSON(http.StatusOK, gin.H{ "access_token": token, @@ -114,15 +129,8 @@ func (r Routes) acceptInvitation(c *gin.Context) { return } - token, err := r.tokens.IssueAccessToken(user.ID, user.Email, user.Role) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel criar a sessao."}) - return - } - c.JSON(http.StatusOK, gin.H{ - "access_token": token, - "token_type": "Bearer", + "message": "Senha criada com sucesso. Faca login para acessar o Mira.", "user": gin.H{ "id": user.ID, "email": user.Email, @@ -132,3 +140,84 @@ func (r Routes) acceptInvitation(c *gin.Context) { }, }) } + +func (r Routes) refresh(c *gin.Context) { + refreshToken, err := c.Cookie(refreshCookieName) + if err != nil || strings.TrimSpace(refreshToken) == "" { + c.JSON(http.StatusUnauthorized, gin.H{"message": "Sessao expirada."}) + return + } + + tokenHash := HashRefreshToken(refreshToken) + session, err := r.sessions.FindValidRefreshSession(c.Request.Context(), tokenHash) + if errors.Is(err, ErrRefreshTokenNotFound) { + r.clearRefreshCookie(c) + c.JSON(http.StatusUnauthorized, gin.H{"message": "Sessao expirada."}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel renovar a sessao."}) + return + } + + if err := r.sessions.RevokeRefreshSession(c.Request.Context(), tokenHash); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel renovar a sessao."}) + return + } + + accessToken, err := r.createSession(c, session.User) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel renovar a sessao."}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "access_token": accessToken, + "token_type": "Bearer", + "user": gin.H{ + "id": session.User.ID, + "email": session.User.Email, + "name": session.User.Name, + "role": session.User.Role, + "status": session.User.Status, + }, + }) +} + +func (r Routes) logout(c *gin.Context) { + refreshToken, err := c.Cookie(refreshCookieName) + if err == nil && strings.TrimSpace(refreshToken) != "" { + _ = r.sessions.RevokeRefreshSession(c.Request.Context(), HashRefreshToken(refreshToken)) + } + r.clearRefreshCookie(c) + c.JSON(http.StatusOK, gin.H{"message": "Sessao encerrada."}) +} + +func (r Routes) createSession(c *gin.Context, user users.User) (string, error) { + refreshToken, err := GenerateRefreshToken() + if err != nil { + return "", err + } + + if err := r.sessions.CreateRefreshSession( + c.Request.Context(), + user.ID, + HashRefreshToken(refreshToken), + time.Now().Add(r.refreshTTL), + ); err != nil { + return "", err + } + + r.setRefreshCookie(c, refreshToken) + return r.tokens.IssueAccessToken(user.ID, user.Email, user.Role) +} + +func (r Routes) setRefreshCookie(c *gin.Context, token string) { + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie(refreshCookieName, token, int(r.refreshTTL.Seconds()), "/api/v1/auth", "", r.secureCookies, true) +} + +func (r Routes) clearRefreshCookie(c *gin.Context) { + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie(refreshCookieName, "", 0, "/api/v1/auth", "", r.secureCookies, true) +} diff --git a/backend/internal/auth/sessions.go b/backend/internal/auth/sessions.go new file mode 100644 index 0000000..750a3fb --- /dev/null +++ b/backend/internal/auth/sessions.go @@ -0,0 +1,112 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "time" + + "mira/backend/internal/users" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ErrRefreshTokenNotFound = errors.New("refresh token not found") + +type RefreshSession struct { + ID string + TokenHash string + User users.User + ExpiresAt time.Time + RevokedAt *time.Time +} + +type SessionStore struct { + db *pgxpool.Pool +} + +func NewSessionStore(db *pgxpool.Pool) SessionStore { + return SessionStore{db: db} +} + +func GenerateRefreshToken() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(bytes), nil +} + +func HashRefreshToken(token string) string { + hash := sha256.Sum256([]byte(token)) + return hex.EncodeToString(hash[:]) +} + +func (s SessionStore) CreateRefreshSession(ctx context.Context, userID string, tokenHash string, expiresAt time.Time) error { + _, err := s.db.Exec(ctx, ` + INSERT INTO refresh_tokens (user_id, token_hash, expires_at) + VALUES ($1::uuid, $2, $3) + `, userID, tokenHash, expiresAt) + return err +} + +func (s SessionStore) FindValidRefreshSession(ctx context.Context, tokenHash string) (RefreshSession, error) { + row := s.db.QueryRow(ctx, ` + SELECT + refresh_tokens.id::text, + refresh_tokens.token_hash, + users.id::text, + users.email, + users.name, + users.password_hash, + users.role, + users.status, + users.created_at, + users.updated_at, + refresh_tokens.expires_at, + refresh_tokens.revoked_at + FROM refresh_tokens + INNER JOIN users ON users.id = refresh_tokens.user_id + WHERE refresh_tokens.token_hash = $1 + AND refresh_tokens.revoked_at IS NULL + AND refresh_tokens.expires_at > now() + AND users.status = 'active' + `, tokenHash) + + var session RefreshSession + err := row.Scan( + &session.ID, + &session.TokenHash, + &session.User.ID, + &session.User.Email, + &session.User.Name, + &session.User.PasswordHash, + &session.User.Role, + &session.User.Status, + &session.User.CreatedAt, + &session.User.UpdatedAt, + &session.ExpiresAt, + &session.RevokedAt, + ) + if errors.Is(err, pgx.ErrNoRows) { + return RefreshSession{}, ErrRefreshTokenNotFound + } + if err != nil { + return RefreshSession{}, err + } + return session, nil +} + +func (s SessionStore) RevokeRefreshSession(ctx context.Context, tokenHash string) error { + _, err := s.db.Exec(ctx, ` + UPDATE refresh_tokens + SET revoked_at = now() + WHERE token_hash = $1 + AND revoked_at IS NULL + `, tokenHash) + return err +} diff --git a/backend/internal/calendar/attachments.go b/backend/internal/calendar/attachments.go index ed7bf6f..3506bdd 100644 --- a/backend/internal/calendar/attachments.go +++ b/backend/internal/calendar/attachments.go @@ -137,6 +137,26 @@ func (s Store) FindAttachment(ctx context.Context, id string, viewerUserID strin return scanAttachment(row) } +func (s Store) DeleteAttachment(ctx context.Context, id string) (Attachment, error) { + row := s.db.QueryRow(ctx, ` + DELETE FROM calendar_item_attachments + WHERE id = $1::uuid + RETURNING + id::text, + calendar_item_id::text, + original_filename, + stored_filename, + mime_type, + size_bytes, + storage_driver, + storage_path, + created_by::text, + created_at + `, id) + + return scanAttachment(row) +} + type attachmentScanner interface { Scan(dest ...any) error } diff --git a/backend/internal/calendar/chat.go b/backend/internal/calendar/chat.go new file mode 100644 index 0000000..08c7882 --- /dev/null +++ b/backend/internal/calendar/chat.go @@ -0,0 +1,377 @@ +package calendar + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" +) + +var ErrChatMessageNotFound = errors.New("chat message not found") + +const ( + ChatChannelExternal = "external" + ChatChannelInternal = "internal" +) + +type ChatMessage struct { + ID string `json:"id"` + CalendarItemID string `json:"calendar_item_id"` + Channel string `json:"channel"` + Body string `json:"body"` + CreatedBy *string `json:"created_by"` + AuthorName string `json:"author_name"` + AuthorRole string `json:"author_role"` + CreatedAt time.Time `json:"created_at"` + EditedAt *time.Time `json:"edited_at"` + DeletedAt *time.Time `json:"deleted_at"` + Attachments []ChatAttachment `json:"attachments"` +} + +type ChatAttachment struct { + ID string `json:"id"` + ChatMessageID string `json:"chat_message_id"` + OriginalFilename string `json:"original_filename"` + StoredFilename string `json:"stored_filename"` + MimeType string `json:"mime_type"` + SizeBytes int64 `json:"size_bytes"` + StorageDriver string `json:"storage_driver"` + StoragePath string `json:"-"` + CreatedBy *string `json:"created_by"` + CreatedAt time.Time `json:"created_at"` +} + +type CreateChatMessageInput struct { + CalendarItemID string + Channel string + Body string + CreatedBy string +} + +type CreateChatAttachmentInput struct { + ChatMessageID string + OriginalFilename string + StoredFilename string + MimeType string + SizeBytes int64 + StorageDriver string + StoragePath string + CreatedBy string +} + +func (s Store) ListChatMessages(ctx context.Context, calendarItemID string, channel string) ([]ChatMessage, error) { + rows, err := s.db.Query(ctx, ` + SELECT + calendar_item_chat_messages.id::text, + calendar_item_chat_messages.calendar_item_id::text, + calendar_item_chat_messages.channel, + calendar_item_chat_messages.body, + calendar_item_chat_messages.created_by::text, + COALESCE(users.name, 'Usuario removido'), + COALESCE(users.role, ''), + calendar_item_chat_messages.created_at, + calendar_item_chat_messages.edited_at, + calendar_item_chat_messages.deleted_at + FROM calendar_item_chat_messages + LEFT JOIN users ON users.id = calendar_item_chat_messages.created_by + WHERE calendar_item_chat_messages.calendar_item_id = $1::uuid + AND calendar_item_chat_messages.channel = $2 + ORDER BY calendar_item_chat_messages.created_at ASC + `, calendarItemID, channel) + if err != nil { + return nil, err + } + defer rows.Close() + + messages := []ChatMessage{} + for rows.Next() { + message, err := scanChatMessage(rows) + if err != nil { + return nil, err + } + messages = append(messages, message) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + if err := s.attachChatAttachments(ctx, messages); err != nil { + return nil, err + } + + return messages, nil +} + +func (s Store) CreateChatMessage(ctx context.Context, input CreateChatMessageInput) (ChatMessage, error) { + row := s.db.QueryRow(ctx, ` + INSERT INTO calendar_item_chat_messages ( + calendar_item_id, + channel, + body, + created_by + ) + VALUES ($1::uuid, $2, $3, NULLIF($4, '')::uuid) + RETURNING + calendar_item_chat_messages.id::text, + calendar_item_chat_messages.calendar_item_id::text, + calendar_item_chat_messages.channel, + calendar_item_chat_messages.body, + calendar_item_chat_messages.created_by::text, + COALESCE((SELECT users.name FROM users WHERE users.id = calendar_item_chat_messages.created_by), 'Usuario removido'), + COALESCE((SELECT users.role FROM users WHERE users.id = calendar_item_chat_messages.created_by), ''), + calendar_item_chat_messages.created_at, + calendar_item_chat_messages.edited_at, + calendar_item_chat_messages.deleted_at + `, input.CalendarItemID, input.Channel, input.Body, input.CreatedBy) + + return scanChatMessage(row) +} + +func (s Store) FindChatMessage(ctx context.Context, id string) (ChatMessage, error) { + row := s.db.QueryRow(ctx, ` + SELECT + calendar_item_chat_messages.id::text, + calendar_item_chat_messages.calendar_item_id::text, + calendar_item_chat_messages.channel, + calendar_item_chat_messages.body, + calendar_item_chat_messages.created_by::text, + COALESCE(users.name, 'Usuario removido'), + COALESCE(users.role, ''), + calendar_item_chat_messages.created_at, + calendar_item_chat_messages.edited_at, + calendar_item_chat_messages.deleted_at + FROM calendar_item_chat_messages + LEFT JOIN users ON users.id = calendar_item_chat_messages.created_by + WHERE calendar_item_chat_messages.id = $1::uuid + `, id) + + message, err := scanChatMessage(row) + if err != nil { + return ChatMessage{}, err + } + messages := []ChatMessage{message} + if err := s.attachChatAttachments(ctx, messages); err != nil { + return ChatMessage{}, err + } + return messages[0], nil +} + +func (s Store) UpdateChatMessage(ctx context.Context, id string, body string) (ChatMessage, error) { + row := s.db.QueryRow(ctx, ` + UPDATE calendar_item_chat_messages + SET body = $2, edited_at = now() + WHERE id = $1::uuid + AND deleted_at IS NULL + RETURNING + calendar_item_chat_messages.id::text, + calendar_item_chat_messages.calendar_item_id::text, + calendar_item_chat_messages.channel, + calendar_item_chat_messages.body, + calendar_item_chat_messages.created_by::text, + COALESCE((SELECT users.name FROM users WHERE users.id = calendar_item_chat_messages.created_by), 'Usuario removido'), + COALESCE((SELECT users.role FROM users WHERE users.id = calendar_item_chat_messages.created_by), ''), + calendar_item_chat_messages.created_at, + calendar_item_chat_messages.edited_at, + calendar_item_chat_messages.deleted_at + `, id, body) + + message, err := scanChatMessage(row) + if err != nil { + return ChatMessage{}, err + } + messages := []ChatMessage{message} + if err := s.attachChatAttachments(ctx, messages); err != nil { + return ChatMessage{}, err + } + return messages[0], nil +} + +func (s Store) DeleteChatMessage(ctx context.Context, id string) (ChatMessage, error) { + row := s.db.QueryRow(ctx, ` + UPDATE calendar_item_chat_messages + SET deleted_at = now(), body = '' + WHERE id = $1::uuid + AND deleted_at IS NULL + RETURNING + calendar_item_chat_messages.id::text, + calendar_item_chat_messages.calendar_item_id::text, + calendar_item_chat_messages.channel, + calendar_item_chat_messages.body, + calendar_item_chat_messages.created_by::text, + COALESCE((SELECT users.name FROM users WHERE users.id = calendar_item_chat_messages.created_by), 'Usuario removido'), + COALESCE((SELECT users.role FROM users WHERE users.id = calendar_item_chat_messages.created_by), ''), + calendar_item_chat_messages.created_at, + calendar_item_chat_messages.edited_at, + calendar_item_chat_messages.deleted_at + `, id) + + return scanChatMessage(row) +} + +func (s Store) CreateChatAttachment(ctx context.Context, input CreateChatAttachmentInput) (ChatAttachment, error) { + row := s.db.QueryRow(ctx, ` + INSERT INTO calendar_item_chat_attachments ( + chat_message_id, + original_filename, + stored_filename, + mime_type, + size_bytes, + storage_driver, + storage_path, + created_by + ) + VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, NULLIF($8, '')::uuid) + RETURNING + id::text, + chat_message_id::text, + original_filename, + stored_filename, + mime_type, + size_bytes, + storage_driver, + storage_path, + created_by::text, + created_at + `, input.ChatMessageID, input.OriginalFilename, input.StoredFilename, input.MimeType, input.SizeBytes, input.StorageDriver, input.StoragePath, input.CreatedBy) + + return scanChatAttachment(row) +} + +func (s Store) FindChatAttachment(ctx context.Context, id string, viewerUserID string) (ChatAttachment, error) { + row := s.db.QueryRow(ctx, ` + SELECT + calendar_item_chat_attachments.id::text, + calendar_item_chat_attachments.chat_message_id::text, + calendar_item_chat_attachments.original_filename, + calendar_item_chat_attachments.stored_filename, + calendar_item_chat_attachments.mime_type, + calendar_item_chat_attachments.size_bytes, + calendar_item_chat_attachments.storage_driver, + calendar_item_chat_attachments.storage_path, + calendar_item_chat_attachments.created_by::text, + calendar_item_chat_attachments.created_at + FROM calendar_item_chat_attachments + INNER JOIN calendar_item_chat_messages ON calendar_item_chat_messages.id = calendar_item_chat_attachments.chat_message_id + INNER JOIN calendar_items ON calendar_items.id = calendar_item_chat_messages.calendar_item_id + WHERE calendar_item_chat_attachments.id = $1::uuid + AND calendar_item_chat_messages.deleted_at IS NULL + AND ( + $2 = '' + OR ( + calendar_item_chat_messages.channel = 'external' + AND EXISTS ( + SELECT 1 + FROM client_users + WHERE client_users.client_id = calendar_items.client_id + AND client_users.user_id = $2::uuid + ) + ) + ) + `, id, viewerUserID) + + return scanChatAttachment(row) +} + +func (s Store) attachChatAttachments(ctx context.Context, messages []ChatMessage) error { + if len(messages) == 0 { + return nil + } + + ids := make([]string, 0, len(messages)) + indexByID := map[string]int{} + for index, message := range messages { + ids = append(ids, message.ID) + indexByID[message.ID] = index + } + + rows, err := s.db.Query(ctx, ` + SELECT + id::text, + chat_message_id::text, + original_filename, + stored_filename, + mime_type, + size_bytes, + storage_driver, + storage_path, + created_by::text, + created_at + FROM calendar_item_chat_attachments + WHERE chat_message_id::text = ANY($1) + ORDER BY created_at ASC + `, ids) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + attachment, err := scanChatAttachment(rows) + if err != nil { + return err + } + index, ok := indexByID[attachment.ChatMessageID] + if ok { + messages[index].Attachments = append(messages[index].Attachments, attachment) + } + } + + return rows.Err() +} + +type chatMessageScanner interface { + Scan(dest ...any) error +} + +func scanChatMessage(row chatMessageScanner) (ChatMessage, error) { + var message ChatMessage + err := row.Scan( + &message.ID, + &message.CalendarItemID, + &message.Channel, + &message.Body, + &message.CreatedBy, + &message.AuthorName, + &message.AuthorRole, + &message.CreatedAt, + &message.EditedAt, + &message.DeletedAt, + ) + if errors.Is(err, pgx.ErrNoRows) { + return ChatMessage{}, ErrChatMessageNotFound + } + if err != nil { + return ChatMessage{}, err + } + message.Attachments = []ChatAttachment{} + return message, nil +} + +type chatAttachmentScanner interface { + Scan(dest ...any) error +} + +func scanChatAttachment(row chatAttachmentScanner) (ChatAttachment, error) { + var attachment ChatAttachment + err := row.Scan( + &attachment.ID, + &attachment.ChatMessageID, + &attachment.OriginalFilename, + &attachment.StoredFilename, + &attachment.MimeType, + &attachment.SizeBytes, + &attachment.StorageDriver, + &attachment.StoragePath, + &attachment.CreatedBy, + &attachment.CreatedAt, + ) + if errors.Is(err, pgx.ErrNoRows) { + return ChatAttachment{}, ErrAttachmentNotFound + } + if err != nil { + return ChatAttachment{}, err + } + return attachment, nil +} diff --git a/backend/internal/calendar/chat_events.go b/backend/internal/calendar/chat_events.go new file mode 100644 index 0000000..b2f1168 --- /dev/null +++ b/backend/internal/calendar/chat_events.go @@ -0,0 +1,55 @@ +package calendar + +import "sync" + +type ChatEventHub struct { + mu sync.Mutex + subscribers map[string]map[chan struct{}]struct{} +} + +func NewChatEventHub() *ChatEventHub { + return &ChatEventHub{ + subscribers: map[string]map[chan struct{}]struct{}{}, + } +} + +func (h *ChatEventHub) Subscribe(itemID string, channel string) (chan struct{}, func()) { + key := chatEventKey(itemID, channel) + events := make(chan struct{}, 1) + + h.mu.Lock() + if h.subscribers[key] == nil { + h.subscribers[key] = map[chan struct{}]struct{}{} + } + h.subscribers[key][events] = struct{}{} + h.mu.Unlock() + + return events, func() { + h.mu.Lock() + defer h.mu.Unlock() + + delete(h.subscribers[key], events) + if len(h.subscribers[key]) == 0 { + delete(h.subscribers, key) + } + close(events) + } +} + +func (h *ChatEventHub) Publish(itemID string, channel string) { + key := chatEventKey(itemID, channel) + + h.mu.Lock() + defer h.mu.Unlock() + + for events := range h.subscribers[key] { + select { + case events <- struct{}{}: + default: + } + } +} + +func chatEventKey(itemID string, channel string) string { + return itemID + ":" + channel +} diff --git a/backend/internal/calendar/routes.go b/backend/internal/calendar/routes.go index 7e8b7a9..af6dcae 100644 --- a/backend/internal/calendar/routes.go +++ b/backend/internal/calendar/routes.go @@ -22,6 +22,7 @@ type Routes struct { store Store provider Provider commemorative CommemorativeProvider + chatEvents *ChatEventHub uploadMaxSizeBytes int64 uploadAllowedMIMEs map[string]bool storageDriver string @@ -54,6 +55,7 @@ func NewRoutes(store Store, provider Provider, commemorative CommemorativeProvid store: store, provider: provider, commemorative: commemorative, + chatEvents: NewChatEventHub(), uploadMaxSizeBytes: int64(options.UploadMaxSizeMB) * 1024 * 1024, uploadAllowedMIMEs: allowedMIMEs, storageDriver: options.StorageDriver, @@ -80,7 +82,15 @@ func (r Routes) Register(router *gin.RouterGroup, requireAuth gin.HandlerFunc) { router.DELETE("/items/:itemId", r.cancelCalendarItem) router.GET("/items/:itemId/attachments", r.listAttachments) router.POST("/items/:itemId/attachments", r.uploadAttachment) + router.GET("/items/:itemId/chat", r.listChatMessages) + router.GET("/items/:itemId/chat/stream", r.streamChatMessages) + router.POST("/items/:itemId/chat", r.createChatMessage) + router.PATCH("/chat/messages/:messageId", r.updateChatMessage) + router.DELETE("/chat/messages/:messageId", r.deleteChatMessage) + router.POST("/chat/messages/:messageId/attachments", r.uploadChatAttachment) + router.GET("/chat/attachments/:attachmentId/download", r.downloadChatAttachment) router.GET("/attachments/:attachmentId/download", r.downloadAttachment) + router.DELETE("/attachments/:attachmentId", r.deleteAttachment) } func (r Routes) holidays(c *gin.Context) { @@ -604,6 +614,376 @@ func (r Routes) listAttachments(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"attachments": attachments}) } +func (r Routes) listChatMessages(c *gin.Context) { + claims, ok := auth.ClaimsFromContext(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."}) + return + } + + channel := strings.TrimSpace(c.Query("channel")) + if channel == "" { + channel = ChatChannelExternal + } + if !isValidChatChannel(channel) { + c.JSON(http.StatusBadRequest, gin.H{"message": "Canal de chat invalido."}) + return + } + if channel == ChatChannelInternal && claims.Role == users.RoleClientViewer { + c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."}) + return + } + + viewerUserID := "" + if claims.Role == users.RoleClientViewer { + viewerUserID = claims.UserID + } + + item, err := r.store.FindCalendarItem(c.Request.Context(), c.Param("itemId"), viewerUserID) + if errors.Is(err, ErrCalendarItemNotFound) { + c.JSON(http.StatusNotFound, gin.H{"message": "Postagem nao encontrada."}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar a postagem."}) + return + } + + messages, err := r.store.ListChatMessages(c.Request.Context(), item.ID, channel) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar mensagens."}) + return + } + + c.JSON(http.StatusOK, gin.H{"messages": messages}) +} + +func (r Routes) streamChatMessages(c *gin.Context) { + claims, ok := auth.ClaimsFromContext(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."}) + return + } + + channel := strings.TrimSpace(c.Query("channel")) + if channel == "" { + channel = ChatChannelExternal + } + if !isValidChatChannel(channel) { + c.JSON(http.StatusBadRequest, gin.H{"message": "Canal de chat invalido."}) + return + } + if channel == ChatChannelInternal && claims.Role == users.RoleClientViewer { + c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."}) + return + } + + viewerUserID := "" + if claims.Role == users.RoleClientViewer { + viewerUserID = claims.UserID + } + + item, err := r.store.FindCalendarItem(c.Request.Context(), c.Param("itemId"), viewerUserID) + if errors.Is(err, ErrCalendarItemNotFound) { + c.JSON(http.StatusNotFound, gin.H{"message": "Postagem nao encontrada."}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar a postagem."}) + return + } + + events, unsubscribe := r.chatEvents.Subscribe(item.ID, channel) + defer unsubscribe() + + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("X-Accel-Buffering", "no") + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Streaming nao suportado."}) + return + } + + _, _ = c.Writer.Write([]byte("event: ready\ndata: ok\n\n")) + flusher.Flush() + + heartbeat := time.NewTicker(25 * time.Second) + defer heartbeat.Stop() + + for { + select { + case <-c.Request.Context().Done(): + return + case <-events: + _, _ = c.Writer.Write([]byte("event: chat\ndata: refresh\n\n")) + flusher.Flush() + case <-heartbeat.C: + _, _ = c.Writer.Write([]byte("event: ping\ndata: keepalive\n\n")) + flusher.Flush() + } + } +} + +type createChatMessageRequest struct { + Channel string `json:"channel"` + Body string `json:"body" binding:"max=4000"` +} + +func (r Routes) createChatMessage(c *gin.Context) { + claims, ok := auth.ClaimsFromContext(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."}) + return + } + + var input createChatMessageRequest + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"message": "Informe uma mensagem valida."}) + return + } + + channel := strings.TrimSpace(input.Channel) + if channel == "" { + channel = ChatChannelExternal + } + if !isValidChatChannel(channel) { + c.JSON(http.StatusBadRequest, gin.H{"message": "Canal de chat invalido."}) + return + } + if channel == ChatChannelInternal && claims.Role == users.RoleClientViewer { + c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."}) + return + } + + body := strings.TrimSpace(input.Body) + if body == "" { + c.JSON(http.StatusBadRequest, gin.H{"message": "Informe uma mensagem valida."}) + return + } + + viewerUserID := "" + if claims.Role == users.RoleClientViewer { + viewerUserID = claims.UserID + } + + item, err := r.store.FindCalendarItem(c.Request.Context(), c.Param("itemId"), viewerUserID) + if errors.Is(err, ErrCalendarItemNotFound) { + c.JSON(http.StatusNotFound, gin.H{"message": "Postagem nao encontrada."}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar a postagem."}) + return + } + + message, err := r.store.CreateChatMessage(c.Request.Context(), CreateChatMessageInput{ + CalendarItemID: item.ID, + Channel: channel, + Body: body, + CreatedBy: claims.UserID, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel enviar a mensagem."}) + return + } + + _ = r.store.RecordAudit(c.Request.Context(), claims.UserID, "chat_message.created", "calendar_item", item.ID, map[string]any{ + "channel": channel, + }) + + r.chatEvents.Publish(item.ID, channel) + + c.JSON(http.StatusCreated, gin.H{"message": message}) +} + +type updateChatMessageRequest struct { + Body string `json:"body" binding:"required,min=1,max=4000"` +} + +func (r Routes) updateChatMessage(c *gin.Context) { + claims, ok := auth.ClaimsFromContext(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."}) + return + } + + var input updateChatMessageRequest + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"message": "Informe uma mensagem valida."}) + return + } + + message, err := r.authorizedChatMessage(c, claims, c.Param("messageId"), true) + if err != nil { + return + } + if message.DeletedAt != nil { + c.JSON(http.StatusNotFound, gin.H{"message": "Mensagem nao encontrada."}) + return + } + if len(message.Attachments) > 0 { + c.JSON(http.StatusBadRequest, gin.H{"message": "Mensagens com anexos nao podem ser editadas."}) + return + } + + updated, err := r.store.UpdateChatMessage(c.Request.Context(), message.ID, strings.TrimSpace(input.Body)) + if errors.Is(err, ErrChatMessageNotFound) { + c.JSON(http.StatusNotFound, gin.H{"message": "Mensagem nao encontrada."}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel editar a mensagem."}) + return + } + + r.chatEvents.Publish(updated.CalendarItemID, updated.Channel) + + c.JSON(http.StatusOK, gin.H{"message": updated}) +} + +func (r Routes) deleteChatMessage(c *gin.Context) { + claims, ok := auth.ClaimsFromContext(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."}) + return + } + + message, err := r.authorizedChatMessage(c, claims, c.Param("messageId"), true) + if err != nil { + return + } + if message.DeletedAt != nil { + c.JSON(http.StatusNotFound, gin.H{"message": "Mensagem nao encontrada."}) + return + } + + deleted, err := r.store.DeleteChatMessage(c.Request.Context(), message.ID) + if errors.Is(err, ErrChatMessageNotFound) { + c.JSON(http.StatusNotFound, gin.H{"message": "Mensagem nao encontrada."}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel excluir a mensagem."}) + return + } + + r.chatEvents.Publish(deleted.CalendarItemID, deleted.Channel) + + c.JSON(http.StatusOK, gin.H{"message": deleted}) +} + +func (r Routes) uploadChatAttachment(c *gin.Context) { + claims, ok := auth.ClaimsFromContext(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."}) + return + } + + message, err := r.authorizedChatMessage(c, claims, c.Param("messageId"), false) + if err != nil { + return + } + if message.DeletedAt != nil { + c.JSON(http.StatusNotFound, gin.H{"message": "Mensagem nao encontrada."}) + return + } + if message.CreatedBy == nil || *message.CreatedBy != claims.UserID { + c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."}) + return + } + + upload, err := r.saveUploadedFile(c, filepath.Join("chat", message.ID)) + if err != nil { + return + } + + attachment, err := r.store.CreateChatAttachment(c.Request.Context(), CreateChatAttachmentInput{ + ChatMessageID: message.ID, + OriginalFilename: upload.originalFilename, + StoredFilename: upload.storedFilename, + MimeType: upload.mimeType, + SizeBytes: upload.sizeBytes, + StorageDriver: r.storageDriver, + StoragePath: upload.storagePath, + CreatedBy: claims.UserID, + }) + if err != nil { + _ = os.Remove(upload.storagePath) + c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel registrar o anexo."}) + return + } + + r.chatEvents.Publish(message.CalendarItemID, message.Channel) + + c.JSON(http.StatusCreated, gin.H{"attachment": attachment}) +} + +func (r Routes) downloadChatAttachment(c *gin.Context) { + claims, ok := auth.ClaimsFromContext(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."}) + return + } + + viewerUserID := "" + if claims.Role == users.RoleClientViewer { + viewerUserID = claims.UserID + } + + attachment, err := r.store.FindChatAttachment(c.Request.Context(), c.Param("attachmentId"), viewerUserID) + if errors.Is(err, ErrAttachmentNotFound) { + c.JSON(http.StatusNotFound, gin.H{"message": "Anexo nao encontrado."}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar o anexo."}) + return + } + + c.Header("Content-Type", attachment.MimeType) + c.Header("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(attachment.OriginalFilename, `"`, "")+`"`) + c.File(attachment.StoragePath) +} + +func (r Routes) authorizedChatMessage(c *gin.Context, claims auth.Claims, messageID string, requireOwner bool) (ChatMessage, error) { + message, err := r.store.FindChatMessage(c.Request.Context(), messageID) + if errors.Is(err, ErrChatMessageNotFound) { + c.JSON(http.StatusNotFound, gin.H{"message": "Mensagem nao encontrada."}) + return ChatMessage{}, err + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar a mensagem."}) + return ChatMessage{}, err + } + if message.Channel == ChatChannelInternal && claims.Role == users.RoleClientViewer { + c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."}) + return ChatMessage{}, errors.New("forbidden") + } + + viewerUserID := "" + if claims.Role == users.RoleClientViewer { + viewerUserID = claims.UserID + } + if _, err := r.store.FindCalendarItem(c.Request.Context(), message.CalendarItemID, viewerUserID); err != nil { + if errors.Is(err, ErrCalendarItemNotFound) { + c.JSON(http.StatusNotFound, gin.H{"message": "Postagem nao encontrada."}) + } else { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar a postagem."}) + } + return ChatMessage{}, err + } + + if requireOwner && claims.Role != users.RoleSuperAdmin && (message.CreatedBy == nil || *message.CreatedBy != claims.UserID) { + c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."}) + return ChatMessage{}, errors.New("forbidden") + } + + return message, nil +} + func (r Routes) uploadAttachment(c *gin.Context) { claims, ok := auth.ClaimsFromContext(c) if !ok || (claims.Role != users.RoleSuperAdmin && claims.Role != users.RoleAgencyUser) { @@ -611,75 +991,23 @@ func (r Routes) uploadAttachment(c *gin.Context) { return } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, r.uploadMaxSizeBytes) - file, header, err := c.Request.FormFile("file") + upload, err := r.saveUploadedFile(c, c.Param("itemId")) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"message": "Envie um arquivo valido."}) - return - } - defer file.Close() - - if header.Size > r.uploadMaxSizeBytes { - c.JSON(http.StatusBadRequest, gin.H{"message": "Arquivo acima do limite permitido."}) - return - } - - head := make([]byte, 512) - n, err := file.Read(head) - if err != nil && err != io.EOF { - c.JSON(http.StatusBadRequest, gin.H{"message": "Nao foi possivel ler o arquivo."}) - return - } - if _, err := file.Seek(0, io.SeekStart); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel processar o arquivo."}) - return - } - - mimeType := http.DetectContentType(head[:n]) - if len(r.uploadAllowedMIMEs) > 0 && !r.uploadAllowedMIMEs[mimeType] { - c.JSON(http.StatusBadRequest, gin.H{"message": "Tipo de arquivo nao permitido."}) - return - } - - storedFilename, err := randomStoredFilename(header.Filename) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel preparar o arquivo."}) - return - } - - itemDir := filepath.Join(r.storageLocalPath, c.Param("itemId")) - if err := os.MkdirAll(itemDir, 0o750); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel preparar o armazenamento."}) - return - } - - storagePath := filepath.Join(itemDir, storedFilename) - destination, err := os.OpenFile(storagePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel salvar o arquivo."}) - return - } - defer destination.Close() - - sizeBytes, err := io.Copy(destination, file) - if err != nil { - _ = os.Remove(storagePath) - c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel salvar o arquivo."}) return } attachment, err := r.store.CreateAttachment(c.Request.Context(), CreateAttachmentInput{ CalendarItemID: c.Param("itemId"), - OriginalFilename: filepath.Base(header.Filename), - StoredFilename: storedFilename, - MimeType: mimeType, - SizeBytes: sizeBytes, + OriginalFilename: upload.originalFilename, + StoredFilename: upload.storedFilename, + MimeType: upload.mimeType, + SizeBytes: upload.sizeBytes, StorageDriver: r.storageDriver, - StoragePath: storagePath, + StoragePath: upload.storagePath, CreatedBy: claims.UserID, }) if err != nil { - _ = os.Remove(storagePath) + _ = os.Remove(upload.storagePath) c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel registrar o anexo."}) return } @@ -694,6 +1022,96 @@ func (r Routes) uploadAttachment(c *gin.Context) { c.JSON(http.StatusCreated, gin.H{"attachment": attachment}) } +type savedUpload struct { + originalFilename string + storedFilename string + mimeType string + sizeBytes int64 + storagePath string +} + +func (r Routes) saveUploadedFile(c *gin.Context, relativeDir string) (savedUpload, error) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, r.uploadMaxSizeBytes) + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"message": "Envie um arquivo valido."}) + return savedUpload{}, err + } + defer file.Close() + + if header.Size > r.uploadMaxSizeBytes { + c.JSON(http.StatusBadRequest, gin.H{"message": "Arquivo acima do limite permitido."}) + return savedUpload{}, errors.New("file too large") + } + + head := make([]byte, 512) + n, err := file.Read(head) + if err != nil && err != io.EOF { + c.JSON(http.StatusBadRequest, gin.H{"message": "Nao foi possivel ler o arquivo."}) + return savedUpload{}, err + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel processar o arquivo."}) + return savedUpload{}, err + } + + mimeType := normalizeUploadMimeType(http.DetectContentType(head[:n]), header.Filename) + if len(r.uploadAllowedMIMEs) > 0 && !r.uploadAllowedMIMEs[mimeType] { + c.JSON(http.StatusBadRequest, gin.H{"message": "Tipo de arquivo nao permitido."}) + return savedUpload{}, errors.New("invalid mime") + } + + storedFilename, err := randomStoredFilename(header.Filename) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel preparar o arquivo."}) + return savedUpload{}, err + } + + storageDir := filepath.Join(r.storageLocalPath, filepath.Clean(relativeDir)) + if err := os.MkdirAll(storageDir, 0o750); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel preparar o armazenamento."}) + return savedUpload{}, err + } + + storagePath := filepath.Join(storageDir, storedFilename) + destination, err := os.OpenFile(storagePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel salvar o arquivo."}) + return savedUpload{}, err + } + defer destination.Close() + + sizeBytes, err := io.Copy(destination, file) + if err != nil { + _ = os.Remove(storagePath) + c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel salvar o arquivo."}) + return savedUpload{}, err + } + + return savedUpload{ + originalFilename: filepath.Base(header.Filename), + storedFilename: storedFilename, + mimeType: mimeType, + sizeBytes: sizeBytes, + storagePath: storagePath, + }, nil +} + +func normalizeUploadMimeType(detected string, filename string) string { + switch strings.ToLower(filepath.Ext(filename)) { + case ".docx": + if detected == "application/zip" || detected == "application/octet-stream" { + return "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + } + case ".doc": + if detected == "application/octet-stream" { + return "application/msword" + } + } + + return detected +} + func (r Routes) downloadAttachment(c *gin.Context) { claims, ok := auth.ClaimsFromContext(c) if !ok { @@ -721,6 +1139,37 @@ func (r Routes) downloadAttachment(c *gin.Context) { c.File(attachment.StoragePath) } +func (r Routes) deleteAttachment(c *gin.Context) { + claims, ok := auth.ClaimsFromContext(c) + if !ok || (claims.Role != users.RoleSuperAdmin && claims.Role != users.RoleAgencyUser) { + c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."}) + return + } + + attachment, err := r.store.DeleteAttachment(c.Request.Context(), c.Param("attachmentId")) + if errors.Is(err, ErrAttachmentNotFound) { + c.JSON(http.StatusNotFound, gin.H{"message": "Anexo nao encontrado."}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel excluir o anexo."}) + return + } + + if attachment.StorageDriver == "local" { + _ = os.Remove(attachment.StoragePath) + } + + _ = r.store.RecordAudit(c.Request.Context(), claims.UserID, "attachment.deleted", "calendar_item_attachment", attachment.ID, map[string]any{ + "calendar_item_id": attachment.CalendarItemID, + "filename": attachment.OriginalFilename, + "mime_type": attachment.MimeType, + "size_bytes": attachment.SizeBytes, + }) + + c.JSON(http.StatusOK, gin.H{"attachment": attachment}) +} + func randomStoredFilename(original string) (string, error) { bytes := make([]byte, 16) if _, err := rand.Read(bytes); err != nil { @@ -738,3 +1187,12 @@ func isValidCalendarItemStatus(status string) bool { return false } } + +func isValidChatChannel(channel string) bool { + switch channel { + case ChatChannelExternal, ChatChannelInternal: + return true + default: + return false + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index d9d6dc9..a7a2268 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -48,7 +48,7 @@ func Load() Config { SuperAdminEmail: env("SUPER_ADMIN_EMAIL", ""), SuperAdminPassword: env("SUPER_ADMIN_PASSWORD", ""), JWTSecret: env("JWT_SECRET", ""), - AccessTokenTTL: minutes("JWT_ACCESS_TOKEN_TTL_MINUTES", 15), + AccessTokenTTL: minutes("JWT_ACCESS_TOKEN_TTL_MINUTES", 480), RefreshTokenTTL: hours("JWT_REFRESH_TOKEN_TTL_DAYS", 7*24), CORSAllowedOrigins: list("CORS_ALLOWED_ORIGINS", "http://localhost:5173"), CalendarProvider: env("CALENDAR_PROVIDER", "brasilapi"), @@ -56,14 +56,14 @@ func Load() Config { CalendarAPIKey: env("CALENDAR_API_KEY", ""), CalendarCacheTTL: hours("CALENDAR_CACHE_TTL_HOURS", 24), UploadMaxSizeMB: integer("UPLOAD_MAX_SIZE_MB", 10), - UploadAllowedMIMEs: list("UPLOAD_ALLOWED_MIME_TYPES", "image/jpeg,image/png,image/webp,application/pdf"), + UploadAllowedMIMEs: list("UPLOAD_ALLOWED_MIME_TYPES", "image/jpeg,image/png,image/webp,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"), StorageDriver: env("STORAGE_DRIVER", "local"), StorageLocalPath: env("STORAGE_LOCAL_PATH", "/var/lib/mira/uploads"), SMTPHost: env("SMTP_HOST", ""), SMTPPort: env("SMTP_PORT", "587"), SMTPUser: env("SMTP_USER", ""), - SMTPPassword: env("SMTP_PASSWORD", ""), - SMTPFromEmail: env("SMTP_FROM_EMAIL", ""), + SMTPPassword: envAlias("SMTP_PASS", "SMTP_PASSWORD", ""), + SMTPFromEmail: envAlias("MAIL_FROM", "SMTP_FROM_EMAIL", ""), SMTPFromName: env("SMTP_FROM_NAME", "Mira"), } } @@ -76,6 +76,14 @@ func env(key, fallback string) string { return value } +func envAlias(primary string, secondary string, fallback string) string { + value := strings.TrimSpace(os.Getenv(primary)) + if value != "" { + return value + } + return env(secondary, fallback) +} + func list(key, fallback string) []string { raw := env(key, fallback) parts := strings.Split(raw, ",") diff --git a/backend/internal/httpserver/server.go b/backend/internal/httpserver/server.go index eddfcb4..84cd258 100644 --- a/backend/internal/httpserver/server.go +++ b/backend/internal/httpserver/server.go @@ -9,6 +9,7 @@ import ( "mira/backend/internal/clients" "mira/backend/internal/config" "mira/backend/internal/database" + "mira/backend/internal/mail" "mira/backend/internal/security" "mira/backend/internal/users" @@ -39,8 +40,17 @@ func New(cfg config.Config, logger *slog.Logger, db *database.DB, userStore user tokenService := auth.NewTokenService(cfg.JWTSecret, cfg.AccessTokenTTL) requireAuth := auth.RequireAuth(tokenService) - authRoutes := auth.NewRoutes(userStore, tokenService) - userRoutes := users.NewRoutes(userStore, cfg.AppPublicURL) + sessionStore := auth.NewSessionStore(db.Pool) + authRoutes := auth.NewRoutes(userStore, sessionStore, tokenService, cfg.RefreshTokenTTL, cfg.AppEnv == "production") + mailSender := mail.NewSender(mail.SMTPConfig{ + Host: cfg.SMTPHost, + Port: cfg.SMTPPort, + User: cfg.SMTPUser, + Password: cfg.SMTPPassword, + From: cfg.SMTPFromEmail, + FromName: cfg.SMTPFromName, + }) + userRoutes := users.NewRoutes(userStore, cfg.AppPublicURL, mailSender) clientRoutes := clients.NewRoutes(clientStore) calendarRoutes := calendar.NewRoutes(calendarStore, calendar.NewBrasilAPIProvider(cfg.CalendarAPIBaseURL), calendar.NewFeriadosBrasilProvider(), calendar.RouteOptions{ UploadMaxSizeMB: cfg.UploadMaxSizeMB, diff --git a/backend/internal/mail/smtp.go b/backend/internal/mail/smtp.go new file mode 100644 index 0000000..1b7833e --- /dev/null +++ b/backend/internal/mail/smtp.go @@ -0,0 +1,150 @@ +package mail + +import ( + "crypto/tls" + "fmt" + "net" + "net/mail" + "net/smtp" + "strings" +) + +type SMTPConfig struct { + Host string + Port string + User string + Password string + From string + FromName string +} + +type Sender struct { + cfg SMTPConfig +} + +func NewSender(cfg SMTPConfig) Sender { + if strings.TrimSpace(cfg.Port) == "" { + cfg.Port = "587" + } + return Sender{cfg: cfg} +} + +func (s Sender) IsConfigured() bool { + return strings.TrimSpace(s.cfg.Host) != "" && + strings.TrimSpace(s.cfg.Port) != "" && + strings.TrimSpace(s.cfg.From) != "" +} + +func (s Sender) SendInvitation(to string, invitationURL string) error { + subject := "Convite para acessar o Mira" + textBody := "Voce foi convidado para acessar o Mira.\n\nCrie sua senha pelo link abaixo:\n" + invitationURL + "\n\nEste convite expira em 7 dias." + htmlBody := `
Voce foi convidado para acessar o Mira.
Este convite expira em 7 dias.
` + + return s.send(to, subject, textBody, htmlBody) +} + +func (s Sender) send(to string, subject string, textBody string, htmlBody string) error { + if !s.IsConfigured() { + return nil + } + + from := mail.Address{Name: strings.TrimSpace(s.cfg.FromName), Address: strings.TrimSpace(s.cfg.From)} + recipient := mail.Address{Address: strings.TrimSpace(to)} + boundary := "mira-mail-boundary" + message := strings.Join([]string{ + "From: " + from.String(), + "To: " + recipient.String(), + "Subject: " + subject, + "MIME-Version: 1.0", + "Content-Type: multipart/alternative; boundary=\"" + boundary + "\"", + "", + "--" + boundary, + "Content-Type: text/plain; charset=UTF-8", + "", + textBody, + "--" + boundary, + "Content-Type: text/html; charset=UTF-8", + "", + htmlBody, + "--" + boundary + "--", + "", + }, "\r\n") + + host := strings.TrimSpace(s.cfg.Host) + port := strings.TrimSpace(s.cfg.Port) + addr := net.JoinHostPort(host, port) + var auth smtp.Auth + if strings.TrimSpace(s.cfg.User) != "" || strings.TrimSpace(s.cfg.Password) != "" { + auth = smtp.PlainAuth("", strings.TrimSpace(s.cfg.User), strings.TrimSpace(s.cfg.Password), host) + } + + if port == "465" { + if err := sendMailTLS(addr, host, auth, from.Address, []string{recipient.Address}, []byte(message)); err != nil { + return fmt.Errorf("send smtp email: %w", err) + } + return nil + } + + if err := sendMailStartTLS(addr, host, auth, from.Address, []string{recipient.Address}, []byte(message)); err != nil { + return fmt.Errorf("send smtp email: %w", err) + } + return nil +} + +func sendMailStartTLS(addr string, host string, auth smtp.Auth, from string, to []string, message []byte) error { + client, err := smtp.Dial(addr) + if err != nil { + return err + } + defer client.Quit() + + if ok, _ := client.Extension("STARTTLS"); ok { + if err := client.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil { + return err + } + } + + return sendMailWithClient(client, auth, from, to, message) +} + +func sendMailTLS(addr string, host string, auth smtp.Auth, from string, to []string, message []byte) error { + conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}) + if err != nil { + return err + } + + client, err := smtp.NewClient(conn, host) + if err != nil { + _ = conn.Close() + return err + } + defer client.Quit() + + return sendMailWithClient(client, auth, from, to, message) +} + +func sendMailWithClient(client *smtp.Client, auth smtp.Auth, from string, to []string, message []byte) error { + if auth != nil { + if err := client.Auth(auth); err != nil { + return err + } + } + if err := client.Mail(from); err != nil { + return err + } + for _, recipient := range to { + if err := client.Rcpt(recipient); err != nil { + return err + } + } + + writer, err := client.Data() + if err != nil { + return err + } + if _, err := writer.Write(message); err != nil { + _ = writer.Close() + return err + } + return writer.Close() +} diff --git a/backend/internal/security/headers.go b/backend/internal/security/headers.go index 13e908c..ae3e98d 100644 --- a/backend/internal/security/headers.go +++ b/backend/internal/security/headers.go @@ -9,7 +9,7 @@ import ( func Headers() gin.HandlerFunc { return func(c *gin.Context) { - c.Header("Content-Security-Policy", "default-src 'self'; frame-ancestors 'none'") + c.Header("Content-Security-Policy", "default-src 'self'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'") c.Header("X-Content-Type-Options", "nosniff") c.Header("X-Frame-Options", "DENY") c.Header("Referrer-Policy", "strict-origin-when-cross-origin") diff --git a/backend/internal/users/invitations.go b/backend/internal/users/invitations.go index 63415d1..20ce8ff 100644 --- a/backend/internal/users/invitations.go +++ b/backend/internal/users/invitations.go @@ -25,6 +25,8 @@ type Invitation struct { CreatedBy *string `json:"created_by"` CreatedAt time.Time `json:"created_at"` InvitationURL string `json:"invitation_url,omitempty"` + EmailSent bool `json:"email_sent"` + EmailError string `json:"email_error,omitempty"` } type CreateInvitationInput struct { diff --git a/backend/internal/users/routes.go b/backend/internal/users/routes.go index 28134d2..84374b5 100644 --- a/backend/internal/users/routes.go +++ b/backend/internal/users/routes.go @@ -5,18 +5,22 @@ import ( "strings" "time" + "mira/backend/internal/mail" + "github.com/gin-gonic/gin" ) type Routes struct { store Store appPublicURL string + mailSender mail.Sender } -func NewRoutes(store Store, appPublicURL string) Routes { +func NewRoutes(store Store, appPublicURL string, mailSender mail.Sender) Routes { return Routes{ store: store, appPublicURL: strings.TrimRight(appPublicURL, "/"), + mailSender: mailSender, } } @@ -106,6 +110,14 @@ func (r Routes) createInvitation(c *gin.Context) { } invitation.InvitationURL = r.invitationURL(token) + invitation.EmailSent = false + if r.mailSender.IsConfigured() { + if err := r.mailSender.SendInvitation(invitation.Email, invitation.InvitationURL); err != nil { + invitation.EmailError = "Convite criado, mas nao foi possivel enviar o e-mail." + } else { + invitation.EmailSent = true + } + } c.JSON(http.StatusCreated, gin.H{"invitation": invitation}) } diff --git a/backend/migrations/005_refresh_tokens.sql b/backend/migrations/005_refresh_tokens.sql new file mode 100644 index 0000000..05ec6fc --- /dev/null +++ b/backend/migrations/005_refresh_tokens.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens (user_id); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens (token_hash); diff --git a/backend/migrations/006_calendar_item_chat_messages.sql b/backend/migrations/006_calendar_item_chat_messages.sql new file mode 100644 index 0000000..ab34901 --- /dev/null +++ b/backend/migrations/006_calendar_item_chat_messages.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS calendar_item_chat_messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + calendar_item_id UUID NOT NULL REFERENCES calendar_items(id) ON DELETE CASCADE, + channel TEXT NOT NULL CHECK (channel IN ('external', 'internal')), + body TEXT NOT NULL, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_calendar_item_chat_messages_item_channel + ON calendar_item_chat_messages (calendar_item_id, channel, created_at); diff --git a/backend/migrations/007_chat_message_features.sql b/backend/migrations/007_chat_message_features.sql new file mode 100644 index 0000000..64f487b --- /dev/null +++ b/backend/migrations/007_chat_message_features.sql @@ -0,0 +1,19 @@ +ALTER TABLE calendar_item_chat_messages +ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ, +ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; + +CREATE TABLE IF NOT EXISTS calendar_item_chat_attachments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + chat_message_id UUID NOT NULL REFERENCES calendar_item_chat_messages(id) ON DELETE CASCADE, + original_filename TEXT NOT NULL, + stored_filename TEXT NOT NULL, + mime_type TEXT NOT NULL, + size_bytes BIGINT NOT NULL CHECK (size_bytes >= 0), + storage_driver TEXT NOT NULL, + storage_path TEXT NOT NULL, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_calendar_item_chat_attachments_message + ON calendar_item_chat_attachments (chat_message_id, created_at); diff --git a/docker-compose.yml b/docker-compose.yml index db8ced4..bceb764 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,8 +41,8 @@ services: SMTP_HOST: ${SMTP_HOST} SMTP_PORT: ${SMTP_PORT} SMTP_USER: ${SMTP_USER} - SMTP_PASSWORD: ${SMTP_PASSWORD} - SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL} + SMTP_PASS: ${SMTP_PASS} + MAIL_FROM: ${MAIL_FROM} SMTP_FROM_NAME: ${SMTP_FROM_NAME} UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB} UPLOAD_ALLOWED_MIME_TYPES: ${UPLOAD_ALLOWED_MIME_TYPES} diff --git a/frontend/nginx.conf b/frontend/nginx.conf index a3c8e9b..9a37058 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -9,6 +9,7 @@ server { add_header X-Frame-Options "DENY" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' blob: data:; connect-src 'self' http://localhost:8081 http://127.0.0.1:8081; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; location / { try_files $uri $uri/ /index.html; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c3d32e4..2deda89 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,21 +10,63 @@ import { ClientDashboardView } from "./views/ClientDashboardView"; import { PostDetailView } from "./views/PostDetailView"; import { Topbar } from "./components/layout/Topbar"; import { Sidebar } from "./components/layout/Sidebar"; -import { clearStoredToken, getStoredToken, loadCurrentUser, type AuthUser } from "@/lib/auth"; +import { clearStoredToken, getStoredToken, loadCurrentUser, logout, refreshSession, type AuthUser } from "@/lib/auth"; import { listClients, type Client } from "@/lib/clients"; +const APP_STATE_STORAGE_KEY = "mira_app_state"; +const validViews = new Set(["dashboard", "clients", "client-dashboard", "post-detail", "calendar", "users"]); + +type StoredAppState = { + currentView: string; + selectedClientId: string | null; + selectedPostId: string | null; +}; + +function readStoredAppState(): StoredAppState { + const fallback = { + currentView: "dashboard", + selectedClientId: null, + selectedPostId: null, + }; + + try { + const raw = localStorage.getItem(APP_STATE_STORAGE_KEY); + if (!raw) return fallback; + + const parsed = JSON.parse(raw) as PartialCarregando postagem...
; } @@ -103,6 +644,159 @@ export function PostDetailView({ itemId, onBack }: PostDetailViewProps) { if (!item) return null; + const chatCard = ( ++ {emojiCategoryId === "recent" && emojiSearch.trim() === "" ? "Os emojis usados aparecem aqui." : "Nenhum emoji encontrado."} +
+ )} +{item.client_name} ยท {dateFormatter.format(parseDateValue(item.scheduled_date))} @@ -147,7 +860,7 @@ export function PostDetailView({ itemId, onBack }: PostDetailViewProps) {