Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
emmdim committed Dec 11, 2024
1 parent 449d51e commit fc14344
Show file tree
Hide file tree
Showing 7 changed files with 257 additions and 0 deletions.
11 changes: 11 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/vocdoni/saas-backend/account"
"github.com/vocdoni/saas-backend/db"
"github.com/vocdoni/saas-backend/notifications"
"github.com/vocdoni/saas-backend/objectstorage"
"github.com/vocdoni/saas-backend/stripe"
"github.com/vocdoni/saas-backend/subscriptions"
"go.vocdoni.io/dvote/apiclient"
Expand Down Expand Up @@ -40,6 +41,8 @@ type APIConfig struct {
StripeClient *stripe.StripeClient
// Subscriptions permissions manager
Subscriptions *subscriptions.Subscriptions
// Object storage
ObjectStorage *objectstorage.ObjectStorageClient
}

// API type represents the API HTTP server with JWT authentication capabilities.
Expand All @@ -57,6 +60,7 @@ type API struct {
transparentMode bool
stripe *stripe.StripeClient
subscriptions *subscriptions.Subscriptions
objectStorage *objectstorage.ObjectStorageClient
}

// New creates a new API HTTP server. It does not start the server. Use Start() for that.
Expand All @@ -77,6 +81,7 @@ func New(conf *APIConfig) *API {
transparentMode: conf.FullTransparentMode,
stripe: conf.StripeClient,
subscriptions: conf.Subscriptions,
objectStorage: conf.ObjectStorage,
}
}

Expand Down Expand Up @@ -153,6 +158,12 @@ func (a *API) initRouter() http.Handler {
// pending organization invitations
log.Infow("new route", "method", "GET", "path", organizationPendingMembersEndpoint)
r.Get(organizationPendingMembersEndpoint, a.pendingOrganizationMembersHandler)
// upload a file to the object storage
log.Infow("new route", "method", "POST", "path", objectStorageUploadEndpoint)
r.Post(objectStorageUploadEndpoint, a.uploadObjectStorageHandler)
// download a file from the object storage
// log.Infow("new route", "method", "GET", "path", objectStorageDownloadEndpoint)
// r.Get(objectStorageDownloadEndpoint, a.downloadObjectStorageHandler)
})

// Public routes
Expand Down
64 changes: 64 additions & 0 deletions api/object_storage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package api

import (
"io"
"net/http"
)

