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 support for signed upload urls. #1661

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

erikcarlsson
Copy link

No description provided.

@encore-cla
Copy link

encore-cla bot commented Dec 19, 2024

All committers have signed the CLA.

@erikcarlsson erikcarlsson force-pushed the sign-url branch 3 times, most recently from 3d7696f to 6bf10f5 Compare December 19, 2024 10:31
export interface UploadUrlOptions {
/** The expiration time of the url, in seconds from signing. The maximum
* value is seven days */
ttl: number;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Options should typically be optional:

Suggested change
ttl: number;
ttl?: number;

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

#[napi]
pub async fn get_upload_url(
&self,
options: Option<UploadUrlOptions>, // TODO: can/should this be made non-optional, since ttl is required?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ttl shouldn't be required, right? The user-facing docs state the ttl defaults to 7 days if not provided

impl From<UploadUrlOptions> for core::UploadUrlOptions {
fn from(value: UploadUrlOptions) -> Self {
Self {
ttl: value.ttl as u64,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Convert this to the default of 7 days here

#[napi(object)]
#[derive(Debug, Default)]
pub struct UploadUrlOptions {
pub ttl: i64,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pub ttl: i64,
pub ttl: Option<i64>,


// WithTtl is used for signed URLs, to specify the lifetime of the generated
// URL, in seconds. The max value is seven days.
func WithTtl(ttl uint64) withTtlOption {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Go convention is TTL not Ttl. Also make it take a time.Duration instead of an uint64

Suggested change
func WithTtl(ttl uint64) withTtlOption {
func WithTTL(ttl time.Duration) withTTLOption {

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

* Anyone with possession of the URL can write to the given object name
* without any additional auth.
*/
async getUploadUrl(name: string, options?: UploadUrlOptions): Promise<string> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's make this signedUploadUrl, and have it return a SignedURL object with just a single field {url: string}

Suggested change
async getUploadUrl(name: string, options?: UploadUrlOptions): Promise<string> {
async signedUploadUrl(name: string, options?: SignedUploadUrlOptions): Promise<SignedURL> {

Copy link
Author

@erikcarlsson erikcarlsson Dec 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Although used SignedUploadUrl instead of SignedUrl. I think we ended up agreeing on making upload URLs more flexible, but not so far as folding in signed GET and PUT URLs in one type. But let me know if I got that wrong.

@@ -120,6 +120,7 @@ pub fn resolve_bucket_usage(data: &ResolveUsageData, bucket: Lrc<Bucket>) -> Opt
"list" => Operation::ListObjects,
"exists" | "attrs" => Operation::GetObjectMetadata,
"upload" => Operation::WriteObject,
"getUploadUrl" => Operation::WriteObject,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rename to signedUploadUrl as per above. Also, let's introduce a new SignedUploadUrl operation (needs to be done in legacymeta.rs as well)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

client: client,
cfg: runtimeCfg,
client: client,
presign_client: s3.NewPresignClient(client),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instantiate in clientForProvider instead so we can cache it

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, thanks

client *s3.Client
cfg *config.Bucket
client *s3.Client
presign_client *s3.PresignClient
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
presign_client *s3.PresignClient
presignClient *s3.PresignClient

Bucket: &b.cfg.CloudName,
Key: &object,
}
sign_opts := func(opts *s3.PresignOptions) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
sign_opts := func(opts *s3.PresignOptions) {
signOpts := func(opts *s3.PresignOptions) {

sign_opts := func(opts *s3.PresignOptions) {
opts.Expires = time.Duration(data.Ttl) * time.Second
}
req, err := b.presign_client.PresignPutObject(data.Ctx, &params, sign_opts)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do the error check here instead

Suggested change
req, err := b.presign_client.PresignPutObject(data.Ctx, &params, sign_opts)
req, err := b.presign_client.PresignPutObject(data.Ctx, &params, sign_opts)
if err != nil {
return "", mapErr(err)
}
return req.URL, nil

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@@ -833,3 +838,32 @@ func gzipBytes(data []byte) []byte {
_ = w.Close()
return buf.Bytes()
}

const dummyPrivateKey = `-----BEGIN PRIVATE KEY-----
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should base64-encode this and decode it at runtime; there are a bunch of tools that automatically flag private keys in source code and they get upset otherwise

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, better to avoid those. I reversed instead of base64 just because I think it's easier to inspect. Let me know if you can think of a problem with that.

Comment on lines 344 to 346
message LocalSignOptions {
// Base prefix to use for presigned URLs.
string base_url = 1;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indentation could use some work :)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agh, vscode tabs/spaces, fixed.

private_key: String,
}

fn local_sign_config_from_client(client: Arc<LazyGCSClient>) -> Option<LocalSignOptions> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can take &LazyGCSClient instead to avoid the clone below (this code path isn't performance sensitive but more idiomatic)

Comment on lines 53 to 59
Self {
client,
client: client.clone(),
encore_name: cfg.encore_name.clone().into(),
cloud_name: cfg.cloud_name.clone().into(),
public_base_url: cfg.public_base_url.clone(),
key_prefix: cfg.key_prefix.clone(),
local_sign: local_sign_config_from_client(client.clone()),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can instead do:

    let local_sign = local_sign_config_from_client(&client);
    Self {
            client,
            encore_name: cfg.encore_name.clone().into(),
            cloud_name: cfg.cloud_name.clone().into(),
            public_base_url: cfg.public_base_url.clone(),
            key_prefix: cfg.key_prefix.clone(),
            local_sign,
    }

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment on lines 442 to 461
fn replace_url_prefix(orig_url: String, base: String) -> String {
match Url::parse(&orig_url) {
Ok(url) => {
let mut out = format!(
"{}/{}",
base.trim_end_matches('/'),
url.path().trim_start_matches("/")
);
if let Some(query) = url.query() {
out.push('?');
out.push_str(query);
}
out
}
Err(_) => {
// If the input URL fails parsing, just don't do the replace
orig_url
}
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid unnecessary cloning of the input arguments we can do:

Suggested change
fn replace_url_prefix(orig_url: String, base: String) -> String {
match Url::parse(&orig_url) {
Ok(url) => {
let mut out = format!(
"{}/{}",
base.trim_end_matches('/'),
url.path().trim_start_matches("/")
);
if let Some(query) = url.query() {
out.push('?');
out.push_str(query);
}
out
}
Err(_) => {
// If the input URL fails parsing, just don't do the replace
orig_url
}
}
}
fn replace_url_prefix<'a>(orig_url: &'a str, base: &str) -> Cow<'a, str> {
match url::Url::parse(orig_url) {
Ok(url) => {
let mut out = format!(
"{}/{}",
base.trim_end_matches('/'),
url.path().trim_start_matches("/")
);
if let Some(query) = url.query() {
out.push('?');
out.push_str(query);
}
Cow::Owned(out)
}
Err(_) => {
// If the input URL fails parsing, just don't do the replace
Cow::Borrowed(orig_url)
}
}
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, done

opts := &storage.SignedURLOptions{
Scheme: storage.SigningSchemeV4,
Method: "PUT",
Expires: time.Now().Add(time.Duration(data.Ttl)),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

data.Ttl is already a time.Duration right? Can skip the extra conversion then

Suggested change
Expires: time.Now().Add(time.Duration(data.Ttl)),
Expires: time.Now().Add(data.Ttl),

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

return url, nil
}

func replaceURLPrefix(orig_url string, base string) string {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
func replaceURLPrefix(orig_url string, base string) string {
func replaceURLPrefix(origURL string, base string) string {

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

if err != nil {
return orig_url // If the input URL is not valid, just return it as-is
}
out := strings.TrimRight(base, "/") + "/" + strings.TrimLeft(u.Path, "/")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds an extra trailing slash when u.Path is empty, right?

Suggested change
out := strings.TrimRight(base, "/") + "/" + strings.TrimLeft(u.Path, "/")
out := strings.TrimRight(base, "/")
if u.Path != "" {
out += "/" + strings.TrimLeft(u.Path, "/")
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, but IMO we don't need to handle that case. 1) we currently use path style URLs which means the bucket name is in the path. And 2) we only support object scoped signed URLs atm, which means we have a non-empty object key for all valid requests. Both would need to change and if we change (1) we would anyway need to make some other changes to make local URLs work. But let me know if I'm missing something or if you prefer not to have this potential bug lurking :).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather fix it now, since it's a trivial fix and the code is more general that way

Comment on lines 204 to 210
clients := clientSet{
client: client,
presignClient: s3.NewPresignClient(client),
}

mgr.clients[prov] = &clients
return &clients
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
clients := clientSet{
client: client,
presignClient: s3.NewPresignClient(client),
}
mgr.clients[prov] = &clients
return &clients
clients := &clientSet{
client: client,
presignClient: s3.NewPresignClient(client),
}
mgr.clients[prov] = clients
return clients

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Ctx context.Context
Object CloudObject

Ttl time.Duration
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Ttl time.Duration
TTL time.Duration

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@erikcarlsson erikcarlsson force-pushed the sign-url branch 6 times, most recently from 7e02e6c to 82bc1a0 Compare January 13, 2025 11:55
Comment on lines 3 to 4
// protoc-gen-go v1.35.2
// protoc v5.29.1
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would rather keep the protobuf versions the same so we don't end up with lots of spurious regenerations between different developers. Can you get those existing versions installed instead?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, done

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.31.0
// protoc v4.23.4
// protoc-gen-go v1.35.2
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants