diff --git a/src/adapters/worlds-manager.ts b/src/adapters/worlds-manager.ts index 23e1205b..f571defa 100644 --- a/src/adapters/worlds-manager.ts +++ b/src/adapters/worlds-manager.ts @@ -1,4 +1,12 @@ -import { AppComponents, IPermissionChecker, IWorldsManager, Permissions, WorldMetadata, WorldRecord } from '../types' +import { + AppComponents, + IPermissionChecker, + IWorldsManager, + Permissions, + WorldMetadata, + WorldRecord, + ContributorDomain +} from '../types' import { streamToBuffer } from '@dcl/catalyst-storage' import { Entity, EthAddress } from '@dcl/schemas' import SQL from 'sql-template-strings' @@ -181,6 +189,26 @@ export async function createWorldsManagerComponent({ await database.query(sql) } + async function getContributableDomains(address: string): Promise<{ domains: ContributorDomain[]; count: number }> { + const result = await database.query(SQL` + SELECT DISTINCT name, array_agg(permission) as user_permissions, size, owner + FROM ( + SELECT * + FROM worlds w, json_each_text(w.permissions) AS perm(permission, permissionValue) + WHERE permission = ANY(ARRAY['deployment', 'streaming']) + ) AS wp + WHERE EXISTS ( + SELECT 1 FROM json_array_elements_text(wp.permissionValue::json -> 'wallets') as wallet WHERE LOWER(wallet) = LOWER(${address}) + ) + GROUP BY name, size, owner + `) + + return { + domains: result.rows, + count: result.rowCount + } + } + return { getRawWorldRecords, getDeployedWorldCount, @@ -190,6 +218,7 @@ export async function createWorldsManagerComponent({ deployScene, storePermissions, permissionCheckerForWorld, - undeploy + undeploy, + getContributableDomains } } diff --git a/src/controllers/handlers/contributor-handler.ts b/src/controllers/handlers/contributor-handler.ts new file mode 100644 index 00000000..8fd9cf25 --- /dev/null +++ b/src/controllers/handlers/contributor-handler.ts @@ -0,0 +1,16 @@ +import { DecentralandSignatureContext } from '@dcl/platform-crypto-middleware' +import { HandlerContextWithPath } from '../../types' +import { IHttpServerComponent } from '@well-known-components/interfaces' + +export async function getContributableDomainsHandler( + ctx: HandlerContextWithPath<'worldsManager', '/world/contribute'> & DecentralandSignatureContext +): Promise { + const { worldsManager } = ctx.components + const address = ctx.verification!.auth + const body = await worldsManager.getContributableDomains(address) + + return { + status: 200, + body + } +} diff --git a/src/controllers/routes.ts b/src/controllers/routes.ts index 41c019a3..d87fcec1 100644 --- a/src/controllers/routes.ts +++ b/src/controllers/routes.ts @@ -22,6 +22,7 @@ import { undeployEntity } from './handlers/undeploy-entity-handler' import { bearerTokenMiddleware, errorHandler } from '@dcl/platform-server-commons' import { reprocessABHandler } from './handlers/reprocess-ab-handler' import { garbageCollectionHandler } from './handlers/garbage-collection' +import { getContributableDomainsHandler } from './handlers/contributor-handler' export async function setupRouter(globalContext: GlobalContext): Promise> { const router = new Router() @@ -48,6 +49,8 @@ export async function setupRouter(globalContext: GlobalContext): Promise permissionCheckerForWorld(worldName: string): Promise undeploy(worldName: string): Promise + getContributableDomains(address: string): Promise<{ domains: ContributorDomain[]; count: number }> } export type IPermissionsManager = { @@ -367,3 +368,10 @@ export type WorldRecord = { export type BlockedRecord = { wallet: string; created_at: Date; updated_at: Date } export const TWO_DAYS_IN_MS = 2 * 24 * 60 * 60 * 1000 + +export type ContributorDomain = { + name: string + user_permissions: string[] + owner: string + size: string +} diff --git a/test/integration/contributor-handler.spec.ts b/test/integration/contributor-handler.spec.ts new file mode 100644 index 00000000..246d23e3 --- /dev/null +++ b/test/integration/contributor-handler.spec.ts @@ -0,0 +1,139 @@ +import { Authenticator } from '@dcl/crypto' +import { test } from '../components' +import { IWorldCreator, IWorldsManager, Permissions, PermissionType } from '../../src/types' +import { defaultPermissions } from '../../src/logic/permissions-checker' +import { Identity, getIdentity, getAuthHeaders } from '../utils' + +test('ContributorHandler', function ({ components }) { + let worldCreator: IWorldCreator + let worldsManager: IWorldsManager + let identity: Identity + let worldName: string + let owner: string + + function makeRequest(path: string, identity: Identity) { + return components.localFetch.fetch(path, { + method: 'GET', + headers: { + ...getAuthHeaders( + 'get', + path, + { + origin: 'https://play.decentraland.org', + intent: 'dcl:explorer:comms-handshake', + signer: 'dcl:explorer', + isGuest: 'false' + }, + (payload) => + Authenticator.signPayload( + { + ephemeralIdentity: identity.ephemeralIdentity, + expiration: new Date(), + authChain: identity.authChain.authChain + }, + payload + ) + ) + } + }) + } + + beforeEach(async () => { + worldCreator = components.worldCreator + worldsManager = components.worldsManager + + identity = await getIdentity() + + const created = await worldCreator.createWorldWithScene({ owner: identity.authChain }) + worldName = created.worldName + owner = created.owner.authChain[0].payload.toLowerCase() + }) + + describe('/wallet/contribute', () => { + describe("when user doesn't have contributor permission to any world", () => { + it('returns an empty list', async () => { + const r = await makeRequest('/wallet/contribute', identity) + + expect(r.status).toBe(200) + expect(await r.json()).toMatchObject({ domains: [], count: 0 }) + }) + }) + + describe('when user has streamer permission to world', () => { + it('returns list of domains', async () => { + const permissions: Permissions = { + ...defaultPermissions(), + streaming: { + type: PermissionType.AllowList, + wallets: [identity.realAccount.address] + } + } + await worldsManager.storePermissions(worldName, permissions) + const r = await makeRequest('/wallet/contribute', identity) + + expect(r.status).toBe(200) + expect(await r.json()).toMatchObject({ + domains: [ + { + name: worldName, + user_permissions: ['streaming'], + owner, + size: '0' + } + ], + count: 1 + }) + }) + }) + + describe('when user has deployment permission to world', () => { + it('returns list of domains', async () => { + const permissions: Permissions = { + ...defaultPermissions(), + deployment: { + type: PermissionType.AllowList, + wallets: [identity.realAccount.address] + } + } + + await worldsManager.storePermissions(worldName, permissions) + const r = await makeRequest('/wallet/contribute', identity) + + expect(r.status).toBe(200) + const result = await r.json() + expect(result).toMatchObject({ + domains: [ + { + name: worldName, + user_permissions: ['deployment'], + owner, + size: '0' + } + ], + count: 1 + }) + }) + }) + + describe('when world streaming permission is unrestricted', () => { + it('return empty list', async () => { + const permissions: Permissions = { + ...defaultPermissions(), + streaming: { + type: PermissionType.Unrestricted + } + } + + await worldsManager.storePermissions(worldName, permissions) + const r = await makeRequest('/wallet/contribute', identity) + + expect(r.status).toBe(200) + const result = await r.json() + expect(result).toMatchObject({ + domains: [], + count: 0 + }) + }) + }) + }) +}) diff --git a/test/mocks/worlds-manager-mock.ts b/test/mocks/worlds-manager-mock.ts index 97b32621..f5b366dd 100644 --- a/test/mocks/worlds-manager-mock.ts +++ b/test/mocks/worlds-manager-mock.ts @@ -1,5 +1,6 @@ import { AppComponents, + ContributorDomain, IPermissionChecker, IWorldsManager, Permissions, @@ -121,7 +122,35 @@ export async function createWorldsManagerMockComponent({ await storage.delete([`name-${worldName.toLowerCase()}`]) } + async function getContributableDomains(address: string): Promise<{ domains: ContributorDomain[]; count: number }> { + const domains: ContributorDomain[] = [] + for await (const name of storage.allFileIds('name-')) { + const metadata = await getMetadataForWorld(name) + const entity = await getEntityForWorld(name) + if (entity) { + const content = await storage.retrieve(`${entity.id}.auth`) + const authChain = JSON.parse((await streamToBuffer(await content?.asStream())).toString()) + const hasDeploymentPermission = metadata.permissions.deployment.wallets.includes(address) + const hasStreamingPermission = + 'wallets' in metadata.permissions.streaming && metadata.permissions.streaming.wallets.includes(address) + if (hasStreamingPermission || hasStreamingPermission) { + domains.push({ + name, + user_permissions: [ + ...(hasDeploymentPermission ? ['deployment'] : []), + ...(hasStreamingPermission ? ['streaming'] : []) + ], + size: '0', + owner: authChain[0].payload + }) + } + } + } + return { domains, count: domains.length } + } + return { + getContributableDomains, getRawWorldRecords, getDeployedWorldCount, getDeployedWorldEntities,