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

feat: pre-signed URL for S3 storage #2855

Merged
merged 1 commit into from
Jan 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions api/v1/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,7 @@ func SaveResourceBlob(ctx context.Context, s *store.Store, create *store.Resourc
Bucket: s3Config.Bucket,
URLPrefix: s3Config.URLPrefix,
URLSuffix: s3Config.URLSuffix,
PreSign: s3Config.PreSign,
})
if err != nil {
return errors.Wrap(err, "Failed to create s3 client")
Expand Down
1 change: 1 addition & 0 deletions api/v1/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ type StorageS3Config struct {
Bucket string `json:"bucket"`
URLPrefix string `json:"urlPrefix"`
URLSuffix string `json:"urlSuffix"`
PreSign bool `json:"presign"`
}

type Storage struct {
Expand Down
4 changes: 4 additions & 0 deletions api/v1/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,8 @@ definitions:
type: string
urlSuffix:
type: string
presign:
type: boolean
type: object
api_v1.StorageType:
enum:
Expand Down Expand Up @@ -668,6 +670,8 @@ definitions:
type: string
urlSuffix:
type: string
presign:
type: boolean
type: object
github_com_usememos_memos_api_v1.StorageType:
enum:
Expand Down
5 changes: 5 additions & 0 deletions bin/memos/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
"github.com/spf13/viper"
"go.uber.org/zap"

"github.com/usememos/memos/internal/jobs"

"github.com/usememos/memos/internal/log"
"github.com/usememos/memos/server"
_profile "github.com/usememos/memos/server/profile"
Expand Down Expand Up @@ -91,6 +93,9 @@ var (

printGreetings()

// update (pre-sign) object storage links if applicable
go jobs.RunPreSignLinks(ctx, storeInstance)

if err := s.Start(ctx); err != nil {
if err != http.ErrServerClosed {
log.Error("failed to start server", zap.Error(err))
Expand Down
23 changes: 13 additions & 10 deletions docs/api/v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -1255,6 +1255,7 @@ Get GetImage from URL
| secretKey | string | | No |
| urlPrefix | string | | No |
| urlSuffix | string | | No |
| presign | boolean | | No |

#### api_v1.StorageType

Expand Down Expand Up @@ -1540,16 +1541,18 @@ Get GetImage from URL

#### github_com_usememos_memos_api_v1.StorageS3Config

| Name | Type | Description | Required |
| --------- | ------ | ----------- | -------- |
| accessKey | string | | No |
| bucket | string | | No |
| endPoint | string | | No |
| path | string | | No |
| region | string | | No |
| secretKey | string | | No |
| urlPrefix | string | | No |
| urlSuffix | string | | No |
| Name | Type | Description | Required |
|-----------|---------| ----------- | -------- |
| accessKey | string | | No |
| bucket | string | | No |
| endPoint | string | | No |
| path | string | | No |
| region | string | | No |
| secretKey | string | | No |
| urlPrefix | string | | No |
| urlSuffix | string | | No |
| presign | boolean | | No |


#### github_com_usememos_memos_api_v1.StorageType

Expand Down
140 changes: 140 additions & 0 deletions internal/jobs/presign_link.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package jobs

import (
"context"
"encoding/json"
"strings"
"time"

"github.com/pkg/errors"
"go.uber.org/zap"

apiv1 "github.com/usememos/memos/api/v1"
"github.com/usememos/memos/internal/log"
"github.com/usememos/memos/plugin/storage/s3"
"github.com/usememos/memos/store"
)

// RunPreSignLinks is a background job that pre-signs external links stored in the database.
// It uses S3 client to generate presigned URLs and updates the corresponding resources in the store.
func RunPreSignLinks(ctx context.Context, dataStore *store.Store) {
for {
started := time.Now()
if err := signExternalLinks(ctx, dataStore); err != nil {
log.Warn("failed sign external links", zap.Error(err))
} else {
log.Info("links pre-signed", zap.Duration("duration", time.Since(started)))
}
select {
case <-time.After(s3.LinkLifetime / 2):
case <-ctx.Done():
return
}
}
}

func signExternalLinks(ctx context.Context, dataStore *store.Store) error {
const pageSize = 32

objectStore, err := findObjectStorage(ctx, dataStore)
if err != nil {
return errors.Wrapf(err, "find object storage")
}
if objectStore == nil || !objectStore.Config.PreSign {
// object storage not set or not supported
return nil
}

var offset int
var limit = pageSize
for {
resources, err := dataStore.ListResources(ctx, &store.FindResource{
GetBlob: false,
Limit: &limit,
Offset: &offset,
})
if err != nil {
return errors.Wrapf(err, "list resources, offset %d", offset)
}

for _, res := range resources {
if res.ExternalLink == "" {
// not for object store
continue
}
if strings.Contains(res.ExternalLink, "?") && time.Since(time.Unix(res.UpdatedTs, 0)) < s3.LinkLifetime/2 {
// resource not signed (hack for migration)
// resource was recently updated - skipping
continue
}
newLink, err := objectStore.PreSignLink(ctx, res.ExternalLink)
if err != nil {
log.Warn("failed pre-sign link", zap.Int32("resource", res.ID), zap.String("link", res.ExternalLink), zap.Error(err))
continue // do not fail - we may want update left over links too
}
now := time.Now().Unix()
// we may want to use here transaction and batch update in the future
_, err = dataStore.UpdateResource(ctx, &store.UpdateResource{
ID: res.ID,
UpdatedTs: &now,
ExternalLink: &newLink,
})
if err != nil {
// something with DB - better to stop here
return errors.Wrapf(err, "update resource %d link to %q", res.ID, newLink)
}
}

offset += limit
if len(resources) < limit {
break
}
}
return nil
}

// findObjectStorage returns current default storage if it's S3-compatible or nil otherwise.
// Returns error only in case of internal problems (ie: database or configuration issues).
// May return nil client and nil error.
func findObjectStorage(ctx context.Context, dataStore *store.Store) (*s3.Client, error) {
systemSettingStorageServiceID, err := dataStore.GetSystemSetting(ctx, &store.FindSystemSetting{Name: apiv1.SystemSettingStorageServiceIDName.String()})
if err != nil {
return nil, errors.Wrap(err, "Failed to find SystemSettingStorageServiceIDName")
}

storageServiceID := apiv1.DefaultStorage
if systemSettingStorageServiceID != nil {
err = json.Unmarshal([]byte(systemSettingStorageServiceID.Value), &storageServiceID)
if err != nil {
return nil, errors.Wrap(err, "Failed to unmarshal storage service id")
}
}
storage, err := dataStore.GetStorage(ctx, &store.FindStorage{ID: &storageServiceID})
if err != nil {
return nil, errors.Wrap(err, "Failed to find StorageServiceID")
}

if storage == nil {
return nil, nil // storage not configured - not an error, just return empty ref
}
storageMessage, err := apiv1.ConvertStorageFromStore(storage)

if err != nil {
return nil, errors.Wrap(err, "Failed to ConvertStorageFromStore")
}
if storageMessage.Type != apiv1.StorageS3 {
return nil, nil
}

s3Config := storageMessage.Config.S3Config
return s3.NewClient(ctx, &s3.Config{
AccessKey: s3Config.AccessKey,
SecretKey: s3Config.SecretKey,
EndPoint: s3Config.EndPoint,
Region: s3Config.Region,
Bucket: s3Config.Bucket,
URLPrefix: s3Config.URLPrefix,
URLSuffix: s3Config.URLSuffix,
PreSign: s3Config.PreSign,
})
}
44 changes: 44 additions & 0 deletions plugin/storage/s3/s3.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,19 @@ import (
"io"
"net/url"
"strings"
"time"

"github.com/aws/aws-sdk-go-v2/aws"
s3config "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"
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
errors2 "github.com/pkg/errors"
)

const LinkLifetime = 24 * time.Hour

type Config struct {
AccessKey string
SecretKey string
Expand All @@ -24,6 +28,7 @@ type Config struct {
Region string
URLPrefix string
URLSuffix string
PreSign bool
}

type Client struct {
Expand Down Expand Up @@ -93,5 +98,44 @@ func (client *Client) UploadFile(ctx context.Context, filename string, fileType
if link == "" {
return "", errors.New("failed to get file link")
}
if client.Config.PreSign {
return client.PreSignLink(ctx, link)
}
return link, nil
}

// PreSignLink generates a pre-signed URL for the given sourceLink.
// If the link does not belong to the configured storage endpoint, it is returned as-is.
// If the link belongs to the storage, the function generates a pre-signed URL using the AWS S3 client.
func (client *Client) PreSignLink(ctx context.Context, sourceLink string) (string, error) {
u, err := url.Parse(sourceLink)
if err != nil {
return "", errors2.Wrapf(err, "parse URL")
}
// if link doesn't belong to storage, then return as-is.
// the empty hostname is corner-case for AWS native endpoint.
if client.Config.EndPoint != "" && !strings.Contains(client.Config.EndPoint, u.Hostname()) {
return sourceLink, nil
}

filename := u.Path
if prefixLen := len(client.Config.URLPrefix); len(filename) >= prefixLen {
filename = filename[prefixLen:]
}
if suffixLen := len(client.Config.URLSuffix); len(filename) >= suffixLen {
filename = filename[:len(filename)-suffixLen]
}
filename = strings.Trim(filename, "/")
if strings.HasPrefix(filename, client.Config.Bucket) {
filename = strings.Trim(filename[len(client.Config.Bucket):], "/")
}

req, err := awss3.NewPresignClient(client.Client).PresignGetObject(ctx, &awss3.GetObjectInput{
Bucket: aws.String(client.Config.Bucket),
Key: aws.String(filename),
}, awss3.WithPresignExpires(LinkLifetime))
if err != nil {
return "", errors2.Wrapf(err, "pre-sign link")
}
return req.URL, nil
}
3 changes: 3 additions & 0 deletions store/db/mysql/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@ func (d *DB) UpdateResource(ctx context.Context, update *store.UpdateResource) (
if v := update.InternalPath; v != nil {
set, args = append(set, "`internal_path` = ?"), append(args, *v)
}
if v := update.ExternalLink; v != nil {
set, args = append(set, "`external_link` = ?"), append(args, *v)
}
if v := update.MemoID; v != nil {
set, args = append(set, "`memo_id` = ?"), append(args, *v)
}
Expand Down
3 changes: 3 additions & 0 deletions store/db/postgres/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ func (d *DB) UpdateResource(ctx context.Context, update *store.UpdateResource) (
if v := update.InternalPath; v != nil {
set, args = append(set, "internal_path = "+placeholder(len(args)+1)), append(args, *v)
}
if v := update.ExternalLink; v != nil {
set, args = append(set, "external_link = "+placeholder(len(args)+1)), append(args, *v)
}
if v := update.MemoID; v != nil {
set, args = append(set, "memo_id = "+placeholder(len(args)+1)), append(args, *v)
}
Expand Down
3 changes: 3 additions & 0 deletions store/db/sqlite/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ func (d *DB) UpdateResource(ctx context.Context, update *store.UpdateResource) (
if v := update.InternalPath; v != nil {
set, args = append(set, "`internal_path` = ?"), append(args, *v)
}
if v := update.ExternalLink; v != nil {
set, args = append(set, "`external_link` = ?"), append(args, *v)
}
if v := update.MemoID; v != nil {
set, args = append(set, "`memo_id` = ?"), append(args, *v)
}
Expand Down
1 change: 1 addition & 0 deletions store/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ type UpdateResource struct {
UpdatedTs *int64
Filename *string
InternalPath *string
ExternalLink *string
MemoID *int32
Blob []byte
}
Expand Down
11 changes: 9 additions & 2 deletions web/src/components/CreateStorageServiceDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Button, IconButton, Input, Typography } from "@mui/joy";
import { useEffect, useState } from "react";
import { Button, IconButton, Input, Checkbox, Typography } from "@mui/joy";
import React, { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import * as api from "@/helpers/api";
import { useTranslate } from "@/utils/i18n";
Expand Down Expand Up @@ -29,6 +29,7 @@ const CreateStorageServiceDialog: React.FC<Props> = (props: Props) => {
bucket: "",
urlPrefix: "",
urlSuffix: "",
presign: false,
});
const isCreating = storage === undefined;

Expand Down Expand Up @@ -220,6 +221,12 @@ const CreateStorageServiceDialog: React.FC<Props> = (props: Props) => {
onChange={(e) => setPartialS3Config({ urlSuffix: e.target.value })}
fullWidth
/>
<Checkbox
className="mb-2"
label={t("setting.storage-section.presign-placeholder")}
checked={s3Config.presign}
onChange={(e) => setPartialS3Config({ presign: e.target.checked })}
/>
<div className="mt-2 w-full flex flex-row justify-end items-center space-x-1">
<Button variant="plain" color="neutral" onClick={handleCloseBtnClick}>
{t("common.cancel")}
Expand Down
3 changes: 2 additions & 1 deletion web/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,8 @@
"url-prefix": "URL prefix",
"url-prefix-placeholder": "Custom URL prefix, optional",
"url-suffix": "URL suffix",
"url-suffix-placeholder": "Custom URL suffix, optional"
"url-suffix-placeholder": "Custom URL suffix, optional",
"presign-placeholder": "Pre-sign URL, optional"
},
"member-section": {
"create-a-member": "Create a member",
Expand Down
1 change: 1 addition & 0 deletions web/src/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@
"update-storage": "Обновить хранилище",
"url-prefix": "Префикс URL",
"url-prefix-placeholder": "Пользовательский префикс URL, необязательно",
"presign-placeholder": "Генерировать временную публичную ссылку, необязательно",
"url-suffix": "суффикс URL",
"url-suffix-placeholder": "Пользовательский суффикс URL, необязательно",
"warning-text": "Вы уверены, что хотите удалить это хранилище?\nЭТО ДЕЙСТВИЕ НЕВОЗМОЖНО ОТМЕНИТЬ❗"
Expand Down
1 change: 1 addition & 0 deletions web/src/types/modules/storage.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface StorageS3Config {
bucket: string;
urlPrefix: string;
urlSuffix: string;
presign: boolean;
}

interface StorageConfig {
Expand Down
Loading