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

@s3/store: Allow disabling tagging on expiration extension #553

Merged
merged 1 commit into from
Jan 17, 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
11 changes: 11 additions & 0 deletions packages/s3-store/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,17 @@ Options to pass to the AWS S3 SDK.
Checkout the [`S3ClientConfig`](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-s3/interfaces/s3clientconfig.html)
docs for the supported options. You need to at least set the `region`, `bucket` name, and your preferred method of authentication.


#### `options.expirationPeriodInMilliseconds`

Enables the expiration extension and sets the expiration period of an upload url in milliseconds.
Once the expiration period has passed, the upload url will return a 410 Gone status code.

#### `options.useTags`

Some S3 providers don't support tagging objects.
If you are using certain features like the expiration extension and your provider doesn't support tagging, you can set this option to `false` to disable tagging.

## Extensions

The tus protocol supports optional [extensions][]. Below is a table of the supported extensions in `@tus/s3-store`.
Expand Down
25 changes: 22 additions & 3 deletions packages/s3-store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type Options = {
// The server calculates the optimal part size, which takes this size into account,
// but may increase it to not exceed the S3 10K parts limit.
partSize?: number
useTags?: boolean
expirationPeriodInMilliseconds?: number
// Options to pass to the AWS S3 SDK.
s3ClientConfig: S3ClientConfig & {bucket: string}
Expand Down Expand Up @@ -71,6 +72,7 @@ export class S3Store extends DataStore {
private client: S3
private preferredPartSize: number
private expirationPeriodInMilliseconds = 0
private useTags = true
public maxMultipartParts = 10_000 as const
public minPartSize = 5_242_880 as const // 5MiB
public maxUploadSize = 5_497_558_138_880 as const // 5TiB
Expand All @@ -89,9 +91,22 @@ export class S3Store extends DataStore {
this.bucket = bucket
this.preferredPartSize = partSize || 8 * 1024 * 1024
this.expirationPeriodInMilliseconds = options.expirationPeriodInMilliseconds ?? 0
this.useTags = options.useTags ?? true
this.client = new S3(restS3ClientConfig)
}

protected shouldUseExpirationTags() {
return this.expirationPeriodInMilliseconds !== 0 && this.useTags
}

protected useCompleteTag(value: 'true' | 'false') {
if (!this.shouldUseExpirationTags()) {
return undefined
}

return `Tus-Completed=${value}`
}

/**
* Saves upload metadata to a `${file_id}.info` file on S3.
* Please note that the file is empty and the metadata is saved
Expand All @@ -104,7 +119,7 @@ export class S3Store extends DataStore {
Bucket: this.bucket,
Key: this.infoKey(upload.id),
Body: JSON.stringify(upload),
Tagging: `Tus-Completed=false`,
Tagging: this.useCompleteTag('false'),
Metadata: {
'upload-id': uploadId,
'tus-version': TUS_RESUMABLE,
Expand All @@ -114,12 +129,16 @@ export class S3Store extends DataStore {
}

private async completeMetadata(upload: Upload) {
if (!this.shouldUseExpirationTags()) {
return
}

const {'upload-id': uploadId} = await this.getMetadata(upload.id)
await this.client.putObject({
Bucket: this.bucket,
Key: this.infoKey(upload.id),
Body: JSON.stringify(upload),
Tagging: `Tus-Completed=true`,
Tagging: this.useCompleteTag('true'),
Metadata: {
'upload-id': uploadId,
'tus-version': TUS_RESUMABLE,
Expand Down Expand Up @@ -197,7 +216,7 @@ export class S3Store extends DataStore {
Bucket: this.bucket,
Key: this.partKey(id, true),
Body: readStream,
Tagging: 'Tus-Completed=false',
Tagging: this.useCompleteTag('false'),
})
log(`[${id}] finished uploading incomplete part`)
return data.ETag as string
Expand Down
61 changes: 61 additions & 0 deletions test/s3.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const STORE_PATH = '/upload'

interface S3Options {
partSize?: number
useTags?: boolean
expirationPeriodInMilliseconds?: number
}

Expand Down Expand Up @@ -143,6 +144,66 @@ describe('S3 Store E2E', () => {
)
})

it('should not set tags when using useTags and the upload is not completed', async () => {
const store = createStore({
useTags: false,
expirationPeriodInMilliseconds: expireTime,
partSize: 5_242_880,
})
const server = new Server({
path: STORE_PATH,
datastore: store,
})
const listener = server.listen()
agent = request.agent(listener)

const data = allocMB(11)
const {uploadId} = await createUpload(agent, data.length)
await patchUpload(agent, uploadId, data.subarray(0, 1024 * 1024 * 6))

const {TagSet} = await s3Client.getObjectTagging({
Bucket: s3Credentials.bucket,
Key: uploadId + '.info',
})

assert.equal(TagSet?.length, 0)

await new Promise((resolve) => listener.close(resolve))
})

it('should not set tags when using useTags and the upload is completed', async () => {
const store = createStore({
useTags: false,
expirationPeriodInMilliseconds: expireTime,
partSize: 5_242_880,
})
const server = new Server({
path: STORE_PATH,
datastore: store,
})
const listener = server.listen()
agent = request.agent(listener)

const data = allocMB(11)
const {uploadId} = await createUpload(agent, data.length)
const {offset} = await patchUpload(
agent,
uploadId,
data.subarray(0, 1024 * 1024 * 6)
)

await patchUpload(agent, uploadId, data.subarray(offset), offset)

const {TagSet} = await s3Client.getObjectTagging({
Bucket: s3Credentials.bucket,
Key: uploadId + '.info',
})

assert.equal(TagSet?.length, 0)

await new Promise((resolve) => listener.close(resolve))
})

it('calling deleteExpired will delete all expired objects', async () => {
const data = allocMB(11)
const {uploadId} = await createUpload(agent, data.length)
Expand Down
Loading