diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..562de9b --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,20 @@ +# App configuration +APP_ENV=development +PORT=6969 + +# Database configuration +DB_USER=postgres +DB_PASSWORD=postgres +DB_NAME=postgres +DB_HOST=localhost +DB_PORT=5432 +DB_SSLMODE=disable + +# JWT configuration +JWT_SECRET=your-jwt-secret + +# AWS configuration +AWS_REGION=us-west-2 +AWS_ACCESS_KEY_ID=your-access-key-id +AWS_SECRET_ACCESS_KEY=your-secret-access-key +AWS_S3_BUCKET=your-bucket-name \ No newline at end of file diff --git a/backend/common/env_name.go b/backend/common/env_name.go index a036ae4..841cfe5 100644 --- a/backend/common/env_name.go +++ b/backend/common/env_name.go @@ -2,3 +2,4 @@ package common const PRODUCTION_ENV = "production" const DEVELOPMENT_ENV = "development" +const STAGING_ENV = "staging" diff --git a/backend/db/db.go b/backend/db/db.go index b4a3b78..5470edf 100644 --- a/backend/db/db.go +++ b/backend/db/db.go @@ -29,4 +29,4 @@ func (q *Queries) WithTx(tx pgx.Tx) *Queries { return &Queries{ db: tx, } -} +} \ No newline at end of file diff --git a/backend/go.mod b/backend/go.mod index 4e06469..11783df 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -15,6 +15,24 @@ require ( ) require ( + github.com/aws/aws-sdk-go-v2 v1.32.6 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 // indirect + github.com/aws/aws-sdk-go-v2/config v1.28.6 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.47 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.24.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.33.2 // indirect + github.com/aws/smithy-go v1.22.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -23,6 +41,7 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index baed4e6..cc6d284 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,39 @@ +github.com/aws/aws-sdk-go-v2 v1.32.6 h1:7BokKRgRPuGmKkFMhEg/jSul+tB9VvXhcViILtfG8b4= +github.com/aws/aws-sdk-go-v2 v1.32.6/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 h1:lL7IfaFzngfx0ZwUGOZdsFFnQ5uLvR0hWqqhyE7Q9M8= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7/go.mod h1:QraP0UcVlQJsmHfioCrveWOC1nbiWUl3ej08h4mXWoc= +github.com/aws/aws-sdk-go-v2/config v1.28.6 h1:D89IKtGrs/I3QXOLNTH93NJYtDhm8SYa9Q5CsPShmyo= +github.com/aws/aws-sdk-go-v2/config v1.28.6/go.mod h1:GDzxJ5wyyFSCoLkS+UhGB0dArhb9mI+Co4dHtoTxbko= +github.com/aws/aws-sdk-go-v2/credentials v1.17.47 h1:48bA+3/fCdi2yAwVt+3COvmatZ6jUDNkDTIsqDiMUdw= +github.com/aws/aws-sdk-go-v2/credentials v1.17.47/go.mod h1:+KdckOejLW3Ks3b0E3b5rHsr2f9yuORBum0WPnE5o5w= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21 h1:AmoU1pziydclFT/xRV+xXE/Vb8fttJCLRPv8oAkprc0= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21/go.mod h1:AjUdLYe4Tgs6kpH4Bv7uMZo7pottoyHMn4eTcIcneaY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25 h1:s/fF4+yDQDoElYhfIVvSNyeCydfbuTKzhxSXDXCPasU= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25/go.mod h1:IgPfDv5jqFIzQSNbUEMoitNooSMXjRSDkhXv8jiROvU= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25 h1:ZntTCl5EsYnhN/IygQEUugpdwbhdkom9uHcbCftiGgA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25/go.mod h1:DBdPrgeocww+CSl1C8cEV8PN1mHMBhuCDLpXezyvWkE= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25 h1:r67ps7oHCYnflpgDy2LZU0MAQtQbYIOqNNnqGO6xQkE= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25/go.mod h1:GrGY+Q4fIokYLtjCVB/aFfCVL6hhGUFl8inD18fDalE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6 h1:HCpPsWqmYQieU7SS6E9HXfdAMSud0pteVXieJmcpIRI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6/go.mod h1:ngUiVRCco++u+soRRVBIvBZxSMMvOVMXA4PJ36JLfSw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6 h1:50+XsN70RS7dwJ2CkVNXzj7U2L1HKP8nqTd3XWEXBN4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6/go.mod h1:WqgLmwY7so32kG01zD8CPTJWVWM+TzJoOVHwTg4aPug= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6 h1:BbGDtTi0T1DYlmjBiCr/le3wzhA37O8QTC5/Ab8+EXk= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6/go.mod h1:hLMJt7Q8ePgViKupeymbqI0la+t9/iYFBjxQCFwuAwI= +github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0 h1:nyuzXooUNJexRT0Oy0UQY6AhOzxPxhtt4DcBIHyCnmw= +github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0/go.mod h1:sT/iQz8JK3u/5gZkT+Hmr7GzVZehUMkRZpOaAwYXeGY= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.7 h1:rLnYAfXQ3YAccocshIH5mzNNwZBkBo+bP6EhIxak6Hw= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.7/go.mod h1:ZHtuQJ6t9A/+YDuxOLnbryAmITtr8UysSny3qcyvJTc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6 h1:JnhTZR3PiYDNKlXy50/pNeix9aGMo6lLpXwJ1mw8MD4= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6/go.mod h1:URronUEGfXZN1VpdktPSD1EkAL9mfrV+2F4sjH38qOY= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.2 h1:s4074ZO1Hk8qv65GqNXqDjmkf4HSQqJukaLuuW0TpDA= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.2/go.mod h1:mVggCnIWoM09jP71Wh+ea7+5gAp53q+49wDFs1SW5z8= +github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro= +github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -28,6 +64,9 @@ github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= @@ -83,6 +122,7 @@ golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/middleware/req_validator.go b/backend/internal/middleware/req_validator.go index d1444da..198a981 100644 --- a/backend/internal/middleware/req_validator.go +++ b/backend/internal/middleware/req_validator.go @@ -3,7 +3,9 @@ package middleware import ( "fmt" "net/http" + "os" "reflect" + "strings" "KonferCA/SPUR/db" "github.com/go-playground/validator/v10" @@ -33,6 +35,7 @@ func NewRequestBodyValidator() *RequestBodyValidator { v := validator.New() v.RegisterValidation("valid_user_role", validateUserRole) v.RegisterValidation("non_admin_role", validateNonAdminRole) + v.RegisterValidation("s3_url", validateS3URL) return &RequestBodyValidator{validator: v} } @@ -105,3 +108,15 @@ func validateNonAdminRole(fl validator.FieldLevel) bool { return false } + +// validateS3URL ensures that file URLs point to our S3 bucket +func validateS3URL(fl validator.FieldLevel) bool { + url := fl.Field().String() + bucket := os.Getenv("AWS_S3_BUCKET") + if bucket == "" { + log.Warn().Msg("AWS_S3_BUCKET environment variable not set") + return false + } + expectedPrefix := fmt.Sprintf("https://%s.s3.us-east-1.amazonaws.com/", bucket) + return strings.HasPrefix(url, expectedPrefix) +} diff --git a/backend/internal/middleware/upload.go b/backend/internal/middleware/upload.go new file mode 100644 index 0000000..ee79a74 --- /dev/null +++ b/backend/internal/middleware/upload.go @@ -0,0 +1,22 @@ +package middleware + +import ( + "net/http" + + "github.com/labstack/echo/v4" +) + +func UploadMiddleware() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + // Check if the request is multipart/form-data + if c.Request().Header.Get("Content-Type") != "" && c.Request().Method == "POST" { + err := c.Request().ParseMultipartForm(10 << 20) // 10 MB + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Failed to parse multipart form") + } + } + return next(c) + } + } +} \ No newline at end of file diff --git a/backend/internal/server/index.go b/backend/internal/server/index.go index b9f1457..c0603a0 100644 --- a/backend/internal/server/index.go +++ b/backend/internal/server/index.go @@ -2,6 +2,7 @@ package server import ( "fmt" + "net/http" "os" "reflect" "time" @@ -10,8 +11,10 @@ import ( "github.com/labstack/echo/v4" echoMiddleware "github.com/labstack/echo/v4/middleware" + "KonferCA/SPUR/common" "KonferCA/SPUR/db" "KonferCA/SPUR/internal/middleware" + "KonferCA/SPUR/storage" ) type Server struct { @@ -21,11 +24,18 @@ type Server struct { apiV1 *echo.Group authLimiter *middleware.RateLimiter apiLimiter *middleware.RateLimiter + Storage *storage.Storage } // Create a new Server instance and registers all routes and middlewares. // Initialize database pool connection. func New(testing bool) (*Server, error) { + var dbPool *pgxpool.Pool + var queries *db.Queries + var storageClient *storage.Storage + var err error + + // format connection string connStr := fmt.Sprintf( "host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", os.Getenv("DB_HOST"), @@ -35,16 +45,23 @@ func New(testing bool) (*Server, error) { os.Getenv("DB_NAME"), os.Getenv("DB_SSLMODE"), ) - pool, err := db.NewPool(connStr) + + // initialize database connection using pool.go + dbPool, err = db.NewPool(connStr) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to connect to database: %v", err) + } + queries = db.New(dbPool) + + if !testing { + // Initialize storage only for non-test environment + storageClient, err = storage.NewStorage() + if err != nil { + return nil, fmt.Errorf("failed to initialize storage: %v", err) + } } - - // Initialize queries - queries := db.New(pool) e := echo.New() - e.Debug = true // create rate limiters @@ -66,25 +83,16 @@ func New(testing bool) (*Server, error) { ) } - // setup error handler and middlewares - e.HTTPErrorHandler = globalErrorHandler - - e.Use(middleware.Logger()) - e.Use(echoMiddleware.Recover()) - e.Use(apiLimiter.RateLimit()) // global rate limit - - customValidator := middleware.NewRequestBodyValidator() - e.Validator = customValidator - server := &Server{ - DBPool: pool, + DBPool: dbPool, queries: queries, echoInstance: e, authLimiter: authLimiter, apiLimiter: apiLimiter, + Storage: storageClient, } - // setup api routes + // setup api routes first server.setupV1Routes() server.setupAuthRoutes() server.setupCompanyRoutes() @@ -98,8 +106,49 @@ func New(testing bool) (*Server, error) { server.setupFundingTransactionRoutes() server.setupMeetingRoutes() server.setupHealthRoutes() + server.setupStorageRoutes() + + // setup error handler and middlewares after routes + e.HTTPErrorHandler = globalErrorHandler + + // setup cors based on environment + if os.Getenv("APP_ENV") == common.PRODUCTION_ENV { + e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{ + AllowOrigins: []string{"https://spur.konfer.ca"}, + AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}, + AllowHeaders: []string{ + echo.HeaderOrigin, + echo.HeaderContentType, + echo.HeaderAccept, + echo.HeaderContentLength, + "X-Request-ID", + }, + })) + } else if os.Getenv("APP_ENV") == common.STAGING_ENV { + e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{ + AllowOrigins: []string{"https://nk-preview.konfer.ca"}, + AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}, + AllowHeaders: []string{ + echo.HeaderOrigin, + echo.HeaderContentType, + echo.HeaderAccept, + echo.HeaderContentLength, + "X-Request-ID", + }, + })) + } else { + // use default cors middleware for development + e.Use(echoMiddleware.CORS()) + } + + e.Use(middleware.Logger()) + e.Use(echoMiddleware.Recover()) + e.Use(apiLimiter.RateLimit()) // global rate limit + + customValidator := middleware.NewRequestBodyValidator() + e.Validator = customValidator - // setup static routes + // setup static routes last server.setupStaticRoutes() return server, nil @@ -108,8 +157,9 @@ func New(testing bool) (*Server, error) { func (s *Server) setupV1Routes() { s.apiV1 = s.echoInstance.Group("/api/v1") + fmt.Println("Registered routes:") for _, route := range s.echoInstance.Routes() { - s.echoInstance.Logger.Printf("Route: %s %s", route.Method, route.Path) + fmt.Printf("Route: %s %s\n", route.Method, route.Path) } } diff --git a/backend/internal/server/projects.go b/backend/internal/server/projects.go index a5ec817..adf3342 100644 --- a/backend/internal/server/projects.go +++ b/backend/internal/server/projects.go @@ -2,8 +2,12 @@ package server import ( "context" - "fmt" "net/http" + "io" + "fmt" + "time" + "path/filepath" + "strings" "KonferCA/SPUR/db" mw "KonferCA/SPUR/internal/middleware" @@ -24,14 +28,15 @@ func (s *Server) handleCreateProject(c echo.Context) error { queries := db.New(s.DBPool) - _, err = queries.GetCompanyByID(context.Background(), companyID) + // Start a transaction + tx, err := s.DBPool.Begin(context.Background()) if err != nil { - return handleDBError(err, "verify", "company") + return handleDBError(err, "begin", "transaction") } + defer tx.Rollback(context.Background()) - fmt.Printf("Creating project with params: company_id=%v, title=%s, status=%s\n", - companyID, req.Title, req.Status) - + // Create project + qtx := queries.WithTx(tx) params := db.CreateProjectParams{ CompanyID: companyID, Title: req.Title, @@ -39,13 +44,41 @@ func (s *Server) handleCreateProject(c echo.Context) error { Status: req.Status, } - project, err := queries.CreateProject(context.Background(), params) + project, err := qtx.CreateProject(context.Background(), params) if err != nil { - fmt.Printf("Error creating project: %v\n", err) return handleDBError(err, "create", "project") } - fmt.Printf("Created project: %+v\n", project) + // Create files + for _, file := range req.Files { + fileParams := db.CreateProjectFileParams{ + ProjectID: project.ID, + FileType: file.FileType, + FileUrl: file.FileURL, + } + _, err := qtx.CreateProjectFile(context.Background(), fileParams) + if err != nil { + return handleDBError(err, "create", "project file") + } + } + + // Create links + for _, link := range req.Links { + linkParams := db.CreateProjectLinkParams{ + ProjectID: project.ID, + LinkType: link.LinkType, + Url: link.URL, + } + _, err := qtx.CreateProjectLink(context.Background(), linkParams) + if err != nil { + return handleDBError(err, "create", "project link") + } + } + + // Commit transaction + if err := tx.Commit(context.Background()); err != nil { + return handleDBError(err, "commit", "transaction") + } return c.JSON(http.StatusCreated, project) } @@ -114,30 +147,61 @@ func (s *Server) handleCreateProjectFile(c echo.Context) error { return err } - var req *CreateProjectFileRequest - req, ok := c.Get(mw.REQUEST_BODY_KEY).(*CreateProjectFileRequest) - if !ok { - return echo.NewHTTPError(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) + // Get the file from form data + file, err := c.FormFile("file") + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "file is required") } - queries := db.New(s.DBPool) - _, err = queries.GetProject(context.Background(), projectID) + fileType := c.FormValue("file_type") + if fileType == "" { + return echo.NewHTTPError(http.StatusBadRequest, "file_type is required") + } + + // Upload file to storage + src, err := file.Open() if err != nil { - return handleDBError(err, "verify", "project") + return echo.NewHTTPError(http.StatusInternalServerError, "failed to read file") } + defer src.Close() + // Read file contents + fileBytes, err := io.ReadAll(src) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to read file contents") + } + + // Generate unique file key + fileExt := filepath.Ext(file.Filename) + fileKey := fmt.Sprintf("uploads/%d%s", time.Now().UnixNano(), fileExt) + + fileURL, err := s.Storage.UploadFile(c.Request().Context(), fileKey, fileBytes) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to upload file") + } + + // Create database record + queries := db.New(s.DBPool) params := db.CreateProjectFileParams{ ProjectID: projectID, - FileType: req.FileType, - FileUrl: req.FileURL, + FileType: fileType, + FileUrl: fileURL, } - file, err := queries.CreateProjectFile(context.Background(), params) + projectFile, err := queries.CreateProjectFile(context.Background(), params) if err != nil { + // Try to cleanup the uploaded file if database record creation fails + if fileURL != "" { + // Extract key from URL + parts := strings.Split(fileURL, ".amazonaws.com/") + if len(parts) == 2 { + _ = s.Storage.DeleteFile(c.Request().Context(), parts[1]) + } + } return handleDBError(err, "create", "project file") } - return c.JSON(http.StatusCreated, file) + return c.JSON(http.StatusCreated, projectFile) } func (s *Server) handleListProjectFiles(c echo.Context) error { diff --git a/backend/internal/server/storage.go b/backend/internal/server/storage.go new file mode 100644 index 0000000..2416105 --- /dev/null +++ b/backend/internal/server/storage.go @@ -0,0 +1,102 @@ +package server + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/smithy-go" + "github.com/labstack/echo/v4" +) + +type UploadResponse struct { + FileURL string `json:"file_url"` +} + +func (s *Server) setupStorageRoutes() { + fmt.Println("Setting up storage routes...") + s.apiV1.POST("/storage/upload", s.handleFileUpload) + fmt.Println("Storage routes set up") +} + +func (s *Server) handleFileUpload(c echo.Context) error { + fmt.Println("Handling file upload...") + fmt.Printf("Content-Type: %s\n", c.Request().Header.Get("Content-Type")) + + // Parse multipart form with 10MB limit + err := c.Request().ParseMultipartForm(10 << 20) + if err != nil { + fmt.Printf("Error parsing multipart form: %v\n", err) + return echo.NewHTTPError(http.StatusBadRequest, "Failed to parse multipart form") + } + + // Get file from request + file, err := c.FormFile("file") + if err != nil { + fmt.Printf("Error getting form file: %v\n", err) + return echo.NewHTTPError(http.StatusBadRequest, "Invalid file upload") + } + + // Open the uploaded file + src, err := file.Open() + if err != nil { + fmt.Printf("Error opening file: %v\n", err) + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to read uploaded file") + } + defer src.Close() + + // Load AWS configuration + cfg, err := config.LoadDefaultConfig(context.TODO(), + config.WithRegion("us-east-1"), + config.WithClientLogMode(aws.LogRequestWithBody|aws.LogResponseWithBody), + ) + if err != nil { + fmt.Printf("Error loading AWS config: %v\n", err) + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to load AWS config") + } + + // Create S3 client with custom endpoint + bucket := os.Getenv("AWS_S3_BUCKET") + s3Client := s3.NewFromConfig(cfg, func(o *s3.Options) { + o.UsePathStyle = true + o.EndpointResolver = s3.EndpointResolverFromURL(fmt.Sprintf("https://%s.s3.us-east-1.amazonaws.com", bucket)) + + }) + + // Generate unique file key + fileExt := filepath.Ext(file.Filename) + fileKey := fmt.Sprintf("uploads/%d%s", time.Now().UnixNano(), fileExt) + + // Upload to S3 + _, err = s3Client.PutObject(context.TODO(), &s3.PutObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(fileKey), + Body: src, + ContentType: aws.String(file.Header.Get("Content-Type")), + }) + if err != nil { + var apiErr smithy.APIError + if errors.As(err, &apiErr) { + fmt.Printf("Response error details: %+v\n", apiErr) + } + fmt.Printf("Error uploading to S3: %v\n", err) + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upload to S3") + } + + // Construct the file URL + fileURL := fmt.Sprintf("https://%s.s3.us-east-1.amazonaws.com/%s", + bucket, + fileKey) + + fmt.Printf("File uploaded successfully: %s\n", fileURL) + return c.JSON(http.StatusOK, UploadResponse{ + FileURL: fileURL, + }) +} \ No newline at end of file diff --git a/backend/internal/server/types.go b/backend/internal/server/types.go index a8bf50a..81811c2 100644 --- a/backend/internal/server/types.go +++ b/backend/internal/server/types.go @@ -119,11 +119,36 @@ type UpdateCompanyAnswerRequest struct { AnswerText string `json:"answer_text" validate:"required"` } +type TeamMember struct { + ID string `json:"id"` + Name string `json:"name"` + Role string `json:"role"` + Avatar string `json:"avatar,omitempty"` +} + +type ProjectFile struct { + FileType string `json:"file_type" validate:"required"` + FileURL string `json:"file_url" validate:"required,url,s3_url"` +} + +type ProjectLink struct { + LinkType string `json:"link_type" validate:"required"` + URL string `json:"url" validate:"required,url"` +} + type CreateProjectRequest struct { - CompanyID string `json:"company_id" validate:"required"` - Title string `json:"title" validate:"required"` - Description *string `json:"description"` - Status string `json:"status" validate:"required"` + CompanyID string `json:"company_id" validate:"required"` + Title string `json:"title" validate:"required"` + Description *string `json:"description"` + Status string `json:"status" validate:"required"` + FoundedDate string `json:"founded_date,omitempty"` + CompanyStage string `json:"company_stage,omitempty"` + InvestmentStage string `json:"investment_stage,omitempty"` + Inspiration string `json:"inspiration,omitempty"` + Vision string `json:"vision,omitempty"` + TeamMembers []TeamMember `json:"team_members,omitempty" validate:"dive"` + Files []ProjectFile `json:"files" validate:"dive"` + Links []ProjectLink `json:"links" validate:"dive"` } type UpdateProjectRequest struct { @@ -132,21 +157,11 @@ type UpdateProjectRequest struct { Status string `json:"status" validate:"required"` } -type CreateProjectFileRequest struct { - FileType string `json:"file_type" validate:"required"` - FileURL string `json:"file_url" validate:"required,url"` -} - type CreateProjectCommentRequest struct { UserID string `json:"user_id" validate:"required"` Comment string `json:"comment" validate:"required"` } -type CreateProjectLinkRequest struct { - LinkType string `json:"link_type" validate:"required"` - URL string `json:"url" validate:"required,url"` -} - type AddProjectTagRequest struct { TagID string `json:"tag_id" validate:"required,uuid"` } @@ -186,3 +201,13 @@ type UpdateMeetingRequest struct { Location *string `json:"location"` Notes *string `json:"notes"` } + +type CreateProjectFileRequest struct { + FileType string `json:"file_type" validate:"required"` + FileURL string `json:"file_url" validate:"required,url,s3_url"` +} + +type CreateProjectLinkRequest struct { + LinkType string `json:"link_type" validate:"required"` + URL string `json:"url" validate:"required,url"` +} diff --git a/backend/storage/storage.go b/backend/storage/storage.go new file mode 100644 index 0000000..60872c8 --- /dev/null +++ b/backend/storage/storage.go @@ -0,0 +1,87 @@ +package storage + +import ( + "bytes" + "context" + "fmt" + "os" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +type Storage struct { + s3Client *s3.Client + bucket string +} + +// NewStorage creates a new Storage instance with S3 client +func NewStorage() (*Storage, error) { + bucket := os.Getenv("AWS_S3_BUCKET") + if bucket == "" { + return nil, fmt.Errorf("AWS_S3_BUCKET environment variable not set") + } + + cfg, err := config.LoadDefaultConfig(context.Background()) + if err != nil { + return nil, fmt.Errorf("unable to load SDK config: %v", err) + } + + client := s3.NewFromConfig(cfg) + + return &Storage{ + s3Client: client, + bucket: bucket, + }, nil +} + +// ValidateFileURL checks if a file URL points to our S3 bucket +func (s *Storage) ValidateFileURL(url string) bool { + expectedPrefix := fmt.Sprintf("https://%s.s3.us-east-1.amazonaws.com/", s.bucket) + return url != "" && len(url) > len(expectedPrefix) && url[:len(expectedPrefix)] == expectedPrefix +} + +// GetPresignedURL generates a presigned URL for uploading a file +func (s *Storage) GetPresignedURL(ctx context.Context, key string) (string, error) { + presignClient := s3.NewPresignClient(s.s3Client) + + putObjectInput := &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + } + + presignedReq, err := presignClient.PresignPutObject(ctx, putObjectInput) + if err != nil { + return "", fmt.Errorf("couldn't get presigned URL: %v", err) + } + + return presignedReq.URL, nil +} + +// UploadFile uploads a file to S3 and returns its URL +func (s *Storage) UploadFile(ctx context.Context, key string, data []byte) (string, error) { + _, err := s.s3Client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + Body: bytes.NewReader(data), + }) + if err != nil { + return "", fmt.Errorf("couldn't upload file: %v", err) + } + + return fmt.Sprintf("https://%s.s3.us-east-1.amazonaws.com/%s", s.bucket, key), nil +} + +// DeleteFile deletes a file from S3 +func (s *Storage) DeleteFile(ctx context.Context, key string) error { + _, err := s.s3Client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }) + if err != nil { + return fmt.Errorf("couldn't delete file: %v", err) + } + + return nil +} \ No newline at end of file diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..088334e --- /dev/null +++ b/frontend/src/contexts/AuthContext.tsx @@ -0,0 +1,61 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; +import type { User } from '@/types'; + +interface AuthContextType { + user: User | null; + companyId: string | null; + setUser: (user: User | null) => void; + setCompanyId: (id: string | null) => void; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [companyId, setCompanyId] = useState(null); + + // Load user from localStorage on mount + useEffect(() => { + const storedUser = localStorage.getItem('user'); + const storedCompanyId = localStorage.getItem('company_id'); + + if (storedUser) { + setUser(JSON.parse(storedUser)); + } + if (storedCompanyId) { + setCompanyId(storedCompanyId); + } + }, []); + + // Save user to localStorage when it changes + useEffect(() => { + if (user) { + localStorage.setItem('user', JSON.stringify(user)); + } else { + localStorage.removeItem('user'); + } + }, [user]); + + // Save companyId to localStorage when it changes + useEffect(() => { + if (companyId) { + localStorage.setItem('company_id', companyId); + } else { + localStorage.removeItem('company_id'); + } + }, [companyId]); + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} \ No newline at end of file diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index 096e5be..9825234 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -1,7 +1,9 @@ import { useState, useEffect, FormEvent } from 'react'; import { Button, TextInput, TextArea } from '@components'; +import { register, RegisterError, saveRefreshToken } from '@services'; +import { useAuth } from '@/contexts/AuthContext'; -type RegistrationStep = +type RegistrationStep = | 'login-register' | 'verify-email' | 'signing-in' @@ -25,7 +27,8 @@ interface FormErrors { } const Register = () => { - const [currentStep, setCurrentStep] = useState('login-register'); + const [currentStep, setCurrentStep] = + useState('login-register'); const [formData, setFormData] = useState({ firstName: '', lastName: '', @@ -33,11 +36,13 @@ const Register = () => { bio: '', linkedIn: '', email: '', - password: '' + password: '', }); const [errors, setErrors] = useState({}); + const { setUser, setCompanyId } = useAuth(); - const LINKEDIN_REGEX = /^(https?:\/\/)?([\w]+\.)?linkedin\.com\/(pub|in|profile)\/([-a-zA-Z0-9]+)\/?$/; + const LINKEDIN_REGEX = + /^(https?:\/\/)?([\w]+\.)?linkedin\.com\/(pub|in|profile)\/([-a-zA-Z0-9]+)\/?$/; const validateLinkedIn = (url: string): boolean => { if (!url) return false; @@ -46,42 +51,60 @@ const Register = () => { const handleLinkedInChange = (e: React.ChangeEvent) => { const { name, value } = e.target; - setFormData(prev => ({ + setFormData((prev) => ({ ...prev, - [name]: value + [name]: value, })); if (value && !validateLinkedIn(value)) { - setErrors(prev => ({ + setErrors((prev) => ({ ...prev, - linkedIn: "Please enter a valid LinkedIn profile URL" + linkedIn: 'Please enter a valid LinkedIn profile URL', })); } else { - setErrors(prev => ({ + setErrors((prev) => ({ ...prev, - linkedIn: undefined + linkedIn: undefined, })); } }; - const handleChange = (e: React.ChangeEvent) => { + const handleChange = ( + e: React.ChangeEvent + ) => { const { name, value } = e.target; - setFormData(prev => ({ + setFormData((prev) => ({ ...prev, - [name]: value + [name]: value, })); if (errors[name as keyof FormErrors]) { - setErrors(prev => ({ + setErrors((prev) => ({ ...prev, - [name]: undefined + [name]: undefined, })); } }; - const handleInitialSubmit = (e: FormEvent) => { + const handleInitialSubmit = async (e: FormEvent) => { e.preventDefault(); - setCurrentStep('verify-email'); + try { + const regResp = await register(formData.email, formData.password); + console.log(regResp); + + setUser(regResp.user); + saveRefreshToken(regResp.refreshToken); + + setCompanyId('mock-company-id'); + + setCurrentStep('verify-email'); + } catch (error) { + if (error instanceof RegisterError) { + console.log('do something here', error.statusCode, error.body); + } else { + // TODO: handle error with some kind of notification + } + } }; const handleFormSubmit = (e: FormEvent) => { @@ -95,7 +118,7 @@ const Register = () => { formData.lastName.trim() !== '' && formData.position.trim() !== '' && formData.bio.trim() !== '' && - formData.linkedIn.trim() !== '' && + formData.linkedIn.trim() !== '' && !errors.linkedIn ); }; @@ -104,41 +127,35 @@ const Register = () => {

Register or Login


-

Register for Spur+Konfer

- +

+ Register for Spur+Konfer +

+
- - - - -
-

- Already have an account? -

+

Already have an account?

+
); @@ -188,29 +210,30 @@ const Register = () => {

Welcome to Spur+Konfer

- To begin your application, please enter your organization's details + To begin your application, please enter your organization's + details

- - - { />