Skip to content

Commit

Permalink
Implements image storage
Browse files Browse the repository at this point in the history
- Add image upload/download endpoints with authentication
- Create object storage client for file handling
- Integrate storage functionality with user system
- Update API config and database schema
- Add environment variables for storage configuration
  • Loading branch information
emmdim committed Jan 2, 2025
1 parent 20fc22b commit 75c5215
Show file tree
Hide file tree
Showing 14 changed files with 409 additions and 5 deletions.
14 changes: 14 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 All @@ -33,13 +34,16 @@ type APIConfig struct {
Account *account.Account
MailService notifications.NotificationService
WebAppURL string
ServerURL string
// FullTransparentMode if true allows signing all transactions and does not
// modify any of them.
FullTransparentMode bool
// Stripe secrets
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 @@ -54,9 +58,11 @@ type API struct {
mail notifications.NotificationService
secret string
webAppURL string
serverURL string
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 @@ -74,9 +80,11 @@ func New(conf *APIConfig) *API {
mail: conf.MailService,
secret: conf.Secret,
webAppURL: conf.WebAppURL,
serverURL: conf.ServerURL,
transparentMode: conf.FullTransparentMode,
stripe: conf.StripeClient,
subscriptions: conf.Subscriptions,
objectStorage: conf.ObjectStorage,
}
}

Expand Down Expand Up @@ -162,6 +170,9 @@ func (a *API) initRouter() http.Handler {
// get stripe subscription portal session info
log.Infow("new route", "method", "GET", "path", subscriptionsPortal)
r.Get(subscriptionsPortal, a.createSubscriptionPortalSessionHandler)
// upload an image to the object storage
log.Infow("new route", "method", "POST", "path", objectStorageUploadTypedEndpoint)
r.Post(objectStorageUploadTypedEndpoint, a.uploadImageWithFormHandler)
})

