Skip to content

Commit f1a3b29

Browse files
committed
feat: tus with linked buckets
2 parents 197cdf0 + 0cf1f70 commit f1a3b29

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

73 files changed

+1301
-553
lines changed

migrations/multitenant/0006-add-tenants-external-credentials.sql

-38
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
alter table storage.objects add column if not exists owner_id text default null;
2+
alter table storage.buckets add column if not exists owner_id text default null;
3+
4+
comment on column storage.objects.owner is 'Field is deprecated, use owner_id instead';
5+
comment on column storage.buckets.owner is 'Field is deprecated, use owner_id instead';
6+
7+
ALTER TABLE storage.buckets
8+
DROP CONSTRAINT IF EXISTS buckets_owner_fkey;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
CREATE TABLE bucket_credentials (
2+
"id" uuid NOT NULL DEFAULT extensions.uuid_generate_v4(),
3+
name text NOT NULL unique,
4+
access_key text NULL,
5+
secret_key text NULL,
6+
role text null,
7+
region text not null,
8+
endpoint text NULL,
9+
force_path_style boolean NOT NULL default false,
10+
PRIMARY KEY (id)
11+
);
12+
13+
ALTER TABLE storage.buckets ADD COLUMN credential_id uuid DEFAULT NULL;
14+
ALTER TABLE storage.buckets ADD CONSTRAINT fk_bucket_credential FOREIGN KEY (credential_id) REFERENCES bucket_credentials(id);

src/app.ts

