Skip to content

Commit 4393c43

Browse files
committed
feat: restrict in flight psa requests with max quota
1 parent de180a6 commit 4393c43

9 files changed

Lines changed: 92 additions & 2 deletions

File tree

.env.tpl

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,7 @@ S3_ENDPOINT = http://localhost:9095
4242
S3_REGION = test
4343
S3_ACCESS_KEY_ID = test
4444
S3_SECRET_ACCESS_KEY = test
45-
S3_BUCKET_NAME = test
45+
S3_BUCKET_NAME = test
46+
47+
# PSA
48+
PSA_QUOTA = 100

packages/api/src/bindings.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ export interface ServiceConfiguration {
7575

7676
/** Mailchimp api key */
7777
MAILCHIMP_API_KEY: string
78+
79+
/** PSA quota with number of in flight requests possible */
80+
PSA_QUOTA: number
7881
}
7982

8083
export interface Ucan {
@@ -96,6 +99,7 @@ export interface AuthOptions {
9699
checkHasAccountRestriction?: boolean
97100
checkHasDeleteRestriction?: boolean
98101
checkHasPsaAccess?: boolean
102+
checkHasPsaQuota?: boolean
99103
}
100104

101105
export interface RouteContext {

packages/api/src/config.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ const CLUSTER_SERVICE_URLS = {
1818
IpfsCluster3: 'https://nft3.storage.ipfscluster.io/api/',
1919
}
2020

21+
const DEFAULT_PSA_QUOTA = 100
22+
2123
/** @type ServiceConfiguration|undefined */
2224
let _globalConfig
2325

@@ -99,6 +101,7 @@ export function serviceConfigFromVariables(vars) {
99101
S3_ACCESS_KEY_ID: vars.S3_ACCESS_KEY_ID,
100102
S3_SECRET_ACCESS_KEY: vars.S3_SECRET_ACCESS_KEY,
101103
S3_BUCKET_NAME: vars.S3_BUCKET_NAME,
104+
PSA_QUOTA: vars.PSA_QUOTA ? Number(vars.PSA_QUOTA) : DEFAULT_PSA_QUOTA,
102105
PRIVATE_KEY: vars.PRIVATE_KEY,
103106
// These are injected in esbuild
104107
// @ts-ignore
@@ -144,6 +147,7 @@ export function loadConfigVariables() {
144147
'S3_ACCESS_KEY_ID',
145148
'S3_SECRET_ACCESS_KEY',
146149
'S3_BUCKET_NAME',
150+
'PSA_QUOTA',
147151
]
148152

149153
for (const name of required) {

packages/api/src/errors.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,17 @@ export class ErrorPinningUnauthorized extends HTTPError {
197197
}
198198
ErrorPinningUnauthorized.CODE = 'ERROR_PINNING_UNAUTHORIZED'
199199

200+
export class ErrorPinningQuotaExceeded extends HTTPError {
201+
constructor(
202+
msg = 'Pinning quota exceeded for this user, please wait for in flight pinning requests to end or delete failed ones.'
203+
) {
204+
super(msg, 429)
205+
this.name = 'PinningQuotaExceeded'
206+
this.code = ErrorPinningQuotaExceeded.CODE
207+
}
208+
}
209+
ErrorPinningQuotaExceeded.CODE = 'ERROR_PINNING_QUOTA_EXCEEDED'
210+
200211
export class ErrorDeleteRestricted extends HTTPError {
201212
constructor(msg = 'Delete operations restricted.') {
202213
super(msg, 403)

packages/api/src/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const r = new Router(getContext, {
4444
const checkHasAccountRestriction = true
4545
const checkHasDeleteRestriction = true
4646
const checkHasPsaAccess = true
47+
const checkHasPsaQuota = true
4748
const checkUcan = true
4849

4950
// Monitoring
@@ -98,6 +99,7 @@ r.add(
9899
withAuth(withMode(pinsAdd, RW), {
99100
checkHasPsaAccess,
100101
checkHasAccountRestriction,
102+
checkHasPsaQuota,
101103
}),
102104
[postCors]
103105
)
@@ -107,6 +109,7 @@ r.add(
107109
withAuth(withMode(pinsReplace, RW), {
108110
checkHasPsaAccess,
109111
checkHasAccountRestriction,
112+
checkHasPsaQuota,
110113
}),
111114
[postCors]
112115
)

packages/api/src/middleware/auth.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@ import {
22
ErrorAccountRestricted,
33
ErrorDeleteRestricted,
44
ErrorPinningUnauthorized,
5+
ErrorPinningQuotaExceeded,
56
} from '../errors'
7+
import { getServiceConfig } from '../config.js'
68
import { validate } from '../utils/auth'
79
import { hasTag } from '../utils/utils'
810

11+
const { PSA_QUOTA } = getServiceConfig()
12+
913
/**
1014
*
1115
* @param {import('../bindings').Handler} handler
@@ -37,6 +41,16 @@ export function withAuth(handler, options) {
3741
throw new ErrorPinningUnauthorized()
3842
}
3943

44+
if (options?.checkHasPsaQuota) {
45+
const countPendingPsaRequests = await auth.db.getPendingPsaRequestsCount(
46+
auth.user.id
47+
)
48+
49+
if (countPendingPsaRequests >= PSA_QUOTA) {
50+
throw new ErrorPinningQuotaExceeded()
51+
}
52+
}
53+
4054
return handler(event, { ...ctx, auth })
4155
}
4256
}

packages/api/src/utils/db-client.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const PIN_SERVICES = [
1515
]
1616
/** @type {Array<definitions['pin']['status']>} */
1717
export const PIN_STATUSES = ['PinQueued', 'Pinning', 'Pinned', 'PinError']
18+
export const PIN_IN_FLIGHT_STATUSES = ['PinQueued', 'Pinning', 'PinError']
1819

1920
export class DBClient {
2021
/**
@@ -635,6 +636,27 @@ export class DBClient {
635636

636637
return stats
637638
}
639+
640+
/**
641+
* @param {number} userId
642+
*/
643+
async getPendingPsaRequestsCount(userId) {
644+
const { error, count } = await this.client
645+
.from('upload')
646+
.select(this.uploadQuery, { count: 'exact', head: true })
647+
.eq('user_id', userId)
648+
.is('deleted_at', null)
649+
.in('content.pin.status', PIN_IN_FLIGHT_STATUSES)
650+
.in('type', ['Remote'])
651+
652+
if (error) {
653+
throw new DBError(error)
654+
}
655+
656+
console.log('count: ' + count)
657+
658+
return count || 0
659+
}
638660
}
639661

640662
export class DBError extends Error {

packages/api/test/pin-add.spec.js

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ describe('Pin add ', () => {
1717
/** @type{DBTestClient} */
1818
let client
1919

20-
before(async () => {
20+
beforeEach(async () => {
2121
client = await createClientWithUser()
2222
})
2323

@@ -229,4 +229,31 @@ describe('Pin add ', () => {
229229
assert.strictEqual(pin.meta.invalid, undefined)
230230
assert.strictEqual(pin.meta.valid, 'string')
231231
})
232+
233+
it('should restrict requests to quota', async () => {
234+
const cids = [
235+
'bafkreidyeivj7adnnac6ljvzj2e3rd5xdw3revw4da7mx2ckrstapoupoq',
236+
'bafybeih74zqc6kamjpruyra4e4pblnwdpickrvk4hvturisbtveghflovq',
237+
]
238+
239+
await Promise.all(
240+
cids.map(async (cid) => {
241+
const res = await fetch('pins', {
242+
method: 'POST',
243+
headers: { Authorization: `Bearer ${client.token}` },
244+
body: JSON.stringify({ cid }),
245+
})
246+
assert.strictEqual(res.status, 200)
247+
})
248+
)
249+
250+
const extraQuotaCid =
251+
'bafkreihbjbbccwxn7hzv5hun5pxuswide7q3lhjvfbvmd7r3kf2sodybgi'
252+
const res = await fetch('pins', {
253+
method: 'POST',
254+
headers: { Authorization: `Bearer ${client.token}` },
255+
body: JSON.stringify({ cid: extraQuotaCid }),
256+
})
257+
assert.strictEqual(res.status, 429)
258+
})
232259
})

packages/api/test/scripts/globals.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,5 @@ globalThis.S3_REGION = 'test'
2424
globalThis.S3_ACCESS_KEY_ID = 'test'
2525
globalThis.S3_SECRET_ACCESS_KEY = 'test'
2626
globalThis.S3_BUCKET_NAME = 'test'
27+
28+
globalThis.PSA_QUOTA = '2'

0 commit comments

Comments
 (0)