Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add endpoint for getting url for put image in s3 storage #4

Merged
merged 3 commits into from
Jul 22, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ forms:

storage:
endpoint: https://fra1.digitaloceanspaces.com
allowed_buckets:
- bucket
bucket: bucket
presigned_url_expiration: 3m

auth:
addr: http://127.0.0.1:5000
3 changes: 2 additions & 1 deletion docs/spec/components/schemas/Form.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ allOf:
type: string
example: "+13165282105"
email:
type: cabehilary88@gmail.com
type: string
example: cabehilary88@gmail.com
image:
type: string
description: |
Expand Down
23 changes: 23 additions & 0 deletions docs/spec/components/schemas/UploadImage.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
allOf:
- $ref: '#/components/schemas/UploadImageKey'
- type: object
x-go-is-request: true
required:
- attributes
properties:
attributes:
type: object
required:
- content_type
- content_length
properties:
content_type:
type: string
example: image/png
description: Allowed content-type is `image/png` or `image/jpeg`
content_length:
type: integer
format: int64
example: 150000
description: Image size. It cannot be more than 4 megabytes.

7 changes: 7 additions & 0 deletions docs/spec/components/schemas/UploadImageKey.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
type: object
required:
- type
properties:
type:
type: string
enum: [ upload_image ]
15 changes: 15 additions & 0 deletions docs/spec/components/schemas/UploadImageResponse.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
allOf:
- $ref: '#/components/schemas/UploadImageResponseKey'
- type: object
required:
- attributes
properties:
attributes:
type: object
required:
- url
properties:
url:
type: string
description: Pre-Signed URL for upload the file
Zaptoss marked this conversation as resolved.
Show resolved Hide resolved
example: https://bucket.nyc3.digitaloceanspaces.com/somefile?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=DO00PTJRCBZELX6E4EEK%2F20240722%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20240722T133921Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=940c9058b90e8836b03470fdb51af1f24baabc16a7a83b80352d3d618aa4f23f
11 changes: 11 additions & 0 deletions docs/spec/components/schemas/UploadImageResponseKey.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
type: object
required:
- id
- type
properties:
id:
type: string
description: UUID for check form submission status
Zaptoss marked this conversation as resolved.
Show resolved Hide resolved
type:
type: string
enum: [ upload_image_response ]
43 changes: 43 additions & 0 deletions docs/spec/paths/integrations@forms-svc@v1@image.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
post:
tags:
- User form
summary: Generate pre-signed url
description: |
Generate Pre-Signed URL for the provided content-length
Zaptoss marked this conversation as resolved.
Show resolved Hide resolved
and content-type, with a configurable lifetime.
The response contains a URL with a signature and
other information that should be used to upload image
in S3 Storage. The name is generated on the server side.
'verified: true' must be specified in the JWT.
operationId: uploadImage
security:
- BearerAuth: []
requestBody:
content:
application/vnd.api+json:
schema:
type: object
required:
- data
properties:
data:
$ref: '#/components/schemas/UploadImage'
responses:
200:
description: "Success"
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/UploadImageResponse'
400:
$ref: '#/components/responses/invalidParameter'
401:
$ref: '#/components/responses/invalidAuth'
429:
description: "It is necessary to wait some time before sending the next form"
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/Errors'
500:
$ref: '#/components/responses/internalError'
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ require (
github.com/go-chi/chi v4.1.2+incompatible
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
github.com/go-sql-driver/mysql v1.6.0
github.com/google/uuid v1.6.0
github.com/pkg/errors v0.9.1
github.com/rarimo/geo-auth-svc v0.1.0
github.com/rubenv/sql-migrate v1.6.1
gitlab.com/distributed_lab/ape v1.7.1
gitlab.com/distributed_lab/dig v0.0.0-20230207152643-c44f80a4294c
gitlab.com/distributed_lab/figure v2.1.2+incompatible
gitlab.com/distributed_lab/figure/v3 v3.1.4
gitlab.com/distributed_lab/kit v1.11.3
gitlab.com/distributed_lab/logan v3.8.1+incompatible
Expand Down Expand Up @@ -59,7 +62,6 @@ require (
github.com/mmcloughlin/addchain v0.4.0 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/rs/cors v1.8.3 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
Expand All @@ -77,7 +79,6 @@ require (
github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
gitlab.com/distributed_lab/figure v2.1.2+incompatible // indirect
gitlab.com/distributed_lab/lorem v0.2.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/crypto v0.24.0 // indirect
Expand Down
5 changes: 5 additions & 0 deletions internal/service/handlers/legacy_submit_form.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"strings"
"time"

"github.com/rarimo/geo-auth-svc/pkg/auth"
"github.com/rarimo/geo-forms-svc/internal/data"
"github.com/rarimo/geo-forms-svc/internal/service/requests"
"github.com/rarimo/geo-forms-svc/resources"
Expand All @@ -20,6 +21,10 @@ func LegacySubmitForm(w http.ResponseWriter, r *http.Request) {
}

nullifier := strings.ToLower(UserClaims(r)[0].Nullifier)
if !auth.Authenticates(UserClaims(r), auth.VerifiedGrant(nullifier)) {
ape.RenderErr(w, problems.Unauthorized())
return
}

formStatus, err := FormsQ(r).Last(nullifier)
if err != nil {
Expand Down
5 changes: 5 additions & 0 deletions internal/service/handlers/submit_form.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"time"

validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/rarimo/geo-auth-svc/pkg/auth"
"github.com/rarimo/geo-forms-svc/internal/data"
"github.com/rarimo/geo-forms-svc/internal/service/requests"
"github.com/rarimo/geo-forms-svc/internal/storage"
Expand All @@ -23,6 +24,10 @@ func SubmitForm(w http.ResponseWriter, r *http.Request) {
}

nullifier := strings.ToLower(UserClaims(r)[0].Nullifier)
if !auth.Authenticates(UserClaims(r), auth.VerifiedGrant(nullifier)) {
ape.RenderErr(w, problems.Unauthorized())
return
}

lastForm, err := FormsQ(r).Last(nullifier)
if err != nil {
Expand Down
62 changes: 62 additions & 0 deletions internal/service/handlers/upload_image.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package handlers

import (
"net/http"
"strings"
"time"

"github.com/rarimo/geo-auth-svc/pkg/auth"
"github.com/rarimo/geo-forms-svc/internal/service/requests"
"github.com/rarimo/geo-forms-svc/resources"
"gitlab.com/distributed_lab/ape"
"gitlab.com/distributed_lab/ape/problems"
)

func UploadImage(w http.ResponseWriter, r *http.Request) {
req, err := requests.NewUploadImage(r)
if err != nil {
ape.RenderErr(w, problems.BadRequest(err)...)
return
}

nullifier := strings.ToLower(UserClaims(r)[0].Nullifier)
if !auth.Authenticates(UserClaims(r), auth.VerifiedGrant(nullifier)) {
ape.RenderErr(w, problems.Unauthorized())
return
}

lastForm, err := FormsQ(r).Last(nullifier)
if err != nil {
Log(r).WithError(err).Errorf("Failed to get last user form for nullifier [%s]", nullifier)
ape.RenderErr(w, problems.InternalError())
return
}

if lastForm != nil {
next := lastForm.CreatedAt.Add(Forms(r).Cooldown)
if next.After(time.Now().UTC()) {
Log(r).Debugf("Form submitted time: %s; next available time: %s", lastForm.CreatedAt, next)
ape.RenderErr(w, problems.TooManyRequests())
return
}
}

signedURL, key, err := Storage(r).GeneratePUTURL(req.Data.Attributes.ContentType, req.Data.Attributes.ContentLength)
if err != nil {
Log(r).WithError(err).Error("Failed to generate pre-signed url")
ape.RenderErr(w, problems.InternalError())
return
}

ape.Render(w, resources.UploadImageResponseResponse{
Data: resources.UploadImageResponse{
Key: resources.Key{
ID: key,
Type: resources.UPLOAD_IMAGE_RESPONSE,
},
Attributes: resources.UploadImageResponseAttributes{
Url: signedURL,
},
},
})
}
4 changes: 2 additions & 2 deletions internal/service/requests/legacy_submit_form.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
)

// 4 b64 letters encode 3 bytes, max image size = 12 MB -> (12/3)*4 * (1 << 20)
const maxImageSize = (1 << 20) * 16
const maxBase64ImageSize = (1 << 20) * 16

func NewLegacySubmitForm(r *http.Request) (req resources.SubmitFormRequest, err error) {
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
Expand All @@ -35,7 +35,7 @@ func NewLegacySubmitForm(r *http.Request) (req resources.SubmitFormRequest, err
"data/attributes/postal": validation.Validate(req.Data.Attributes.Postal, validation.Required),
"data/attributes/phone": validation.Validate(req.Data.Attributes.Phone, validation.Required),
"data/attributes/email": validation.Validate(req.Data.Attributes.Email, validation.Required, validation.Match(regexp.MustCompile(`[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,64}`))),
"data/attributes/image": validation.Validate(req.Data.Attributes.Image, validation.Required, is.Base64, validation.Length(0, maxImageSize)),
"data/attributes/image": validation.Validate(req.Data.Attributes.Image, validation.Required, is.Base64, validation.Length(0, maxBase64ImageSize)),
}

return req, errs.Filter()
Expand Down
26 changes: 26 additions & 0 deletions internal/service/requests/upload_image.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package requests

import (
"encoding/json"
"net/http"

validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/rarimo/geo-forms-svc/resources"
)

const maxImageSize = 1 << 22

func NewUploadImage(r *http.Request) (req resources.UploadImageRequest, err error) {
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
err = newDecodeError("body", err)
return
}

errs := validation.Errors{
"data/type": validation.Validate(req.Data.Type, validation.Required, validation.In(resources.UPLOAD_IMAGE)),
"data/attributes/content_type": validation.Validate(req.Data.Attributes.ContentType, validation.Required, validation.In("image/png", "image/jpeg")),
"data/attributes/content_length": validation.Validate(req.Data.Attributes.ContentLength, validation.Required, validation.Length(1, int(maxImageSize))),
}

return req, errs.Filter()
}
1 change: 1 addition & 0 deletions internal/service/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ func Run(ctx context.Context, cfg config.Config) {
)
r.Route("/integrations/geo-forms-svc/v1", func(r chi.Router) {
r.Use(handlers.AuthMiddleware(cfg.Auth(), cfg.Log()))
r.Post("/image", handlers.UploadImage)
r.Route("/status", func(r chi.Router) {
r.Get("/{id}", handlers.StatusByID)
r.Get("/last", handlers.LastStatus)
Expand Down
15 changes: 11 additions & 4 deletions internal/storage/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package storage

import (
"fmt"
"time"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
Expand Down Expand Up @@ -43,8 +44,9 @@ func (c *storager) Storage() *Storage {
}

var cfg struct {
Endpoint string `fig:"endpoint,required"`
AllowedBuckets []string `fig:"allowed_buckets,required"`
Endpoint string `fig:"endpoint,required"`
Bucket string `fig:"bucket,required"`
PresignedURLExpiration *time.Duration `fig:"presigned_url_expiration"`
}

err = figure.Out(&cfg).
Expand All @@ -54,6 +56,10 @@ func (c *storager) Storage() *Storage {
panic(fmt.Errorf("failed to figure out s3 storage config: %w", err))
}

if cfg.PresignedURLExpiration == nil {
cfg.PresignedURLExpiration = &defaultPresignedURLExpiration
}

s3Config := &aws.Config{
Credentials: credentials.NewStaticCredentials(envCfg.SpacesKey, envCfg.SpacesSecret, ""),
Endpoint: aws.String(cfg.Endpoint),
Expand All @@ -69,8 +75,9 @@ func (c *storager) Storage() *Storage {
s3Client := s3.New(newSession)

return &Storage{
client: s3Client,
allowedBuckets: cfg.AllowedBuckets,
client: s3Client,
bucket: cfg.Bucket,
presignedURLExpiration: *cfg.PresignedURLExpiration,
}
}).(*Storage)
}
27 changes: 19 additions & 8 deletions internal/storage/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/google/uuid"
"github.com/pkg/errors"
)

Expand Down Expand Up @@ -42,14 +43,7 @@ func (s *Storage) ValidateImage(object *url.URL) error {
return fmt.Errorf("failed to parse url [%s]: %w", object.String(), err)
}

found := false
for _, bucket := range s.allowedBuckets {
if spacesURL.Bucket == bucket {
found = true
break
}
}
if !found {
if spacesURL.Bucket != s.bucket {
return ErrBucketNotAllowed
}

Expand All @@ -73,6 +67,23 @@ func (s *Storage) ValidateImage(object *url.URL) error {
return nil
}

func (s *Storage) GeneratePUTURL(contentType string, contentLength int64) (signedURL, key string, err error) {
Zaptoss marked this conversation as resolved.
Show resolved Hide resolved
key = uuid.New().String()
req, _ := s.client.PutObjectRequest(&s3.PutObjectInput{
Bucket: &s.bucket,
Key: &key,
ContentType: &contentType,
ContentLength: &contentLength,
})

signedURL, err = req.Presign(s.presignedURLExpiration)
if err != nil {
return "", "", fmt.Errorf("failed to sign request: %w", err)
}

return signedURL, key, nil
}

func parseDOSpacesURL(object *url.URL) (*SpacesURL, error) {
spacesURL := &SpacesURL{
URL: object,
Expand Down
Loading
Loading