Skip to content

Commit

Permalink
Generate pseudo-random URLs for public uploaded files
Browse files Browse the repository at this point in the history
  • Loading branch information
neoxelox committed Dec 13, 2024
1 parent 4b8358d commit cc388a3
Show file tree
Hide file tree
Showing 4 changed files with 92 additions and 17 deletions.
5 changes: 4 additions & 1 deletion apps/web/src/actions/files/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ export const uploadFileAction = authProcedure
}),
)
.handler(async ({ input, ctx }) => {
const result = await uploadFile(input.file, ctx.workspace)
const result = await uploadFile({
file: input.file,
workspace: ctx.workspace,
})

return result.unwrap()
})
2 changes: 1 addition & 1 deletion packages/core/src/lib/disk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ export class DiskWrapper {
region: awsConfig.region,
bucket: awsConfig.publicBucket,
supportsACL: false,
visibility: 'private',
visibility: 'public',
})
}

Expand Down
61 changes: 53 additions & 8 deletions packages/core/src/services/files/upload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { join } from 'path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Workspace } from '../../browser'
import * as constants from '../../constants'
import * as lib from '../../lib'
import { BadRequestError, Result, UnprocessableEntityError } from '../../lib'
import { diskFactory } from '../../lib/disk'
import * as factories from '../../tests/factories'
Expand All @@ -25,13 +26,14 @@ describe('uploadFile', () => {
workspace = w

vi.spyOn(disk, 'putFile').mockResolvedValue(Result.ok(undefined))
vi.spyOn(lib, 'generateUUIDIdentifier').mockReturnValue('fake-uuid')
})

it('not uploads empty file', async () => {
const file = new File([Buffer.from('')], 'file')

await expect(
uploadFile(file, workspace, disk).then((r) => r.unwrap()),
uploadFile({ file, workspace }, disk).then((r) => r.unwrap()),
).rejects.toThrowError(new BadRequestError(`File is empty`))
})

Expand All @@ -41,7 +43,7 @@ describe('uploadFile', () => {
const file = new File([Buffer.from('Too large!')], 'file')

await expect(
uploadFile(file, workspace, disk).then((r) => r.unwrap()),
uploadFile({ file, workspace }, disk).then((r) => r.unwrap()),
).rejects.toThrowError(new BadRequestError(`File too large`))
})

Expand All @@ -51,7 +53,7 @@ describe('uploadFile', () => {
const file = new File([content], name)

await expect(
uploadFile(file, workspace, disk).then((r) => r.unwrap()),
uploadFile({ file, workspace }, disk).then((r) => r.unwrap()),
).rejects.toThrowError(new BadRequestError(`Unsupported file type: .bin`))
})

Expand All @@ -65,7 +67,7 @@ describe('uploadFile', () => {
const file = new File([content], name)

await expect(
uploadFile(file, workspace, disk).then((r) => r.unwrap()),
uploadFile({ file, workspace }, disk).then((r) => r.unwrap()),
).rejects.toThrowError(
new UnprocessableEntityError(`Failed to upload .png file`, {}),
)
Expand All @@ -80,8 +82,10 @@ describe('uploadFile', () => {
const file = new File([content], name)

await expect(
uploadFile(file, workspace, disk).then((r) => r.unwrap()),
).resolves.toContain(`/workspaces/${workspace.id}/files/${name}`)
uploadFile({ file, workspace }, disk).then((r) => r.unwrap()),
).resolves.toContain(
`/workspaces/${workspace.id}/files/fake-uuid/${name}`,
)
expect(convertFile).not.toHaveBeenCalled()
}
})
Expand All @@ -97,9 +101,50 @@ describe('uploadFile', () => {
const file = new File([content], name)

await expect(
uploadFile(file, workspace, disk).then((r) => r.unwrap()),
).resolves.toContain(`/workspaces/${workspace.id}/files/${name}`)
uploadFile({ file, workspace }, disk).then((r) => r.unwrap()),
).resolves.toContain(
`/workspaces/${workspace.id}/files/fake-uuid/${name}`,
)
expect(convertFile).toHaveBeenCalledWith(file)
}
})

it('it uploads files with a workspace prefix', async () => {
const convertFile = vi.spyOn(convert, 'convertFile')

const name = 'file.png'
const content = await readFile(join(IMAGES_PATH, name))
const file = new File([content], name)

await expect(
uploadFile({ file, workspace }, disk).then((r) => r.unwrap()),
).resolves.toContain(`/workspaces/${workspace.id}/files/fake-uuid/${name}`)
expect(convertFile).not.toHaveBeenCalled()
})

it('it uploads files with a custom prefix', async () => {
const convertFile = vi.spyOn(convert, 'convertFile')

const name = 'file.png'
const content = await readFile(join(IMAGES_PATH, name))
const file = new File([content], name)

await expect(
uploadFile({ file, prefix: 'custom' }, disk).then((r) => r.unwrap()),
).resolves.toContain(`/custom/files/fake-uuid/${name}`)
expect(convertFile).not.toHaveBeenCalled()
})

it('it uploads files with an unknown prefix', async () => {
const convertFile = vi.spyOn(convert, 'convertFile')

const name = 'file.png'
const content = await readFile(join(IMAGES_PATH, name))
const file = new File([content], name)

await expect(
uploadFile({ file }, disk).then((r) => r.unwrap()),
).resolves.toContain(`/unknown/files/fake-uuid/${name}`)
expect(convertFile).not.toHaveBeenCalled()
})
})
41 changes: 34 additions & 7 deletions packages/core/src/services/files/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { diskFactory, DiskWrapper } from '../../lib/disk'

import {
BadRequestError,
generateUUIDIdentifier,
Result,
TypedResult,
UnprocessableEntityError,
Expand All @@ -13,13 +14,40 @@ import { Workspace } from '../../browser'
import { MAX_UPLOAD_SIZE_IN_MB, SUPPORTED_IMAGE_TYPES } from '../../constants'
import { convertFile } from './convert'

function generateKey({
filename,
prefix,
workspace,
}: {
filename: string
prefix?: string
workspace?: Workspace
}) {
let keyPrefix = prefix
if (!keyPrefix && workspace) keyPrefix = `workspaces/${workspace.id}`
if (!keyPrefix) keyPrefix = 'unknown'

const keyUuid = generateUUIDIdentifier()

const keyFilename = slugify(filename, { preserveCharacters: ['.'] })

return `${keyPrefix}/files/${keyUuid}/${keyFilename}`
}

export async function uploadFile(
file: File,
workspace: Workspace,
{
file,
prefix,
workspace,
}: {
file: File
prefix?: string
workspace?: Workspace
},
disk: DiskWrapper = diskFactory('public'),
): Promise<TypedResult<string, Error>> {
const key = generateKey({ filename: file.name, prefix, workspace })
const extension = path.extname(file.name).toLowerCase()
const key = `workspaces/${workspace.id}/files/${slugify(file.name, { preserveCharacters: ['.'] })}`

if (file.size === 0) {
return Result.error(new BadRequestError(`File is empty`))
Expand All @@ -38,10 +66,9 @@ export async function uploadFile(

try {
await disk.putFile(key, file).then((r) => r.unwrap())
const url = await disk.getSignedUrl(key, {
expiresIn: undefined,
contentDisposition: 'inline',
})
// TODO: Use temporal signed URLs, with a (micro)service
// acting as a reverse proxy refreshing the signed urls
const url = await disk.getUrl(key)

return Result.ok(url)
} catch (error) {
Expand Down

0 comments on commit cc388a3

Please sign in to comment.