// Public routes
Expand Down Expand Up @@ -213,6 +224,9 @@ func (a *API) initRouter() http.Handler {
// handle stripe webhook
log.Infow("new route", "method", "POST", "path", subscriptionsWebhook)
r.Post(subscriptionsWebhook, a.handleWebhook)
// upload an image to the object storage
log.Infow("new route", "method", "GET", "path", objectStorageDownloadTypedEndpoint)
r.Get(objectStorageDownloadTypedEndpoint, a.downloadImageInlineHandler)
})
a.router = r
return r
Expand Down
55 changes: 54 additions & 1 deletion api/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@
- [🛒 Create Checkout session](#-create-checkout-session)
- [🛍️ Get Checkout session info](#-get-checkout-session-info)
- [🔗 Create Subscription Portal Session](#-create-subscription-portal-session)
- [📦 Storage](#-storage)
- [ 🌄 Upload image](#-upload-image)
- [ 📄 Get object](#-get-object)


</details>

Expand Down Expand Up @@ -994,4 +998,53 @@ This request can be made only by organization admins.
|:---:|:---:|:---|
| `401` | `40001` | `user not authorized` |
| `400` | `40011` | `no organization provided` |
| `500` | `50002` | `internal server error` |
| `500` | `50002` | `internal server error` |

## 📦 Storage

### 🌄 Upload image

* **Path** `/storage`
* **Method** `POST`

Accepting files uploaded by forms as such:
```html
<form action="http://localhost:8000" method="post" enctype="multipart/form-data">
<p><input type="text" name="text" value="text default">
<p><input type="file" name="file1">
<p><input type="file" name="file2">
<p><button type="submit">Submit</button>
</form>
```

* **Response**

```json
{
"urls": ["https://file1.store.com","https://file1.store.com"]
}
```

* **Errors**

| HTTP Status | Error code | Message |
|:---:|:---:|:---|
| `401` | `40001` | `user not authorized` |
| `400` | `40024` | `the obejct/parameters provided are invalid` |
| `500` | `50002` | `internal server error` |
| `500` | `50006` | `internal storage error` |


### 📄 Get object
This method return if exists, in inline mode. the image/file of the provided by the obectID

* **Path** `/storage/{objectID}`
* **Method** `GET`

* **Errors**

| HTTP Status | Error code | Message |
|:---:|:---:|:---|
| `400` | `40024` | `the obejct/parameters provided are invalid` |
| `500` | `50002` | `internal server error` |
| `500` | `50006` | `internal storage error` |
2 changes: 2 additions & 0 deletions api/errors_definition.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,12 @@ var (
ErrOganizationSubscriptionIncative = Error{Code: 40021, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("organization subscription not active")}
ErrNoDefaultPLan = Error{Code: 40022, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("did not found default plan for organization")}
ErPlanNotFound = Error{Code: 40023, HTTPstatus: http.StatusNotFound, Err: fmt.Errorf("plan not found")}
ErrStorageInvalidObject = Error{Code: 40024, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("the obejct/parameters provided are invalid")}

ErrMarshalingServerJSONFailed = Error{Code: 50001, HTTPstatus: http.StatusInternalServerError, Err: fmt.Errorf("marshaling (server-side) JSON failed")}
ErrGenericInternalServerError = Error{Code: 50002, HTTPstatus: http.StatusInternalServerError, Err: fmt.Errorf("internal server error")}
ErrCouldNotCreateFaucetPackage = Error{Code: 50003, HTTPstatus: http.StatusInternalServerError, Err: fmt.Errorf("could not create faucet package")}
ErrVochainRequestFailed = Error{Code: 50004, HTTPstatus: http.StatusInternalServerError, Err: fmt.Errorf("vochain request failed")}
ErrStripeError = Error{Code: 50005, HTTPstatus: http.StatusInternalServerError, Err: fmt.Errorf("stripe error")}
ErrInternalStorageError = Error{Code: 50006, HTTPstatus: http.StatusInternalServerError, Err: fmt.Errorf("internal storage error")}
)
104 changes: 104 additions & 0 deletions api/object_storage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package api

import (
"fmt"
"net/http"
"regexp"

"github.com/go-chi/chi/v5"
)

// isObjectNameRgx is a regular expression to match object names.
var isObjectNameRgx = regexp.MustCompile(`^([a-zA-Z0-9]+)\.(jpg|jpeg|png)`)

// uploadImageWithFormHandler handles the uploading of images through a multipart form.
// It expects the request to contain a "file" field with one or more files to be uploaded.
func (a *API) uploadImageWithFormHandler(w http.ResponseWriter, r *http.Request) {
// check if the user is authenticated
// get the user from the request context
user, ok := userFromContext(r.Context())
if !ok {
ErrUnauthorized.Write(w)
return
}

// 32 MB is the default used by FormFile() function
if err := r.ParseMultipartForm(32 << 20); err != nil {
ErrStorageInvalidObject.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"]
var returnURLs []string
for _, fileHeader := range files {
// Open the file
file, err := fileHeader.Open()
if err != nil {
ErrStorageInvalidObject.Withf("cannot open file %s", err.Error()).Write(w)
break
}
defer func() {
if err := file.Close(); err != nil {
ErrStorageInvalidObject.Withf("cannot close file %s", err.Error()).Write(w)
return
}
}()
// upload the file using the object storage client
// and get the URL of the uploaded file
storedFileID, err := a.objectStorage.Put(file, fileHeader.Size, user.Email)
if err != nil {
ErrInternalStorageError.With(err.Error()).Write(w)
break
}
returnURLs = append(returnURLs, objectURL(a.serverURL, storedFileID))
}
httpWriteJSON(w, map[string][]string{"urls": returnURLs})
}

// downloadImageInlineHandler handles the HTTP request to download an image inline.
// It retrieves the object ID from the URL parameters, fetches the object from the
// object storage, and writes the object data to the HTTP response with appropriate
// headers for inline display.
func (a *API) downloadImageInlineHandler(w http.ResponseWriter, r *http.Request) {
objectName := chi.URLParam(r, "objectName")
if objectName == "" {
ErrMalformedURLParam.With("objectName is required").Write(w)
return
}
objectID, ok := objectIDfromName(objectName)
if !ok {
ErrStorageInvalidObject.With("invalid objectName").Write(w)
return
}
// get the object from the object storage client
object, err := a.objectStorage.Get(objectID)
if err != nil {
ErrStorageInvalidObject.Withf("cannot get object %s", err.Error()).Write(w)
return
}
// write the object to the response
w.Header().Set("Content-Type", object.ContentType)
// w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
w.Header().Set("Content-Disposition", "inline")
if _, err := w.Write(object.Data); err != nil {
ErrInternalStorageError.Withf("cannot write object %s", err.Error()).Write(w)
return
}
}

// objectURL returns the URL for the object with the given objectID.
func objectURL(baseURL, objectID string) string {
return fmt.Sprintf("%s/storage/%s", baseURL, objectID)
}

// objectIDfromURL returns the objectID from the given URL. If the URL is not an
// object URL, it returns an empty string and false.
func objectIDfromName(url string) (string, bool) {
objectID := isObjectNameRgx.FindStringSubmatch(url)
if len(objectID) != 3 {
return "", false
}
return objectID[1], true
}
4 changes: 3 additions & 1 deletion api/plans.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package api
import (
"net/http"
"strconv"

"github.com/go-chi/chi/v5"
)

// getSubscriptionsHandler handles the request to get the subscriptions of an organization.
Expand All @@ -20,7 +22,7 @@ func (a *API) getPlansHandler(w http.ResponseWriter, r *http.Request) {

func (a *API) planInfoHandler(w http.ResponseWriter, r *http.Request) {
// get the plan ID from the URL
planID := r.URL.Query().Get("planID")
planID := chi.URLParam(r, "planID")
// check the the planID is not empty
if planID == "" {
ErrMalformedURLParam.Withf("planID is required").Write(w)
Expand Down
5 changes: 5 additions & 0 deletions api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,9 @@ const (
subscriptionsCheckoutSession = "/subscriptions/checkout/{sessionID}"
// GET /subscriptions/portal to get the stripe subscription portal URL
subscriptionsPortal = "/subscriptions/{address}/portal"
// object storage routes
// POST /storage/{origin} to upload an image to the object storage
objectStorageUploadTypedEndpoint = "/storage"
// GET /storage/{origin}/{filename} to download an image from the object storage
objectStorageDownloadTypedEndpoint = "/storage/{objectName}"
)
3 changes: 2 additions & 1 deletion api/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http"
"time"

"github.com/go-chi/chi/v5"
"github.com/vocdoni/saas-backend/db"
"github.com/vocdoni/saas-backend/internal"
"github.com/vocdoni/saas-backend/notifications/mailtemplates"
Expand Down Expand Up @@ -174,7 +175,7 @@ func (a *API) verifyUserAccountHandler(w http.ResponseWriter, r *http.Request) {
// returned.
func (a *API) userVerificationCodeInfoHandler(w http.ResponseWriter, r *http.Request) {
// get the user email of the user from the request query
userEmail := r.URL.Query().Get("email")
userEmail := chi.URLParam(r, "email")
// check the email is not empty
if userEmail == "" {
ErrInvalidUserData.With("no email provided").Write(w)
Expand Down
8 changes: 8 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 @@ -20,6 +21,7 @@ import (

func main() {
// define flags
flag.String("server", "http://localhost:8080", "The full URL of the server (http or https)")
flag.StringP("host", "h", "0.0.0.0", "listen address")
flag.IntP("port", "p", 8080, "listen port")
flag.StringP("secret", "s", "", "API secret")
Expand All @@ -46,6 +48,7 @@ func main() {
}
viper.AutomaticEnv()
// read the configuration
server := viper.GetString("server")
host := viper.GetString("host")
port := viper.GetInt("port")
apiEndpoint := viper.GetString("vocdoniApi")
Expand Down Expand Up @@ -113,6 +116,7 @@ func main() {
Client: apiClient,
Account: acc,
WebAppURL: webURL,
ServerURL: server,
FullTransparentMode: fullTransparentMode,
StripeClient: stripeClient,
}
Expand Down Expand Up @@ -144,6 +148,10 @@ func main() {
DB: database,
})
apiConf.Subscriptions = subscriptions
// initialize the s3 like object storage
apiConf.ObjectStorage = objectstorage.New(&objectstorage.ObjectStorageConfig{
DB: database,
})
// create the local API server
api.New(apiConf).Start()
log.Infow("server started", "host", host, "port", port)
Expand Down
4 changes: 4 additions & 0 deletions db/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ func (ms *MongoStorage) initCollections(database string) error {
if ms.plans, err = getCollection("plans"); err != nil {
return err
}
// objects collection
if ms.objects, err = getCollection("objects"); err != nil {
return err
}
return nil
}

Expand Down
5 changes: 5 additions & 0 deletions db/mongo.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type MongoStorage struct {
organizations *mongo.Collection
organizationInvites *mongo.Collection
plans *mongo.Collection
objects *mongo.Collection
}

type Options struct {
Expand Down Expand Up @@ -119,6 +120,10 @@ func (ms *MongoStorage) Reset() error {
if err := ms.plans.Drop(ctx); err != nil {
return err
}
// drop the objects collection
if err := ms.objects.Drop(ctx); err != nil {
return err
}
// init the collections
if err := ms.initCollections(ms.database); err != nil {
return err
Expand Down
Loading

0 comments on commit 75c5215

Please sign in to comment.