diff --git a/packages/api/README.md b/packages/api/README.md index 9e1319f3d9..ecf42b2a6c 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -115,6 +115,10 @@ The given API has a set of three different authentication levels: The 👮 API methods are only allowed with a Magic Token, and consequently only available via https://web3.storage +### Account restriction + +If a user's account is restricted, it means that they might have gone over the storage limit assigned to them. This restriction disables several actions such as uploading files, adding and replacing pin requests, or publishing a name record. Note that even if the account has [pinning service API access](https://docs.web3.storage/how-tos/pinning-services-api/#requesting-access), account restriction will disable adding and replacing of pins. It is however still possible to delete pins and create/delete API tokens. For more information, please email . + ### 🔒 `POST /car` Upload a CAR file for a root CID. _Authenticated_ diff --git a/packages/api/src/auth.js b/packages/api/src/auth.js index 9a874ab1cc..4577339559 100644 --- a/packages/api/src/auth.js +++ b/packages/api/src/auth.js @@ -1,16 +1,18 @@ import * as JWT from './utils/jwt.js' import { - UserNotFoundError, + AccountRestrictedError, + MagicTokenRequiredError, + NoTokenError, PinningUnauthorizedError, TokenNotFoundError, UnrecognisedTokenError, - NoTokenError, - MagicTokenRequiredError + UserNotFoundError } from './errors.js' +import { USER_TAGS } from './constants.js' /** * Middleware: verify the request is authenticated with a valid magic link token. - * On successful login, adds `auth.user` to the Request + * On successful login, adds `auth.user` and `auth.userTags` to the Request * * @param {import('itty-router').RouteHandler} handler * @returns {import('itty-router').RouteHandler} @@ -19,14 +21,15 @@ export function withMagicToken (handler) { /** * @param {Request} request * @param {import('./env').Env} - * @returns {Response} + * @returns {Promise} */ return async (request, env, ctx) => { const token = getTokenFromRequest(request, env) const magicUser = await tryMagicToken(token, env) if (magicUser) { - request.auth = { user: magicUser } + const userTags = await getUserTags(magicUser._id, env) + request.auth = { user: magicUser, userTags } env.sentry && env.sentry.setUser(magicUser) return handler(request, env, ctx) } @@ -37,7 +40,7 @@ export function withMagicToken (handler) { /** * Middleware: verify the request is authenticated with a valid an api token *or* a magic link token. - * On successful login, adds `auth.user` and `auth.auth.token` to the Request + * On successful login, adds `auth.user`, `auth.authToken`, and `auth.userTags` to the Request * * @param {import('itty-router').RouteHandler} handler * @returns {import('itty-router').RouteHandler} @@ -46,21 +49,30 @@ export function withApiOrMagicToken (handler) { /** * @param {Request} request * @param {import('./env').Env} - * @returns {Response} + * @returns {Promise} */ return async (request, env, ctx) => { const token = getTokenFromRequest(request, env) const magicUser = await tryMagicToken(token, env) if (magicUser) { - request.auth = { user: magicUser } + const userTags = await getUserTags(magicUser._id, env) + request.auth = { + user: magicUser, + userTags + } env.sentry && env.sentry.setUser(magicUser) return handler(request, env, ctx) } const apiToken = await tryWeb3ApiToken(token, env) if (apiToken) { - request.auth = { authToken: apiToken, user: apiToken.user } + const userTags = await getUserTags(apiToken.user._id, env) + request.auth = { + authToken: apiToken, + user: apiToken.user, + userTags + } env.sentry && env.sentry.setUser(apiToken.user) return handler(request, env, ctx) } @@ -69,6 +81,23 @@ export function withApiOrMagicToken (handler) { } } +/** + * Middleware: verify that the authenticated request is for a user whose + * account is not restricted. + * + * @param {import('itty-router').RouteHandler} handler + * @returns {import('itty-router').RouteHandler} + */ +export function withAccountNotRestricted (handler) { + return async (request, env, ctx) => { + const isAccountRestricted = request.auth.userTags.find(v => (v.tag === USER_TAGS.ACCOUNT_RESTRICTION && v.value === 'true')) + if (!isAccountRestricted) { + return handler(request, env, ctx) + } + throw new AccountRestrictedError() + } +} + /** * Middleware: verify that the authenticated request is for a user who is * authorized to pin. @@ -78,7 +107,7 @@ export function withApiOrMagicToken (handler) { */ export function withPinningAuthorized (handler) { return async (request, env, ctx) => { - const authorized = await env.db.isPinningAuthorized(request.auth.user._id) + const authorized = request.auth.userTags.find(v => (v.tag === USER_TAGS.PSA_ACCESS && v.value === 'true')) if (authorized) { return handler(request, env, ctx) } @@ -146,6 +175,10 @@ function findUserByIssuer (issuer, env) { return env.db.getUser(issuer) } +function getUserTags (userId, env) { + return env.db.getUserTags(userId) +} + function verifyAuthToken (token, decoded, env) { return env.db.getKey(decoded.sub, token) } diff --git a/packages/api/src/constants.js b/packages/api/src/constants.js index a5763f195c..3414bd59d1 100644 --- a/packages/api/src/constants.js +++ b/packages/api/src/constants.js @@ -6,3 +6,7 @@ export const DAG_SIZE_CALC_LIMIT = 1024 * 1024 * 9 export const MAX_BLOCK_SIZE = 1 << 20 // 1MiB export const UPLOAD_TYPES = ['Car', 'Blob', 'Multipart', 'Upload'] export const PIN_STATUSES = ['PinQueued', 'Pinning', 'Pinned', 'PinError'] +export const USER_TAGS = { + ACCOUNT_RESTRICTION: 'HasAccountRestriction', + PSA_ACCESS: 'HasPsaAccess' +} diff --git a/packages/api/src/errors.js b/packages/api/src/errors.js index 69b2d82244..1d6dda573c 100644 --- a/packages/api/src/errors.js +++ b/packages/api/src/errors.js @@ -29,6 +29,15 @@ export class PinningUnauthorizedError extends HTTPError { } PinningUnauthorizedError.CODE = 'ERROR_PINNING_UNAUTHORIZED' +export class AccountRestrictedError extends HTTPError { + constructor (msg = 'This account is restricted, email support@web3.storage for more information.') { + super(msg, 403) + this.name = 'AccountRestrictedError' + this.code = AccountRestrictedError.CODE + } +} +AccountRestrictedError.CODE = 'ERROR_ACCOUNT_RESTRICTED' + export class TokenNotFoundError extends HTTPError { constructor (msg = 'API token no longer valid') { super(msg, 401) diff --git a/packages/api/src/index.js b/packages/api/src/index.js index 8922e349e6..22fa83679c 100644 --- a/packages/api/src/index.js +++ b/packages/api/src/index.js @@ -2,7 +2,7 @@ import { Router } from 'itty-router' import { errorHandler } from './error-handler.js' import { addCorsHeaders, withCorsHeaders, corsOptions } from './cors.js' -import { withApiOrMagicToken, withMagicToken, withPinningAuthorized } from './auth.js' +import { withAccountNotRestricted, withApiOrMagicToken, withMagicToken, withPinningAuthorized } from './auth.js' import { envAll } from './env.js' import { statusGet } from './status.js' import { carHead, carGet, carPut, carPost } from './car.js' @@ -27,7 +27,8 @@ const auth = { '🤲': handler => withCorsHeaders(handler), '🔒': handler => withCorsHeaders(withApiOrMagicToken(handler)), '👮': handler => withCorsHeaders(withMagicToken(handler)), - '📌': handler => auth['🔒'](withPinningAuthorized(handler)) + '📌': handler => withPinningAuthorized(handler), + '🚫': handler => withAccountNotRestricted(handler) } const mode = { @@ -41,20 +42,20 @@ router.get('/status/:cid', mode['👀'](auth['🤲'](statusGet))) router.get('/car/:cid', mode['👀'](auth['🤲'](carGet))) router.head('/car/:cid', mode['👀'](auth['🤲'](carHead))) -router.post('/car', mode['📝'](auth['🔒'](carPost))) -router.put('/car/:cid', mode['📝'](auth['🔒'](carPut))) -router.post('/upload', mode['📝'](auth['🔒'](uploadPost))) +router.post('/car', mode['📝'](auth['🔒'](auth['🚫'](carPost)))) +router.put('/car/:cid', mode['📝'](auth['🔒'](auth['🚫'](carPut)))) +router.post('/upload', mode['📝'](auth['🔒'](auth['🚫'](uploadPost)))) router.get('/user/uploads', mode['👀'](auth['🔒'](userUploadsGet))) -router.post('/pins', mode['📝'](auth['📌'](pinPost))) -router.post('/pins/:requestId', mode['📝'](auth['📌'](pinPost))) -router.get('/pins/:requestId', mode['👀'](auth['📌'](pinGet))) -router.get('/pins', mode['👀'](auth['📌'](pinsGet))) -router.delete('/pins/:requestId', mode['📝'](auth['📌'](pinDelete))) +router.post('/pins', mode['📝'](auth['🔒'](auth['🚫'](auth['📌'](pinPost))))) +router.post('/pins/:requestId', mode['📝'](auth['🔒'](auth['🚫'](auth['📌'](pinPost))))) +router.get('/pins/:requestId', mode['👀'](auth['🔒'](auth['📌'](pinGet)))) +router.get('/pins', mode['👀'](auth['🔒'](auth['📌'](pinsGet)))) +router.delete('/pins/:requestId', mode['📝'](auth['🔒'](auth['📌'](pinDelete)))) router.get('/name/:key', mode['👀'](auth['🤲'](nameGet))) router.get('/name/:key/watch', mode['👀'](auth['🤲'](nameWatchGet))) -router.post('/name/:key', mode['📝'](auth['🔒'](namePost))) +router.post('/name/:key', mode['📝'](auth['🔒'](auth['🚫'](namePost)))) router.delete('/user/uploads/:cid', mode['📝'](auth['👮'](userUploadsDelete))) router.post('/user/uploads/:cid/rename', mode['📝'](auth['👮'](userUploadsRename))) diff --git a/packages/api/test/auth.spec.js b/packages/api/test/auth.spec.js new file mode 100644 index 0000000000..cdf71482d9 --- /dev/null +++ b/packages/api/test/auth.spec.js @@ -0,0 +1,274 @@ +/* eslint-env mocha, browser */ +import assert from 'assert' +import * as uint8arrays from 'uint8arrays' +import fetch, { Blob } from '@web-std/fetch' +import { endpoint } from './scripts/constants.js' +import { createNameKeypair, createNameRecord, getTestJWT } from './scripts/helpers.js' +import { AccountRestrictedError, PinningUnauthorizedError } from '../src/errors.js' +import { createCar } from './scripts/car.js' + +const EMAIL_ERROR_MESSAGE = 'Error message does not contain support email address' +const RESTRICTED_ERROR_CHECK = /This account is restricted./ +const SUPPORT_EMAIL_CHECK = /support@web3.storage/ + +describe('Pinning service API access', () => { + const cid = 'bafybeibqmrg5e5bwhx2ny4kfcjx2mm3ohh2cd4i54wlygquwx7zbgwqs4e' + let notAuthorizedToken + + before(async () => { + notAuthorizedToken = await getTestJWT() + }) + + describe('GET /pins', () => { + it('should throw if user not authorized to pin', async () => { + const res = await fetch(new URL('pins', endpoint).toString(), { + method: 'GET', + headers: { + Authorization: `Bearer ${notAuthorizedToken}`, + 'Content-Type': 'application/json' + } + }) + + assert(!res.ok) + const { message, code } = await res.json() + assert.match(message, SUPPORT_EMAIL_CHECK, EMAIL_ERROR_MESSAGE) + assert.strictEqual(code, PinningUnauthorizedError.CODE) + }) + }) + + describe('GET /pins/:requestId', () => { + it('should throw if user not authorized to pin', async () => { + const res = await fetch(new URL('pins', endpoint).toString(), { + method: 'POST', + headers: { + Authorization: `Bearer ${notAuthorizedToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ cid }) + }) + + assert(!res.ok) + const { message, code } = await res.json() + assert.match(message, SUPPORT_EMAIL_CHECK, EMAIL_ERROR_MESSAGE) + assert.strictEqual(code, PinningUnauthorizedError.CODE) + }) + }) + + describe('POST /pins', () => { + it('should throw if user not authorized to pin', async () => { + const res = await fetch(new URL('pins', endpoint).toString(), { + method: 'POST', + headers: { + Authorization: `Bearer ${notAuthorizedToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ cid }) + }) + + assert(!res.ok) + const { message, code } = await res.json() + assert.match(message, SUPPORT_EMAIL_CHECK, EMAIL_ERROR_MESSAGE) + assert.strictEqual(code, PinningUnauthorizedError.CODE) + }) + }) + + describe('POST /pins/:requestId', () => { + it('should throw if user not authorized to pin', async () => { + const res = await fetch(new URL('pins/UniqueIdOfPinRequest', endpoint).toString(), { + method: 'POST', + headers: { + Authorization: `Bearer ${notAuthorizedToken}`, + 'Content-Type': 'application/json' + } + }) + + assert(!res.ok) + const { message, code } = await res.json() + assert.match(message, SUPPORT_EMAIL_CHECK, EMAIL_ERROR_MESSAGE) + assert.strictEqual(code, PinningUnauthorizedError.CODE) + }) + }) + + describe('DELETE /pins/:requestId', () => { + it('should throw if user not authorized to pin', async () => { + const res = await fetch(new URL('pins/1', endpoint).toString(), { + method: 'DELETE', + headers: { + Authorization: `Bearer ${notAuthorizedToken}`, + 'Content-Type': 'application/json' + } + }) + + assert(!res.ok) + const { message, code } = await res.json() + assert.match(message, SUPPORT_EMAIL_CHECK, EMAIL_ERROR_MESSAGE) + assert.strictEqual(code, PinningUnauthorizedError.CODE) + }) + }) +}) + +describe('Account restriction', () => { + let restrictedToken + let restrictedTokenPSAEnabled + + before(async () => { + restrictedToken = await getTestJWT('test-restriction', 'test-restriction') + restrictedTokenPSAEnabled = await getTestJWT('test-pinning-and-restriction', 'test-pinning-and-restriction') + }) + + describe('POST /name/:key', () => { + it('should throw when publishing value for key', async () => { + const { id: key, privateKey } = await createNameKeypair() + const value = '/ipfs/bafybeiauyddeo2axgargy56kwxirquxaxso3nobtjtjvoqu552oqciudrm' + const record = await createNameRecord(privateKey, value) + const res = await fetch(new URL(`name/${key}`, endpoint), { + method: 'POST', + headers: { Authorization: `Bearer ${restrictedToken}` }, + body: uint8arrays.toString(record, 'base64pad') + }) + + assert(res, 'Server responded') + assert.strictEqual(res.status, 403) + const { code, message } = await res.json() + assert.strictEqual(code, AccountRestrictedError.CODE) + assert.match(message, RESTRICTED_ERROR_CHECK) + assert.match(message, SUPPORT_EMAIL_CHECK, EMAIL_ERROR_MESSAGE) + }) + }) + + describe('POST /car', () => { + it('should throw if account is restricted', async () => { + const { car: carBody } = await createCar('hello world!') + + const res = await fetch(new URL('car', endpoint), { + method: 'POST', + headers: { + Authorization: `Bearer ${restrictedToken}`, + 'Content-Type': 'application/car', + 'X-Name': 'car' + }, + body: carBody + }) + + assert.strictEqual(res.ok, false) + const { code, message } = await res.json() + assert.strictEqual(code, AccountRestrictedError.CODE) + assert.match(message, RESTRICTED_ERROR_CHECK) + assert.match(message, SUPPORT_EMAIL_CHECK, EMAIL_ERROR_MESSAGE) + }) + }) + + describe('POST /pins', () => { + let baseUrl + + before(async () => { + baseUrl = new URL('pins', endpoint).toString() + }) + + it('should throw if account is PSA enabled, but restricted', async () => { + const res = await fetch(baseUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${restrictedTokenPSAEnabled}` + }, + body: JSON.stringify({ + cid: 'abc' + }) + }) + + assert(res, 'Server responded') + assert.strictEqual(res.status, 403) + const { code, message } = await res.json() + assert.strictEqual(code, AccountRestrictedError.CODE) + assert.match(message, RESTRICTED_ERROR_CHECK) + assert.match(message, SUPPORT_EMAIL_CHECK, EMAIL_ERROR_MESSAGE) + }) + + it('should throw if account is restricted', async () => { + const res = await fetch(baseUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${restrictedToken}` + }, + body: JSON.stringify({ + cid: 'abc' + }) + }) + + assert(res, 'Server responded') + assert.strictEqual(res.status, 403) + const { code, message } = await res.json() + assert.strictEqual(code, AccountRestrictedError.CODE) + assert.match(message, RESTRICTED_ERROR_CHECK) + assert.match(message, SUPPORT_EMAIL_CHECK, EMAIL_ERROR_MESSAGE) + }) + }) + + describe('POST /pins/:requestId', () => { + let baseUrl + + before(async () => { + baseUrl = new URL('pins/UniqueIdOfPinRequest', endpoint).toString() + }) + + it('should throw if account is PSA enabled, but restricted', async () => { + const res = await fetch(baseUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${restrictedTokenPSAEnabled}` + }, + body: JSON.stringify({ + cid: 'abc' + }) + }) + + assert(res, 'Server responded') + assert.strictEqual(res.status, 403) + const { code, message } = await res.json() + assert.strictEqual(code, AccountRestrictedError.CODE) + assert.match(message, RESTRICTED_ERROR_CHECK) + assert.match(message, SUPPORT_EMAIL_CHECK, EMAIL_ERROR_MESSAGE) + }) + + it('should throw if account is restricted', async () => { + const res = await fetch(baseUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${restrictedToken}` + }, + body: JSON.stringify({ + cid: 'abc' + }) + }) + + assert(res, 'Server responded') + assert.strictEqual(res.status, 403) + const { code, message } = await res.json() + assert.strictEqual(code, AccountRestrictedError.CODE) + assert.match(message, RESTRICTED_ERROR_CHECK) + assert.match(message, SUPPORT_EMAIL_CHECK, EMAIL_ERROR_MESSAGE) + }) + }) + + describe('POST /upload', () => { + it('should throw if account is restricted', async () => { + const name = 'single-file-upload' + const file = new Blob(['hello world!']) + + const res = await fetch(new URL('upload', endpoint).toString(), { + method: 'POST', + headers: { + Authorization: `Bearer ${restrictedToken}`, + 'X-Name': name + }, + body: file + }) + + assert.strictEqual(res.ok, false) + const { code, message } = await res.json() + assert.strictEqual(code, AccountRestrictedError.CODE) + assert.match(message, RESTRICTED_ERROR_CHECK) + assert.match(message, SUPPORT_EMAIL_CHECK, EMAIL_ERROR_MESSAGE) + }) + }) +}) diff --git a/packages/api/test/fixtures/init-data.sql b/packages/api/test/fixtures/init-data.sql index 61d0c7add5..dc92d8db23 100644 --- a/packages/api/test/fixtures/init-data.sql +++ b/packages/api/test/fixtures/init-data.sql @@ -14,6 +14,12 @@ VALUES ('test-pinning-user', 'test-pinning@user.com', 'test-pinning', 'test-pinn INSERT INTO public.user ( name, email, issuer, public_address) VALUES ('test-pinning-2-user', 'test-pinning2@user.com', 'test-pinning-2', 'test-pinning-2'); +INSERT INTO public.user (id, name, email, issuer, public_address) +VALUES (6, 'test-pinning-and-restriction-user', 'test-pinning-and-restriction@user.com', 'test-pinning-and-restriction', 'test-pinning-and-restriction'); + +INSERT INTO public.user (id, name, email, issuer, public_address) +VALUES (7, 'test-restricted-user', 'test-restriction@user.com', 'test-restriction', 'test-restriction'); + INSERT INTO auth_key (name, secret, user_id) VALUES ('test-key', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LW1hZ2ljLWlzc3VlciIsImlzcyI6IndlYjMtc3RvcmFnZSIsImlhdCI6MTYzMzk1NzM4OTg3MiwibmFtZSI6InRlc3QtbWFnaWMtaXNzdWVyIn0.p2nD1Q4X4Z6DtJ0vxk35hhZOqSPVymhN5uyXrXth1zs', 1); @@ -30,6 +36,11 @@ VALUES ('test-pinning', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LX INSERT INTO auth_key (name, secret, user_id) VALUES ('test-pinning-2', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXBpbm5pbmctMiIsImlzcyI6IndlYjMtc3RvcmFnZSIsImlhdCI6MTYzMzk1NzM4OTg3MiwibmFtZSI6InRlc3QtcGlubmluZy0yIn0.B0lwP5T2KLP0D1XGvz_f7AJcJ_j65NPN3BsxZ4Io2-g', 5); +-- Used to test account restriction +INSERT INTO auth_key (name, secret, user_id) +VALUES ('test-pinning-and-restriction', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXBpbm5pbmctYW5kLXJlc3RyaWN0aW9uIiwiaXNzIjoid2ViMy1zdG9yYWdlIiwiaWF0IjoxNjMzOTU3Mzg5ODcyLCJuYW1lIjoidGVzdC1waW5uaW5nLWFuZC1yZXN0cmljdGlvbiJ9.L2SCQ7C-gDm840m2l2shE-0HqiTwnWDXCwHjpT61msk', 6), + ('test-restriction', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXJlc3RyaWN0aW9uIiwiaXNzIjoid2ViMy1zdG9yYWdlIiwiaWF0IjoxNjMzOTU3Mzg5ODcyLCJuYW1lIjoidGVzdC1yZXN0cmljdGlvbiJ9.uAklG7dHOxRD85c564RBcBqeFGUGNBper7VLaXBGnFg', 7); + -- /user route data testing INSERT INTO content (cid) VALUES ('bafkreigpimx5kl6thyfysh2witvbo5nexvu3q3uc3y65rj5sr5czcc7wae'), @@ -82,10 +93,14 @@ VALUES ( 1669394359626000000 ); --- user 'test-pinning' is authorized -INSERT INTO public.user_tag (user_id, tag, value, reason) -VALUES (4, 'HasPsaAccess', true, 'test'), - (5, 'HasPsaAccess', true, 'test'); +INSERT INTO user_tag (user_id, tag, value, reason, deleted_at) +VALUES (4, 'HasPsaAccess', true, 'test', null), + (5, 'HasPsaAccess', true, 'test', null), + (6, 'HasPsaAccess', true, 'test', null), + (5, 'HasAccountRestriction', true, 'Revoked access', '2021-07-14T19:27:14.934572+00:00'), + (5, 'HasAccountRestriction', false, 'Re-enabled access', null), + (6, 'HasAccountRestriction', true, 'Revoked access', null), + (7, 'HasAccountRestriction', true, 'Revoked access', null); INSERT INTO content (cid) VALUES ('bafybeid46f7zggioxjm5p2ze2l6s6wbqvoo4gzbdzfjtdosthmfyxdign4'), diff --git a/packages/api/test/pin.spec.js b/packages/api/test/pin.spec.js index 3ac8c08a92..d76ee95a63 100644 --- a/packages/api/test/pin.spec.js +++ b/packages/api/test/pin.spec.js @@ -10,7 +10,7 @@ import { INVALID_REPLACE, getEffectivePinStatus } from '../src/utils/psa.js' -import { PinningUnauthorizedError, PSAErrorResourceNotFound, PSAErrorInvalidData, PSAErrorRequiredData } from '../src/errors.js' +import { PSAErrorResourceNotFound, PSAErrorInvalidData, PSAErrorRequiredData } from '../src/errors.js' /** * @@ -79,8 +79,6 @@ const createPinRequest = async (cid, token) => { } describe('Pinning APIs endpoints', () => { - const supportEmailCheck = /support@web3.storage/ - describe('GET /pins', () => { let baseUrl let token @@ -491,21 +489,6 @@ describe('Pinning APIs endpoints', () => { assert.strictEqual(data.count, 1) assert.strictEqual(data.results[0].pin.name, 'Image.jpeg') }) - - it('error if user not authorized to pin', async () => { - const notAuthorizedToken = await getTestJWT('test-upload', 'test-upload') - const res = await fetch(new URL('pins', endpoint).toString(), { - method: 'GET', - headers: { - Authorization: `Bearer ${notAuthorizedToken}`, - 'Content-Type': 'application/json' - } - }) - assert(!res.ok) - const data = await res.json() - assert.match(data.message, supportEmailCheck, 'Error message does not contain support email address') - assert.strictEqual(data.code, PinningUnauthorizedError.CODE) - }) }) describe('POST /pins', () => { @@ -658,25 +641,6 @@ describe('Pinning APIs endpoints', () => { assert.strictEqual(error.details, '#/meta: Instance type "number" is invalid. Expected "object".') }) - it('error if user not authorized to pin', async () => { - const notAuthorizedToken = await getTestJWT() - const res = await fetch(new URL('pins', endpoint).toString(), { - method: 'POST', - headers: { - Authorization: `Bearer ${notAuthorizedToken}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - cid: 'bafybeibqmrg5e5bwhx2ny4kfcjx2mm3ohh2cd4i54wlygquwx7zbgwqs4e' - }) - }) - - assert(!res.ok) - const data = await res.json() - assert.match(data.message, supportEmailCheck, 'Error message does not contain support email address') - assert.strictEqual(data.code, PinningUnauthorizedError.CODE) - }) - it('returns the pin request', async () => { const sourceCid = 'bafybeidhbtemubjbsbuhyai5oaebqf2fdrvhnshbkncyqpnoy2bl2mpt4q' const res = await fetch(new URL('pins', endpoint).toString(), { @@ -807,25 +771,6 @@ describe('Pinning APIs endpoints', () => { assert.deepStrictEqual(data.pin.meta, meta) }) - it('error if user not authorized to pin', async () => { - const notAuthorizedToken = await getTestJWT() - const res = await fetch(new URL('pins', endpoint).toString(), { - method: 'POST', - headers: { - Authorization: `Bearer ${notAuthorizedToken}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - cid: 'bafybeibqmrg5e5bwhx2ny4kfcjx2mm3ohh2cd4i54wlygquwx7zbgwqs4e' - }) - }) - - assert(!res.ok) - const data = await res.json() - assert.match(data.message, supportEmailCheck, 'Error message does not contain support email address') - assert.strictEqual(data.code, PinningUnauthorizedError.CODE) - }) - it('returns the pin request with specified origins', async () => { const requestId = 'ab62cf3c-c98d-494b-a756-b3a3fb6ddcab' const origins = [ @@ -970,21 +915,6 @@ describe('Pinning APIs endpoints', () => { assert(res.ok, 'Server responded') assert.equal(res.status, 202) }) - - it('error if user not authorized to pin', async () => { - const notAuthorizedToken = await getTestJWT() - const res = await fetch(new URL('pins/1', endpoint).toString(), { - method: 'DELETE', - headers: { - Authorization: `Bearer ${notAuthorizedToken}`, - 'Content-Type': 'application/json' - } - }) - assert(!res.ok) - const data = await res.json() - assert.match(data.message, supportEmailCheck, 'Error message does not contain support email address') - assert.strictEqual(data.code, PinningUnauthorizedError.CODE) - }) }) describe('POST /pins/:requestId', () => { @@ -1077,21 +1007,5 @@ describe('Pinning APIs endpoints', () => { const error = await res.json() assert.equal(error.details, INVALID_REPLACE) }) - - it('error if user not authorized to pin', async () => { - const notAuthorizedToken = await getTestJWT() - const res = await fetch(new URL('pins/UniqueIdOfPinRequest', endpoint).toString(), { - method: 'POST', - headers: { - Authorization: `Bearer ${notAuthorizedToken}`, - 'Content-Type': 'application/json' - } - }) - - assert(!res.ok) - const data = await res.json() - assert.match(data.message, supportEmailCheck, 'Error message does not contain support email address') - assert.strictEqual(data.code, PinningUnauthorizedError.CODE) - }) }) }) diff --git a/packages/db/errors.js b/packages/db/errors.js index 1e3e4efc09..a672ce6a63 100644 --- a/packages/db/errors.js +++ b/packages/db/errors.js @@ -15,3 +15,19 @@ export class DBError extends Error { } DBError.CODE = 'ERROR_DB' + +export class ConstraintError extends Error { + /** + * @param {{ + * message: string + * details?: string + * }} cause + */ + constructor ({ message, details }) { + super(`${message}, details: ${details}`) + this.name = 'ConstraintError' + this.code = ConstraintError.CODE + } +} + +ConstraintError.CODE = 'CONSTRAINT_ERROR_DB' diff --git a/packages/db/index.d.ts b/packages/db/index.d.ts index 7d0da00595..82a127acd3 100644 --- a/packages/db/index.d.ts +++ b/packages/db/index.d.ts @@ -66,4 +66,5 @@ export class DBClient { createContent (content: ContentInput, opt?: {updatePinRequests?: boolean}) : Promise deleteKey (id: number): Promise query(document: RequestDocument, variables: V): Promise + getUserTags (userId: number): Promise<{ tag: string, value: string }[]> } diff --git a/packages/db/index.js b/packages/db/index.js index 32029c50e2..a052d19cc2 100644 --- a/packages/db/index.js +++ b/packages/db/index.js @@ -3,7 +3,7 @@ import { PostgrestClient } from '@supabase/postgrest-js' import { normalizeUpload, normalizeContent, normalizePins, normalizeDeals, normalizePsaPinRequest } from './utils.js' -import { DBError } from './errors.js' +import { ConstraintError, DBError } from './errors.js' const uploadQuery = ` _id:id::text, @@ -131,25 +131,62 @@ export class DBClient { } /** - * Check that a user is authorized to pin. + * Returns the value stored for an active (non-deleted) user tag. * * @param {number} userId - * @returns {Promise} + * @param {string} tag + * @returns {Promise} */ - async isPinningAuthorized (userId) { - const { count, error } = await this._client + async getUserTagValue (userId, tag) { + const { data, error } = await this._client .from('user_tag') - .select('value', { count: 'exact' }) + .select('value') .eq('user_id', userId) - .eq('tag', 'HasPsaAccess') - .eq('value', true) + .eq('tag', tag) .filter('deleted_at', 'is', null) if (error) { throw new DBError(error) } - return count > 0 + // Expects unique entries. + if (data.length > 1) { + throw new ConstraintError({ message: `More than one row found for user tag ${tag}` }) + } + + return data.length ? data[0].value : undefined + } + + /** + * Returns all the active (non-deleted) user tags for a user id. + * + * @param {number} userId + * @returns {Promise<{ tag: string, value: string }[]>} + */ + async getUserTags (userId) { + const { data, error } = await this._client + .from('user_tag') + .select(` + tag, + value + `) + .eq('user_id', userId) + .filter('deleted_at', 'is', null) + + if (error) { + throw new DBError(error) + } + + // Ensure active user tags are unique. + const tags = new Set() + data.forEach(item => { + if (tags.has(item.tag)) { + throw new ConstraintError({ message: `More than one row found for user tag ${item.tag}` }) + } + tags.add(item.tag) + }) + + return data } /** diff --git a/packages/db/postgres/tables.sql b/packages/db/postgres/tables.sql index d7c02943cd..fd21041a79 100644 --- a/packages/db/postgres/tables.sql +++ b/packages/db/postgres/tables.sql @@ -43,8 +43,8 @@ CREATE TABLE IF NOT EXISTS public.user_tag tag user_tag_type NOT NULL, value TEXT NOT NULL, reason TEXT NOT NULL, - inserted_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL, - deleted_at TIMESTAMP WITH TIME ZONE + inserted_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL, + deleted_at TIMESTAMP WITH TIME ZONE ); CREATE UNIQUE INDEX IF NOT EXISTS user_tag_is_deleted_idx ON user_tag (user_id, tag, deleted_at) WHERE deleted_at IS NOT NULL; diff --git a/packages/tools/docker/cluster/docker-compose.yml b/packages/tools/docker/cluster/docker-compose.yml index a4ed89ae17..6e843a9adb 100644 --- a/packages/tools/docker/cluster/docker-compose.yml +++ b/packages/tools/docker/cluster/docker-compose.yml @@ -47,7 +47,7 @@ services: cluster0: container_name: cluster0 - image: ipfs/ipfs-cluster:latest + image: ipfs/ipfs-cluster:v0.14.5 depends_on: - ipfs0 environment: