diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 3bf2299a..6a2b91b4 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -20,12 +20,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v6 with: - go-version: '1.24' + go-version: '1.25' cache-dependency-path: "**/go.sum" - name: Setup @@ -34,14 +34,14 @@ jobs: - name: Check run: make check - - name: Build - run: make build - - name: Test env: MONGODB_URI: ${{ secrets.MONGODB_URI }} run: make test + - name: Build + run: make build + build_windows: runs-on: windows-latest defaults: @@ -67,10 +67,10 @@ jobs: - name: Check run: .\build.bat checks - - name: Build - run: .\build.bat build - - name: Test env: MONGODB_URI: ${{ secrets.MONGODB_URI }} run: .\build.bat test + + - name: Build + run: .\build.bat build diff --git a/README.md b/README.md index bc8cb372..307d2164 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Project maintained by [Nebula Labs](https://about.utdnebula.com). Documentation for the current production API can be found [here.](https://api.utdnebula.com/swagger/index.html) -## How to use +## How to use Nebula API in your own project - Visit our [Discord](https://discord.utdnebula.com) and ask to be provisioned an API key (please provide details on your use case) - Read the documentation listed above (and authenticate with your key for interactive demos) @@ -21,7 +21,8 @@ Contributions are welcome! This project uses the MIT License. -Please visit our [Discord](https://discord.utdnebula.com) and talk to us if you'd like to contribute! +Please visit our [Discord](https://discord.utdnebula.com) and talk to us if you'd like to contribute! Don't be afraid to ask for help! + ### How to Contribute Create your own fork by [forking this repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo#forking-a-repository) @@ -32,7 +33,7 @@ Submit proposed changes via a [Pull Request](https://docs.github.com/en/pull-req ## Building ### Requirements -- [Golang 1.23 or Higher](https://go.dev/dl/) +- [Golang 1.24 or Higher](https://go.dev/dl/) ### Building for Windows cd into `nebula-api\api` @@ -43,6 +44,8 @@ Setup Go Dependencies with Build with `.\build.bat build` +This will create an executable named `go-api.exe` in the `api` directory + Run with `.\go-api.exe` > Note: some have experienced issues with Windows Defender or other antivirus blocking `go-api.exe` from reading files, editing files, or causing slowed performance. Consider adding a exception to your `nebula-api` folder. @@ -56,17 +59,23 @@ Setup Go dependencies with Build with `make build` +This will create an executable named `go-api` in the `api` directory + +> Note: If Make fails with "swag: No such file or directory" or similar, you may need to add GOPATH/bin to your path to your path, On Mac, use ``echo 'export PATH=${PATH}:`go env GOPATH`/bin' >> ~/.zshrc && source ~/.zshrc`` to add it permanently to your .zshrc + Run with `./go-api` -## Running to API locally +## Running API locally Copy `.env.template` to `.env` with `cp .env.template .env` -Enter Nebula MongoDB URI in `.env` +Enter Nebula MongoDB URI in `.env` (ask for help in the [Discord](https://discord.utdnebula.com)) Run `go-api` Check command output to see the route serving traffic. It's likely port 8080 Visit `http://localhost:8080` to access nebula-api locally + +> Storage and email routes require additional environment variables. If you're working on these routes, ask for help in the [Discord](https://discord.utdnebula.com) diff --git a/api/.env.template b/api/.env.template index f7cd3326..27962beb 100644 --- a/api/.env.template +++ b/api/.env.template @@ -1,3 +1,5 @@ +# See README.MD for more information + # DATABASE URI MONGODB_URI= CLUBS_DB_URI= @@ -9,9 +11,21 @@ CLUBS_DB_URI= #PORT= #GIN_MODE=release -# CLOUD STORAGE (internal use only) +# SENRTY +SENTRY_ENVIRONMENT=development + +# For Google Cloud Routes /storage /email (internal use only) GOOGLE_APPLICATION_CREDENTIALS= STORAGE_ROUTE_KEY= +# MAX_UPLOAD_SIZE=104857600 +EMAIL_SEND_ROUTE_KEY= +EMAIL_QUEUE_ROUTE_KEY= -# SENRTY -SENTRY_ENVIRONMENT= +# For SMTP email sending +SMTP_HOST=smtp.gmail.com +SMTP_USERNAME= +SMTP_PASSWORD= + +# For Email Google Cloud Task Queueing +GCLOUD_EMAIL_QUEUE_PATH= +GCLOUD_EMAIL_QUEUE_URL= diff --git a/api/Dockerfile b/api/Dockerfile index 962baf7d..32f717ec 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1 -FROM docker.io/golang:1.24 AS builder +FROM docker.io/golang:1.25 AS builder WORKDIR /build COPY ./configs ./configs diff --git a/api/Makefile b/api/Makefile index d03e8663..add78b94 100644 --- a/api/Makefile +++ b/api/Makefile @@ -17,14 +17,14 @@ check: gofmt -w ./.. goimports -w ./.. +test: + go test ./... -count=1 + build: server.go swag fmt swag init -g server.go --outputTypes yaml,go go build -o $(EXEC_NAME) server.go -test: - go test ./... - clean: $(EXEC_NAME) rm $(EXEC_NAME) diff --git a/api/build.bat b/api/build.bat index 615c63fc..c174700f 100644 --- a/api/build.bat +++ b/api/build.bat @@ -38,17 +38,18 @@ echo Checks done! echo[ if "%1"=="checks" exit +:test +echo Testing... +go test ./... -count=1 +if ERRORLEVEL 1 exit /b %ERRORLEVEL% :: fail if error occurred +echo Testing complete! +echo[ +if "%1"=="build" exit + :build echo Building... go build -o %EXEC_NAME% server.go if ERRORLEVEL 1 exit /b %ERRORLEVEL% :: fail if error occurred echo Build complete! echo[ -if "%1"=="build" exit - -:test -echo Testing... -go test ./... -if ERRORLEVEL 1 exit /b %ERRORLEVEL% :: fail if error occurred -echo Testing complete! -echo[ \ No newline at end of file +if "%1"=="build" exit \ No newline at end of file diff --git a/api/configs/env.go b/api/configs/env.go index d1017dd5..60cdfdb5 100644 --- a/api/configs/env.go +++ b/api/configs/env.go @@ -71,3 +71,26 @@ func GetEnvLimit() int64 { return limit } + +func GetEnvMaxUploadSize() int64 { + const ( + defaultLimit int64 = 30 * 1024 * 1024 + hardCapLimit int64 = 50 * 1024 * 1024 + ) + + limitString, exist := os.LookupEnv("MAX_UPLOAD_SIZE") + if !exist { + return defaultLimit + } + + limit, err := strconv.ParseInt(limitString, 10, 64) + if err != nil { + return defaultLimit + } + + if limit > hardCapLimit { + return hardCapLimit + } + + return limit +} diff --git a/api/controllers/astra.go b/api/controllers/astra.go index 542e5dbf..ddf80304 100644 --- a/api/controllers/astra.go +++ b/api/controllers/astra.go @@ -28,7 +28,7 @@ var astraCollection *mongo.Collection = configs.GetCollection("astra") // @Success 200 {object} schema.APIResponse[schema.MultiBuildingEvents[schema.AstraEvent]] "All AstraEvents with events on the inputted date" // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" func AstraEvents(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() date := c.Param("date") @@ -61,7 +61,7 @@ func AstraEvents(c *gin.Context) { // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 404 {object} schema.APIResponse[string] "A string describing the error" func AstraEventsByBuilding(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() date := c.Param("date") @@ -120,7 +120,7 @@ func AstraEventsByBuilding(c *gin.Context) { // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 404 {object} schema.APIResponse[string] "A string describing the error" func AstraEventsByBuildingAndRoom(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() date := c.Param("date") diff --git a/api/controllers/autocomplete.go b/api/controllers/autocomplete.go index a4c0429b..a975798f 100644 --- a/api/controllers/autocomplete.go +++ b/api/controllers/autocomplete.go @@ -25,7 +25,7 @@ var DAGCollection *mongo.Collection = configs.GetCollection("DAG") // @Success 200 {object} schema.APIResponse[[]schema.Autocomplete] "An aggregation of courses for use in generating autocomplete DAGs" // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" func AutocompleteDAG(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) defer cancel() var autocompleteDAG []schema.Autocomplete diff --git a/api/controllers/calendar.go b/api/controllers/calendar.go index 40407ebb..13536542 100644 --- a/api/controllers/calendar.go +++ b/api/controllers/calendar.go @@ -30,7 +30,7 @@ var cometCalendarCollection *mongo.Collection = configs.GetCollection("cometCale // @Success 200 {object} schema.APIResponse[schema.MultiBuildingEvents[schema.Event]] "All CometCalendarEvents with events on the inputted date" // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" func CometCalendarEvents(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() date := c.Param("date") @@ -63,7 +63,7 @@ func CometCalendarEvents(c *gin.Context) { // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 404 {object} schema.APIResponse[string] "A string describing the error" func CometCalendarEventsByBuilding(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() date := c.Param("date") @@ -135,7 +135,7 @@ func CometCalendarEventsByBuilding(c *gin.Context) { // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 404 {object} schema.APIResponse[string] "A string describing the error" func CometCalendarEventsByBuildingAndRoom(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() date := c.Param("date") diff --git a/api/controllers/club.go b/api/controllers/club.go index 103c5a1d..b904e070 100644 --- a/api/controllers/club.go +++ b/api/controllers/club.go @@ -23,7 +23,7 @@ import ( // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 400 {object} schema.APIResponse[string] "A string describing the error" func ClubDirectoryInfo(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() var clubsDatabase *sql.DB = configs.ConnectClubsDB() @@ -88,7 +88,7 @@ func ClubDirectoryInfo(c *gin.Context) { // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 400 {object} schema.APIResponse[string] "A string describing the error" func ClubSearch(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() var clubsDatabase *sql.DB = configs.ConnectClubsDB() diff --git a/api/controllers/controller_utils.go b/api/controllers/controller_utils.go index 57579fe1..77555677 100644 --- a/api/controllers/controller_utils.go +++ b/api/controllers/controller_utils.go @@ -31,7 +31,7 @@ func respond[T any](c *gin.Context, status int, message string, data T) { func getQuery[T any](flag string, c *gin.Context) (bson.M, error) { switch flag { case "Search": - query, err := schema.FilterQuery[T](c) + query, err := schema.FilterQuery[T](c.Request.URL.Query()) if err != nil { respond(c, http.StatusBadRequest, "Invalid query parameters", err.Error()) return nil, err diff --git a/api/controllers/course.go b/api/controllers/course.go index 1cb12450..18782cb7 100644 --- a/api/controllers/course.go +++ b/api/controllers/course.go @@ -41,10 +41,7 @@ var courseCollection *mongo.Collection = configs.GetCollection("courses") // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 400 {object} schema.APIResponse[string] "A string describing the error" func CourseSearch(c *gin.Context) { - //name := c.Query("name") // value of specific query parameter: string - //queryParams := c.Request.URL.Query() // map of all query params: map[string][]string - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() var courses []schema.Course @@ -122,7 +119,7 @@ func CourseById(c *gin.Context) { // @Success 200 {object} schema.APIResponse[[]schema.Course] "All courses" // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" func CourseAll(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) defer cancel() var courses []schema.Course @@ -223,7 +220,7 @@ func CourseProfessorById(c *gin.Context) { // courseAggregate is a generic function that gets a specified field of the courses, filters depending on the flag func courseAggregate[T any](flag string, c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() var queryResults []T @@ -243,6 +240,7 @@ func courseAggregate[T any](flag string, c *gin.Context) { } // Determine the endpoint based on the type of the desired query results + var zero T var endpoint string switch any(zero).(type) { @@ -295,7 +293,7 @@ func buildCoursePipeline(endpoint string, courseQuery bson.M, paginate map[strin var lookupStages, dedupStages mongo.Pipeline switch endpoint { case "sections": - lookupStages, dedupStages = mongo.Pipeline{}, mongo.Pipeline{} + // No extra stages middle stages case "professors": // Lookup the list of professors from the list of sections @@ -336,7 +334,6 @@ func buildCoursePipeline(endpoint string, courseQuery bson.M, paginate map[strin middleStages := append(append(lookupStages, replaceStages...), dedupStages...) paginateStages := mongo.Pipeline{ - // Keep order deterministic between calls bson.D{{Key: "$sort", Value: bson.D{{Key: "_id", Value: 1}}}}, paginate["latter_offset"], diff --git a/api/controllers/discounts.go b/api/controllers/discounts.go index ecda529c..7fd21006 100644 --- a/api/controllers/discounts.go +++ b/api/controllers/discounts.go @@ -3,10 +3,10 @@ package controllers import ( "context" "fmt" + "log" "net/http" "regexp" - "strconv" - "strings" + "sync" "time" "github.com/UTDNebula/nebula-api/api/configs" @@ -18,95 +18,67 @@ import ( var discountCollection *mongo.Collection = configs.GetCollection("discounts") -// discountCategories -// potentially we may want to add an init function to create this list dynamically -var discountCategories = []string{ - "Accommodations", - "Auto Services", - "Child Care", - "Clothes, Flowers and Gifts", - "Dining", - "Entertainment", - "Health and Beauty", - "Home and Garden", - "Housing", - "Miscellaneous", - "Pet Care", - "Professional Services", - "Technology", -} +var discountCategories []string +var discountCategoriesOnce sync.Once // @Id discountPrograms // @Router /discountPrograms [get] // @Tags Discounts -// @Description "Returns paginated list of discounts matching the query's string-typed key-value pairs. See offset for more details on pagination." +// @Description "Returns list of discounts filtered using field-specific keyword searches or full-text search." // @Produce json -// @Param offset query number false "The starting position of the current page of discounts (e.g. For starting at the 17th discount, offset=16)." -// @Param category query string false "The discount's category." +// @Param category query string false "The discount's category (exact match with suggestions)." // @Param business query string false "The discount's business contains this keyword (case-insensitive)." // @Param address query string false "The discount's address contains this keyword (case-insensitive)." // @Param discount query string false "The discount's discount contains this keyword (case-insensitive)." -// @Param q query string false "Full text search of all discount's fields." +// @Param q query string false "Full-text search, must be used alone." // @Success 200 {object} schema.APIResponse[[]schema.DiscountProgram] "A list of discounts" // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 400 {object} schema.APIResponse[string] "A string describing the error" func DiscountSearch(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + fetchDiscountCategories() + + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() var cursor *mongo.Cursor var err error - _, hasQ := c.GetQuery("q") - _, hasBusiness := c.GetQuery("business") - _, hasAddress := c.GetQuery("address") - _, hasDiscount := c.GetQuery("discount") - _, hasCategory := c.GetQuery("category") - if hasQ { - if hasBusiness || hasAddress || hasDiscount || hasCategory { - // q may only be used alone - respond(c, http.StatusBadRequest, "Invalid query parameters", "Parameter q may not be used with other parameters") - return - } + var params schema.DiscountQueryParams + if err = c.ShouldBindQuery(¶ms); err != nil { + respond(c, http.StatusBadRequest, "Invalid query parameters", err.Error()) + return + } + params.TrimSpace() - pipeline, err := buildFuzzySearchPipeline(c) - if err != nil { - respond(c, http.StatusBadRequest, "Invalid query parameters", err.Error()) + if params.Q != "" { + if params.HasFields() { + respond(c, http.StatusBadRequest, "Invalid query parameters", "q must be used alone") return } + + pipeline := buildFuzzySearchPipeline(params.Q) cursor, err = discountCollection.Aggregate(ctx, pipeline) if err != nil { respondWithInternalError(c, err) return } - } else { - if !hasBusiness && !hasAddress && !hasDiscount && !hasCategory { - respond(c, http.StatusBadRequest, "Invalid query parameters", "Unknown query") - return - } - - query, err := buildDiscountSearchQuery(c) + // If no fields are specified, it just returns paginated collection + discountQuery, err := buildDiscountSearchQuery(params) if err != nil { respond(c, http.StatusBadRequest, "Invalid query parameters", err.Error()) return } - optionLimit, err := configs.GetOptionLimit(&query, c) - if err != nil { - respond(c, http.StatusBadRequest, "offset is not type integer", err.Error()) - return - } - cursor, err = discountCollection.Find(ctx, query, optionLimit) + cursor, err = discountCollection.Find(ctx, discountQuery) if err != nil { respondWithInternalError(c, err) return } } - defer cursor.Close(ctx) - var discounts []schema.DiscountProgram + discounts := make([]schema.DiscountProgram, 0) if err = cursor.All(ctx, &discounts); err != nil { respondWithInternalError(c, err) return @@ -116,106 +88,119 @@ func DiscountSearch(c *gin.Context) { } -// Build the query for field-based search -func buildDiscountSearchQuery(c *gin.Context) (bson.M, error) { - business, hasBusiness := c.GetQuery("business") - address, hasAddress := c.GetQuery("address") - discount, hasDiscount := c.GetQuery("discount") - category, hasCategory := c.GetQuery("category") +// fetchDiscountCategories aggregates the list of discount categories +func fetchDiscountCategories() { + discountCategoriesOnce.Do(func() { + ctx, cancel := context.WithTimeout(context.Background(), 7*time.Second) + defer cancel() + results, err := discountCollection.Distinct(ctx, "category", bson.M{}) + if err != nil { + panic(err) + } + for _, result := range results { + category, ok := result.(string) + if !ok { + continue // Skip invalid category + } + discountCategories = append(discountCategories, category) + } + log.Printf("Available categories: %s.\n", discountCategories) + }) +} + +// buildDiscountSearchQuery constructs the Mongo query for FIELD-BASED SEARCH. +// Users only search for 4 main fields of discount +func buildDiscountSearchQuery(p schema.DiscountQueryParams) (bson.M, error) { query := bson.M{} - // We use regexp.QuoteMeta and option i to essentially do string.toLower().contains(key) on fields - if hasBusiness { - cleanedBusiness := strings.TrimSpace(regexp.QuoteMeta(business)) - query["business"] = bson.D{{Key: "$regex", Value: cleanedBusiness}, {Key: "$options", Value: "i"}} + // Use regexp.QuoteMeta and option i for insensitive-matching + if p.Business != "" { + query["business"] = bson.D{ + {Key: "$regex", Value: regexp.QuoteMeta(p.Business)}, + {Key: "$options", Value: "i"}, + } } - if hasAddress { - cleanedAddress := strings.TrimSpace(regexp.QuoteMeta(address)) - query["address"] = bson.D{{Key: "$regex", Value: cleanedAddress}, {Key: "$options", Value: "i"}} + + if p.Address != "" { + query["address"] = bson.D{ + {Key: "$regex", Value: regexp.QuoteMeta(p.Address)}, + {Key: "$options", Value: "i"}, + } } - if hasDiscount { - cleanedDiscount := strings.TrimSpace(regexp.QuoteMeta(discount)) - query["discount"] = bson.D{{Key: "$regex", Value: cleanedDiscount}, {Key: "$options", Value: "i"}} + + if p.Discount != "" { + query["discount"] = bson.D{ + {Key: "$regex", Value: regexp.QuoteMeta(p.Discount)}, + {Key: "$options", Value: "i"}, + } } - if hasCategory { - found := false + if p.Category != "" { + categoryFound := false for _, discountCategory := range discountCategories { - //case insensitive equal - if strings.EqualFold(discountCategory, category) { + if discountCategory == p.Category { query["category"] = discountCategory - found = true + categoryFound = true break } } - if !found { - return nil, fmt.Errorf("unknown category %s. Valid categories are %s", category, strings.Join(discountCategories, " | ")) + if !categoryFound { + return nil, fmt.Errorf("unknown category, valid categories are %s", discountCategories) } } return query, nil } -// Build the pipeline to perform fuzzy search on q -func buildFuzzySearchPipeline(c *gin.Context) (mongo.Pipeline, error) { - q, _ := c.GetQuery("q") - if strings.TrimSpace(q) == "" { - return mongo.Pipeline{}, fmt.Errorf("empty q") +// buildFuzzySearchPipeline constructs the pipeline to perform fuzzy search on keyword q. +func buildFuzzySearchPipeline(q string) mongo.Pipeline { + var fuzzySearches bson.A + // TODO: Tune the configuration to get better results + fuzzyConfigs := []schema.FuzzySearchConfig{ + {Field: "category", MaxEdits: 2, BoostScore: 5}, + {Field: "discount", MaxEdits: 2, BoostScore: 3}, + {Field: "business", MaxEdits: 2, BoostScore: 2}, + {Field: "address", MaxEdits: 1, BoostScore: 1}, } - var offset int64 - var err error - if c.Query("offset") == "" { - offset = 0 - } else { - offset, err = strconv.ParseInt(c.Query("offset"), 10, 64) - if err != nil { - return mongo.Pipeline{}, err - } - } - - var fuzzySearchArr bson.A - fields := [4]string{"category", "discount", "business", "address"} - maxEditsList := [4]int{2, 2, 2, 1} - boostScores := [4]int{5, 3, 2, 1} - for i, field := range fields { - fuzzySearchArr = append(fuzzySearchArr, bson.D{ + for _, config := range fuzzyConfigs { + fuzzySearches = append(fuzzySearches, bson.D{ {Key: "text", Value: bson.D{ {Key: "query", Value: q}, - {Key: "path", Value: field}, + {Key: "path", Value: config.Field}, {Key: "fuzzy", Value: bson.D{ - {Key: "maxEdits", Value: maxEditsList[i]}, + {Key: "maxEdits", Value: config.MaxEdits}, + + // Should match first 2 characters + {Key: "prefixLength", Value: 2}, }}, {Key: "score", Value: bson.D{ - {Key: "boost", Value: bson.D{ - {Key: "value", Value: boostScores[i]}, - }}, + {Key: "boost", Value: bson.D{{Key: "value", Value: config.BoostScore}}}, }}, }}, }) } return mongo.Pipeline{ - // Fuzzy searches bson.D{ {Key: "$search", Value: bson.D{ + // Name of the index search of this collection {Key: "index", Value: "discount_searches"}, {Key: "compound", Value: bson.D{ - {Key: "should", Value: fuzzySearchArr}, + {Key: "should", Value: fuzzySearches}, + + // Should match at least 1 field to prevent super unrelated docs + {Key: "minimumShouldMatch", Value: 1}, }}, }}, }, - // Sort and paginate + // Sort the results based on relevancy for determinism bson.D{ {Key: "$sort", Value: bson.D{ - {Key: "score", Value: bson.D{ - {Key: "$meta", Value: "searchScore"}, - }}, + {Key: "score", Value: bson.D{{Key: "$meta", Value: "searchScore"}}}, }}, }, - bson.D{{Key: "$skip", Value: offset}}, - bson.D{{Key: "$limit", Value: configs.GetEnvLimit()}}, - }, nil + } } diff --git a/api/controllers/email.go b/api/controllers/email.go new file mode 100644 index 00000000..74efedf8 --- /dev/null +++ b/api/controllers/email.go @@ -0,0 +1,147 @@ +package controllers + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "time" + + "github.com/UTDNebula/nebula-api/api/schema" + "github.com/gin-gonic/gin" + "github.com/wneessen/go-mail" + + cloudtasks "cloud.google.com/go/cloudtasks/apiv2" + taskspb "cloud.google.com/go/cloudtasks/apiv2/cloudtaskspb" +) + +// @Id sendEmail +// @Router /email/send [post] +// @Tags Internal +// @Description "Send an email via SMTP. This route is restricted to only Nebula Labs internal Projects." +// @Accept json +// @Produce json +// @Param request body schema.EmailRequest true "Email Request Body" +// @Param x-email-send-key header string true "The internal email send key" +// @Success 200 {object} schema.APIResponse[schema.EmailRequest] "Email Request Body" +// @Failure 500 {object} schema.APIResponse[string] "A string describing the error" +// @Failure 400 {object} schema.APIResponse[string] "A string describing the error" +func SendEmail(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + + var req schema.EmailRequest + + if err := c.ShouldBindJSON(&req); err != nil { + respond(c, http.StatusBadRequest, "invalid request payload", err.Error()) + return + } + + client := c.MustGet("emailClient").(*mail.Client) + smtpUsername := os.Getenv("SMTP_USERNAME") + + m := mail.NewMsg() + if err := m.FromFormat(req.From, smtpUsername); err != nil { + respond(c, http.StatusInternalServerError, "failed to set from address", err.Error()) + return + } + + if err := m.To(req.To...); err != nil { + respond(c, http.StatusBadRequest, "invalid to address(es)", err.Error()) + return + } + + m.Subject(req.Subject) + m.SetBodyString(mail.TypeTextHTML, req.Body) + + for _, att := range req.Attachments { + m.AttachReader(att.Name, bytes.NewReader(att.Data)) + } + + for _, emb := range req.Embeds { + m.EmbedReader(emb.Name, bytes.NewReader(emb.Data), mail.WithFileContentID(emb.Name)) + } + + if err := client.DialAndSendWithContext(ctx, m); err != nil { + respond(c, http.StatusInternalServerError, "failed to send email", err.Error()) + return + } + + respond(c, http.StatusOK, "success", req) +} + +// @Id QueueEmail +// @Router /email/queue [post] +// @Tags Internal +// @Description "Queue an email to be sent via SMTP. Multi-recipient emails will be queued as separate emails to avoid bypassing queueing system. This route is restricted to only Nebula Labs internal Projects." +// @Accept json +// @Produce json +// @Param request body schema.EmailRequest true "Email Request Body" +// @Param x-email-queue-key header string true "The internal email queue key" +// @Success 200 {object} schema.APIResponse[[]string] "The list of queued task names" +// @Failure 500 {object} schema.APIResponse[string] "A string describing the error" +// @Failure 400 {object} schema.APIResponse[string] "A string describing the error" +func QueueEmail(c *gin.Context) { + // Request must be able to bind to email request + var emailReq schema.EmailRequest + if err := c.ShouldBindJSON(&emailReq); err != nil { + respond(c, http.StatusBadRequest, "invalid request payload", err.Error()) + return + } + + client := c.MustGet("tasksClient").(*cloudtasks.Client) + queuePath := os.Getenv("GCLOUD_EMAIL_QUEUE_PATH") + queueUrl := os.Getenv("GCLOUD_EMAIL_QUEUE_URL") + + baseEmailReq := emailReq + baseEmailReq.To = []string{} + + queuedTasks := []string{} + + numOfRecipients := len(emailReq.To) + for i, to := range emailReq.To { + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + + baseEmailReq.To = []string{to} + + body, err := json.Marshal(baseEmailReq) + if err != nil { + respond(c, http.StatusInternalServerError, fmt.Sprintf("failed to serialize email request for recipient %s %d/%d", to, i, numOfRecipients), err.Error()) + return + } + + // Build the Task payload. + // // https://docs.cloud.google.com/tasks/docs/creating-http-target-tasks + taskReq := &taskspb.CreateTaskRequest{ + Parent: queuePath, + Task: &taskspb.Task{ + MessageType: &taskspb.Task_HttpRequest{ + HttpRequest: &taskspb.HttpRequest{ + HttpMethod: taskspb.HttpMethod_POST, + Url: queueUrl, + Headers: map[string]string{ + "x-email-send-key": os.Getenv("EMAIL_SEND_ROUTE_KEY"), // Must get from env bc queue only has x-email-queue-key header + "x-api-key": c.GetHeader("x-api-key"), + }, + }, + }, + }, + } + + // Add a payload message if one is present. + taskReq.Task.GetHttpRequest().Body = []byte(body) + + task, err := client.CreateTask(ctx, taskReq) + if err != nil { + respond(c, http.StatusInternalServerError, fmt.Sprintf("failed to queue email for recipient %s %d/%d", to, i, numOfRecipients), err.Error()) + return + } + + queuedTasks = append(queuedTasks, task.GetName()) + } + + respond(c, http.StatusOK, "success", queuedTasks) +} diff --git a/api/controllers/events.go b/api/controllers/events.go index 68247ced..eef1896c 100644 --- a/api/controllers/events.go +++ b/api/controllers/events.go @@ -29,7 +29,7 @@ var eventsCollection *mongo.Collection = configs.GetCollection("events") // @Success 200 {object} schema.APIResponse[schema.MultiBuildingEvents[schema.SectionWithTime]] "All sections with meetings on the specified date" // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" func Events(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) date := c.Param("date") @@ -62,7 +62,7 @@ func Events(c *gin.Context) { // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 404 {object} schema.APIResponse[string] "A string describing the error" func EventsByBuilding(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() date := c.Param("date") @@ -120,7 +120,7 @@ func EventsByBuilding(c *gin.Context) { // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 404 {object} schema.APIResponse[string] "A string describing the error" func EventsByRoom(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() date := c.Param("date") @@ -191,7 +191,7 @@ func EventsByRoom(c *gin.Context) { // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 404 {object} schema.APIResponse[string] "A string describing the error" func SectionsByRoomDetailed(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() date := c.Param("date") diff --git a/api/controllers/grades.go b/api/controllers/grades.go index 04a656a9..7da70801 100644 --- a/api/controllers/grades.go +++ b/api/controllers/grades.go @@ -165,7 +165,7 @@ func gradesAggregation(flag string, c *gin.Context) { var err error - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() // @TODO: Recommend forcing using first_name and last_name to ensure single professors per query. diff --git a/api/controllers/mazevo.go b/api/controllers/mazevo.go index a2b9647f..0c97527a 100644 --- a/api/controllers/mazevo.go +++ b/api/controllers/mazevo.go @@ -26,7 +26,7 @@ var mazevoCollection *mongo.Collection = configs.GetCollection("mazevo") // @Success 200 {object} schema.APIResponse[schema.MultiBuildingEvents[schema.MazevoEvent]] "All MazevoEvents with events on the inputted date" // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" func MazevoEvents(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() date := c.Param("date") @@ -47,3 +47,107 @@ func MazevoEvents(c *gin.Context) { respond(c, http.StatusOK, "success", mazevoEvents) } + +// @Id mazevoEventsByBuilding +// @Router /mazevo/{date}/{building} [get] +// @Tags Events +// @Description "Returns all sections with MazevoEvent meetings on the specified date in the specified building" +// @Produce json +// @Param date path string true "date (ISO format) to retrieve mazevo events" +// @Param building path string true "building abbreviation of the event location" +// @Success 200 {object} schema.APIResponse[schema.MultiBuildingEvents[schema.MazevoEvent]] "All MazevoEvents sections with meetings on the specified date in the specified building" +// @Failure 500 {object} schema.APIResponse[string] "A string describing the error" +// @Failure 404 {object} schema.APIResponse[string] "A string describing the error" +func MazevoEventsByBuilding(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + + date := c.Param("date") + building := c.Param("building") + + var mazevoEvents schema.MultiBuildingEvents[schema.MazevoEvent] + var mazevoEventsByBuilding schema.SingleBuildingEvents[schema.MazevoEvent] + + // find and parse matching date + err := mazevoCollection.FindOne(ctx, bson.M{"date": date}).Decode(&mazevoEvents) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + mazevoEvents.Date = date + mazevoEvents.Buildings = []schema.SingleBuildingEvents[schema.MazevoEvent]{} + } else { + respondWithInternalError(c, err) + return + } + } + + // filter by the specified building + for _, b := range mazevoEvents.Buildings { + if b.Building == building { + mazevoEventsByBuilding = b + break + } + } + + // if no building found return an error + if mazevoEventsByBuilding.Building == "" { + respond(c, http.StatusNotFound, "error", "No events found for the specified building") + return + } + + respond(c, http.StatusOK, "success", mazevoEventsByBuilding) +} + +// @Id mazevoEventsByRoom +// @Router /mazevo/{date}/{building}/{room} [get] +// @Tags Events +// @Description "Returns all sections with MazevoEvent meetings on the specified date in the specified building and room" +// @Produce json +// @Param date path string true "date (ISO format) to retrieve mazevo events" +// @Param building path string true "building abbreviation of the event location" +// @Param room path string true "room number" +// @Success 200 {object} schema.APIResponse[schema.MultiBuildingEvents[schema.MazevoEvent]] "All MazevoEvents sections with meetings on the specified date in the specified building" +// @Failure 500 {object} schema.APIResponse[string] "A string describing the error" +// @Failure 404 {object} schema.APIResponse[string] +func MazevoEventsByRoom(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + + date := c.Param("date") + building := c.Param("building") + room := c.Param("room") + + var mazevoEvents schema.MultiBuildingEvents[schema.MazevoEvent] + var mazevoEventsByRoom schema.RoomEvents[schema.MazevoEvent] + + // find and parse matching date + err := mazevoCollection.FindOne(ctx, bson.M{"date": date}).Decode(&mazevoEvents) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + mazevoEvents.Date = date + mazevoEvents.Buildings = []schema.SingleBuildingEvents[schema.MazevoEvent]{} + } else { + respondWithInternalError(c, err) + return + } + } + + // filter for the specified building and room + for _, b := range mazevoEvents.Buildings { + if b.Building == building { + for _, r := range b.Rooms { + if r.Room == room { + mazevoEventsByRoom = r + break + } + } + break + } + } + + if mazevoEventsByRoom.Room == "" { + respond(c, http.StatusNotFound, "error", "No events found for that specific building and room") + return + } + + respond(c, http.StatusOK, "success", mazevoEventsByRoom) +} diff --git a/api/controllers/professor.go b/api/controllers/professor.go index 28c25044..c61bdd7a 100644 --- a/api/controllers/professor.go +++ b/api/controllers/professor.go @@ -56,7 +56,7 @@ func ProfessorSearch(c *gin.Context) { //name := c.Query("name") // value of specific query parameter: string //queryParams := c.Request.URL.Query() // map of all query params: map[string][]string - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() var professors []schema.Professor @@ -100,7 +100,7 @@ func ProfessorSearch(c *gin.Context) { // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 400 {object} schema.APIResponse[string] "A string describing the error" func ProfessorById(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() var professor schema.Professor @@ -134,7 +134,7 @@ func ProfessorById(c *gin.Context) { // @Success 200 {object} schema.APIResponse[[]schema.Professor] "All professors" // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" func ProfessorAll(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) defer cancel() var professors []schema.Professor @@ -250,7 +250,7 @@ func ProfessorSectionById(c *gin.Context) { // Get data for professor aggregate endpoints depending on flag func professorAggregate[T any](flag string, c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() var profAggregate []T diff --git a/api/controllers/rooms.go b/api/controllers/rooms.go index d961d3bb..4aba7943 100644 --- a/api/controllers/rooms.go +++ b/api/controllers/rooms.go @@ -25,7 +25,7 @@ var buildingCollection *mongo.Collection = configs.GetCollection("rooms") // @Success 200 {object} schema.APIResponse[[]schema.BuildingRooms] "All schedulable rooms being used in the current and futures semesters from CourseBook, Astra, and Mazevo" // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" func Rooms(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() var buildingRooms []schema.BuildingRooms // buildings and rooms to be returned diff --git a/api/controllers/section.go b/api/controllers/section.go index 0adbda4a..c89323c3 100644 --- a/api/controllers/section.go +++ b/api/controllers/section.go @@ -49,10 +49,8 @@ var sectionCollection *mongo.Collection = configs.GetCollection("sections") // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 400 {object} schema.APIResponse[string] "A string describing the error" func SectionSearch(c *gin.Context) { - //name := c.Query("name") // value of specific query parameter: string - //queryParams := c.Request.URL.Query() // map of all query params: map[string][]string - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() var sections []schema.Section @@ -96,7 +94,7 @@ func SectionSearch(c *gin.Context) { // @Failure 500 {object} schema.APIResponse[string] "A string describing the error" // @Failure 400 {object} schema.APIResponse[string] "A string describing the error" func SectionById(c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() var section schema.Section @@ -172,7 +170,7 @@ func SectionCourseById(c *gin.Context) { // Get an array of courses from sections, filtered based on the the flag func sectionCourse(flag string, c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() var sectionCourses []schema.Course @@ -188,64 +186,11 @@ func sectionCourse(flag string, c *gin.Context) { return } - // pipeline of query an array of courses from filtered sections - sectionCoursePipeline := mongo.Pipeline{ - // filter the sections - bson.D{{Key: "$match", Value: sectionQuery}}, - - // paginate the sections before pulling courses from those sections - paginate["former_offset"], - paginate["limit"], - - // lookup the course referenced by sections from the course collection - bson.D{ - {Key: "$lookup", Value: bson.D{ - {Key: "from", Value: "courses"}, - {Key: "localField", Value: "course_reference"}, - {Key: "foreignField", Value: "_id"}, - {Key: "as", Value: "course_reference"}, - }}, - }, - - // project to remove every other fields except for courses - bson.D{ - {Key: "$project", Value: bson.D{ - {Key: "courses", Value: "$course_reference"}, - }}, - }, - - // unwind the courses - bson.D{ - {Key: "$unwind", Value: bson.D{ - {Key: "path", Value: "$courses"}, - {Key: "preserveNullAndEmptyArrays", Value: false}, - }}, - }, - - // replace the combinations of id and course with courses entirely - bson.D{{Key: "$replaceWith", Value: "$courses"}}, - - // remove duplicate courses - bson.D{{Key: "$group", Value: bson.D{ - {Key: "_id", Value: "$_id"}, - {Key: "course", Value: bson.D{{Key: "$first", Value: "$$ROOT"}}}, - }}}, - bson.D{{Key: "$replaceWith", Value: "$course"}}, - - // keep order deterministic between calls - bson.D{{Key: "$sort", Value: bson.D{{Key: "_id", Value: 1}}}}, - - // paginate the courses - paginate["latter_offset"], - paginate["limit"], - } - - cursor, err := sectionCollection.Aggregate(ctx, sectionCoursePipeline) + cursor, err := sectionCollection.Aggregate(ctx, buildSectionPipeline("courses", sectionQuery, paginate)) if err != nil { respondWithInternalError(c, err) return } - // Parse the array of courses if err = cursor.All(ctx, §ionCourses); err != nil { respondWithInternalError(c, err) return @@ -255,10 +200,8 @@ func sectionCourse(flag string, c *gin.Context) { case "Search": respond(c, http.StatusOK, "success", sectionCourses) case "ById": - // Section is only referenced by only one course, so return a single course respond(c, http.StatusOK, "success", sectionCourses[0]) } - } // @Id sectionProfessorSearch @@ -311,7 +254,7 @@ func SectionProfessorById(c *gin.Context) { // Get an array of professors from sections, func sectionProfessor(flag string, c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() var sectionProfessors []schema.Professor @@ -327,69 +270,89 @@ func sectionProfessor(flag string, c *gin.Context) { return } - // pipeline to query an array of professors from filtered sections - sectionProfessorPipeline := mongo.Pipeline{ + cursor, err := sectionCollection.Aggregate(ctx, buildSectionPipeline("professors", sectionQuery, paginate)) + if err != nil { + respondWithInternalError(c, err) + return + } + if err = cursor.All(ctx, §ionProfessors); err != nil { + respondWithInternalError(c, err) + return + } + + respond(c, http.StatusOK, "success", sectionProfessors) +} + +// buildSectionPipeline builds the pipeline to aggregate courses or professors from filtered sections +func buildSectionPipeline(endpoint string, sectionQuery bson.M, paginate map[string]bson.D) mongo.Pipeline { + baseStages := mongo.Pipeline{ // filter the sections bson.D{{Key: "$match", Value: sectionQuery}}, // paginate the sections before pulling courses from those sections paginate["former_offset"], paginate["limit"], + } - // lookup the professors referenced by sections from the course collection - bson.D{ - {Key: "$lookup", Value: bson.D{ + var lookupStages mongo.Pipeline + switch endpoint { + case "courses": + lookupStages = mongo.Pipeline{ + // lookup the course referenced by sections from the course collection + bson.D{{Key: "$lookup", Value: bson.D{ + {Key: "from", Value: "courses"}, + {Key: "localField", Value: "course_reference"}, + {Key: "foreignField", Value: "_id"}, + {Key: "as", Value: "courses"}, + }}}, + } + + case "professors": + lookupStages = mongo.Pipeline{ + // lookup the professors referenced by sections from the course collection + bson.D{{Key: "$lookup", Value: bson.D{ {Key: "from", Value: "professors"}, {Key: "localField", Value: "professors"}, {Key: "foreignField", Value: "_id"}, {Key: "as", Value: "professors"}, - }}, - }, - - // project to remove every other fields except for professors - bson.D{ - {Key: "$project", Value: bson.D{ - {Key: "professors", Value: "$professors"}, - }}, - }, - - // unwind the professors - bson.D{ - {Key: "$unwind", Value: bson.D{ - {Key: "path", Value: "$professors"}, - {Key: "preserveNullAndEmptyArrays", Value: false}, - }}, - }, - - // replace the combinations of id and course with courses entirely - bson.D{{Key: "$replaceWith", Value: "$professors"}}, - - // remove duplicate professors + }}}, + } + + default: + panic("invalid endpoint for buildSectionPipeline: " + endpoint) + } + + replaceStages := mongo.Pipeline{ + // project to remove every other fields except for courses/professors + bson.D{{Key: "$project", Value: bson.D{ + {Key: endpoint, Value: "$" + endpoint}, + }}}, + + // unwind the courses/professors + bson.D{{Key: "$unwind", Value: bson.D{ + {Key: "path", Value: "$" + endpoint}, + {Key: "preserveNullAndEmptyArrays", Value: false}, + }}}, + + // replace the combinations of id and course/professor with courses/professors entirely + bson.D{{Key: "$replaceWith", Value: "$" + endpoint}}, + + // remove duplicate courses/professors bson.D{{Key: "$group", Value: bson.D{ {Key: "_id", Value: "$_id"}, - {Key: "professor", Value: bson.D{{Key: "$first", Value: "$$ROOT"}}}, + {Key: "item", Value: bson.D{{Key: "$first", Value: "$$ROOT"}}}, }}}, - bson.D{{Key: "$replaceWith", Value: "$professor"}}, + bson.D{{Key: "$replaceWith", Value: "$item"}}, + } + paginateStages := mongo.Pipeline{ // keep order deterministic between calls bson.D{{Key: "$sort", Value: bson.D{{Key: "_id", Value: 1}}}}, - // paginate the courses + // paginate the courses/professors paginate["latter_offset"], paginate["limit"], } - cursor, err := sectionCollection.Aggregate(ctx, sectionProfessorPipeline) - if err != nil { - respondWithInternalError(c, err) - return - } - // Parse the array of courses - if err = cursor.All(ctx, §ionProfessors); err != nil { - respondWithInternalError(c, err) - return - } - - respond(c, http.StatusOK, "success", sectionProfessors) - + return append(append(append(baseStages, lookupStages...), replaceStages...), paginateStages...) } diff --git a/api/controllers/storage.go b/api/controllers/storage.go index 96f9b659..65e7ebe4 100644 --- a/api/controllers/storage.go +++ b/api/controllers/storage.go @@ -1,18 +1,21 @@ package controllers import ( + "bytes" "context" "errors" "fmt" "io" "net/http" "net/url" + "strings" "time" "cloud.google.com/go/storage" "github.com/gin-gonic/gin" "google.golang.org/api/iterator" + "github.com/UTDNebula/nebula-api/api/configs" "github.com/UTDNebula/nebula-api/api/schema" ) @@ -37,9 +40,14 @@ func getOrCreateBucket(client *storage.Client, bucket string) (*storage.BucketHa bucketHandle := client.Bucket(schema.BUCKET_PREFIX + bucket) _, err := bucketHandle.Attrs(ctx) if err != nil { - err = bucketHandle.Create(ctx, PROJECT_ID, nil) - if err != nil { - return nil, errors.New("failed to create bucket: " + err.Error()) + + if errors.Is(err, storage.ErrBucketNotExist) { + err = bucketHandle.Create(ctx, PROJECT_ID, nil) + if err != nil { + return nil, errors.New("failed to create bucket: " + err.Error()) + } + } else { + return nil, err } } return bucketHandle, nil @@ -203,6 +211,33 @@ func ObjectInfo(c *gin.Context) { func PostObject(c *gin.Context) { bucket := c.Param("bucket") objectID := c.Param("objectID") + + maxUploadSize := configs.GetEnvMaxUploadSize() + + // Force early 413 check via Content-Length if present + if c.Request.ContentLength > maxUploadSize { + respond(c, http.StatusRequestEntityTooLarge, "error", fmt.Sprintf("File too large. Maximum allowed size is %d bytes (%dMB)", maxUploadSize, maxUploadSize/(1024*1024))) + return + } + + // Use MaxBytesReader to limit the body + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadSize) + + // Read and validate the entire (capped) request body before touching GCS. + + fileBytes, readErr := io.ReadAll(c.Request.Body) + if readErr != nil { + var maxBytesErr *http.MaxBytesError + if errors.As(readErr, &maxBytesErr) { + respond(c, http.StatusRequestEntityTooLarge, "error", fmt.Sprintf("File too large. Maximum allowed size is %d bytes (%dMB)", maxUploadSize, maxUploadSize/(1024*1024))) + return + } + respondWithInternalError(c, readErr) + return + } + + fileReader := bytes.NewReader(fileBytes) + client := getClient(c) ctx := context.Background() @@ -212,14 +247,6 @@ func PostObject(c *gin.Context) { return } - // Read body as byte stream - fileReader := c.Request.Body - if fileReader == nil { - respond(c, http.StatusBadRequest, "error", "Empty body") - return - } - defer fileReader.Close() - objectHandle := bucketHandle.Object(objectID) if objectHandle == nil { respondWithInternalError(c, err) @@ -251,11 +278,7 @@ func PostObject(c *gin.Context) { // Generate public URL escapedObject := url.PathEscape(objectID) - url := fmt.Sprintf( - "https://storage.googleapis.com/%s/%s", - schema.BUCKET_PREFIX+bucket, - escapedObject, - ) + url := fmt.Sprintf("https://storage.googleapis.com/%s/%s", schema.BUCKET_PREFIX+bucket, escapedObject) objectInfo := schema.ObjectInfoFromAttrs(attrs, url) respond(c, http.StatusOK, "success", objectInfo) @@ -330,10 +353,28 @@ func ObjectSignedURL(c *gin.Context) { respondWithInternalError(c, err) return } + + headers := append([]string{}, body.Headers...) + // Upload size limits for signed URL uploads. + if strings.EqualFold(body.Method, http.MethodPut) || strings.EqualFold(body.Method, http.MethodPost) { + maxUploadSize := configs.GetEnvMaxUploadSize() + hasContentLengthRange := false + for _, header := range headers { + if strings.HasPrefix(strings.ToLower(header), "x-goog-content-length-range:") { + hasContentLengthRange = true + break + } + } + + if !hasContentLengthRange { + headers = append(headers, fmt.Sprintf("x-goog-content-length-range:0,%d", maxUploadSize)) + } + } + opts := &storage.SignedURLOptions{ Scheme: storage.SigningSchemeV4, Method: body.Method, - Headers: body.Headers, + Headers: headers, Expires: expirationTime, } diff --git a/api/controllers/trends.go b/api/controllers/trends.go index 6857a8bb..fcb535c0 100644 --- a/api/controllers/trends.go +++ b/api/controllers/trends.go @@ -39,11 +39,26 @@ func TrendsProfessorSectionSearch(c *gin.Context) { trendsSectionSearch("Professor", c) } +// @Id trendsCombinedSectionSearch +// @Router /combined/sections/trends [get] +// @Tags Combined +// @Description "Returns sections matching both course and professor criteria from the combined trends collection. Specialized high-speed convenience endpoint for UTD Trends internal use; limited query flexibility." +// @Produce json +// @Param course_number query string true "The course's official number" +// @Param subject_prefix query string true "The course's subject prefix" +// @Param first_name query string true "The professor's first name" +// @Param last_name query string true "The professor's last name" +// @Success 200 {object} schema.APIResponse[[]schema.Section] "A list of Sections" +// @Failure 500 {object} schema.APIResponse[string] "A string describing the error" +func TrendsCombinedSectionSearch(c *gin.Context) { + trendsSectionSearch("Combined", c) +} + // trendsSectionSearch handles trends-based section routes for both course and professor query. // Reduce the repetitiveness of routes whose aggregation behaviors are identical. // This is subject to change as requests might be more complex. func trendsSectionSearch(flag string, c *gin.Context) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() var detailedSections []schema.Section @@ -62,10 +77,19 @@ func trendsSectionSearch(flag string, c *gin.Context) { } case "Professor": trendsCollection = configs.GetCollection("trends_prof_sections") - trendsQuery, err = schema.FilterQuery[schema.Professor](c) + trendsQuery, err = schema.FilterQuery[schema.Professor](c.Request.URL.Query()) if err != nil { return } + case "Combined": + trendsCollection = configs.GetCollection("trends_course_and_prof_sections") + trendsQuery = bson.M{ + "_id": bson.M{ + "course": c.Query("subject_prefix") + c.Query("course_number"), + "prof_first": c.Query("first_name"), + "prof_last": c.Query("last_name"), + }, + } default: // This should never happen, but act as a fallback err = fmt.Errorf("invalid flag for trendsSectionSearch: %s", flag) diff --git a/api/docs/docs.go b/api/docs/docs.go index 0132c09e..ba940344 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -399,6 +399,62 @@ const docTemplate = `{ } } }, + "/combined/sections/trends": { + "get": { + "description": "\"Returns sections matching both course and professor criteria from the combined trends collection. Specialized high-speed convenience endpoint for UTD Trends internal use; limited query flexibility.\"", + "produces": [ + "application/json" + ], + "tags": [ + "Combined" + ], + "operationId": "trendsCombinedSectionSearch", + "parameters": [ + { + "type": "string", + "description": "The course's official number", + "name": "course_number", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "The course's subject prefix", + "name": "subject_prefix", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "The professor's first name", + "name": "first_name", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "The professor's last name", + "name": "last_name", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "A list of Sections", + "schema": { + "$ref": "#/definitions/schema.APIResponse-array_schema_Section" + } + }, + "500": { + "description": "A string describing the error", + "schema": { + "$ref": "#/definitions/schema.APIResponse-string" + } + } + } + } + }, "/course": { "get": { "description": "\"Returns paginated list of courses matching the query's string-typed key-value pairs. See offset for more details on pagination.\"", @@ -975,7 +1031,7 @@ const docTemplate = `{ }, "/discountPrograms": { "get": { - "description": "\"Returns paginated list of discounts matching the query's string-typed key-value pairs. See offset for more details on pagination.\"", + "description": "\"Returns list of discounts filtered using field-specific keyword searches or full-text search.\"", "produces": [ "application/json" ], @@ -984,15 +1040,9 @@ const docTemplate = `{ ], "operationId": "discountPrograms", "parameters": [ - { - "type": "number", - "description": "The starting position of the current page of discounts (e.g. For starting at the 17th discount, offset=16).", - "name": "offset", - "in": "query" - }, { "type": "string", - "description": "The discount's category.", + "description": "The discount's category (exact match with suggestions).", "name": "category", "in": "query" }, @@ -1016,7 +1066,7 @@ const docTemplate = `{ }, { "type": "string", - "description": "Full text search of all discount's fields.", + "description": "Full-text search, must be used alone.", "name": "q", "in": "query" } @@ -1043,6 +1093,112 @@ const docTemplate = `{ } } }, + "/email/queue": { + "post": { + "description": "\"Queue an email to be sent via SMTP. Multi-recipient emails will be queued as separate emails to avoid bypassing queueing system. This route is restricted to only Nebula Labs internal Projects.\"", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Internal" + ], + "operationId": "QueueEmail", + "parameters": [ + { + "description": "Email Request Body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.EmailRequest" + } + }, + { + "type": "string", + "description": "The internal email queue key", + "name": "x-email-queue-key", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "The list of queued task names", + "schema": { + "$ref": "#/definitions/schema.APIResponse-array_string" + } + }, + "400": { + "description": "A string describing the error", + "schema": { + "$ref": "#/definitions/schema.APIResponse-string" + } + }, + "500": { + "description": "A string describing the error", + "schema": { + "$ref": "#/definitions/schema.APIResponse-string" + } + } + } + } + }, + "/email/send": { + "post": { + "description": "\"Send an email via SMTP. This route is restricted to only Nebula Labs internal Projects.\"", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Internal" + ], + "operationId": "sendEmail", + "parameters": [ + { + "description": "Email Request Body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.EmailRequest" + } + }, + { + "type": "string", + "description": "The internal email send key", + "name": "x-email-send-key", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "Email Request Body", + "schema": { + "$ref": "#/definitions/schema.APIResponse-schema_EmailRequest" + } + }, + "400": { + "description": "A string describing the error", + "schema": { + "$ref": "#/definitions/schema.APIResponse-string" + } + }, + "500": { + "description": "A string describing the error", + "schema": { + "$ref": "#/definitions/schema.APIResponse-string" + } + } + } + } + }, "/events/{date}": { "get": { "description": "\"Returns all sections with meetings on the specified date\"", @@ -1463,6 +1619,109 @@ const docTemplate = `{ } } }, + "/mazevo/{date}/{building}": { + "get": { + "description": "\"Returns all sections with MazevoEvent meetings on the specified date in the specified building\"", + "produces": [ + "application/json" + ], + "tags": [ + "Events" + ], + "operationId": "mazevoEventsByBuilding", + "parameters": [ + { + "type": "string", + "description": "date (ISO format) to retrieve mazevo events", + "name": "date", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "building abbreviation of the event location", + "name": "building", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "All MazevoEvents sections with meetings on the specified date in the specified building", + "schema": { + "$ref": "#/definitions/schema.APIResponse-schema_MultiBuildingEvents-schema_MazevoEvent" + } + }, + "404": { + "description": "A string describing the error", + "schema": { + "$ref": "#/definitions/schema.APIResponse-string" + } + }, + "500": { + "description": "A string describing the error", + "schema": { + "$ref": "#/definitions/schema.APIResponse-string" + } + } + } + } + }, + "/mazevo/{date}/{building}/{room}": { + "get": { + "description": "\"Returns all sections with MazevoEvent meetings on the specified date in the specified building and room\"", + "produces": [ + "application/json" + ], + "tags": [ + "Events" + ], + "operationId": "mazevoEventsByRoom", + "parameters": [ + { + "type": "string", + "description": "date (ISO format) to retrieve mazevo events", + "name": "date", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "building abbreviation of the event location", + "name": "building", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "room number", + "name": "room", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "All MazevoEvents sections with meetings on the specified date in the specified building", + "schema": { + "$ref": "#/definitions/schema.APIResponse-schema_MultiBuildingEvents-schema_MazevoEvent" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/schema.APIResponse-string" + } + }, + "500": { + "description": "A string describing the error", + "schema": { + "$ref": "#/definitions/schema.APIResponse-string" + } + } + } + } + }, "/professor": { "get": { "description": "\"Returns paginated list of professors matching the query's string-typed key-value pairs. See offset for more details on pagination.\"", @@ -3342,6 +3601,23 @@ const docTemplate = `{ } } }, + "schema.APIResponse-array_string": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "string" + } + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + } + }, "schema.APIResponse-int": { "type": "object", "properties": { @@ -3398,6 +3674,20 @@ const docTemplate = `{ } } }, + "schema.APIResponse-schema_EmailRequest": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.EmailRequest" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + } + }, "schema.APIResponse-schema_MultiBuildingEvents-schema_AstraEvent": { "type": "object", "properties": { @@ -3946,7 +4236,10 @@ const docTemplate = `{ "type": "string" }, "address": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } }, "business": { "type": "string" @@ -3968,6 +4261,61 @@ const docTemplate = `{ } } }, + "schema.EmailAttachment": { + "type": "object", + "required": [ + "data", + "name" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "integer" + } + }, + "name": { + "type": "string" + } + } + }, + "schema.EmailRequest": { + "type": "object", + "required": [ + "body", + "subject", + "to" + ], + "properties": { + "attachments": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.EmailAttachment" + } + }, + "body": { + "type": "string" + }, + "embeds": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.EmailAttachment" + } + }, + "from": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "to": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "schema.Event": { "type": "object", "properties": { @@ -4617,7 +4965,7 @@ const docTemplate = `{ // SwaggerInfo holds exported Swagger Info so clients can modify it var SwaggerInfo = &swag.Spec{ - Version: "1.2.0", + Version: "1.3.0", Host: "api.utdnebula.com", BasePath: "", Schemes: []string{"https", "http"}, diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 1f004478..ac516237 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -109,6 +109,17 @@ definitions: status: type: integer type: object + schema.APIResponse-array_string: + properties: + data: + items: + type: string + type: array + message: + type: string + status: + type: integer + type: object schema.APIResponse-int: properties: data: @@ -145,6 +156,15 @@ definitions: status: type: integer type: object + schema.APIResponse-schema_EmailRequest: + properties: + data: + $ref: '#/definitions/schema.EmailRequest' + message: + type: string + status: + type: integer + type: object schema.APIResponse-schema_MultiBuildingEvents-schema_AstraEvent: properties: data: @@ -502,7 +522,9 @@ definitions: _id: type: string address: - type: string + items: + type: string + type: array business: type: string category: @@ -516,6 +538,43 @@ definitions: website: type: string type: object + schema.EmailAttachment: + properties: + data: + items: + type: integer + type: array + name: + type: string + required: + - data + - name + type: object + schema.EmailRequest: + properties: + attachments: + items: + $ref: '#/definitions/schema.EmailAttachment' + type: array + body: + type: string + embeds: + items: + $ref: '#/definitions/schema.EmailAttachment' + type: array + from: + type: string + subject: + type: string + to: + items: + type: string + type: array + required: + - body + - subject + - to + type: object schema.Event: properties: _id: @@ -914,7 +973,7 @@ info: contact: {} description: The public Nebula Labs API for access to pertinent UT Dallas data title: nebula-api - version: 1.2.0 + version: 1.3.0 paths: /astra/{date}: get: @@ -1179,6 +1238,46 @@ paths: $ref: '#/definitions/schema.APIResponse-string' tags: - Clubs + /combined/sections/trends: + get: + description: '"Returns sections matching both course and professor criteria + from the combined trends collection. Specialized high-speed convenience endpoint + for UTD Trends internal use; limited query flexibility."' + operationId: trendsCombinedSectionSearch + parameters: + - description: The course's official number + in: query + name: course_number + required: true + type: string + - description: The course's subject prefix + in: query + name: subject_prefix + required: true + type: string + - description: The professor's first name + in: query + name: first_name + required: true + type: string + - description: The professor's last name + in: query + name: last_name + required: true + type: string + produces: + - application/json + responses: + "200": + description: A list of Sections + schema: + $ref: '#/definitions/schema.APIResponse-array_schema_Section' + "500": + description: A string describing the error + schema: + $ref: '#/definitions/schema.APIResponse-string' + tags: + - Combined /course: get: description: '"Returns paginated list of courses matching the query''s string-typed @@ -1575,16 +1674,11 @@ paths: - Courses /discountPrograms: get: - description: '"Returns paginated list of discounts matching the query''s string-typed - key-value pairs. See offset for more details on pagination."' + description: '"Returns list of discounts filtered using field-specific keyword + searches or full-text search."' operationId: discountPrograms parameters: - - description: The starting position of the current page of discounts (e.g. - For starting at the 17th discount, offset=16). - in: query - name: offset - type: number - - description: The discount's category. + - description: The discount's category (exact match with suggestions). in: query name: category type: string @@ -1600,7 +1694,7 @@ paths: in: query name: discount type: string - - description: Full text search of all discount's fields. + - description: Full-text search, must be used alone. in: query name: q type: string @@ -1621,6 +1715,79 @@ paths: $ref: '#/definitions/schema.APIResponse-string' tags: - Discounts + /email/queue: + post: + consumes: + - application/json + description: '"Queue an email to be sent via SMTP. Multi-recipient emails will + be queued as separate emails to avoid bypassing queueing system. This route + is restricted to only Nebula Labs internal Projects."' + operationId: QueueEmail + parameters: + - description: Email Request Body + in: body + name: request + required: true + schema: + $ref: '#/definitions/schema.EmailRequest' + - description: The internal email queue key + in: header + name: x-email-queue-key + required: true + type: string + produces: + - application/json + responses: + "200": + description: The list of queued task names + schema: + $ref: '#/definitions/schema.APIResponse-array_string' + "400": + description: A string describing the error + schema: + $ref: '#/definitions/schema.APIResponse-string' + "500": + description: A string describing the error + schema: + $ref: '#/definitions/schema.APIResponse-string' + tags: + - Internal + /email/send: + post: + consumes: + - application/json + description: '"Send an email via SMTP. This route is restricted to only Nebula + Labs internal Projects."' + operationId: sendEmail + parameters: + - description: Email Request Body + in: body + name: request + required: true + schema: + $ref: '#/definitions/schema.EmailRequest' + - description: The internal email send key + in: header + name: x-email-send-key + required: true + type: string + produces: + - application/json + responses: + "200": + description: Email Request Body + schema: + $ref: '#/definitions/schema.APIResponse-schema_EmailRequest' + "400": + description: A string describing the error + schema: + $ref: '#/definitions/schema.APIResponse-string' + "500": + description: A string describing the error + schema: + $ref: '#/definitions/schema.APIResponse-string' + tags: + - Internal /events/{date}: get: description: '"Returns all sections with meetings on the specified date"' @@ -1907,6 +2074,79 @@ paths: $ref: '#/definitions/schema.APIResponse-string' tags: - Events + /mazevo/{date}/{building}: + get: + description: '"Returns all sections with MazevoEvent meetings on the specified + date in the specified building"' + operationId: mazevoEventsByBuilding + parameters: + - description: date (ISO format) to retrieve mazevo events + in: path + name: date + required: true + type: string + - description: building abbreviation of the event location + in: path + name: building + required: true + type: string + produces: + - application/json + responses: + "200": + description: All MazevoEvents sections with meetings on the specified date + in the specified building + schema: + $ref: '#/definitions/schema.APIResponse-schema_MultiBuildingEvents-schema_MazevoEvent' + "404": + description: A string describing the error + schema: + $ref: '#/definitions/schema.APIResponse-string' + "500": + description: A string describing the error + schema: + $ref: '#/definitions/schema.APIResponse-string' + tags: + - Events + /mazevo/{date}/{building}/{room}: + get: + description: '"Returns all sections with MazevoEvent meetings on the specified + date in the specified building and room"' + operationId: mazevoEventsByRoom + parameters: + - description: date (ISO format) to retrieve mazevo events + in: path + name: date + required: true + type: string + - description: building abbreviation of the event location + in: path + name: building + required: true + type: string + - description: room number + in: path + name: room + required: true + type: string + produces: + - application/json + responses: + "200": + description: All MazevoEvents sections with meetings on the specified date + in the specified building + schema: + $ref: '#/definitions/schema.APIResponse-schema_MultiBuildingEvents-schema_MazevoEvent' + "404": + description: Not Found + schema: + $ref: '#/definitions/schema.APIResponse-string' + "500": + description: A string describing the error + schema: + $ref: '#/definitions/schema.APIResponse-string' + tags: + - Events /professor: get: description: '"Returns paginated list of professors matching the query''s string-typed diff --git a/api/go.mod b/api/go.mod index 56babb68..55357d4b 100644 --- a/api/go.mod +++ b/api/go.mod @@ -1,41 +1,42 @@ module github.com/UTDNebula/nebula-api/api -go 1.24.0 - -toolchain go1.24.2 +go 1.25.0 require ( - cloud.google.com/go/storage v1.51.0 + cloud.google.com/go/storage v1.61.3 github.com/getsentry/sentry-go v0.33.0 github.com/getsentry/sentry-go/gin v0.33.0 github.com/gin-gonic/gin v1.10.1 + github.com/google/go-cmp v0.7.0 github.com/joho/godotenv v1.5.1 github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.1 github.com/swaggo/swag v1.16.6 + github.com/wneessen/go-mail v0.7.2 go.mongodb.org/mongo-driver v1.17.4 - google.golang.org/api v0.224.0 + google.golang.org/api v0.271.0 ) require ( - cel.dev/expr v0.19.2 // indirect - cloud.google.com/go v0.118.3 // indirect - cloud.google.com/go/auth v0.15.0 // indirect - cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect - cloud.google.com/go/compute/metadata v0.6.0 // indirect - cloud.google.com/go/iam v1.4.1 // indirect - cloud.google.com/go/monitoring v1.24.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect + cel.dev/expr v0.25.1 // indirect + cloud.google.com/go v0.123.0 // indirect + cloud.google.com/go/auth v0.18.2 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/iam v1.5.3 // indirect + cloud.google.com/go/monitoring v1.24.3 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 // indirect - github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect - github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect + github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.22.0 // indirect @@ -44,33 +45,35 @@ require ( github.com/go-openapi/swag v0.24.1 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.5 // indirect - github.com/googleapis/gax-go/v2 v2.14.1 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect + github.com/googleapis/gax-go/v2 v2.17.0 // indirect 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/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.34.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect go.opentelemetry.io/otel/sdk v1.40.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect - golang.org/x/oauth2 v0.28.0 // indirect - golang.org/x/time v0.10.0 // indirect - golang.org/x/tools v0.38.0 // indirect - google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect - google.golang.org/grpc v1.71.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.41.0 // indirect + google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/grpc v1.79.3 // indirect ) require ( + cloud.google.com/go/cloudtasks v1.13.7 github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.14.1 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect @@ -108,12 +111,12 @@ require ( github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect golang.org/x/arch v0.21.0 // indirect - golang.org/x/crypto v0.45.0 // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.31.0 // indirect - google.golang.org/protobuf v1.36.8 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/api/go.sum b/api/go.sum index 35aad764..badc5e4e 100644 --- a/api/go.sum +++ b/api/go.sum @@ -1,33 +1,35 @@ -cel.dev/expr v0.19.2 h1:V354PbqIXr9IQdwy4SYA4xa0HXaWq1BUPAGzugBY5V4= -cel.dev/expr v0.19.2/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= -cloud.google.com/go v0.118.3 h1:jsypSnrE/w4mJysioGdMBg4MiW/hHx/sArFpaBWHdME= -cloud.google.com/go v0.118.3/go.mod h1:Lhs3YLnBlwJ4KA6nuObNMZ/fCbOQBPuWKPoE0Wa/9Vc= -cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps= -cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8= -cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M= -cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc= -cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= -cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= -cloud.google.com/go/iam v1.4.1 h1:cFC25Nv+u5BkTR/BT1tXdoF2daiVbZ1RLx2eqfQ9RMM= -cloud.google.com/go/iam v1.4.1/go.mod h1:2vUEJpUG3Q9p2UdsyksaKpDzlwOrnMzS30isdReIcLM= -cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc= -cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA= -cloud.google.com/go/longrunning v0.6.5 h1:sD+t8DO8j4HKW4QfouCklg7ZC1qC4uzVZt8iz3uTW+Q= -cloud.google.com/go/longrunning v0.6.5/go.mod h1:Et04XK+0TTLKa5IPYryKf5DkpwImy6TluQ1QTLwlKmY= -cloud.google.com/go/monitoring v1.24.0 h1:csSKiCJ+WVRgNkRzzz3BPoGjFhjPY23ZTcaenToJxMM= -cloud.google.com/go/monitoring v1.24.0/go.mod h1:Bd1PRK5bmQBQNnuGwHBfUamAV1ys9049oEPHnn4pcsc= -cloud.google.com/go/storage v1.51.0 h1:ZVZ11zCiD7b3k+cH5lQs/qcNaoSz3U9I0jgwVzqDlCw= -cloud.google.com/go/storage v1.51.0/go.mod h1:YEJfu/Ki3i5oHC/7jyTgsGZwdQ8P9hqMqvpi5kRKGgc= -cloud.google.com/go/trace v1.11.3 h1:c+I4YFjxRQjvAhRmSsmjpASUKq88chOX854ied0K/pE= -cloud.google.com/go/trace v1.11.3/go.mod h1:pt7zCYiDSQjC9Y2oqCsh9jF4GStB/hmjrYLsxRR27q8= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 h1:3c8yed4lgqTt+oTQ+JNMDo+F4xprBf+O/il4ZC0nRLw= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0/go.mod h1:obipzmGjfSjam60XLwGfqUkJsfiheAl+TUjG+4yzyPM= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0 h1:OqVGm6Ei3x5+yZmSJG1Mh2NwHvpVmZ08CB5qJhT9Nuk= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0/go.mod h1:SZiPHWGOOk3bl8tkevxkoiwPgsIl6CwrWcbwjfHZpdM= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= +cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/cloudtasks v1.13.7 h1:H2v8GEolNtMFfYzUpZBaZbydqU7drpyo99GtAgA+m4I= +cloud.google.com/go/cloudtasks v1.13.7/go.mod h1:H0TThOUG+Ml34e2+ZtW6k6nt4i9KuH3nYAJ5mxh7OM4= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= +cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY= +cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw= +cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= +cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= +cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= +cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= +cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KMOg= +cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk= +cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= +cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= @@ -40,19 +42,20 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 h1:Om6kYQYDUk5wWbT0t0q6pvyM49i9XZAv9dDrkDA7gjk= -github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= -github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= -github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= -github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= -github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= -github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= @@ -69,6 +72,8 @@ github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -127,10 +132,10 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.5 h1:VgzTY2jogw3xt39CusEnFJWm7rlsq5yL5q9XdLOuP5g= -github.com/googleapis/enterprise-certificate-proxy v0.3.5/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= -github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= -github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= +github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= +github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= +github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -174,10 +179,13 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -198,6 +206,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8= +github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= @@ -211,16 +221,16 @@ go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFX go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/detectors/gcp v1.34.0 h1:JRxssobiPg23otYU5SbWtQC//snGVIM3Tx6QRzlQBao= -go.opentelemetry.io/contrib/detectors/gcp v1.34.0/go.mod h1:cV4BMFcscUR/ckqLkbfQmF0PRsq8w/lMGzdbCSveBHo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0 h1:WDdP9acbMYjbKIyJUhTvtzj601sVJOqgWdUxSdR/Ysc= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0/go.mod h1:BLbf7zbNIONBLPwvFnwNHGj4zge8uTCM/UPIVW1Mq2I= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk= go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= @@ -235,23 +245,23 @@ golang.org/x/arch v0.21.0 h1:iTC9o7+wP6cPWpDWkivCvQFGAHDQ59SrSxsLPcnkArw= golang.org/x/arch v0.21.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= -golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -259,8 +269,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -269,28 +279,30 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= -golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.224.0 h1:Ir4UPtDsNiwIOHdExr3fAj4xZ42QjK7uQte3lORLJwU= -google.golang.org/api v0.224.0/go.mod h1:3V39my2xAGkodXy0vEqcEtkqgw2GtrFL5WuBZlCTCOQ= -google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE= -google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE= -google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= -google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb h1:TLPQVbx1GJ8VKZxz52VAxl1EBgKXXbTiU9Fc5fZeLn4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= -google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= -google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= -google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= -google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.271.0 h1:cIPN4qcUc61jlh7oXu6pwOQqbJW2GqYh5PS6rB2C/JY= +google.golang.org/api v0.271.0/go.mod h1:CGT29bhwkbF+i11qkRUJb2KMKqcJ1hdFceEIRd9u64Q= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= +google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 h1:7ei4lp52gK1uSejlA8AZl5AJjeLUOHBQscRQZUgAcu0= +google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20/go.mod h1:ZdbssH/1SOVnjnDlXzxDHK2MCidiqXtbYccJNzNYPEE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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= diff --git a/api/max_upload_size_test.go b/api/max_upload_size_test.go new file mode 100644 index 00000000..f971511f --- /dev/null +++ b/api/max_upload_size_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "cloud.google.com/go/storage" + "github.com/UTDNebula/nebula-api/api/controllers" + "github.com/gin-gonic/gin" +) + +func TestMaxUploadSize(t *testing.T) { + // Set the environment variable for max upload size (e.g., 100 bytes) + os.Setenv("MAX_UPLOAD_SIZE", "100") + defer os.Unsetenv("MAX_UPLOAD_SIZE") + + // Setup Gin + gin.SetMode(gin.TestMode) + router := gin.New() + + router.POST("/storage/:bucket/:objectID", func(c *gin.Context) { + + c.Set("gcsClient", &storage.Client{}) + controllers.PostObject(c) + }) + + t.Run("Upload within limit", func(t *testing.T) { + + defer func() { + if r := recover(); r != nil { + + } + }() + + data := make([]byte, 50) + req, _ := http.NewRequest("POST", "/storage/test-bucket/small-file", bytes.NewBuffer(data)) + req.Header.Set("Content-Type", "application/octet-stream") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code == http.StatusRequestEntityTooLarge { + t.Errorf("Expected status NOT 413, got %d", w.Code) + } + }) + + t.Run("Upload exceeding limit via Content-Length", func(t *testing.T) { + data := make([]byte, 150) + req, _ := http.NewRequest("POST", "/storage/test-bucket/large-file", bytes.NewBuffer(data)) + req.Header.Set("Content-Type", "application/octet-stream") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusRequestEntityTooLarge { + t.Errorf("Expected status 413, got %d. Body: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "File too large") { + t.Errorf("Expected error message 'File too large', got %s", w.Body.String()) + } + }) + + t.Run("Upload exceeding limit via Stream", func(t *testing.T) { + pr, pw := io.Pipe() + go func() { + pw.Write(make([]byte, 150)) + pw.Close() + }() + + req, _ := http.NewRequest("POST", "/storage/test-bucket/stream-file", pr) + req.ContentLength = -1 + req.Header.Set("Content-Type", "application/octet-stream") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusRequestEntityTooLarge { + t.Errorf("Expected status 413, got %d. Body: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "File too large") { + t.Errorf("Expected error message 'File too large', got %s", w.Body.String()) + } + }) +} diff --git a/api/routes/combined.go b/api/routes/combined.go new file mode 100644 index 00000000..710c7834 --- /dev/null +++ b/api/routes/combined.go @@ -0,0 +1,12 @@ +package routes + +import ( + "github.com/UTDNebula/nebula-api/api/controllers" + "github.com/gin-gonic/gin" +) + +func CombinedRoute(router *gin.Engine) { + combinedGroup := router.Group("/combined") + combinedGroup.OPTIONS("", controllers.Preflight) + combinedGroup.GET("/sections/trends", controllers.TrendsCombinedSectionSearch) +} diff --git a/api/routes/email.go b/api/routes/email.go new file mode 100644 index 00000000..1c4e2db0 --- /dev/null +++ b/api/routes/email.go @@ -0,0 +1,122 @@ +package routes + +import ( + "context" + "log" + "net/http" + "os" + "sync" + + cloudtasks "cloud.google.com/go/cloudtasks/apiv2" + "github.com/gin-gonic/gin" + "github.com/wneessen/go-mail" + + "github.com/UTDNebula/nebula-api/api/controllers" + "github.com/UTDNebula/nebula-api/api/schema" +) + +var emailClient *mail.Client +var emailClientOnce sync.Once + +var tasksClient *cloudtasks.Client +var tasksClientOnce sync.Once + +func initTasksClient() *cloudtasks.Client { + // Singleton to prevent multiple clients + tasksClientOnce.Do(func() { + + // Checking if env variables are set to know whether or not to skip the route + qPath := os.Getenv("GCLOUD_EMAIL_QUEUE_PATH") + qUrl := os.Getenv("GCLOUD_EMAIL_QUEUE_URL") + + if qPath == "" || qUrl == "" { + log.Println("Cloud Tasks environment variables are not fully configured; skipping email queuing routes") + return + } + + ctx := context.Background() + c, err := cloudtasks.NewClient(ctx) + if err != nil { + log.Printf("Failed to create Cloud Tasks client: %v", err) + return + } + tasksClient = c + }) + return tasksClient +} + +func initEmailClient() *mail.Client { + // Singleton to prevent multiple clients + emailClientOnce.Do(func() { + + // Checking if env variables are set to know whether or not to skip the route + smtpHost := os.Getenv("SMTP_HOST") + smtpUser := os.Getenv("SMTP_USERNAME") + smtpPass := os.Getenv("SMTP_PASSWORD") + sendKey := os.Getenv("EMAIL_SEND_ROUTE_KEY") + + if smtpHost == "" || smtpUser == "" || smtpPass == "" || sendKey == "" { + log.Println("SMTP environment variables are not fully configured; skipping email routes") + return + } + + c, err := mail.NewClient( + smtpHost, + mail.WithTLSPortPolicy(mail.TLSMandatory), + mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover), + mail.WithUsername(smtpUser), + mail.WithPassword(smtpPass), + ) + if err != nil { + log.Printf("Failed to create SMTP client: %v", err) + return + } + emailClient = c + }) + return emailClient +} + +func EmailRoute(router *gin.Engine) { + eClient := initEmailClient() + tClient := initTasksClient() + + if eClient == nil { + log.Println("SMTP client not initialized") + } + + if tClient == nil { + log.Println("Cloud Tasks client not initialized") + } + + if eClient == nil || tClient == nil { + log.Println("skipping email routes") + return + } + + // Restrict with password + authMiddleware := func(key string, envKey string) gin.HandlerFunc { + return func(c *gin.Context) { + secret := c.GetHeader(key) + expected, exist := os.LookupEnv(envKey) + if !exist || secret != expected { + c.AbortWithStatusJSON(http.StatusForbidden, schema.APIResponse[string]{Status: http.StatusForbidden, Message: "error", Data: "Forbidden"}) + return + } + c.Next() + } + } + + // All routes related to email come here + emailGroup := router.Group("/email") + + // Pass to next layer + emailGroup.Use(func(c *gin.Context) { + c.Set("emailClient", eClient) + c.Set("tasksClient", tClient) + c.Next() + }) + + emailGroup.OPTIONS("", controllers.Preflight) + emailGroup.POST("/send", authMiddleware("x-email-send-key", "EMAIL_SEND_ROUTE_KEY"), controllers.SendEmail) + emailGroup.POST("/queue", authMiddleware("x-email-queue-key", "EMAIL_QUEUE_ROUTE_KEY"), controllers.QueueEmail) +} diff --git a/api/routes/mazevo.go b/api/routes/mazevo.go index ef401481..3fe61ac9 100644 --- a/api/routes/mazevo.go +++ b/api/routes/mazevo.go @@ -11,4 +11,6 @@ func MazevoRoute(router *gin.Engine) { mazevoGroup.OPTIONS("", controllers.Preflight) mazevoGroup.GET(":date", controllers.MazevoEvents) + mazevoGroup.GET(":date/:building", controllers.MazevoEventsByBuilding) + mazevoGroup.GET(":date/:building/:room", controllers.MazevoEventsByRoom) } diff --git a/api/routes/storage.go b/api/routes/storage.go index b7088f29..2bfcb0fa 100644 --- a/api/routes/storage.go +++ b/api/routes/storage.go @@ -40,10 +40,11 @@ func initStorageClient() *storage.Client { log.Println("Error loading 'GOOGLE_APPLICATION_CREDENTIALS' from the .env file, skipping cloud storage routes") return } - c, err = storage.NewClient(ctx, option.WithCredentialsJSON([]byte(encodedCreds))) + jsonCreds := []byte(encodedCreds) + c, err = storage.NewClient(ctx, option.WithAuthCredentialsJSON(option.ServiceAccount, jsonCreds)) } if err != nil { - log.Printf("Failed to create GCS client: %v", err) + log.Printf("Error initializing GCS client: %v", err) return } client = c diff --git a/api/schema/budgets.go b/api/schema/budgets.go new file mode 100644 index 00000000..a677286e --- /dev/null +++ b/api/schema/budgets.go @@ -0,0 +1,65 @@ +package schema + +// Must also be updated in api-tools/parser/budgets.go + +// Table utils +type Table[T any] struct { + Name string `bson:"name" json:"name"` + Rows []Row[T] `bson:"rows" json:"rows"` + Total T `bson:"total" json:"total"` +} +type Table2[T any] struct { + Name string `bson:"name" json:"name"` + Rows []Table[T] `bson:"rows" json:"rows"` + Total T `bson:"total" json:"total"` +} +type Row[T any] struct { + Label string `bson:"label" json:"label"` + Value T `bson:"value" json:"value"` +} + +// Top level container for both the planned budget and the actual annual financial report +type Budget struct { + Id string `bson:"_id" json:"_id"` + OperatingBudget *OperatingBudget `bson:"operating_budget" json:"operating_budget"` + AnnualFinancialReport *AnnualFinancialReport `bson:"annual_financial_report" json:"annual_financial_report"` + Notes string `bson:"notes" json:"notes"` +} + +// Operating Budget Structs +type OperatingBudget struct { + OperatingRevenues Table[float64] `bson:"operating_revenues" json:"operating_revenues"` + OperatingExpenses Table[float64] `bson:"operating_expenses" json:"operating_expenses"` + BudgetedNonoperatingRevenues Table[float64] `bson:"budgeted_nonoperating_revenues" json:"budgeted_nonoperating_revenues"` + SalariesDoeAndInstructionalAdmin Table[SalariesDoeAndInstructionalAdminValues] `bson:"salaries_doe_and_instructional_admin" json:"salaries_doe_and_instructional_admin"` + ServiceDepartmentsFunds Table2[FundsValues] `bson:"service_departments_funds" json:"service_departments_funds"` + DesignatedFunds Table2[FundsValues] `bson:"designated_funds" json:"designated_funds"` + BudgetedTuitionAndStudentFees Table2[float64] `bson:"budgeted_tuition_and_student_fees" json:"budgeted_tuition_and_student_fees"` + AuxiliaryExpenses Table2[AuxiliaryExpensesValues] `bson:"auxiliary_expenses" json:"auxiliary_expenses"` + RestrictedFunds Table2[FundsValues] `bson:"restricted_funds" json:"restricted_funds"` +} +type SalariesDoeAndInstructionalAdminValues struct { + Total float64 `bson:"total" json:"total"` + FacultySalaries float64 `bson:"faculty_salaries" json:"faculty_salaries"` + DepartmentalOperatingExpenses float64 `bson:"departmental_operating_expenses" json:"departmental_operating_expenses"` + InstructionalAdministration float64 `bson:"instructional_administration" json:"instructional_administration"` +} +type FundsValues struct { + EstimatedIncome float64 `bson:"estimated_income" json:"estimated_income"` + BudgetedExpenses float64 `bson:"budgeted_expenses" json:"budgeted_expenses"` +} +type AuxiliaryExpensesValues struct { + EstimatedIncome float64 `bson:"estimated_income" json:"estimated_income"` + BudgetedExpenses float64 `bson:"budgeted_expenses" json:"budgeted_expenses"` + DebtService float64 `bson:"debt_service" json:"debt_service"` + Other float64 `bson:"other" json:"other"` +} + +// Annual Financial Report (AFR) Structs +type AnnualFinancialReport struct { + OperatingRevenues Table[float64] `bson:"operating_revenues" json:"operating_revenues"` + OperatingExpenses Table[float64] `bson:"operating_expenses" json:"operating_expenses"` + NonoperatingRevenues Table[float64] `bson:"nonoperating_revenues" json:"nonoperating_revenues"` + BeginningNetPosition float64 `bson:"beginning_net_position" json:"beginning_net_position"` + EndingNetPosition float64 `bson:"ending_net_position" json:"ending_net_position"` +} diff --git a/api/schema/filter.go b/api/schema/filter.go index 21df52c3..bfc21b69 100644 --- a/api/schema/filter.go +++ b/api/schema/filter.go @@ -1,37 +1,163 @@ package schema import ( - "github.com/gin-gonic/gin" + "fmt" + "net/url" + "reflect" + "strings" + "sync" + "time" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +var ( + queryableCache sync.Map + baseStruct = map[reflect.Type]bool{ + reflect.TypeFor[time.Time](): true, + reflect.TypeFor[primitive.ObjectID](): true, + } + ignoredParameters = map[string]bool{ + "offset": true, + } ) -func FilterQuery[F any](c *gin.Context) (bson.M, error) { +// FilterQuery converts URL query parameters into a MongoDB BSON query filter. +// +// It validates that each query parameter corresponds to a field in type F that is +// marked as queryable. +// +// Returns an error if: +// - A query parameter key is not defined in the struct +// - A field exists but is not marked as queryable +func FilterQuery[F any](urlValues url.Values) (bson.M, error) { + queryable, err := loadQueryable(reflect.TypeFor[F]()) + if err != nil { + return nil, err + } - // Placeholder until filtering is fixed query := bson.M{} - for k := range c.Request.URL.Query() { - query[k] = c.Query(k) - } - - /* - src := c.Request.URL.Query() - dst := make(url.Values) - filter := new(F) - // decode in bson dst - if err := decoder.Decode(filter, src); err != nil { - return nil, err - } - if err := encoder.Encode(filter, dst); err != nil { - return nil, err - } - query := make(bson.M) - // merge dst into bson.M - for k, v := range src { - if dst.Has(k) { - query[k] = v[0] - } + for key, values := range urlValues { + if _, ok := ignoredParameters[key]; ok { + continue + } + + allowed, exists := queryable[key] + if !exists { + return nil, fmt.Errorf("unknown query parameter '%s'", key) + } + if !allowed { + return nil, fmt.Errorf("field '%s' cannot be used for filtering", key) + } + + if len(values) == 0 { + query[key] = "" + } else { + query[key] = values[0] } - */ + } return query, nil } + +// loadQueryable returns a map indicating which fields of the given type are queryable. +func loadQueryable(t reflect.Type) (map[string]bool, error) { + if cached, ok := queryableCache.Load(t); ok { + //should literally never fail but its best practice to check casts + if queryMap, ok := cached.(map[string]bool); ok { + return queryMap, nil + } + queryableCache.Delete(t) + return nil, fmt.Errorf("queryableCache was corrupted: %s was not of type map[string]bool", t.String()) + } + + queryable := make(map[string]bool) + if err := recBuild(t, "", queryable, make([]reflect.Type, 0)); err != nil { + return nil, err + } + + actual, _ := queryableCache.LoadOrStore(t, queryable) + if queryMap, ok := actual.(map[string]bool); ok { + return queryMap, nil + } + queryableCache.Delete(t) + return nil, fmt.Errorf("queryableCache was corrupted: %s was not of type map[string]bool", t.String()) +} + +// recBuild recursively traverses a struct type to build a map of queryable fields. +// +// It constructs dot-notation paths for nested fields and determines whether each field +// can be used for filtering based on the "queryable" tag. +func recBuild(t reflect.Type, prefix string, queryableMap map[string]bool, visited []reflect.Type) error { + if willCreateLoop(visited, t) { + return nil + } + + newVisited := append(visited, t) + t = drillType(t) + + if t.Kind() != reflect.Struct { + return nil + } + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + json, hasJson := field.Tag.Lookup("json") + if !hasJson { + return fmt.Errorf("exported field '%s.%s' missing json tag", t.Name(), field.Name) + } else if json == "-" { + continue + } + + // Determine the JSON path + fullPath := strings.Split(json, ",")[0] + if prefix != "" { + fullPath = prefix + "." + fullPath + } + + fieldType := drillType(field.Type) + _, queryable := field.Tag.Lookup("queryable") + if fieldType.Kind() == reflect.Struct { + if queryable { + // don't recurse into time.Time + if _, ok := baseStruct[fieldType]; ok { + queryableMap[fullPath] = true + } else if err := recBuild(field.Type, fullPath, queryableMap, newVisited); err != nil { + return err + } + } else { + queryableMap[fullPath] = false + } + } else { + queryableMap[fullPath] = queryable + } + } + return nil +} + +// willCreateLoop determines if adding `value` to the `visited` list would create +// a loop. +// +// Pointer types are allowed to recur once, i.e., only considered a loop when +// the same pointer type appears twice in a row. +func willCreateLoop(visited []reflect.Type, value reflect.Type) bool { + if value.Kind() != reflect.Ptr { + return false + } + + for i := len(visited) - 1; i > 0; i-- { + if visited[i] == value { + return visited[i-1] == visited[len(visited)-1] + } + } + return false +} + +// drillType gets the base of a type, removing pointers and slices/arrays +func drillType(t reflect.Type) reflect.Type { + for t.Kind() == reflect.Ptr || t.Kind() == reflect.Slice || t.Kind() == reflect.Array { + t = t.Elem() + } + return t +} diff --git a/api/schema/filter_test.go b/api/schema/filter_test.go new file mode 100644 index 00000000..fbfc21f3 --- /dev/null +++ b/api/schema/filter_test.go @@ -0,0 +1,376 @@ +package schema + +import ( + "net/url" + "reflect" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "go.mongodb.org/mongo-driver/bson" +) + +type _normal struct { + Name string `bson:"name" json:"name" queryable:""` + Number int `bson:"number" json:"number" queryable:""` + Hidden bool `bson:"hidden" json:"hidden"` +} + +type _normalWithBaseStruct struct { + Name string `bson:"name" json:"name" queryable:""` + Time time.Time `bson:"time" json:"time" queryable:""` +} + +type _missingJson struct { + Name string + Number int + Hidden bool +} + +type _missingQueryable struct { + Name string `bson:"name" json:"name"` + Number int `bson:"number" json:"number"` + Hidden bool `bson:"hidden" json:"hidden"` +} + +type _nested struct { + Name string `bson:"name" json:"name" queryable:""` + Number int `bson:"number" json:"number" queryable:""` + Hidden bool `bson:"hidden" json:"hidden"` + Nested _normal `bson:"nested" json:"nested" queryable:""` +} + +type _nestedAnonymous struct { + Name string `bson:"name" json:"name" queryable:""` + Number int `bson:"number" json:"number" queryable:""` + Hidden bool `bson:"hidden" json:"hidden"` + Nested struct { + Normal _normal `bson:"normal" json:"normal" queryable:""` + NormalHidden _normal `bson:"nested_hidden" json:"nested_hidden"` + } `bson:"nested" json:"nested" queryable:""` + NestedHidden struct { + } `bson:"nested" json:"nested_hidden"` +} + +type _nestedEmbedded struct { + _normal `bson:",inline" json:",inline" queryable:""` +} + +type _nestedPointer struct { + Name string `bson:"name" json:"name" queryable:""` + Number int `bson:"number" json:"number" queryable:""` + Hidden bool `bson:"hidden" json:"hidden"` + Nested *_normal `bson:"nested" json:"nested" queryable:""` +} + +type _nestedDoublePointer struct { + Name string `bson:"name" json:"name" queryable:""` + Number int `bson:"number" json:"number" queryable:""` + Hidden bool `bson:"hidden" json:"hidden"` + Nested *_nestedPointer `bson:"nested" json:"nested" queryable:""` +} + +type _nestedInfinite struct { + Name string `bson:"name" json:"name" queryable:""` + Number int `bson:"number" json:"number" queryable:""` + Hidden bool `bson:"hidden" json:"hidden"` + Nested *_nestedInfinite `bson:"nested" json:"nested" queryable:""` +} + +type _slice struct { + Names []string `bson:"names" json:"names" queryable:""` +} + +type _array struct { + Names [12]string `bson:"names" json:"names" queryable:""` +} + +type _slicePointer struct { + Names []*string `bson:"names" json:"names" queryable:""` +} + +type _sliceDoublePointer struct { + Names *[]*string `bson:"names" json:"names" queryable:""` +} + +type _jsonExcluded struct { + Name string `bson:"name" json:"name" queryable:""` + Number int `bson:"-" json:"-"` + Hidden bool `bson:"hidden" json:"hidden"` +} + +func TestFilterQuery(t *testing.T) { + + testCases := map[string]struct { + Function func(values url.Values) (bson.M, error) + UrlQuery map[string][]string + Fail bool + Expected bson.M + }{ + "Normal": { + Function: FilterQuery[_normal], + UrlQuery: map[string][]string{ + "name": {"bob"}, + "number": {"0"}, + }, + Expected: bson.M{ + "name": "bob", + "number": "0", + }, + }, + "Normal with Base Struct": { + Function: FilterQuery[_normalWithBaseStruct], + UrlQuery: map[string][]string{ + "name": {"bob"}, + "time": {"2020-01-01T00:00:00Z"}, + }, + Expected: bson.M{ + "name": "bob", + "time": "2020-01-01T00:00:00Z", + }, + }, + "Nested": { + Function: FilterQuery[_nested], + UrlQuery: map[string][]string{ + "name": {"bob"}, + "number": {"0"}, + "nested.name": {"bob"}, + "nested.number": {"0"}, + }, + Expected: bson.M{ + "name": "bob", + "number": "0", + "nested.name": "bob", + "nested.number": "0", + }, + }, + "Multiple values": { + Function: FilterQuery[_nested], + UrlQuery: map[string][]string{ + "name": {"first", "true"}, + }, + Expected: bson.M{ + "name": "first", + }, + }, + "Fail empty parameter": { + Function: FilterQuery[_nested], + UrlQuery: map[string][]string{ + "": {"false"}, + }, + Fail: true, + }, + "Fail field cannot be queried": { + Function: FilterQuery[_nested], + UrlQuery: map[string][]string{ + "hidden": {"false"}, + }, + Fail: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + result, err := tc.Function(tc.UrlQuery) + if tc.Fail { + if err == nil { + t.Errorf("expected error but got nil") + } + } else { + if err != nil { + t.Errorf("unexpected error %v ", err) + } + + if diff := cmp.Diff(tc.Expected, result); diff != "" { + t.Errorf("Failed (-expected +got)\n %s", diff) + } + } + }) + } + +} + +func TestLoadQueryable(t *testing.T) { + + testcases := map[string]struct { + Type reflect.Type + Expected map[string]bool + Fail bool + }{ + "Normal": { + Type: reflect.TypeFor[_normal](), + Expected: map[string]bool{ + "name": true, + "number": true, + "hidden": false, + }, + }, + "Missing Json": { + Type: reflect.TypeFor[_missingJson](), + Fail: true, + }, + "Missing Queryable": { + Type: reflect.TypeFor[_missingQueryable](), + Expected: map[string]bool{ + "name": false, + "number": false, + "hidden": false, + }, + }, + "Nested": { + Type: reflect.TypeFor[_nested](), + Expected: map[string]bool{ + "name": true, + "number": true, + "hidden": false, + "nested.name": true, + "nested.number": true, + "nested.hidden": false, + }, + }, + "Nested Embedded": { + Type: reflect.TypeFor[_nestedEmbedded](), + Expected: map[string]bool{ + "name": true, + "number": true, + "hidden": false, + }, + }, + "Nested Anonymous": { + Type: reflect.TypeFor[_nestedAnonymous](), + Expected: map[string]bool{ + "name": true, + "number": true, + "hidden": false, + "nested_hidden": false, + "nested.normal.name": true, + "nested.normal.number": true, + "nested.normal.hidden": false, + "nested.nested_hidden": false, + }, + }, + "Nested Pointer": { + Type: reflect.TypeFor[_nestedPointer](), + Expected: map[string]bool{ + "name": true, + "number": true, + "hidden": false, + "nested.name": true, + "nested.number": true, + "nested.hidden": false, + }, + }, + "Nested Double Pointer": { + Type: reflect.TypeFor[_nestedDoublePointer](), + Expected: map[string]bool{ + "name": true, + "number": true, + "hidden": false, + "nested.name": true, + "nested.number": true, + "nested.hidden": false, + "nested.nested.name": true, + "nested.nested.number": true, + "nested.nested.hidden": false, + }, + }, + "Slice": { + Type: reflect.TypeFor[_slice](), + Expected: map[string]bool{ + "names": true, + }, + }, + "Array": { + Type: reflect.TypeFor[_array](), + Expected: map[string]bool{ + "names": true, + }, + }, + "Slice Pointer": { + Type: reflect.TypeFor[_slicePointer](), + Expected: map[string]bool{ + "names": true, + }, + }, + "Slice Double Pointer": { + Type: reflect.TypeFor[_sliceDoublePointer](), + Expected: map[string]bool{ + "names": true, + }, + }, + "Nested Infinite": { + Type: reflect.TypeFor[_nestedInfinite](), + Expected: map[string]bool{ + "name": true, + "number": true, + "hidden": false, + "nested.name": true, + "nested.number": true, + "nested.hidden": false, + "nested.nested.name": true, + "nested.nested.number": true, + "nested.nested.hidden": false, + }, + }, + "Json Excluded": { + Type: reflect.TypeFor[_jsonExcluded](), + Expected: map[string]bool{ + "name": true, + "hidden": false, + }, + }, + } + + for name, tc := range testcases { + t.Run(name, func(t *testing.T) { + result, err := loadQueryable(tc.Type) + + if (err != nil) != tc.Fail { + t.Errorf("loadQueryable() error = %v, fail %v", err, tc.Fail) + return + } + + if diff := cmp.Diff(tc.Expected, result); diff != "" { + t.Errorf("Failed (-expected +got)\n %s", diff) + } + }) + } + + t.Run("Cache Corruption", func(t *testing.T) { + rType := reflect.TypeFor[_normal]() + + t.Run("Corrupted on Load", func(t *testing.T) { + queryableCache.Store(rType, 14) + + _, err := loadQueryable(rType) + if err == nil { + t.Fatal("expected error when cache contains wrong type") + } + + if _, exists := queryableCache.Load(rType); exists { + t.Error("corrupted cache entry should have been deleted") + } + + if _, err = loadQueryable(rType); err != nil { + t.Fatalf("unexpected failure after cache cleared: %v", err) + } + }) + + t.Run("Recovery After Corruption", func(t *testing.T) { + queryableCache.Store(rType, "wrong type") + + if _, err := loadQueryable(reflect.TypeFor[_normal]()); err == nil { + t.Fatal("expected corruption error") + } + + // the first will load, the second will be cached + for range 2 { + if _, err := loadQueryable(reflect.TypeFor[_normal]()); err != nil { + t.Fatalf("should recover after corruption: %v", err) + } + } + + }) + }) + +} diff --git a/api/schema/objects.go b/api/schema/objects.go index 27fa58f5..791681f9 100644 --- a/api/schema/objects.go +++ b/api/schema/objects.go @@ -13,7 +13,7 @@ type Course struct { Subject_prefix string `bson:"subject_prefix" json:"subject_prefix" queryable:""` Course_number string `bson:"course_number" json:"course_number" queryable:""` Title string `bson:"title" json:"title" queryable:""` - Description string `bson:"description" json:"description"` + Description string `bson:"description" json:"description" queryable:""` Enrollment_reqs string `bson:"enrollment_reqs" json:"enrollment_reqs"` School string `bson:"school" json:"school" queryable:""` Credit_hours string `bson:"credit_hours" json:"credit_hours" queryable:""` @@ -44,32 +44,32 @@ type BasicCourse struct { } type AcademicSession struct { - Name string `bson:"name" json:"name"` - Start_date time.Time `bson:"start_date" json:"start_date"` - End_date time.Time `bson:"end_date" json:"end_date"` + Name string `bson:"name" json:"name" queryable:""` + Start_date time.Time `bson:"start_date" json:"start_date" queryable:""` + End_date time.Time `bson:"end_date" json:"end_date" queryable:""` } type Assistant struct { - First_name string `bson:"first_name" json:"first_name"` - Last_name string `bson:"last_name" json:"last_name"` - Role string `bson:"role" json:"role"` - Email string `bson:"email" json:"email"` + First_name string `bson:"first_name" json:"first_name" queryable:""` + Last_name string `bson:"last_name" json:"last_name" queryable:""` + Role string `bson:"role" json:"role" queryable:""` + Email string `bson:"email" json:"email" queryable:""` } type Location struct { - Building string `bson:"building" json:"building"` - Room string `bson:"room" json:"room"` - Map_uri string `bson:"map_uri" json:"map_uri"` + Building string `bson:"building" json:"building" queryable:""` + Room string `bson:"room" json:"room" queryable:""` + Map_uri string `bson:"map_uri" json:"map_uri" queryable:""` } type Meeting struct { - Start_date time.Time `bson:"start_date" json:"start_date"` - End_date time.Time `bson:"end_date" json:"end_date"` - Meeting_days []string `bson:"meeting_days" json:"meeting_days"` - Start_time string `bson:"start_time" json:"start_time"` - End_time string `bson:"end_time" json:"end_time"` - Modality string `bson:"modality" json:"modality"` - Location Location `bson:"location" json:"location"` + Start_date time.Time `bson:"start_date" json:"start_date" queryable:""` + End_date time.Time `bson:"end_date" json:"end_date" queryable:""` + Meeting_days []string `bson:"meeting_days" json:"meeting_days" queryable:""` + Start_time string `bson:"start_time" json:"start_time" queryable:""` + End_time string `bson:"end_time" json:"end_time" queryable:""` + Modality string `bson:"modality" json:"modality" queryable:""` + Location Location `bson:"location" json:"location" queryable:""` } type Section struct { @@ -77,14 +77,14 @@ type Section struct { Section_number string `bson:"section_number" json:"section_number" queryable:""` Course_reference primitive.ObjectID `bson:"course_reference" json:"course_reference" queryable:""` Section_corequisites *CollectionRequirement `bson:"section_corequisites" json:"section_corequisites"` - Academic_session AcademicSession `bson:"academic_session" json:"academic_session"` + Academic_session AcademicSession `bson:"academic_session" json:"academic_session" queryable:""` Professors []primitive.ObjectID `bson:"professors" json:"professors"` - Teaching_assistants []Assistant `bson:"teaching_assistants" json:"teaching_assistants"` + Teaching_assistants []Assistant `bson:"teaching_assistants" json:"teaching_assistants" queryable:""` Internal_class_number string `bson:"internal_class_number" json:"internal_class_number" queryable:""` Instruction_mode string `bson:"instruction_mode" json:"instruction_mode" queryable:""` - Meetings []Meeting `bson:"meetings" json:"meetings"` - Core_flags []string `bson:"core_flags" json:"core_flags"` - Syllabus_uri string `bson:"syllabus_uri" json:"syllabus_uri"` + Meetings []Meeting `bson:"meetings" json:"meetings" queryable:""` + Core_flags []string `bson:"core_flags" json:"core_flags" queryable:""` + Syllabus_uri string `bson:"syllabus_uri" json:"syllabus_uri" queryable:""` Grade_distribution []int `bson:"grade_distribution" json:"grade_distribution"` Attributes interface{} `bson:"attributes" json:"attributes"` Professor_details *[]BasicProfessor `bson:"professor_details,omitempty" json:"professor_details,omitempty"` // only shows if professor_details was set by the endpoint @@ -98,10 +98,10 @@ type Professor struct { Titles []string `bson:"titles" json:"titles" queryable:""` Email string `bson:"email" json:"email" queryable:""` Phone_number string `bson:"phone_number" json:"phone_number" queryable:""` - Office Location `bson:"office" json:"office"` - Profile_uri string `bson:"profile_uri" json:"profile_uri"` - Image_uri string `bson:"image_uri" json:"image_uri"` - Office_hours []Meeting `bson:"office_hours" json:"office_hours"` + Office Location `bson:"office" json:"office" queryable:""` + Profile_uri string `bson:"profile_uri" json:"profile_uri" queryable:""` + Image_uri string `bson:"image_uri" json:"image_uri" queryable:""` + Office_hours []Meeting `bson:"office_hours" json:"office_hours" queryable:""` Sections []primitive.ObjectID `bson:"sections" json:"sections"` } @@ -129,13 +129,41 @@ type DiscountProgram struct { Id primitive.ObjectID `bson:"_id" json:"_id"` Category string `bson:"category" json:"category"` Business string `bson:"business" json:"business"` - Address string `bson:"address" json:"address"` + Address []string `bson:"address" json:"address"` Phone string `bson:"phone" json:"phone"` Email string `bson:"email" json:"email"` Website string `bson:"website" json:"website"` Discount string `bson:"discount" json:"discount"` } +type DiscountQueryParams struct { + Category string `form:"category"` + Business string `form:"business"` + Address string `form:"address"` + Discount string `form:"discount"` + Q string `form:"q"` +} + +// The configuration for fuzzy searches +type FuzzySearchConfig struct { + Field string + MaxEdits int + BoostScore int +} + +// TrimSpace sanitizes all of fields of the discount query params +func (params *DiscountQueryParams) TrimSpace() { + params.Category = strings.TrimSpace(params.Category) + params.Business = strings.TrimSpace(params.Business) + params.Address = strings.TrimSpace(params.Address) + params.Discount = strings.TrimSpace(params.Discount) + params.Q = strings.TrimSpace(params.Q) +} + +func (params *DiscountQueryParams) HasFields() bool { + return params.Category != "" || params.Business != "" || params.Address != "" || params.Discount != "" +} + type Event struct { Id primitive.ObjectID `bson:"_id" json:"_id"` Summary string `bson:"summary" json:"summary"` @@ -288,6 +316,7 @@ type ObjectSignedURLBody struct { } // Academic Calendar type +// Must also be updated in api-tools/parser/academicCalendars.go type AcademicCalendar struct { Id string `bson:"_id" json:"_id"` Timeline string `bson:"timeline" json:"timeline"` @@ -300,6 +329,7 @@ type AcademicCalendar struct { MidtermsDue string `bson:"midterms_due" json:"midterms_due"` UniversityClosings [][]string `bson:"university_closings" json:"university_closings"` NoClasses [][]string `bson:"no_classes" json:"no_classes"` + URL string `bson:"url" json:"url"` } type AcademicCalendarSession struct { Name string `bson:"name" json:"name"` @@ -351,6 +381,20 @@ type Club struct { Contacts []Contact `json:"contacts"` } +type EmailAttachment struct { + Name string `json:"name" binding:"required"` + Data []byte `json:"data" binding:"required"` +} + +type EmailRequest struct { + From string `json:"from,omitempty"` + To []string `json:"to" binding:"required,dive,email"` + Subject string `json:"subject" binding:"required"` + Body string `json:"body" binding:"required"` + Attachments []EmailAttachment `json:"attachments,omitempty"` + Embeds []EmailAttachment `json:"embeds,omitempty"` +} + // Type for all API responses type APIResponse[T any] struct { Status int `json:"status"` diff --git a/api/server.go b/api/server.go index bf3403f3..7dfbb4d4 100644 --- a/api/server.go +++ b/api/server.go @@ -26,7 +26,7 @@ func swagger_controller_placeholder() {} // @title nebula-api // @description The public Nebula Labs API for access to pertinent UT Dallas data -// @version 1.2.0 +// @version 1.3.0 // @host api.utdnebula.com // @schemes https http // @x-google-backend {"address": "https://nebula-api-1062216541483.us-central1.run.app"} @@ -77,6 +77,7 @@ func main() { routes.CourseRoute(router) routes.SectionRoute(router) routes.ProfessorRoute(router) + routes.CombinedRoute(router) routes.GradesRoute(router) routes.AutocompleteRoute(router) routes.StorageRoute(router) @@ -87,6 +88,7 @@ func main() { routes.CalendarRoute(router) routes.ClubRoute(router) routes.DiscountRoutes(router) + routes.EmailRoute(router) // Retrieve the port string to serve traffic on portString := configs.GetPortString()