diff --git a/backend/dto/marker_dto.go b/backend/dto/marker_dto.go index 2be81270..e53de8bc 100644 --- a/backend/dto/marker_dto.go +++ b/backend/dto/marker_dto.go @@ -1,6 +1,9 @@ package dto -import "chulbong-kr/models" +import ( + "chulbong-kr/models" + "time" +) type MarkerRequest struct { MarkerID int `json:"markerId,omitempty"` @@ -47,8 +50,9 @@ type MarkerSimple struct { } type MarkerSimpleWithDescrption struct { - MarkerID int `json:"markerId" db:"MarkerID"` - Latitude float64 `json:"latitude" db:"Latitude"` - Longitude float64 `json:"longitude" db:"Longitude"` - Description string `json:"description" db:"Description"` + MarkerID int `json:"markerId" db:"MarkerID"` + Latitude float64 `json:"latitude" db:"Latitude"` + Longitude float64 `json:"longitude" db:"Longitude"` + Description string `json:"description" db:"Description"` + CreatedAt time.Time `json:"-" db:"CreatedAt"` } diff --git a/backend/handlers/comment_api.go b/backend/handlers/comment_api.go index 24ef250e..9b59f31e 100644 --- a/backend/handlers/comment_api.go +++ b/backend/handlers/comment_api.go @@ -3,6 +3,7 @@ package handlers import ( "chulbong-kr/dto" "chulbong-kr/services" + "chulbong-kr/utils" "strconv" "github.com/gofiber/fiber/v2" @@ -16,7 +17,7 @@ func PostCommentHandler(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) } - containsBadWord, _ := services.CheckForBadWords(req.CommentText) + containsBadWord, _ := utils.CheckForBadWords(req.CommentText) if containsBadWord { return c.Status(fiber.StatusBadRequest).SendString("Comment contains inappropriate content.") } diff --git a/backend/handlers/marker_api.go b/backend/handlers/marker_api.go index f3ce5aca..235c218c 100644 --- a/backend/handlers/marker_api.go +++ b/backend/handlers/marker_api.go @@ -55,7 +55,7 @@ func CreateMarkerWithPhotosHandler(c *fiber.Ctx) error { description = descValues[0] } - containsBadWord, _ := services.CheckForBadWords(description) + containsBadWord, _ := utils.CheckForBadWords(description) if containsBadWord { return c.Status(fiber.StatusBadRequest).SendString("Comment contains inappropriate content.") } @@ -163,18 +163,30 @@ func DeleteMarkerHandler(c *fiber.Ctx) error { // UploadMarkerPhotoToS3Handler to upload a file to S3 func UploadMarkerPhotoToS3Handler(c *fiber.Ctx) error { - file, err := c.FormFile("file") + // Parse the multipart form + form, err := c.MultipartForm() if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Could not get uploaded file"}) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Failed to parse form"}) } - fileURL, err := services.UploadFileToS3(file) + markerIDstr, markerIDExists := form.Value["markerId"] + if !markerIDExists { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Failed to parse form"}) + } + + markerID, err := strconv.Atoi(markerIDstr[0]) if err != nil { - // Interpret the error message to set the appropriate HTTP status code - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Failed to parse form"}) + } + + files := form.File["photos"] + + urls, err := services.UploadMarkerPhotoToS3(markerID, files) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to upload photos"}) } - return c.JSON(fiber.Map{"url": fileURL}) + return c.JSON(fiber.Map{"urls": urls}) } // DeleteObjectFromS3Handler handles requests to delete objects from S3. @@ -261,7 +273,7 @@ func GetUserMarkersHandler(c *fiber.Ctx) error { markersWithPhotos, total, err := services.GetAllMarkersByUserWithPagination(userID, page, pageSize) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "check if you have added markers"}) } totalPages := int(math.Ceil(float64(total) / float64(pageSize))) diff --git a/backend/main.go b/backend/main.go index 301f8839..62668b32 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,7 +1,6 @@ package main import ( - "bufio" "chulbong-kr/database" "chulbong-kr/handlers" "chulbong-kr/middlewares" @@ -66,8 +65,9 @@ func main() { }) services.RedisStore = store - services.ResetCache("badwords") - loadBadWordsIntoRedis("badwords.txt") + if err := utils.LoadBadWords("badwords.txt"); err != nil { + log.Fatalf("Failed to load bad words: %v", err) + } // Initialize global variables setTokenExpirationTime() @@ -142,10 +142,10 @@ func main() { // Enable CORS for all routes app.Use(cors.New(cors.Config{ - AllowOrigins: "http://localhost:5173,https://chulbong-kr.vercel.app", // List allowed origins - AllowMethods: "GET,POST,PUT,DELETE,OPTIONS", // Explicitly list allowed methods - AllowHeaders: "*", // TODO: Allow specific headers - ExposeHeaders: "Accept, Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Accept-Encoding", + AllowOrigins: "http://localhost:5173,https://chulbong-kr.vercel.app", // List allowed origins + AllowMethods: "GET,POST,PUT,DELETE,OPTIONS", // Explicitly list allowed methods + AllowHeaders: "*", // TODO: Allow specific headers + // ExposeHeaders: "Accept", AllowCredentials: true, })) @@ -193,6 +193,7 @@ func main() { }), handlers.GetAllMarkersHandler) api.Get("/markers/:markerId/details", middlewares.AuthSoftMiddleware, handlers.GetMarker) api.Get("/markers/close", handlers.FindCloseMarkersHandler) + api.Post("/markers/upload", middlewares.AdminOnly, handlers.UploadMarkerPhotoToS3Handler) markerGroup := api.Group("/markers") { @@ -203,7 +204,6 @@ func main() { // markerGroup.Get("/:markerId", handlers.GetMarker) markerGroup.Post("/new", handlers.CreateMarkerWithPhotosHandler) - markerGroup.Post("/upload", handlers.UploadMarkerPhotoToS3Handler) markerGroup.Post("/:markerID/dislike", handlers.LeaveDislikeHandler) markerGroup.Post("/:markerID/favorites", handlers.AddFavoriteHandler) @@ -259,43 +259,3 @@ func setTokenExpirationTime() { // Assign the converted duration to the global variable services.TOKEN_DURATION = time.Duration(durationInt) * time.Hour } - -func loadBadWordsIntoRedis(filePath string) { - const batchSize = 500 - - file, err := os.Open(filePath) - if err != nil { - panic(err) - } - defer file.Close() - - scanner := bufio.NewScanner(file) - batch := make([]string, 0, batchSize) - - for scanner.Scan() { - word := scanner.Text() - batch = append(batch, word) - - // Once we've collected enough words, insert them in a batch. - if len(batch) >= batchSize { - err := services.AddBadWords(batch) - if err != nil { - fmt.Printf("Failed to insert batch: %v\n", err) - } - // Reset the batch slice for the next group of words - batch = batch[:0] - } - } - - // Don't forget to insert any words left in the batch after finishing the loop - if len(batch) > 0 { - err := services.AddBadWords(batch) - if err != nil { - fmt.Printf("Failed to insert final batch: %v\n", err) - } - } - - if err := scanner.Err(); err != nil { - panic(err) - } -} diff --git a/backend/services/marker_service.go b/backend/services/marker_service.go index 6f9f1625..97dc3732 100644 --- a/backend/services/marker_service.go +++ b/backend/services/marker_service.go @@ -9,8 +9,6 @@ import ( "chulbong-kr/database" "chulbong-kr/dto" "chulbong-kr/models" - - "github.com/jmoiron/sqlx" ) func CreateMarkerWithPhotos(markerDto *dto.MarkerRequest, userID int, form *multipart.Form) (*dto.MarkerResponse, error) { @@ -89,35 +87,19 @@ func GetAllMarkers() ([]dto.MarkerSimple, error) { return markers, nil } -func GetAllMarkersByUserWithPagination(userID, page, pageSize int) ([]models.MarkerWithPhotos, int, error) { +func GetAllMarkersByUserWithPagination(userID, page, pageSize int) ([]dto.MarkerSimpleWithDescrption, int, error) { offset := (page - 1) * pageSize // Query to select markers created by a specific user with LIMIT and OFFSET for pagination markerQuery := ` SELECT - M.MarkerID, - M.UserID, + M.MarkerID, ST_X(M.Location) AS Latitude, ST_Y(M.Location) AS Longitude, - M.Description, - U.Username, - M.CreatedAt, - M.UpdatedAt, - IFNULL(D.DislikeCount, 0) AS DislikeCount + M.Description, + M.CreatedAt FROM Markers M -INNER JOIN - Users U ON M.UserID = U.UserID -LEFT JOIN - ( - SELECT - MarkerID, - COUNT(DislikeID) AS DislikeCount - FROM - MarkerDislikes - GROUP BY - MarkerID - ) D ON M.MarkerID = D.MarkerID WHERE M.UserID = ? ORDER BY @@ -125,41 +107,12 @@ ORDER BY LIMIT ? OFFSET ? ` - var markersWithUsernames []dto.MarkerWithDislike - err := database.DB.Select(&markersWithUsernames, markerQuery, userID, pageSize, offset) + markersWithDescription := make([]dto.MarkerSimpleWithDescrption, 0) + err := database.DB.Select(&markersWithDescription, markerQuery, userID, pageSize, offset) if err != nil { return nil, 0, err } - // Fetch all photos at once - photoQuery := `SELECT * FROM Photos WHERE MarkerID IN (?)` - query, args, err := sqlx.In(photoQuery, getMarkerIDs(markersWithUsernames)) - if err != nil { - return nil, 0, err - } - var allPhotos []models.Photo - err = database.DB.Select(&allPhotos, database.DB.Rebind(query), args...) - if err != nil { - return nil, 0, err - } - - // Map photos to their markers - photoMap := make(map[int][]models.Photo) // markerID to photos - for _, photo := range allPhotos { - photoMap[photo.MarkerID] = append(photoMap[photo.MarkerID], photo) - } - - // Assemble the final structure - markersWithPhotos := make([]models.MarkerWithPhotos, 0) - for _, marker := range markersWithUsernames { - markersWithPhotos = append(markersWithPhotos, models.MarkerWithPhotos{ - Marker: marker.Marker, - Photos: photoMap[marker.MarkerID], - Username: marker.Username, - DislikeCount: marker.DislikeCount, - }) - } - // Query to get the total count of markers for the user countQuery := `SELECT COUNT(DISTINCT Markers.MarkerID) FROM Markers WHERE Markers.UserID = ?` var total int @@ -168,7 +121,7 @@ LIMIT ? OFFSET ? return nil, 0, err } - return markersWithPhotos, total, nil + return markersWithDescription, total, nil } // GetMarker retrieves a single marker and its associated photo by the marker's ID @@ -227,7 +180,7 @@ WHERE M.MarkerID = ?` DislikeCount: markersWithUsernames.DislikeCount, } - PublishMarkerUpdate(fmt.Sprintf("user: %s", markersWithPhotos.Username)) + // PublishMarkerUpdate(fmt.Sprintf("user: %s", markersWithPhotos.Username)) return &markersWithPhotos, nil } @@ -455,6 +408,42 @@ func RemoveFavorite(userID, markerID int) error { return nil } +func UploadMarkerPhotoToS3(markerID int, files []*multipart.FileHeader) ([]string, error) { + // Begin a transaction for database operations + tx, err := database.DB.Beginx() + if err != nil { + return nil, err + } + + defer tx.Rollback() + + picUrls := make([]string, 0) + // Process file uploads from the multipart form + for _, file := range files { + fileURL, err := UploadFileToS3(file) + if err != nil { + fmt.Printf("Failed to upload file to S3: %v\n", err) + continue // Skip this file and continue with the next + } + picUrls = append(picUrls, fileURL) + // Associate each photo with the marker in the database + if _, err := tx.Exec("INSERT INTO Photos (MarkerID, PhotoURL, UploadedAt) VALUES (?, ?, NOW())", markerID, fileURL); err != nil { + // Attempt to delete the uploaded file from S3 + if delErr := DeleteDataFromS3(fileURL); delErr != nil { + fmt.Printf("Also failed to delete the file from S3: %v\n", delErr) + } + return nil, err + } + } + + // If no errors, commit the transaction + if err := tx.Commit(); err != nil { + return nil, err + } + + return picUrls, nil +} + // Helper function to extract marker IDs func getMarkerIDs(markers []dto.MarkerWithDislike) []interface{} { ids := make([]interface{}, len(markers)) diff --git a/backend/services/redis_services.go b/backend/services/redis_services.go index dcbc4dea..4e7e5454 100644 --- a/backend/services/redis_services.go +++ b/backend/services/redis_services.go @@ -1,16 +1,15 @@ package services import ( - "context" "encoding/json" - "strings" "time" "github.com/gofiber/storage/redis/v3" ) -var RedisStore *redis.Storage -var ctx = context.Background() +var ( + RedisStore *redis.Storage +) const ( ALL_MARKERS_KEY string = "all_markers" @@ -79,25 +78,65 @@ func ResetCache(key string) error { return nil } -func AddBadWords(batch []string) error { - // SAdd returns *redis.IntCmd, which has an Err() method to fetch the error if any occurred during the operation - err := RedisStore.Conn().SAdd(ctx, "badwords", batch).Err() - if err != nil { - return err - } - return nil -} - -func CheckForBadWords(input string) (bool, error) { - words := strings.Fields(input) - for _, word := range words { - exists, err := RedisStore.Conn().SIsMember(ctx, "badwords", word).Result() - if err != nil { - return false, err - } - if exists { - return true, nil - } - } - return false, nil -} +// func CompileBadWordsPattern(badWords []string) error { +// var pattern strings.Builder +// // Start of the group +// pattern.WriteString("(") +// for i, word := range badWords { +// // QuoteMeta escapes all regex meta characters in the bad word +// pattern.WriteString(regexp.QuoteMeta(word)) +// // Separate words with a pipe, except the last word +// if i < len(badWords)-1 { +// pattern.WriteString("|") +// } +// } +// // End of the group +// pattern.WriteString(")") + +// var err error +// badWordRegex, err = regexp.Compile(pattern.String()) +// if err != nil { +// return err +// } +// return nil +// } + +// func LoadBadWordsIntoRedis(filePath string) { +// const batchSize = 500 + +// file, err := os.Open(filePath) +// if err != nil { +// panic(err) +// } +// defer file.Close() + +// scanner := bufio.NewScanner(file) +// batch := make([]string, 0, batchSize) + +// for scanner.Scan() { +// word := scanner.Text() +// batch = append(batch, word) + +// // Once we've collected enough words, insert them in a batch. +// if len(batch) >= batchSize { +// err := AddBadWords(batch) +// if err != nil { +// fmt.Printf("Failed to insert batch: %v\n", err) +// } +// // Reset the batch slice for the next group of words +// batch = batch[:0] +// } +// } + +// // Don't forget to insert any words left in the batch after finishing the loop +// if len(batch) > 0 { +// err := AddBadWords(batch) +// if err != nil { +// fmt.Printf("Failed to insert final batch: %v\n", err) +// } +// } + +// if err := scanner.Err(); err != nil { +// panic(err) +// } +// } diff --git a/backend/utils/badword_util.go b/backend/utils/badword_util.go new file mode 100644 index 00000000..33135fcf --- /dev/null +++ b/backend/utils/badword_util.go @@ -0,0 +1,80 @@ +package utils + +import ( + "bufio" + "errors" + "os" + "regexp" + "strings" +) + +var ( + badWordsList []string + badWordRegex *regexp.Regexp +) + +func CompileBadWordsPattern() error { + var pattern strings.Builder + pattern.WriteString(`(`) + for i, word := range badWordsList { + if word == "" { + continue + } + pattern.WriteString(regexp.QuoteMeta(word)) + if i < len(badWordsList)-1 { + pattern.WriteString(`|`) + } + } + pattern.WriteString(`)`) + + var err error + badWordRegex, err = regexp.Compile(pattern.String()) + return err +} + +func CheckForBadWords(input string) (bool, error) { + if badWordRegex == nil { + return false, errors.New("bad words pattern not compiled") + } + + return badWordRegex.MatchString(input), nil +} + +// func CheckForBadWords(input string) (bool, error) { +// // TODO: Normalize input for comparison + +// // TODO: consider parallelizing +// for _, word := range badWordsList { +// if word == "" { +// continue +// } + +// // Check if the bad word is a substring of the input +// if strings.Contains(input, word) { +// return true, nil +// } +// } +// return false, nil +// } + +// LoadBadWords loads bad words from a file into memory +func LoadBadWords(filePath string) error { + file, err := os.Open(filePath) + if err != nil { + return err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + word := scanner.Text() + badWordsList = append(badWordsList, word) + } + + if err := scanner.Err(); err != nil { + return err + } + + CompileBadWordsPattern() + return nil +} diff --git a/backend/utils/badword_util_test.go b/backend/utils/badword_util_test.go new file mode 100644 index 00000000..fad4d720 --- /dev/null +++ b/backend/utils/badword_util_test.go @@ -0,0 +1,57 @@ +package utils + +import ( + "log" + "testing" +) + +func TestCheckForBadWords(t *testing.T) { + if err := LoadBadWords("../badwords.txt"); err != nil { + log.Fatalf("Failed to load bad words: %v", err) + } + + tests := []struct { + name string + input string + want bool + }{ + { + name: "contains bad word", + input: "시잇ㅄ발", + want: true, + }, + { + name: "contains no bad word", + input: "좋아요 굿!!!", + want: false, + }, + { + name: "contains multiple bad words", + input: "뭐라노 ㅅㅂ", + want: true, + }, + { + name: "contains no bad word 2", + input: "ㅋㅋㅋㅋㅋㅋ너무좋아요 근데 이게 뭐라고 ~~", + want: false, + }, + { + name: "contains bad word with punctuation", + input: "아닠ㅋㅋ병신?", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := CheckForBadWords(tt.input) + if err != nil { + t.Errorf("CheckForBadWords() error = %v", err) + return + } + if got != tt.want { + t.Errorf("CheckForBadWords() got = %v, want %v", got, tt.want) + } + }) + } +}