diff --git a/.env.example b/.env.example index 05d4774..e46572c 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ APP_ENV=production -APP_PUBLIC_URL=http://localhost:5173 +APP_PUBLIC_URL=https://app.example.com.br 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=480 +JWT_ACCESS_TOKEN_TTL_MINUTES=15 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_PORT= +SMTP_HOST=smtp.example.com.br +SMTP_PORT=587 SMTP_USER= -SMTP_PASS= -MAIL_FROM= +SMTP_PASSWORD= +SMTP_FROM_EMAIL=no-reply@example.com.br SMTP_FROM_NAME=Mira UPLOAD_MAX_SIZE_MB=10 -UPLOAD_ALLOWED_MIME_TYPES=image/jpeg,image/png,image/webp,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document +UPLOAD_ALLOWED_MIME_TYPES=image/jpeg,image/png,image/webp,application/pdf 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 deleted file mode 100644 index 518e0f4..0000000 --- a/backend/internal/auth/rate_limit.go +++ /dev/null @@ -1,68 +0,0 @@ -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 8ae9853..1fd94ed 100644 --- a/backend/internal/auth/routes.go +++ b/backend/internal/auth/routes.go @@ -4,40 +4,35 @@ 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 - sessions SessionStore - tokens TokenService - refreshTTL time.Duration - secureCookies bool - loginRateLimiter *LoginRateLimiter + users users.Store + tokens TokenService } -func NewRoutes(users users.Store, sessions SessionStore, tokens TokenService, refreshTTL time.Duration, secureCookies bool) Routes { +func NewRoutes(users users.Store, tokens TokenService) Routes { return Routes{ - users: users, - sessions: sessions, - tokens: tokens, - refreshTTL: refreshTTL, - secureCookies: secureCookies, - loginRateLimiter: NewLoginRateLimiter(5, 15*time.Minute), + users: users, + tokens: tokens, } } func (r Routes) Register(router *gin.RouterGroup) { router.POST("/login", r.login) router.POST("/invitations/accept", r.acceptInvitation) - router.POST("/logout", r.logout) - router.POST("/refresh", r.refresh) + + 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."}) + }) } type loginRequest struct { @@ -52,16 +47,8 @@ func (r Routes) login(c *gin.Context) { return } - 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) + user, err := r.users.FindByEmail(c.Request.Context(), strings.TrimSpace(input.Email)) if errors.Is(err, users.ErrNotFound) { - r.loginRateLimiter.RecordFailure(rateLimitKey) c.JSON(http.StatusUnauthorized, gin.H{"message": "E-mail ou senha invalidos."}) return } @@ -70,17 +57,15 @@ 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.createSession(c, user) + 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 } - r.loginRateLimiter.Clear(rateLimitKey) c.JSON(http.StatusOK, gin.H{ "access_token": token, @@ -129,8 +114,15 @@ 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{ - "message": "Senha criada com sucesso. Faca login para acessar o Mira.", + "access_token": token, + "token_type": "Bearer", "user": gin.H{ "id": user.ID, "email": user.Email, @@ -140,84 +132,3 @@ 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 deleted file mode 100644 index 750a3fb..0000000 --- a/backend/internal/auth/sessions.go +++ /dev/null @@ -1,112 +0,0 @@ -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 3506bdd..ed7bf6f 100644 --- a/backend/internal/calendar/attachments.go +++ b/backend/internal/calendar/attachments.go @@ -137,26 +137,6 @@ 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 deleted file mode 100644 index 08c7882..0000000 --- a/backend/internal/calendar/chat.go +++ /dev/null @@ -1,377 +0,0 @@ -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 deleted file mode 100644 index b2f1168..0000000 --- a/backend/internal/calendar/chat_events.go +++ /dev/null @@ -1,55 +0,0 @@ -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 af6dcae..7e8b7a9 100644 --- a/backend/internal/calendar/routes.go +++ b/backend/internal/calendar/routes.go @@ -22,7 +22,6 @@ type Routes struct { store Store provider Provider commemorative CommemorativeProvider - chatEvents *ChatEventHub uploadMaxSizeBytes int64 uploadAllowedMIMEs map[string]bool storageDriver string @@ -55,7 +54,6 @@ 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, @@ -82,15 +80,7 @@ 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) { @@ -614,376 +604,6 @@ 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) { @@ -991,23 +611,75 @@ func (r Routes) uploadAttachment(c *gin.Context) { return } - upload, err := r.saveUploadedFile(c, c.Param("itemId")) + 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 + } + 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: upload.originalFilename, - StoredFilename: upload.storedFilename, - MimeType: upload.mimeType, - SizeBytes: upload.sizeBytes, + OriginalFilename: filepath.Base(header.Filename), + StoredFilename: storedFilename, + MimeType: mimeType, + SizeBytes: sizeBytes, StorageDriver: r.storageDriver, - StoragePath: upload.storagePath, + StoragePath: storagePath, CreatedBy: claims.UserID, }) if err != nil { - _ = os.Remove(upload.storagePath) + _ = os.Remove(storagePath) c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel registrar o anexo."}) return } @@ -1022,96 +694,6 @@ 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 { @@ -1139,37 +721,6 @@ 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 { @@ -1187,12 +738,3 @@ 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 a7a2268..d9d6dc9 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", 480), + AccessTokenTTL: minutes("JWT_ACCESS_TOKEN_TTL_MINUTES", 15), 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,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + UploadAllowedMIMEs: list("UPLOAD_ALLOWED_MIME_TYPES", "image/jpeg,image/png,image/webp,application/pdf"), 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: envAlias("SMTP_PASS", "SMTP_PASSWORD", ""), - SMTPFromEmail: envAlias("MAIL_FROM", "SMTP_FROM_EMAIL", ""), + SMTPPassword: env("SMTP_PASSWORD", ""), + SMTPFromEmail: env("SMTP_FROM_EMAIL", ""), SMTPFromName: env("SMTP_FROM_NAME", "Mira"), } } @@ -76,14 +76,6 @@ 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 84cd258..eddfcb4 100644 --- a/backend/internal/httpserver/server.go +++ b/backend/internal/httpserver/server.go @@ -9,7 +9,6 @@ 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" @@ -40,17 +39,8 @@ func New(cfg config.Config, logger *slog.Logger, db *database.DB, userStore user tokenService := auth.NewTokenService(cfg.JWTSecret, cfg.AccessTokenTTL) requireAuth := auth.RequireAuth(tokenService) - 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) + authRoutes := auth.NewRoutes(userStore, tokenService) + userRoutes := users.NewRoutes(userStore, cfg.AppPublicURL) 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 deleted file mode 100644 index 1b7833e..0000000 --- a/backend/internal/mail/smtp.go +++ /dev/null @@ -1,150 +0,0 @@ -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 ae3e98d..13e908c 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'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'") + c.Header("Content-Security-Policy", "default-src 'self'; frame-ancestors 'none'") 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 20ce8ff..63415d1 100644 --- a/backend/internal/users/invitations.go +++ b/backend/internal/users/invitations.go @@ -25,8 +25,6 @@ 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 84374b5..28134d2 100644 --- a/backend/internal/users/routes.go +++ b/backend/internal/users/routes.go @@ -5,22 +5,18 @@ 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, mailSender mail.Sender) Routes { +func NewRoutes(store Store, appPublicURL string) Routes { return Routes{ store: store, appPublicURL: strings.TrimRight(appPublicURL, "/"), - mailSender: mailSender, } } @@ -110,14 +106,6 @@ 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 deleted file mode 100644 index 05ec6fc..0000000 --- a/backend/migrations/005_refresh_tokens.sql +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index ab34901..0000000 --- a/backend/migrations/006_calendar_item_chat_messages.sql +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 64f487b..0000000 --- a/backend/migrations/007_chat_message_features.sql +++ /dev/null @@ -1,19 +0,0 @@ -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 bceb764..db8ced4 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_PASS: ${SMTP_PASS} - MAIL_FROM: ${MAIL_FROM} + SMTP_PASSWORD: ${SMTP_PASSWORD} + SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL} 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 9a37058..a3c8e9b 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -9,7 +9,6 @@ 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 2deda89..c3d32e4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,63 +10,21 @@ 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, logout, refreshSession, type AuthUser } from "@/lib/auth"; +import { clearStoredToken, getStoredToken, loadCurrentUser, 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...
; } @@ -644,159 +103,6 @@ export function PostDetailView({ itemId, onBack, user }: 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))} @@ -860,7 +147,7 @@ export function PostDetailView({ itemId, onBack, user }: PostDetailViewProps) {