Skip to content
Open
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
12 changes: 11 additions & 1 deletion lib/cli/storage-s3/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ export const builder = function(yargs) {
type: 'string',
demand: true
})
.option('secret', {
alias: 's',
describe: 'The secret to use for auth JSON Web Tokens. Anyone who ' +
'knows this token can freely enter the system if they want, so keep ' +
'it safe.',
type: 'string',
default: process.env.SECRET,
demand: true
})
.epilog('Each option can be be overwritten with an environment variable ' +
'by converting the option to uppercase, replacing dashes with ' +
'underscores and prefixing it with `STF_STORAGE_S3_` (e.g. ' +
Expand All @@ -42,6 +51,7 @@ export const handler = function(argv) {
profile: argv.profile,
bucket: argv.bucket,
endpoint: argv.endpoint,
maxFileSize: argv.maxFileSize
maxFileSize: argv.maxFileSize,
secret: argv.secret
})
}
47 changes: 36 additions & 11 deletions lib/units/storage/s3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { fromIni } from '@aws-sdk/credential-providers'
import { S3 } from '@aws-sdk/client-s3'
import logger from '../../util/logger.js'
import rateLimitConfig from '../ratelimit/index.js'
import { accessTokenAuth } from '../api/helpers/securityHandlers.js'
import db from '../../db/index.js'

// @ts-ignore
import formidable from 'formidable'
Expand All @@ -20,6 +22,7 @@ interface S3Options {
bucket: string
maxFileSize: number
port: number
secret: string
}

interface UploadedFile {
Expand All @@ -37,24 +40,28 @@ interface ResourceResponse {
href: string
}

export default function (options: S3Options) {
export const createApp = (options: S3Options, s3: S3) => {
const log = logger.createLogger('storage:s3')
const app = express()
const server = http.createServer(app)
const s3 = new S3({
// JS SDK v3 switched credential providers from classes to functions.
// This is the closest approximation from codemod of what your application needs.
// Reference: https://www.npmjs.com/package/@aws-sdk/credential-providers
credentials: fromIni({
profile: options.profile
}),
endpoint: options.endpoint
})

app.set('strict routing', true)
app.set('case sensitive routing', true)
app.set('trust proxy', true)
app.use(rateLimitConfig)

// Same check as storage-temp: every file belongs to a logged in user
app.use(async (req: any, res, next) => {
req.options = {
secret: options.secret
}
try {
await accessTokenAuth(req)
next()
} catch (err: any) {
res.status(err.status || 500).json({ message: err.message })
}
})

app.use(bodyParser.json())
app.use(validator())

Expand Down Expand Up @@ -161,6 +168,24 @@ export default function (options: S3Options) {
}
})

return app
}

export default async function (options: S3Options) {
const log = logger.createLogger('storage:s3')
await db.connect()

const s3 = new S3({
// JS SDK v3 switched credential providers from classes to functions.
// This is the closest approximation from codemod of what your application needs.
// Reference: https://www.npmjs.com/package/@aws-sdk/credential-providers
credentials: fromIni({
profile: options.profile
}),
endpoint: options.endpoint
})

const server = http.createServer(createApp(options, s3))
server.listen(options.port)
log.info('Listening on port %s', options.port)
}
67 changes: 67 additions & 0 deletions test/unit/units/storage/s3.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import type {AddressInfo} from 'node:net'
import type {Server} from 'node:http'

vi.mock('../../../../lib/db/api.js', () => ({
default: {
loadUser: vi.fn(),
loadAccessTokenById: vi.fn()
}
}))

import dbapi from '../../../../lib/db/api.js'
import * as jwtutil from '../../../../lib/util/jwtutil.js'
import {createApp} from '../../../../lib/units/storage/s3.js'

const secret = 'secret'
const user = {email: 'user@example.com', name: 'User', privilege: 'user'}

describe('S3 storage unit', () => {
let server: Server
let base: string
const s3 = {getObject: vi.fn(), putObject: vi.fn()}

beforeEach(async() => {
vi.mocked(dbapi.loadUser).mockResolvedValue(user)
s3.getObject.mockReset().mockResolvedValue({ContentType: 'text/plain', Body: 'content'})
s3.putObject.mockReset().mockResolvedValue({})

const app = createApp({
profile: 'default',
bucket: 'bucket',
maxFileSize: 1024,
port: 0,
secret
}, s3 as any)
server = app.listen(0)
await new Promise(resolve => server.once('listening', resolve))
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
})

afterEach(() => new Promise<void>(resolve => server.close(() => resolve())))

it('does not hand out a file without a token', async() => {
const res = await fetch(`${base}/s/blob/id/name`)

expect(res.status).toBe(401)
expect(s3.getObject).not.toHaveBeenCalled()
})

it('does not store a file without a token', async() => {
const form = new FormData()
form.append('file', new Blob(['content']), 'file.txt')
const res = await fetch(`${base}/s/upload/blob`, {method: 'POST', body: form})

expect(res.status).toBe(401)
expect(s3.putObject).not.toHaveBeenCalled()
})

it('hands out a file to a logged in user', async() => {
const jwt = jwtutil.encode({payload: {email: user.email, name: user.name}, secret})
const res = await fetch(`${base}/s/blob/id/name`, {headers: {Authorization: `Bearer ${jwt}`}})

expect(res.status).toBe(200)
expect(await res.text()).toBe('content')
expect(s3.getObject).toHaveBeenCalledWith({Key: 'id', Bucket: 'bucket'})
})
})