diff --git a/api/location.go b/api/location.go index 2651ba6..110e86e 100755 --- a/api/location.go +++ b/api/location.go @@ -215,13 +215,29 @@ func (server *Server) getLocation(ctx *gin.Context) { return } + type reviewWithImages struct { + db.GetListLocationReviewsRow + Images []db.GetImagesByReviewRow `json:"images"` + } + + attachImages := func(reviews []db.GetListLocationReviewsRow) []reviewWithImages { + result := make([]reviewWithImages, 0, len(reviews)) + for _, r := range reviews { + images, _ := server.Store.GetImagesByReview(ctx, r.ID) + if images == nil { + images = []db.GetImagesByReviewRow{} + } + result = append(result, reviewWithImages{r, images}) + } + return result + } + users_reviews, err := server.Store.GetListLocationReviews(ctx, db.GetListLocationReviewsParams{ LocationID: req.ID, Limit: 5, Offset: 0, IsCritics: false, }) - if err != nil { ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Something went wrong while try to receive user reviews for this location")) return @@ -233,7 +249,6 @@ func (server *Server) getLocation(ctx *gin.Context) { Offset: 0, IsCritics: true, }) - if err != nil { ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Something went wrong while try to receive user reviews for this location")) return @@ -242,8 +257,8 @@ func (server *Server) getLocation(ctx *gin.Context) { res := gin.H{ "tags": tags, "detail": location, - "users_review": users_reviews, - "critics_review": critics_reviews, + "users_review": attachImages(users_reviews), + "critics_review": attachImages(critics_reviews), } ctx.JSON(http.StatusOK, res) diff --git a/api/reviews.go b/api/reviews.go index aaaccc9..47b55f2 100755 --- a/api/reviews.go +++ b/api/reviews.go @@ -1,13 +1,19 @@ package api import ( + "fmt" "log" "net/http" + "path/filepath" + "strconv" + "time" db "git.nochill.in/nochill/hiling_go/db/repository" + "git.nochill.in/nochill/hiling_go/util" "git.nochill.in/nochill/hiling_go/util/token" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" ) type createReviewReq struct { @@ -17,6 +23,7 @@ type createReviewReq struct { IsFromCritic *bool `json:"is_from_critic"` IsHided *bool `json:"is_hided"` LocationId int32 `json:"location_id" binding:"required"` + Title string `json:"title"` } func (server *Server) createReview(ctx *gin.Context) { @@ -51,6 +58,7 @@ func (server *Server) createReview(ctx *gin.Context) { IsFromCritic: *req.IsFromCritic, IsHided: *req.IsHided, LocationID: req.LocationId, + Title: pgtype.Text{String: req.Title, Valid: req.Title != ""}, } review, err := server.Store.CreateReview(ctx, arg) @@ -63,10 +71,77 @@ func (server *Server) createReview(ctx *gin.Context) { ctx.JSON(http.StatusOK, review) } +func (server *Server) uploadReviewImages(ctx *gin.Context) { + reviewIdStr := ctx.PostForm("review_id") + reviewId, err := strconv.Atoi(reviewIdStr) + if err != nil || reviewId <= 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"message": "Invalid review_id"}) + return + } + + authPayload := ctx.MustGet(authorizationPayloadKey).(*token.Payload) + + form, err := ctx.MultipartForm() + if err != nil { + ctx.JSON(http.StatusBadRequest, ErrorResponse(err, "Failed to parse multipart form")) + return + } + + files := form.File["images"] + if len(files) == 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"message": "No images provided"}) + return + } + + var params []db.CreateImageParams + + for _, fileHeader := range files { + fileExt := filepath.Ext(fileHeader.Filename) + key := fmt.Sprintf("reviews/%d/%s%d%s", reviewId, util.RandomString(5), time.Now().UnixNano(), fileExt) + + f, err := fileHeader.Open() + if err != nil { + ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Failed to open image")) + return + } + defer f.Close() + + contentType := fileHeader.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/octet-stream" + } + + publicURL, err := server.R2.UploadFile(ctx.Request.Context(), key, f, contentType) + if err != nil { + ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Failed to upload image to storage")) + return + } + + params = append(params, db.CreateImageParams{ + ImageUrl: publicURL, + UploadedBy: int32(authPayload.UserID), + ImageType: "reviews", + ImageOf: int32(reviewId), + }) + } + + if err := server.Store.CreateImage(ctx, params); err != nil { + ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Failed to save image records")) + return + } + + ctx.JSON(http.StatusOK, gin.H{"uploaded": len(params)}) +} + type getUserReviewByLocationReq struct { LocationID int32 `uri:"location_id" binding:"required"` } +type userReviewWithImagesResponse struct { + db.GetUserReviewByLocationRow + Images []db.GetImagesByReviewRow `json:"images"` +} + func (server *Server) getUserReviewByLocation(ctx *gin.Context) { var req getUserReviewByLocationReq @@ -95,5 +170,13 @@ func (server *Server) getUserReviewByLocation(ctx *gin.Context) { return } - ctx.JSON(http.StatusOK, review) + images, err := server.Store.GetImagesByReview(ctx, review.ID) + if err != nil { + images = []db.GetImagesByReviewRow{} + } + + ctx.JSON(http.StatusOK, userReviewWithImagesResponse{ + GetUserReviewByLocationRow: review, + Images: images, + }) } diff --git a/api/server.go b/api/server.go index 186202d..6b759f2 100755 --- a/api/server.go +++ b/api/server.go @@ -5,6 +5,7 @@ import ( db "git.nochill.in/nochill/hiling_go/db/repository" "git.nochill.in/nochill/hiling_go/util" + "git.nochill.in/nochill/hiling_go/util/cloudfare" "git.nochill.in/nochill/hiling_go/util/token" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" @@ -17,6 +18,7 @@ type Server struct { TokenMaker token.Maker Router *gin.Engine MeilisearchClient *meilisearch.Client + R2 *cloudfare.R2Client } func NewServer(config util.Config, store db.Store) (*Server, error) { @@ -30,11 +32,23 @@ func NewServer(config util.Config, store db.Store) (*Server, error) { APIKey: config.MeilisearchKey, }) + r2Client, err := cloudfare.NewR2Client( + config.R2AccountID, + config.R2AccessKeyID, + config.R2SecretAccessKey, + config.R2BucketName, + config.R2PublicApiID, + ) + if err != nil { + return nil, fmt.Errorf("cannot create R2 client: %w", err) + } + server := &Server{ Config: config, Store: store, TokenMaker: tokenMaker, MeilisearchClient: meiliClient, + R2: r2Client, } server.getRoutes() @@ -45,7 +59,7 @@ func (server *Server) getRoutes() { router := gin.Default() router.Use(cors.New(cors.Config{ - AllowOrigins: []string{"http://localhost:5173", "http://192.168.18.164:5173", "http://192.168.1.13:5173"}, + AllowOrigins: []string{"http://localhost:5173", "http://192.168.18.164:5173", "http://192.168.18.150:5173", "http://192.168.1.9:5173"}, AllowCredentials: true, AllowHeaders: []string{"Content-Type", "Content-Length", "Accept-Encoding", "Authorization", "accept", "origin", "Cache-Control", "X-Requested-With", "X-XSRF-TOKEN"}, AllowMethods: []string{"POST", "PUT", "GET", "DELETE", "PATCH"}, @@ -77,6 +91,7 @@ func (server *Server) getRoutes() { // REQUIRE AUTH TOKEN authRoutes := router.Use(authMiddleware(server.TokenMaker)) authRoutes.POST("/review/location", server.createReview) + authRoutes.POST("/review/location/images", server.uploadReviewImages) authRoutes.PATCH("/user", server.updateUser) authRoutes.GET("/user/review/location/:location_id", server.getUserReviewByLocation) authRoutes.GET("/user/profile", server.getUserStats) diff --git a/db/migrations/000001_init_schema.up.sql b/db/migrations/000001_init_schema.up.sql index 16e8274..3103c1c 100755 --- a/db/migrations/000001_init_schema.up.sql +++ b/db/migrations/000001_init_schema.up.sql @@ -118,6 +118,7 @@ CREATE TABLE location_images ( CREATE TABLE reviews ( "id" serial primary key not null, + "title" varchar, "submitted_by" integer references "users"("id") not null, "comments" text not null, "score" smallint not null, diff --git a/db/mock/store.go b/db/mock/store.go index 2e35d26..81288d1 100755 --- a/db/mock/store.go +++ b/db/mock/store.go @@ -84,6 +84,21 @@ func (mr *MockStoreMockRecorder) CreateImage(arg0, arg1 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateImage", reflect.TypeOf((*MockStore)(nil).CreateImage), arg0, arg1) } +// GetImagesByReview mocks base method. +func (m *MockStore) GetImagesByReview(arg0 context.Context, arg1 int32) ([]db.GetImagesByReviewRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetImagesByReview", arg0, arg1) + ret0, _ := ret[0].([]db.GetImagesByReviewRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetImagesByReview indicates an expected call of GetImagesByReview. +func (mr *MockStoreMockRecorder) GetImagesByReview(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetImagesByReview", reflect.TypeOf((*MockStore)(nil).GetImagesByReview), arg0, arg1) +} + // CreateLocation mocks base method. func (m *MockStore) CreateLocation(arg0 context.Context, arg1 db.CreateLocationParams) (int32, error) { m.ctrl.T.Helper() diff --git a/db/queries/reviews.sql b/db/queries/reviews.sql index f797c94..19db799 100755 --- a/db/queries/reviews.sql +++ b/db/queries/reviews.sql @@ -1,12 +1,14 @@ -- name: CheckIfReviewExists :one -SELECT COUNT(1) -FROM reviews +SELECT COUNT(1) +FROM reviews WHERE reviews.location_id = $1 AND reviews.submitted_by = $2; -- name: GetUserReviewByLocation :one SELECT re.id, + re.title, + re.submitted_by, re.location_id, re.score, re.comments, diff --git a/db/repository/db.go b/db/repository/db.go index 9959cd0..9d485b5 100755 --- a/db/repository/db.go +++ b/db/repository/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.25.0 +// sqlc v1.30.0 package db diff --git a/db/repository/follow.sql.go b/db/repository/follow.sql.go index ded44a0..1f68df9 100755 --- a/db/repository/follow.sql.go +++ b/db/repository/follow.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.25.0 +// sqlc v1.30.0 // source: follow.sql package db diff --git a/db/repository/images.go b/db/repository/images.go index 72b8b9a..95120db 100755 --- a/db/repository/images.go +++ b/db/repository/images.go @@ -61,6 +61,39 @@ func (q *Queries) GetImagesByLocation(ctx context.Context, arg GetImagesByLocati return items, nil } +type GetImagesByReviewRow struct { + ID int32 `json:"id"` + Src string `json:"src"` +} + +const getImagesByReviewQ = ` +SELECT id, image_url as src +FROM images +WHERE image_type = 'reviews' AND image_of = $1 +` + +func (q *Queries) GetImagesByReview(ctx context.Context, reviewID int32) ([]GetImagesByReviewRow, error) { + rows, err := q.db.Query(ctx, getImagesByReviewQ, reviewID) + if err != nil { + return nil, err + } + defer rows.Close() + + items := []GetImagesByReviewRow{} + for rows.Next() { + var i GetImagesByReviewRow + if err := rows.Scan(&i.ID, &i.Src); err != nil { + return nil, err + } + items = append(items, i) + } + + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + type CreateImageParams struct { ImageUrl string `json:"url"` UploadedBy int32 `json:"uploaded_by"` @@ -87,11 +120,7 @@ func (q *Queries) CreateImage(ctx context.Context, arg []CreateImageParams) erro // Replacing ? with $n for postgres queryStr = util.ReplaceSQL(queryStr, "?") - // prepare the statement - _, err := q.db.Exec(ctx, queryStr) - - // format all vals at once - // _, err := stmt.Ex(ctx, values...) + _, err := q.db.Exec(ctx, queryStr, values...) return err } diff --git a/db/repository/images.sql.go b/db/repository/images.sql.go index cdd981d..cacab73 100755 --- a/db/repository/images.sql.go +++ b/db/repository/images.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.25.0 +// sqlc v1.30.0 // source: images.sql package db diff --git a/db/repository/locations.sql.go b/db/repository/locations.sql.go index 6700afd..dd99bd9 100755 --- a/db/repository/locations.sql.go +++ b/db/repository/locations.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.25.0 +// sqlc v1.30.0 // source: locations.sql package db diff --git a/db/repository/models.go b/db/repository/models.go index 1fece82..dcd1ee5 100755 --- a/db/repository/models.go +++ b/db/repository/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.25.0 +// sqlc v1.30.0 package db @@ -58,11 +58,12 @@ func (ns NullCommentType) Value() (driver.Value, error) { type LocationType string const ( - LocationTypeBeach LocationType = "beach" - LocationTypeAmusementpark LocationType = "amusement park" - LocationTypeCulinary LocationType = "culinary" - LocationTypeHikingCamping LocationType = "hiking / camping" - LocationTypeOther LocationType = "other" + LocationTypeTraditionalmarket LocationType = "traditional market" + LocationTypeMall LocationType = "mall" + LocationTypeCulinary LocationType = "culinary" + LocationTypeRecreation LocationType = "recreation" + LocationTypeAccommodation LocationType = "accommodation" + LocationTypeOther LocationType = "other" ) func (e *LocationType) Scan(src interface{}) error { @@ -168,6 +169,12 @@ type Comment struct { UpdatedAt pgtype.Timestamp `json:"updated_at"` } +type Emote struct { + ID int32 `json:"id"` + Name string `json:"name"` + EmoteID string `json:"emote_id"` +} + type Image struct { ID int32 `json:"id"` ImageUrl string `json:"image_url"` @@ -204,6 +211,15 @@ type LocationImage struct { UpdatedAt pgtype.Timestamp `json:"updated_at"` } +type LocationPageVisit struct { + ID int32 `json:"id"` + LocationID int32 `json:"location_id"` + WeekKey string `json:"week_key"` + VisitCount int64 `json:"visit_count"` + IsDeleted pgtype.Bool `json:"is_deleted"` + UpdatedAt pgtype.Timestamp `json:"updated_at"` +} + type NewsEvent struct { ID int32 `json:"id"` Title string `json:"title"` @@ -243,6 +259,7 @@ type Region struct { type Review struct { ID int32 `json:"id"` + Title pgtype.Text `json:"title"` SubmittedBy int32 `json:"submitted_by"` Comments string `json:"comments"` Score int16 `json:"score"` diff --git a/db/repository/news_events.sql.go b/db/repository/news_events.sql.go index 499319b..b131c1c 100755 --- a/db/repository/news_events.sql.go +++ b/db/repository/news_events.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.25.0 +// sqlc v1.30.0 // source: news_events.sql package db diff --git a/db/repository/provinces.sql.go b/db/repository/provinces.sql.go index 909c352..c32c580 100755 --- a/db/repository/provinces.sql.go +++ b/db/repository/provinces.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.25.0 +// sqlc v1.30.0 // source: provinces.sql package db diff --git a/db/repository/querier.go b/db/repository/querier.go index 88fafcb..3b5ff24 100755 --- a/db/repository/querier.go +++ b/db/repository/querier.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.25.0 +// sqlc v1.30.0 package db diff --git a/db/repository/regencies.sql.go b/db/repository/regencies.sql.go index 2a50faf..2711730 100755 --- a/db/repository/regencies.sql.go +++ b/db/repository/regencies.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.25.0 +// sqlc v1.30.0 // source: regencies.sql package db diff --git a/db/repository/regions.sql.go b/db/repository/regions.sql.go index b7fe525..7addea5 100755 --- a/db/repository/regions.sql.go +++ b/db/repository/regions.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.25.0 +// sqlc v1.30.0 // source: regions.sql package db diff --git a/db/repository/reviews.go b/db/repository/reviews.go index 3ab5de8..ee2a4e1 100755 --- a/db/repository/reviews.go +++ b/db/repository/reviews.go @@ -14,18 +14,20 @@ INSERT INTO reviews ( score, is_from_critic, is_hided, - location_id -) VALUES ($1, $2, $3, $4, $5, $6) -RETURNING id, submitted_by, comments, score, is_from_critic, is_hided, location_id, created_at, updated_at + location_id, + title +) VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING id, submitted_by, comments, score, is_from_critic, is_hided, location_id, title, created_at, updated_at ` type CreateReviewParams struct { - SubmittedBy int32 `json:"submitted_by"` - Comments string `json:"comments"` - Score int16 `json:"score"` - IsFromCritic bool `json:"is_from_critic"` - IsHided bool `json:"is_hided"` - LocationID int32 `json:"location_id"` + SubmittedBy int32 `json:"submitted_by"` + Comments string `json:"comments"` + Score int16 `json:"score"` + IsFromCritic bool `json:"is_from_critic"` + IsHided bool `json:"is_hided"` + LocationID int32 `json:"location_id"` + Title pgtype.Text `json:"title"` } func (q *Queries) CreateReview(ctx context.Context, arg CreateReviewParams) (Review, error) { @@ -36,6 +38,7 @@ func (q *Queries) CreateReview(ctx context.Context, arg CreateReviewParams) (Rev arg.IsFromCritic, arg.IsHided, arg.LocationID, + arg.Title, ) var i Review err := row.Scan( @@ -46,6 +49,7 @@ func (q *Queries) CreateReview(ctx context.Context, arg CreateReviewParams) (Rev &i.IsFromCritic, &i.IsHided, &i.LocationID, + &i.Title, &i.CreatedAt, &i.UpdatedAt, ) @@ -61,6 +65,7 @@ type GetListLocationReviewsParams struct { type GetListLocationReviewsRow struct { ID int32 `json:"id"` + Title pgtype.Text `json:"title"` Score int8 `json:"score"` Comment string `json:"comments"` UserID int32 `json:"user_id"` @@ -73,6 +78,7 @@ type GetListLocationReviewsRow struct { const getListLocationReviews = ` SELECT re.id as id, + re.title as title, re.score as score, re.comments as comments, u.id as user_id, @@ -101,6 +107,7 @@ func (q *Queries) GetListLocationReviews(ctx context.Context, arg GetListLocatio var i GetListLocationReviewsRow if err := rows.Scan( &i.ID, + &i.Title, &i.Score, &i.Comment, &i.UserID, diff --git a/db/repository/reviews.sql.go b/db/repository/reviews.sql.go index 9ab7ca9..51991d3 100755 --- a/db/repository/reviews.sql.go +++ b/db/repository/reviews.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.25.0 +// sqlc v1.30.0 // source: reviews.sql package db @@ -12,8 +12,8 @@ import ( ) const checkIfReviewExists = `-- name: CheckIfReviewExists :one -SELECT COUNT(1) -FROM reviews +SELECT COUNT(1) +FROM reviews WHERE reviews.location_id = $1 AND reviews.submitted_by = $2 ` @@ -32,6 +32,8 @@ func (q *Queries) CheckIfReviewExists(ctx context.Context, arg CheckIfReviewExis const getUserReviewByLocation = `-- name: GetUserReviewByLocation :one SELECT re.id, + re.title, + re.submitted_by, re.location_id, re.score, re.comments, @@ -47,12 +49,14 @@ type GetUserReviewByLocationParams struct { } type GetUserReviewByLocationRow struct { - ID int32 `json:"id"` - LocationID int32 `json:"location_id"` - Score int16 `json:"score"` - Comments string `json:"comments"` - CreatedAt pgtype.Timestamp `json:"created_at"` - UpdatedAt pgtype.Timestamp `json:"updated_at"` + ID int32 `json:"id"` + Title pgtype.Text `json:"title"` + SubmittedBy int32 `json:"submitted_by"` + LocationID int32 `json:"location_id"` + Score int16 `json:"score"` + Comments string `json:"comments"` + CreatedAt pgtype.Timestamp `json:"created_at"` + UpdatedAt pgtype.Timestamp `json:"updated_at"` } func (q *Queries) GetUserReviewByLocation(ctx context.Context, arg GetUserReviewByLocationParams) (GetUserReviewByLocationRow, error) { @@ -60,6 +64,8 @@ func (q *Queries) GetUserReviewByLocation(ctx context.Context, arg GetUserReview var i GetUserReviewByLocationRow err := row.Scan( &i.ID, + &i.Title, + &i.SubmittedBy, &i.LocationID, &i.Score, &i.Comments, diff --git a/db/repository/sessions.sql.go b/db/repository/sessions.sql.go index da04aa5..ebaccd4 100755 --- a/db/repository/sessions.sql.go +++ b/db/repository/sessions.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.25.0 +// sqlc v1.30.0 // source: sessions.sql package db diff --git a/db/repository/store.go b/db/repository/store.go index a4bcd57..c78c52f 100755 --- a/db/repository/store.go +++ b/db/repository/store.go @@ -19,6 +19,7 @@ type Store interface { GetListLocationReviews(ctx context.Context, arg GetListLocationReviewsParams) ([]GetListLocationReviewsRow, error) CreateLocation(ctx context.Context, arg CreateLocationParams) (int32, error) CreateImage(ctx context.Context, arg []CreateImageParams) error + GetImagesByReview(ctx context.Context, reviewID int32) ([]GetImagesByReviewRow, error) CreateLocationTx(ctx context.Context, arg CreateLocationTxParams) error GetNewsEventsList(ctx context.Context, arg GetNewsEventsListParams) ([]NewsEventRow, error) GetListRecentLocationsWithRatings(ctx context.Context, arg GetListRecentLocationsParams) ([]GetListRecentLocationsWithRatingsRow, error) diff --git a/db/repository/users.sql.go b/db/repository/users.sql.go index 6455940..38d2fbb 100755 --- a/db/repository/users.sql.go +++ b/db/repository/users.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.25.0 +// sqlc v1.30.0 // source: users.sql package db diff --git a/go.mod b/go.mod index 3d1dcc2..745dda3 100755 --- a/go.mod +++ b/go.mod @@ -1,13 +1,15 @@ module git.nochill.in/nochill/hiling_go -go 1.20 +go 1.24 require ( github.com/aead/chacha20poly1305 v0.0.0-20201124145622-1a5aba2a8b29 github.com/gin-contrib/cors v1.4.0 github.com/gin-gonic/gin v1.9.1 github.com/go-playground/validator/v10 v10.17.0 + github.com/henvic/pgq v0.0.2 github.com/jackc/pgx/v5 v5.5.3 + github.com/meilisearch/meilisearch-go v0.26.2 github.com/o1egl/paseto v1.0.0 github.com/spf13/viper v1.16.0 github.com/stretchr/testify v1.8.4 @@ -20,6 +22,25 @@ require ( github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect github.com/aead/poly1305 v0.0.0-20180717145839-3fee0db0b635 // indirect github.com/andybalholm/brotli v1.0.4 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.14 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.14 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect + github.com/aws/smithy-go v1.24.2 // indirect github.com/bytedance/sonic v1.10.2 // indirect github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect github.com/chenzhuoyu/iasm v0.9.1 // indirect @@ -32,7 +53,6 @@ require ( github.com/goccy/go-json v0.10.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect - github.com/henvic/pgq v0.0.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect @@ -44,7 +64,6 @@ require ( github.com/magiconair/properties v1.8.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/meilisearch/meilisearch-go v0.26.2 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect diff --git a/go.sum b/go.sum index df7a442..f3a3899 100755 --- a/go.sum +++ b/go.sum @@ -47,6 +47,44 @@ github.com/aead/poly1305 v0.0.0-20180717145839-3fee0db0b635 h1:52m0LGchQBBVqJRyY github.com/aead/poly1305 v0.0.0-20180717145839-3fee0db0b635/go.mod h1:lmLxL+FV291OopO93Bwf9fQLQeLyt33VJRUg5VJ30us= github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= +github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= +github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI= +github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo= +github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ= +github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 h1:hlSuz394kV0vhv9drL5lhuEFbEOEP1VyQpy15qWh1Pk= +github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM= github.com/bytedance/sonic v1.10.2 h1:GQebETVBxYB7JGWJtLBi07OVzWwt+8dWA00gEVW2ZFE= @@ -77,6 +115,7 @@ github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5y github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= +github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= @@ -93,6 +132,7 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 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.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= 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= @@ -146,6 +186,7 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -200,6 +241,7 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -242,6 +284,7 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM= github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA= diff --git a/util/cloudfare/r2.go b/util/cloudfare/r2.go index 1046aea..9d19cf3 100755 --- a/util/cloudfare/r2.go +++ b/util/cloudfare/r2.go @@ -1,2 +1,57 @@ package cloudfare +import ( + "context" + "fmt" + "mime/multipart" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +type R2Client struct { + s3Client *s3.Client + bucketName string + publicBaseURL string +} + +func NewR2Client(accountID, accessKeyID, secretAccessKey, bucketName, publicApiID string) (*R2Client, error) { + r2Endpoint := fmt.Sprintf("https://%s.r2.cloudflarestorage.com", accountID) + + cfg, err := config.LoadDefaultConfig(context.Background(), + config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKeyID, secretAccessKey, "")), + config.WithRegion("auto"), + ) + if err != nil { + return nil, fmt.Errorf("failed to load R2 config: %w", err) + } + + client := s3.NewFromConfig(cfg, func(o *s3.Options) { + o.BaseEndpoint = aws.String(r2Endpoint) + }) + + return &R2Client{ + s3Client: client, + bucketName: bucketName, + publicBaseURL: fmt.Sprintf("https://pub-%s.r2.dev", publicApiID), + }, nil +} + +// UploadFile uploads a multipart file to R2 and returns the public URL. +// key is the object path inside the bucket, e.g. "reviews/18/image.png" +func (r *R2Client) UploadFile(ctx context.Context, key string, file multipart.File, contentType string) (string, error) { + _, err := r.s3Client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(r.bucketName), + Key: aws.String(key), + Body: file, + ContentType: aws.String(contentType), + }) + if err != nil { + return "", fmt.Errorf("failed to upload to R2: %w", err) + } + + publicURL := fmt.Sprintf("%s/%s", r.publicBaseURL, key) + return publicURL, nil +} diff --git a/util/config.go b/util/config.go index 62d0e9f..d3c1c54 100755 --- a/util/config.go +++ b/util/config.go @@ -17,6 +17,11 @@ type Config struct { RefreshTokenDuration time.Duration `mapstructure:"REFRESH_TOKEN_DURATION"` MeilisearchHost string `mapstructure:"MEILISEARCH_HOST"` MeilisearchKey string `mapstructure:"MEILISEARCH_KEY"` + R2AccountID string `mapstructure:"R2_ACCOUNT_ID"` + R2AccessKeyID string `mapstructure:"R2_ACCESS_KEY_ID"` + R2SecretAccessKey string `mapstructure:"R2_SECRET_ACCESS_KEY"` + R2BucketName string `mapstructure:"R2_BUCKET_NAME"` + R2PublicApiID string `mapstructure:"R2_PUBLIC_API_ID"` } func LoadConfig(path string) (config Config, err error) {