diff --git a/README.md b/README.md index 93aed74..b1a44bf 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,139 @@ -# nexo +# Nexo Nexo is a self-hosted alternative to Notion. Organize documents, build flexible databases with multiple views (table, board, calendar, gallery), and automate workflows — all under your control. + +## Getting Started + +### Prerequisites + +- Go 1.22+ +- SQLite (default) or PostgreSQL + +### Installation + +```bash +go build -o nexo ./cmd +``` + +### Running + +```bash +# Minimal — SQLite, required vars only +SESSION_SECRET_KEY="your-secret-at-least-32-chars-long" ./nexo server + +# With config file +./nexo server --config config +``` + +--- + +## Configuration + +All options can be set via environment variable, CLI flag, or `config.yaml`. +Priority: **env var > CLI flag > config file > default**. + +### Required + +| Env var | Description | +|---------|-------------| +| `SESSION_SECRET_KEY` | JWT signing key. **Minimum 32 characters.** The server refuses to start if this is shorter or unset. Generate with: `openssl rand -base64 48` | + +### Server + +| Env var | CLI flag | Default | Description | +|---------|----------|---------|-------------| +| `HTTP_PORT` | `--http.port` | `8080` | Listening port | +| `HTTP_LOGS` | `--http.logs` | `false` | Enable HTTP access logs | +| `HTTP_CORS_ALLOW_ORIGINS` | `--http.cors_allow_origins` | `*` | Comma-separated list of allowed CORS origins. **Set this in production** (e.g. `https://app.example.com`). Use `*` only for local dev. | + +### Database + +| Env var | CLI flag | Default | Description | +|---------|----------|---------|-------------| +| `DATABASE_DIALECT` | `--database.dialect` | `sqlite` | `sqlite` or `postgres` | +| `DATABASE_DSN` | `--database.dsn` | `./database.sqlite` | SQLite file path or PostgreSQL DSN | + +### Session / JWT + +| Env var | CLI flag | Default | Description | +|---------|----------|---------|-------------| +| `SESSION_SECRET_KEY` | `--session.secret_key` | *(none)* | **Required.** ≥ 32 chars | +| `SESSION_EXPIRATION_MINUTES` | `--session.expiration_minutes` | `43200` (30 days) | Token lifetime in minutes | +| `SESSION_ISSUER` | `--session.issuer` | `nexo` | JWT `iss` claim | + +### Logger + +| Env var | CLI flag | Default | Description | +|---------|----------|---------|-------------| +| `LOGGER_LEVEL` | `--logger.level` | `info` | `debug`, `info`, `warn`, `error` | +| `LOGGER_PRETTY` | `--logger.pretty` | `false` | Human-readable logs (dev only) | + +--- + +## Config file (`config.yaml`) + +```yaml +http: + port: 8080 + logs: true + cors_allow_origins: "https://app.example.com" + +logger: + level: info + pretty: false + +database: + dialect: sqlite + dsn: ./database.sqlite + +session: + # Required — do not commit real values to source control + # Generate: openssl rand -base64 48 + secret_key: "CHANGE_ME_AT_LEAST_32_CHARACTERS_LONG" + expiration_minutes: 43200 + issuer: nexo +``` + +See `config-example.yaml` for a minimal working example. + +--- + +## Docker + +```bash +docker build -t nexo . +docker run -p 8080:8080 \ + -e SESSION_SECRET_KEY="$(openssl rand -base64 48)" \ + -e HTTP_CORS_ALLOW_ORIGINS="https://app.example.com" \ + -e DATABASE_DSN="/data/nexo.sqlite" \ + -v nexo_data:/data \ + nexo +``` + +Or with `docker-compose.yaml`: + +```bash +# Copy and fill in secrets +cp .env.example .env +docker compose up -d +``` + +--- + +## WebSocket collaboration + +The collaboration endpoint at `/ws/collab/` requires a valid JWT passed as the `token` query parameter. Every connection is authorized against the resource identified by the room ID: + +| Room prefix | Resource checked | +|-------------|-----------------| +| `document:{id}` | Document permissions | +| `drawing:{id}` | Drawing → space permissions | +| `row:{dbId}:{rowId}` | Database → space permissions | + +Connections with an invalid token, unknown room format, or insufficient permissions are rejected. + +--- + +## License + +MIT diff --git a/application/action/execute_actions.go b/application/action/execute_actions.go index fb2313c..f004df5 100644 --- a/application/action/execute_actions.go +++ b/application/action/execute_actions.go @@ -21,7 +21,15 @@ func (app *ActionApplication) ExecuteActions(input dto.ExecuteActionInput) { } for _, action := range actions { - go app.executeAction(action, input.TriggerData) + a := action + go func() { + defer func() { + if r := recover(); r != nil { + app.Logger.Error().Any("panic", r).Str("action_id", a.Id).Msg("action execution panicked") + } + }() + app.executeAction(a, input.TriggerData) + }() } } @@ -40,8 +48,15 @@ func (app *ActionApplication) executeAction(action domain.Action, triggerData ma // Parse steps var steps []dto.ActionStep if action.Steps != nil { - stepsJSON, _ := json.Marshal(action.Steps) - json.Unmarshal(stepsJSON, &steps) + stepsJSON, err := json.Marshal(action.Steps) + if err != nil { + logger.Error().Err(err).Msg("failed to marshal action steps") + return + } + if err := json.Unmarshal(stepsJSON, &steps); err != nil { + logger.Error().Err(err).Msg("failed to unmarshal action steps") + return + } } // Execute each step diff --git a/application/session/validate.go b/application/session/validate.go index 452101c..fdddefb 100644 --- a/application/session/validate.go +++ b/application/session/validate.go @@ -20,18 +20,18 @@ func (c *SessionApplication) ValidateToken(input dto.ValidateTokenInput) (*dto.V sessionId, err := tokenutil.GetSessionIdFromToken(input.Token, c.Config) if err != nil { - logger.Error().Err(err).Str("token", input.Token).Msg("failed to get session id from token") + logger.Error().Err(err).Msg("failed to get session id from token") return nil, apperrors.ErrInvalidToken } session, err := c.SessionPers.GetById(sessionId) if err != nil { - logger.Error().Err(err).Str("token", input.Token).Msg("failed to get session by token") + logger.Error().Err(err).Str("session_id", sessionId).Msg("failed to get session by id") return nil, apperrors.ErrInvalidToken } if session.ExpiresAt.Before(time.Now()) { - logger.Warn().Str("token", input.Token).Msg("session has expired") + logger.Warn().Str("session_id", sessionId).Msg("session has expired") return nil, apperrors.ErrSessionExpired } diff --git a/application/webhook/trigger_webhooks.go b/application/webhook/trigger_webhooks.go index 9662357..899afa8 100644 --- a/application/webhook/trigger_webhooks.go +++ b/application/webhook/trigger_webhooks.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "io" "net/http" "time" @@ -23,7 +24,15 @@ func (app *WebhookApplication) TriggerWebhooks(input dto.TriggerWebhookInput) { } for _, webhook := range webhooks { - go app.deliverWebhook(webhook, input.Event, input.Payload) + w := webhook + go func() { + defer func() { + if r := recover(); r != nil { + app.Logger.Error().Any("panic", r).Str("webhook_id", w.Id).Msg("webhook delivery panicked") + } + }() + app.deliverWebhook(w, input.Event, input.Payload) + }() } } @@ -61,10 +70,11 @@ func (app *WebhookApplication) deliverWebhook(webhook domain.Webhook, event stri return } + deliveryId, _ := webhookPayload["id"].(string) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Webhook-Signature", signature) req.Header.Set("X-Webhook-Event", event) - req.Header.Set("X-Webhook-Delivery", webhookPayload["id"].(string)) + req.Header.Set("X-Webhook-Delivery", deliveryId) client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) @@ -81,9 +91,8 @@ func (app *WebhookApplication) deliverWebhook(webhook domain.Webhook, event stri success := resp.StatusCode >= 200 && resp.StatusCode < 300 responseBody := "" if !success { - buf := new(bytes.Buffer) - buf.ReadFrom(resp.Body) - responseBody = buf.String() + limited, _ := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) + responseBody = string(limited) if len(responseBody) > 500 { responseBody = responseBody[:500] } diff --git a/config-example.yaml b/config-example.yaml index 78cde9e..60f9900 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -1,9 +1,25 @@ http: port: 8080 logs: true + # Comma-separated list of allowed CORS origins. + # Use * for local dev only. Set to your frontend URL in production. + cors_allow_origins: "*" + logger: level: info pretty: false + database: dialect: sqlite - dsn: ./database.sqlite \ No newline at end of file + dsn: ./database.sqlite + # PostgreSQL example: + # dialect: postgres + # dsn: "host=localhost user=nexo password=nexo dbname=nexo sslmode=disable" + +session: + # Required — must be at least 32 characters. + # Generate: openssl rand -base64 48 + # Can also be set via SESSION_SECRET_KEY env var (recommended in production). + secret_key: "CHANGE_ME_USE_openssl_rand_base64_48" + expiration_minutes: 43200 # 30 days + issuer: nexo diff --git a/domain/common.go b/domain/common.go index 9757cd2..4aa8b7c 100644 --- a/domain/common.go +++ b/domain/common.go @@ -3,6 +3,7 @@ package domain import ( "database/sql/driver" "encoding/json" + "fmt" ) // JSONB is a map of strings to interfaces @@ -20,7 +21,11 @@ func (j *JSONB) Scan(value any) error { *j = nil return nil } - return json.Unmarshal([]byte(value.(string)), j) + s, ok := value.(string) + if !ok { + return fmt.Errorf("JSONB.Scan: expected string, got %T", value) + } + return json.Unmarshal([]byte(s), j) } // JSONBArray is a slice that can be stored as JSONB in PostgreSQL @@ -38,5 +43,9 @@ func (j *JSONBArray) Scan(value any) error { *j = nil return nil } - return json.Unmarshal([]byte(value.(string)), j) + s, ok := value.(string) + if !ok { + return fmt.Errorf("JSONBArray.Scan: expected string, got %T", value) + } + return json.Unmarshal([]byte(s), j) } diff --git a/infrastructure/collaboration/handler.go b/infrastructure/collaboration/handler.go index df2d292..f88b740 100644 --- a/infrastructure/collaboration/handler.go +++ b/infrastructure/collaboration/handler.go @@ -5,8 +5,10 @@ import ( "github.com/gofiber/fiber/v2" "github.com/gofiber/websocket/v2" + fiberoapi "github.com/labbs/fiber-oapi" "github.com/labbs/nexo/application/session" - "github.com/labbs/nexo/application/session/dto" + sessionDto "github.com/labbs/nexo/application/session/dto" + "github.com/labbs/nexo/domain" "github.com/rs/zerolog" ) @@ -14,22 +16,23 @@ const wsCollabPrefix = "/ws/collab/" // Handler manages WebSocket connections for Y.js collaboration. type Handler struct { - hub *Hub - sessionApp *session.SessionApplication - logger zerolog.Logger + hub *Hub + sessionApp *session.SessionApplication + documentPers domain.DocumentPers + logger zerolog.Logger } // NewHandler creates a new collaboration WebSocket handler. -func NewHandler(hub *Hub, sessionApp *session.SessionApplication, logger zerolog.Logger) *Handler { +func NewHandler(hub *Hub, sessionApp *session.SessionApplication, documentPers domain.DocumentPers, logger zerolog.Logger) *Handler { return &Handler{ - hub: hub, - sessionApp: sessionApp, - logger: logger.With().Str("component", "collaboration.handler").Logger(), + hub: hub, + sessionApp: sessionApp, + documentPers: documentPers, + logger: logger.With().Str("component", "collaboration.handler").Logger(), } } -// UpgradeMiddleware checks for WebSocket upgrade and validates the JWT token -// before upgrading the connection. +// UpgradeMiddleware validates the JWT token before upgrading the WebSocket connection. func (h *Handler) UpgradeMiddleware() fiber.Handler { return func(c *fiber.Ctx) error { if !websocket.IsWebSocketUpgrade(c) { @@ -43,13 +46,13 @@ func (h *Handler) UpgradeMiddleware() fiber.Handler { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "missing token"}) } - result, err := h.sessionApp.ValidateToken(dto.ValidateTokenInput{Token: token}) + result, err := h.sessionApp.ValidateToken(sessionDto.ValidateTokenInput{Token: token}) if err != nil { h.logger.Warn().Err(err).Msg("invalid token on websocket upgrade") return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid token"}) } - // Store auth context and path in locals for the WebSocket handler + c.Locals("auth_context", result.AuthContext) c.Locals("user_id", result.AuthContext.UserID) c.Locals("path", c.Path()) @@ -57,12 +60,70 @@ func (h *Handler) UpgradeMiddleware() fiber.Handler { } } +// canAccessRoom checks if the user has at least viewer access to the room's resource. +// roomID format: "document:{id}", "drawing:{id}", "row:{databaseId}:{rowId}" +func (h *Handler) canAccessRoom(userID string, authCtx *fiberoapi.AuthContext, roomID string) bool { + // Admin bypasses all checks + for _, role := range authCtx.Roles { + if role == string(domain.RoleAdmin) { + return true + } + } + + parts := strings.SplitN(roomID, ":", 3) + if len(parts) < 2 { + return false + } + + resourceType := parts[0] + resourceID := parts[1] + + switch resourceType { + case "document": + doc, err := h.documentPers.GetDocumentWithPermissions(resourceID, userID) + if err != nil { + h.logger.Warn().Err(err).Str("room_id", roomID).Msg("failed to load document for access check") + return false + } + return doc.HasPermission(userID, domain.PermissionRoleViewer) + + case "drawing": + ok, err := h.sessionApp.CanAccessResource(sessionDto.CanAccessResourceInput{ + Context: authCtx, + ResourceType: "drawing", + ResourceID: resourceID, + Action: "read", + }) + if err != nil { + h.logger.Warn().Err(err).Str("room_id", roomID).Msg("failed to check drawing access") + return false + } + return ok + + case "row", "database": + ok, err := h.sessionApp.CanAccessResource(sessionDto.CanAccessResourceInput{ + Context: authCtx, + ResourceType: "database", + ResourceID: resourceID, + Action: "read", + }) + if err != nil { + h.logger.Warn().Err(err).Str("room_id", roomID).Msg("failed to check database access") + return false + } + return ok + + default: + return false + } +} + // WebSocketHandler returns the Fiber WebSocket handler for collaboration. func (h *Handler) WebSocketHandler() fiber.Handler { return websocket.New(func(c *websocket.Conn) { - // Extract room ID from path: /ws/collab/ roomID := strings.TrimPrefix(c.Locals("path").(string), wsCollabPrefix) userID, _ := c.Locals("user_id").(string) + authCtx, _ := c.Locals("auth_context").(*fiberoapi.AuthContext) h.logger.Debug().Str("event", "ws_connection").Str("room_id", roomID).Str("user_id", userID).Msg("new WebSocket connection") @@ -71,6 +132,12 @@ func (h *Handler) WebSocketHandler() fiber.Handler { return } + if authCtx == nil || !h.canAccessRoom(userID, authCtx, roomID) { + h.logger.Warn().Str("room_id", roomID).Str("user_id", userID).Msg("unauthorized WebSocket room access") + c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "forbidden")) + return + } + room := h.hub.GetOrCreateRoom(roomID) client := &Client{ UserID: userID, @@ -82,7 +149,6 @@ func (h *Handler) WebSocketHandler() fiber.Handler { h.hub.RemoveRoomIfEmpty(roomID) }() - // Read loop: relay all binary messages to other clients in the room for { messageType, msg, err := c.ReadMessage() if err != nil { @@ -92,7 +158,6 @@ func (h *Handler) WebSocketHandler() fiber.Handler { break } - // Only relay binary messages (Y.js protocol) if messageType == websocket.BinaryMessage { room.Broadcast(c, msg) } diff --git a/infrastructure/config/config.go b/infrastructure/config/config.go index b1b3d46..81a0ca5 100644 --- a/infrastructure/config/config.go +++ b/infrastructure/config/config.go @@ -12,6 +12,8 @@ type Config struct { Port int // HttpLogs indicates if HTTP logs are enabled HttpLogs bool + // CorsAllowOrigins is a comma-separated list of allowed CORS origins + CorsAllowOrigins string } // Logger is the configuration for the zerolog logger. diff --git a/infrastructure/config/http_flags.go b/infrastructure/config/http_flags.go index a67400b..33b0a7e 100644 --- a/infrastructure/config/http_flags.go +++ b/infrastructure/config/http_flags.go @@ -28,5 +28,14 @@ func ServerFlags(cfg *Config) []cli.Flag { altsrcyaml.YAML("http.logs", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), ), }, + &cli.StringFlag{ + Name: "http.cors_allow_origins", + Value: "*", + Destination: &cfg.Server.CorsAllowOrigins, + Sources: cli.NewValueSourceChain( + cli.EnvVar("HTTP_CORS_ALLOW_ORIGINS"), + altsrcyaml.YAML("http.cors_allow_origins", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, } } diff --git a/infrastructure/config/session_flags.go b/infrastructure/config/session_flags.go index 9075c7b..dfc3c71 100644 --- a/infrastructure/config/session_flags.go +++ b/infrastructure/config/session_flags.go @@ -19,7 +19,6 @@ func SessionFlags(cfg *Config) []cli.Flag { }, &cli.StringFlag{ Name: "session.secret_key", - Value: "supersecretkey", // In production, use a secure key and do not hardcode it Destination: &cfg.Session.SecretKey, Sources: cli.NewValueSourceChain( cli.EnvVar("SESSION_SECRET_KEY"), diff --git a/infrastructure/database/gorm.go b/infrastructure/database/gorm.go index 85ef942..58e53e0 100644 --- a/infrastructure/database/gorm.go +++ b/infrastructure/database/gorm.go @@ -1,6 +1,8 @@ package database import ( + "time" + "github.com/labbs/nexo/infrastructure/config" zerologadapter "github.com/labbs/nexo/infrastructure/logger/zerolog" @@ -37,5 +39,14 @@ func Configure(_cfg config.Config, logger z.Logger) (Config, error) { return Config{}, err } + sqlDB, err := db.DB() + if err != nil { + return Config{}, err + } + sqlDB.SetMaxOpenConns(100) + sqlDB.SetMaxIdleConns(10) + sqlDB.SetConnMaxLifetime(time.Hour) + sqlDB.SetConnMaxIdleTime(30 * time.Minute) + return Config{Db: db}, nil } diff --git a/infrastructure/helpers/mapper/map_struct_by_field_names.go b/infrastructure/helpers/mapper/map_struct_by_field_names.go index 508a4a4..675c0e6 100644 --- a/infrastructure/helpers/mapper/map_struct_by_field_names.go +++ b/infrastructure/helpers/mapper/map_struct_by_field_names.go @@ -1,6 +1,7 @@ package mapper import ( + "errors" "reflect" ) @@ -13,7 +14,7 @@ func MapStructByFieldNames(src any, dst any) error { // Ensure we have pointers if srcValue.Kind() != reflect.Ptr || dstValue.Kind() != reflect.Ptr { - panic("both src and dst must be pointers") + return errors.New("both src and dst must be pointers") } // Get the underlying structs @@ -22,7 +23,7 @@ func MapStructByFieldNames(src any, dst any) error { // Ensure we have structs if srcStruct.Kind() != reflect.Struct || dstStruct.Kind() != reflect.Struct { - panic("both src and dst must point to structs") + return errors.New("both src and dst must point to structs") } srcType := srcStruct.Type() diff --git a/infrastructure/http/http.go b/infrastructure/http/http.go index 3ebfc6d..d7d0afc 100644 --- a/infrastructure/http/http.go +++ b/infrastructure/http/http.go @@ -2,6 +2,7 @@ package http import ( "encoding/json" + "time" "github.com/labbs/nexo/application/session" "github.com/labbs/nexo/infrastructure/config" @@ -30,6 +31,10 @@ func Configure(_cfg config.Config, logger z.Logger, sessionApp *session.SessionA JSONEncoder: json.Marshal, JSONDecoder: json.Unmarshal, DisableStartupMessage: true, + BodyLimit: 10 * 1024 * 1024, // 10 MB + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, } r := fiber.New(fiberConfig) @@ -41,7 +46,20 @@ func Configure(_cfg config.Config, logger z.Logger, sessionApp *session.SessionA r.Use(recover.New(recover.Config{ EnableStackTrace: true, })) - r.Use(cors.New()) + r.Use(cors.New(cors.Config{ + AllowOrigins: _cfg.Server.CorsAllowOrigins, + AllowMethods: "GET,POST,PUT,PATCH,DELETE,OPTIONS", + AllowHeaders: "Origin,Content-Type,Accept,Authorization", + AllowCredentials: false, + MaxAge: 86400, + })) + r.Use(func(c *fiber.Ctx) error { + c.Set("X-Content-Type-Options", "nosniff") + c.Set("X-Frame-Options", "DENY") + c.Set("X-XSS-Protection", "1; mode=block") + c.Set("Referrer-Policy", "strict-origin-when-cross-origin") + return c.Next() + }) r.Use(compress.New()) r.Use(requestid.New()) diff --git a/infrastructure/persistence/database_pers.go b/infrastructure/persistence/database_pers.go index 7a7f519..676e916 100644 --- a/infrastructure/persistence/database_pers.go +++ b/infrastructure/persistence/database_pers.go @@ -2,12 +2,19 @@ package persistence import ( "errors" + "regexp" "github.com/labbs/nexo/infrastructure/helpers/apperrors" "github.com/labbs/nexo/domain" "gorm.io/gorm" ) +var validPropertyName = regexp.MustCompile(`^[a-zA-Z0-9_\-]+$`) + +func isValidPropertyName(name string) bool { + return len(name) > 0 && len(name) <= 100 && validPropertyName.MatchString(name) +} + type databasePers struct { db *gorm.DB } @@ -224,6 +231,9 @@ func (p *databaseRowPers) GetByDatabaseIdWithOptions(databaseId string, options if sort.Direction == "desc" { direction = "DESC" } + if !isValidPropertyName(sort.PropertyId) { + continue + } // Sort by JSON property value (SQLite compatible) query = query.Order("json_extract(properties, '$." + sort.PropertyId + "') " + direction) } @@ -269,6 +279,9 @@ func (p *databaseRowPers) applyFilters(query *gorm.DB, filter *domain.FilterConf } func (p *databaseRowPers) applyFilterRule(query *gorm.DB, rule domain.FilterRule, _ string) *gorm.DB { + if !isValidPropertyName(rule.Property) { + return query + } // Use json_extract for SQLite compatibility propertyPath := "json_extract(properties, '$." + rule.Property + "')" diff --git a/interfaces/cli/server/server.go b/interfaces/cli/server/server.go index d9e38da..bb6493e 100644 --- a/interfaces/cli/server/server.go +++ b/interfaces/cli/server/server.go @@ -2,6 +2,7 @@ package server import ( "context" + "fmt" "strconv" "github.com/labbs/nexo/application/action" @@ -72,6 +73,12 @@ func runServer(cfg config.Config) error { deps.Logger = logger.NewLogger(cfg.Logger.Level, cfg.Logger.Pretty, cfg.Version) logger := deps.Logger.With().Str("component", "interfaces.cli.http.runserver").Logger() + // Validate JWT secret key + if len(cfg.Session.SecretKey) < 32 { + logger.Fatal().Msg("SESSION_SECRET_KEY must be set and at least 32 characters long") + return fmt.Errorf("SESSION_SECRET_KEY must be at least 32 characters") + } + // Initialize other cron scheduler (go-cron) deps.CronScheduler, err = cronscheduler.Configure(deps.Logger) if err != nil { diff --git a/interfaces/http/router.go b/interfaces/http/router.go index 591cd83..9036e5c 100644 --- a/interfaces/http/router.go +++ b/interfaces/http/router.go @@ -3,6 +3,7 @@ package http import ( "github.com/labbs/nexo/infrastructure" "github.com/labbs/nexo/infrastructure/collaboration" + "github.com/labbs/nexo/infrastructure/persistence" v1 "github.com/labbs/nexo/interfaces/http/v1" ) @@ -23,7 +24,8 @@ func SetupRoutes(deps infrastructure.Deps) { func setupCollaborationRoutes(deps infrastructure.Deps) { logger := deps.Logger.With().Str("component", "http.router.collaboration").Logger() logger.Info().Str("event", "setup_collaboration_routes").Msg("Setting up collaboration WebSocket routes") - handler := collaboration.NewHandler(deps.CollaborationHub, deps.SessionApplication, deps.Logger) + documentPers := persistence.NewDocumentPers(deps.Database.Db) + handler := collaboration.NewHandler(deps.CollaborationHub, deps.SessionApplication, documentPers, deps.Logger) // The frontend connects to ws:///?token= // Room formats: "document:" or "row::" diff --git a/interfaces/http/v1/auth/handlers.go b/interfaces/http/v1/auth/handlers.go index 4ba3dea..6096dbf 100644 --- a/interfaces/http/v1/auth/handlers.go +++ b/interfaces/http/v1/auth/handlers.go @@ -51,9 +51,10 @@ func (ctrl Controller) Logout(ctx *fiber.Ctx, input struct{}) (*dtos.LogoutRespo } } - err = ctrl.AuthApplication.Logout(authDto.LogoutInput{SessionId: authCtx.Claims["session_id"].(string)}) + sessionId, _ := authCtx.Claims["session_id"].(string) + err = ctrl.AuthApplication.Logout(authDto.LogoutInput{SessionId: sessionId}) if err != nil { - logger.Error().Err(err).Str("session_id", authCtx.Claims["session_id"].(string)).Msg("failed to logout user") + logger.Error().Err(err).Str("session_id", sessionId).Msg("failed to logout user") return nil, &fiberoapi.ErrorResponse{ Code: fiber.StatusInternalServerError, Details: err.Error(), diff --git a/interfaces/http/v1/webhook/handlers.go b/interfaces/http/v1/webhook/handlers.go index e303773..49fdfc9 100644 --- a/interfaces/http/v1/webhook/handlers.go +++ b/interfaces/http/v1/webhook/handlers.go @@ -2,6 +2,8 @@ package webhook import ( "errors" + "net" + "net/url" "github.com/gofiber/fiber/v2" fiberoapi "github.com/labbs/fiber-oapi" @@ -10,6 +12,33 @@ import ( "github.com/labbs/nexo/interfaces/http/v1/webhook/dtos" ) +// validateWebhookURL checks the URL is valid HTTP/HTTPS and not targeting internal networks. +func validateWebhookURL(rawURL string) error { + u, err := url.ParseRequestURI(rawURL) + if err != nil { + return errors.New("invalid URL format") + } + if u.Scheme != "http" && u.Scheme != "https" { + return errors.New("URL must use http or https") + } + host := u.Hostname() + ips, err := net.LookupHost(host) + if err != nil { + // DNS failure — allow it (server may not have external DNS in all envs) + return nil + } + for _, ipStr := range ips { + ip := net.ParseIP(ipStr) + if ip == nil { + continue + } + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return errors.New("URL must not target internal or private addresses") + } + } + return nil +} + func (ctrl *Controller) ListWebhooks(ctx *fiber.Ctx, _ dtos.EmptyRequest) (*dtos.ListWebhooksResponse, *fiberoapi.ErrorResponse) { requestId := ctx.Locals("requestid").(string) logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.webhook.list").Logger() @@ -66,6 +95,9 @@ func (ctrl *Controller) CreateWebhook(ctx *fiber.Ctx, req dtos.CreateWebhookRequ if req.Url == "" { return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusBadRequest, Details: "URL is required", Type: "BAD_REQUEST"} } + if err := validateWebhookURL(req.Url); err != nil { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusBadRequest, Details: err.Error(), Type: "BAD_REQUEST"} + } if len(req.Events) == 0 { return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusBadRequest, Details: "At least one event is required", Type: "BAD_REQUEST"}