diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..f36d5f14a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +**/.env +.idea +*.log +main.exe +kubernetes \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000000..fa9613cdf2 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +cd ./peerprep && npx lint-staged \ No newline at end of file diff --git a/.lefthook.yml b/.lefthook.yml new file mode 100644 index 0000000000..d57925957c --- /dev/null +++ b/.lefthook.yml @@ -0,0 +1,10 @@ +#pre-commit: +# parallel: true +# commands: +# golangci-lint: +# glob: "*.go" +# run: golangci-lint run {staged_files} +# lint-staged: +# root: "peerprep/" +# glob: "*.(ts|tsx|css|scss|md|json)" +# run: npx lint-staged diff --git a/README.md b/README.md index 259f7bba2e..c3369ef2e0 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ +[![Review Assignment Due Date](https://classroom.github.com/assets/deadline-readme-button-22041afd0340ce965d47ae6ef1cefeee28c7c493a6346c4f15d667ab976d596c.svg)](https://classroom.github.com/a/bzPrOe11) # CS3219 Project (PeerPrep) - AY2425S1 -## Group: Gxx +## Group: G14 -### Note: -- You can choose to develop individual microservices within separate folders within this repository **OR** use individual repositories (all public) for each microservice. -- In the latter scenario, you should enable sub-modules on this GitHub classroom repository to manage the development/deployment **AND** add your mentor to the individual repositories as a collaborator. -- The teaching team should be given access to the repositories as we may require viewing the history of the repository in case of any disputes or disagreements. +View README [here](https://docs.google.com/document/d/1yadBdlLO0Mf9iY_GwbeY8kvQEzlA9_R3mVLtTnpQkVk/edit?tab=t.0) \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000000..e19d5e9927 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,3 @@ +MONGODB_URI=mongodb+srv://:@..mongodb.net/?retryWrites=true&w=majority&appName= +PORT=:9090 +CORS_ORIGIN=http://localhost:3000 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000000..185e18856c --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,2 @@ +.env +log \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000000..0b9a6955f4 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,29 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.23 + +# Set destination for COPY +WORKDIR /backend + +# Download Go modules +# TODO: don't include the .env file in the COPY +# TODO: multistage build +COPY go.mod go.sum ./ +RUN go mod download + +# Copy the source code. Note the slash at the end, as explained in +# https://docs.docker.com/reference/dockerfile/#copy +COPY . . + +# Build +RUN CGO_ENABLED=0 GOOS=linux go build -o /backend/app + +# Optional: +# To bind to a TCP port, runtime parameters must be supplied to the docker command. +# But we can document in the Dockerfile what ports +# the application is going to listen on by default. +# https://docs.docker.com/reference/dockerfile/#expose +EXPOSE 9090 + +# Run +CMD ["/backend/app"] \ No newline at end of file diff --git a/backend/common/logger_struct.go b/backend/common/logger_struct.go new file mode 100644 index 0000000000..af2ce0ba2e --- /dev/null +++ b/backend/common/logger_struct.go @@ -0,0 +1,14 @@ +package common + +import ( + "github.com/sirupsen/logrus" +) + +// contains the logger +type Logger struct { + Log *logrus.Logger +} + +func NewLogger(logger *logrus.Logger) *Logger { + return &Logger{Log: logger} +} diff --git a/backend/common/question_struct.go b/backend/common/question_struct.go new file mode 100644 index 0000000000..1ac42365c4 --- /dev/null +++ b/backend/common/question_struct.go @@ -0,0 +1,24 @@ +// defines the JSON format of questions. +package common + +type Question struct { + Title string `json:"title"` + TitleSlug string `json:"titleSlug"` + Difficulty string `json:"difficulty"` + TopicTags []string `json:"topicTags"` + Content string `json:"content"` + Schemas []string `json:"schemas"` + Id int `json:"id"` +} + +type FrontendQuestion struct { + Title string `json:"title"` + Difficulty string `json:"difficulty"` + TopicTags []string `json:"topicTags"` + Content string `json:"content"` +} + +type MatchingQuestion struct { + TopicTags []string `json:"topicTags"` + Difficulty string `json:"difficulty"` +} \ No newline at end of file diff --git a/backend/database/database_interactions.go b/backend/database/database_interactions.go new file mode 100644 index 0000000000..f079ded7f8 --- /dev/null +++ b/backend/database/database_interactions.go @@ -0,0 +1,117 @@ +// contains the database-related functions for the questions API. +package database + +import ( + "context" + "errors" + "fmt" + "net/http" + + "peerprep/common" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func (db *QuestionDB) GetAllQuestionsWithQuery( + logger *common.Logger, + filter bson.D, +) ([]common.Question, error) { + questionCursor, err := db.questions.Find(context.Background(), filter) + + if err != nil { + logger.Log.Error("Error retrieving questions: ", err.Error()) + return nil, err + } + + var questions []common.Question + + if err = questionCursor.All(context.Background(), &questions); err != nil { + logger.Log.Error("Error decoding questions: ", err.Error()) + return nil, err + } + + return questions, nil +} + +func (db *QuestionDB) GetOneQuestionWithQuery( + logger *common.Logger, + filter bson.D, +) (*common.Question, error) { + // Define the aggregation pipeline with the $match and $sample stages + pipeline := mongo.Pipeline{ + {{Key: "$match", Value: filter}}, + {{Key: "$sample", Value: bson.D{{Key: "size", Value: 1}}}}, + } + + // Execute the aggregation pipeline + cursor, err := db.questions.Aggregate(context.Background(), pipeline) + if err != nil { + logger.Log.Error("Error retrieving questions: ", err.Error()) + return nil, err + } + + var questions []common.Question + if err = cursor.All(context.Background(), &questions); err != nil { + logger.Log.Error("Error decoding questions: ", err.Error()) + return nil, err + } + + if len(questions) == 0 { + return nil, nil + } + + return &questions[0], nil +} + +func (db *QuestionDB) AddQuestion(logger *common.Logger, question *common.Question) (int, error) { + if db.QuestionExists(question) { + logger.Log.Warn("Cannot add question: question already exists") + return http.StatusConflict, errors.New("question already exists") + } + + if _, err := db.questions.InsertOne(context.Background(), question); err != nil { + logger.Log.Error("Error adding question", err.Error()) + return http.StatusBadGateway, err + } + + return http.StatusOK, nil +} + +func (db *QuestionDB) UpsertQuestion( + logger *common.Logger, + question *common.Question, +) (int, error) { + + filter := bson.D{bson.E{Key: "id", Value: question.Id}} + setter := bson.M{"$set": question} + upsert := options.Update().SetUpsert(true) + + _, err := db.questions.UpdateOne(context.Background(), filter, setter, upsert) + + if err != nil { + logger.Log.Error("Error while upserting question", err.Error()) + return http.StatusBadGateway, err + } + + return http.StatusOK, nil +} + +func (db *QuestionDB) DeleteQuestion(logger *common.Logger, id int) (int, error) { + deleteStatus, err := db.questions.DeleteOne( + context.Background(), + bson.D{bson.E{Key: "id", Value: id}}, + ) + + if err != nil { + logger.Log.Error("Error deleting question", err.Error()) + return http.StatusBadGateway, err + } else if deleteStatus.DeletedCount == 0 { + msg := fmt.Sprintf("Question with ID %d not found when deleting question", id) + logger.Log.Warn(msg) + return http.StatusNotFound, errors.New(msg) + } + + return http.StatusNoContent, nil +} diff --git a/backend/database/database_util.go b/backend/database/database_util.go new file mode 100644 index 0000000000..f750d10d32 --- /dev/null +++ b/backend/database/database_util.go @@ -0,0 +1,45 @@ +// utility functions for questions api +package database + +import ( + "context" + "peerprep/common" + + "go.mongodb.org/mongo-driver/bson" +) + +// used to check if a question already exists in the database. +func (db *QuestionDB) QuestionExists(question *common.Question) bool { + filter := bson.D{bson.E{Key: "title", Value: question.Title}} + err := db.questions.FindOne(context.Background(), filter). + Decode(&common.Question{}) + //FindOne() returns error if no document is found + return err == nil +} + +// used to find the next question ID to be used. Similar to SERIAL in psql. +func (db *QuestionDB) FindNextQuestionId() int { + id := struct { + Next int `json:"next"` + }{} + filter := bson.D{} + + if err := db.nextId.FindOne(context.Background(), filter).Decode(&id); err != nil { + return -1 + } + + return id.Next +} + +// used to check if a question being replaced will cause duplicates in the database + +func (db *QuestionDB) QuestionExistsExceptId(question *common.Question) bool { + filter := bson.D{ + bson.E{Key: "title", Value: question.Title}, + bson.E{Key: "id", Value: bson.D{bson.E{Key: "$ne", Value: question.Id}}}, + } + err := db.questions.FindOne(context.Background(), filter). + Decode(&common.Question{}) + //FindOne() returns error if no document is found + return err == nil +} diff --git a/backend/database/initialise_database.go b/backend/database/initialise_database.go new file mode 100644 index 0000000000..c26d0a2bff --- /dev/null +++ b/backend/database/initialise_database.go @@ -0,0 +1,45 @@ +// this is used to initialise the database connection +package database + +import ( + "context" + "log" + "os" + + "github.com/joho/godotenv" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func InitialiseDB() (*mongo.Client, error) { + // Load environment variables + err := godotenv.Load(".env") + + if err != nil { + log.Fatal("Error loading environment variables: " + err.Error()) + } + + mongoURI := os.Getenv("MONGODB_URI") + + if mongoURI == "" { + log.Fatal("MONGODB_URI not set in environment variables") + } + + clientOptions := options.Client().ApplyURI(mongoURI) + + // Connect to MongoDB + server, err := mongo.Connect(context.Background(), clientOptions) + + if err != nil { + log.Fatal("Error connecting to MongoDB" + err.Error()) + } + + // Check the connection + err = server.Ping(context.Background(), nil) + + if err != nil { + return nil, err + } + + return server, nil +} diff --git a/backend/database/questionDB_struct.go b/backend/database/questionDB_struct.go new file mode 100644 index 0000000000..8dde24466a --- /dev/null +++ b/backend/database/questionDB_struct.go @@ -0,0 +1,20 @@ +package database + +import ( + "go.mongodb.org/mongo-driver/mongo" +) + +// QuestionDB is a struct that contains a pointer to a mongo client. +// questions is the collection with all the questions, nextId is a single-entry collection that stores the next ID to be used. +type QuestionDB struct { + questions *mongo.Collection + nextId *mongo.Collection +} + +// returns a pointer to an instance of the Question collection +func NewQuestionDB(client *mongo.Client) *QuestionDB { + + questionCollection := client.Database("questions").Collection("questions") + idCollection := client.Database("questions").Collection("counter") + return &QuestionDB{questions: questionCollection, nextId: idCollection} +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000000..d72e6f59e7 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,56 @@ +module peerprep + +go 1.23 + +require github.com/joho/godotenv v1.5.1 // indirect -allows to load environment variables from a .env file instead of hardcoding them in the code + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gin-gonic/gin v1.10.0 + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.13.6 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/montanaflynn/stats v0.7.1 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect - Logging library + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect + go.mongodb.org/mongo-driver v1.16.1 + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.24.0 // indirect + golang.org/x/net v0.26.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +require ( + github.com/gin-contrib/cors v1.7.2 + github.com/microcosm-cc/bluemonday v1.0.27 +) + +require ( + github.com/aymerick/douceur v0.2.0 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/kr/text v0.2.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000000..7511fea62b --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,150 @@ +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= +github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc= +github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +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/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +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= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +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= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver v1.16.1 h1:rIVLL3q0IHM39dvE+z2ulZLp9ENZKThVfuvN/IiN4l8= +go.mongodb.org/mongo-driver v1.16.1/go.mod h1:oB6AhJQvFQL4LEHyXi6aJzQJtBiTQHiAd83l0GdFaiw= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +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.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +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.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +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.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +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= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +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.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +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/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000000..747789f078 --- /dev/null +++ b/backend/main.go @@ -0,0 +1,75 @@ +package main + +import ( + "fmt" + "io" + "log" + "os" + "time" + + "github.com/joho/godotenv" + + apicommon "peerprep/common" + apidatabase "peerprep/database" + gintransport "peerprep/transport" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" +) + +func main() { + //initialise logger file and directory if they do not exist + + err := godotenv.Load(".env") + if err != nil { + log.Fatal("Error loading environment variables: " + err.Error()) + } + + ORIGIN := os.Getenv("CORS_ORIGIN") + if ORIGIN == "" { + ORIGIN = "http://localhost:3000" + } + PORT := os.Getenv("PORT") + if PORT == "" { + PORT = ":9090" + } + + logger := apicommon.NewLogger(logrus.New()) + + logDirectory := "./log" + + if err := os.MkdirAll(logDirectory, 0755); err != nil { + logger.Log.Error("Failed to create log directory: " + err.Error()) + } + + logFile, err := os.OpenFile("./log/question_api.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + + if err != nil { + logger.Log.Warn("Failed to log to file, using default stderr") + } + + defer logFile.Close() + + logger.Log.Out = logFile + + //initialise the database and handle errors + server, err := apidatabase.InitialiseDB() + + if err != nil { + panic(err) + } + + //create a new instance of the questionDB + questionDB := apidatabase.NewQuestionDB(server) + + f, _ := os.Create("log/gin.log") + gin.DefaultWriter = io.MultiWriter(f, os.Stdout) + + router := gin.Default() + gintransport.SetCors(router, ORIGIN) + gintransport.SetAllEndpoints(router, questionDB, logger) + + logger.Log.Info(fmt.Sprintf("Server started at time: %s", time.Now().String())) + + router.Run(PORT) +} diff --git a/backend/transport/add_question.go b/backend/transport/add_question.go new file mode 100644 index 0000000000..5af6730089 --- /dev/null +++ b/backend/transport/add_question.go @@ -0,0 +1,51 @@ +// this is a method used to add questions to the database. This function will eventually be only called by admins. +package transport + +import ( + "net/http" + "peerprep/common" + "peerprep/database" + "strings" + + "github.com/microcosm-cc/bluemonday" + + "github.com/gin-gonic/gin" +) + +func AddQuestionWithLogger(db *database.QuestionDB, logger *common.Logger) gin.HandlerFunc { + return func(ctx *gin.Context) { + var feQuestion common.FrontendQuestion + + if err := ctx.BindJSON(&feQuestion); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"Error adding question": err.Error()}) + logger.Log.Error("Error converting JSON to question: ", err.Error()) + return + } + + p := bluemonday.UGCPolicy() + feQuestion.Content = p.Sanitize(feQuestion.Content) + + titleSlug := strings.ToLower(feQuestion.Title) + titleSlug = strings.ReplaceAll(titleSlug, " ", "-") + + question := common.Question{ + Title: feQuestion.Title, + TitleSlug: titleSlug, + Difficulty: feQuestion.Difficulty, + TopicTags: feQuestion.TopicTags, + Schemas: make([]string, 0), + Content: feQuestion.Content, + Id: -1, + } + + status, err := db.AddQuestion(logger, &question) + + if err != nil { + ctx.JSON(status, err.Error()) + return + } + + ctx.JSON(status, "Question added successfully") + logger.Log.Info("Question added successfully") + } +} diff --git a/backend/transport/delete_question.go b/backend/transport/delete_question.go new file mode 100644 index 0000000000..c965879b84 --- /dev/null +++ b/backend/transport/delete_question.go @@ -0,0 +1,36 @@ +/* +This is used to delete a question using the unique ID of the question. +The endpoint is defined as /questions/delete/id= +Since deletion is a dangerous operation, it will perform a delete only on an exact match of the ID. +*/ + +package transport + +import ( + "fmt" + "net/http" + "peerprep/common" + "peerprep/database" + "strconv" + + "github.com/gin-gonic/gin" +) + +func DeleteQuestionWithLogger(db *database.QuestionDB, logger *common.Logger) gin.HandlerFunc { + return func(ctx *gin.Context) { + id, err := strconv.Atoi(ctx.Param("id")) + + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"Error deleting question": "Invalid ID"}) + logger.Log.Warn("Attempted deletion with invalid ID: ", ctx.Param("id")) + return + } + + if status, err := db.DeleteQuestion(logger, id); err != nil { + ctx.JSON(status, err.Error()) + } else { + ctx.JSON(status, "Question deleted successfully") + logger.Log.Warn(fmt.Sprintf("Question with ID %d deleted successfully", id)) + } + } +} \ No newline at end of file diff --git a/backend/transport/get_all_questions.go b/backend/transport/get_all_questions.go new file mode 100644 index 0000000000..d08f5d3040 --- /dev/null +++ b/backend/transport/get_all_questions.go @@ -0,0 +1,31 @@ +// endpoint to get all questions +package transport + +import ( + "net/http" + "peerprep/common" + "peerprep/database" + + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/bson" +) +func GetAllQuestionsWithLogger(db *database.QuestionDB, logger *common.Logger) gin.HandlerFunc { + return func(ctx *gin.Context) { + questions, err := db.GetAllQuestionsWithQuery(logger, bson.D{}) + + if err != nil { + ctx.JSON(http.StatusBadGateway, err.Error()) + return + } + + if len(questions) == 0 { + ctx.JSON(http.StatusNotFound, gin.H{"Error retrieving questions": "No questions found"}) + logger.Log.Error("No questions found when retrieving all questions") + return + } + + ctx.JSON(http.StatusOK, questions) + logger.Log.Info("All questions retrieved successfully") + } +} + diff --git a/backend/transport/get_matching_questions.go b/backend/transport/get_matching_questions.go new file mode 100644 index 0000000000..8a49ac7653 --- /dev/null +++ b/backend/transport/get_matching_questions.go @@ -0,0 +1,65 @@ +// this is used to get all questions that match a query. The query can be on either the title or the id of the question +package transport + +import ( + "fmt" + "net/http" + "peerprep/common" + "peerprep/database" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/bson" +) + +//matches if the id is equal to the query, or if the title contains the query +func GetMatchingQuestionsWithLogger(db *database.QuestionDB, logger *common.Logger) gin.HandlerFunc { + return func(ctx *gin.Context) { + query := ctx.Param("query") + + if strings.Contains(query, " ") { + ctx.JSON(http.StatusBadRequest, gin.H{"Error finding questions": "endpoint cannot contain spaces"}) + logger.Log.Warn("Attempted to query with illegal endpoint: ", query) + return + } + + // replace all dashes with spaces + query = strings.ReplaceAll(query, "-", " ") + + title_filter := bson.D{bson.E{Key: "title", Value: bson.D{bson.E{Key: "$regex", Value: "(?i)" + query}}}} + + var id_filter bson.D + + if id, err := strconv.Atoi(query); err == nil { + id_filter = bson.D{bson.E{Key: "id", Value: id}} + } + + var filter bson.D + + if id_filter == nil { + //query is a string + filter = title_filter + } else { + //query is an integer + filter = bson.D{bson.E{Key: "$or", Value: []bson.D{id_filter, title_filter}}} + } + + questions, err := db.GetAllQuestionsWithQuery(logger, filter) + + if err != nil { + ctx.JSON(http.StatusBadGateway, err.Error()) + return + } + + if len(questions) == 0 { + ctx.JSON(http.StatusNotFound, gin.H{"No questions match the query": query}) + logger.Log.Warn(fmt.Sprintf("No questions found matching the query: %s", query)) + return + } + + ctx.JSON(http.StatusOK, questions) + logger.Log.Info(fmt.Sprintf("Questions matching query %s retrieved successfully", query)) + } +} + diff --git a/backend/transport/get_question.go b/backend/transport/get_question.go new file mode 100644 index 0000000000..1f37e09caa --- /dev/null +++ b/backend/transport/get_question.go @@ -0,0 +1,53 @@ +// This is used to get a specific question using the unique ID of the question. +// The endpoint is defined as /questions/solve/id= +package transport + +import ( + "fmt" + "net/http" + "peerprep/common" + "peerprep/database" + "strconv" + + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/bson" +) + +func GetQuestionWithLogger(db *database.QuestionDB, logger *common.Logger) gin.HandlerFunc { + return func(ctx *gin.Context) { + // get the numeric ID from the URL + id, err := strconv.Atoi(ctx.Param("id")) + + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"Error retrieving question": "Invalid ID"}) + logger.Log.Warn("Invalid ID: ", ctx.Param("id")) + return + } + + var questions []common.Question + questions, err = db.GetAllQuestionsWithQuery( + logger, + bson.D{bson.E{Key: "id", Value: id}}, + ) + + if err != nil { + ctx.JSON(http.StatusBadGateway, err.Error()) + return + } + + if len(questions) == 0 { + ctx.JSON(http.StatusNotFound, "Question not found") + logger.Log.Warn(fmt.Sprintf("Question with ID %d not found", id)) + return + } + + if len(questions) > 1 { + ctx.JSON(http.StatusBadGateway, "more than one question found") + logger.Log.Error("Multiple questions with the same id") + return + } + + ctx.JSON(http.StatusOK, questions[0]) + logger.Log.Info(fmt.Sprintf("Question with ID %d returned successfully", id)) + } +} diff --git a/backend/transport/get_random_matching_question.go b/backend/transport/get_random_matching_question.go new file mode 100644 index 0000000000..1d2191a72a --- /dev/null +++ b/backend/transport/get_random_matching_question.go @@ -0,0 +1,43 @@ +package transport + +import ( + "net/http" + "peerprep/common" + "peerprep/database" + + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/bson" +) + +func GetRandomMatchingQuestion(db *database.QuestionDB, logger *common.Logger) (gin.HandlerFunc) { + return func(ctx *gin.Context) { + var request common.MatchingQuestion + + err := ctx.BindJSON(&request) + + if err != nil { + ctx.JSON(http.StatusBadGateway, "error binding request from JSON") + logger.Log.Error("Error converting JSON to matching request:", err.Error()) + return + } + + filter := bson.D{ + {Key: "topicTags", Value: bson.D{{Key: "$in", Value: request.TopicTags}}}, + {Key: "difficulty", Value: request.Difficulty}, + } + question, err := db.GetOneQuestionWithQuery(logger, filter) + + if err != nil { + ctx.JSON(http.StatusBadGateway, "error retrieving questions from database") + return + } + + if question == nil { + ctx.JSON(http.StatusNotFound, "no questions found matching the request") + return + } + + ctx.JSON(http.StatusOK, question) + logger.Log.Info("matching-service request handled successfully") + } +} \ No newline at end of file diff --git a/backend/transport/health.go b/backend/transport/health.go new file mode 100644 index 0000000000..dde5539d69 --- /dev/null +++ b/backend/transport/health.go @@ -0,0 +1,14 @@ +package transport + +import ( + "github.com/gin-gonic/gin" + "net/http" + "peerprep/common" +) + +func HealthCheck(logger *common.Logger) gin.HandlerFunc { + return func(ctx *gin.Context) { + ctx.JSON(http.StatusOK, gin.H{"Status": "healthy"}) + logger.Log.Info("Health check successful") + } +} diff --git a/backend/transport/question_http_requests.go b/backend/transport/question_http_requests.go new file mode 100644 index 0000000000..cc8c69f159 --- /dev/null +++ b/backend/transport/question_http_requests.go @@ -0,0 +1,35 @@ +// This is used to keep track of all the endpoints that we are using in the application +package transport + +import ( + "time" + + "peerprep/common" + "peerprep/database" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +func SetAllEndpoints(router *gin.Engine, db *database.QuestionDB, logger *common.Logger) { + router.GET("/questions", GetAllQuestionsWithLogger(db, logger)) + router.POST("/questions", AddQuestionWithLogger(db, logger)) + router.GET("/questions/search/:query", GetMatchingQuestionsWithLogger(db, logger)) + router.GET("/questions/solve/:id", GetQuestionWithLogger(db, logger)) + router.DELETE("/questions/delete/:id", DeleteQuestionWithLogger(db, logger)) + router.PUT("/questions/replace/:id", ReplaceQuestionWithLogger(db, logger)) + router.GET("/health", HealthCheck(logger)) + router.POST("/match", GetRandomMatchingQuestion(db, logger)) +} + +// enable CORS for the frontend +func SetCors(router *gin.Engine, origin string) { + router.Use(cors.New(cors.Config{ + AllowOrigins: []string{"http://host.docker.internal", origin}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Content-Length", "Authorization"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + MaxAge: 2 * time.Minute, + })) +} diff --git a/backend/transport/replace_question.go b/backend/transport/replace_question.go new file mode 100644 index 0000000000..cdfcd1c176 --- /dev/null +++ b/backend/transport/replace_question.go @@ -0,0 +1,74 @@ +//updates a question with a given id. The id must be matched perfectly. +//Updating questions will not increment the next id like that of a post request, unless the supplied id is greater or equal to the next id. + +package transport + +import ( + "net/http" + "strconv" + + "peerprep/common" + "peerprep/database" + + "github.com/gin-gonic/gin" +) + +// for PUT requests, to replace an entire question with a new question, or create a new question if the id does not yet exist +func ReplaceQuestionWithLogger(db *database.QuestionDB, logger *common.Logger) gin.HandlerFunc { + return func(ctx *gin.Context) { + id_param, err := strconv.Atoi(ctx.Param("id")) + + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"Error replacing question": "ID is not a number"}) + logger.Log.Warn("Attempted to update with invalid ID: ", ctx.Param("id")) + return + } + + var new_question common.Question + err = ctx.BindJSON(&new_question) + + if err != nil { + logger.Log.Error("error", "Failed to bind JSON", err) + ctx.JSON(http.StatusBadGateway, gin.H{"Error replacing question": err.Error()}) + return + } + + //if id_param >= db.FindNextQuestionId() { + // logger.Log.Info( + // "Attempting to update a question with an ID greater than next ID, creating a new question", + // ) + // status, err := db.AddQuestion(logger, &new_question) + // + // if err != nil { + // ctx.JSON(status, err.Error()) + // return + // } + // + // ctx.JSON(status, "Question added successfully") + // logger.Log.Info("Question added successfully") + // return + //} + + logger.Log.Info("Replacing question with ID: ", id_param) + new_question.Id = id_param + + // used to ensure that replacing a question will not cause a conflict with another question + // e.g new question shares same title as question A, but is used to replace question B. This will result in 2 question A's in the database. + if db.QuestionExistsExceptId(&new_question) { + ctx.JSON(http.StatusConflict, "Question already exists") + logger.Log.Warn("Cannot replace question: question already exists") + return + } + + status, err := db.UpsertQuestion(logger, &new_question) + + if err != nil { + ctx.JSON(status, err.Error()) + return + } + + ctx.JSON(status, "Question updated successfully") + logger.Log.Info("Question upserted successfully") + + } +} diff --git a/collab/Dockerfile b/collab/Dockerfile new file mode 100644 index 0000000000..be3fb98083 --- /dev/null +++ b/collab/Dockerfile @@ -0,0 +1,16 @@ +FROM golang:1.20 + +WORKDIR /collab + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# Build +RUN CGO_ENABLED=0 GOOS=linux go build -o /collab/app + +EXPOSE 4000 + +# Run +CMD ["/collab/app"] \ No newline at end of file diff --git a/collab/go.mod b/collab/go.mod new file mode 100644 index 0000000000..baf51b62a7 --- /dev/null +++ b/collab/go.mod @@ -0,0 +1,39 @@ +module collab +go 1.20 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/go-redis/redis/v8 v8.11.5 + github.com/gorilla/websocket v1.5.3 +) + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/collab/go.sum b/collab/go.sum new file mode 100644 index 0000000000..dc30be64b8 --- /dev/null +++ b/collab/go.sum @@ -0,0 +1,100 @@ +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +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/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= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +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.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/collab/main.go b/collab/main.go new file mode 100644 index 0000000000..f5b602bcbb --- /dev/null +++ b/collab/main.go @@ -0,0 +1,471 @@ +package main + +import ( + "collab/verify" + "context" + "encoding/json" + "io" + "log" + "net/http" + "os" + "strconv" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +// types +const ( + AUTH = "auth" + AUTH_SUCCESS = "auth_success" + AUTH_FAIL = "auth_fail" + CLOSE_SESSION = "close_session" + CONTENT_CHANGE = "content_change" + PING = "ping" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true + }, +} + +type Client struct { + conn *websocket.Conn + roomID string + authenticated bool +} + +type Hub struct { + clients map[*Client]bool + workspaces map[string]string + broadcast chan Message + register chan *Client + unregister chan *Client + mutex sync.Mutex +} + +type Message struct { + Type string `json:"type"` + RoomID string `json:"roomId"` + Content string `json:"data"` + UserID string `json:"userId"` + Token string `json:"token"` + MatchHash string `json:"matchHash"` +} + +func verifyToken(token string) (bool, string) { + client := &http.Client{} + USER_SERVICE_URI := os.Getenv("USER_SERVICE_URI") + if USER_SERVICE_URI == "" { + USER_SERVICE_URI = "http://localhost:3001" + } + req, err := http.NewRequest("GET", USER_SERVICE_URI+"/auth/verify-token", nil) + if err != nil { + log.Println("Error creating request:", err) + return false, "" + } + + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := client.Do(req) + if err != nil { + log.Println("Error making request:", err) + return false, "" + } + defer resp.Body.Close() + + var response struct { + Message string `json:"message"` + Data struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + IsAdmin bool `json:"isAdmin"` + } `json:"data"` + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Println("Error reading response body:", err) + return false, "" + } + + // Unmarshal the response body into the struct + if err := json.Unmarshal(body, &response); err != nil { + log.Println("Error unmarshaling response:", err) + return false, "" + } + + // Check if the token was verified successfully + if resp.StatusCode != http.StatusOK { + log.Println("Token verification failed with status:", resp.Status) + return false, "" + } + + // Return true and the ID from the response + return true, response.Data.ID +} + +// NewHub creates a new hub instance +func NewHub() *Hub { + return &Hub{ + clients: make(map[*Client]bool), + workspaces: make(map[string]string), + broadcast: make(chan Message), + register: make(chan *Client), + unregister: make(chan *Client), + } +} + +// Run starts the hub's main loop +func (h *Hub) Run() { + for { + select { + case client := <-h.register: + h.mutex.Lock() + h.clients[client] = true + h.mutex.Unlock() + + case client := <-h.unregister: + h.mutex.Lock() + if _, ok := h.clients[client]; ok { + delete(h.clients, client) + client.conn.Close() + } + h.mutex.Unlock() + + case message := <-h.broadcast: + h.mutex.Lock() + // Update the current workspace for this RoomID + if message.Content != "" { + h.workspaces[message.RoomID] = message.Content + } + for client := range h.clients { + if client.roomID == message.RoomID { + + log.Println("Original message: ", message) + + msgJson, _ := json.Marshal(message) + + log.Printf("Sending message to client: %s", msgJson) + + err := client.conn.WriteMessage(websocket.TextMessage, + msgJson, + ) + if err != nil { + log.Printf("Error sending message: %v", err) + client.conn.Close() + delete(h.clients, client) + } + } + } + h.mutex.Unlock() + } + + } +} + +// ServeWs handles WebSocket requests +func serveWs( + hub *Hub, c *gin.Context, + roomMappings *verify.RoomMappings, + persistMappings *verify.PersistMappings, +) { + log.Println("handler called!") + roomID := c.Query("roomID") + if roomID == "" { + http.Error(c.Writer, "roomID required", http.StatusBadRequest) + return + } + + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + log.Println("Failed to upgrade:", err) + return + } + + client := &Client{conn: conn, roomID: roomID} + hub.register <- client + + go handleMessages(client, hub, roomMappings, persistMappings) +} + +func authenticateClient( + token string, match string, client *Client, + roomMappings *verify.RoomMappings, + persistMappings *verify.PersistMappings, +) bool { + ok, userID := verifyToken(token) + if !ok { + log.Println("bad token in request") + return false + } + return verify.VerifyRoomAndMoveToPersist( + roomMappings, client.roomID, userID, match, persistMappings) +} + +func authenticateClientNoMatch( + token string, client *Client, + persistMappings *verify.PersistMappings, +) bool { + ok, userID := verifyToken(token) + if !ok { + log.Println("bad token in request") + return false + } + return verify.VerifyPersist(persistMappings, client.roomID, userID) +} + +func handleMessages( + client *Client, hub *Hub, + roomMappings *verify.RoomMappings, + persistMappings *verify.PersistMappings, +) { + defer func() { + hub.unregister <- client + }() + + for { + _, message, err := client.conn.ReadMessage() + if err != nil { + log.Printf("WebSocket error: %v", err) + break + } + + log.Printf("Raw message received: %s", string(message)) + + var msgData Message + if err := json.Unmarshal(message, &msgData); err != nil { + log.Printf("Failed to parse message: %v", err) + continue + } + + log.Printf("Raw message parsed: %s", msgData) + + if msgData.Type == AUTH { + token := msgData.Token + if token == "" { + log.Println("Authentication failed - no token attached") + + msg := Message{ + Type: AUTH_FAIL, + RoomID: client.roomID, + Content: "Authentication failed - no token attached", + } + msgJson, _ := json.Marshal(msg) + client.conn.WriteMessage(websocket.TextMessage, msgJson) + client.conn.Close() + break + } + isSuccess := false + match := msgData.MatchHash + if match != "" && + !authenticateClient(token, match, client, roomMappings, persistMappings) { + log.Println( + "failed to find a matching room from match hash, proceeding with persistence check", + ) + } + // I will ping the persistent map even if I've found it in the original map + if !authenticateClientNoMatch(token, client, persistMappings) { + log.Println("failed to find a persistent room") + isSuccess = false + } else { + isSuccess = true + } + if !isSuccess { + msg := Message{ + Type: AUTH_FAIL, + RoomID: client.roomID, + Content: "Authentication failed", + } + msgJson, _ := json.Marshal(msg) + client.conn.WriteMessage(websocket.TextMessage, msgJson) + + client.conn.Close() + break + } + client.authenticated = true + + serverContent := hub.workspaces[client.roomID] + + newMsg := Message{ + Type: AUTH_SUCCESS, + RoomID: client.roomID, + Content: serverContent, + } + msgJson, _ := json.Marshal(newMsg) + client.conn.WriteMessage(websocket.TextMessage, msgJson) + + log.Println("Client authenticated successfully") + } + + // old logic before type changes + // if msgData["type"] == "ping" { + // //receives ping from client1, need to send a ping to client2 + // //eventually, if present, client2 will send the ping back, which will be broadcasted back to client1. + + // userID, _ := msgData["userId"].(string) + // request := Message { + // RoomID: client.roomID, + // UserID: userID, + // Content: []byte("ping request"), + // } + + // hub.broadcast <- request + // } + + if msgData.Type == CLOSE_SESSION { + closeMessage := Message{ + Type: CLOSE_SESSION, + RoomID: client.roomID, + Content: "The session has been closed by a user.", + } + targetId := msgData.UserID + ownData, err := persistMappings.Conn.HGetAll(context.Background(), targetId).Result() + if err != nil { + log.Printf("Error retrieving data for userID %s: %v", targetId, err) + } else { + // delete room under user id if it curr matches the room ID + ownRoomId := ownData["roomId"] + if ownRoomId == client.roomID { + _, err := persistMappings.Conn.Del(context.Background(), targetId).Result() + if err != nil { + log.Printf("Error deleting data for userID %s: %v", targetId, err) + } + } + // delete room under other user if it curr matches the room ID + otherUser := ownData["otherUser"] + othRoomId, err := persistMappings.Conn.HGet(context.Background(), otherUser, "roomId").Result() + if err != nil { + log.Printf("Error retrieving data for otherUser %s: %v", otherUser, err) + } else { + if othRoomId == client.roomID { + _, err := persistMappings.Conn.Del(context.Background(), otherUser).Result() + if err != nil { + log.Printf("Error deleting data for other user %s: %v", otherUser, err) + } + } + } + } + hub.broadcast <- closeMessage + } else if msgData.Type == CONTENT_CHANGE { + // Broadcast the message to other clients + hub.broadcast <- Message{ + RoomID: client.roomID, + Content: msgData.Content, + Type: msgData.Type, + UserID: msgData.UserID, + } + } else if msgData.Type == PING { + // Broadcast the message to other clients + hub.broadcast <- Message{ + RoomID: client.roomID, + Type: msgData.Type, + UserID: msgData.UserID, + } + + extendExpiryTime(msgData.UserID, persistMappings) + } else { + log.Printf("Unknown message type: %s", msgData.Type) + } + } +} + +func extendExpiryTime(userId string, persistMappings *verify.PersistMappings) { + + ctx := context.Background() + if err := persistMappings.Conn.Expire(ctx, userId, time.Minute*10).Err(); err != nil { + log.Println("Error extending room time on ping: ", err.Error()) + } else { + + log.Printf("expiration reset for 10 minutes for user %s: ", userId) + } + +} + +type ClientWorkspace struct { + Clients int `json:"clients"` + Workspace string `json:"workspace"` +} + +// Status endpoint that shows the number of clients and the current color for each roomID +func statusHandler(hub *Hub) gin.HandlerFunc { + + return func(c *gin.Context) { + hub.mutex.Lock() + defer hub.mutex.Unlock() + + status := make(map[string]ClientWorkspace) + for client := range hub.clients { + roomID := client.roomID + currentStatus, ok := status[roomID] + if !ok { + // Initialize status for a new roomID + status[roomID] = ClientWorkspace{ + Clients: 1, + Workspace: hub.workspaces[roomID], + } + } else { + // Update the client count for an existing roomID + status[roomID] = ClientWorkspace{ + Clients: currentStatus.Clients + 1, + Workspace: hub.workspaces[roomID], + } + } + } + + c.JSON(http.StatusOK, status) + } +} + +func main() { + r := gin.Default() + hub := NewHub() + go hub.Run() + + REDIS_URI := os.Getenv("REDIS_URI") + if REDIS_URI == "" { + REDIS_URI = "localhost:9190" + } + + REDIS_ROOM_MAPPING := 1 + REDIS_ROOM_PERSIST := 2 + + if os.Getenv("REDIS_ROOM_MAPPING") != "" { + num, err := strconv.Atoi(os.Getenv("REDIS_ROOM_MAPPING")) + if err != nil { + log.Fatal("DB no of room map is badly formatted" + err.Error()) + } else { + REDIS_ROOM_MAPPING = num + } + } + + if os.Getenv("REDIS_ROOM_PERSIST") != "" { + num, err := strconv.Atoi(os.Getenv("REDIS_ROOM_PERSIST")) + if err != nil { + log.Fatal("DB no of room persistance store is badly formatted" + err.Error()) + } else { + REDIS_ROOM_PERSIST = num + } + } + + roomMappings := verify.InitialiseRoomMappings(REDIS_URI, REDIS_ROOM_MAPPING) + persistMappings := verify.InitialisePersistMappings(REDIS_URI, REDIS_ROOM_PERSIST) + + // WebSocket connection endpoint + r.GET("/ws", func(c *gin.Context) { + serveWs(hub, c, roomMappings, persistMappings) + }) + + // Status endpoint + r.GET("/status", statusHandler(hub)) + + PORT := os.Getenv("PORT") + if PORT == "" { + PORT = ":4000" + } + r.Run(PORT) +} diff --git a/collab/verify/persistMappings.go b/collab/verify/persistMappings.go new file mode 100644 index 0000000000..b0945a617b --- /dev/null +++ b/collab/verify/persistMappings.go @@ -0,0 +1,36 @@ +package verify + +import ( + "context" + "log" + + redis "github.com/go-redis/redis/v8" +) + +// same as room mappings, but separated for type safety +type PersistMappings struct { + Conn *redis.Client +} + +func InitialisePersistMappings(addr string, db_num int) *PersistMappings { + conn := redis.NewClient(&redis.Options{ + Addr: addr, + DB: db_num, + }) + + return &PersistMappings{ + Conn: conn, + } +} + +func VerifyPersist(persistMappings *PersistMappings, roomID string, userID string) bool { + data, err := persistMappings.Conn.HGetAll(context.Background(), userID).Result() + if err != nil { + log.Printf("Error retrieving data for userID %s: %v", userID, err) + return false + } + + log.Printf("current roomID: %s, expected roomID: %s", data["roomId"], roomID) + + return data["roomId"] == roomID +} diff --git a/collab/verify/roomMappings.go b/collab/verify/roomMappings.go new file mode 100644 index 0000000000..9d3e45728c --- /dev/null +++ b/collab/verify/roomMappings.go @@ -0,0 +1,67 @@ +package verify + +import ( + "context" + "log" + //"time" + + redis "github.com/go-redis/redis/v8" +) + +// same as client mappings, but separated for type safety +type RoomMappings struct { + Conn *redis.Client +} + +func InitialiseRoomMappings(addr string, db_num int) *RoomMappings { + conn := redis.NewClient(&redis.Options{ + Addr: addr, + DB: db_num, + }) + + return &RoomMappings{ + Conn: conn, + } +} + +func VerifyRoomAndMoveToPersist( + roomMappings *RoomMappings, + roomID string, + userId string, + matchHash string, + persistMappings *PersistMappings, +) bool { + ctx := context.Background() + data, err := roomMappings.Conn.HGetAll(ctx, matchHash).Result() + if err != nil { + log.Printf("Error retrieving data for matchHash %s: %v", matchHash, err) + return false + } + + if data["roomId"] != roomID || data["thisUser"] != userId { + log.Printf("Mismatch in room data and user data") + return false + } + + roomMappings.Conn.Del(ctx, matchHash); + persistentRoom := map[string]interface{}{ + "roomId": roomID, + "otherUser": data["otherUser"], + "requestTime": data["requestTime"], + } + + // this always overrides the persistent room + if err := persistMappings.Conn.HSet(ctx, userId, persistentRoom).Err(); err != nil { + log.Printf("error sending room to persistent storage: %s", err.Error()) + } + + /* + if err := persistMappings.Conn.Expire(ctx, userId, 20 * time.Second).Err(); err != nil { + log.Printf("error setting expiration for persisting room storage: %s", err.Error()) + } else { + log.Printf("expiration set for 10 minutes for user %s: ", userId) + + } + */ + return true +} diff --git a/comms/.env.example b/comms/.env.example new file mode 100644 index 0000000000..da78d27a07 --- /dev/null +++ b/comms/.env.example @@ -0,0 +1 @@ +FRONTEND= diff --git a/comms/.gitignore b/comms/.gitignore new file mode 100644 index 0000000000..37d7e73486 --- /dev/null +++ b/comms/.gitignore @@ -0,0 +1,2 @@ +node_modules +.env diff --git a/comms/Dockerfile b/comms/Dockerfile new file mode 100644 index 0000000000..b895ccf667 --- /dev/null +++ b/comms/Dockerfile @@ -0,0 +1,8 @@ +FROM node:lts-alpine3.20 + +WORKDIR /comms +COPY package*.json ./ +RUN npm install --force +COPY . . +EXPOSE 4001 +CMD ["npm", "run", "dev"] diff --git a/comms/package-lock.json b/comms/package-lock.json new file mode 100644 index 0000000000..b8c4b5da93 --- /dev/null +++ b/comms/package-lock.json @@ -0,0 +1,1377 @@ +{ + "name": "collabcomms", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "collabcomms", + "version": "1.0.0", + "dependencies": { + "express": "^4.21.1", + "nodemon": "^3.1.7", + "socket.io": "^4.8.1" + } + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", + "license": "MIT" + }, + "node_modules/@types/cors": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.8.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.6.tgz", + "integrity": "sha512-tosuJYKrIqjQIlVCM4PEGxOmyg3FCPa/fViuJChnGeEIhjA46oy8FMVoF9su1/v8PNs2a8Q0iFNyOx0uOF91nw==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.8" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/engine.io": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.2.tgz", + "integrity": "sha512-gmNvsYi9C8iErnZdVcJnvCpSKbWTt1E8+JZo8b+daLninywUWi5NQ5STSHZ9rFjFO7imNcvb8Pc5pe/wMR5xEw==", + "license": "MIT", + "dependencies": { + "@types/cookie": "^0.4.1", + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", + "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.10", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemon": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.7.tgz", + "integrity": "sha512-hLj7fuMow6f0lbB0cD14Lz2xNjwsyruH251Pk4t/yIitCFJbmY1myuLlHm/q06aST4jg6EgAh74PIBBrRqpVAQ==", + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/socket.io": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", + "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", + "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", + "license": "MIT", + "dependencies": { + "debug": "~4.3.4", + "ws": "~8.17.1" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-adapter/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/comms/package.json b/comms/package.json new file mode 100644 index 0000000000..b068cb72e8 --- /dev/null +++ b/comms/package.json @@ -0,0 +1,12 @@ +{ + "name": "collabcomms", + "version": "1.0.0", + "scripts": { + "dev": "nodemon server.js" + }, + "dependencies": { + "express": "^4.21.1", + "nodemon": "^3.1.7", + "socket.io": "^4.8.1" + } +} diff --git a/comms/server.js b/comms/server.js new file mode 100644 index 0000000000..fd69980ebd --- /dev/null +++ b/comms/server.js @@ -0,0 +1,67 @@ +const express = require("express"); +const http = require("http"); +const app = express(); + +const server = http.createServer(app); +const io = require("socket.io")(server, { + path: '/comms', + cors: { + // temporarily use * to allow all origins + origin: `*`, + methods: ["GET", "POST"] + } +}); + +io.on("connection", (socket) => { + // emit endCall to the room it was in. + socket.on("disconnecting", () => { + // for each room in the disconnecting socket... + socket.rooms.forEach((target) => { + // ignoring the room matching its own id... + if (target === socket.id) { + return; + } + // get the user ids within the room... + io.sockets.adapter.rooms + .get(target) + .forEach( + (id) => { + // and for each user id in the room not matching + // its own id... + if (id === socket.id) { + return; + } + // leave the target room... + io.sockets.sockets.get(id).leave(target); + console.log(id + " leaves " + target); + // then tell the user id to endCall. + io.to(id).emit("endCall"); + } + ); + }); + }); + + // join a room and inform the peer that the other person has joined + socket.on("joinRoom", (data) => { + console.log(socket.id + " is joining " + data.target); + socket.join(data.target); + socket.to(data.target).emit("peerConnected"); + }); + + // propagate the socket events for starting and handshaking a call forward. + socket.on("startCall", (data) => { + console.log(socket.id + " is starting call in "+ data.target); + socket.to(data.target).emit("startCall", { + signal: data.signalData + }); + }); + + socket.on("handshakeCall", (data) => { + console.log("handshaking call in " + data.target); + socket.to(data.target).emit("handshakeCall", { + signal: data.signal + }); + }); +}); + +server.listen(4001, () => console.log("comms server is running on port 4001")); diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000000..ede0c47510 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,191 @@ +services: + peerprep: + build: peerprep + image: modembcc/peerprep:7.0.3 + env_file: + - peerprep/.env + ports: + - "3000:3000" + extra_hosts: + - "host.docker.internal:host-gateway" + + develop: + watch: + - action: sync + path: peerprep + target: /frontend + + user-service: + build: user-service + image: modembcc/user-service:6.0.0 + volumes: + - /user-service/node_modules + env_file: + - user-service/.env + extra_hosts: + - "host.docker.internal:host-gateway" + ports: + - "3001:3001" + develop: + watch: + - action: rebuild + path: user-service + target: /user-service + + backend: + build: backend + image: modembcc/backend:6.0.0 + env_file: + - backend/.env + ports: + - "9090:9090" + develop: + watch: + - action: rebuild + path: backend + target: backend/app + + # blob and mq + redis: + image: redis + container_name: redis-peerprep + extra_hosts: + - "host.docker.internal:host-gateway" + ports: + - "9190:6379" + + rabbitmq: + image: rabbitmq:3-management + container_name: rabbitmq + environment: + RABBITMQ_DEFAULT_USER: grp14 + RABBITMQ_DEFAULT_PASS: grp14 + extra_hosts: + - "host.docker.internal:host-gateway" + ports: + - "9100:5672" + - "9101:15672" + healthcheck: + test: rabbitmq-diagnostics check_port_connectivity + interval: 30s + timeout: 30s + retries: 10 + + matching-service: + build: matching-service + image: modembcc/matching-service:6.0.0 + env_file: + - matching-service/.env + extra_hosts: + - "host.docker.internal:host-gateway" + develop: + watch: + - action: rebuild + path: matching-service + target: matching-service/app + depends_on: + rabbitmq: + condition: service_healthy + + matching-service-api: + build: matching-service-api + image: modembcc/matching-service-api:6.0.0 + env_file: + - matching-service-api/.env + extra_hosts: + - "host.docker.internal:host-gateway" + ports: + - "9200:9200" + develop: + watch: + - action: rebuild + path: matching-service + target: matching-service/app + depends_on: + rabbitmq: + condition: service_healthy + + storage-blob-api: + build: storage-blob-api + image: modembcc/storage-blob-api:6.0.0 + env_file: + - storage-blob-api/.env + extra_hosts: + - "host.docker.internal:host-gateway" + ports: + - "9300:9300" + develop: + watch: + - action: rebuild + path: storage-blob-api + target: storage-blob-api/app + depends_on: + - redis + + collab: + build: collab + image: modembcc/collab:7.0.3 + env_file: + - collab/.env + ports: + - "4000:4000" + develop: + watch: + - action: rebuild + path: collab + target: collab/app + + formatter: + build: formatter + image: modembcc/formatter:6.0.0 + extra_hosts: + - "host.docker.internal:host-gateway" + ports: + - "5000:5000" + develop: + watch: + - action: sync + path: formatter + target: formatter/app + + comms: + build: comms + image: modembcc/comms:7.0.0 + #env_file: + #- comms/.env + ports: + - "4001:4001" + develop: + watch: + - action: sync + path: comms + target: comms/app + + nginx: + build: nginx + image: modembcc/nginx:6.0.0 + volumes: + - ./nginx/nginx.conf:/etc/nginx/internal.conf + ports: + - "70:70" + depends_on: + - peerprep + - backend + - user-service + - storage-blob-api + - matching-service-api + + inbound-gateway: + build: inbound-gateway + image: modembcc/inbound-gateway:6.0.1 + ports: + - "80:80" + volumes: + - ./inbound-gateway/nginx.conf:/etc/nginx/external.conf + depends_on: + - peerprep + - comms +# mongo: +# image: "mongo:latest" +# ports: +# - "27017:27017" diff --git a/formatter/Dockerfile b/formatter/Dockerfile new file mode 100644 index 0000000000..5a91905e7e --- /dev/null +++ b/formatter/Dockerfile @@ -0,0 +1,11 @@ +FROM alpine:latest + +RUN apk add --no-cache clang clang-extra-tools python3 py3-pip nodejs npm +RUN python3 -m venv /src/.venv +RUN /src/.venv/bin/pip install black fastapi uvicorn +RUN npm install -g prettier esprima +ENV PATH="/src/.venv/bin:$PATH" +WORKDIR /src +COPY src /src +EXPOSE 5000 +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5000"] diff --git a/formatter/src/app.py b/formatter/src/app.py new file mode 100644 index 0000000000..861b06a15c --- /dev/null +++ b/formatter/src/app.py @@ -0,0 +1,94 @@ +import asyncio +import logging +import subprocess + +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +from parsers import validate_python_code, validate_javascript_code, validate_cpp_code + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = FastAPI() + +origins = [ + "http://localhost:3000", + "http://localhost:80", + "http://localhost", + "*", +] + +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +class CodeFormatRequest(BaseModel): + code: str + + +async def run_formatter(command: list, code: str, timeout: int = 5) -> str: + try: + if command[0] == "black": + await validate_python_code(code) + elif command[0] == "prettier": + await validate_javascript_code(code) + elif command[0] == "clang-format": + await validate_cpp_code(code) + + process = await asyncio.create_subprocess_exec( + *command, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + stdout, stderr = await asyncio.wait_for(process.communicate(input=code.encode()), timeout=timeout) + + if process.returncode != 0: + error_message = stderr.decode().strip() + logger.error(f"Formatter error: {error_message}") + raise HTTPException(status_code=500, detail=f"Formatter error: {error_message}") + + return stdout.decode().strip() + + except HTTPException as http_err: + raise http_err + except asyncio.TimeoutError: + logger.error("Timed out") + raise HTTPException(status_code=504, detail="Timed out") + except Exception as e: + logger.error(f"Unexpected error: {str(e)}") + raise HTTPException(status_code=500, detail=f"Unexpected error: {str(e)}") + + +@app.post("/format/python") +async def format_python(request: CodeFormatRequest): + print(request.code) + command = ["black", "-q", "-"] + formatted_code = await run_formatter(command, request.code) + return {"formatted_code": formatted_code} + + +@app.post("/format/cpp") +async def format_cpp(request: CodeFormatRequest): + command = ["clang-format"] + formatted_code = await run_formatter(command, request.code) + return {"formatted_code": formatted_code} + + +@app.post("/format/javascript") +async def format_javascript(request: CodeFormatRequest): + command = ["prettier", "--stdin-filepath", "file.js"] + formatted_code = await run_formatter(command, request.code) + return {"formatted_code": formatted_code} + + +@app.get("/") +async def hello_world(): + return {"Status": "Hello from formatter"} diff --git a/formatter/src/js_syntax_check.js b/formatter/src/js_syntax_check.js new file mode 100644 index 0000000000..85dada5e79 --- /dev/null +++ b/formatter/src/js_syntax_check.js @@ -0,0 +1,11 @@ +const esprima = require("esprima"); + +const code = process.argv[2]; + +try { + esprima.parseScript(code); + console.log("Parsed OK"); +} catch (error) { + console.error("Failed to parse code: ", error); + process.exit(1); +} diff --git a/formatter/src/parsers.py b/formatter/src/parsers.py new file mode 100644 index 0000000000..30ee9b40db --- /dev/null +++ b/formatter/src/parsers.py @@ -0,0 +1,48 @@ +import ast +import asyncio +import logging +import subprocess + +from fastapi import HTTPException + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def validate_python_code(code: str): + try: + ast.parse(code) + except SyntaxError as e: + logger.error(f"Syntax error: {e}") + raise HTTPException(status_code=400, detail=f"Syntax error: {e}") + + +async def validate_javascript_code(code: str): + try: + result = subprocess.run( + ["node", "js_syntax_check.js", code], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode != 0: + raise HTTPException(status_code=400, detail="Failed to parse JavaScript code: " + result.stderr.strip()) + except subprocess.TimeoutExpired: + logger.error("JavaScript syntax check timed out") + raise HTTPException(status_code=504, detail="JavaScript syntax check timed out") + + +async def validate_cpp_code(code: str): + try: + result = subprocess.run( + ["clang", "-fsyntax-only", "-x", "c++", "-"], + input=code, + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode != 0: + raise HTTPException(status_code=400, detail="Failed to parse C++ code: " + result.stderr.strip()) + except subprocess.TimeoutExpired: + logger.error("C++ syntax check timed out") + raise HTTPException(status_code=504, detail="C++ syntax check timed out") diff --git a/inbound-gateway/Dockerfile b/inbound-gateway/Dockerfile new file mode 100644 index 0000000000..5a5b07759c --- /dev/null +++ b/inbound-gateway/Dockerfile @@ -0,0 +1,4 @@ +FROM nginx:alpine +COPY nginx.conf /etc/nginx/external.conf +EXPOSE 80 +CMD ["nginx", "-c", "external.conf", "-g", "daemon off;"] \ No newline at end of file diff --git a/inbound-gateway/README.md b/inbound-gateway/README.md new file mode 100644 index 0000000000..28f1e06e06 --- /dev/null +++ b/inbound-gateway/README.md @@ -0,0 +1,7 @@ +nginx is dockerised, just have to run `docker compose up --build` as usual. + +if edits are made to the local nginx.conf file, following command must be run to see changes reflected in docker: + +`docker exec cs3219-ay2425s1-project-g14-nginx-1 nginx -s reload` + +(or just exec `nginx -s reload` in the container directly) diff --git a/inbound-gateway/nginx.conf b/inbound-gateway/nginx.conf new file mode 100644 index 0000000000..3cda58bedc --- /dev/null +++ b/inbound-gateway/nginx.conf @@ -0,0 +1,62 @@ +worker_processes 1; + +events { + worker_connections 1024; +} + +http { + include mime.types; + + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + sendfile on; + #tcp_nopush on; + + keepalive_timeout 65; + + upstream peerprep { + server peerprep:3000; + } + + server { + listen 80; + + location / { + proxy_pass http://peerprep/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; # port 80 implicitly removes this port + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 86400; + } + + location /comms/ { + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Host $host; + proxy_set_header X-NginX-Proxy false; + + + proxy_pass http://comms:4001; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 86400; + } + + location /health { + access_log off; + return 200 'OK\n'; + add_header Content-Type text/plain; + } + } +} \ No newline at end of file diff --git a/kubernetes/apply_k8s_configs.ps1 b/kubernetes/apply_k8s_configs.ps1 new file mode 100644 index 0000000000..45b78493cc --- /dev/null +++ b/kubernetes/apply_k8s_configs.ps1 @@ -0,0 +1,24 @@ +# Array of Kubernetes YAML files to apply +$files = @( + "backend-deployment.yaml", + "collab-service-deployment.yaml", + "matching-service-api-deployment.yaml", + "matching-service-deployment.yaml", + "nginx-deployment.yaml", + "formatter-deployment.yaml", + "formatter-service.yaml", + "peerprep-deployment.yaml", + "rabbitmq-statefulset.yaml", + "redis-statefulset.yaml", + "storage-blob-api-deployment.yaml", + "user-service-deployment.yaml", + "inbound-gateway-deployment.yaml" +) + +# Loop through each file and apply it using kubectl +foreach ($file in $files) { + Write-Output "Applying $file..." + kubectl apply -f $file +} + +Write-Output "All files applied." diff --git a/kubernetes/backend-HPA.yaml b/kubernetes/backend-HPA.yaml new file mode 100644 index 0000000000..ce822006f7 --- /dev/null +++ b/kubernetes/backend-HPA.yaml @@ -0,0 +1,18 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: backend +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: backend + minReplicas: 1 + maxReplicas: 5 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 80 \ No newline at end of file diff --git a/kubernetes/backend-deployment.yaml b/kubernetes/backend-deployment.yaml new file mode 100644 index 0000000000..9730eb9094 --- /dev/null +++ b/kubernetes/backend-deployment.yaml @@ -0,0 +1,34 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backend +spec: + selector: + matchLabels: + app: backend + template: + metadata: + labels: + app: backend + spec: + containers: + - name: backend + image: modembcc/backend:6.0.0 + imagePullPolicy: IfNotPresent + env: + - name: MONGODB_URI + value: mongodb+srv://nxtms3:np0aUdwlMkISiUia@questions.bh9su.mongodb.net/?retryWrites=true&w=majority&appName=questions + - name: PORT + value: :9090 + - name: CORS_ORIGIN + value: http://peerprep:3000 + ports: + - containerPort: 9090 + name: backend + resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "200m" + memory: "256Mi" diff --git a/kubernetes/backend-service.yaml b/kubernetes/backend-service.yaml new file mode 100644 index 0000000000..52f340d801 --- /dev/null +++ b/kubernetes/backend-service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: backend +spec: + selector: + app: backend + ports: + - protocol: TCP + port: 9090 + targetPort: 9090 + type: ClusterIP \ No newline at end of file diff --git a/kubernetes/collab-service-HPA.yaml b/kubernetes/collab-service-HPA.yaml new file mode 100644 index 0000000000..23865f8bc4 --- /dev/null +++ b/kubernetes/collab-service-HPA.yaml @@ -0,0 +1,18 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: collab +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: collab + minReplicas: 1 + maxReplicas: 5 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 80 diff --git a/kubernetes/collab-service-deployment.yaml b/kubernetes/collab-service-deployment.yaml new file mode 100644 index 0000000000..052bb7f1b6 --- /dev/null +++ b/kubernetes/collab-service-deployment.yaml @@ -0,0 +1,34 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: collab +spec: + selector: + matchLabels: + app: collab + template: + metadata: + labels: + app: collab + spec: + containers: + - name: collab + image: modembcc/collab:7.0.3 + imagePullPolicy: Always + env: + - name: PORT + value: :4000 + - name: REDIS_URI + value: redis:6379 + - name: USER_SERVICE_URI + value: http://user-service:3001 + ports: + - containerPort: 4000 + name: collab + resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "200m" + memory: "256Mi" diff --git a/kubernetes/collab-service-service.yaml b/kubernetes/collab-service-service.yaml new file mode 100644 index 0000000000..4e0da5d4bd --- /dev/null +++ b/kubernetes/collab-service-service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: collab +spec: + selector: + app: collab + ports: + - protocol: TCP + port: 4000 + targetPort: 4000 + #nodePort: 31000 + type: ClusterIP \ No newline at end of file diff --git a/kubernetes/comms-HPA.yaml b/kubernetes/comms-HPA.yaml new file mode 100644 index 0000000000..58d48297a6 --- /dev/null +++ b/kubernetes/comms-HPA.yaml @@ -0,0 +1,18 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: comms +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: comms + minReplicas: 1 + maxReplicas: 5 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 80 diff --git a/kubernetes/comms-deployment.yaml b/kubernetes/comms-deployment.yaml new file mode 100644 index 0000000000..37e7becfe6 --- /dev/null +++ b/kubernetes/comms-deployment.yaml @@ -0,0 +1,27 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: comms +spec: + selector: + matchLabels: + app: comms + template: + metadata: + labels: + app: comms + spec: + containers: + - name: comms + image: modembcc/comms:7.0.0 + imagePullPolicy: Always + ports: + - containerPort: 4001 + name: comms + resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "200m" + memory: "256Mi" diff --git a/kubernetes/comms-service.yaml b/kubernetes/comms-service.yaml new file mode 100644 index 0000000000..571002401b --- /dev/null +++ b/kubernetes/comms-service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: comms +spec: + selector: + app: comms + ports: + - protocol: TCP + port: 4001 + targetPort: 4001 + type: ClusterIP diff --git a/kubernetes/formatter-HPA.yaml b/kubernetes/formatter-HPA.yaml new file mode 100644 index 0000000000..91356e3b4b --- /dev/null +++ b/kubernetes/formatter-HPA.yaml @@ -0,0 +1,18 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: formatter +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: formatter + minReplicas: 1 + maxReplicas: 5 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 80 diff --git a/kubernetes/formatter-deployment.yaml b/kubernetes/formatter-deployment.yaml new file mode 100644 index 0000000000..296dd1ef81 --- /dev/null +++ b/kubernetes/formatter-deployment.yaml @@ -0,0 +1,27 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: formatter +spec: + selector: + matchLabels: + app: formatter + template: + metadata: + labels: + app: formatter + spec: + containers: + - name: formatter + image: modembcc/formatter:6.0.0 + imagePullPolicy: Always + ports: + - containerPort: 5000 + name: formatter + resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "200m" + memory: "256Mi" diff --git a/kubernetes/formatter-service.yaml b/kubernetes/formatter-service.yaml new file mode 100644 index 0000000000..5b834a90a4 --- /dev/null +++ b/kubernetes/formatter-service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: formatter +spec: + selector: + app: formatter + ports: + - protocol: TCP + port: 5000 + targetPort: 5000 + type: ClusterIP \ No newline at end of file diff --git a/kubernetes/inbound-gateway-HPA.yaml b/kubernetes/inbound-gateway-HPA.yaml new file mode 100644 index 0000000000..fdb151879e --- /dev/null +++ b/kubernetes/inbound-gateway-HPA.yaml @@ -0,0 +1,18 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: inbound-gateway +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: inbound-gateway + minReplicas: 1 + maxReplicas: 5 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 80 diff --git a/kubernetes/inbound-gateway-deployment.yaml b/kubernetes/inbound-gateway-deployment.yaml new file mode 100644 index 0000000000..7e1f840660 --- /dev/null +++ b/kubernetes/inbound-gateway-deployment.yaml @@ -0,0 +1,27 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: inbound-gateway +spec: + replicas: 1 + selector: + matchLabels: + app: inbound-gateway + template: + metadata: + labels: + app: inbound-gateway + spec: + containers: + - name: inbound-gateway + image: modembcc/inbound-gateway:6.0.1 + imagePullPolicy: Always + ports: + - containerPort: 80 + resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "200m" + memory: "256Mi" diff --git a/kubernetes/inbound-gateway-service.yaml b/kubernetes/inbound-gateway-service.yaml new file mode 100644 index 0000000000..d8bcfeb653 --- /dev/null +++ b/kubernetes/inbound-gateway-service.yaml @@ -0,0 +1,54 @@ +apiVersion: cloud.google.com/v1 +kind: BackendConfig +metadata: + name: inbound-gateway-backendconfig +spec: + timeoutSec: 3600 + healthCheck: + checkIntervalSec: 10 # Time between health checks + timeoutSec: 5 # Timeout for each health check + healthyThreshold: 1 # Number of successes for "healthy" status + unhealthyThreshold: 3 # Number of failures for "unhealthy" status + requestPath: /health # Path for the health check (set this to your health check path) + port: 80 # Port the health check uses +--- +apiVersion: v1 +kind: Service +metadata: + name: inbound-gateway + annotations: + beta.cloud.google.com/backend-config: '{"default": "inbound-gateway-backendconfig"}' +spec: + selector: + app: inbound-gateway + ports: + - protocol: TCP + port: 80 + targetPort: 80 + type: NodePort +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: inbound-gateway-ingress + annotations: + # Use this if you have an NGINX Ingress Controller + kubernetes.io/ingress.global-static-ip-name: "cert-service" + networking.gke.io/managed-certificates: "peerprep-grp14" + kubernetes.io/ingress.class: "gce" +spec: + defaultBackend: + service: + name: inbound-gateway + port: + number: 80 + # rules: + # - host: peerprep-grp14.com # Replace with your domain + # http: + # paths: + # - pathType: ImplementationSpecific + # backend: + # service: + # name: inbound-gateway # Your service name + # port: + # number: 80 diff --git a/kubernetes/matching-service-HPA.yaml b/kubernetes/matching-service-HPA.yaml new file mode 100644 index 0000000000..3baed23703 --- /dev/null +++ b/kubernetes/matching-service-HPA.yaml @@ -0,0 +1,18 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: matching-service +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: matching-service + minReplicas: 1 + maxReplicas: 5 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 80 diff --git a/kubernetes/matching-service-api-HPA.yaml b/kubernetes/matching-service-api-HPA.yaml new file mode 100644 index 0000000000..758a1c2bde --- /dev/null +++ b/kubernetes/matching-service-api-HPA.yaml @@ -0,0 +1,18 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: matching-service-api +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: matching-service-api + minReplicas: 1 + maxReplicas: 5 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 80 diff --git a/kubernetes/matching-service-api-deployment.yaml b/kubernetes/matching-service-api-deployment.yaml new file mode 100644 index 0000000000..f1803501a4 --- /dev/null +++ b/kubernetes/matching-service-api-deployment.yaml @@ -0,0 +1,34 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: matching-service-api +spec: + selector: + matchLabels: + app: matching-service-api + template: + metadata: + labels: + app: matching-service-api + spec: + containers: + - name: matching-service-api + image: modembcc/matching-service-api:6.0.0 + imagePullPolicy: Always + env: + - name: PORT + value: :9200 + - name: RABBIT_URI + value: amqp://grp14:grp14@rabbitmq/ + - name: CORS_ORIGIN + value: http://peerprep:3000 + ports: + - containerPort: 9200 + name: matching-api + resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "200m" + memory: "256Mi" diff --git a/kubernetes/matching-service-api-service.yaml b/kubernetes/matching-service-api-service.yaml new file mode 100644 index 0000000000..4abdff43e5 --- /dev/null +++ b/kubernetes/matching-service-api-service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: matching-service-api +spec: + selector: + app: matching-service-api + ports: + - protocol: TCP + port: 9200 + targetPort: 9200 + type: ClusterIP \ No newline at end of file diff --git a/kubernetes/matching-service-deployment.yaml b/kubernetes/matching-service-deployment.yaml new file mode 100644 index 0000000000..a970909d5b --- /dev/null +++ b/kubernetes/matching-service-deployment.yaml @@ -0,0 +1,31 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: matching-service +spec: + selector: + matchLabels: + app: matching-service + template: + metadata: + labels: + app: matching-service + spec: + containers: + - name: matching-service + image: modembcc/matching-service:6.0.0 + imagePullPolicy: Always + env: + - name: RABBIT_URI + value: amqp://grp14:grp14@rabbitmq/ + - name: REDIS_URI + value: redis:6379 + - name: BACKEND_MATCH_URI + value: http://backend:9090/match + resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "200m" + memory: "256Mi" diff --git a/kubernetes/nginx-HPA.yaml b/kubernetes/nginx-HPA.yaml new file mode 100644 index 0000000000..f963696bd0 --- /dev/null +++ b/kubernetes/nginx-HPA.yaml @@ -0,0 +1,18 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: nginx +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: nginx + minReplicas: 1 + maxReplicas: 5 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 80 diff --git a/kubernetes/nginx-deployment.yaml b/kubernetes/nginx-deployment.yaml new file mode 100644 index 0000000000..54bc932c0a --- /dev/null +++ b/kubernetes/nginx-deployment.yaml @@ -0,0 +1,27 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx +spec: + replicas: 1 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: modembcc/nginx:6.0.0 + imagePullPolicy: Always + ports: + - containerPort: 70 + resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "200m" + memory: "256Mi" diff --git a/kubernetes/nginx-service.yaml b/kubernetes/nginx-service.yaml new file mode 100644 index 0000000000..9080580ddd --- /dev/null +++ b/kubernetes/nginx-service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: nginx +spec: + selector: + app: nginx + ports: + - protocol: TCP + port: 70 + targetPort: 70 + type: ClusterIP diff --git a/kubernetes/peerprep-HPA.yaml b/kubernetes/peerprep-HPA.yaml new file mode 100644 index 0000000000..829f048889 --- /dev/null +++ b/kubernetes/peerprep-HPA.yaml @@ -0,0 +1,18 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: peerprep +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: peerprep + minReplicas: 1 + maxReplicas: 5 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 80 diff --git a/kubernetes/peerprep-deployment.yaml b/kubernetes/peerprep-deployment.yaml new file mode 100644 index 0000000000..3f7ac79c06 --- /dev/null +++ b/kubernetes/peerprep-deployment.yaml @@ -0,0 +1,44 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: peerprep +spec: + selector: + matchLabels: + app: peerprep + template: + metadata: + labels: + app: peerprep + spec: + containers: + - name: peerprep + image: modembcc/peerprep:7.0.3 + imagePullPolicy: Always + env: + - name: NEXT_PUBLIC_QUESTION_SERVICE + value: backend + - name: NEXT_PUBLIC_USER_SERVICE + value: users + - name: NEXT_PUBLIC_MATCHING_SERVICE + value: matchmaking + - name: NEXT_PUBLIC_STORAGE_BLOB + value: blob + - name: NEXT_PUBLIC_NGINX + value: http://nginx:70 + - name: NEXT_PUBLIC_COLLAB + value: collab + - name: DEV_ENV + value: not + - name: NEXT_PUBLIC_FORMATTER + value: formatter + ports: + - containerPort: 3000 + name: peerprep + resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "200m" + memory: "256Mi" diff --git a/kubernetes/peerprep-service.yaml b/kubernetes/peerprep-service.yaml new file mode 100644 index 0000000000..5c41976c54 --- /dev/null +++ b/kubernetes/peerprep-service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: peerprep +spec: + selector: + app: peerprep + ports: + - protocol: TCP + port: 3000 + targetPort: 3000 + type: ClusterIP diff --git a/kubernetes/rabbitmq-service.yaml b/kubernetes/rabbitmq-service.yaml new file mode 100644 index 0000000000..0792128eb3 --- /dev/null +++ b/kubernetes/rabbitmq-service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: rabbitmq +spec: + type: ClusterIP # or ClusterIP if you don't need external access + ports: + - name: rabbitmq + port: 5672 + targetPort: 5672 + - name: management + port: 15672 + targetPort: 15672 + selector: + app: rabbitmq \ No newline at end of file diff --git a/kubernetes/rabbitmq-statefulset.yaml b/kubernetes/rabbitmq-statefulset.yaml new file mode 100644 index 0000000000..9361107b25 --- /dev/null +++ b/kubernetes/rabbitmq-statefulset.yaml @@ -0,0 +1,33 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: rabbitmq +spec: + serviceName: "rabbitmq" + replicas: 1 + selector: + matchLabels: + app: rabbitmq + template: + metadata: + labels: + app: rabbitmq + spec: + containers: + - name: rabbitmq + image: rabbitmq:3-management + ports: + - containerPort: 5672 + - containerPort: 15672 + env: + - name: RABBITMQ_DEFAULT_USER + value: "grp14" + - name: RABBITMQ_DEFAULT_PASS + value: "grp14" + readinessProbe: + exec: + command: ["rabbitmq-diagnostics", "check_port_connectivity"] + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 10 \ No newline at end of file diff --git a/kubernetes/redis-service.yaml b/kubernetes/redis-service.yaml new file mode 100644 index 0000000000..b9823a58e8 --- /dev/null +++ b/kubernetes/redis-service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: redis +spec: + type: ClusterIP # Or ClusterIP depending on your access needs + ports: + - name: redis + port: 6379 + targetPort: 6379 + # nodePort: 9190 # Optional, if you want to specify a NodePort + selector: + app: redis \ No newline at end of file diff --git a/kubernetes/redis-statefulset.yaml b/kubernetes/redis-statefulset.yaml new file mode 100644 index 0000000000..1c5239be70 --- /dev/null +++ b/kubernetes/redis-statefulset.yaml @@ -0,0 +1,31 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: redis +spec: + serviceName: "redis" + replicas: 1 # Number of Redis instances + selector: + matchLabels: + app: redis + template: + metadata: + labels: + app: redis + spec: + containers: + - name: redis + image: redis + ports: + - containerPort: 6379 # Redis default port + volumeMounts: + - name: redis-storage + mountPath: /data # Mount path for Redis data + volumeClaimTemplates: + - metadata: + name: redis-storage + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 1Gi \ No newline at end of file diff --git a/kubernetes/storage-blob-api-HPA.yaml b/kubernetes/storage-blob-api-HPA.yaml new file mode 100644 index 0000000000..d37cfe9c8c --- /dev/null +++ b/kubernetes/storage-blob-api-HPA.yaml @@ -0,0 +1,18 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: storage-blob-api +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: storage-blob-api + minReplicas: 1 + maxReplicas: 5 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 80 diff --git a/kubernetes/storage-blob-api-deployment.yaml b/kubernetes/storage-blob-api-deployment.yaml new file mode 100644 index 0000000000..9e74158b44 --- /dev/null +++ b/kubernetes/storage-blob-api-deployment.yaml @@ -0,0 +1,32 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: storage-blob-api +spec: + selector: + matchLabels: + app: storage-blob-api + template: + metadata: + labels: + app: storage-blob-api + spec: + containers: + - name: storage-blob-api + image: modembcc/storage-blob-api:6.0.0 + imagePullPolicy: IfNotPresent + env: + - name: PORT + value: :9300 + - name: REDIS_URI + value: redis:6379 + ports: + - containerPort: 9300 + name: storage-api + resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "200m" + memory: "256Mi" diff --git a/kubernetes/storage-blob-api-service.yaml b/kubernetes/storage-blob-api-service.yaml new file mode 100644 index 0000000000..71a313a4d1 --- /dev/null +++ b/kubernetes/storage-blob-api-service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: storage-blob-api +spec: + selector: + app: storage-blob-api + ports: + - protocol: TCP + port: 9300 + targetPort: 9300 + type: ClusterIP \ No newline at end of file diff --git a/kubernetes/user-service-HPA.yaml b/kubernetes/user-service-HPA.yaml new file mode 100644 index 0000000000..47e4ee0257 --- /dev/null +++ b/kubernetes/user-service-HPA.yaml @@ -0,0 +1,18 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: user-service +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: user-service + minReplicas: 1 + maxReplicas: 5 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 80 diff --git a/kubernetes/user-service-deployment.yaml b/kubernetes/user-service-deployment.yaml new file mode 100644 index 0000000000..031de3dacb --- /dev/null +++ b/kubernetes/user-service-deployment.yaml @@ -0,0 +1,38 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: user-service +spec: + selector: + matchLabels: + app: user-service + template: + metadata: + labels: + app: user-service + spec: + containers: + - name: user-service + image: modembcc/user-service:6.0.0 + imagePullPolicy: IfNotPresent + env: + - name: DB_CLOUD_URI + value: mongodb+srv://modemhappy:dvVbAqwq5lEOr6WN@cluster0.1oehu.mongodb.net/peerprep?retryWrites=true&w=majority&appName=Cluster0 + - name: DB_LOCAL_URI + value: mongodb://127.0.0.1:27017/peerprepUserServiceDB + - name: PORT + value: "3001" + - name: ENV + value: PROD + - name: JWT_SECRET + value: you-can-replace-this-with-your-own-secret + ports: + - containerPort: 3001 + name: user-service + resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "200m" + memory: "256Mi" diff --git a/kubernetes/user-service-service.yaml b/kubernetes/user-service-service.yaml new file mode 100644 index 0000000000..c8ca7215b3 --- /dev/null +++ b/kubernetes/user-service-service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: user-service +spec: + selector: + app: user-service + ports: + - protocol: TCP + port: 3001 + targetPort: 3001 + type: ClusterIP \ No newline at end of file diff --git a/lc/.gitignore b/lc/.gitignore new file mode 100644 index 0000000000..bf1633d2ff --- /dev/null +++ b/lc/.gitignore @@ -0,0 +1,2 @@ +.env +content/ \ No newline at end of file diff --git a/lc/README.md b/lc/README.md new file mode 100644 index 0000000000..5886fba7cc --- /dev/null +++ b/lc/README.md @@ -0,0 +1,38 @@ +Simple script to scrape questions. This could probably be done programmatically into our db + +`titles.json` retrieved using the below query. Then run `getquestions.py` then `writemongo.py` + + +`{"categorySlug": "", "skip": 0, "limit": 50, "filters": {}}` + + +```graphql +query problemsetQuestionList($categorySlug: String, $limit: Int, $skip: Int, $filters: QuestionListFilterInput) { + problemsetQuestionList: questionList( + categorySlug: $categorySlug + limit: $limit + skip: $skip + filters: $filters + ) { + total: totalNum + questions: data { + acRate + difficulty + freqBar + frontendQuestionId: questionFrontendId + isFavor + paidOnly: isPaidOnly + status + title + titleSlug + topicTags { + name + id + slug + } + hasSolution + hasVideoSolution + } + } +} +``` \ No newline at end of file diff --git a/lc/content/mergedata.py b/lc/content/mergedata.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/lc/content/mergedata.py @@ -0,0 +1 @@ + diff --git a/lc/getquestions.py b/lc/getquestions.py new file mode 100644 index 0000000000..f4870b2b3d --- /dev/null +++ b/lc/getquestions.py @@ -0,0 +1,35 @@ +import json +import os + +import dotenv +import requests + +dotenv.load_dotenv() + +url = "https://leetcode.com/graphql/" +query = """ +query questionContent($titleSlug: String!) { + question(titleSlug: $titleSlug) { + content + mysqlSchemas + } +} +""" + + +headers = { + "Cookie": os.getenv("COOKIE"), + "Content-Type": "application/json" +} + +with open("titles.json") as f: + data = json.load(f) + questions = data["data"]["problemsetQuestionList"]["questions"] + slugs = [q["titleSlug"] for q in questions] + +for slug in slugs: + variables = {"titleSlug": slug} + r = requests.get(url, json={"query": query, "variables": variables}, headers=headers) + js = r.json() + with open(f"content/{slug}.json", "w") as f: + json.dump(js, f) diff --git a/lc/titles.json b/lc/titles.json new file mode 100644 index 0000000000..d3b2d6f05f --- /dev/null +++ b/lc/titles.json @@ -0,0 +1,1389 @@ +{ + "data": { + "problemsetQuestionList": { + "total": 3308, + "questions": [ + { + "acRate": 53.89468359875297, + "difficulty": "Easy", + "freqBar": null, + "frontendQuestionId": "1", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Two Sum", + "titleSlug": "two-sum", + "topicTags": [ + { + "name": "Array", + "id": "VG9waWNUYWdOb2RlOjU=", + "slug": "array" + }, + { + "name": "Hash Table", + "id": "VG9waWNUYWdOb2RlOjY=", + "slug": "hash-table" + } + ], + "hasSolution": true, + "hasVideoSolution": true + }, + { + "acRate": 44.245868642454674, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "2", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Add Two Numbers", + "titleSlug": "add-two-numbers", + "topicTags": [ + { + "name": "Linked List", + "id": "VG9waWNUYWdOb2RlOjc=", + "slug": "linked-list" + }, + { + "name": "Math", + "id": "VG9waWNUYWdOb2RlOjg=", + "slug": "math" + }, + { + "name": "Recursion", + "id": "VG9waWNUYWdOb2RlOjMx", + "slug": "recursion" + } + ], + "hasSolution": true, + "hasVideoSolution": true + }, + { + "acRate": 35.52858411742299, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "3", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Longest Substring Without Repeating Characters", + "titleSlug": "longest-substring-without-repeating-characters", + "topicTags": [ + { + "name": "Hash Table", + "id": "VG9waWNUYWdOb2RlOjY=", + "slug": "hash-table" + }, + { + "name": "String", + "id": "VG9waWNUYWdOb2RlOjEw", + "slug": "string" + }, + { + "name": "Sliding Window", + "id": "VG9waWNUYWdOb2RlOjU1ODIx", + "slug": "sliding-window" + } + ], + "hasSolution": true, + "hasVideoSolution": true + }, + { + "acRate": 41.61176393805736, + "difficulty": "Hard", + "freqBar": null, + "frontendQuestionId": "4", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Median of Two Sorted Arrays", + "titleSlug": "median-of-two-sorted-arrays", + "topicTags": [ + { + "name": "Array", + "id": "VG9waWNUYWdOb2RlOjU=", + "slug": "array" + }, + { + "name": "Binary Search", + "id": "VG9waWNUYWdOb2RlOjEx", + "slug": "binary-search" + }, + { + "name": "Divide and Conquer", + "id": "VG9waWNUYWdOb2RlOjEy", + "slug": "divide-and-conquer" + } + ], + "hasSolution": true, + "hasVideoSolution": true + }, + { + "acRate": 34.52650574784693, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "5", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Longest Palindromic Substring", + "titleSlug": "longest-palindromic-substring", + "topicTags": [ + { + "name": "Two Pointers", + "id": "VG9waWNUYWdOb2RlOjk=", + "slug": "two-pointers" + }, + { + "name": "String", + "id": "VG9waWNUYWdOb2RlOjEw", + "slug": "string" + }, + { + "name": "Dynamic Programming", + "id": "VG9waWNUYWdOb2RlOjEz", + "slug": "dynamic-programming" + } + ], + "hasSolution": true, + "hasVideoSolution": true + }, + { + "acRate": 49.42985942734227, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "6", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Zigzag Conversion", + "titleSlug": "zigzag-conversion", + "topicTags": [ + { + "name": "String", + "id": "VG9waWNUYWdOb2RlOjEw", + "slug": "string" + } + ], + "hasSolution": true, + "hasVideoSolution": true + }, + { + "acRate": 29.217590977956398, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "7", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Reverse Integer", + "titleSlug": "reverse-integer", + "topicTags": [ + { + "name": "Math", + "id": "VG9waWNUYWdOb2RlOjg=", + "slug": "math" + } + ], + "hasSolution": true, + "hasVideoSolution": true + }, + { + "acRate": 17.98909576348899, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "8", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "String to Integer (atoi)", + "titleSlug": "string-to-integer-atoi", + "topicTags": [ + { + "name": "String", + "id": "VG9waWNUYWdOb2RlOjEw", + "slug": "string" + } + ], + "hasSolution": true, + "hasVideoSolution": false + }, + { + "acRate": 57.64058301926786, + "difficulty": "Easy", + "freqBar": null, + "frontendQuestionId": "9", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Palindrome Number", + "titleSlug": "palindrome-number", + "topicTags": [ + { + "name": "Math", + "id": "VG9waWNUYWdOb2RlOjg=", + "slug": "math" + } + ], + "hasSolution": true, + "hasVideoSolution": true + }, + { + "acRate": 28.427855636654076, + "difficulty": "Hard", + "freqBar": null, + "frontendQuestionId": "10", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Regular Expression Matching", + "titleSlug": "regular-expression-matching", + "topicTags": [ + { + "name": "String", + "id": "VG9waWNUYWdOb2RlOjEw", + "slug": "string" + }, + { + "name": "Dynamic Programming", + "id": "VG9waWNUYWdOb2RlOjEz", + "slug": "dynamic-programming" + }, + { + "name": "Recursion", + "id": "VG9waWNUYWdOb2RlOjMx", + "slug": "recursion" + } + ], + "hasSolution": true, + "hasVideoSolution": false + }, + { + "acRate": 56.28513251885974, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "11", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Container With Most Water", + "titleSlug": "container-with-most-water", + "topicTags": [ + { + "name": "Array", + "id": "VG9waWNUYWdOb2RlOjU=", + "slug": "array" + }, + { + "name": "Two Pointers", + "id": "VG9waWNUYWdOb2RlOjk=", + "slug": "two-pointers" + }, + { + "name": "Greedy", + "id": "VG9waWNUYWdOb2RlOjE3", + "slug": "greedy" + } + ], + "hasSolution": true, + "hasVideoSolution": true + }, + { + "acRate": 66.44744320562003, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "12", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Integer to Roman", + "titleSlug": "integer-to-roman", + "topicTags": [ + { + "name": "Hash Table", + "id": "VG9waWNUYWdOb2RlOjY=", + "slug": "hash-table" + }, + { + "name": "Math", + "id": "VG9waWNUYWdOb2RlOjg=", + "slug": "math" + }, + { + "name": "String", + "id": "VG9waWNUYWdOb2RlOjEw", + "slug": "string" + } + ], + "hasSolution": true, + "hasVideoSolution": true + }, + { + "acRate": 62.82880641965184, + "difficulty": "Easy", + "freqBar": null, + "frontendQuestionId": "13", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Roman to Integer", + "titleSlug": "roman-to-integer", + "topicTags": [ + { + "name": "Hash Table", + "id": "VG9waWNUYWdOb2RlOjY=", + "slug": "hash-table" + }, + { + "name": "Math", + "id": "VG9waWNUYWdOb2RlOjg=", + "slug": "math" + }, + { + "name": "String", + "id": "VG9waWNUYWdOb2RlOjEw", + "slug": "string" + } + ], + "hasSolution": true, + "hasVideoSolution": true + }, + { + "acRate": 43.91752141600064, + "difficulty": "Easy", + "freqBar": null, + "frontendQuestionId": "14", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Longest Common Prefix", + "titleSlug": "longest-common-prefix", + "topicTags": [ + { + "name": "String", + "id": "VG9waWNUYWdOb2RlOjEw", + "slug": "string" + }, + { + "name": "Trie", + "id": "VG9waWNUYWdOb2RlOjI3", + "slug": "trie" + } + ], + "hasSolution": true, + "hasVideoSolution": true + }, + { + "acRate": 35.46129382115968, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "15", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "3Sum", + "titleSlug": "3sum", + "topicTags": [ + { + "name": "Array", + "id": "VG9waWNUYWdOb2RlOjU=", + "slug": "array" + }, + { + "name": "Two Pointers", + "id": "VG9waWNUYWdOb2RlOjk=", + "slug": "two-pointers" + }, + { + "name": "Sorting", + "id": "VG9waWNUYWdOb2RlOjYxMDQ5", + "slug": "sorting" + } + ], + "hasSolution": true, + "hasVideoSolution": true + }, + { + "acRate": 46.120289423430684, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "16", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "3Sum Closest", + "titleSlug": "3sum-closest", + "topicTags": [ + { + "name": "Array", + "id": "VG9waWNUYWdOb2RlOjU=", + "slug": "array" + }, + { + "name": "Two Pointers", + "id": "VG9waWNUYWdOb2RlOjk=", + "slug": "two-pointers" + }, + { + "name": "Sorting", + "id": "VG9waWNUYWdOb2RlOjYxMDQ5", + "slug": "sorting" + } + ], + "hasSolution": true, + "hasVideoSolution": true + }, + { + "acRate": 61.869733846755025, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "17", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Letter Combinations of a Phone Number", + "titleSlug": "letter-combinations-of-a-phone-number", + "topicTags": [ + { + "name": "Hash Table", + "id": "VG9waWNUYWdOb2RlOjY=", + "slug": "hash-table" + }, + { + "name": "String", + "id": "VG9waWNUYWdOb2RlOjEw", + "slug": "string" + }, + { + "name": "Backtracking", + "id": "VG9waWNUYWdOb2RlOjE0", + "slug": "backtracking" + } + ], + "hasSolution": true, + "hasVideoSolution": false + }, + { + "acRate": 36.8037698072919, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "18", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "4Sum", + "titleSlug": "4sum", + "topicTags": [ + { + "name": "Array", + "id": "VG9waWNUYWdOb2RlOjU=", + "slug": "array" + }, + { + "name": "Two Pointers", + "id": "VG9waWNUYWdOb2RlOjk=", + "slug": "two-pointers" + }, + { + "name": "Sorting", + "id": "VG9waWNUYWdOb2RlOjYxMDQ5", + "slug": "sorting" + } + ], + "hasSolution": true, + "hasVideoSolution": false + }, + { + "acRate": 46.86725214849113, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "19", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Remove Nth Node From End of List", + "titleSlug": "remove-nth-node-from-end-of-list", + "topicTags": [ + { + "name": "Linked List", + "id": "VG9waWNUYWdOb2RlOjc=", + "slug": "linked-list" + }, + { + "name": "Two Pointers", + "id": "VG9waWNUYWdOb2RlOjk=", + "slug": "two-pointers" + } + ], + "hasSolution": true, + "hasVideoSolution": true + }, + { + "acRate": 41.13097467112164, + "difficulty": "Easy", + "freqBar": null, + "frontendQuestionId": "20", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Valid Parentheses", + "titleSlug": "valid-parentheses", + "topicTags": [ + { + "name": "String", + "id": "VG9waWNUYWdOb2RlOjEw", + "slug": "string" + }, + { + "name": "Stack", + "id": "VG9waWNUYWdOb2RlOjE1", + "slug": "stack" + } + ], + "hasSolution": true, + "hasVideoSolution": true + }, + { + "acRate": 65.38002387584561, + "difficulty": "Easy", + "freqBar": null, + "frontendQuestionId": "21", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Merge Two Sorted Lists", + "titleSlug": "merge-two-sorted-lists", + "topicTags": [ + { + "name": "Linked List", + "id": "VG9waWNUYWdOb2RlOjc=", + "slug": "linked-list" + }, + { + "name": "Recursion", + "id": "VG9waWNUYWdOb2RlOjMx", + "slug": "recursion" + } + ], + "hasSolution": true, + "hasVideoSolution": true + }, + { + "acRate": 75.59928494537527, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "22", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Generate Parentheses", + "titleSlug": "generate-parentheses", + "topicTags": [ + { + "name": "String", + "id": "VG9waWNUYWdOb2RlOjEw", + "slug": "string" + }, + { + "name": "Dynamic Programming", + "id": "VG9waWNUYWdOb2RlOjEz", + "slug": "dynamic-programming" + }, + { + "name": "Backtracking", + "id": "VG9waWNUYWdOb2RlOjE0", + "slug": "backtracking" + } + ], + "hasSolution": true, + "hasVideoSolution": true + }, + { + "acRate": 54.28992320843026, + "difficulty": "Hard", + "freqBar": null, + "frontendQuestionId": "23", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Merge k Sorted Lists", + "titleSlug": "merge-k-sorted-lists", + "topicTags": [ + { + "name": "Linked List", + "id": "VG9waWNUYWdOb2RlOjc=", + "slug": "linked-list" + }, + { + "name": "Divide and Conquer", + "id": "VG9waWNUYWdOb2RlOjEy", + "slug": "divide-and-conquer" + }, + { + "name": "Heap (Priority Queue)", + "id": "VG9waWNUYWdOb2RlOjYxMDUw", + "slug": "heap-priority-queue" + }, + { + "name": "Merge Sort", + "id": "VG9waWNUYWdOb2RlOjYxMDUx", + "slug": "merge-sort" + } + ], + "hasSolution": true, + "hasVideoSolution": true + }, + { + "acRate": 65.44222029767059, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "24", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Swap Nodes in Pairs", + "titleSlug": "swap-nodes-in-pairs", + "topicTags": [ + { + "name": "Linked List", + "id": "VG9waWNUYWdOb2RlOjc=", + "slug": "linked-list" + }, + { + "name": "Recursion", + "id": "VG9waWNUYWdOb2RlOjMx", + "slug": "recursion" + } + ], + "hasSolution": true, + "hasVideoSolution": false + }, + { + "acRate": 60.60096496855557, + "difficulty": "Hard", + "freqBar": null, + "frontendQuestionId": "25", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Reverse Nodes in k-Group", + "titleSlug": "reverse-nodes-in-k-group", + "topicTags": [ + { + "name": "Linked List", + "id": "VG9waWNUYWdOb2RlOjc=", + "slug": "linked-list" + }, + { + "name": "Recursion", + "id": "VG9waWNUYWdOb2RlOjMx", + "slug": "recursion" + } + ], + "hasSolution": true, + "hasVideoSolution": false + }, + { + "acRate": 57.84416491365798, + "difficulty": "Easy", + "freqBar": null, + "frontendQuestionId": "26", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Remove Duplicates from Sorted Array", + "titleSlug": "remove-duplicates-from-sorted-array", + "topicTags": [ + { + "name": "Array", + "id": "VG9waWNUYWdOb2RlOjU=", + "slug": "array" + }, + { + "name": "Two Pointers", + "id": "VG9waWNUYWdOb2RlOjk=", + "slug": "two-pointers" + } + ], + "hasSolution": true, + "hasVideoSolution": false + }, + { + "acRate": 58.35721169804881, + "difficulty": "Easy", + "freqBar": null, + "frontendQuestionId": "27", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Remove Element", + "titleSlug": "remove-element", + "topicTags": [ + { + "name": "Array", + "id": "VG9waWNUYWdOb2RlOjU=", + "slug": "array" + }, + { + "name": "Two Pointers", + "id": "VG9waWNUYWdOb2RlOjk=", + "slug": "two-pointers" + } + ], + "hasSolution": true, + "hasVideoSolution": true + }, + { + "acRate": 43.5581950412328, + "difficulty": "Easy", + "freqBar": null, + "frontendQuestionId": "28", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Find the Index of the First Occurrence in a String", + "titleSlug": "find-the-index-of-the-first-occurrence-in-a-string", + "topicTags": [ + { + "name": "Two Pointers", + "id": "VG9waWNUYWdOb2RlOjk=", + "slug": "two-pointers" + }, + { + "name": "String", + "id": "VG9waWNUYWdOb2RlOjEw", + "slug": "string" + }, + { + "name": "String Matching", + "id": "VG9waWNUYWdOb2RlOjYxMDUy", + "slug": "string-matching" + } + ], + "hasSolution": true, + "hasVideoSolution": true + }, + { + "acRate": 17.800371886302564, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "29", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Divide Two Integers", + "titleSlug": "divide-two-integers", + "topicTags": [ + { + "name": "Math", + "id": "VG9waWNUYWdOb2RlOjg=", + "slug": "math" + }, + { + "name": "Bit Manipulation", + "id": "VG9waWNUYWdOb2RlOjE5", + "slug": "bit-manipulation" + } + ], + "hasSolution": true, + "hasVideoSolution": false + }, + { + "acRate": 32.53735145928916, + "difficulty": "Hard", + "freqBar": null, + "frontendQuestionId": "30", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Substring with Concatenation of All Words", + "titleSlug": "substring-with-concatenation-of-all-words", + "topicTags": [ + { + "name": "Hash Table", + "id": "VG9waWNUYWdOb2RlOjY=", + "slug": "hash-table" + }, + { + "name": "String", + "id": "VG9waWNUYWdOb2RlOjEw", + "slug": "string" + }, + { + "name": "Sliding Window", + "id": "VG9waWNUYWdOb2RlOjU1ODIx", + "slug": "sliding-window" + } + ], + "hasSolution": true, + "hasVideoSolution": true + }, + { + "acRate": 41.21488838323158, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "31", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Next Permutation", + "titleSlug": "next-permutation", + "topicTags": [ + { + "name": "Array", + "id": "VG9waWNUYWdOb2RlOjU=", + "slug": "array" + }, + { + "name": "Two Pointers", + "id": "VG9waWNUYWdOb2RlOjk=", + "slug": "two-pointers" + } + ], + "hasSolution": true, + "hasVideoSolution": true + }, + { + "acRate": 34.8478904273991, + "difficulty": "Hard", + "freqBar": null, + "frontendQuestionId": "32", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Longest Valid Parentheses", + "titleSlug": "longest-valid-parentheses", + "topicTags": [ + { + "name": "String", + "id": "VG9waWNUYWdOb2RlOjEw", + "slug": "string" + }, + { + "name": "Dynamic Programming", + "id": "VG9waWNUYWdOb2RlOjEz", + "slug": "dynamic-programming" + }, + { + "name": "Stack", + "id": "VG9waWNUYWdOb2RlOjE1", + "slug": "stack" + } + ], + "hasSolution": true, + "hasVideoSolution": false + }, + { + "acRate": 41.614719446945315, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "33", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Search in Rotated Sorted Array", + "titleSlug": "search-in-rotated-sorted-array", + "topicTags": [ + { + "name": "Array", + "id": "VG9waWNUYWdOb2RlOjU=", + "slug": "array" + }, + { + "name": "Binary Search", + "id": "VG9waWNUYWdOb2RlOjEx", + "slug": "binary-search" + } + ], + "hasSolution": true, + "hasVideoSolution": false + }, + { + "acRate": 45.29630830308422, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "34", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Find First and Last Position of Element in Sorted Array", + "titleSlug": "find-first-and-last-position-of-element-in-sorted-array", + "topicTags": [ + { + "name": "Array", + "id": "VG9waWNUYWdOb2RlOjU=", + "slug": "array" + }, + { + "name": "Binary Search", + "id": "VG9waWNUYWdOb2RlOjEx", + "slug": "binary-search" + } + ], + "hasSolution": true, + "hasVideoSolution": false + }, + { + "acRate": 47.303615102085935, + "difficulty": "Easy", + "freqBar": null, + "frontendQuestionId": "35", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Search Insert Position", + "titleSlug": "search-insert-position", + "topicTags": [ + { + "name": "Array", + "id": "VG9waWNUYWdOb2RlOjU=", + "slug": "array" + }, + { + "name": "Binary Search", + "id": "VG9waWNUYWdOb2RlOjEx", + "slug": "binary-search" + } + ], + "hasSolution": true, + "hasVideoSolution": true + }, + { + "acRate": 60.70343293731061, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "36", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Valid Sudoku", + "titleSlug": "valid-sudoku", + "topicTags": [ + { + "name": "Array", + "id": "VG9waWNUYWdOb2RlOjU=", + "slug": "array" + }, + { + "name": "Hash Table", + "id": "VG9waWNUYWdOb2RlOjY=", + "slug": "hash-table" + }, + { + "name": "Matrix", + "id": "VG9waWNUYWdOb2RlOjYxMDUz", + "slug": "matrix" + } + ], + "hasSolution": true, + "hasVideoSolution": false + }, + { + "acRate": 62.99265299694539, + "difficulty": "Hard", + "freqBar": null, + "frontendQuestionId": "37", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Sudoku Solver", + "titleSlug": "sudoku-solver", + "topicTags": [ + { + "name": "Array", + "id": "VG9waWNUYWdOb2RlOjU=", + "slug": "array" + }, + { + "name": "Hash Table", + "id": "VG9waWNUYWdOb2RlOjY=", + "slug": "hash-table" + }, + { + "name": "Backtracking", + "id": "VG9waWNUYWdOb2RlOjE0", + "slug": "backtracking" + }, + { + "name": "Matrix", + "id": "VG9waWNUYWdOb2RlOjYxMDUz", + "slug": "matrix" + } + ], + "hasSolution": true, + "hasVideoSolution": false + }, + { + "acRate": 56.56580734387951, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "38", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Count and Say", + "titleSlug": "count-and-say", + "topicTags": [ + { + "name": "String", + "id": "VG9waWNUYWdOb2RlOjEw", + "slug": "string" + } + ], + "hasSolution": true, + "hasVideoSolution": false + }, + { + "acRate": 72.85140608176994, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "39", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Combination Sum", + "titleSlug": "combination-sum", + "topicTags": [ + { + "name": "Array", + "id": "VG9waWNUYWdOb2RlOjU=", + "slug": "array" + }, + { + "name": "Backtracking", + "id": "VG9waWNUYWdOb2RlOjE0", + "slug": "backtracking" + } + ], + "hasSolution": true, + "hasVideoSolution": true + }, + { + "acRate": 56.506162869711716, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "40", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Combination Sum II", + "titleSlug": "combination-sum-ii", + "topicTags": [ + { + "name": "Array", + "id": "VG9waWNUYWdOb2RlOjU=", + "slug": "array" + }, + { + "name": "Backtracking", + "id": "VG9waWNUYWdOb2RlOjE0", + "slug": "backtracking" + } + ], + "hasSolution": true, + "hasVideoSolution": false + }, + { + "acRate": 40.01663993086235, + "difficulty": "Hard", + "freqBar": null, + "frontendQuestionId": "41", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "First Missing Positive", + "titleSlug": "first-missing-positive", + "topicTags": [ + { + "name": "Array", + "id": "VG9waWNUYWdOb2RlOjU=", + "slug": "array" + }, + { + "name": "Hash Table", + "id": "VG9waWNUYWdOb2RlOjY=", + "slug": "hash-table" + } + ], + "hasSolution": true, + "hasVideoSolution": false + }, + { + "acRate": 63.229984041849605, + "difficulty": "Hard", + "freqBar": null, + "frontendQuestionId": "42", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Trapping Rain Water", + "titleSlug": "trapping-rain-water", + "topicTags": [ + { + "name": "Array", + "id": "VG9waWNUYWdOb2RlOjU=", + "slug": "array" + }, + { + "name": "Two Pointers", + "id": "VG9waWNUYWdOb2RlOjk=", + "slug": "two-pointers" + }, + { + "name": "Dynamic Programming", + "id": "VG9waWNUYWdOb2RlOjEz", + "slug": "dynamic-programming" + }, + { + "name": "Stack", + "id": "VG9waWNUYWdOb2RlOjE1", + "slug": "stack" + }, + { + "name": "Monotonic Stack", + "id": "VG9waWNUYWdOb2RlOjYxMDU0", + "slug": "monotonic-stack" + } + ], + "hasSolution": true, + "hasVideoSolution": false + }, + { + "acRate": 41.04549609866359, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "43", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Multiply Strings", + "titleSlug": "multiply-strings", + "topicTags": [ + { + "name": "Math", + "id": "VG9waWNUYWdOb2RlOjg=", + "slug": "math" + }, + { + "name": "String", + "id": "VG9waWNUYWdOb2RlOjEw", + "slug": "string" + }, + { + "name": "Simulation", + "id": "VG9waWNUYWdOb2RlOjYxMDU1", + "slug": "simulation" + } + ], + "hasSolution": true, + "hasVideoSolution": false + }, + { + "acRate": 28.681117460619287, + "difficulty": "Hard", + "freqBar": null, + "frontendQuestionId": "44", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Wildcard Matching", + "titleSlug": "wildcard-matching", + "topicTags": [ + { + "name": "String", + "id": "VG9waWNUYWdOb2RlOjEw", + "slug": "string" + }, + { + "name": "Dynamic Programming", + "id": "VG9waWNUYWdOb2RlOjEz", + "slug": "dynamic-programming" + }, + { + "name": "Greedy", + "id": "VG9waWNUYWdOb2RlOjE3", + "slug": "greedy" + }, + { + "name": "Recursion", + "id": "VG9waWNUYWdOb2RlOjMx", + "slug": "recursion" + } + ], + "hasSolution": true, + "hasVideoSolution": false + }, + { + "acRate": 40.75777371906237, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "45", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Jump Game II", + "titleSlug": "jump-game-ii", + "topicTags": [ + { + "name": "Array", + "id": "VG9waWNUYWdOb2RlOjU=", + "slug": "array" + }, + { + "name": "Dynamic Programming", + "id": "VG9waWNUYWdOb2RlOjEz", + "slug": "dynamic-programming" + }, + { + "name": "Greedy", + "id": "VG9waWNUYWdOb2RlOjE3", + "slug": "greedy" + } + ], + "hasSolution": true, + "hasVideoSolution": false + }, + { + "acRate": 79.3349422389389, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "46", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Permutations", + "titleSlug": "permutations", + "topicTags": [ + { + "name": "Array", + "id": "VG9waWNUYWdOb2RlOjU=", + "slug": "array" + }, + { + "name": "Backtracking", + "id": "VG9waWNUYWdOb2RlOjE0", + "slug": "backtracking" + } + ], + "hasSolution": true, + "hasVideoSolution": false + }, + { + "acRate": 60.13615074082911, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "47", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Permutations II", + "titleSlug": "permutations-ii", + "topicTags": [ + { + "name": "Array", + "id": "VG9waWNUYWdOb2RlOjU=", + "slug": "array" + }, + { + "name": "Backtracking", + "id": "VG9waWNUYWdOb2RlOjE0", + "slug": "backtracking" + } + ], + "hasSolution": true, + "hasVideoSolution": false + }, + { + "acRate": 76.03482336005133, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "48", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Rotate Image", + "titleSlug": "rotate-image", + "topicTags": [ + { + "name": "Array", + "id": "VG9waWNUYWdOb2RlOjU=", + "slug": "array" + }, + { + "name": "Math", + "id": "VG9waWNUYWdOb2RlOjg=", + "slug": "math" + }, + { + "name": "Matrix", + "id": "VG9waWNUYWdOb2RlOjYxMDUz", + "slug": "matrix" + } + ], + "hasSolution": true, + "hasVideoSolution": false + }, + { + "acRate": 69.51786504472769, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "49", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Group Anagrams", + "titleSlug": "group-anagrams", + "topicTags": [ + { + "name": "Array", + "id": "VG9waWNUYWdOb2RlOjU=", + "slug": "array" + }, + { + "name": "Hash Table", + "id": "VG9waWNUYWdOb2RlOjY=", + "slug": "hash-table" + }, + { + "name": "String", + "id": "VG9waWNUYWdOb2RlOjEw", + "slug": "string" + }, + { + "name": "Sorting", + "id": "VG9waWNUYWdOb2RlOjYxMDQ5", + "slug": "sorting" + } + ], + "hasSolution": true, + "hasVideoSolution": true + }, + { + "acRate": 35.660303493246104, + "difficulty": "Medium", + "freqBar": null, + "frontendQuestionId": "50", + "isFavor": false, + "paidOnly": false, + "status": null, + "title": "Pow(x, n)", + "titleSlug": "powx-n", + "topicTags": [ + { + "name": "Math", + "id": "VG9waWNUYWdOb2RlOjg=", + "slug": "math" + }, + { + "name": "Recursion", + "id": "VG9waWNUYWdOb2RlOjMx", + "slug": "recursion" + } + ], + "hasSolution": true, + "hasVideoSolution": false + } + ] + } + } +} \ No newline at end of file diff --git a/lc/writemongo.py b/lc/writemongo.py new file mode 100644 index 0000000000..e42ef9f2f7 --- /dev/null +++ b/lc/writemongo.py @@ -0,0 +1,39 @@ +import json +import os + +from tqdm.auto import tqdm +from dotenv import load_dotenv + + +from pymongo.mongo_client import MongoClient +from pymongo.server_api import ServerApi + + +load_dotenv() +uri = os.getenv("MONGO_URI") + +# Create a new client and connect to the server +client = MongoClient(uri, server_api=ServerApi('1')) +db = client["questions"] +collection = db["questions"] + +# Send a ping to confirm a successful connection +try: + client.admin.command('ping') + print("Pinged your deployment. You successfully connected to MongoDB!") +except Exception as e: + print(e) + +with open("titles.json") as f: + data = json.load(f) + questions = data["data"]["problemsetQuestionList"]["questions"] + for i, q in enumerate(tqdm(questions)): + q_dict = {"title": q["title"], "titleSlug": q["titleSlug"], "difficulty": q["difficulty"], + "topicTags": [tag["name"] for tag in q["topicTags"]]} + + with open(f"content/{q['titleSlug']}.json", "r") as f: + js = json.load(f) + q_dict["content"] = js["data"]["question"]["content"] + q_dict["schemas"] = js["data"]["question"]["mysqlSchemas"] + + collection.insert_one(q_dict) diff --git a/locusts/__pycache__/locustfile.cpython-311.pyc b/locusts/__pycache__/locustfile.cpython-311.pyc new file mode 100644 index 0000000000..2bc5f5b832 Binary files /dev/null and b/locusts/__pycache__/locustfile.cpython-311.pyc differ diff --git a/locusts/locustfile.py b/locusts/locustfile.py new file mode 100644 index 0000000000..fd3f0b5e02 --- /dev/null +++ b/locusts/locustfile.py @@ -0,0 +1,15 @@ +from locust import HttpUser, TaskSet, task, between + +class UserBehavior(TaskSet): + @task + def load_homepage(self): + self.client.get("/") + + # @task + # def perform_action(self): + # # Add more endpoints or actions specific to your application. + # self.client.post("/auth/login", json={"username": "testuser", "password": "testpass"}) + +class WebsiteUser(HttpUser): + tasks = [UserBehavior] + wait_time = between(1, 5) # Wait between requests to simulate real usage diff --git a/locusts/readme.md b/locusts/readme.md new file mode 100644 index 0000000000..e3222d24de --- /dev/null +++ b/locusts/readme.md @@ -0,0 +1,7 @@ +# To do stress test + +- run `pip install locust` +- if there's a warning about path, follow the instructions to put locust in your PATH variable +- run `locust --host=https://peerprep-grp14.com` +- go to `http://localhost:8089` in your browser to access the locust frontend and begin stress test +- you can edit `locustfile.py` to add more endpoints / instructions diff --git a/matching-service-api/.env.example b/matching-service-api/.env.example new file mode 100644 index 0000000000..28af422e62 --- /dev/null +++ b/matching-service-api/.env.example @@ -0,0 +1,4 @@ +PORT=9200 +RABBIT_URI= +CORS_ORIGIN= + diff --git a/matching-service-api/.gitignore b/matching-service-api/.gitignore new file mode 100644 index 0000000000..fbf828d63a --- /dev/null +++ b/matching-service-api/.gitignore @@ -0,0 +1 @@ +log \ No newline at end of file diff --git a/matching-service-api/Dockerfile b/matching-service-api/Dockerfile new file mode 100644 index 0000000000..6399e54f32 --- /dev/null +++ b/matching-service-api/Dockerfile @@ -0,0 +1,16 @@ +FROM golang:1.20 + +WORKDIR /matching-service-api + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# Build +RUN CGO_ENABLED=0 GOOS=linux go build -o /matching-service-api/app + +EXPOSE 9200 + +# Run +CMD ["/matching-service-api/app"] \ No newline at end of file diff --git a/matching-service-api/go.mod b/matching-service-api/go.mod new file mode 100644 index 0000000000..5bff70930f --- /dev/null +++ b/matching-service-api/go.mod @@ -0,0 +1,37 @@ +module matching-service-api + +go 1.20 + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/cors v1.7.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gin-gonic/gin v1.10.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/streadway/amqp v1.1.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/matching-service-api/go.sum b/matching-service-api/go.sum new file mode 100644 index 0000000000..d9b585090b --- /dev/null +++ b/matching-service-api/go.sum @@ -0,0 +1,91 @@ +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= +github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/streadway/amqp v1.1.0 h1:py12iX8XSyI7aN/3dUT8DFIDJazNJsVJdxNVEpnQTZM= +github.com/streadway/amqp v1.1.0/go.mod h1:WYSrTEYHOXHd0nwFeUXAe2G2hRnQT+deZJJf88uS9Bg= +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= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/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.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/matching-service-api/log/matching_service_api.log b/matching-service-api/log/matching_service_api.log new file mode 100644 index 0000000000..a8bd4b4e2a --- /dev/null +++ b/matching-service-api/log/matching_service_api.log @@ -0,0 +1,170 @@ +time="2024-10-13T11:40:53+08:00" level=info msg="Server started at time: 2024-10-13 11:40:53.9651404 +0800 +08 m=+0.020315501" +time="2024-10-13T11:44:08+08:00" level=info msg="Server started at time: 2024-10-13 11:44:08.2137412 +0800 +08 m=+0.019447901" +time="2024-10-13T11:45:18+08:00" level=info msg="Server started at time: 2024-10-13 11:45:18.8711385 +0800 +08 m=+0.018465501" +time="2024-10-13T11:46:37+08:00" level=info msg="Server started at time: 2024-10-13 11:46:37.0114407 +0800 +08 m=+0.019357101" +time="2024-10-13T11:46:49+08:00" level=info msg="request from user hello successfully published" +time="2024-10-13T11:47:24+08:00" level=info msg="request from user hello successfully published" +time="2024-10-13T11:52:42+08:00" level=info msg="Server started at time: 2024-10-13 11:52:42.820679 +0800 +08 m=+0.019017101" +time="2024-10-13T11:52:49+08:00" level=error msg="error publishing message:Exception (504) Reason: \"channel/connection is not open\"" +time="2024-10-13T11:52:49+08:00" level=info msg="request from user hello successfully published" +time="2024-10-13T11:54:36+08:00" level=info msg="Server started at time: 2024-10-13 11:54:36.2585996 +0800 +08 m=+0.018451501" +time="2024-10-13T11:54:39+08:00" level=error msg="error publishing message:Exception (504) Reason: \"channel/connection is not open\"" +time="2024-10-13T11:54:39+08:00" level=info msg="request from user hello successfully published" +time="2024-10-13T12:54:45+08:00" level=info msg="Server started at time: 2024-10-13 12:54:45.809209 +0800 +08 m=+0.018372701" +time="2024-10-13T12:55:54+08:00" level=info msg="Server started at time: 2024-10-13 12:55:54.7209082 +0800 +08 m=+0.017350501" +time="2024-10-13T12:55:59+08:00" level=info msg="request from user hello successfully published" +time="2024-10-13T12:56:13+08:00" level=info msg="request from user hello successfully published" +time="2024-10-15T21:16:09+08:00" level=info msg="Server started at time: 2024-10-15 21:16:09.5830829 +0800 +08 m=+0.018717201" +time="2024-10-15T21:19:11+08:00" level=info msg="request from user user1 successfully published" +time="2024-10-15T21:24:05+08:00" level=info msg="request from user user1 successfully published" +time="2024-10-15T21:24:19+08:00" level=info msg="request from user user1 successfully published" +time="2024-10-15T21:27:07+08:00" level=info msg="request from user user1 successfully published" +time="2024-10-15T21:27:16+08:00" level=info msg="request from user user2 successfully published" +time="2024-10-15T21:34:56+08:00" level=info msg="request from user user2 successfully published" +time="2024-10-15T21:35:04+08:00" level=info msg="request from user user1 successfully published" +time="2024-10-16T11:47:58+08:00" level=info msg="Server started at time: 2024-10-16 11:47:58.9097737 +0800 +08 m=+0.018180201" +time="2024-10-16T11:58:13+08:00" level=info msg="request from user user1 successfully published" +time="2024-10-16T11:58:19+08:00" level=info msg="request from user user2 successfully published" +time="2024-10-16T12:04:09+08:00" level=info msg="request from user user1 successfully published" +time="2024-10-16T12:04:12+08:00" level=info msg="request from user user2 successfully published" +time="2024-10-16T12:06:43+08:00" level=info msg="request from user user2 successfully published" +time="2024-10-16T12:06:47+08:00" level=info msg="request from user user1 successfully published" +time="2024-10-16T12:12:09+08:00" level=info msg="request from user user1 successfully published" +time="2024-10-16T12:12:14+08:00" level=info msg="request from user user2 successfully published" +time="2024-10-16T12:16:10+08:00" level=info msg="request from user user1 successfully published" +time="2024-10-16T12:16:15+08:00" level=info msg="request from user user2 successfully published" +time="2024-10-25T22:40:44+08:00" level=info msg="Server started at time: 2024-10-25 22:40:44.6771508 +0800 +08 m=+0.090065801" +time="2024-10-25T23:25:28+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-27T15:25:31+08:00" level=info msg="Server started at time: 2024-10-27 15:25:31.7266589 +0800 +08 m=+0.095761501" +time="2024-10-27T15:26:55+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-27T15:26:55+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-27T15:29:09+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-27T15:29:10+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-27T16:08:08+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-27T16:08:10+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-27T16:16:30+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-27T16:16:33+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-27T16:19:10+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-27T16:19:10+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-27T16:19:41+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-27T16:19:41+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-27T16:21:10+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-27T16:21:11+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-27T16:21:12+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-27T16:21:23+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-27T16:21:24+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-27T16:22:44+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-27T16:22:44+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-27T16:27:03+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-27T16:27:05+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-27T16:27:48+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-27T16:27:50+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-27T16:28:28+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-27T16:28:28+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-27T16:29:33+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-27T16:29:33+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-27T16:34:11+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-27T16:34:12+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-27T16:35:54+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-27T16:35:56+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-27T16:36:54+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-27T16:36:55+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-28T13:01:44+08:00" level=info msg="Server started at time: 2024-10-28 13:01:44.2402807 +0800 +08 m=+0.155052801" +time="2024-10-28T13:06:50+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T13:06:50+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-28T13:06:51+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T13:06:52+08:00" level=info msg="request from user 671f1ba30b0e2619aaa4dd7a successfully published" +time="2024-10-28T13:12:22+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T13:12:24+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-28T13:50:55+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-28T13:51:03+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T13:57:58+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-28T13:57:59+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T13:59:34+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-28T13:59:35+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T14:00:45+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-28T14:00:46+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T14:04:50+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-28T14:04:51+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T14:10:02+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-28T14:10:03+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T14:17:49+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T14:17:49+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-28T14:17:50+08:00" level=info msg="request from user 671f1ba30b0e2619aaa4dd7a successfully published" +time="2024-10-28T14:17:50+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T14:20:11+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-28T14:20:12+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T14:31:29+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-28T14:31:29+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T20:45:31+08:00" level=info msg="Server started at time: 2024-10-28 20:45:31.7844893 +0800 +08 m=+0.088887001" +time="2024-10-28T20:45:42+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T20:45:42+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T20:46:17+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T20:46:18+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T20:53:53+08:00" level=info msg="Server started at time: 2024-10-28 20:53:53.3049845 +0800 +08 m=+0.077096601" +time="2024-10-28T20:56:49+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T20:56:49+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T20:58:05+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T20:58:06+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T21:00:59+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T21:00:59+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T21:01:35+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T21:01:35+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T21:09:10+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T21:09:11+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T21:32:14+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T21:32:15+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T21:33:06+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T21:33:07+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T21:33:37+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T21:33:37+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T21:34:03+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T21:34:05+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T21:34:46+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T21:34:46+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T21:36:18+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T21:36:19+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T21:39:20+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T21:39:21+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T21:40:23+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T21:40:25+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T21:41:06+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T21:41:07+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T21:43:19+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T21:43:21+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T21:43:49+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T21:43:50+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T21:45:11+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T21:45:12+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T21:48:21+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T21:48:21+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-28T21:49:30+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T21:49:30+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-28T21:49:46+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T21:49:46+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-28T21:52:11+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T21:52:11+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-28T21:54:06+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T21:54:07+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-28T21:54:48+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T21:54:48+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-28T21:57:05+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T21:57:05+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-28T21:57:21+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T21:57:22+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-28T21:57:56+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T21:57:57+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-28T22:00:23+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-28T22:00:23+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T22:03:12+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T22:03:13+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-28T22:03:49+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T22:03:50+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-28T22:05:59+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-28T22:05:59+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T22:07:05+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T22:07:05+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-28T22:07:25+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" +time="2024-10-28T22:07:25+08:00" level=info msg="request from user 6702c9a3f1217a1b2123575b successfully published" +time="2024-10-28T22:09:51+08:00" level=info msg="request from user 671f1bdf0b0e2619aaa4dd85 successfully published" +time="2024-10-28T22:09:52+08:00" level=info msg="request from user 67188e6ebe7127ec5d472f15 successfully published" diff --git a/matching-service-api/main.go b/matching-service-api/main.go new file mode 100644 index 0000000000..1b72b5c0b8 --- /dev/null +++ b/matching-service-api/main.go @@ -0,0 +1,72 @@ +package main + +import( + + "fmt" + "log" + "os" + "time" + + "matching-service-api/models" + "github.com/joho/godotenv" + + "github.com/gin-gonic/gin" + "matching-service-api/transport" +) + +func main() { + //initialise logger file and directory if they do not exist + + err := godotenv.Load(".env") + if err != nil { + log.Fatal("Error loading environment variables: " + err.Error()) + } + + ORIGIN := os.Getenv("CORS_ORIGIN") + if ORIGIN == "" { + ORIGIN = "http://localhost:3000" + } + PORT := os.Getenv("PORT") + if PORT == "" { + PORT = ":9200" + } + + logger := models.NewLogger() + + logDirectory := "./log" + + if err := os.MkdirAll(logDirectory, 0755); err != nil { + logger.Log.Error("Failed to create log directory: " + err.Error()) + } + + logFile, err := os.OpenFile("./log/matching_service_api.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + + if err != nil { + logger.Log.Warn("Failed to log to file, using default stderr") + } + + defer logFile.Close() + + logger.Log.Out = logFile + + URI := os.Getenv("RABBIT_URI") + if URI == "" { + logger.Log.Fatal("Error finding the queue URI") + } + channel, err := models.InitialiseQueue(URI) + + if err != nil { + panic(err) + } + + defer channel.Connection.Close() + defer channel.Channel.Close() + + router := gin.Default() + transport.SetCors(router, ORIGIN) + transport.SetAllEndpoints(router, channel, logger) + + logger.Log.Info(fmt.Sprintf("Server started at time: %s", time.Now().String())) + + router.Run(PORT) +} \ No newline at end of file diff --git a/matching-service-api/mappings/client_mappings.go b/matching-service-api/mappings/client_mappings.go new file mode 100644 index 0000000000..5833f90858 --- /dev/null +++ b/matching-service-api/mappings/client_mappings.go @@ -0,0 +1,18 @@ +package mappings + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "io" +) + +func GenerateMatchingHash() (string, error) { + bytes := make([]byte, 16) + + if _, err := io.ReadFull(rand.Reader, bytes); err != nil { + return "", errors.New("Failed to generate random matching hash" + err.Error()) + } + + return hex.EncodeToString(bytes), nil +} diff --git a/matching-service-api/models/logger.go b/matching-service-api/models/logger.go new file mode 100644 index 0000000000..2149803b88 --- /dev/null +++ b/matching-service-api/models/logger.go @@ -0,0 +1,15 @@ +package models + +import ( + "github.com/sirupsen/logrus" +) + +type Logger struct { + Log *logrus.Logger +} + +func NewLogger() *Logger { + return &Logger { + Log: logrus.New(), + } +} \ No newline at end of file diff --git a/matching-service-api/models/producer_queue.go b/matching-service-api/models/producer_queue.go new file mode 100644 index 0000000000..1f0435551d --- /dev/null +++ b/matching-service-api/models/producer_queue.go @@ -0,0 +1,57 @@ +package models + +import ( + rabbit "github.com/streadway/amqp" + "log" + "time" +) + +type ProducerQueue struct { + Connection *rabbit.Connection + Channel *rabbit.Channel + + Queue rabbit.Queue +} + +func InitialiseQueue(URI string) (*ProducerQueue, error) { + var connection *rabbit.Connection + var err error + + for i := 0; i < 10; i++ { + connection, err = rabbit.Dial(URI) + if err == nil { + break + } + log.Printf("Could not establish connection to RabbitMQ, retrying in 5 seconds... (%d/10)\n", i+1) + time.Sleep(5 * time.Second) + } + + if err != nil { + return nil, err + } + + channel, err := connection.Channel() + + if err != nil { + return nil, err + } + + queue, err := channel.QueueDeclare( + "match_queue", // name of the queue + true, // durable + false, // delete when unused + false, // exclusive + false, // no-wait + nil, // arguments + ) + + if err != nil { + return nil, err + } + + return &ProducerQueue{ + Connection: connection, + Channel: channel, + Queue: queue, + }, nil +} \ No newline at end of file diff --git a/matching-service-api/models/request.go b/matching-service-api/models/request.go new file mode 100644 index 0000000000..910658e08c --- /dev/null +++ b/matching-service-api/models/request.go @@ -0,0 +1,13 @@ +package models + +type Request struct { + MatchHash string + + UserId string `json:"userId"` + + TopicTags []string `json:"topicTags"` + + Difficulty string `json:"difficulty"` + + RequestTime string `json:"requestTime"` +} \ No newline at end of file diff --git a/matching-service-api/transport/endpoints.go b/matching-service-api/transport/endpoints.go new file mode 100644 index 0000000000..fc171adcb4 --- /dev/null +++ b/matching-service-api/transport/endpoints.go @@ -0,0 +1,25 @@ +package transport + +import ( + "matching-service-api/models" + "time" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +func SetAllEndpoints(router *gin.Engine, producerQueue *models.ProducerQueue, logger *models.Logger) { + router.POST("/request", HandleRequest(producerQueue, logger)) + +} + +func SetCors(router *gin.Engine, origin string) { + router.Use(cors.New(cors.Config{ + AllowOrigins: []string{origin}, + AllowMethods: []string{"POST", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Content-Length", "Authorization"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + MaxAge: 2 * time.Minute, + })) +} diff --git a/matching-service-api/transport/request_handler.go b/matching-service-api/transport/request_handler.go new file mode 100644 index 0000000000..ca6424be5b --- /dev/null +++ b/matching-service-api/transport/request_handler.go @@ -0,0 +1,78 @@ +package transport + +import ( + "encoding/json" + "fmt" + "matching-service-api/models" + "matching-service-api/mappings" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/streadway/amqp" +) + +func HandleRequest(channel *models.ProducerQueue, logger *models.Logger) gin.HandlerFunc { + return func(ctx *gin.Context) { + var req models.Request + + if err := ctx.BindJSON(&req); err != nil { + logger.Log.Error("error receiving request: ", err.Error()) + ctx.JSON(http.StatusBadGateway, gin.H{"error receiving request": err.Error()}) + return + } + + parsedTime, err := time.Parse("2006-01-02 15-04-05", req.RequestTime) + + if err != nil { + logger.Log.Error("error parsing the time: ", err.Error()) + ctx.JSON(http.StatusBadRequest, "error parsing time, ensure time is parsed in YYYY-MM-DD HH:mm:ss format") + return + } + + // adding this matching hash to reserve user-id as a store for + // persistence. + matchHash, err := mappings.GenerateMatchingHash(); + if err != nil { + logger.Log.Error("Error: " + err.Error()) + ctx.JSON(http.StatusInternalServerError, "failed to generate query hash") + return + } + req.MatchHash = matchHash; + + //current time is more than 30 seconds after request time, timeout + if time.Now().After(parsedTime.Add(30 * time.Second).Add(-8 * time.Hour)) { + logger.Log.Warn("request timeout") + ctx.JSON(http.StatusRequestTimeout, "request time is too old") + return + } + + message, err := json.Marshal(req) + + if err != nil { + logger.Log.Error("error converting request to bytes: ", err.Error()) + ctx.JSON(http.StatusBadGateway, "error processing request") + return + } + + if err := channel.Channel.Publish( + "", + channel.Queue.Name, + false, + false, + amqp.Publishing{ + DeliveryMode: amqp.Persistent, + ContentType: "text/plain", + Body: []byte(message), + }); err != nil { + logger.Log.Error("error publishing message:", err.Error()) + return + } + + logger.Log.Info(fmt.Sprintf("request from user %s successfully published", req.UserId)) + ctx.JSON(http.StatusOK, gin.H{ + "match_code": matchHash, + "message": "processing request", + }) + } +} diff --git a/matching-service/.env.example b/matching-service/.env.example new file mode 100644 index 0000000000..3a9910a6fa --- /dev/null +++ b/matching-service/.env.example @@ -0,0 +1,5 @@ +RABBIT_URI= + +REDIS_URI= + +BACKEND_MATCH_URI="[BACKEND_URI]/match" \ No newline at end of file diff --git a/matching-service/.gitignore b/matching-service/.gitignore new file mode 100644 index 0000000000..fbf828d63a --- /dev/null +++ b/matching-service/.gitignore @@ -0,0 +1 @@ +log \ No newline at end of file diff --git a/matching-service/Dockerfile b/matching-service/Dockerfile new file mode 100644 index 0000000000..21b5d761e6 --- /dev/null +++ b/matching-service/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.20 + +WORKDIR /matching-service + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# Build +RUN CGO_ENABLED=0 GOOS=linux go build -o /matching-service/app + +# Run +CMD ["/matching-service/app"] \ No newline at end of file diff --git a/matching-service/consumer/begin_consuming.go b/matching-service/consumer/begin_consuming.go new file mode 100644 index 0000000000..414c9994a3 --- /dev/null +++ b/matching-service/consumer/begin_consuming.go @@ -0,0 +1,35 @@ +package consumer + +import ( + "matching-service/models" + db "matching-service/storage" +) + +func BeginConsuming(mq *models.MessageQueue, logger *models.Logger, clientMappings *db.ClientMappings, roomMappings *db.RoomMappings) { + logger.Log.Info("Begin processing requests") + + msgs, err := mq.Channel.Consume( + mq.Queue.Name, // queue + "", // consumer + true, // auto-ack + false, // exclusive + false, // no-local + false, // no-wait + nil, // args + ) + + if err != nil { + logger.Log.Error("Error when consuming requests:" + err.Error()) + } + + forever := make(chan bool) + + go func() { + for req := range msgs { + if err := Process(req, clientMappings, roomMappings); err != nil { + logger.Log.Error(err.Error()) + } + } + }() + <-forever //blocks forever +} diff --git a/matching-service/consumer/process_request.go b/matching-service/consumer/process_request.go new file mode 100644 index 0000000000..b8a4f77a23 --- /dev/null +++ b/matching-service/consumer/process_request.go @@ -0,0 +1,60 @@ +//This is the logic to handle user requests. +//Each client is internally mapped to all the possible questions that satisfy their request. +//If another user comes where their possible questions overlap with that of another user, a random question in the intersection is selected. + +package consumer + +import ( + "context" + "encoding/json" + "fmt" + "matching-service/models" + db "matching-service/storage" + + rabbit "github.com/streadway/amqp" +) + +func Process(msg rabbit.Delivery, clientMappings *db.ClientMappings, roomMappings *db.RoomMappings) error { + var request models.IncomingRequests + + if err := json.Unmarshal(msg.Body, &request); err != nil { + return fmt.Errorf("error unmarshling the request from JSON: %s", err.Error()) + } + + keys, err := clientMappings.Conn.Keys(context.Background(), "*").Result() + + if err != nil { + fmt.Println("error getting keys") + } else { + fmt.Printf("queue before user match: %s / ",keys) + } + + room, err := clientMappings.HandleRequest(request) + + if err != nil { + return fmt.Errorf("error handling incoming request: %s", err.Error()) + } + + keys, err = clientMappings.Conn.Keys(context.Background(), "*").Result() + + if err != nil { + fmt.Println("error getting keys") + } else { + fmt.Printf("queue after user match:%s / ", keys) + } + + if err != nil { + return fmt.Errorf("error handling incoming request: %s", err.Error()) + } + + fmt.Println("success handling incoming request!") + if room != nil { + if err := roomMappings.SendToStorageBlob(room); err != nil { + return err + } + + fmt.Println("success sending to storage blob") + } + + return nil +} diff --git a/matching-service/go.mod b/matching-service/go.mod new file mode 100644 index 0000000000..36d5dc7d8e --- /dev/null +++ b/matching-service/go.mod @@ -0,0 +1,13 @@ +module matching-service + +go 1.20 + +require ( + github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/go-redis/redis/v8 v8.11.5 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/streadway/amqp v1.1.0 // indirect + golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect +) diff --git a/matching-service/go.sum b/matching-service/go.sum new file mode 100644 index 0000000000..58a675bd43 --- /dev/null +++ b/matching-service/go.sum @@ -0,0 +1,21 @@ +github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/streadway/amqp v1.1.0 h1:py12iX8XSyI7aN/3dUT8DFIDJazNJsVJdxNVEpnQTZM= +github.com/streadway/amqp v1.1.0/go.mod h1:WYSrTEYHOXHd0nwFeUXAe2G2hRnQT+deZJJf88uS9Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/matching-service/log/matching_service.log b/matching-service/log/matching_service.log new file mode 100644 index 0000000000..3150348d5f --- /dev/null +++ b/matching-service/log/matching_service.log @@ -0,0 +1,44 @@ +time="2024-10-15T21:34:37+08:00" level=info msg="Beginning consumption from message queue" +time="2024-10-15T21:34:37+08:00" level=info msg="Begin processing requests" +time="2024-10-16T11:47:46+08:00" level=info msg="Beginning consumption from message queue" +time="2024-10-16T11:47:46+08:00" level=info msg="Begin processing requests" +time="2024-10-16T12:06:22+08:00" level=info msg="Beginning consumption from message queue" +time="2024-10-16T12:06:22+08:00" level=info msg="Begin processing requests" +time="2024-10-16T12:11:53+08:00" level=info msg="Beginning consumption from message queue" +time="2024-10-16T12:11:53+08:00" level=info msg="Begin processing requests" +time="2024-10-17T22:06:35+08:00" level=info msg="Beginning consumption from message queue" +time="2024-10-17T22:06:35+08:00" level=info msg="Begin processing requests" +time="2024-10-17T22:18:03+08:00" level=info msg="Beginning consumption from message queue" +time="2024-10-17T22:18:03+08:00" level=info msg="Begin processing requests" +time="2024-10-17T22:32:32+08:00" level=info msg="Beginning consumption from message queue" +time="2024-10-17T22:32:32+08:00" level=info msg="Begin processing requests" +time="2024-10-17T22:33:42+08:00" level=info msg="Beginning consumption from message queue" +time="2024-10-17T22:33:42+08:00" level=info msg="Begin processing requests" +time="2024-10-17T22:36:02+08:00" level=info msg="Beginning consumption from message queue" +time="2024-10-17T22:36:02+08:00" level=info msg="Begin processing requests" +time="2024-10-17T22:36:17+08:00" level=error msg="error parsing the time: parsing time \"2024-10-17 22-36-30\" as \"2006-01-02 15-04-05 +0800\": cannot parse \"\" as \" +0800\"" +time="2024-10-17T22:39:31+08:00" level=info msg="Beginning consumption from message queue" +time="2024-10-17T22:39:31+08:00" level=info msg="Begin processing requests" +time="2024-10-17T22:43:06+08:00" level=info msg="Beginning consumption from message queue" +time="2024-10-17T22:43:06+08:00" level=info msg="Begin processing requests" +time="2024-10-17T22:51:23+08:00" level=info msg="Beginning consumption from message queue" +time="2024-10-17T22:51:23+08:00" level=info msg="Begin processing requests" +time="2024-10-25T22:40:12+08:00" level=info msg="Beginning consumption from message queue" +time="2024-10-25T22:40:12+08:00" level=info msg="Begin processing requests" +time="2024-10-25T23:25:28+08:00" level=error msg="error handling incoming request: dial tcp [::1]:6379: connectex: No connection could be made because the target machine actively refused it." +time="2024-10-27T15:25:08+08:00" level=info msg="Beginning consumption from message queue" +time="2024-10-27T15:25:08+08:00" level=info msg="Begin processing requests" +time="2024-10-27T15:26:55+08:00" level=error msg="error handling incoming request: dial tcp [::1]:6379: connectex: No connection could be made because the target machine actively refused it." +time="2024-10-27T15:26:56+08:00" level=error msg="error handling incoming request: dial tcp [::1]:6379: connectex: No connection could be made because the target machine actively refused it." +time="2024-10-27T15:28:11+08:00" level=info msg="Beginning consumption from message queue" +time="2024-10-27T15:28:11+08:00" level=info msg="Begin processing requests" +time="2024-10-27T15:29:09+08:00" level=error msg="error handling incoming request: WRONGTYPE Operation against a key holding the wrong kind of value" +time="2024-10-27T15:29:10+08:00" level=error msg="error handling incoming request: WRONGTYPE Operation against a key holding the wrong kind of value" +time="2024-10-27T16:08:08+08:00" level=error msg="error handling incoming request: WRONGTYPE Operation against a key holding the wrong kind of value" +time="2024-10-27T16:08:10+08:00" level=error msg="error handling incoming request: WRONGTYPE Operation against a key holding the wrong kind of value" +time="2024-10-28T13:01:38+08:00" level=info msg="Beginning consumption from message queue" +time="2024-10-28T13:01:38+08:00" level=info msg="Begin processing requests" +time="2024-10-28T20:44:26+08:00" level=info msg="Beginning consumption from message queue" +time="2024-10-28T20:44:26+08:00" level=info msg="Begin processing requests" +time="2024-10-28T20:53:48+08:00" level=info msg="Beginning consumption from message queue" +time="2024-10-28T20:53:48+08:00" level=info msg="Begin processing requests" diff --git a/matching-service/main.go b/matching-service/main.go new file mode 100644 index 0000000000..e9477c8ce3 --- /dev/null +++ b/matching-service/main.go @@ -0,0 +1,141 @@ +package main + +import ( + "log" + "os" + "strconv" + "time" + + "matching-service/consumer" + models "matching-service/models" + "matching-service/storage" + + "github.com/joho/godotenv" + rabbit "github.com/streadway/amqp" + + "github.com/sirupsen/logrus" +) + +func initialiseLogger() (*models.Logger, *os.File) { + logger := models.Logger{ + Log: logrus.New(), + } + + logDirectory := "./log" + + if err := os.MkdirAll(logDirectory, 0755); err != nil { + logger.Log.Error("Failed to create log directory: " + err.Error()) + } + + logFile, err := os.OpenFile("./log/matching_service.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + + if err != nil { + logger.Log.Warn("Failed to log to file, using default stderr") + } + + + logger.Log.Out = logFile + + return &logger, logFile +} + + +func createRabbitChannel(channel *rabbit.Channel, queue rabbit.Queue) *models.MessageQueue { + return &models.MessageQueue{ + Channel: channel, + Queue: queue, + } +} + +func main() { + + if err := godotenv.Load(".env"); err != nil { + log.Fatal("Error loading the environment variables" + err.Error()) + } + + URI := os.Getenv("RABBIT_URI") + + if URI == "" { + log.Fatal("No Rabbit URI found in environment variables") + } + + var connection *rabbit.Connection + var err error + + for i := 0; i < 10; i++ { + connection, err = rabbit.Dial(URI) + if err == nil { + break + } + log.Printf("Could not establish connection to RabbitMQ, retrying in 5 seconds... (%d/10)\n", i+1) + time.Sleep(5 * time.Second) + } + + if err != nil { + log.Fatal("Could not establish connection to RabbitMQ after 10 attempts: " + err.Error()) + } + + defer connection.Close() + + channel, err := connection.Channel() + + if err != nil { + log.Fatal("Could not open a channel" + err.Error()) + } + + defer channel.Close() + + queue, err := channel.QueueDeclare( + "match_queue", // name + true, // durable + false, // delete when unused + false, // exclusive + false, // no-wait + nil, // arguments + ) + + if err != nil { + log.Fatal("Could not declare a queue" + err.Error()) + } + + mq := createRabbitChannel(channel, queue) + + logger, logFile := initialiseLogger() + + defer logFile.Close() + + REDIS_URI := os.Getenv("REDIS_URI") + + if REDIS_URI == "" { + REDIS_URI = "localhost://9190" + } + + REDIS_CLIENT_MAPPING := 0 + REDIS_ROOM_MAPPING := 1 + + if os.Getenv("REDIS_CLIENT_MAPPING") != "" { + num, err := strconv.Atoi(os.Getenv("REDIS_CLIENT_MAPPING")) + if err != nil { + log.Fatal("DB no of client map is badly formatted" + err.Error()) + } else { + REDIS_CLIENT_MAPPING = num + } + } + + if os.Getenv("REDIS_ROOM_MAPPING") != "" { + num, err := strconv.Atoi(os.Getenv("REDIS_ROOM_MAPPING")) + if err != nil { + log.Fatal("DB no of room map is badly formatted" + err.Error()) + } else { + REDIS_ROOM_MAPPING = num + } + } + + clientMappings := storage.InitialiseClientMappings(REDIS_URI, REDIS_CLIENT_MAPPING) + roomMappings := storage.InitialiseRoomMappings(REDIS_URI, REDIS_ROOM_MAPPING) + + + logger.Log.Info("Beginning consumption from message queue") + consumer.BeginConsuming(mq, logger, clientMappings, roomMappings) + +} diff --git a/matching-service/mappings/client_mappings.go b/matching-service/mappings/client_mappings.go new file mode 100644 index 0000000000..80b9eb5868 --- /dev/null +++ b/matching-service/mappings/client_mappings.go @@ -0,0 +1,98 @@ +//this file is deprecated +package mappings + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "matching-service/models" +) + +// TODO: consider using redis to store this information instead +type Mappings struct { + Topics map[string][]string + Difficulty map[string]string +} + +func CreateMappings() *Mappings { + return &Mappings{ + Topics: make(map[string][]string), + Difficulty: make(map[string]string), + } +} + +// TODO: implement logic to implement TTL for values +// logic to find matching categories and generates a room id for the 2 users +func (db *Mappings) HandleRequest(request models.IncomingRequests) (*models.Room, error) { + for user1, topics := range db.Topics { + if difficulty, exists := db.Difficulty[user1]; !exists { + return nil, fmt.Errorf("user %s only exists in topics store and not in difficulty store", user1) + } else if difficulty != request.Difficulty { + continue + } + + overlapping := findOverLap(topics, request.TopicTags) + + // user1 does not match with this user + if len(overlapping) == 0 { + continue + } + + //match found, generate room Id and return the room + if roomId, err := generateRoomId(); err != nil { + return nil, err + } else { + //match found! delete user1 from store + delete(db.Topics, user1) + delete(db.Difficulty, user1) + + return &models.Room{ + RoomId: roomId, + User1: user1, + User2: request.UserId, + TopicTags: overlapping, + Difficulty: request.Difficulty, + }, nil + } + } + + //no match found + //add user2 to the mappings + db.Topics[request.UserId] = request.TopicTags + db.Difficulty[request.UserId] = request.Difficulty + + return nil, nil +} + +func generateRoomId() (string, error) { + bytes := make([]byte, 16) + + if _, err := io.ReadFull(rand.Reader, bytes); err != nil { + return "", errors.New("Failed to generate random room Id" + err.Error()) + } + + return hex.EncodeToString(bytes), nil +} + +func findOverLap(user1 []string, user2 []string) []string { + + stringMap := make(map[string]bool) + var commonStrings []string + + // Store each string from slice1 in the map + for _, topic := range user1 { + stringMap[topic] = true + } + + // Iterate over slice2 and check for common strings + for _, topic := range user2 { + if stringMap[topic] { + commonStrings = append(commonStrings, topic) + delete(stringMap, topic) // Remove to avoid duplicates in result + } + } + + return commonStrings +} diff --git a/matching-service/models/logger.go b/matching-service/models/logger.go new file mode 100644 index 0000000000..be2a439d1d --- /dev/null +++ b/matching-service/models/logger.go @@ -0,0 +1,10 @@ +package models + +import ( + + "github.com/sirupsen/logrus" +) + +type Logger struct { + Log *logrus.Logger +} \ No newline at end of file diff --git a/matching-service/models/matching_request.go b/matching-service/models/matching_request.go new file mode 100644 index 0000000000..329021ee5d --- /dev/null +++ b/matching-service/models/matching_request.go @@ -0,0 +1,14 @@ +package models + +type IncomingRequests struct { + MatchHash string `json:"matchHash"` + UserId string `json:"userId"` + TopicTags []string `json:"topicTags"` + Difficulty string `json:"difficulty"` + RequestTime string `json:"requestTime"` +} + +type OutGoingRequests struct { + TopicTags []string `json:"topicTags"` + Difficulty string `json:"difficulty"` +} \ No newline at end of file diff --git a/matching-service/models/message_queue.go b/matching-service/models/message_queue.go new file mode 100644 index 0000000000..47cdc6ea65 --- /dev/null +++ b/matching-service/models/message_queue.go @@ -0,0 +1,11 @@ +package models + +import ( + rabbit "github.com/streadway/amqp" +) + +type MessageQueue struct { + Channel *rabbit.Channel + Queue rabbit.Queue +} + diff --git a/matching-service/models/room.go b/matching-service/models/room.go new file mode 100644 index 0000000000..d8c25f188d --- /dev/null +++ b/matching-service/models/room.go @@ -0,0 +1,23 @@ +package models + +type Room struct { + // stores what key to ship the resulting blob to + MatchHash1 string `json:"matchHash1"` + MatchHash2 string `json:"matchHash2"` + + // user information + RoomId string `json:"roomId"` + User1 string `json:"user1"` + User2 string `json:"user2"` + RequestTime string `json:"requestTime"` //takes user1's requestTime since this is older + + //contains question Data + Title string `json:"title"` + TitleSlug string `json:"titleSlug"` + Difficulty string `json:"difficulty"` + TopicTags []string `json:"topicTags"` + Content string `json:"content"` + Schemas []string `json:"schemas"` + QuestionId int `json:"id"` + +} diff --git a/matching-service/storage/client_mappings.go b/matching-service/storage/client_mappings.go new file mode 100644 index 0000000000..5622044e23 --- /dev/null +++ b/matching-service/storage/client_mappings.go @@ -0,0 +1,175 @@ +package storage + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "io" + "matching-service/models" + "matching-service/transport" + "time" + + redis "github.com/go-redis/redis/v8" +) + + +type ClientMappings struct { + Conn *redis.Client +} + +func InitialiseClientMappings(addr string, db_num int) *ClientMappings { + conn := redis.NewClient(&redis.Options{ + Addr:addr, + DB: db_num, + }) + + return &ClientMappings{ + Conn: conn, + } + +} + +func (db *ClientMappings) HandleRequest(request models.IncomingRequests) (*models.Room, error){ + ctx := context.Background() + user2, user2_difficulty, user2_topics := request.UserId, request.Difficulty, request.TopicTags + user2_requestTime, user2_matchHash := request.RequestTime, request.MatchHash + + currMappings, err := db.Conn.Keys(ctx, "*").Result() + + if err != nil { + return nil, err + } + + for _, user1 := range currMappings { + + if user1 == user2 { + //users cannot match with themselves + continue + } + + result, err := db.Conn.HGetAll(ctx, user1).Result() + + if err == redis.Nil { + continue //key expired + } else if err != nil { + return nil, err + } + + var user1_topics []string + if err := json.Unmarshal([]byte(result["topicTags"]), &user1_topics); err != nil { + return nil, err + } + + user1_difficulty := result["difficulty"] + user1_requestTime := result["requestTime"] + + if user1_difficulty != user2_difficulty { + continue + } + + overlappingTopics := findOverlap(user1_topics, user2_topics) + + if len(overlappingTopics) == 0 { + continue + } + + roomId, err := generateRoomId() + + if err != nil { + return nil, err + } + + user1_matchHash := result["matchHash"] + + db.Conn.Del(ctx, user1) + + room := models.Room{ + MatchHash1: user1_matchHash, + MatchHash2: user2_matchHash, + RoomId: roomId, + User1: user1, + User2: user2, + RequestTime: user1_requestTime, + } + + err = transport.FindSuitableQuestionId(overlappingTopics, user1_difficulty, &room) + + if err != nil { + return nil, err + } else if room.QuestionId == 0 { + //no matching question + continue + } + + return &room, nil + + } + + //no match found + + user2_topics_json, err := json.Marshal(user2_topics) + + if err != nil { + return nil, err + } + + err = db.Conn.HSet(ctx, user2, map[string]interface{}{ + "matchHash": user2_matchHash, + "topicTags": user2_topics_json, + "difficulty": user2_difficulty, + "requestTime": user2_requestTime, + }).Err() + + if err != nil { + return nil, err + } + + requestTime, err := time.Parse("2006-01-02 15-04-05", user2_requestTime) + + if err != nil { + return nil, err + } + + expiryTime := requestTime.Add(30 * time.Second).Add(-8 * time.Hour) + diff := int(time.Until(expiryTime).Seconds()) + err = db.Conn.Expire(ctx, user2, time.Duration(diff) * time.Second).Err() + + if err != nil { + return nil, err + } + + return nil, nil +} + +func findOverlap(user1 []string, user2 []string) []string { + + stringMap := make(map[string]bool) + var commonStrings []string + + // Store each string from slice1 in the map + for _, topic := range user1 { + stringMap[topic] = true + } + + // Iterate over slice2 and check for common strings + for _, topic := range user2 { + if stringMap[topic] { + commonStrings = append(commonStrings, topic) + delete(stringMap, topic) // Remove to avoid duplicates in result + } + } + + return commonStrings +} + +func generateRoomId() (string, error) { + bytes := make([]byte, 16) + + if _, err := io.ReadFull(rand.Reader, bytes); err != nil { + return "", errors.New("Failed to generate random room Id" + err.Error()) + } + + return hex.EncodeToString(bytes), nil +} \ No newline at end of file diff --git a/matching-service/storage/room_mappings.go b/matching-service/storage/room_mappings.go new file mode 100644 index 0000000000..47826e2d15 --- /dev/null +++ b/matching-service/storage/room_mappings.go @@ -0,0 +1,102 @@ +package storage + +import ( + "context" + "encoding/json" + "fmt" + "matching-service/models" + "time" + + redis "github.com/go-redis/redis/v8" +) + +// same as client mappings, but separated for type safety +type RoomMappings struct { + Conn *redis.Client +} + +func InitialiseRoomMappings(addr string, db_num int) *RoomMappings { + conn := redis.NewClient(&redis.Options{ + Addr: addr, + DB: db_num, + }) + + return &RoomMappings{ + Conn: conn, + } +} + +func (db *RoomMappings) SendToStorageBlob(room *models.Room) error { + ctx := context.Background() + topics_json, err := json.Marshal(room.TopicTags) + + if err != nil { + return fmt.Errorf("error marshling topics: %s", err.Error()) + } + + schema_json, err := json.Marshal(room.Schemas) + + if err != nil { + return fmt.Errorf("error marshling topics: %s", err.Error()) + } + + // this is where the value is being set + user1_info := map[string]interface{}{ + "roomId": room.RoomId, + "thisUser": room.User1, + "otherUser": room.User2, + "requestTime": room.RequestTime, + + "title": room.Title, + "titleSlug": room.TitleSlug, + "difficulty": room.Difficulty, + "topicTags": topics_json, + "content": room.Content, + "schemas": schema_json, + "id": room.QuestionId, + } + + user2_info := map[string]interface{}{ + "roomId": room.RoomId, + "thisUser": room.User2, + "otherUser": room.User1, + "requestTime": room.RequestTime, + + "title": room.Title, + "titleSlug": room.TitleSlug, + "difficulty": room.Difficulty, + "topicTags": topics_json, + "content": room.Content, + "schemas": schema_json, + "id": room.QuestionId, + } + + // TODO: Modify this - this is where the key-value is being set + if err1 := db.Conn.HSet(ctx, room.MatchHash1, user1_info).Err(); err1 != nil { + return fmt.Errorf("error setting user1's room to storage: %s", err1.Error()) + } + + if err2 := db.Conn.HSet(ctx, room.MatchHash2, user2_info).Err(); err2 != nil { + return fmt.Errorf("error setting user2's room to storage: %s", err2.Error()) + } + + requestTime, err := time.Parse("2006-01-02 15-04-05", room.RequestTime) + + if err != nil { + return fmt.Errorf("error parsing the time: %s", err.Error()) + } + + expiryTime := requestTime.Add(30 * time.Second).Add(-8 * time.Hour) + + diff := int(time.Until(expiryTime).Seconds()) + + if err1 := db.Conn.Expire(ctx, room.MatchHash1, time.Duration(diff)*time.Second).Err(); err1 != nil { + return fmt.Errorf("error setting expiry time on room data: %s", err1.Error()) + } + + if err2 := db.Conn.Expire(ctx, room.MatchHash2, time.Duration(diff)*time.Second).Err(); err2 != nil { + return fmt.Errorf("error setting expiry time on room data: %s", err2.Error()) + } + + return nil +} diff --git a/matching-service/transport/request_questions.go b/matching-service/transport/request_questions.go new file mode 100644 index 0000000000..8237499968 --- /dev/null +++ b/matching-service/transport/request_questions.go @@ -0,0 +1,70 @@ +//handles the request to the question-service to find a suitable question for the 2 users to match on + +package transport + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "io" + "matching-service/models" + "net/http" +) + +func FindSuitableQuestionId(topicTags []string, difficulty string, target *models.Room) (error) { + data := models.OutGoingRequests{ + TopicTags: topicTags, + Difficulty: difficulty, + } + + reqBody, err := json.Marshal(data) + + if err != nil { + return fmt.Errorf("failed to convert outgoing req to JSON: %s", err.Error()) + } + + URI := os.Getenv("BACKEND_MATCH_URI") + + if URI == "" { + URI = "http://localhost:9090/match" + } + + req, err := http.NewRequest("POST", URI, bytes.NewBuffer(reqBody)) + + if err != nil { + return fmt.Errorf("failed to make request: %s", err.Error()) + } + + req.Header.Set("Content-Type", "application/json") + + client := http.DefaultClient + + resp, err := client.Do(req) + + if err != nil { + return fmt.Errorf("error sending request: %s", err.Error()) + } else if resp.StatusCode == http.StatusNotFound { + //no matching questions + return nil + } else if resp.StatusCode >= 300 { + return fmt.Errorf("question service encountered error when processing request") + } + + + body, err := io.ReadAll(resp.Body) + + if err != nil { + return fmt.Errorf("error reading response body: %s", err.Error()) + } + + //unmarshal the data into the target room struct + err = json.Unmarshal(body, target) + + if err != nil { + return fmt.Errorf("error unmarshalling JSON to question: %s", err.Error()) + } + + + return nil +} \ No newline at end of file diff --git a/nginx/Dockerfile b/nginx/Dockerfile new file mode 100644 index 0000000000..4bc7df8168 --- /dev/null +++ b/nginx/Dockerfile @@ -0,0 +1,4 @@ +FROM nginx:alpine +COPY nginx.conf /etc/nginx/internal.conf +EXPOSE 70 +CMD ["nginx", "-c", "internal.conf", "-g", "daemon off;"] \ No newline at end of file diff --git a/nginx/README.md b/nginx/README.md new file mode 100644 index 0000000000..28f1e06e06 --- /dev/null +++ b/nginx/README.md @@ -0,0 +1,7 @@ +nginx is dockerised, just have to run `docker compose up --build` as usual. + +if edits are made to the local nginx.conf file, following command must be run to see changes reflected in docker: + +`docker exec cs3219-ay2425s1-project-g14-nginx-1 nginx -s reload` + +(or just exec `nginx -s reload` in the container directly) diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000000..e4ee87b73f --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,124 @@ +worker_processes 1; + +events { + worker_connections 1024; +} + +http { + include mime.types; + + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + sendfile on; + #tcp_nopush on; + + keepalive_timeout 65; + + + + upstream peerprep { + server peerprep:3000; + } + + upstream user_service { + server user-service:3001; + } + + upstream backend { + server backend:9090; + } + + upstream matching_service_api { + server matching-service-api:9200; + } + + upstream storage_blob_api { + server storage-blob-api:9300; + } + + upstream collab { + server collab:4000; + } + + upstream formatter { + server formatter:5000; + } + + # upstream comms { + # server comms:4001; + # } + + server { + listen 70; + location / { + proxy_pass http://peerprep/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 86400; + } + + location /users/ { + proxy_pass http://user_service/; + + } + + location /backend/ { + proxy_pass http://backend/; + } + + location /matchmaking/ { + proxy_pass http://matching_service_api/; + } + + location /blob/ { + proxy_pass http://storage_blob_api/; + } + + location /collab/ { + proxy_pass http://collab/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 86400; + } + + location /formatter/ { + proxy_pass http://formatter/; + } + + # location /comms/ { + # proxy_pass http://comms/; + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Forwarded-Proto $scheme; + # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # proxy_http_version 1.1; + # proxy_read_timeout 86400; + # } + + # location /socket.io/ { + # proxy_pass http://comms/socket.io/; + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Forwarded-Proto $scheme; + # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # proxy_http_version 1.1; + # proxy_set_header Upgrade $http_upgrade; + # proxy_set_header Connection "upgrade"; + # proxy_read_timeout 86400; + # } + } +} \ No newline at end of file diff --git a/peerprep/.dockerignore b/peerprep/.dockerignore new file mode 100644 index 0000000000..a38a8f3a16 --- /dev/null +++ b/peerprep/.dockerignore @@ -0,0 +1,4 @@ + /node_modules +# /.next +# .DS_Store +# .env \ No newline at end of file diff --git a/peerprep/.env.example b/peerprep/.env.example new file mode 100644 index 0000000000..d0878f2289 --- /dev/null +++ b/peerprep/.env.example @@ -0,0 +1,8 @@ +#NEXT_PUBLIC_BASE_URL=http://host.docker.internal:80 +NEXT_PUBLIC_QUESTION_SERVICE=backend +NEXT_PUBLIC_USER_SERVICE=users + +NEXT_PUBLIC_NGINX=http://host.docker.internal:80 +DEV_ENV=not +NEXT_PUBLIC_MATCHING_SERVICE=matchmaking +NEXT_PUBLIC_STORAGE_BLOB=blob \ No newline at end of file diff --git a/peerprep/.env.sample b/peerprep/.env.sample new file mode 100644 index 0000000000..0db3be0468 --- /dev/null +++ b/peerprep/.env.sample @@ -0,0 +1,7 @@ +# THIS CANNOT BE RIGHT, TEMP FIX +NEXT_PUBLIC_BASE_URL=http://localhost +# location of question service +NEXT_PUBLIC_QUESTION_SERVICE=api +NEXT_PUBLIC_USER_SERVICE=users +# dev flag, originally used when services were not up +DEV_ENV=not \ No newline at end of file diff --git a/peerprep/.eslintrc.json b/peerprep/.eslintrc.json new file mode 100644 index 0000000000..c3bd7e6aef --- /dev/null +++ b/peerprep/.eslintrc.json @@ -0,0 +1,11 @@ +{ + "extends": [ + "next/core-web-vitals", + "next/typescript", + "prettier" + ], + "ignorePatterns": [ + "**/*.css", + "**/*.scss" + ] +} diff --git a/peerprep/.gitignore b/peerprep/.gitignore new file mode 100644 index 0000000000..b8e093b167 --- /dev/null +++ b/peerprep/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# environment file +.env +.dockerignore + +.eslintcache + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/peerprep/.husky/.gitignore b/peerprep/.husky/.gitignore new file mode 100644 index 0000000000..31354ec138 --- /dev/null +++ b/peerprep/.husky/.gitignore @@ -0,0 +1 @@ +_ diff --git a/peerprep/.husky/pre-commit b/peerprep/.husky/pre-commit new file mode 100644 index 0000000000..b28c372e66 --- /dev/null +++ b/peerprep/.husky/pre-commit @@ -0,0 +1 @@ +npm run lint-staged \ No newline at end of file diff --git a/peerprep/.prettierignore b/peerprep/.prettierignore new file mode 100644 index 0000000000..1b8ac8894b --- /dev/null +++ b/peerprep/.prettierignore @@ -0,0 +1,3 @@ +# Ignore artifacts: +build +coverage diff --git a/peerprep/.prettierrc b/peerprep/.prettierrc new file mode 100644 index 0000000000..b4bfed3579 --- /dev/null +++ b/peerprep/.prettierrc @@ -0,0 +1,3 @@ +{ + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/peerprep/Dockerfile b/peerprep/Dockerfile new file mode 100644 index 0000000000..48dfc02924 --- /dev/null +++ b/peerprep/Dockerfile @@ -0,0 +1,11 @@ +FROM node:lts-alpine3.20 + +WORKDIR /frontend +# TODO: don't include the .env file in the COPY +# TODO: multistage build +COPY package*.json ./ +RUN npm install --force +COPY . . +RUN npm run build +EXPOSE 3000 +CMD ["npm", "run", "start"] \ No newline at end of file diff --git a/peerprep/README.md b/peerprep/README.md new file mode 100644 index 0000000000..5aec39a728 --- /dev/null +++ b/peerprep/README.md @@ -0,0 +1,47 @@ +Icon attribution: + +- Code icons created by Royyan Wijaya - + Flaticon +- Code icons created by Freepik - Flaticon +- Person icons created by spaceman.design - + Flaticon + +This is a [Next.js](https://nextjs.org/) project bootstrapped with [ +`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.js`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and +load Inter, a custom Google Font. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions +are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use +the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) +from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/peerprep/api/gateway.ts b/peerprep/api/gateway.ts new file mode 100644 index 0000000000..1c80c44c32 --- /dev/null +++ b/peerprep/api/gateway.ts @@ -0,0 +1,95 @@ +import { cookies } from "next/headers"; +import { LoginResponse, StatusBody, UserServiceResponse } from "./structs"; +import { CookieNames } from "@/app/actions/session"; + +export function generateAuthHeaders() { + return { + Authorization: `Bearer ${cookies().get(CookieNames.SESSION.valueOf())?.value}`, + }; +} + +export function getSessionToken() { + return cookies().get(CookieNames.SESSION.valueOf())?.value; +} + +export function getUserData() { + return cookies().get(CookieNames.USER_DATA.valueOf())?.value; +} + +export function generateJSONHeaders() { + return { + ...generateAuthHeaders(), + "Content-type": "application/json; charset=UTF-8", + }; +} + +export const userServiceUrl = `${process.env.NEXT_PUBLIC_NGINX}/${process.env.NEXT_PUBLIC_USER_SERVICE}`; + +export async function getSessionLogin(validatedFields: { + email: string; + password: string; +}): Promise { + try { + const res = await fetch(`${userServiceUrl}/auth/login`, { + method: "POST", + body: JSON.stringify(validatedFields), + headers: { + "Content-type": "application/json; charset=UTF-8", + }, + }); + const json = await res.json(); + + if (!res.ok) { + // TODO: handle not OK + return { error: json.message, status: res.status }; + } + // TODO: handle OK + return json; + } catch (err: any) { + return { error: err.message, status: 400 }; + } +} + +export async function postSignupUser(validatedFields: { + username: string; + email: string; + password: string; +}): Promise { + try { + console.log(JSON.stringify(validatedFields)); + const res = await fetch(`${userServiceUrl}/users`, { + method: "POST", + body: JSON.stringify(validatedFields), + headers: { + "Content-type": "application/json; charset=UTF-8", + }, + }); + const json = await res.json(); + + if (!res.ok) { + // TODO: handle not OK + return { error: json.message, status: res.status }; + } + // TODO: handle OK + return json; + } catch (err: any) { + return { error: err.message, status: 400 }; + } +} + +export async function verifyUser(): Promise { + try { + const res = await fetch(`${userServiceUrl}/auth/verify-token`, { + method: "GET", + headers: generateAuthHeaders(), + }); + const json = (await res.json()) as UserServiceResponse; + + if (!res.ok) { + return { error: json.message, status: res.status }; + } + return json; + } catch (err: any) { + return { error: err.message, status: 400 }; + } +} diff --git a/peerprep/api/structs.ts b/peerprep/api/structs.ts new file mode 100644 index 0000000000..53e5ef4945 --- /dev/null +++ b/peerprep/api/structs.ts @@ -0,0 +1,140 @@ +import { z, ZodType } from "zod"; + +export enum Difficulty { + All = "All", + Easy = "Easy", + Medium = "Medium", + Hard = "Hard", +} + +export interface QuestionBody { + difficulty: Difficulty; + title: string; + content: string; + topicTags: string[]; +} + +// TODO remove this (unused) +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface QuestionFullBody extends QuestionBody {} + +export interface Question extends QuestionFullBody { + id: number; +} + +export interface StatusBody { + status: number; + error?: string; +} + +export interface UserData { + id: string; + username: string; + email: string; + isAdmin: boolean; + createdAt: number; +} + +export interface UserDataAccessToken extends UserData { + accessToken: string; +} + +export interface LoginResponse { + message: string; + data: UserDataAccessToken; +} + +export interface UserServiceResponse { + message: string; + data: UserData; +} + +export interface MatchRequest { + userId: string; + topicTags: string[]; + difficulty: string; + requestTime: string; +} + +export interface MatchReqInitRes { + match_code: string; +} + +export interface MatchData { + roomId: string; + user1: string; + user2: string; + questionId: string; +} + +export interface MatchResponse { + isMatchFound: boolean; + data: MatchData; +} + +// credit - taken from Next.JS Auth tutorial +export type FormState = + | { + errors?: { + name?: string[]; + email?: string[]; + password?: string[]; + }; + message?: string; + } + | undefined; + +export const SignupFormSchema = z.object({ + username: z + .string() + .min(2, { message: "Name must be at least 2 characters long." }) + .trim(), + email: z.string().email({ message: "Please enter a valid email." }).trim(), + password: z + .string() + .min(8, { message: "Be at least 8 characters long" }) + .regex(/[a-zA-Z]/, { message: "Contain at least one letter." }) + .regex(/[0-9]/, { message: "Contain at least one number." }) + .regex(/[^a-zA-Z0-9]/, { + message: "Contain at least one special character.", + }) + .trim(), +}); + +export const LoginFormSchema = z.object({ + email: z.string().email({ message: "Please enter a valid email." }).trim(), + password: z + .string() + .min(8, { message: "Be at least 8 characters long" }) + // .regex(/[a-zA-Z]/, { message: "Contain at least one letter." }) + // .regex(/[0-9]/, { message: "Contain at least one number." }) + // .regex(/[^a-zA-Z0-9]/, { + // message: "Contain at least one special character.", + // }) + .trim(), +}); + +// TODO: remove `any` +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function isError(obj: any | StatusBody): obj is StatusBody { + return (obj as StatusBody).status !== undefined; +} + +export type Language = "javascript" | "python" | "c_cpp"; +// maybe this shud be in structs +export type FormatResponse = { + formatted_code: string; +}; + +export const QuestionSchema = z.object({ + difficulty: z.nativeEnum(Difficulty), + title: z.string().min(2, { + message: "Please input a title of at least length 2.", + }), + content: z.string().min(2, { + message: "Please input content.", + }), + topicTags: z.array(z.string()).min(1, { + message: "Please input at least one topic tag. Press enter to add a tag.", + }), +}) satisfies ZodType; diff --git a/peerprep/app/actions/server_actions.ts b/peerprep/app/actions/server_actions.ts new file mode 100644 index 0000000000..40893ab135 --- /dev/null +++ b/peerprep/app/actions/server_actions.ts @@ -0,0 +1,103 @@ +"use server"; +import { getSessionLogin, postSignupUser, verifyUser } from "@/api/gateway"; +// defines the server-sided login action. +import { + FormState, + isError, + LoginFormSchema, + SignupFormSchema, + UserData, + UserServiceResponse, +} from "@/api/structs"; +import { createSession } from "@/app/actions/session"; +import { redirect } from "next/navigation"; +import { cookies } from "next/headers"; // credit - taken from Next.JS Auth tutorial + +// credit - taken from Next.JS Auth tutorial +export async function signup(state: FormState, formData: FormData) { + // Validate form fields + const validatedFields = SignupFormSchema.safeParse({ + username: formData.get("username"), + email: formData.get("email"), + password: formData.get("password"), + }); + + // If any form fields are invalid, return early + if (!validatedFields.success) { + return { + errors: validatedFields.error.flatten().fieldErrors, + }; + } + + const json = await postSignupUser(validatedFields.data); + + if (!isError(json)) { + // TODO: handle OK + redirect("/auth/login"); + } else { + // TODO: handle failure codes: 400, 409, 500. + console.log(`Error in signup: ${json.status}: ${json.error}`); + return { + errors: { + username: ["Username is already in use."], + email: ["Email is already in use."], + }, + }; + } +} + +export async function login(state: FormState, formData: FormData) { + // Validate form fields + const validatedFields = LoginFormSchema.safeParse({ + email: formData.get("email"), + password: formData.get("password"), + }); + + // If any form fields are invalid, return early + if (!validatedFields.success) { + return { + errors: validatedFields.error.flatten().fieldErrors, + }; + } + + const json = await getSessionLogin(validatedFields.data); + if (!isError(json)) { + await createSession(json.data); + redirect("/questions"); + } else { + if (json.status === 401) { + return { + errors: { + email: ["Invalid email or password."], + }, + }; + } else if (json.status === 500) { + console.log( + "Get session login error: " + json.error + " : " + json.status, + ); + + return { + errors: { + email: ["Please try again."], + }, + }; + } + } +} + +export async function hydrateUid(): Promise { + if (!cookies().has("session")) { + // TODO: this should not be required because of middleware + console.log("No session found - triggering switch back to login page."); + // redirect("/auth/login"); + } + const json = await verifyUser(); + if (isError(json)) { + console.log("Failed to fetch user ID."); + console.log(`Error ${json.status}: ${json.error}`); + // redirect("/auth/logout"); + } + // TODO: handle error handling + const response = json as UserServiceResponse; + return response.data; +} diff --git a/peerprep/app/actions/session.ts b/peerprep/app/actions/session.ts new file mode 100644 index 0000000000..0b5f685fef --- /dev/null +++ b/peerprep/app/actions/session.ts @@ -0,0 +1,48 @@ +import "server-only"; +import { cookies } from "next/headers"; +import { UserData, UserDataAccessToken } from "@/api/structs"; + +export enum CookieNames { + SESSION = "session", + USER_DATA = "userdata", +} + +export async function createSession(userDataAccessToken: UserDataAccessToken) { + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); + try { + console.log("Setting cookie..."); + + cookies().set( + CookieNames.SESSION.valueOf(), + userDataAccessToken.accessToken, + { + httpOnly: true, + // TODO: set this to true + secure: false, + expires: expiresAt, + sameSite: "lax", + path: "/", + }, + ); + + const userData: UserData = { + email: userDataAccessToken.email, + username: userDataAccessToken.username, + id: userDataAccessToken.id, + isAdmin: userDataAccessToken.isAdmin, + createdAt: userDataAccessToken.createdAt, + }; + + cookies().set(CookieNames.USER_DATA.valueOf(), JSON.stringify(userData), { + httpOnly: true, + secure: false, + expires: expiresAt, + sameSite: "lax", + path: "/", + }); + + console.log("Cookies set successfully."); + } catch (error) { + console.error("Error setting cookie:", error); + } +} diff --git a/peerprep/app/api/internal/formatter/helper.ts b/peerprep/app/api/internal/formatter/helper.ts new file mode 100644 index 0000000000..bd17bd76ce --- /dev/null +++ b/peerprep/app/api/internal/formatter/helper.ts @@ -0,0 +1,28 @@ +import { FormatResponse, Language, StatusBody } from "@/api/structs"; + +export async function callFormatter( + code: string, + language: Language, +): Promise { + try { + const response = await fetch("/api/internal/formatter", { + method: "POST", + body: JSON.stringify({ code, language }), + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = (await response.json()) as StatusBody; + console.error("FormatterHelper: ", errorData); + throw new Error(`${errorData.error}`); + } + + const formattedCode = (await response.json()) as FormatResponse; + console.log(formattedCode); + return formattedCode; + } catch (err: any) { + throw new Error(`${err.message}`); + } +} diff --git a/peerprep/app/api/internal/formatter/route.ts b/peerprep/app/api/internal/formatter/route.ts new file mode 100644 index 0000000000..8747daccd9 --- /dev/null +++ b/peerprep/app/api/internal/formatter/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { FormatResponse, Language } from "@/api/structs"; + +export async function POST(req: NextRequest) { + const { code, language }: { code: string; language: Language } = + await req.json(); + + let endpoint: string; + + switch (language) { + case "javascript": + endpoint = "javascript"; + break; + case "python": + endpoint = "python"; + break; + case "c_cpp": + endpoint = "cpp"; + break; + default: + return NextResponse.json( + { error: "Unsupported language type" }, + { status: 400 }, + ); + } + + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_NGINX}/${process.env.NEXT_PUBLIC_FORMATTER}/format/${endpoint}`, + { + method: "POST", + body: JSON.stringify({ code }), + headers: { + "Content-Type": "application/json", + }, + }, + ); + + if (!response.ok) { + const errorData = await response.json(); + console.log("Formatter: ", errorData); + throw new Error(`Failed to format code: ${errorData.detail}`); + } + + const formattedCode = (await response.json()) as FormatResponse; + return NextResponse.json(formattedCode); + } catch (error: any) { + return NextResponse.json({ error: `${error.message}` }, { status: 500 }); + } +} diff --git a/peerprep/app/api/internal/matching/helper.ts b/peerprep/app/api/internal/matching/helper.ts new file mode 100644 index 0000000000..d351cc1fc2 --- /dev/null +++ b/peerprep/app/api/internal/matching/helper.ts @@ -0,0 +1,54 @@ +import { + MatchData, + MatchRequest, + MatchResponse, + StatusBody, + MatchReqInitRes +} from "@/api/structs"; + +// helper to be called from client to check storage blob +export async function checkMatchStatus( + matchHash: string +): Promise { + console.debug("In matching helper, checking storage blob:", matchHash); + const res = await fetch( + `/api/internal/matching?matchHash=${matchHash}`, + { + method: "GET", + } + ); + if (!res.ok) { + return { + error: await res.text(), + status: res.status, + }; + } + const json = (await res.json()) as MatchData; + const isMatchFound = true; // TODO differntiate?? + + return { + isMatchFound, + data: json, + } as MatchResponse; +} + +export async function findMatch( + matchRequest: MatchRequest +): Promise { + console.debug( + "In matching helper, posting match request", + JSON.stringify(matchRequest) + ); + const res = await fetch(`/api/internal/matching`, { + method: "POST", + body: JSON.stringify(matchRequest), + }); + if (!res.ok) { + return { + error: await res.text(), + status: res.status, + } as StatusBody; + } + const json = await res.json(); + return json as MatchReqInitRes; +} diff --git a/peerprep/app/api/internal/matching/route.ts b/peerprep/app/api/internal/matching/route.ts new file mode 100644 index 0000000000..d043241f5a --- /dev/null +++ b/peerprep/app/api/internal/matching/route.ts @@ -0,0 +1,69 @@ +import { generateAuthHeaders, generateJSONHeaders } from "@/api/gateway"; +import { QuestionFullBody } from "@/api/structs"; +import { NextRequest, NextResponse } from "next/server"; + +// all get request interpreted as getting from storage blob +export async function GET(request: NextRequest) { + const matchHash = request.nextUrl.searchParams.get("matchHash"); // Assuming you're passing the userId as a query parameter + console.log("in route,", matchHash); + if (!matchHash) { + return NextResponse.json({ error: "MatchHash is required" }, { status: 400 }); + } + + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_NGINX}/${process.env.NEXT_PUBLIC_STORAGE_BLOB}/request/${matchHash}`, + { + method: "GET", + headers: generateAuthHeaders(), + } + ); + if (!response.ok) { + return NextResponse.json( + { + error: await response.text(), + status: response.status, + }, + { status: response.status } + ); + } + return response; + } catch (err: any) { + return NextResponse.json( + { error: err.message, status: 400 }, + { status: 400 } + ); + } +} + +// for matching stuff all post requests interpreted as posting matchmaking request +export async function POST(request: NextRequest) { + const body = await request.json(); + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_NGINX}/${process.env.NEXT_PUBLIC_MATCHING_SERVICE}/request`, + { + method: "POST", + body: JSON.stringify(body), + } + ); + if (response.ok) { + return NextResponse.json( + { match_code: (await response.json()).match_code }, + { status: response.status } + ); + } + return NextResponse.json( + { + error: (await response.json())["Error adding question: "], + status: response.status, + }, + { status: response.status } + ); + } catch (err: any) { + return NextResponse.json( + { error: err.message, status: 400 }, + { status: 400 } + ); + } +} diff --git a/peerprep/app/auth/login/page.tsx b/peerprep/app/auth/login/page.tsx new file mode 100644 index 0000000000..887fd49c16 --- /dev/null +++ b/peerprep/app/auth/login/page.tsx @@ -0,0 +1,77 @@ +"use client"; +import React from "react"; +import style from "@/style/form.module.css"; +import { useFormState, useFormStatus } from "react-dom"; +import FormTextInput from "@/components/shared/form/FormTextInput"; +import { login } from "@/app/actions/server_actions"; +import Link from "next/link"; + +function LoginPage() { + const [state, action] = useFormState(login, undefined); + // we can actually use server actions to auth the user... maybe we can + // change our AddQn action too. + return ( +
+
+
+