func (a *API) uploadObjectStorageHandler(w http.ResponseWriter, r *http.Request) {
// 32 MB is the default used by FormFile() function
if err := r.ParseMultipartForm(32 << 20); err != nil {
ErrGenericInternalServerError.With("could not parse form").Write(w)
return
}

// Get a reference to the fileHeaders.
// They are accessible only after ParseMultipartForm is called
files := r.MultipartForm.File["file"]
for _, fileHeader := range files {
// Open the file
file, err := fileHeader.Open()
if err != nil {
ErrGenericInternalServerError.Withf("cannot open file %s", err.Error()).Write(w)
break
}
defer func() {
if err := file.Close(); err != nil {
ErrGenericInternalServerError.Withf("cannot close file %s", err.Error()).Write(w)
return
}
}()
buff := make([]byte, 512)
_, err = file.Read(buff)
if err != nil {
ErrGenericInternalServerError.Withf("cannot read file %s", err.Error()).Write(w)
break
}
// checking the content type
// so we don't allow files other than images
filetype := http.DetectContentType(buff)
if filetype != "image/jpeg" && filetype != "image/png" && filetype != "image/jpg" {
ErrGenericInternalServerError.With("The provided file format is not allowed. Please upload a JPEG,JPG or PNG image").Write(w)
break
}
_, err = file.Seek(0, io.SeekStart)
if err != nil {
ErrGenericInternalServerError.Withf("%s", err.Error()).Write(w)
break
}
fileBytes, err := io.ReadAll(file)
if err != nil {
ErrGenericInternalServerError.Withf("cannot read file %s", err.Error()).Write(w)
break
}
// upload the file using the object storage client
// and get the URL of the uploaded file
url, err := a.objectStorage.Upload(fileHeader.Filename, fileBytes)
if err != nil {
ErrGenericInternalServerError.Withf("cannot upload file %s", err.Error()).Write(w)
break
}
httpWriteJSON(w, map[string]string{"url": url})
}

}
6 changes: 6 additions & 0 deletions api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,10 @@ const (
planInfoEndpoint = "/plans/{planID}"
// POST /subscriptions/webhook to receive the subscription webhook from stripe
subscriptionsWebhook = "/subscriptions/webhook"

// object storage routes
// POST /storage to upload a file
objectStorageUploadEndpoint = "/storage"
// GET /storage/{key} to download a file
objectStorageDownloadEndpoint = "/storage/{key}"

Check failure on line 74 in api/routes.go

View workflow job for this annotation

GitHub Actions / lint

const `objectStorageDownloadEndpoint` is unused (unused)

Check failure on line 74 in api/routes.go

View workflow job for this annotation

GitHub Actions / lint

const `objectStorageDownloadEndpoint` is unused (unused)
)
16 changes: 16 additions & 0 deletions cmd/service/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/vocdoni/saas-backend/db"
"github.com/vocdoni/saas-backend/notifications/mailtemplates"
"github.com/vocdoni/saas-backend/notifications/smtp"
"github.com/vocdoni/saas-backend/objectstorage"
"github.com/vocdoni/saas-backend/stripe"
"github.com/vocdoni/saas-backend/subscriptions"
"go.vocdoni.io/dvote/apiclient"
Expand All @@ -37,6 +38,11 @@ func main() {
flag.String("emailFromName", "Vocdoni", "Email service from name")
flag.String("stripeApiSecret", "", "Stripe API secret")
flag.String("stripeWebhookSecret", "", "Stripe Webhook secret")
flag.String("storageApiKey", "", "Object storage API key")
flag.String("storageApiSecret", "", "Object storage API secret")
flag.String("storageApiEndpoint", "", "Object storage API endpoint")
flag.String("storageApiRegion", "", "Object storage API region name")
flag.String("storageApiBucket", "", "Object storage API bucket name")
// parse flags
flag.Parse()
// initialize Viper
Expand Down Expand Up @@ -67,6 +73,12 @@ func main() {
// stripe vars
stripeApiSecret := viper.GetString("stripeApiSecret")
stripeWebhookSecret := viper.GetString("stripeWebhookSecret")
// object storage vars
storageApiKey := viper.GetString("storageApiKey")
storageApiSecret := viper.GetString("storageApiSecret")
storageApiEndpoint := viper.GetString("storageApiEndpoint")
storageApiRegion := viper.GetString("storageApiRegion")
storageApiBucket := viper.GetString("storageApiBucket")

log.Init("debug", "stdout", os.Stderr)
// create Stripe client and include it in the API configuration
Expand Down Expand Up @@ -144,6 +156,10 @@ func main() {
DB: database,
})
apiConf.Subscriptions = subscriptions
// initialize the s3 like object storage
if apiConf.ObjectStorage, err = objectstorage.New(storageApiKey, storageApiSecret, storageApiEndpoint, storageApiRegion, storageApiBucket); err != nil {
log.Fatalf("could not create the object storage: %v", err)
}
// create the local API server
api.New(apiConf).Start()
log.Infow("server started", "host", host, "port", port)
Expand Down
19 changes: 19 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ module github.com/vocdoni/saas-backend
go 1.23.3

require (
github.com/aws/aws-sdk-go-v2 v1.32.6
github.com/aws/aws-sdk-go-v2/config v1.28.6
github.com/aws/aws-sdk-go-v2/credentials v1.17.47
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.43
github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0
github.com/docker/go-connections v0.5.0
github.com/ethereum/go-ethereum v1.14.3
github.com/frankban/quicktest v1.14.6
Expand Down Expand Up @@ -36,6 +41,20 @@ require (
github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9 // indirect
github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5 // indirect
github.com/arnaucube/go-blindsecp256k1 v0.0.0-20211204171003-644e7408753f // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.24.7 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.2 // indirect
github.com/aws/smithy-go v1.22.1 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.10.0 // indirect
Expand Down
38 changes: 38 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -134,14 +134,52 @@ github.com/aws/aws-sdk-go v1.25.48/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpi
github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
github.com/aws/aws-sdk-go-v2 v1.2.0/go.mod h1:zEQs02YRBw1DjK0PoJv3ygDYOFTre1ejlJWl8FwAuQo=
github.com/aws/aws-sdk-go-v2 v1.32.6 h1:7BokKRgRPuGmKkFMhEg/jSul+tB9VvXhcViILtfG8b4=
github.com/aws/aws-sdk-go-v2 v1.32.6/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 h1:lL7IfaFzngfx0ZwUGOZdsFFnQ5uLvR0hWqqhyE7Q9M8=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7/go.mod h1:QraP0UcVlQJsmHfioCrveWOC1nbiWUl3ej08h4mXWoc=
github.com/aws/aws-sdk-go-v2/config v1.1.1/go.mod h1:0XsVy9lBI/BCXm+2Tuvt39YmdHwS5unDQmxZOYe8F5Y=
github.com/aws/aws-sdk-go-v2/config v1.28.6 h1:D89IKtGrs/I3QXOLNTH93NJYtDhm8SYa9Q5CsPShmyo=
github.com/aws/aws-sdk-go-v2/config v1.28.6/go.mod h1:GDzxJ5wyyFSCoLkS+UhGB0dArhb9mI+Co4dHtoTxbko=
github.com/aws/aws-sdk-go-v2/credentials v1.1.1/go.mod h1:mM2iIjwl7LULWtS6JCACyInboHirisUUdkBPoTHMOUo=
github.com/aws/aws-sdk-go-v2/credentials v1.17.47 h1:48bA+3/fCdi2yAwVt+3COvmatZ6jUDNkDTIsqDiMUdw=
github.com/aws/aws-sdk-go-v2/credentials v1.17.47/go.mod h1:+KdckOejLW3Ks3b0E3b5rHsr2f9yuORBum0WPnE5o5w=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.0.2/go.mod h1:3hGg3PpiEjHnrkrlasTfxFqUsZ2GCk/fMUn4CbKgSkM=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21 h1:AmoU1pziydclFT/xRV+xXE/Vb8fttJCLRPv8oAkprc0=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21/go.mod h1:AjUdLYe4Tgs6kpH4Bv7uMZo7pottoyHMn4eTcIcneaY=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.43 h1:iLdpkYZ4cXIQMO7ud+cqMWR1xK5ESbt1rvN77tRi1BY=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.43/go.mod h1:OgbsKPAswXDd5kxnR4vZov69p3oYjbvUyIRBAAV0y9o=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25 h1:s/fF4+yDQDoElYhfIVvSNyeCydfbuTKzhxSXDXCPasU=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25/go.mod h1:IgPfDv5jqFIzQSNbUEMoitNooSMXjRSDkhXv8jiROvU=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25 h1:ZntTCl5EsYnhN/IygQEUugpdwbhdkom9uHcbCftiGgA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25/go.mod h1:DBdPrgeocww+CSl1C8cEV8PN1mHMBhuCDLpXezyvWkE=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25 h1:r67ps7oHCYnflpgDy2LZU0MAQtQbYIOqNNnqGO6xQkE=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25/go.mod h1:GrGY+Q4fIokYLtjCVB/aFfCVL6hhGUFl8inD18fDalE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6 h1:HCpPsWqmYQieU7SS6E9HXfdAMSud0pteVXieJmcpIRI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6/go.mod h1:ngUiVRCco++u+soRRVBIvBZxSMMvOVMXA4PJ36JLfSw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.0.2/go.mod h1:45MfaXZ0cNbeuT0KQ1XJylq8A6+OpVV2E5kvY/Kq+u8=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6 h1:50+XsN70RS7dwJ2CkVNXzj7U2L1HKP8nqTd3XWEXBN4=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6/go.mod h1:WqgLmwY7so32kG01zD8CPTJWVWM+TzJoOVHwTg4aPug=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6 h1:BbGDtTi0T1DYlmjBiCr/le3wzhA37O8QTC5/Ab8+EXk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6/go.mod h1:hLMJt7Q8ePgViKupeymbqI0la+t9/iYFBjxQCFwuAwI=
github.com/aws/aws-sdk-go-v2/service/route53 v1.1.1/go.mod h1:rLiOUrPLW/Er5kRcQ7NkwbjlijluLsrIbu/iyl35RO4=
github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0 h1:nyuzXooUNJexRT0Oy0UQY6AhOzxPxhtt4DcBIHyCnmw=
github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0/go.mod h1:sT/iQz8JK3u/5gZkT+Hmr7GzVZehUMkRZpOaAwYXeGY=
github.com/aws/aws-sdk-go-v2/service/sso v1.1.1/go.mod h1:SuZJxklHxLAXgLTc1iFXbEWkXs7QRTQpCLGaKIprQW0=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.7 h1:rLnYAfXQ3YAccocshIH5mzNNwZBkBo+bP6EhIxak6Hw=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.7/go.mod h1:ZHtuQJ6t9A/+YDuxOLnbryAmITtr8UysSny3qcyvJTc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6 h1:JnhTZR3PiYDNKlXy50/pNeix9aGMo6lLpXwJ1mw8MD4=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6/go.mod h1:URronUEGfXZN1VpdktPSD1EkAL9mfrV+2F4sjH38qOY=
github.com/aws/aws-sdk-go-v2/service/sts v1.1.1/go.mod h1:Wi0EBZwiz/K44YliU0EKxqTCJGUfYTWXrrBwkq736bM=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.2 h1:s4074ZO1Hk8qv65GqNXqDjmkf4HSQqJukaLuuW0TpDA=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.2/go.mod h1:mVggCnIWoM09jP71Wh+ea7+5gAp53q+49wDFs1SW5z8=
github.com/aws/smithy-go v1.1.0/go.mod h1:EzMw8dbp/YJL4A5/sbhGddag+NPT7q084agLbB9LgIw=
github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro=
github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
Expand Down
103 changes: 103 additions & 0 deletions objectstorage/objectstorage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package objectstorage

import (
"bytes"
"context"
"fmt"
"time"

"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/feature/s3/manager"
"github.com/aws/aws-sdk-go-v2/service/s3"
)

type ObjectStorageClient struct {
config aws.Config
client *s3.Client
defaultBucket string
}

func New(apiKey, apiSecret, apiEndpoint, apiRegion, apiBucket string) (*ObjectStorageClient, error) {
cfg, err := config.LoadDefaultConfig(context.Background(),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(apiKey, apiSecret, "")),
)
if err != nil {
return nil, fmt.Errorf("error seting up s3 session %v", err)
}

// s3Config := &aws.Config{
// Credentials: aws.NewStaticCredentials(apiKey, apiSecret, ""), // Specifies your credentials.
// Endpoint: aws.String(apiEndpoint), // Find your endpoint in the control panel, under Settings. Prepend "https://".
// S3ForcePathStyle: aws.Bool(false), // // Configures to use subdomain/virtual calling format. Depending on your version, alternatively use o.UsePathStyle = false
// Region: aws.String(apiRegion), // Must be "us-east-1" when creating new Spaces. Otherwise, use the region in your endpoint, such as "nyc3".
// }
client := s3.NewFromConfig(cfg)
return &ObjectStorageClient{
config: cfg,
client: client,
}, nil
}

// key is set in a string and can have a directory like notation (for example "folder-path/hello-world.txt")
func (osc *ObjectStorageClient) Upload(key string, payload []byte) (string, error) {
// The session the S3 Uploader will use
// Create an uploader with the session and default options
uploader := manager.NewUploader(osc.client)
// Upload the file to S3.
ctx, cancel := context.WithTimeout(context.Background(), time.Second*40)
defer cancel()
out, err := uploader.Upload(ctx, &s3.PutObjectInput{
Bucket: aws.String(osc.defaultBucket), // The path to the directory you want to upload the object to, starting with your Space name. Bucket: aws.String(myBucket),
Key: aws.String(key), // Object key, referenced whenever you want to access this file later. Key: aws.String(myString),
Body: bytes.NewReader(payload), // The object's contents.
// ACL: s3.ObjectCannedACLPrivate, // Defines Access-control List (ACL) permissions, such as private or public.
// Metadata: map[string]*string{ // Required. Defines metadata tags.
// "x-amz-meta-my-key": aws.String("your-value"),
// },
})
if err != nil {
return "", fmt.Errorf("failed to upload file, %v", err)
}
// object := s3.PutObjectInput{
// Bucket: aws.String(osc.config.apiBucket), // The path to the directory you want to upload the object to, starting with your Space name.
// Key: aws.String("folder-path/hello-world.txt"), // Object key, referenced whenever you want to access this file later.
// }

// _, err := osc.client.PutObject(&object)
// if err != nil {
// return err
// }
return out.Location, nil
}

// key is set in a string and can have a directory like notation (for example "folder-path/hello-world.txt")
func (osc *ObjectStorageClient) Get(key string) ([]byte, error) {
// The session the S3 Downloader will use
// sess := session.Must(session.NewSession(os.config))

// Create a downloader with the session and default options
downloader := manager.NewDownloader(osc.client)

// Create a file to write the S3 Object contents to.
// downloadFile, err := os.Create("downloaded-file")
// if err != nil {
// return nil, fmt.Errorf("failed to create file %v", err)
// }
// defer downloadFile.Close()

downloadFile := manager.NewWriteAtBuffer([]byte{})
ctx, cancel := context.WithTimeout(context.Background(), time.Second*40)
defer cancel()
// Write the contents of S3 Object to the file, returns the number of bytes
numBytes, err := downloader.Download(ctx, downloadFile, &s3.GetObjectInput{
Bucket: aws.String(osc.defaultBucket),
Key: aws.String(key),
})
if err != nil {
return nil, fmt.Errorf("failed to download file, %v", err)
}
fmt.Printf("file downloaded, %d bytes\n", numBytes)
return downloadFile.Bytes(), nil
}

0 comments on commit fc14344

Please sign in to comment.