+1
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ const build = (opts: buildOpts = {}): FastifyInstance => {
6060
app.register(plugins.logTenantId)
6161
app.register(plugins.logRequest({ excludeUrls: ['/status', '/metrics'] }))
6262
app.register(routes.multiPart, { prefix: 'upload/resumable' })
63+
app.register(routes.credentials, { prefix: 'credentials' })
6364
app.register(routes.bucket, { prefix: 'bucket' })
6465
app.register(routes.object, { prefix: 'object' })
6566
app.register(routes.render, { prefix: 'render/image' })

src/auth/jwt.ts

+19-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import { getJwtSecret as getJwtSecretForTenant } from '../database/tenant'
1+
import { getJwtSecret as getJwtSecretForTenant, getTenantConfig } from '../database/tenant'
22
import jwt from 'jsonwebtoken'
3+
import crypto from 'crypto'
34
import { getConfig } from '../config'
5+
import { StorageBackendError } from '../storage'
46

5-
const { isMultitenant, jwtSecret, jwtAlgorithm } = getConfig()
7+
const { isMultitenant, jwtSecret, jwtAlgorithm, serviceKey } = getConfig()
68

79
interface jwtInterface {
810
sub?: string
@@ -21,6 +23,21 @@ export type SignedUploadToken = {
2123
exp: number
2224
}
2325

26+
export async function compareServiceKey(tenantId: string, jwt: string) {
27+
if (isMultitenant) {
28+
const { serviceKey } = await getTenantConfig(tenantId)
29+
return crypto.timingSafeEqual(Buffer.from(serviceKey), Buffer.from(jwt))
30+
}
31+
32+
return crypto.timingSafeEqual(Buffer.from(serviceKey), Buffer.from(jwt))
33+
}
34+
35+
export async function mustBeServiceKey(tenantId: string, jwt: string) {
36+
if (!(await compareServiceKey(tenantId, jwt))) {
37+
throw new StorageBackendError('unauthorized', 401, 'Unauthorized')
38+
}
39+
}
40+
2441
/**
2542
* Gets the JWT secret key from the env PGRST_JWT_SECRET when running in single-tenant
2643
* or querying the multi-tenant database by the given tenantId

src/config.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ export function getConfig(): StorageConfigType {
119119
10
120120
),
121121
databaseConnectionTimeout: parseInt(
122-
getOptionalConfigFromEnv('DATABASE_CONNECTION_TIMEOUT') || '30000',
122+
getOptionalConfigFromEnv('DATABASE_CONNECTION_TIMEOUT') || '3000',
123123
10
124124
),
125125
region: getConfigFromEnv('REGION'),

src/database/connection.ts

+13-33
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import pg from 'pg'
22
import { Knex, knex } from 'knex'
33
import { JwtPayload } from 'jsonwebtoken'
44
import { getConfig } from '../config'
5-
import { logger } from '../monitoring'
65
import { DbActiveConnection, DbActivePool } from '../monitoring/metrics'
76

87
// https://github.com/knex/knex/issues/387#issuecomment-51554522
@@ -30,6 +29,7 @@ export interface User {
3029
}
3130

3231
export const connections = new Map<string, Knex>()
32+
const searchPath = ['storage', 'public', 'extensions']
3333

3434
export class TenantConnection {
3535
public readonly role: string
@@ -63,13 +63,13 @@ export class TenantConnection {
6363

6464
knexPool = knex({
6565
client: 'pg',
66-
searchPath: ['public', 'storage', 'extensions'],
66+
searchPath: isExternalPool ? undefined : searchPath,
6767
pool: {
6868
min: 0,
6969
max: isExternalPool ? 1 : options.maxConnections || databaseMaxConnections,
7070
propagateCreateError: false,
7171
acquireTimeoutMillis: databaseConnectionTimeout,
72-
idleTimeoutMillis: isExternalPool ? 100 : undefined,
72+
idleTimeoutMillis: isExternalPool ? 100 : databaseFreePoolAfterInactivity,
7373
reapIntervalMillis: isExternalPool ? 110 : undefined,
7474
},
7575
connection: connectionString,
@@ -97,38 +97,12 @@ export class TenantConnection {
9797
})
9898

9999
if (!isExternalPool) {
100-
let freePoolIntervalFn: NodeJS.Timeout | undefined
101-
102100
knexPool.client.pool.on('poolDestroySuccess', () => {
103-
if (freePoolIntervalFn) {
104-
clearTimeout(freePoolIntervalFn)
105-
}
106-
107101
if (connections.get(connectionString) === knexPool) {
108102
connections.delete(connectionString)
109103
}
110104
})
111105

112-
knexPool.client.pool.on('stopReaping', () => {
113-
if (freePoolIntervalFn) {
114-
clearTimeout(freePoolIntervalFn)
115-
}
116-
117-
freePoolIntervalFn = setTimeout(async () => {
118-
connections.delete(connectionString)
119-
knexPool?.destroy().catch((e) => {
120-
logger.error(e, 'Error destroying pool')
121-
})
122-
clearTimeout(freePoolIntervalFn)
123-
}, databaseFreePoolAfterInactivity)
124-
})
125-
126-
knexPool.client.pool.on('startReaping', () => {
127-
if (freePoolIntervalFn) {
128-
clearTimeout(freePoolIntervalFn)
129-
freePoolIntervalFn = undefined
130-
}
131-
})
132106
connections.set(connectionString, knexPool)
133107
}
134108

@@ -141,10 +115,16 @@ export class TenantConnection {
141115
}
142116
}
143117

144-
transaction(isolation?: Knex.IsolationLevels, instance?: Knex) {
145-
return (instance || this.pool).transactionProvider({
146-
isolationLevel: isolation,
147-
})
118+
transaction(instance?: Knex): Knex.TransactionProvider {
119+
return async () => {
120+
const pool = instance || this.pool
121+
const tnx = await pool.transaction()
122+
123+
if (!instance) {
124+
await tnx.raw(`set search_path to ${searchPath.join(', ')}`)
125+
}
126+
return tnx
127+
}
148128
}
149129

150130
asSuperUser() {

src/database/tenant.ts

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { runMigrationsOnTenant } from './migrate'
55
import { knex } from './multitenant-db'
66
import { StorageBackendError } from '../storage'
77
import { JwtPayload } from 'jsonwebtoken'
8+
import { Credential } from '../storage/schemas'
89

910
interface TenantConfig {
1011
anonKey: string

src/http/error-handler.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { FastifyInstance } from 'fastify'
22
import { isRenderableError } from '../storage'
33
import { FastifyError } from '@fastify/error'
4+
import { getConfig } from '../config'
5+
6+
const { tusPath } = getConfig()
47

58
/**
69
* The global error handler for all the uncaught exceptions within a request.
@@ -20,7 +23,8 @@ export const setErrorHandler = (app: FastifyInstance) => {
2023

2124
if (isRenderableError(error)) {
2225
const renderableError = error.render()
23-
return reply.status(renderableError.statusCode === '500' ? 500 : 400).send(renderableError)
26+
const body = request.routerPath.includes(tusPath) ? renderableError.error : renderableError
27+
return reply.status(renderableError.statusCode === '500' ? 500 : 400).send(body)
2428
}
2529

2630
// Fastify errors

src/http/plugins/bucket.ts

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import fastifyPlugin from 'fastify-plugin'
2+
import { RouteGenericInterface } from 'fastify/types/route'
3+
import { BucketWithCredentials } from '../../storage/schemas'
4+
import { StorageBackendError } from '../../storage'
5+
6+
declare module 'fastify' {
7+
interface FastifyRequest<RouteGeneric extends RouteGenericInterface = RouteGenericInterface> {
8+
bucket: BucketWithCredentials
9+
}
10+
11+
interface FastifyContextConfig {
12+
getParentBucketId?: ((request: FastifyRequest<any>) => string) | false
13+
}
14+
}
15+
16+
export const parentBucket = fastifyPlugin(async (fastify) => {
17+
fastify.decorateRequest('bucket', undefined)
18+
fastify.addHook('preHandler', async (request) => {
19+
if (typeof request.routeConfig.getParentBucketId === 'undefined') {
20+
throw new Error(
21+
`getParentBucketId not defined in route ${request.routerPath} ${request.routerPath} config`
22+
)
23+
}
24+
25+
if (request.routeConfig.getParentBucketId === false) {
26+
return
27+
}
28+
29+
const bucketId = request.routeConfig.getParentBucketId(request)
30+
31+
if (!bucketId) {
32+
throw new StorageBackendError('invalid_bucket', 400, 'bucket name is invalid or not provided')
33+
}
34+
35+
const bucket = await request.db.asSuperUser().findBucketById(bucketId, '*', {
36+
includeCredentials: true,
37+
})
38+
39+
request.bucket = bucket
40+
})
41+
})

src/http/plugins/db.ts

+23-9
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,24 @@ import { TenantConnection } from '../../database/connection'
33
import { getServiceKeyUser } from '../../database/tenant'
44
import { getPostgresConnection } from '../../database'
55
import { verifyJWT } from '../../auth'
6+
import { Database, StorageKnexDB } from '../../storage/database'
67

78
declare module 'fastify' {
89
interface FastifyRequest {
9-
db: TenantConnection
10+
dbConnection: TenantConnection
11+
db: Database
1012
}
1113
}
1214

1315
export const db = fastifyPlugin(async (fastify) => {
1416
fastify.decorateRequest('db', null)
17+
fastify.decorateRequest('dbConnection', null)
18+
1519
fastify.addHook('preHandler', async (request) => {
1620
const adminUser = await getServiceKeyUser(request.tenantId)
1721
const userPayload = await verifyJWT<{ role?: string }>(request.jwt, adminUser.jwtSecret)
1822

19-
request.db = await getPostgresConnection({
23+
request.dbConnection = await getPostgresConnection({
2024
user: {
2125
payload: userPayload,
2226
jwt: request.jwt,
@@ -28,11 +32,16 @@ export const db = fastifyPlugin(async (fastify) => {
2832
path: request.url,
2933
method: request.method,
3034
})
35+
36+
request.db = new StorageKnexDB(request.dbConnection, {
37+
tenantId: request.tenantId,
38+
host: request.headers['x-forwarded-host'] as string,
39+
})
3140
})
3241

3342
fastify.addHook('onSend', async (request, reply, payload) => {
3443
if (request.db) {
35-
request.db.dispose().catch((e) => {
44+
request.dbConnection.dispose().catch((e) => {
3645
request.log.error(e, 'Error disposing db connection')
3746
})
3847
}
@@ -41,13 +50,13 @@ export const db = fastifyPlugin(async (fastify) => {
4150

4251
fastify.addHook('onTimeout', async (request) => {
4352
if (request.db) {
44-
await request.db.dispose()
53+
await request.dbConnection.dispose()
4554
}
4655
})
4756

4857
fastify.addHook('onRequestAbort', async (request) => {
4958
if (request.db) {
50-
await request.db.dispose()
59+
await request.dbConnection.dispose()
5160
}
5261
})
5362
})
@@ -58,7 +67,7 @@ export const dbSuperUser = fastifyPlugin(async (fastify) => {
5867
fastify.addHook('preHandler', async (request) => {
5968
const adminUser = await getServiceKeyUser(request.tenantId)
6069

61-
request.db = await getPostgresConnection({
70+
request.dbConnection = await getPostgresConnection({
6271
user: adminUser,
6372
superUser: adminUser,
6473
tenantId: request.tenantId,
@@ -67,11 +76,16 @@ export const dbSuperUser = fastifyPlugin(async (fastify) => {
6776
method: request.method,
6877
headers: request.headers,
6978
})
79+
80+
request.db = new StorageKnexDB(request.dbConnection, {
81+
tenantId: request.tenantId,
82+
host: request.headers['x-forwarded-host'] as string,
83+
})
7084
})
7185

7286
fastify.addHook('onSend', async (request, reply, payload) => {
7387
if (request.db) {
74-
request.db.dispose().catch((e) => {
88+
request.dbConnection.dispose().catch((e) => {
7589
request.log.error(e, 'Error disposing db connection')
7690
})
7791
}
@@ -81,13 +95,13 @@ export const dbSuperUser = fastifyPlugin(async (fastify) => {
8195

8296
fastify.addHook('onTimeout', async (request) => {
8397
if (request.db) {
84-
await request.db.dispose()
98+
await request.dbConnection.dispose()
8599
}
86100
})
87101

88102
fastify.addHook('onRequestAbort', async (request) => {
89103
if (request.db) {
90-
await request.db.dispose()
104+
await request.dbConnection.dispose()
91105
}
92106
})
93107
})

src/http/plugins/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ export * from './db'
66
export * from './storage'
77
export * from './tenant-id'
88
export * from './tenant-feature'
9+
export * from './bucket'
910
export * from './metrics'

0 commit comments

Comments
 (0)