Login to start using our services

+ + + + {state?.errors?.email && ( +

{state.errors.email}

+ )} + + + + {state?.errors?.password && ( +
+

Password must:

+
    + {state.errors.password.map((error) => ( +
  • - {error}
  • + ))} +
+
+ )} + + + +

+ No account?  + + {" "} + Register here. + +

+
+
+ ); +} + +function SubmitButton() { + const { pending } = useFormStatus(); + + return ( + + ); +} + +export default LoginPage; diff --git a/peerprep/app/auth/register/page.tsx b/peerprep/app/auth/register/page.tsx new file mode 100644 index 0000000000..b537226844 --- /dev/null +++ b/peerprep/app/auth/register/page.tsx @@ -0,0 +1,71 @@ +"use client"; +import React from "react"; +import style from "@/style/form.module.css"; +import { useFormState, useFormStatus } from "react-dom"; +import FormTextInput from "@/components/shared/form/FormTextInput"; +import { signup } from "@/app/actions/server_actions"; +import Link from "next/link"; + +function RegisterPage() { + const [state, action] = useFormState(signup, undefined); + return ( + // we can actually use server actions to auth the user... maybe we can + // change our AddQn action too. +
+
+

Sign up for an account

+ + {state?.errors?.username && ( +

{state.errors.username}

+ )} + + {state?.errors?.email && ( +

+ {state.errors.email.map((item) => ( +

{item}
+ ))} +

+ )} + + {state?.errors?.password && ( +
+

Password must:

+
    + {state.errors.password.map((error) => ( +
  • - {error}
  • + ))} +
+
+ )} + +

+ Already have an account?{" "} + + Login here. + +

+ +
+ ); +} + +function SubmitButton() { + const { pending } = useFormStatus(); + + return ( + + ); +} + +export default RegisterPage; diff --git a/peerprep/app/globals.css b/peerprep/app/globals.css new file mode 100644 index 0000000000..35c504e5b1 --- /dev/null +++ b/peerprep/app/globals.css @@ -0,0 +1,84 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body { + @apply overflow-hidden; + background-color: #121212; + height: 100%; + display: flex; + flex-direction: column; +} + +body, +p, +h1, +h2 { + @apply text-text-2; +} + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + } + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/peerprep/app/icon.png b/peerprep/app/icon.png new file mode 100644 index 0000000000..d5e519a196 Binary files /dev/null and b/peerprep/app/icon.png differ diff --git a/peerprep/app/layout.tsx b/peerprep/app/layout.tsx new file mode 100644 index 0000000000..2c30ad14fd --- /dev/null +++ b/peerprep/app/layout.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import Navbar from "@/components/navbar/Navbar"; +import ThemeProvider from "./theme-provider"; + +export const metadata: Metadata = { + title: "PeerPrep - One Stop Technical Interview Preparation", + description: "Your choice for Technical Interview Preparation", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + +
{children}
+
+ + + ); +} diff --git a/peerprep/app/page.tsx b/peerprep/app/page.tsx new file mode 100644 index 0000000000..ac4c592ed9 --- /dev/null +++ b/peerprep/app/page.tsx @@ -0,0 +1,57 @@ +import FadeUpAnimation from "@/components/animations/FadeUp"; +import Link from "next/link"; +import BoringSlideShow from "@/components/home/BoringSlideShow"; + +export default function Home() { + return ( + <> + +

+ Welcome to PeerPrep! +

+
+ +
+
+
+ +

Boring code platforms 😢

+
+
+
+

Tired of solving interview questions by yourself?

+

Code with a friend 👥 instead! 😊

+

PeerPrep is a platform for technical interview preparation

+ +

+ Features: +

+
    +
  1. - Online coding
  2. +
  3. - A collaborative code editor
  4. +
  5. - Camera and audio support
  6. +
  7. + - Syntax highlighting and formatting for three languages! +
  8. +
+
+
+
+ +
+ +
+
+ + ); +} diff --git a/peerprep/app/questions/QuestionForm.tsx b/peerprep/app/questions/QuestionForm.tsx new file mode 100644 index 0000000000..983451011e --- /dev/null +++ b/peerprep/app/questions/QuestionForm.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Difficulty, QuestionSchema } from "@/api/structs"; +import { Controller, UseFormReturn } from "react-hook-form"; +import { InputTags } from "@/components/ui/tags"; +import Tiptap from "@/components/modifyQuestion/Tiptap"; +import { Button } from "@/components/ui/button"; +import { z } from "zod"; + +type FormType = UseFormReturn< + { + difficulty: Difficulty; + title: string; + content: string; + topicTags: string[]; + }, + any, + undefined +>; + +type QuestionFormProps = { + form: FormType; + onSubmit: (values: z.infer) => Promise; +}; + +const QuestionForm = ({ form, onSubmit }: QuestionFormProps) => { + return ( +
+ + ( + + Title + + + + Please input a title. + + + )} + /> + ( + + Difficulty + + Please select a difficulty. + + + )} + /> + ( + + Topics + + ( + + )} + /> + + + Please input at least one topic tag. + + + + )} + /> + ( + + Content + + + + Please input content. + + + )} + /> + + + + ); +}; + +export default QuestionForm; diff --git a/peerprep/app/questions/[question]/[roomID]/page.tsx b/peerprep/app/questions/[question]/[roomID]/page.tsx new file mode 100644 index 0000000000..c6c2b3ca8c --- /dev/null +++ b/peerprep/app/questions/[question]/[roomID]/page.tsx @@ -0,0 +1,47 @@ +import { getSessionToken, getUserData } from "@/api/gateway"; +import { isError, Question as QnType, StatusBody } from "@/api/structs"; +import styles from "@/style/question.module.css"; +import ErrorBlock from "@/components/shared/ErrorBlock"; +import React from "react"; +import QuestionBlock from "./question"; +import { fetchQuestion } from "@/app/questions/helper"; + +type Props = { + searchParams: { + match?: string; + }; + params: { + question: number; + roomID: string; + }; +}; + +async function Question({ params, searchParams }: Props) { + const question = await fetchQuestion(params.question); + const authToken = getSessionToken(); + const userData = getUserData(); + let userId; + try { + userId = JSON.parse(userData as string)?.id; + } catch (err) { + console.log("Failed to parse userid"); + } + + return ( +
+ {isError(question) ? ( + + ) : ( + + )} +
+ ); +} + +export default Question; diff --git a/peerprep/app/questions/[question]/[roomID]/question.tsx b/peerprep/app/questions/[question]/[roomID]/question.tsx new file mode 100644 index 0000000000..f7c6638b6c --- /dev/null +++ b/peerprep/app/questions/[question]/[roomID]/question.tsx @@ -0,0 +1,82 @@ +"use client"; +import React from "react"; +import { Difficulty, Question } from "@/api/structs"; +import Chip from "@/components/shared/Chip"; +import styles from "@/style/question.module.css"; +import CollabEditor from "@/components/questionpage/CollabEditor"; +import DOMPurify from "isomorphic-dompurify"; + +interface Props { + question: Question; + roomID?: string; + authToken?: string; + userId?: string; + matchHash?: string; +} + +interface DifficultyChipProps { + diff: Difficulty; +} + +function DifficultyChip({ diff }: DifficultyChipProps) { + return diff === Difficulty.Easy ? ( + Easy + ) : diff === Difficulty.Medium ? ( + Med + ) : ( + Hard + ); +} + +function QuestionBlock({ + question, + roomID, + authToken, + userId, + matchHash, +}: Props) { + return ( + <> +
+
+
+

+ Q{question.id}: {question.title} +

+ +
+
+
+

Topics:

+ {question.topicTags.length == 0 ? ( +

No topics listed.

+ ) : ( + question.topicTags.map((elem, idx) => ( +

+ {elem} +

+ )) + )} +
+ { +
+ } +
+
+ +
+ + ); +} + +export default QuestionBlock; diff --git a/peerprep/app/questions/[question]/page.tsx b/peerprep/app/questions/[question]/page.tsx new file mode 100644 index 0000000000..f5f075b882 --- /dev/null +++ b/peerprep/app/questions/[question]/page.tsx @@ -0,0 +1,29 @@ +import { isError, Question as QnType, StatusBody } from "@/api/structs"; +import styles from "@/style/question.module.css"; +import ErrorBlock from "@/components/shared/ErrorBlock"; +import React from "react"; +import QuestionBlock from "./question"; + +import { fetchQuestion } from "@/app/questions/helper"; + +type Props = { + params: { + question: number; + }; +}; + +async function Question({ params }: Props) { + const question = await fetchQuestion(params.question); + + return ( +
+ {isError(question) ? ( + + ) : ( + + )} +
+ ); +} + +export default Question; diff --git a/peerprep/app/questions/[question]/question.tsx b/peerprep/app/questions/[question]/question.tsx new file mode 100644 index 0000000000..ea0404a734 --- /dev/null +++ b/peerprep/app/questions/[question]/question.tsx @@ -0,0 +1,93 @@ +"use client"; + +import React from "react"; +import { Difficulty, Question } from "@/api/structs"; +import Chip from "@/components/shared/Chip"; +import styles from "@/style/question.module.css"; +import { useRouter } from "next/navigation"; +import { deleteQuestion } from "@/app/questions/helper"; +import CollabEditor from "@/components/questionpage/CollabEditor"; +import DOMPurify from "isomorphic-dompurify"; + +interface Props { + question: Question; +} + +interface DifficultyChipProps { + diff: Difficulty; +} + +function DifficultyChip({ diff }: DifficultyChipProps) { + return diff === Difficulty.Easy ? ( + Easy + ) : diff === Difficulty.Medium ? ( + Med + ) : ( + Hard + ); +} + +function QuestionBlock({ question }: Props) { + const router = useRouter(); + + const handleDelete = async () => { + if ( + confirm( + `Are you sure you want to delete ${question.title}? (ID: ${question.id}) `, + ) + ) { + const status = await deleteQuestion(question.id); + if (status.error) { + alert( + `Failed to delete question. Code ${status.status}: ${status.error}`, + ); + return; + } + console.log(`Successfully deleted the question.`); + router.push("/questions"); + router.refresh(); + } else { + console.log("Deletion cancelled."); + } + }; + + return ( + <> +
+
+
+

+ Q{question.id}: {question.title} +

+ +
+
+
+

Topics:

+ {question.topicTags.length == 0 ? ( +

No topics listed.

+ ) : ( + question.topicTags.map((elem, idx) => ( +

+ {elem} +

+ )) + )} +
+ { +
+ } +
+
+ +
+ + ); +} + +export default QuestionBlock; diff --git a/peerprep/app/questions/edit/[question]/EditQuestion.tsx b/peerprep/app/questions/edit/[question]/EditQuestion.tsx new file mode 100644 index 0000000000..9e50c75dd8 --- /dev/null +++ b/peerprep/app/questions/edit/[question]/EditQuestion.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Question, QuestionSchema } from "@/api/structs"; +import { editQuestion } from "@/app/questions/helper"; +import QuestionForm from "@/app/questions/QuestionForm"; +import { useRouter } from "next/navigation"; + +const EditQuestion = ({ question }: { question: Question }) => { + const router = useRouter(); + + const form = useForm>({ + resolver: zodResolver(QuestionSchema), + defaultValues: { + title: question.title, + difficulty: question.difficulty, + content: question.content, + topicTags: question.topicTags, + }, + }); + + const onSubmit = async (values: z.infer) => { + console.log(values); + const qn: Question = { + id: question.id, + ...values, + }; + const status = await editQuestion(qn); + console.log(status); + if (status.error) { + console.log("Failed to add question."); + console.log(`Code ${status.status}: ${status.error}`); + alert(`Failed to add question. Code ${status.status}: ${status.error}`); + return; + } + console.log(`Successfully modified the question.`); + router.push("/questions"); + }; + + return ( +
+
+ +
+
+ ); +}; + +export default EditQuestion; diff --git a/peerprep/app/questions/edit/[question]/page.tsx b/peerprep/app/questions/edit/[question]/page.tsx new file mode 100644 index 0000000000..9b13e5b8a4 --- /dev/null +++ b/peerprep/app/questions/edit/[question]/page.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import EditQuestion from "@/app/questions/edit/[question]/EditQuestion"; +import { Question } from "@/api/structs"; +import { revalidatePath } from "next/cache"; + +import { fetchQuestion } from "@/app/questions/helper"; + +type Props = { + params: { + question: number; + }; +}; + +const EditQuestionPage = async ({ params }: Props) => { + const question = (await fetchQuestion(params.question)) as Question; + console.log("Fetching question"); + revalidatePath("/questions"); + revalidatePath(`/questions/${params.question}`); + revalidatePath(`/questions/edit/${params.question}`); + + return ; +}; + +export default EditQuestionPage; diff --git a/peerprep/app/questions/helper.ts b/peerprep/app/questions/helper.ts new file mode 100644 index 0000000000..a1987be235 --- /dev/null +++ b/peerprep/app/questions/helper.ts @@ -0,0 +1,117 @@ +"use server"; + +import { isError, Question, QuestionFullBody, StatusBody } from "@/api/structs"; +import { revalidatePath } from "next/cache"; +import { + generateAuthHeaders, + generateJSONHeaders, + verifyUser, +} from "@/api/gateway"; +import DOMPurify from "isomorphic-dompurify"; + +const questionServiceUrl = `${process.env.NEXT_PUBLIC_NGINX}/${process.env.NEXT_PUBLIC_QUESTION_SERVICE}`; + +export async function deleteQuestion(id: number): Promise { + const verify = await verifyUser(); + if (isError(verify) || verify?.data.isAdmin === false) { + return verify as StatusBody; + } + + const res = await fetch(`${questionServiceUrl}/questions/delete/${id}`, { + method: "DELETE", + headers: generateAuthHeaders(), + }); + if (res.ok) { + return { status: res.status }; + } + revalidatePath("/questions"); + const json = await res.json(); + return json as StatusBody; +} + +export async function fetchAllQuestions(): Promise { + console.log("Fetching all questions..."); + const res = await fetch(`${questionServiceUrl}/questions`, { + method: "GET", + headers: generateAuthHeaders(), + cache: "no-store", + }); + if (!res.ok) { + return { status: res.status }; + } + const json = await res.json(); + return json as Question[]; +} + +export async function editQuestion(question: Question): Promise { + const verify = await verifyUser(); + if (isError(verify) || verify?.data.isAdmin === false) { + return verify as StatusBody; + } + + console.log("editing question", question.id); + const res = await fetch( + `${questionServiceUrl}/questions/replace/${question.id}`, + { + method: "PUT", + body: JSON.stringify(question), + headers: generateJSONHeaders(), + }, + ); + if (!res.ok) { + return { status: res.status, error: await res.text() }; + } + revalidatePath("/questions"); + revalidatePath("/questions/edit/" + question.id); + const json = await res.json(); + return json as StatusBody; +} + +export async function addQuestion( + question: QuestionFullBody, +): Promise { + const verify = await verifyUser(); + if (isError(verify) || verify?.data.isAdmin === false) { + return verify as StatusBody; + } + console.log("Adding question", question.title); + const res = await fetch(`${questionServiceUrl}/questions`, { + method: "POST", + body: JSON.stringify(question), + headers: generateJSONHeaders(), + }); + if (!res.ok) { + return { status: res.status, error: await res.text() }; + } + revalidatePath("/questions"); + const json = await res.json(); + return json as StatusBody; +} + +export async function fetchQuestion( + questionId: number, +): Promise { + try { + const response = await fetch( + `${questionServiceUrl}/questions/solve/${questionId}`, + { + method: "GET", + headers: generateAuthHeaders(), + cache: "no-store", + }, + ); + if (!response.ok) { + return { + error: await response.text(), + status: response.status, + }; + } + + const question = (await response.json()) as Question; + question.content = DOMPurify.sanitize(question.content); + revalidatePath(`/questions/edit/${questionId}`); + return question; + } catch (err: any) { + return { error: err.message, status: 400 }; + } +} \ No newline at end of file diff --git a/peerprep/app/questions/loading.tsx b/peerprep/app/questions/loading.tsx new file mode 100644 index 0000000000..c878ad5664 --- /dev/null +++ b/peerprep/app/questions/loading.tsx @@ -0,0 +1,10 @@ +const LoadingPage = () => { + return ( +
+
Loading...
+
Please wait...
+
+ ); +}; + +export default LoadingPage; diff --git a/peerprep/app/questions/new/ExampleQuestion.ts b/peerprep/app/questions/new/ExampleQuestion.ts new file mode 100644 index 0000000000..b579493a72 --- /dev/null +++ b/peerprep/app/questions/new/ExampleQuestion.ts @@ -0,0 +1,61 @@ +export const exampleQuestion = ` +<!-- This is an example question, replace with your own. Remove this line! --> +

Given an integer array nums, return all the triplets +\t[nums[i], nums[j], nums[k]] such that +\ti != j, +\ti != k, and +\tj != k, and +\tnums[i] + nums[j] + nums[k] == 0. +

\n\n +

Notice that the solution set must not contain duplicate triplets.

\n\n +

 

\n + +

+\tExample 1: +

\n\n + +\n + +

\tInput: nums = [-1,0,1,2,-1,-4]\n\n +

+

\tOutput: [[-1,-1,2],[-1,0,1]]\n

+

\tExplanation: \nnums[0] + nums[1] + nums[2] = (-1) + 0 + 1 = 0.\nnums[1] + nums[2] + nums[4] = 0 + 1 + (-1) = 0.\nnums[0] + nums[3] + nums[4] = (-1) + 2 + (-1) = 0.\nThe distinct triplets are [-1,0,1] and [-1,-1,2].\nNotice that the order of the output and the order of the triplets does not matter.\n

+\n\n + + +

+\tExample 2: +

\n\n + +
\n
+\tInput: nums = [0,1,1]\n
+\tOutput: []\n
+\tExplanation: The only possible triplet does not sum up to 0.\n
+
\n\n + +

+\tExample 3: +

\n\n + +
\n
+\tInput: nums = [0,0,0]\n
+\tOutput: [[0,0,0]]\n
+\tExplanation: The only possible triplet sums up to 0.\n
+
\n\n + +

 

\n +

+\tConstraints: +

\n\n +
    \n\t +\t
  • +\t\t3 <= nums.length <= 3000 +\t
  • \n\t +\t
  • +\t\t-10 +\t\t\t5 <= nums[i] <= 10 +\t\t\t5 +\t\t +\t
  • \n +
\n", +`; diff --git a/peerprep/app/questions/new/page.tsx b/peerprep/app/questions/new/page.tsx new file mode 100644 index 0000000000..b47fff5ffa --- /dev/null +++ b/peerprep/app/questions/new/page.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { Difficulty, QuestionSchema } from "@/api/structs"; +import { useRouter } from "next/navigation"; +import { exampleQuestion } from "@/app/questions/new/ExampleQuestion"; +import { zodResolver } from "@hookform/resolvers/zod"; +import QuestionForm from "@/app/questions/QuestionForm"; +import { addQuestion } from "@/app/questions/helper"; + +const NewQuestion = () => { + const router = useRouter(); + + const form = useForm>({ + resolver: zodResolver(QuestionSchema), + defaultValues: { + title: "", + difficulty: Difficulty.Easy, + content: exampleQuestion, + topicTags: [], + }, + }); + + const onSubmit = async (values: z.infer) => { + console.log(values); + const status = await addQuestion(values); + if (status.error) { + console.log("Failed to add question."); + console.log(`Code ${status.status}: ${status.error}`); + return; + } + console.log(`Successfully added the question.`); + router.push("/questions"); + }; + + return ( +
+
+ +
+
+ ); +}; + +export default NewQuestion; diff --git a/peerprep/app/questions/page.tsx b/peerprep/app/questions/page.tsx new file mode 100644 index 0000000000..48c169e0b2 --- /dev/null +++ b/peerprep/app/questions/page.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import QuestionList from "@/components/questionpage/QuestionList"; +import Matchmaking from "@/components/questionpage/Matchmaking"; +import { QuestionFilterProvider } from "@/contexts/QuestionFilterContext"; +import { hydrateUid } from "../actions/server_actions"; +import { isError, Question, StatusBody, UserData } from "@/api/structs"; +import { UserInfoProvider } from "@/contexts/UserInfoContext"; +import { fetchAllQuestions } from "@/app/questions/helper"; +import { redirect } from "next/navigation"; + +async function QuestionsPage() { + const userData = (await hydrateUid()) as UserData; + + if (!userData) { + redirect("/auth/login"); + } + + const questions: Question[] | StatusBody = await fetchAllQuestions(); + + if (isError(questions)) { + return ( +
+
Error...
+
+ ); + } + + return ( + + +
+ +
+ +
+
+
+
+ ); +} + +export default QuestionsPage; diff --git a/peerprep/app/theme-provider.tsx b/peerprep/app/theme-provider.tsx new file mode 100644 index 0000000000..ebc04774af --- /dev/null +++ b/peerprep/app/theme-provider.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { createContext } from "react"; + +export const ThemeContext = createContext({}); + +export default function ThemeProvider({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/peerprep/components.json b/peerprep/components.json new file mode 100644 index 0000000000..bcec1f91f9 --- /dev/null +++ b/peerprep/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} \ No newline at end of file diff --git a/peerprep/components/animations/FadeUp.tsx b/peerprep/components/animations/FadeUp.tsx new file mode 100644 index 0000000000..0c3af43e22 --- /dev/null +++ b/peerprep/components/animations/FadeUp.tsx @@ -0,0 +1,27 @@ +"use client"; +import { motion } from "framer-motion"; +import { ReactNode } from "react"; + +// Code adapted from https://staticmania.com/blog/how-to-use-framer-motion-for-animations-in-next-js + +export const fadeUpVariant = { + initial: { opacity: 0, y: 10 }, + animate: { + opacity: 1, + y: 0, + transition: { + duration: 0.2, + }, + }, + exit: { opacity: 0 }, +}; + +const FadeUpAnimation = ({ children }: { children: ReactNode }) => { + return ( + + {children} + + ); +}; + +export default FadeUpAnimation; diff --git a/peerprep/components/home/BoringSlideShow.tsx b/peerprep/components/home/BoringSlideShow.tsx new file mode 100644 index 0000000000..07a7cadcf8 --- /dev/null +++ b/peerprep/components/home/BoringSlideShow.tsx @@ -0,0 +1,24 @@ +"use client"; + +import Carousel, { ImageProps } from "@/components/home/Carousel"; + +const images: ImageProps[] = [ + { + url: "/boring1.png", + alt: "L**tc*de", + }, + { + url: "/boring2.png", + alt: "H*ckerr*nk", + }, + { + url: "/boring3.png", + alt: "K*ttis", + }, +]; + +const BoringSlideShow = () => { + return ; +}; + +export default BoringSlideShow; diff --git a/peerprep/components/home/Carousel.tsx b/peerprep/components/home/Carousel.tsx new file mode 100644 index 0000000000..59080e425c --- /dev/null +++ b/peerprep/components/home/Carousel.tsx @@ -0,0 +1,96 @@ +import React, { useEffect, useRef, useState } from "react"; +import { AnimatePresence, motion, MotionConfig } from "framer-motion"; +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; + +export type ImageProps = { + url: string; + alt: string; +}; + +const Carousel = ({ images }: { images: ImageProps[] }) => { + const [index, setIndex] = useState(0); + const intervalRef = useRef(null); + + const clearExistingInterval = () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + + const startInterval = () => { + clearExistingInterval(); + intervalRef.current = setInterval(() => { + setIndex((prevIndex) => (prevIndex + 1) % images.length); + }, 5000); + }; + + const prevSlide = () => { + setIndex((prevIndex) => (prevIndex - 1 + images.length) % images.length); + clearExistingInterval(); + startInterval(); + }; + + const nextSlide = () => { + setIndex((prevIndex) => (prevIndex + 1) % images.length); + clearExistingInterval(); + startInterval(); + }; + + useEffect(() => { + startInterval(); + + return () => clearExistingInterval(); + }, [images.length]); + + return ( +
+ +
+
+ + {[...images, ...images].map((image, i) => ( + + ))} + + + + + + + + + + + +
+
+
+
+ ); +}; + +export default Carousel; \ No newline at end of file diff --git a/peerprep/components/modifyQuestion/MenuBar.tsx b/peerprep/components/modifyQuestion/MenuBar.tsx new file mode 100644 index 0000000000..1286ab4a9d --- /dev/null +++ b/peerprep/components/modifyQuestion/MenuBar.tsx @@ -0,0 +1,167 @@ +import styles from "@/style/addquestion.module.css"; + +import React from "react"; +import { Editor } from "@tiptap/core"; +import { + Bold, + Code, + Italic, + List, + ListOrdered, + Redo, + RemoveFormatting, + Strikethrough, + Underline, + Undo, +} from "lucide-react"; +import Tooltip from "./Tooltip"; +import clsx from "clsx"; + +type Props = { + editor: Editor | null; +}; + +export const MenuBar = ({ editor }: Props) => { + if (!editor) { + return null; + } + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; diff --git a/peerprep/components/modifyQuestion/Tiptap.tsx b/peerprep/components/modifyQuestion/Tiptap.tsx new file mode 100644 index 0000000000..6cb6d7c5d7 --- /dev/null +++ b/peerprep/components/modifyQuestion/Tiptap.tsx @@ -0,0 +1,52 @@ +"use client"; + +import "@/style/tiptap.css"; +import { EditorContent, useEditor } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import React from "react"; +import { Placeholder } from "@tiptap/extension-placeholder"; +import { Subscript } from "@tiptap/extension-subscript"; +import { Superscript } from "@tiptap/extension-superscript"; +import { Link } from "@tiptap/extension-link"; +import { Underline } from "@tiptap/extension-underline"; +import { MenuBar } from "@/components/modifyQuestion/MenuBar"; + +type TipTapProps = { + defaultContent: string; + onChange: (richText: string) => void; +}; + +const Tiptap = ({ defaultContent, onChange }: TipTapProps) => { + const editor = useEditor({ + extensions: [ + StarterKit, + Subscript, + Superscript, + Underline, + Link, + Placeholder.configure({ + placeholder: "Add your question here", + }), + ], + content: defaultContent, + immediatelyRender: false, + onUpdate({ editor }) { + onChange(editor.getHTML()); + }, + }); + + return ( +
+ + +
+ +
+
+ ); +}; + +export default Tiptap; diff --git a/peerprep/components/modifyQuestion/Tooltip.tsx b/peerprep/components/modifyQuestion/Tooltip.tsx new file mode 100644 index 0000000000..0b5d9f3e80 --- /dev/null +++ b/peerprep/components/modifyQuestion/Tooltip.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import styles from "@/style/addquestion.module.css"; + +type TooltipProps = { + text: string; + children: React.ReactNode; +}; + +const Tooltip = ({ text, children }: TooltipProps) => { + return ( +
+ {children} + {text} +
+ ); +}; + +export default Tooltip; diff --git a/peerprep/components/navbar/Navbar.tsx b/peerprep/components/navbar/Navbar.tsx new file mode 100644 index 0000000000..f231185128 --- /dev/null +++ b/peerprep/components/navbar/Navbar.tsx @@ -0,0 +1,114 @@ +import Link from "next/link"; +import React from "react"; +import { + Disclosure, + DisclosureButton, + DisclosurePanel, +} from "@headlessui/react"; +import { Menu, X } from "lucide-react"; +import Image from "next/image"; +import { ProfileDropdown } from "@/components/navbar/ProfileDropdown"; + +// Navbar adapted from https://tailwindui.com/components/application-ui/navigation/navbars + +interface NavbarItemProps { + href: string; + name: string; +} + +const navigation: NavbarItemProps[] = [ + { href: "/", name: "Home" }, + { href: "/questions", name: "Questions" }, + // { href: "/auth/login", name: "Login" }, +]; + +const MobileMenu = () => { + return ( +
+ {/* Mobile menu button*/} + + + Open main menu +
+ ); +}; + +const MobileDropdown = () => { + return ( + +
+ {navigation.map((item) => ( + + {item.name} + + ))} +
+
+ ); +}; + +const NavigationList = () => { + return ( +
+
+ {navigation.map((item) => ( + + {item.name} + + ))} +
+
+ ); +}; + +const Navbar = () => { + return ( + +
+
+ +
+
+ + Peerprep + +
+ +
+ +
+
+ +
+ ); +}; + +export default Navbar; diff --git a/peerprep/components/navbar/ProfileDropdown.tsx b/peerprep/components/navbar/ProfileDropdown.tsx new file mode 100644 index 0000000000..b0b9693b99 --- /dev/null +++ b/peerprep/components/navbar/ProfileDropdown.tsx @@ -0,0 +1,70 @@ +import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; +import Image from "next/image"; +import React from "react"; +import Link from "next/link"; +import { UserData } from "@/api/structs"; +import { hydrateUid } from "@/app/actions/server_actions"; + +export const ProfileDropdown = async () => { + let userData; + try { + userData = (await hydrateUid()) as UserData; + } catch { + userData = null; + console.error("Error hydrating user data."); + } + + return ( +
+ {/* Profile dropdown */} + +
+ + + Open user menu + Profile Picture + +
+ + {userData ? ( + <> + +
+ Hello, {userData.username}! +
+
+ + + Sign out + + + + ) : ( + <> + + + Login + + + + )} +
+
+
+ ); +}; diff --git a/peerprep/components/questionpage/CollabEditor.tsx b/peerprep/components/questionpage/CollabEditor.tsx new file mode 100644 index 0000000000..5ba6f4c13a --- /dev/null +++ b/peerprep/components/questionpage/CollabEditor.tsx @@ -0,0 +1,396 @@ +import React, { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import AceEditor from "react-ace"; + +import "ace-builds/src-noconflict/mode-python"; +import "ace-builds/src-noconflict/theme-terminal"; +import "ace-builds/src-noconflict/ext-language_tools"; +import "ace-builds/src-noconflict/ext-searchbox"; +import "ace-builds/src-noconflict/ext-inline_autocomplete"; +import "ace-builds/src-noconflict/keybinding-vim"; +import "ace-builds/src-min-noconflict/ext-searchbox"; +import "ace-builds/src-min-noconflict/ext-language_tools"; +import PeerprepDropdown from "@/components/shared/PeerprepDropdown"; + +import { FormatResponse, Language } from "@/api/structs"; +import PeerprepButton from "../shared/PeerprepButton"; +import CommsPanel from "./CommsPanel"; + +// import { diff_match_patch } from "diff-match-patch"; +import { callFormatter } from "@/app/api/internal/formatter/helper"; +import { Ace } from "ace-builds"; + +const PING_INTERVAL_MILLISECONDS = 5000; +const languages: Language[] = ["javascript", "python", "c_cpp"]; + +const themes = [ + "monokai", + "github", + "tomorrow", + "kuroir", + "twilight", + "xcode", + "textmate", + "solarized_dark", + "solarized_light", + "terminal", +]; + +languages.forEach((lang) => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + require(`ace-builds/src-noconflict/mode-${lang}`); + // eslint-disable-next-line @typescript-eslint/no-require-imports + require(`ace-builds/src-noconflict/snippets/${lang}`); +}); + +// eslint-disable-next-line @typescript-eslint/no-require-imports +themes.forEach((theme) => require(`ace-builds/src-noconflict/theme-${theme}`)); + +interface Props { + roomID?: string; + authToken?: string; + userId?: string | undefined; + matchHash?: string; +} + +interface Message { + type: string; + roomId?: string; + data?: string; + userId?: string | undefined; + token?: string; + matchHash?: string; +} + +enum MessageTypes { + AUTH = "auth", + AUTH_SUCCESS = "auth_success", + AUTH_FAIL = "auth_fail", + CLOSE_SESSION = "close_session", + CONTENT_CHANGE = "content_change", + PING = "ping", +} + +// const dmp = new diff_match_patch(); +// const questionSeed = "def foo():\n pass"; + +export default function CollabEditor({ + roomID, + authToken, + userId, + matchHash, +}: Props) { + const [theme, setTheme] = useState("terminal"); + const [fontSize, setFontSize] = useState(18); + const [language, setLanguage] = useState("python"); + const [value, setValue] = useState(""); + const [socket, setSocket] = useState(null); + const [connected, setConnected] = useState(false); + const [authenticated, setAuthenticated] = useState(false); + const [otherUserConnected, setOtherUserConnected] = useState(false); + const [lastPingReceived, setLastPingReceived] = useState(null); + const router = useRouter(); + + async function formatCode(value: string, language: Language) { + try { + const res = await callFormatter(value, language); + const formatResponse = res as FormatResponse; + const formatted_code = formatResponse.formatted_code; + + setValue(formatted_code); + if ( + socket && + formatted_code !== value && + socket?.readyState === WebSocket.OPEN + ) { + // const patches = generatePatch(value, formatted_code); + const msg: Message = { + type: MessageTypes.CONTENT_CHANGE.valueOf(), + data: formatted_code, + userId: userId, + }; + socket.send(JSON.stringify(msg)); + } + } catch (e: unknown) { + if (e instanceof Error) { + alert(e.message); + console.error(e.message); + } else { + console.error("An unknown error occurred"); + } + } + } + + const handleOnChange = (newValue: string) => { + // const patches = generatePatch(value, newValue); + + setValue(newValue); + console.log("Content changed:", userId, newValue); + + if (socket) { + const msg: Message = { + type: MessageTypes.CONTENT_CHANGE.valueOf(), + data: newValue, + userId: userId, + }; + console.log("Sending message", msg); + socket.send(JSON.stringify(msg)); + } + }; + + const handleOnLoad = (editor: Ace.Editor) => { + editor.container.style.resize = "both"; + }; + + useEffect(() => { + if (!roomID) return; + + console.log("Testing http"); + + const newSocket = new WebSocket(`/api/proxy?roomID=${roomID}`); + + newSocket.onopen = () => { + console.log("WebSocket connection established"); + setConnected(true); + + const authMessage: Message = { + type: MessageTypes.AUTH.valueOf(), + token: authToken, + matchHash: matchHash, // omitted if undefined + }; + newSocket.send(JSON.stringify(authMessage)); + }; + + newSocket.onmessage = (event) => { + console.log("Event is", event); + // console.error(event.data); + + const message: Message = JSON.parse(event.data); + + const msgType = message.type as MessageTypes; + + console.log("Received a message of type", msgType); + console.log("Received a message", message.type); + + switch (msgType) { + case MessageTypes.AUTH: + // This should only be sent, never received by the client + throw new Error("Received unexpected auth message"); + case MessageTypes.AUTH_SUCCESS: + setAuthenticated(true); + console.log("Auth success", message.data); + setValue(message.data as string); + break; + case MessageTypes.AUTH_FAIL: + window.alert("Authentication failed"); + newSocket.close(); + router.push("/questions"); + break; + case MessageTypes.CLOSE_SESSION: + window.alert( + "Session has ended. If you leave the room now, this data will be lost.", + ); + newSocket.close(); + setAuthenticated(false); + setConnected(false); + break; + case MessageTypes.CONTENT_CHANGE: + if (message.userId !== userId) { + console.log( + "Received message from user: ", + message.userId, + "I am", + userId, + "We are the same: ", + message.userId === userId, + ); + setValue(message.data as string); + } + break; + case MessageTypes.PING: + if (message.userId !== userId) { + console.log("other user connected!"); + setOtherUserConnected(true); + setLastPingReceived(Date.now()); + } + break; + default: + const exhaustiveCheck: never = msgType; + console.error("Unknown message type:", exhaustiveCheck); + console.log("Message data:", message); + } + }; + + newSocket.onerror = (event) => { + console.error("WebSocket error observed:", event); + console.error("WebSocket readyState:", newSocket.readyState); + console.error("WebSocket URL:", newSocket.url); + }; + + newSocket.onclose = () => { + console.log("WebSocket connection closed"); + // TODO: should setConnected be false here? + // setConnected(false); + // router.push("/questions"); + }; + + setSocket(newSocket); + + return () => { + newSocket.close(); + }; + }, [authToken, matchHash, roomID, router, userId]); + + // ping ws + const notifyRoomOfConnection = async () => { + // send message over ws + if (socket) { + console.log("PINGING WS FROM " + userId); + const msg: Message = { + type: MessageTypes.PING.valueOf(), + userId: userId, + }; + socket.send(JSON.stringify(msg)); + } + }; + + useEffect(() => { + if (!connected || !socket) return; + + const interval = setInterval( + notifyRoomOfConnection, + PING_INTERVAL_MILLISECONDS, + ); + + const disconnectCheckInterval = setInterval(() => { + if ( + lastPingReceived && + Date.now() - lastPingReceived > 3 * PING_INTERVAL_MILLISECONDS + ) { + setOtherUserConnected(false); + clearInterval(disconnectCheckInterval); + } + }, PING_INTERVAL_MILLISECONDS); + + return () => { + clearInterval(interval); + clearInterval(disconnectCheckInterval); + }; + }, [notifyRoomOfConnection, connected, socket]); + + const handleCloseConnection = () => { + const confirmClose = confirm( + "Are you sure you are finished? This will close the room for all users.", + ); + + if (confirmClose && socket) { + console.log("Sent!"); + const msg: Message = { + type: MessageTypes.CLOSE_SESSION.valueOf(), + userId: userId, + }; + socket.send(JSON.stringify(msg)); + } + }; + + return ( + <> + {authenticated && ( + + )} +
+
+ + setFontSize(Number(e.target.value))} + /> +
+ + setTheme(e.target.value)} + options={themes} + className={ + "rounded border border-gray-600 bg-gray-800 p-2 text-white" + } + /> + + setLanguage(e.target.value as Language)} + options={languages} + className={ + "rounded border border-gray-600 bg-gray-800 p-2 text-white" + } + /> + + formatCode(value, language)}> + Format code + + + {roomID && + (connected ? ( +
+ + Close Room + +
+ ) : ( +
+ + Disconnected. Check logs. + +
+ ))} +
+ {roomID && + (connected ? ( +
+ + + {otherUserConnected + ? "Other user connected" + : "Other user disconnected"} + +
+ ) : ( +
Disconnected. Check logs.
+ ))} + + + ); +} diff --git a/peerprep/components/questionpage/CommsPanel.tsx b/peerprep/components/questionpage/CommsPanel.tsx new file mode 100644 index 0000000000..f08ff67a7c --- /dev/null +++ b/peerprep/components/questionpage/CommsPanel.tsx @@ -0,0 +1,226 @@ +import React, { useEffect, useRef, useState } from "react"; +import Peer, { Instance } from "simple-peer"; +import io from "socket.io-client"; + +interface Props { + className?: string; + roomId?: string; +} + +const socket = io(`/`, { + path: '/comms' +}); + +function CommsPanel({ className, roomId }: Props) { + const [stream, setStream] = useState(); + const [callStarts, setCallStarts] = useState(false); + + const myVideo = useRef(null); + const userVideo = useRef(null); + const connectionRef = useRef(); + + useEffect(() => { + socket.removeAllListeners(); + socket.open(); + return () => { + console.log("socket cleanup called"); + if (socket) { + console.log("destroying socket"); + socket.close(); + } + if (connectionRef.current) { + connectionRef.current.destroy(); + } + }; + }, []); + + useEffect(() => { + // capture the stream within the cleanup function itself. + let videoElement: MediaStream | undefined; + + navigator.mediaDevices + .getUserMedia({ video: true, audio: true }) + .then((newStream) => { + console.log("new stream's status is " + newStream.active); + newStream.getTracks().forEach((track: MediaStreamTrack) => { + console.log( + "media track status (ready/enabled): " + + track.readyState + + "/" + + track.enabled, + ); + }); + if (myVideo.current) { + console.log("can set myVideo.current"); + myVideo.current.srcObject = newStream; + } + setStream(newStream); + videoElement = newStream; + }) + .catch((err) => console.log("failed to get stream", err)); + + return () => { + console.log("cleaning up media"); + if (videoElement) { + console.log("destroying stream"); + videoElement.getTracks().forEach((track) => track.stop()); + } + }; + }, []); + + useEffect(() => { + if (!roomId || !stream || !socket.connected) { + console.log("stream status: " + stream); + console.log("connection status: " + socket.connected); + return; + } + console.log("in hook"); + + // clear all listeners if we are reinitializing this. + socket.removeAllListeners(); + console.log("removed all listeners"); + + // when we receive the first peer connection, we immediately send out + // a peer connection request. + attachSocketInitiator(stream, roomId, userVideo, setCallStarts, connectionRef); + + // as the receiver, I will propagate my data outwards now. + attachSocketReceiver(stream, roomId, userVideo, setCallStarts, connectionRef); + + socket.on("endCall", () => { + // immediately destroy the socket listeners + destroyCallListeners(roomId); + if (userVideo.current) { + (userVideo.current.srcObject as MediaStream) + .getTracks() + .forEach((tracks: MediaStreamTrack) => { + tracks.stop(); + }); + userVideo.current.srcObject = null; + setCallStarts(false); + } + if (connectionRef.current && !connectionRef.current.destroyed) { + connectionRef.current.destroy(); + } + // reattach the sockets + attachSocketInitiator(stream, roomId, userVideo, setCallStarts, connectionRef); + attachSocketReceiver(stream, roomId, userVideo, setCallStarts, connectionRef); + // rejoin the room + socket.emit("joinRoom", { + target: roomId, + }); + }); + + socket.emit("joinRoom", { + target: roomId, + }); + console.log("applied all hooks"); + }, [stream, socket.connected]); + + return ( +
+
+
+
+ {!callStarts && +

+ No signal from other user. + (Don't worry - the call doesn't start until we get a signal from both users!) +

} +
+
+ ); +} + +function destroyCallListeners(roomId: string) { + socket.removeAllListeners("startCall"); + socket.removeAllListeners("peerConnected"); + socket.removeAllListeners("handshakeCall"); +} + +function attachSocketReceiver( + stream: MediaStream, + roomId: string, + userVideo: React.RefObject, + setCallStarts: React.Dispatch>, + connectionRef: React.MutableRefObject, +) { + socket.on("startCall", (data) => { + console.log("received start call signal"); + const peerReceive = new Peer({ + initiator: false, + trickle: false, + stream: stream, + }); + + peerReceive.on("signal", (data) => { + console.log("sending handshake"); + socket.emit("handshakeCall", { signal: data, target: roomId }); + }); + + peerReceive.on("stream", (stream) => { + console.log("setting stream of first user"); + if (userVideo.current) { + console.log("user video exists"); + userVideo.current.srcObject = stream; + setCallStarts(true); + } + }); + + connectionRef.current = peerReceive; + console.log("signalling receiver"); + peerReceive.signal(data.signal); + }); +} + +function attachSocketInitiator( + stream: MediaStream, + roomId: string, + userVideo: React.RefObject, + setCallStarts: React.Dispatch>, + connectionRef: React.MutableRefObject, +) { + socket.on("peerConnected", () => { + console.log("peer connected, starting call"); + const peerInit = new Peer({ + initiator: true, + trickle: false, + stream: stream, + }); + + peerInit.on("signal", (data) => { + console.log("signal to start call received"); + socket.emit("startCall", { signalData: data, target: roomId }); + }); + + peerInit.on("stream", (stream) => { + if (userVideo.current) { + console.log("setting stream for handshake"); + userVideo.current.srcObject = stream; + setCallStarts(true); + } + }); + + connectionRef.current = peerInit; + + socket.on("handshakeCall", (data) => { + console.log("received handshake"); + peerInit.signal(data.signal); + }); + }); +} + +export default CommsPanel; diff --git a/peerprep/components/questionpage/Matchmaking.tsx b/peerprep/components/questionpage/Matchmaking.tsx new file mode 100644 index 0000000000..c168763088 --- /dev/null +++ b/peerprep/components/questionpage/Matchmaking.tsx @@ -0,0 +1,192 @@ +"use client"; +import React, { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import PeerprepButton from "../shared/PeerprepButton"; +import { useQuestionFilter } from "@/contexts/QuestionFilterContext"; +import { useUserInfo } from "@/contexts/UserInfoContext"; +import { + Difficulty, + isError, + MatchRequest, + MatchResponse, +} from "@/api/structs"; +import { + checkMatchStatus, + findMatch, +} from "@/app/api/internal/matching/helper"; +import ResettingStopwatch from "../shared/ResettingStopwatch"; +import PeerprepDropdown from "../shared/PeerprepDropdown"; + +const QUERY_INTERVAL_MILLISECONDS = 5000; +const TIMEOUT_MILLISECONDS = 30000; + +const getMatchRequestTime = (): string => { + const now = new Date(); + const options: Intl.DateTimeFormatOptions = { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }; + + const formattedDate = now.toLocaleString("en-CA", options); // gives YYYY-MM-DD, HH:mm:ss + const finalDate = formattedDate.replace(",", "").replace(/:/g, "-"); + + return finalDate; +}; + +const usePeriodicCallback = ( + callback: () => void, + intervalTime: number, + isActive: boolean, +) => { + useEffect(() => { + if (!isActive) return; + + const interval = setInterval(callback, intervalTime); + + return () => clearInterval(interval); + }, [callback, intervalTime, isActive]); +}; + +const Matchmaking = () => { + const router = useRouter(); + const [isMatching, setIsMatching] = useState(false); + const [matchHash, setMatchHash] = useState(""); + const { difficulties, topicList } = useQuestionFilter(); + const [difficultyFilter, setDifficultyFilter] = useState( + Difficulty.Easy, + ); + const [topicFilter, setTopicFilter] = useState(topicList); + const userData = useUserInfo(); + const userid = userData.id; + const timeout = useRef(); + + useEffect(() => { + setTopicFilter(topicList); + }, [topicList]); + + const stopTimer = () => { + // if user manually stopped it clear timeout + if (timeout.current) { + console.debug("Match request timeout stopped"); + clearTimeout(timeout.current); + } + }; + + const getMatchMakingRequest = (): MatchRequest => { + const matchRequest: MatchRequest = { + userId: userid, + difficulty: difficultyFilter, + topicTags: topicFilter, + requestTime: getMatchRequestTime(), + }; + + return matchRequest; + }; + + // TODO: Canceling the match should propagate a cancellation signal + // Currently, we can actually match yourself rn due to this change + // This indicates to me that 1 users can match on a cancellation + const handleMatch = async () => { + if (!isMatching) { + setIsMatching(true); + + // start 30s timeout + timeout.current = setTimeout(() => { + setMatchHash(""); + setIsMatching(false); + console.log("Match request timed out after 30s"); + alert("Failed to find match. Request timed out."); + }, TIMEOUT_MILLISECONDS); + + // assemble the match request + const matchRequest = getMatchMakingRequest(); + console.log("Match attempted"); + console.debug(matchRequest); + + // send match request + const status = await findMatch(matchRequest); + if (isError(status)) { + stopTimer(); + console.log("Failed to find match. Cancel matching."); + setMatchHash(""); + setIsMatching(false); + return; + } + setMatchHash(status.match_code); + console.log(`Started finding match.`); + } else { + setMatchHash(""); + stopTimer(); + setIsMatching(false); + console.log("User stopped matching"); + } + }; + + const queryResource = async () => { + const res = await checkMatchStatus(matchHash); + if (isError(res)) { + // for now 404 means no match found so dont stop matching on error, let request timeout + return; + } + stopTimer(); + setIsMatching(false); + // TODO: iron out what is in a match response and sync up with collab service rooms + const matchRes: MatchResponse = res as MatchResponse; + console.log("Match found!"); + router.push( + `/questions/${matchRes.data.questionId}/${matchRes.data.roomId}?match=${matchHash}`, + ); + }; + + usePeriodicCallback(queryResource, QUERY_INTERVAL_MILLISECONDS, isMatching); + + return ( + // TODO: move this to some admin panel or something +
+ router.push(`questions/new`)}> + Add Question + +
+ + {isMatching + ? "Cancel Match" + : matchHash === "" + ? "Find Match" + : "Redirecting..."} + + {!isMatching && ( + setDifficultyFilter(e.target.value)} + // truthfully we don't need this difficulties list, but we are temporarily including it + options={difficulties} + /> + )} + {!isMatching && ( + + setTopicFilter( + e.target.value === "all" ? topicList : [e.target.value], + ) + } + options={topicList} + /> + )} + {isMatching && } +
+
+ ); +}; + +export default Matchmaking; diff --git a/peerprep/components/questionpage/QuestionCard.tsx b/peerprep/components/questionpage/QuestionCard.tsx new file mode 100644 index 0000000000..79c4e4a8a8 --- /dev/null +++ b/peerprep/components/questionpage/QuestionCard.tsx @@ -0,0 +1,108 @@ +"use client"; +import React from "react"; +import { Difficulty, Question } from "@/api/structs"; +import PeerprepButton from "../shared/PeerprepButton"; +import { useRouter } from "next/navigation"; +import styles from "@/style/questionCard.module.css"; +import { deleteQuestion } from "@/app/questions/helper"; +import DOMPurify from "isomorphic-dompurify"; +import { useUserInfo } from "@/contexts/UserInfoContext"; + +type QuestionCardProps = { + question: Question; +}; + +const QuestionCard: React.FC = ({ question }) => { + const router = useRouter(); + // Note that this is purely UI, there are additional checks in the API call + const userData = useUserInfo(); + const isAdmin = userData.isAdmin; + + const handleDelete = async () => { + if ( + confirm( + `Are you sure you want to delete ${question.title}? (ID: ${question.id}) `, + ) + ) { + const status = await deleteQuestion(question.id); + router.refresh(); + if (status.error) { + console.log("Failed to delete question."); + console.log(`Code ${status.status}: ${status.error}`); + return; + } + console.log(`Successfully deleted the question.`); + } else { + console.log("Deletion cancelled."); + } + }; + const handleEdit = () => { + router.push(`questions/edit/${question.id}`); + }; + + const getDifficultyColor = (difficulty: Difficulty) => { + switch (difficulty) { + case Difficulty.Easy: + return "text-difficulty-easy"; // Green for easy + case Difficulty.Medium: + return "text-difficulty-med"; // Yellow for medium + case Difficulty.Hard: + return "text-difficulty-hard"; // Red for hard + default: + return "text-secondary-text"; // Default color + } + }; + + const questionContent = DOMPurify.sanitize(question.content); + + const match = questionContent.match(/(.*?)(?=<\/p>)/); + const questionContentSubstring = + (match ? match[0] : "No description found") + "..."; + + return ( +
+
+

{question.title}

+

+ Difficulty:{" "} + + {Difficulty[question.difficulty]} + +

+

+ Topics:{" "} + + {question.topicTags ? question.topicTags.join(", ") : "None"} + +

+
+ +
+ { +
+ } +
+ +
+ router.push(`/questions/${question.id}`)} + > + View + + {isAdmin && Edit} + {isAdmin && ( + Delete + )} +
+
+ ); +}; + +export default QuestionCard; diff --git a/peerprep/components/questionpage/QuestionList.tsx b/peerprep/components/questionpage/QuestionList.tsx new file mode 100644 index 0000000000..5901e00da6 --- /dev/null +++ b/peerprep/components/questionpage/QuestionList.tsx @@ -0,0 +1,89 @@ +"use client"; +import React, { useEffect, useState } from "react"; +import QuestionCard from "./QuestionCard"; +import { Difficulty, Question } from "@/api/structs"; +import PeerprepDropdown from "../shared/PeerprepDropdown"; +import PeerprepSearchBar from "../shared/PeerprepSearchBar"; +import { useQuestionFilter } from "@/contexts/QuestionFilterContext"; + +type Props = { + questions: Question[]; +}; + +// TODO make multiple select for topics at least +const QuestionList = ({ questions }: Props) => { + const [searchFilter, setSearchFilter] = useState(""); + + const { + topicList, + setTopicList, + difficultyFilter, + setDifficultyFilter, + topicFilter, + setTopicFilter, + } = useQuestionFilter(); + + useEffect(() => { + const uniqueTopics = Array.from( + new Set(questions.flatMap((question) => question.topicTags)), + ); + setTopicList(["all", ...uniqueTopics]); + }, []); + + const filteredQuestions = questions.filter((question) => { + const matchesDifficulty = + difficultyFilter === Difficulty.All || + Difficulty[question.difficulty] === difficultyFilter; + const matchesTopic = + topicFilter === "all" || (question.topicTags ?? []).includes(topicFilter); + const matchesSearch = + searchFilter === "" || + (question.title ?? "").toLowerCase().includes(searchFilter.toLowerCase()); + + return matchesDifficulty && matchesTopic && matchesSearch; + }); + + const sortedQuestions = filteredQuestions.sort((a, b) => a.id - b.id); + + const handleSetDifficulty = (e: React.ChangeEvent) => { + const diff = e.target.value; + setDifficultyFilter(diff); + }; + + const handleSetTopics = (e: React.ChangeEvent) => { + const topic = e.target.value; + setTopicFilter(topic); + }; + + return ( +
+
+ setSearchFilter(e.target.value)} + /> + isNaN(Number(key)))} + /> + +
+ +
+ {sortedQuestions.map((question) => ( + + ))} +
+
+ ); +}; + +export default QuestionList; diff --git a/peerprep/components/shared/Chip.tsx b/peerprep/components/shared/Chip.tsx new file mode 100644 index 0000000000..85d3202c73 --- /dev/null +++ b/peerprep/components/shared/Chip.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import styles from "@/style/layout.module.css"; + +interface Props { + className: string; + children: React.ReactNode; +} + +function Chip({ className, children }: Props) { + return

{children}

; +} + +export default Chip; diff --git a/peerprep/components/shared/Container.tsx b/peerprep/components/shared/Container.tsx new file mode 100644 index 0000000000..f73f3b2d97 --- /dev/null +++ b/peerprep/components/shared/Container.tsx @@ -0,0 +1,12 @@ +import React, { ReactNode } from "react"; + +interface Props { + children: ReactNode; + className: string; +} + +function Container({ children, className }: Props) { + return
{children}
; +} + +export default Container; diff --git a/peerprep/components/shared/ErrorBlock.tsx b/peerprep/components/shared/ErrorBlock.tsx new file mode 100644 index 0000000000..b7ba66f073 --- /dev/null +++ b/peerprep/components/shared/ErrorBlock.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import styles from "@/style/error.module.css"; +import { StatusBody } from "@/api/structs"; + +interface Props { + err: StatusBody; +} + +function ErrorBlock({ err }: Props) { + return ( +
+

+ {err.status}: We've seen better days. +

+

Reason: {err.error}

+
+ ); +} + +export default ErrorBlock; diff --git a/peerprep/components/shared/PeerprepButton.tsx b/peerprep/components/shared/PeerprepButton.tsx new file mode 100644 index 0000000000..c2e70d3ec6 --- /dev/null +++ b/peerprep/components/shared/PeerprepButton.tsx @@ -0,0 +1,32 @@ +"use client"; +import React from "react"; +import styles from "@/style/elements.module.css"; + +type PeerprepButtonProps = { + onClick?: () => void; + children: React.ReactNode; + className?: string; + disabled?: boolean; + type?: "button" | "submit" | "reset"; +}; + +const PeerprepButton: React.FC = ({ + onClick, + children, + className, + disabled, + type, +}) => { + return ( + + ); +}; + +export default PeerprepButton; diff --git a/peerprep/components/shared/PeerprepDropdown.tsx b/peerprep/components/shared/PeerprepDropdown.tsx new file mode 100644 index 0000000000..8662029ceb --- /dev/null +++ b/peerprep/components/shared/PeerprepDropdown.tsx @@ -0,0 +1,39 @@ +"use client"; +import React from "react"; +import styles from "@/style/elements.module.css"; +type PeerprepDropdownProps = { + label: string; + value: T; + onChange: (e: React.ChangeEvent) => void; + options: T[]; + className?: string; +}; + +const PeerprepDropdown = ({ + label, + value, + onChange, + options, + className, +}: PeerprepDropdownProps): JSX.Element => { + return ( +
+ + +
+ ); +}; + +export default PeerprepDropdown; diff --git a/peerprep/components/shared/PeerprepSearchBar.tsx b/peerprep/components/shared/PeerprepSearchBar.tsx new file mode 100644 index 0000000000..f4b8c0d308 --- /dev/null +++ b/peerprep/components/shared/PeerprepSearchBar.tsx @@ -0,0 +1,31 @@ +// PeerprepSearchBar.tsx +import React from "react"; +import styles from "@/style/elements.module.css"; +import { Search } from "lucide-react"; + +interface PeerprepSearchBarProps { + value: string; + label?: string; + onChange: (e: React.ChangeEvent) => void; +} + +const PeerprepSearchBar: React.FC = ({ + value, + label = "Search...", + onChange, +}) => { + return ( +
+ + +
+ ); +}; + +export default PeerprepSearchBar; diff --git a/peerprep/components/shared/ResettingStopwatch.tsx b/peerprep/components/shared/ResettingStopwatch.tsx new file mode 100644 index 0000000000..d074936ab6 --- /dev/null +++ b/peerprep/components/shared/ResettingStopwatch.tsx @@ -0,0 +1,38 @@ +import React, { useState, useEffect } from "react"; + +interface ResettingStopwatchProps { + isActive: boolean; +} + +// pass isActive from parent component +// +const ResettingStopwatch: React.FC = ({ + isActive, +}) => { + const [elapsedTime, setElapsedTime] = useState(0); + + useEffect(() => { + let interval: NodeJS.Timeout | null = null; + + if (isActive) { + interval = setInterval(() => { + setElapsedTime((prevTime) => prevTime + 1); + }, 1000); + } + + return () => { + if (interval) clearInterval(interval); + setElapsedTime(0); + }; + }, [isActive]); + + const formatTime = (time: number) => { + const minutes = Math.floor(time / 60); + const seconds = time % 60; + return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`; + }; + + return
{formatTime(elapsedTime)}
; +}; + +export default ResettingStopwatch; diff --git a/peerprep/components/shared/form/FormTextAreaInput.tsx b/peerprep/components/shared/form/FormTextAreaInput.tsx new file mode 100644 index 0000000000..8959817f83 --- /dev/null +++ b/peerprep/components/shared/form/FormTextAreaInput.tsx @@ -0,0 +1,41 @@ +import { ChangeEvent } from "react"; +import style from "@/style/form.module.css"; + +type Props = { + name: string; + label: string; + value: string; + className?: string; + required?: boolean; + disabled?: boolean; + id?: string; + onChange: (e: ChangeEvent) => void; +}; + +function TextInput({ + name, + label, + value, + className, + required, + id, + disabled, + onChange, +}: Props) { + return ( +
+

{label}

+