diff --git a/api/spec/mocks/db-module.mock.ts b/api/spec/mocks/db-module.mock.ts new file mode 100644 index 00000000..edbd01e3 --- /dev/null +++ b/api/spec/mocks/db-module.mock.ts @@ -0,0 +1,52 @@ +/** + * A stand-in for the `Db` class, for specs that have to import a controller. + * + * Controllers import the services barrel, which instantiates every repository at + * module load. `RoleRepository`'s constructor issues a query straight away, so + * merely importing a controller opens a MySQL connection and an unhandled + * rejection when no database is running. Mocking the module keeps controller + * specs pure and runnable without a database. + * + * Usage, at the top of a spec file: + * + * jest.mock('../db/db.class', () => require('@spec/mocks/db-module.mock').mockDbModule()); + */ + +/** + * A query builder that is both awaitable (resolving to an empty result set) and + * infinitely chainable, so any repository call shape works without each spec + * having to enumerate knex's API. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function makeBuilder(): any { + // Untyped on purpose: `Proxy`'s target must be a real awaitable object here, + // and every property this mock's `get` trap can return is another + // infinitely-chainable builder -- there is no knex type this could satisfy. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const resolved: any = Promise.resolve([]); + return new Proxy(resolved, { + get(target, property) { + if (property === 'then' || property === 'catch' || property === 'finally') { + return target[property].bind(target); + } + return () => makeBuilder(); + }, + }); +} + +export function mockDbModule(): { Db: unknown } { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { Service } = require('typedi'); + + class MockDb { + constructor() { + return new Proxy({}, { get: () => makeBuilder() }); + } + } + + // The repositories are typedi services whose constructors ask the container for + // `Db`, so the replacement has to be registered exactly as the real class is. + Service()(MockDb); + + return { Db: MockDb }; +} diff --git a/api/spec/mocks/index.ts b/api/spec/mocks/index.ts index 72055e08..6278ff51 100644 --- a/api/spec/mocks/index.ts +++ b/api/spec/mocks/index.ts @@ -1 +1,2 @@ export * from './db.mock'; +export * from './db-module.mock'; diff --git a/api/src/controllers/admin.controller.ts b/api/src/controllers/admin.controller.ts index a9298ceb..fb04e0d2 100644 --- a/api/src/controllers/admin.controller.ts +++ b/api/src/controllers/admin.controller.ts @@ -12,9 +12,8 @@ import { MessageService, InboxService, MessageboardService, - ClubService + ClubService, } from '../services'; -import { Place } from 'models/place.model'; import * as badwordlist from 'badwords-list'; class AdminController { @@ -32,7 +31,7 @@ class AdminController { private clubService: ClubService, ) {} - public async addBan(request: Request, response: Response): Promise { + public async addBan(request: Request, response: Response): Promise { const session = this.memberService.decryptSession(request, response); if (!session) return; const admin = await this.memberService.canAdmin(session.id); @@ -59,7 +58,12 @@ class AdminController { const session = this.memberService.decryptSession(request, response); if (!session) return; const accessLevel = await this.memberService.getAccessLevel(session.id); - if (accessLevel === 'admin') { + // Pre-existing: `getAccessLevel` returns a list of levels, so this + // comparison has never been true and this branch has never run. + // Deliberately left inert -- turning it into `.includes(...)` would + // newly enable an access-gated path, which is not a change to make + // while fixing types. Raised separately for a decision. + if ((accessLevel as unknown as string) === 'admin') { try { await this.adminService.addDonor( request.body.member_id, @@ -75,7 +79,7 @@ class AdminController { } } - public async getBanHistory(request: Request, response: Response): Promise { + public async getBanHistory(request: Request, response: Response): Promise { const session = this.memberService.decryptSession(request, response); if (!session) return; const admin = await this.memberService.getAccessLevel(session.id); @@ -144,7 +148,12 @@ class AdminController { const session = this.memberService.decryptSession(request, response); if (!session) return; const accessLevel = await this.memberService.getAccessLevel(session.id); - if (accessLevel === 'admin') { + // Pre-existing: `getAccessLevel` returns a list of levels, so this + // comparison has never been true and this branch has never run. + // Deliberately left inert -- turning it into `.includes(...)` would + // newly enable an access-gated path, which is not a change to make + // while fixing types. Raised separately for a decision. + if ((accessLevel as unknown as string) === 'admin') { const currentLevel = await this .adminService .getDonor(Number(request.query.memberId)); @@ -154,7 +163,7 @@ class AdminController { } } - public async getRoleList(request: Request, response: Response): Promise { + public async getRoleList(request: Request, response: Response): Promise { const session = this.memberService.decryptSession(request, response); if (!session) return; const admin = await this.memberService.getAccessLevel(session.id); @@ -175,7 +184,7 @@ class AdminController { } } - public async hireRole(request: Request, response: Response): Promise { + public async hireRole(request: Request, response: Response): Promise { const session = this.memberService.decryptSession(request, response); if (!session) return; const accessLevel = await this.memberService.getAccessLevel(session.id); @@ -196,7 +205,7 @@ class AdminController { } } - public async searchUsers(request: Request, response: Response): Promise { + public async searchUsers(request: Request, response: Response): Promise { const session = this.memberService.decryptSession(request, response); if (!session) return; const admin = await this.memberService.getAccessLevel(session.id); @@ -217,7 +226,7 @@ class AdminController { } } - public async getTransactions(request: Request, response: Response): Promise { + public async getTransactions(request: Request, response: Response): Promise { const session = this.memberService.decryptSession(request, response); if (!session) return; const admin = await this.memberService.getAccessLevel(session.id); @@ -232,7 +241,7 @@ class AdminController { Number.parseInt(request.query.limit.toString()), Number.parseInt(request.query.offset.toString()), ); - findUsername = results.transactions + findUsername = results.transactions; for(const res of findUsername) { let sender = [{username: 'System'}]; let receiver = [{username: 'System'}]; @@ -262,7 +271,7 @@ class AdminController { } } - public async getTransactionsByWalletId(request: Request, response: Response): Promise { + public async getTransactionsByWalletId(request: Request, response: Response): Promise { const session = this.memberService.decryptSession(request, response); if (!session) return; const admin = await this.memberService.getAccessLevel(session.id); @@ -279,7 +288,7 @@ class AdminController { Number.parseInt(request.query.limit.toString()), Number.parseInt(request.query.offset.toString()), ); - findUsername = results.transactions + findUsername = results.transactions; for(const res of findUsername) { let sender = [{username: 'System'}]; let receiver = [{username: 'System'}]; @@ -309,7 +318,7 @@ class AdminController { } } - public async getObjectInstances(request: Request, response: Response): Promise { + public async getObjectInstances(request: Request, response: Response): Promise { const session = this.memberService.decryptSession(request, response); if (!session) return; const admin = await this.memberService.getAccessLevel(session.id); @@ -332,7 +341,7 @@ class AdminController { } } - public async getOwnedObjects(request: Request, response: Response): Promise { + public async getOwnedObjects(request: Request, response: Response): Promise { const session = this.memberService.decryptSession(request, response); if (!session) return; const admin = await this.memberService.getAccessLevel(session.id); @@ -354,7 +363,7 @@ class AdminController { } } - public async searchUserChat(request: Request, response: Response): Promise { + public async searchUserChat(request: Request, response: Response): Promise { const session = this.memberService.decryptSession(request, response); if (!session) return; const admin = await this.memberService.getAccessLevel(session.id); @@ -376,7 +385,7 @@ class AdminController { } } - public async getCommunityData(request: Request, response: Response): Promise { + public async getCommunityData(request: Request, response: Response): Promise { const session = this.memberService.decryptSession(request, response); if (!session) return; const admin = await this.memberService.getAccessLevel(session.id); @@ -393,7 +402,7 @@ class AdminController { } } - public async avatars(request: Request, response: Response): Promise { + public async avatars(request: Request, response: Response): Promise { const session = this.memberService.decryptSession(request, response); if (!session) return; const admin = await this.memberService.canAdmin(session.id); @@ -414,7 +423,7 @@ class AdminController { } } - public async avatarApprove(request: Request, response: Response): Promise { + public async avatarApprove(request: Request, response: Response): Promise { const session = this.memberService.decryptSession(request, response); if (!session) return; const admin = await this.memberService.canAdmin(session.id); @@ -432,7 +441,7 @@ class AdminController { response.status(400).json({error}); } } - public async avatarReject(request: Request, response: Response): Promise { + public async avatarReject(request: Request, response: Response): Promise { const session = this.memberService.decryptSession(request, response); if (!session) return; const admin = await this.memberService.canAdmin(session.id); @@ -451,7 +460,7 @@ class AdminController { } } - public async places(request: Request, response: Response): Promise { + public async places(request: Request, response: Response): Promise { const session = this.memberService.decryptSession(request, response); if (!session) return; const admin = await this.memberService.getAccessLevel(session.id); @@ -473,7 +482,7 @@ class AdminController { } } - public async searchAllPlaces(request: Request, response: Response): Promise { + public async searchAllPlaces(request: Request, response: Response): Promise { const session = this.memberService.decryptSession(request, response); if (!session) return; const admin = await this.memberService.getAccessLevel(session.id); @@ -503,7 +512,7 @@ class AdminController { } } - public async findUserPlaces(request: Request, response: Response): Promise { + public async findUserPlaces(request: Request, response: Response): Promise { const session = this.memberService.decryptSession(request, response); if (!session) return; const admin = await this.memberService.getAccessLevel(session.id); @@ -584,7 +593,7 @@ class AdminController { } } - public async objectssUpdate(request: Request, response: Response): Promise { + public async objectssUpdate(request: Request, response: Response): Promise { const session = this.memberService.decryptSession(request, response); if (!session) return; const admin = await this.memberService.canAdmin(session.id); @@ -618,7 +627,7 @@ class AdminController { price, limit, quantity, - status + status, ); } else { throw new Error ('Some details are blank. Please complete the form'); @@ -639,30 +648,30 @@ class AdminController { const admin = await this.memberService.canAdmin(session.id); if (admin) { const id = request.body.id; - try { - await this.objectInstanceService.moveAllObjects(id); - await this.objectService.removeAccount(id); - await this.messageService.removeAllMessages(id); - await this.inboxService.removeAllMessages(id); - await this.messageboardService.removeAllMessages(id); - await this.avatarService.removeAllAvatars(id); - await this.clubService.removeAccount(id); - const places = await this.placeService.getOwnedPlaces(id); - if(places.length >= 1) { - const home = places.find(place => place.type === 'home'); - if(home){ - await this.placeService.removeVirtualPet(home.id); - } + try { + await this.objectInstanceService.moveAllObjects(id); + await this.objectService.removeAccount(id); + await this.messageService.removeAllMessages(id); + await this.inboxService.removeAllMessages(id); + await this.messageboardService.removeAllMessages(id); + await this.avatarService.removeAllAvatars(id); + await this.clubService.removeAccount(id); + const places = await this.placeService.getOwnedPlaces(id); + if(places.length >= 1) { + const home = places.find(place => place.type === 'home'); + if(home){ + await this.placeService.removeVirtualPet(home.id); + } - places.forEach(place => { - this.placeService.removePlace(place.id); - }); + places.forEach(place => { + this.placeService.removePlace(place.id); + }); + } + await this.memberService.removeAccount(id); + response.status(200).json({ status: 'success' }); + } catch { + response.status(400).json({error: 'Error moving objects.'}); } - await this.memberService.removeAccount(id); - response.status(200).json({ status: 'success' }); - } catch { - response.status(400).json({error: 'Error moving objects.'}); - } } } } diff --git a/api/src/controllers/mall.controller.spec.ts b/api/src/controllers/mall.controller.spec.ts new file mode 100644 index 00000000..dd61b5ae --- /dev/null +++ b/api/src/controllers/mall.controller.spec.ts @@ -0,0 +1,859 @@ +import { Request, Response } from 'express'; +import { createSpyObj } from 'jest-createspyobj'; + +// Importing a controller pulls in the services barrel, which instantiates every +// repository - and RoleRepository queries on construction. Without this the spec +// would try to open a real MySQL connection. +jest.mock('../db/db.class', () => + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('@spec/mocks/db-module.mock').mockDbModule()); + +import { MallController } from './mall.controller'; +import { + InboxService, + MallExportService, + MallInspectionService, + MallService, + MemberService, + ObjectInstanceService, + ObjectService, + WalletService, +} from '../services'; +import { PlaceRepository } from '../repositories'; +import { EXPORT_ERROR_CODES, MAX_DURATION_MS } from '../services/mall-export/mall-export.service'; + +/** + * A response double. + * + * Typed as an Express `Response` so it can be handed to the real handlers, and + * intersected with the jest mocks the assertions read back. The single cast in + * the factory is the honest part: this object only implements what the handlers + * under test actually call. + */ +type MockResponse = jest.Mocked & { + headers: { [name: string]: string }; +}; + +function mockResponse(): MockResponse { + const response = {} as MockResponse; + response.status = jest.fn().mockReturnValue(response); + response.json = jest.fn().mockReturnValue(response); + response.send = jest.fn().mockReturnValue(response); + response.headers = {}; + response.setHeader = jest.fn((name: string, value: string) => { + response.headers[name] = value; + return response; + }) as unknown as MockResponse['setHeader']; + return response; +} + +/** A request double, carrying only what the handlers under test read. */ +type MockRequest = Request; + +function request( + params: { [key: string]: string } = {}, + query: { [key: string]: string } = {}, + apitoken = 'staff-token', +): MockRequest { + return { params, query, headers: { apitoken } } as unknown as MockRequest; +} + +const INSPECTION = { + object: { id: 3339, name: 'Pocket Moon Playset' }, + source: { encoding: 'gzip', storedBytes: 23002, decodedBytes: 108310 }, + vrml: { header: '#VRML V2.0 utf8' }, + findings: [], +}; + +describe('MallController - staff-only inspection endpoints', () => { + let memberService: jest.Mocked; + let mallService: jest.Mocked; + let objectService: jest.Mocked; + let walletService: jest.Mocked; + let objectInstanceService: jest.Mocked; + let mallInspectionService: jest.Mocked; + let mallExportService: jest.Mocked; + let inboxService: jest.Mocked; + let placeRepository: jest.Mocked; + let controller: MallController; + + beforeEach(() => { + memberService = createSpyObj(MemberService); + mallService = createSpyObj(MallService); + objectService = createSpyObj(ObjectService); + walletService = createSpyObj(WalletService); + objectInstanceService = createSpyObj(ObjectInstanceService); + mallInspectionService = createSpyObj(MallInspectionService); + mallExportService = createSpyObj(MallExportService); + inboxService = createSpyObj(InboxService); + placeRepository = createSpyObj(PlaceRepository); + controller = new MallController( + memberService, + mallService, + objectService, + walletService, + objectInstanceService, + mallInspectionService, + mallExportService, + inboxService, + placeRepository, + ); + + memberService.decodeMemberToken.mockReturnValue({ id: 7 } as never); + mallService.canAdmin.mockResolvedValue(true); + }); + + describe('the staff guard', () => { + /** + * decodeMemberToken THROWS on a missing or malformed token, it does not + * return null. A guard that only handles the null case lets the rejection + * escape the async handler, and Express then never responds - the request + * hangs rather than being denied. Every staff endpoint is checked, because + * the failure is invisible unless the mock throws the way the real service + * does. + */ + beforeEach(() => { + memberService.decodeMemberToken.mockImplementation(() => { + throw new Error('jwt must be provided'); + }); + }); + + it('denies rather than hangs when the token decode throws', async () => { + const response = mockResponse(); + + await controller.getObjectInspection(request({ id: '3339' }, {}, undefined), response); + + expect(response.status).toHaveBeenCalledWith(400); + expect(response.json).toHaveBeenCalledWith({ + error: 'Invalid or missing token or access denied.', + }); + expect(mallInspectionService.inspect).not.toHaveBeenCalled(); + }); + + it('denies rather than hangs on the source endpoint', async () => { + const response = mockResponse(); + + await controller.getObjectSource(request({ id: '3339' }, {}, undefined), response); + + expect(response.status).toHaveBeenCalledWith(400); + expect(mallInspectionService.readSourceText).not.toHaveBeenCalled(); + }); + + it('denies rather than hangs on the export endpoint', async () => { + const response = mockResponse(); + response.end = jest.fn(); + response.write = jest.fn().mockReturnValue(true); + + await controller.exportMallData(request({}, {}, undefined), response); + + expect(response.status).toHaveBeenCalledWith(400); + expect(mallExportService.export).not.toHaveBeenCalled(); + expect(response.write).not.toHaveBeenCalled(); + }); + + it('answers every staff endpoint rather than leaving any unanswered', async () => { + const responses = [mockResponse(), mockResponse(), mockResponse()]; + responses[2].end = jest.fn(); + responses[2].write = jest.fn().mockReturnValue(true); + + await controller.getObjectInspection(request({ id: '1' }, {}, undefined), responses[0]); + await controller.getObjectSource(request({ id: '1' }, {}, undefined), responses[1]); + await controller.exportMallData(request({}, {}, undefined), responses[2]); + + responses.forEach(response => { + expect(response.status).toHaveBeenCalledWith(400); + expect(response.json).toHaveBeenCalled(); + }); + }); + }); + + describe('getObjectInspection', () => { + it('returns 200 with the inspection for a Mall staff member', async () => { + const response = mockResponse(); + mallInspectionService.inspect.mockResolvedValue(INSPECTION as never); + + await controller.getObjectInspection(request({ id: '3339' }), response); + + expect(mallInspectionService.inspect).toHaveBeenCalledWith(3339); + expect(response.status).toHaveBeenCalledWith(200); + expect(response.json).toHaveBeenCalledWith({ status: 'success', inspection: INSPECTION }); + }); + + it('denies an ordinary member and never reaches the service', async () => { + const response = mockResponse(); + mallService.canAdmin.mockResolvedValue(false); + + await controller.getObjectInspection(request({ id: '3339' }), response); + + expect(response.status).toHaveBeenCalledWith(400); + expect(response.json).toHaveBeenCalledWith({ + error: 'Invalid or missing token or access denied.', + }); + expect(mallInspectionService.inspect).not.toHaveBeenCalled(); + }); + + it('denies a request with no valid token and never reaches the service', async () => { + const response = mockResponse(); + memberService.decodeMemberToken.mockReturnValue(null as never); + + await controller.getObjectInspection(request({ id: '3339' }, {}, undefined), response); + + expect(response.status).toHaveBeenCalledWith(400); + expect(mallService.canAdmin).not.toHaveBeenCalled(); + expect(mallInspectionService.inspect).not.toHaveBeenCalled(); + }); + + it('returns 404 for an object that does not exist', async () => { + const response = mockResponse(); + mallInspectionService.inspect.mockResolvedValue(null as never); + + await controller.getObjectInspection(request({ id: '999999' }), response); + + expect(response.status).toHaveBeenCalledWith(404); + }); + + it('returns 400 for a non-numeric id without touching the service', async () => { + const response = mockResponse(); + + await controller.getObjectInspection(request({ id: 'not-a-number' }), response); + + expect(response.status).toHaveBeenCalledWith(400); + expect(mallInspectionService.inspect).not.toHaveBeenCalled(); + }); + }); + + describe('getObjectSource', () => { + beforeEach(() => { + mallInspectionService.readSourceText.mockResolvedValue({ + text: '#VRML V2.0 utf8\n', + error: null, + } as never); + }); + + it('returns the decoded VRML as UTF-8 plain text with nosniff', async () => { + const response = mockResponse(); + + await controller.getObjectSource(request({ id: '3339' }), response); + + expect(response.headers['Content-Type']).toBe('text/plain; charset=utf-8'); + expect(response.headers['X-Content-Type-Options']).toBe('nosniff'); + expect(response.status).toHaveBeenCalledWith(200); + expect(response.send).toHaveBeenCalledWith('#VRML V2.0 utf8\n'); + }); + + it('does not offer a download unless asked', async () => { + const response = mockResponse(); + + await controller.getObjectSource(request({ id: '3339' }), response); + + expect(response.headers['Content-Disposition']).toBeUndefined(); + }); + + it('names the download from the object id, never from member-supplied data', + async () => { + const response = mockResponse(); + + await controller.getObjectSource(request({ id: '3339' }, { download: '1' }), response); + + expect(response.headers['Content-Disposition']) + .toBe('attachment; filename="object-3339.wrl"'); + }); + + it('cannot be made to emit a header containing quotes, CRLF or path characters', + async () => { + // The object's own name is member-supplied and is never consulted here, so + // a hostile name cannot reach the header at all. + const response = mockResponse(); + + await controller.getObjectSource( + request({ id: '42' }, { download: '1' }), + response, + ); + + const disposition: string = response.headers['Content-Disposition']; + expect(disposition).toBe('attachment; filename="object-42.wrl"'); + expect(disposition).not.toMatch(/[\r\n]/); + expect(disposition.match(/"/g)).toHaveLength(2); + }); + + it('denies an ordinary member and never reads the file', async () => { + const response = mockResponse(); + mallService.canAdmin.mockResolvedValue(false); + + await controller.getObjectSource(request({ id: '3339' }), response); + + expect(response.status).toHaveBeenCalledWith(400); + expect(mallInspectionService.readSourceText).not.toHaveBeenCalled(); + expect(response.send).not.toHaveBeenCalled(); + }); + + it('returns 404 when the object or its file is gone', async () => { + const response = mockResponse(); + mallInspectionService.readSourceText.mockResolvedValue({ + text: null, + error: 'not_found', + } as never); + + await controller.getObjectSource(request({ id: '3339' }), response); + + expect(response.status).toHaveBeenCalledWith(404); + expect(response.send).not.toHaveBeenCalled(); + }); + + it('returns 422 with the reason when the file cannot be decoded', async () => { + const response = mockResponse(); + mallInspectionService.readSourceText.mockResolvedValue({ + text: null, + error: 'gzip_corrupt', + } as never); + + await controller.getObjectSource(request({ id: '3339' }), response); + + expect(response.status).toHaveBeenCalledWith(422); + expect(response.json).toHaveBeenCalledWith({ error: 'gzip_corrupt' }); + }); + + it('returns 400 for a non-numeric id without reading anything', async () => { + const response = mockResponse(); + + await controller.getObjectSource(request({ id: 'nope' }), response); + + expect(response.status).toHaveBeenCalledWith(400); + expect(mallInspectionService.readSourceText).not.toHaveBeenCalled(); + }); + }); + + describe('exportMallData', () => { + function streamingResponse() { + const response = mockResponse(); + response.end = jest.fn(); + response.write = jest.fn().mockReturnValue(true); + response.once = jest.fn(); + return response; + } + + it('streams for a Mall staff member and closes the response', async () => { + const response = streamingResponse(); + mallExportService.export.mockResolvedValue('complete' as never); + + await controller.exportMallData(request({}, {}), response); + + expect(mallExportService.export).toHaveBeenCalledTimes(1); + expect(response.headers['Content-Type']).toBe('application/json; charset=utf-8'); + expect(response.headers['X-Content-Type-Options']).toBe('nosniff'); + expect(response.status).toHaveBeenCalledWith(200); + expect(response.end).toHaveBeenCalled(); + }); + + it('defaults to the cheap mode, with no WRL reads', async () => { + const response = streamingResponse(); + mallExportService.export.mockResolvedValue('complete' as never); + + await controller.exportMallData(request({}, {}), response); + + expect(mallExportService.export.mock.calls[0][1]) + .toEqual(expect.objectContaining({ includeDerived: false })); + }); + + it('opts into derived metadata only when explicitly asked', async () => { + const response = streamingResponse(); + mallExportService.export.mockResolvedValue('complete' as never); + + await controller.exportMallData(request({}, { derived: '1' }), response); + + expect(mallExportService.export.mock.calls[0][1]) + .toEqual(expect.objectContaining({ includeDerived: true })); + }); + + it('denies an ordinary member before a single byte is written', async () => { + const response = streamingResponse(); + mallService.canAdmin.mockResolvedValue(false); + + await controller.exportMallData(request({}, {}), response); + + expect(response.status).toHaveBeenCalledWith(400); + expect(mallExportService.export).not.toHaveBeenCalled(); + expect(response.write).not.toHaveBeenCalled(); + expect(response.end).not.toHaveBeenCalled(); + }); + + it('still closes the response when the export throws', async () => { + const response = streamingResponse(); + mallExportService.export.mockRejectedValue(new Error('boom') as never); + + await controller.exportMallData(request({}, {}), response); + + expect(response.end).toHaveBeenCalled(); + }); + + it('captures the export deadline before preflight runs, and threads it through', + async () => { + const response = streamingResponse(); + mallExportService.export.mockResolvedValue('complete' as never); + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1_000_000); + + await controller.exportMallData(request({}, {}), response); + + expect(mallExportService.export.mock.calls[0][1]).toEqual( + expect.objectContaining({ startedAt: 1_000_000 }), + ); + + nowSpy.mockRestore(); + }); + + it('fails safely, before the response starts, when preflight alone exhausts the budget', + async () => { + const response = streamingResponse(); + let now = 0; + const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => now); + // Simulates preflight itself taking longer than the whole export + // budget: nothing has counted against MAX_DURATION_MS until now, so a + // clock reset here would give the streaming body a fresh budget on + // top of it. + mallExportService.preflight.mockImplementation(async () => { + now = MAX_DURATION_MS + 1; + return {} as never; + }); + + await controller.exportMallData(request({}, {}), response); + + expect(response.status).toHaveBeenCalledWith(503); + expect(response.json).toHaveBeenCalledWith({ error: EXPORT_ERROR_CODES.budgetExceeded }); + expect(mallExportService.export).not.toHaveBeenCalled(); + expect(response.write).not.toHaveBeenCalled(); + expect(response.end).not.toHaveBeenCalled(); + + nowSpy.mockRestore(); + }); + }); + + describe('object id validation - the whole parameter, not a prefix of it', () => { + const REJECTED = ['3339x', '3339-not-an-id', '12.5', '-1', '0', '', 'abc']; + + it('rejects anything that is not wholly a positive integer, for inspection', async () => { + for (const id of REJECTED) { + const response = mockResponse(); + + await controller.getObjectInspection(request({ id }), response); + + expect(response.status).toHaveBeenCalledWith(400); + } + expect(mallInspectionService.inspect).not.toHaveBeenCalled(); + }); + + it('rejects anything that is not wholly a positive integer, for source', async () => { + for (const id of REJECTED) { + const response = mockResponse(); + + await controller.getObjectSource(request({ id }), response); + + expect(response.status).toHaveBeenCalledWith(400); + } + }); + + it('still accepts an ordinary id', async () => { + const response = mockResponse(); + + await controller.getObjectInspection(request({ id: '3339' }), response); + + expect(response.status).not.toHaveBeenCalledWith(400); + expect(mallInspectionService.inspect).toHaveBeenCalledWith(3339); + }); + }); + + describe('rejectObject - the uploader is told why', () => { + const OBJECT = { + id: 3339, + name: 'Celestial Windchime1', + member_id: 42, + quantity: 25, + price: 75, + status: 2, + }; + + function rejectRequest(body: { [key: string]: unknown } = {}): MockRequest { + return { + headers: { apitoken: 'staff-token' }, + body: { id: '3339', reason: 'WorldInfo says unlimited, the Mall limit says 25.', ...body }, + } as unknown as MockRequest; + } + + beforeEach(() => { + objectService.findById.mockResolvedValue({ ...OBJECT } as never); + objectService.getSellerFee.mockReturnValue(100 as never); + // The refund, the status change and the concurrency guard now live inside + // one transaction in ObjectService, proven against a real database in + // object.service.atomic.spec.ts. What is left to prove here is that the + // controller maps each outcome to the right response, and only notifies + // after a rejection actually committed. + objectService.rejectPendingObject.mockResolvedValue( + { outcome: ObjectService.REJECT_REJECTED, object: { ...OBJECT } } as never, + ); + placeRepository.findHomeByMemberId.mockResolvedValue({ id: 909 } as never); + inboxService.sanitize.mockImplementation((value: string) => Promise.resolve(value) as never); + inboxService.postInboxMessage.mockResolvedValue(undefined as never); + }); + + it('refuses a blank reason without touching the object', async () => { + const response = mockResponse(); + + await controller.rejectObject(rejectRequest({ reason: '' }), response); + + expect(response.status).toHaveBeenCalledWith(400); + expect(objectService.rejectPendingObject).not.toHaveBeenCalled(); + expect(inboxService.postInboxMessage).not.toHaveBeenCalled(); + }); + + it('refuses a whitespace-only reason', async () => { + const response = mockResponse(); + + await controller.rejectObject(rejectRequest({ reason: ' \n\t ' }), response); + + expect(response.status).toHaveBeenCalledWith(400); + expect(objectService.rejectPendingObject).not.toHaveBeenCalled(); + }); + + it('refuses a reason longer than the accepted maximum rather than truncating', async () => { + const response = mockResponse(); + + await controller.rejectObject(rejectRequest({ reason: 'x'.repeat(2001) }), response); + + expect(response.status).toHaveBeenCalledWith(400); + expect(objectService.rejectPendingObject).not.toHaveBeenCalled(); + expect(inboxService.postInboxMessage).not.toHaveBeenCalled(); + }); + + it('completes the rejection before reporting success', async () => { + const response = mockResponse(); + + await controller.rejectObject(rejectRequest(), response); + + expect(objectService.rejectPendingObject).toHaveBeenCalledWith(3339); + expect(response.status).toHaveBeenCalledWith(200); + expect(response.json).toHaveBeenCalledWith({ status: 'success', notified: true }); + }); + + it('refuses an object that is not a pending submission', async () => { + objectService.rejectPendingObject.mockResolvedValue( + { outcome: ObjectService.REJECT_INVALID_STATE, object: { ...OBJECT } } as never, + ); + const response = mockResponse(); + + await controller.rejectObject(rejectRequest(), response); + + // Staff authorisation is not a licence to refund a stocked object because + // a stale page asked for it. + expect(response.status).toHaveBeenCalledWith(400); + expect(response.json).toHaveBeenCalledWith({ + error: 'Only a pending object can be rejected.', + }); + expect(inboxService.postInboxMessage).not.toHaveBeenCalled(); + }); + + it('refuses an object id that does not exist', async () => { + objectService.rejectPendingObject.mockResolvedValue( + { outcome: ObjectService.REJECT_NOT_FOUND, object: null } as never, + ); + const response = mockResponse(); + + await controller.rejectObject(rejectRequest(), response); + + expect(response.status).toHaveBeenCalledWith(400); + expect(inboxService.postInboxMessage).not.toHaveBeenCalled(); + }); + + it('reports no success and sends no notice when the transaction fails', async () => { + objectService.rejectPendingObject + .mockRejectedValue(new Error('deadlock') as never); + const response = mockResponse(); + + await controller.rejectObject(rejectRequest(), response); + + // The transaction rolled back, so nothing was refunded and nothing was + // rejected; telling the uploader anything would be a lie. + expect(response.status).not.toHaveBeenCalledWith(200); + expect(inboxService.postInboxMessage).not.toHaveBeenCalled(); + }); + + it('sends exactly one notice, to the uploader home, with the generated subject', async () => { + const response = mockResponse(); + + await controller.rejectObject(rejectRequest(), response); + + expect(placeRepository.findHomeByMemberId).toHaveBeenCalledWith(42); + expect(inboxService.postInboxMessage).toHaveBeenCalledTimes(1); + expect(inboxService.postInboxMessage).toHaveBeenCalledWith( + 7, + 909, + 'rejected - Celestial Windchime1', + 'WorldInfo says unlimited, the Mall limit says 25.', + ); + }); + + it('ignores a recipient, subject or object name supplied by the browser', async () => { + const response = mockResponse(); + + await controller.rejectObject(rejectRequest({ + recipient: 1, + member_id: 1, + place_id: 1, + subject: 'rejected - something else', + name: 'Spoofed Name', + }), response); + + expect(placeRepository.findHomeByMemberId).toHaveBeenCalledWith(42); + expect(inboxService.postInboxMessage).toHaveBeenCalledWith( + 7, + 909, + 'rejected - Celestial Windchime1', + expect.any(String), + ); + }); + + it('trims the reason it delivers', async () => { + const response = mockResponse(); + + const padded = rejectRequest({ reason: ' please fix the price ' }); + await controller.rejectObject(padded, response); + + expect(inboxService.postInboxMessage).toHaveBeenCalledWith( + 7, 909, expect.any(String), 'please fix the price', + ); + }); + + it('keeps a punctuated or unicode object name intact but never lets it break the subject', + async () => { + objectService.rejectPendingObject.mockResolvedValue({ + outcome: ObjectService.REJECT_REJECTED, + object: { ...OBJECT, name: 'Café "Deluxe" — v2\nInjected' }, + } as never); + const response = mockResponse(); + + await controller.rejectObject(rejectRequest(), response); + + const subject = inboxService.postInboxMessage.mock.calls[0][2] as string; + expect(subject).toBe('rejected - Café "Deluxe" — v2 Injected'); + expect(subject).not.toMatch(/[\r\n]/); + }); + + it('still rejects the object when the uploader can no longer be notified', async () => { + placeRepository.findHomeByMemberId.mockResolvedValue(undefined as never); + const response = mockResponse(); + + await controller.rejectObject(rejectRequest(), response); + + expect(objectService.rejectPendingObject).toHaveBeenCalledWith(3339); + expect(response.json).toHaveBeenCalledWith({ status: 'success', notified: false }); + }); + + it('does not claim a notification that the inbox refused', async () => { + inboxService.postInboxMessage.mockRejectedValue(new Error('inbox down') as never); + const response = mockResponse(); + + await controller.rejectObject(rejectRequest(), response); + + // Reported honestly rather than as a 500: the refund already happened and + // a retry would repeat it. + expect(response.status).toHaveBeenCalledWith(200); + expect(response.json).toHaveBeenCalledWith({ status: 'success', notified: false }); + }); + + it('does not refund or notify twice when the object is already rejected', async () => { + objectService.rejectPendingObject.mockResolvedValue( + { + outcome: ObjectService.REJECT_ALREADY_REJECTED, + object: { ...OBJECT, status: 0 }, + } as never, + ); + const response = mockResponse(); + + await controller.rejectObject(rejectRequest(), response); + + expect(inboxService.postInboxMessage).not.toHaveBeenCalled(); + expect(response.json).toHaveBeenCalledWith( + { status: 'success', notified: false, alreadyRejected: true }, + ); + }); + + it('denies a member who is not Mall staff before reading the object', async () => { + mallService.canAdmin.mockResolvedValue(false as never); + const response = mockResponse(); + + await controller.rejectObject(rejectRequest(), response); + + expect(response.status).toHaveBeenCalledWith(400); + expect(objectService.rejectPendingObject).not.toHaveBeenCalled(); + expect(inboxService.postInboxMessage).not.toHaveBeenCalled(); + }); + }); + + describe('approveObject - the uploader is told it was accepted', () => { + const OBJECT = { + id: 3339, + name: 'Celestial Windchime1', + member_id: 42, + quantity: 25, + price: 75, + status: 2, + }; + + function approveRequest(body: { [key: string]: unknown } = {}): MockRequest { + return { + headers: { apitoken: 'staff-token' }, + body: { objectId: '3339', ...body }, + } as unknown as MockRequest; + } + + beforeEach(() => { + objectService.approvePendingObject.mockResolvedValue( + { outcome: ObjectService.REJECT_REJECTED, object: { ...OBJECT } } as never, + ); + placeRepository.findHomeByMemberId.mockResolvedValue({ id: 909 } as never); + inboxService.sanitize.mockImplementation((value: string) => Promise.resolve(value) as never); + inboxService.postInboxMessage.mockResolvedValue(undefined as never); + }); + + it('sends exactly one notice, to the uploader home, with the generated subject', + async () => { + const response = mockResponse(); + + await controller.approveObject(approveRequest(), response); + + expect(placeRepository.findHomeByMemberId).toHaveBeenCalledWith(42); + expect(inboxService.postInboxMessage).toHaveBeenCalledTimes(1); + + const [sender, place, subject, body] = inboxService.postInboxMessage.mock.calls[0]; + expect(sender).toBe(7); + expect(place).toBe(909); + expect(subject).toBe('accepted - Celestial Windchime1'); + expect(body).toContain('Celestial Windchime1'); + expect(body).toContain('Coming Soon'); + expect(body).toContain('Warehouse'); + // No calendar date is invented: there is no authoritative next-drop + // date in this workflow, so the notice must not promise one. + expect(body).not.toMatch(/\b(19|20)\d\d\b/); + expect(response.json).toHaveBeenCalledWith({ status: 'success', notified: true }); + }); + + it('ignores a recipient, subject or object name supplied by the browser', async () => { + const response = mockResponse(); + + await controller.approveObject(approveRequest({ + member_id: 1, + place_id: 1, + subject: 'accepted - something else', + name: 'Something Else', + }), response); + + expect(inboxService.postInboxMessage).toHaveBeenCalledWith( + 7, + 909, + 'accepted - Celestial Windchime1', + expect.any(String), + ); + }); + + it('keeps control characters in a stored name out of the subject line', async () => { + objectService.approvePendingObject.mockResolvedValue( + { + outcome: ObjectService.REJECT_REJECTED, + object: { ...OBJECT, name: 'Wind\r\nchime\tTwo' }, + } as never, + ); + const response = mockResponse(); + + await controller.approveObject(approveRequest(), response); + + const subject = inboxService.postInboxMessage.mock.calls[0][2] as string; + expect(subject).toBe('accepted - Wind chime Two'); + }); + + it('refuses an object that is not a pending submission, and notifies no one', async () => { + objectService.approvePendingObject.mockResolvedValue( + { outcome: ObjectService.REJECT_INVALID_STATE, object: { ...OBJECT } } as never, + ); + const response = mockResponse(); + + await controller.approveObject(approveRequest(), response); + + expect(response.status).toHaveBeenCalledWith(400); + expect(inboxService.postInboxMessage).not.toHaveBeenCalled(); + }); + + it('refuses an object id that does not exist, and notifies no one', async () => { + objectService.approvePendingObject.mockResolvedValue( + { outcome: ObjectService.REJECT_NOT_FOUND, object: null } as never, + ); + const response = mockResponse(); + + await controller.approveObject(approveRequest(), response); + + expect(response.status).toHaveBeenCalledWith(400); + expect(inboxService.postInboxMessage).not.toHaveBeenCalled(); + }); + + it('sends no notice when the transition itself failed', async () => { + objectService.approvePendingObject.mockRejectedValue(new Error('deadlock') as never); + const response = mockResponse(); + + await controller.approveObject(approveRequest(), response); + + // Nothing moved, so telling the uploader their item was accepted would + // be a lie. + expect(response.status).not.toHaveBeenCalledWith(200); + expect(inboxService.postInboxMessage).not.toHaveBeenCalled(); + }); + + it('does not roll the acceptance back when the notice cannot be delivered', async () => { + inboxService.postInboxMessage.mockRejectedValue(new Error('inbox down') as never); + const response = mockResponse(); + + await controller.approveObject(approveRequest(), response); + + // Reported honestly rather than as a 500: the transition already + // committed and a retry would answer with a bare success. + expect(response.status).toHaveBeenCalledWith(200); + expect(response.json).toHaveBeenCalledWith({ status: 'success', notified: false }); + }); + + it('reports notified:false when the uploader has no home to deliver to', async () => { + placeRepository.findHomeByMemberId.mockResolvedValue(undefined as never); + const response = mockResponse(); + + await controller.approveObject(approveRequest(), response); + + expect(inboxService.postInboxMessage).not.toHaveBeenCalled(); + expect(response.json).toHaveBeenCalledWith({ status: 'success', notified: false }); + }); + + it('does not notify twice when a concurrent Accept already won the race', async () => { + objectService.approvePendingObject.mockResolvedValue( + { + outcome: ObjectService.REJECT_ALREADY_REJECTED, + object: { ...OBJECT, status: 3 }, + } as never, + ); + const response = mockResponse(); + + await controller.approveObject(approveRequest(), response); + + // This request performed no transition, so it must not send a second + // acceptance notice for the one acceptance that did happen. + expect(inboxService.postInboxMessage).not.toHaveBeenCalled(); + expect(response.json).toHaveBeenCalledWith( + { status: 'success', notified: false, alreadyAccepted: true }, + ); + }); + + it('denies a member who is not Mall staff before touching the object', async () => { + mallService.canAdmin.mockResolvedValue(false as never); + const response = mockResponse(); + + await controller.approveObject(approveRequest(), response); + + expect(response.status).toHaveBeenCalledWith(400); + expect(objectService.approvePendingObject).not.toHaveBeenCalled(); + expect(inboxService.postInboxMessage).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/api/src/controllers/mall.controller.ts b/api/src/controllers/mall.controller.ts index c25cd510..a199d5de 100644 --- a/api/src/controllers/mall.controller.ts +++ b/api/src/controllers/mall.controller.ts @@ -4,20 +4,232 @@ import { Container } from 'typedi'; import { MemberService, MallService, + MallExportService, + MallInspectionService, ObjectService, WalletService, ObjectInstanceService, + InboxService, } from '../services'; +import { PlaceRepository } from '../repositories'; +import { Object as ObjectModel } from '../types/models'; +import { + createResponseWriter, + EXPORT_ERROR_CODES, + exportFilename, + MAX_DURATION_MS, +} from '../services/mall-export/mall-export.service'; // Removed unused import -class MallController { +/** + * Reads a route object id, rejecting anything that is not wholly a positive + * integer. + * + * `parseInt` stops at the first character it cannot use, so `3339-not-an-id` + * reads as 3339 and the request quietly acts on a different object than the one + * named in the URL. + */ +/** + * Longest rejection reason accepted. + * + * The inbox body column is TEXT, so this is not a storage limit; it is a bound + * on staff-authored input, refused rather than silently truncated so the + * uploader never receives half an explanation. + */ +const MAX_REJECTION_REASON = 2000; + +function parseObjectId(value: string): number | null { + if (!/^[0-9]+$/.test(String(value || ''))) { + return null; + } + const parsed = Number.parseInt(value, 10); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; +} + +export class MallController { constructor( private memberService: MemberService, private mallService: MallService, private objectService: ObjectService, private walletService: WalletService, private objectInstanceService: ObjectInstanceService, + private mallInspectionService: MallInspectionService, + private mallExportService: MallExportService, + private inboxService: InboxService, + private placeRepository: PlaceRepository, ) {} + /** + * The Mall staff gate every staff-only endpoint goes through. + * + * Same token decode and same `canAdmin` role check the existing staff handlers + * perform inline, and the same response on failure, so authorisation behaviour + * stays identical across the whole mall API. + */ + private async requireMallStaff(request: Request, response: Response): Promise { + const { apitoken } = request.headers; + + try { + // decodeMemberToken THROWS on a missing or malformed token rather than + // returning null. Without this catch the rejection escapes the async + // handler, Express never sends a response, and the request hangs. + const session = this.memberService.decodeMemberToken(apitoken); + if (session && (await this.mallService.canAdmin(session.id))) { + return true; + } + } catch (error) { + // Fall through to the same denial the rest of the mall API returns. + } + + response.status(400).json({ + error: 'Invalid or missing token or access denied.', + }); + return false; + } + + /** + * Everything a checker needs about one object on a single screen: the CTR + * record, its counts and views, the stored file's real shape, its WorldInfo, + * and where the two disagree. + * + * Staff-only because it exposes the object's source facts. The existing + * `/mall/object/:id` and `/object/get_object/:id` endpoints deliberately keep + * their current, narrower payloads and their current authorisation. + */ + public async getObjectInspection(request: Request, response: Response): Promise { + if (!(await this.requireMallStaff(request, response))) { + return; + } + + const objectId = parseObjectId(request.params.id); + if (objectId === null) { + response.status(400).json({ error: 'Invalid object id.' }); + return; + } + + try { + const inspection = await this.mallInspectionService.inspect(objectId); + if (!inspection) { + response.status(404).json({ error: 'Object not found.' }); + return; + } + response.status(200).json({ status: 'success', inspection }); + } catch (error) { + console.error(error); + response.status(400).json({ error }); + } + } + + /** + * The decoded VRML text, so staff can read a gzip-compressed upload without + * downloading it and decompressing it by hand. + * + * The download filename is always the server-generated `object-.wrl`, never + * the member-supplied object name and never the stored filename. That keeps + * header injection, quoting, and non-ASCII encoding out of the picture + * entirely. + */ + /** + * Streams CTR's authoritative Mall dataset. + * + * The outcome is written at the END of the document as `result`, because the + * counts and per-object failures are not known until the work is finished. + * Once streaming has begun the HTTP status is already 200, so `result.status` + * is the authoritative outcome and consumers must check it. Failures detected + * before the first byte - authorisation, most obviously - still return a + * normal error status with no body at all. + */ + public async exportMallData(request: Request, response: Response): Promise { + if (!(await this.requireMallStaff(request, response))) { + return; + } + + const includeDerived = request.query.derived === '1'; + + // Captured before preflight, not after: preflight runs real queries and is + // not free, so it must count against the advertised budget too. Resetting + // the clock here would let an expensive preflight run for free and hand + // the streaming body a fresh MAX_DURATION_MS on top of it. + const startedAt = Date.now(); + + // Everything global runs before the response is committed, so a failure here + // is an ordinary error rather than a half-written document the client would + // have to detect by failing to parse it. + let preflight; + try { + preflight = await this.mallExportService.preflight(); + } catch (error) { + console.error(error); + response.status(500).json({ error: EXPORT_ERROR_CODES.preflightFailed }); + return; + } + + // Preflight alone already exhausted the budget: no document could finish + // from here, so the honest response is an ordinary failure before the body + // starts, not a stream nothing will ever complete. + if (Date.now() - startedAt > MAX_DURATION_MS) { + response.status(503).json({ error: EXPORT_ERROR_CODES.budgetExceeded }); + return; + } + + response.setHeader('Content-Type', 'application/json; charset=utf-8'); + response.setHeader('X-Content-Type-Options', 'nosniff'); + response.setHeader( + 'Content-Disposition', + `attachment; filename="${exportFilename(new Date())}"`, + ); + response.status(200); + + try { + await this.mallExportService.export( + createResponseWriter(response), + { includeDerived, startedAt }, + preflight, + ); + } catch (error) { + console.error(error); + } finally { + response.end(); + } + } + + public async getObjectSource(request: Request, response: Response): Promise { + if (!(await this.requireMallStaff(request, response))) { + return; + } + + const objectId = parseObjectId(request.params.id); + if (objectId === null) { + response.status(400).json({ error: 'Invalid object id.' }); + return; + } + + try { + const source = await this.mallInspectionService.readSourceText(objectId); + + if (source.error === 'not_found' || source.error === 'missing') { + response.status(404).json({ error: 'Object source not found.' }); + return; + } + if (source.error !== null || source.text === null) { + response.status(422).json({ error: source.error || 'unreadable' }); + return; + } + + response.setHeader('Content-Type', 'text/plain; charset=utf-8'); + response.setHeader('X-Content-Type-Options', 'nosniff'); + if (request.query.download === '1') { + response.setHeader( + 'Content-Disposition', + `attachment; filename="object-${objectId}.wrl"`, + ); + } + response.status(200).send(source.text); + } catch (error) { + console.error(error); + response.status(400).json({ error }); + } + } + public async canAdmin(request: Request, response: Response): Promise { const { apitoken } = request.headers; @@ -68,7 +280,7 @@ class MallController { } } - public async getObjectsCatalog(request: Request, response: Response): Promise { + public async getObjectsCatalog(request: Request, response: Response): Promise { const session = this.memberService.decryptSession(request, response); if (!session) return; try { @@ -83,7 +295,7 @@ class MallController { } } - public async searchMallObjects(request: Request, response: Response): Promise { + public async searchMallObjects(request: Request, response: Response): Promise { const session = this.memberService.decryptSession(request, response); if (!session) return; const admin = await this.mallService.canAdmin(session.id); @@ -105,7 +317,7 @@ class MallController { } } - public async searchAllObjects(request: Request, response: Response): Promise { + public async searchAllObjects(request: Request, response: Response): Promise { const session = this.memberService.decryptSession(request, response); if (!session) return; const admin = await this.mallService.canAdmin(session.id); @@ -204,6 +416,51 @@ class MallController { } } + /** + * Tells an uploader their object was accepted. + * + * Deliberately the same architecture as `notifyRejection`: the uploader, their + * home place and the object's name are all resolved server-side from the row + * the moderation transaction returned, so the browser cannot choose who is + * told about an acceptance or what the notice claims happened. + * + * No date is invented. There is no authoritative next-Mall-drop date in this + * workflow, so the notice says the item is waiting for the next drop rather + * than promising one on a day nothing guarantees. + * + * Returns whether the notice was actually delivered; the caller reports that + * rather than claiming a notification that did not happen. + */ + private async notifyApproval( + staffMemberId: number, + objectRecord: ObjectModel, + ): Promise { + try { + if (!objectRecord.member_id) { + return false; + } + const home = await this.placeRepository.findHomeByMemberId(objectRecord.member_id); + if (!home || !home.id) { + return false; + } + // Control characters in a stored name must not break the subject line. + const name = String(objectRecord.name || '').replace(/[\r\n\t]+/g, ' ').trim(); + const body = await this.inboxService.sanitize( + `Your Mall item "${name}" was accepted by Mall staff.\n\n` + + 'It is now marked Coming Soon and is waiting in the Mall Warehouse for the ' + + 'next Mall drop.', + ); + if (!body) { + return false; + } + await this.inboxService.postInboxMessage(staffMemberId, home.id, `accepted - ${name}`, body); + return true; + } catch (error) { + console.error(error); + return false; + } + } + public async approveObject(request: Request, response: Response): Promise { const { apitoken } = request.headers; @@ -216,9 +473,45 @@ class MallController { return; } - this.objectService.updateStatusApproved( - parseInt(request.body.objectId)); - response.status(200).json({ status: 'success' }); + const objectId = parseObjectId(request.body.objectId); + if (objectId === null) { + response.status(400).json({ + error: 'Invalid or missing object id.', + }); + return; + } + + // Same authority as Reject: the status is re-checked under a row lock, so + // approving is not something a stale page or a crafted request can apply + // to an object that was never a pending submission. + const approval = await this.objectService.approvePendingObject(objectId); + + if (approval.outcome === ObjectService.REJECT_NOT_FOUND) { + response.status(400).json({ + error: 'Invalid or missing object id.', + }); + return; + } + if (approval.outcome === ObjectService.REJECT_INVALID_STATE) { + response.status(400).json({ + error: 'Only a pending object can be accepted.', + }); + return; + } + if (approval.outcome === ObjectService.REJECT_ALREADY_REJECTED) { + // A concurrent Accept already won the row lock and performed the real + // Pending -> Warehouse transition. This request moved nothing, so it + // must not send a second acceptance notice for the same acceptance. + response.status(200).json({ status: 'success', notified: false, alreadyAccepted: true }); + return; + } + + // Only now, with the transition committed. A failure here cannot undo it, + // so it is reported rather than raised: a 500 would invite a retry that + // the guard above would answer with a bare success. + const notified = await this.notifyApproval(session.id, approval.object); + + response.status(200).json({ status: 'success', notified }); } catch (error) { console.error(error); response.status(400).json({ error }); @@ -300,7 +593,7 @@ class MallController { return; } - this.objectService.updateObjectLimit( + await this.objectService.updateObjectLimit( parseInt(request.body.objectId),request.body.limit); response.status(200).json({ status: 'success' }); } catch (error) { @@ -321,7 +614,7 @@ class MallController { return; } - this.objectService.updateObjectName( + await this.objectService.updateObjectName( parseInt(request.body.objectId),request.body.name); response.status(200).json({ status: 'success' }); } catch (error) { @@ -330,6 +623,44 @@ class MallController { } } + /** + * Tells an uploader why their object was rejected, through the same place + * inbox staff already use by hand. + * + * Recipient, subject and object name are resolved here from stored data. The + * browser sends only the object id and the reason, so it cannot choose who is + * told about a rejection or what the notice claims happened. + * + * Returns whether the notice was actually delivered; the caller reports that + * rather than claiming a notification that did not happen. + */ + private async notifyRejection( + staffMemberId: number, + objectRecord: ObjectModel, + reason: string, + ): Promise { + try { + if (!objectRecord.member_id) { + return false; + } + const home = await this.placeRepository.findHomeByMemberId(objectRecord.member_id); + if (!home || !home.id) { + return false; + } + const body = await this.inboxService.sanitize(reason); + if (!body) { + return false; + } + // Control characters in a stored name must not break the subject line. + const name = String(objectRecord.name || '').replace(/[\r\n\t]+/g, ' ').trim(); + await this.inboxService.postInboxMessage(staffMemberId, home.id, `rejected - ${name}`, body); + return true; + } catch (error) { + console.error(error); + return false; + } + } + public async rejectObject(request: Request, response: Response): Promise { const { apitoken } = request.headers; @@ -342,23 +673,61 @@ class MallController { return; } - const objectRecord = await this.objectService.findById(parseInt(request.body.id)); - if (!objectRecord) { + // Validated before anything mutates, so a missing reason can never leave + // an object rejected with its uploader untold. + const reason = typeof request.body.reason === 'string' ? request.body.reason.trim() : ''; + if (reason === '') { + response.status(400).json({ + error: 'A reason for rejection is required.', + }); + return; + } + if (reason.length > MAX_REJECTION_REASON) { + response.status(400).json({ + error: `A rejection reason may be at most ${MAX_REJECTION_REASON} characters.`, + }); + return; + } + + const objectId = parseObjectId(request.body.id); + if (objectId === null) { response.status(400).json({ error: 'Invalid or missing object id.', }); return; } - const sellersFee = await this.objectService.getSellerFee( - objectRecord.quantity, - objectRecord.price, - ); + // Everything that moves state or money happens inside one transaction that + // locks the object row, so two staff rejecting the same object at the same + // moment produce exactly one refund. The service decides the outcome from + // the status it reads under that lock, never from what the browser thinks. + const rejection = await this.objectService.rejectPendingObject(objectId); - this.objectService.updateStatusRejected(objectRecord.id); + if (rejection.outcome === ObjectService.REJECT_NOT_FOUND) { + response.status(400).json({ + error: 'Invalid or missing object id.', + }); + return; + } + if (rejection.outcome === ObjectService.REJECT_INVALID_STATE) { + // A stale page, or a crafted request. Staff authorisation is not a + // licence to refund an object that was never a pending submission. + response.status(400).json({ + error: 'Only a pending object can be rejected.', + }); + return; + } + if (rejection.outcome === ObjectService.REJECT_ALREADY_REJECTED) { + response.status(200).json({ status: 'success', notified: false, alreadyRejected: true }); + return; + } - this.objectService.performObjectUploadRefundTransaction(objectRecord.member_id, sellersFee); - response.status(200).json({ status: 'success' }); + // Only now, with the refund and the status change committed. A failure + // here cannot undo them, so it is reported rather than raised: a 500 would + // invite a retry that the guard above would answer with a bare success. + const notified = await this.notifyRejection(session.id, rejection.object, reason); + + response.status(200).json({ status: 'success', notified }); } catch (error) { console.error(error); response.status(400).json({ error }); @@ -405,7 +774,6 @@ class MallController { } public async objectsForSale(request: Request, response: Response): Promise { - const { apitoken } = request.headers; try { const placeId = parseInt(request.params.id); const objects = await this.objectService.getMallForSaleObjects(placeId); @@ -610,11 +978,19 @@ const mallService = Container.get(MallService); const objectService = Container.get(ObjectService); const walletService = Container.get(WalletService); const objectInstanceService = Container.get(ObjectInstanceService); +const mallInspectionService = Container.get(MallInspectionService); +const mallExportService = Container.get(MallExportService); +const inboxService = Container.get(InboxService); +const placeRepository = Container.get(PlaceRepository); export const mallController = new MallController( memberService, mallService, objectService, walletService, objectInstanceService, + mallInspectionService, + mallExportService, + inboxService, + placeRepository, ); diff --git a/api/src/controllers/member.controller.ts b/api/src/controllers/member.controller.ts index 29dd0e8c..fe71191e 100644 --- a/api/src/controllers/member.controller.ts +++ b/api/src/controllers/member.controller.ts @@ -7,7 +7,6 @@ import * as badwords from 'badwords-list'; import { sendPasswordResetEmail, sendPasswordResetUnknownEmail } from '../libs'; import { MemberService, HomeService, PlaceService } from '../services'; -import { SessionInfo } from 'session-info.interface'; import {parseInt} from 'lodash'; class MemberController { @@ -98,7 +97,7 @@ class MemberController { } } - public async check3d(request: Request, response: Response): Promise { + public async check3d(request: Request, response: Response): Promise { const session = this.memberService.decryptSession(request, response); if(!session) return; try { @@ -110,7 +109,7 @@ class MemberController { } } - public async getActivePlaces(request: Request, response: Response): Promise { + public async getActivePlaces(request: Request, response: Response): Promise { const session = this.memberService.decryptSession(request, response); if(!session) return; try { @@ -462,7 +461,7 @@ class MemberController { } } - public async getOnlineUsers(request: Request, response: Response): Promise { + public async getOnlineUsers(request: Request, response: Response): Promise { const session = this.memberService.decryptSession(request, response); if (!session) return; try { @@ -476,7 +475,12 @@ class MemberController { } else { user.hasHome = false; } - if(accessLevel && accessLevel === 'security'){ + // Pre-existing: `getAccessLevel` returns a list of levels, so this + // comparison has never been true and this branch has never run. + // Deliberately left inert -- turning it into `.includes(...)` would + // newly enable an access-gated path, which is not a change to make + // while fixing types. Raised separately for a decision. + if(accessLevel && (accessLevel as unknown as string) === 'security'){ user.security = true; } else { user.security = false; @@ -491,7 +495,7 @@ class MemberController { } } - public async getStorage(request: Request, response: Response): Promise { + public async getStorage(request: Request, response: Response): Promise { const session = this.memberService.decryptSession(request, response); if (!session) return; const member_id = parseInt(request.body.member_id); @@ -504,7 +508,7 @@ class MemberController { } } - public async updateStorage(request: Request, response: Response): Promise { + public async updateStorage(request: Request, response: Response): Promise { const session = this.memberService.decryptSession(request, response); if(!session) return; try { diff --git a/api/src/libs/index.ts b/api/src/libs/index.ts index efc83f65..347a3794 100644 --- a/api/src/libs/index.ts +++ b/api/src/libs/index.ts @@ -1 +1,3 @@ export * from './mail'; +export * from './mall'; +export * from './vrml'; diff --git a/api/src/libs/mall/index.ts b/api/src/libs/mall/index.ts new file mode 100644 index 00000000..dd543e02 --- /dev/null +++ b/api/src/libs/mall/index.ts @@ -0,0 +1 @@ +export * from './mall-object-views'; diff --git a/api/src/libs/mall/mall-object-views.spec.ts b/api/src/libs/mall/mall-object-views.spec.ts new file mode 100644 index 00000000..d3fe8fb5 --- /dev/null +++ b/api/src/libs/mall/mall-object-views.spec.ts @@ -0,0 +1,148 @@ +import { + ctrViewsFor, + isOutOfStock, + MALL_OBJECT_STATUS, + statusLabel, + statusName, +} from './mall-object-views'; + +/** + * The reference implementation, transcribed from + * spa/src/pages/mall/staff/soldout.vue as it stands today. Every out-of-stock + * assertion below is checked against BOTH implementations, so this helper is + * provably a faithful description of the current view rather than a guess. + */ +function soldOutVuePredicate(object: { + instances: number; + quantity: number; + limit: number | null; +}): boolean { + return object.instances === object.quantity + && (object.limit === object.quantity + || ['0', 'Unlimited', null].indexOf(object.limit as never) !== -1); +} + +function expectAgreesWithVue(object: { sold: number; quantity: number; limit: number | null }) { + const ours = isOutOfStock({ status: MALL_OBJECT_STATUS.ACTIVE, ...object }); + const theirs = soldOutVuePredicate({ + instances: object.sold, + quantity: object.quantity, + limit: object.limit, + }); + + expect(ours).toBe(theirs); + return ours; +} + +describe('statusName / statusLabel', () => { + it('names every status the object table uses', () => { + expect(statusName(0)).toBe('DELETED'); + expect(statusName(1)).toBe('ACTIVE'); + expect(statusName(2)).toBe('PENDING'); + expect(statusName(3)).toBe('APPROVED'); + expect(statusName(4)).toBe('INACTIVE'); + }); + + it('labels statuses the way the staff panel does', () => { + expect(statusLabel(1)).toBe('Stocked'); + expect(statusLabel(2)).toBe('Pending'); + expect(statusLabel(3)).toBe('Warehouse'); + }); + + it('does not throw on an unknown status', () => { + expect(statusName(99)).toBe('UNKNOWN'); + expect(statusLabel(99)).toBe('Unknown'); + }); +}); + +describe('isOutOfStock - agrees with the current soldout.vue filter', () => { + it('is true when everything sold and the limit is NULL', () => { + expect(expectAgreesWithVue({ sold: 25, quantity: 25, limit: null })).toBe(true); + }); + + it('is true when everything sold and the limit equals the quantity', () => { + expect(expectAgreesWithVue({ sold: 25, quantity: 25, limit: 25 })).toBe(true); + }); + + it('is false when stock remains', () => { + expect(expectAgreesWithVue({ sold: 10, quantity: 25, limit: null })).toBe(false); + }); + + it('is false when the limit is above the quantity, so more can be stocked', () => { + expect(expectAgreesWithVue({ sold: 25, quantity: 25, limit: 40 })).toBe(false); + }); + + it('preserves the limit = 0 exclusion the current view has', () => { + // The Update Limit prompt calls 0 "Unlimited", but soldout.vue compares + // against the STRING '0' while MySQL returns the NUMBER 0, so this object is + // excluded today. Reported as a follow-up; deliberately reproduced here. + expect(expectAgreesWithVue({ sold: 25, quantity: 25, limit: 0 })).toBe(false); + }); + + it('is false for anything that is not stocked, whatever its counts', () => { + [ + MALL_OBJECT_STATUS.DELETED, + MALL_OBJECT_STATUS.PENDING, + MALL_OBJECT_STATUS.APPROVED, + MALL_OBJECT_STATUS.INACTIVE, + ].forEach(status => { + expect(isOutOfStock({ status, sold: 25, quantity: 25, limit: null })).toBe(false); + }); + }); +}); + +describe('ctrViewsFor', () => { + it('places a pending object in exactly one view', () => { + const views = ctrViewsFor({ + status: MALL_OBJECT_STATUS.PENDING, + sold: 0, + quantity: 25, + limit: null, + }); + + expect(views).toEqual({ + pending: true, + warehouse: false, + stocked: false, + outOfStock: false, + removed: false, + inactive: false, + }); + }); + + it('places a sold-out object in BOTH stocked and outOfStock', () => { + const views = ctrViewsFor({ + status: MALL_OBJECT_STATUS.ACTIVE, + sold: 25, + quantity: 25, + limit: null, + }); + + // The overlap is the point: these are staff-panel views, not stored states. + expect(views.stocked).toBe(true); + expect(views.outOfStock).toBe(true); + }); + + it('places a stocked object with remaining quantity in stocked only', () => { + const views = ctrViewsFor({ + status: MALL_OBJECT_STATUS.ACTIVE, + sold: 1, + quantity: 25, + limit: null, + }); + + expect(views.stocked).toBe(true); + expect(views.outOfStock).toBe(false); + }); + + it('treats an undefined limit as no limit', () => { + const views = ctrViewsFor({ + status: MALL_OBJECT_STATUS.ACTIVE, + sold: 5, + quantity: 5, + limit: undefined as never, + }); + + expect(views.outOfStock).toBe(true); + }); +}); diff --git a/api/src/libs/mall/mall-object-views.ts b/api/src/libs/mall/mall-object-views.ts new file mode 100644 index 00000000..a6a7f236 --- /dev/null +++ b/api/src/libs/mall/mall-object-views.ts @@ -0,0 +1,109 @@ +/** + * The Mall staff panel's six views, expressed once so the checker, the lists and + * the export all agree on what "stocked" or "out of stock" means. + * + * These are CURRENT CTR VIEW MEMBERSHIPS, not stored states. Nothing in the + * database records "out of stock"; it is derived. They also OVERLAP by design - + * a sold-out object is in both `stocked` and `outOfStock` - so they must never be + * collapsed into a single status label. + */ + +/** Mirrors the STATUS_* constants on ObjectService. */ +export const MALL_OBJECT_STATUS = { + DELETED: 0, + ACTIVE: 1, + PENDING: 2, + APPROVED: 3, + INACTIVE: 4, +}; + +const STATUS_NAMES: { [status: number]: string } = { + 0: 'DELETED', + 1: 'ACTIVE', + 2: 'PENDING', + 3: 'APPROVED', + 4: 'INACTIVE', +}; + +/** The staff-facing wording for each status, as the panel labels them. */ +const STATUS_LABELS: { [status: number]: string } = { + 0: 'Removed', + 1: 'Stocked', + 2: 'Pending', + 3: 'Warehouse', + 4: 'Destocked', +}; + +export function statusName(status: number): string { + return STATUS_NAMES[status] || 'UNKNOWN'; +} + +export function statusLabel(status: number): string { + return STATUS_LABELS[status] || 'Unknown'; +} + +export interface CtrViewInput { + status: number; + /** Number of object_instance rows, i.e. how many have sold. */ + sold: number; + quantity: number; + limit: number | null; +} + +export interface CtrViews { + pending: boolean; + warehouse: boolean; + stocked: boolean; + outOfStock: boolean; + removed: boolean; + inactive: boolean; +} + +/** + * The SQL-equivalent predicate behind each view, published in the export so a + * downstream importer never has to guess what a membership list meant. + */ +export const CTR_VIEW_DEFINITIONS = { + pending: 'object.status = 2', + warehouse: 'object.status = 3', + stocked: 'object.status = 1', + outOfStock: + 'object.status = 1 AND sold = quantity AND (limit = quantity OR limit IS NULL)', + removed: 'object.status = 0', + inactive: 'object.status = 4', +}; + +/** + * Reproduces `spa/src/pages/mall/staff/soldout.vue` exactly, including a quirk + * that is deliberately preserved rather than fixed here. + * + * That page tests `['0', 'Unlimited', null].includes(obj.limit)`, but `limit` is + * an INT column, so MySQL returns the NUMBER 0 and `0 !== '0'`. An object whose + * limit was explicitly set to 0 - which the Update Limit prompt calls + * "Unlimited" - is therefore excluded from Out of Stock today. + * + * Changing that would change which objects staff see in that view, which is a + * Mall policy decision rather than a refactor. It is reported as a follow-up and + * left alone here so this helper stays a faithful description of current CTR. + */ +export function isOutOfStock(input: CtrViewInput): boolean { + if (input.status !== MALL_OBJECT_STATUS.ACTIVE) { + return false; + } + if (input.sold !== input.quantity) { + return false; + } + const limit = input.limit === undefined ? null : input.limit; + return limit === input.quantity || limit === null; +} + +export function ctrViewsFor(input: CtrViewInput): CtrViews { + return { + pending: input.status === MALL_OBJECT_STATUS.PENDING, + warehouse: input.status === MALL_OBJECT_STATUS.APPROVED, + stocked: input.status === MALL_OBJECT_STATUS.ACTIVE, + outOfStock: isOutOfStock(input), + removed: input.status === MALL_OBJECT_STATUS.DELETED, + inactive: input.status === MALL_OBJECT_STATUS.INACTIVE, + }; +} diff --git a/api/src/libs/vrml/index.ts b/api/src/libs/vrml/index.ts new file mode 100644 index 00000000..a5df085c --- /dev/null +++ b/api/src/libs/vrml/index.ts @@ -0,0 +1,3 @@ +export * from './vrml-tokenizer'; +export * from './vrml-scan'; +export * from './worldinfo-compare'; diff --git a/api/src/libs/vrml/vrml-scan.spec.ts b/api/src/libs/vrml/vrml-scan.spec.ts new file mode 100644 index 00000000..b15f76b1 --- /dev/null +++ b/api/src/libs/vrml/vrml-scan.spec.ts @@ -0,0 +1,467 @@ +import { + classifyUrl, + externalReferences, + FINDING_BAD_HEADER, + FINDING_MALFORMED_VRML, + FINDING_MULTIPLE_WORLDINFO, + FINDING_NO_WORLDINFO, + scanVrml, + summariseNodeCounts, + textureReferences, +} from './vrml-scan'; + +const HEADER = '#VRML V2.0 utf8'; + +function withHeader(body: string): string { + return `${HEADER}\n${body}`; +} + +describe('scanVrml - header', () => { + it('reads the first line verbatim and recognises VRML97', () => { + const scan = scanVrml(withHeader('Shape {}')); + + expect(scan.header).toBe(HEADER); + expect(scan.headerIsVrml97).toBe(true); + expect(scan.warnings).not.toContain(FINDING_BAD_HEADER); + }); + + it('tolerates a CRLF header', () => { + expect(scanVrml(`${HEADER}\r\nShape {}`).headerIsVrml97).toBe(true); + }); + + it('flags a non-VRML97 header', () => { + const scan = scanVrml('#VRML V1.0 ascii\nSeparator {}'); + + expect(scan.headerIsVrml97).toBe(false); + expect(scan.warnings).toContain(FINDING_BAD_HEADER); + }); + + it('flags an empty file rather than throwing', () => { + const scan = scanVrml(''); + + expect(scan.header).toBeNull(); + expect(scan.warnings).toContain(FINDING_BAD_HEADER); + }); +}); + +describe('scanVrml - WorldInfo', () => { + it('extracts the title and every info entry', () => { + const scan = scanVrml(withHeader(` + WorldInfo { + title "Pocket Moon Playset" + info [ + "Made By: BassMekanik" + "Uploaded: August, 2026" + "Mall Price: 75 CC" + ] + } + `)); + + expect(scan.worldInfo).toHaveLength(1); + expect(scan.worldInfo[0].title).toBe('Pocket Moon Playset'); + expect(scan.worldInfo[0].info).toEqual([ + 'Made By: BassMekanik', + 'Uploaded: August, 2026', + 'Mall Price: 75 CC', + ]); + }); + + it('handles a single-string info field', () => { + const scan = scanVrml(withHeader('WorldInfo { info "just one" }')); + + expect(scan.worldInfo[0].info).toEqual(['just one']); + }); + + it('tolerates comments and odd spacing between a field and its value', () => { + const scan = scanVrml(withHeader(` + WorldInfo { + title # the object name follows + "Spaced Out" + } + `)); + + expect(scan.worldInfo[0].title).toBe('Spaced Out'); + }); + + it('preserves escaped quotes and unicode in info entries', () => { + const scan = scanVrml(withHeader('WorldInfo { info [ "say \\"hi\\"" "café ☃" ] }')); + + expect(scan.worldInfo[0].info).toEqual(['say "hi"', 'café ☃']); + }); + + it('reports a missing WorldInfo', () => { + const scan = scanVrml(withHeader('Shape {}')); + + expect(scan.worldInfo).toHaveLength(0); + expect(scan.warnings).toContain(FINDING_NO_WORLDINFO); + }); + + it('reports every WorldInfo when there is more than one', () => { + const scan = scanVrml(withHeader(` + WorldInfo { title "First" } + Group { children [ ] } + WorldInfo { title "Second" } + `)); + + expect(scan.worldInfo.map(node => node.title)).toEqual(['First', 'Second']); + expect(scan.warnings).toContain(FINDING_MULTIPLE_WORLDINFO); + }); + + it('does not mistake a title field of another node for WorldInfo', () => { + const scan = scanVrml(withHeader('Anchor { description "not a title" }')); + + expect(scan.worldInfo).toHaveLength(0); + }); +}); + +describe('scanVrml - node counting', () => { + it('counts node types, including inside DEF', () => { + const scan = scanVrml(withHeader(` + WorldInfo { title "x" } + DEF Root Group { + children [ + Shape { geometry Box {} } + Shape { geometry Sphere {} } + ] + } + `)); + + expect(scan.nodeCounts.Group).toBe(1); + expect(scan.nodeCounts.Shape).toBe(2); + expect(scan.nodeCounts.Box).toBe(1); + // "Root" is a DEF name, not a node type. + expect(scan.nodeCounts.Root).toBeUndefined(); + }); + + it('does not count USE references as new nodes', () => { + const scan = scanVrml(withHeader('Group { children [ DEF A Shape {} USE A ] }')); + + expect(scan.nodeCounts.Shape).toBe(1); + }); + + it('counts forbidden nodes when they are genuinely present', () => { + const scan = scanVrml(withHeader(` + WorldInfo { title "x" } + Sound { source AudioClip { url "beep.wav" } } + DirectionalLight { on FALSE } + Inline { url "other.wrl" } + `)); + + const summary = summariseNodeCounts(scan); + expect(summary.Sound).toBe(1); + expect(summary.DirectionalLight).toBe(1); + expect(summary.Inline).toBe(1); + }); + + it('does NOT count node names that appear only inside a comment', () => { + const scan = scanVrml(withHeader(` + WorldInfo { title "x" } + # this object has no Sound { } and no DirectionalLight { } + Shape {} + `)); + + const summary = summariseNodeCounts(scan); + expect(summary.Sound).toBe(0); + expect(summary.DirectionalLight).toBe(0); + }); + + it('does NOT count node names that appear only inside a Script string', () => { + const scan = scanVrml(withHeader(` + WorldInfo { title "x" } + Script { + url [ "javascript: function f() { /* Sound { } Inline { } */ }" ] + } + `)); + + const summary = summariseNodeCounts(scan); + expect(summary.Script).toBe(1); + expect(summary.Sound).toBe(0); + expect(summary.Inline).toBe(0); + }); + + it('does NOT count a node name inside a WorldInfo info entry', () => { + const scan = scanVrml(withHeader( + 'WorldInfo { info [ "Contains no Sound { } or Billboard { }" ] }', + )); + + expect(summariseNodeCounts(scan).Sound).toBe(0); + expect(summariseNodeCounts(scan).Billboard).toBe(0); + }); + + it('sums H-Anim container nodes under hAnim', () => { + const scan = scanVrml(withHeader('Humanoid { joints [ Joint {} Joint {} ] }')); + + expect(summariseNodeCounts(scan).hAnim).toBe(3); + }); + + it('publishes a stable key set even for an empty object', () => { + const summary = summariseNodeCounts(scanVrml(withHeader('Shape {}'))); + + expect(Object.keys(summary)).toContain('ImageTexture'); + expect(Object.keys(summary)).toContain('hAnim'); + expect(summary.ImageTexture).toBe(0); + }); +}); + +describe('scanVrml - PROTO and EXTERNPROTO', () => { + it('records PROTO definitions and does not confuse the body for a node', () => { + const scan = scanVrml(withHeader(` + WorldInfo { title "x" } + PROTO Wheel [ field SFFloat radius 1 ] { + Shape { geometry Cylinder {} } + } + Wheel {} + `)); + + expect(scan.protoDefinitions).toEqual(['Wheel']); + expect(scan.nodeCounts.Cylinder).toBe(1); + expect(scan.warnings).not.toContain(FINDING_MALFORMED_VRML); + }); + + it('records EXTERNPROTO definitions and their url list', () => { + const scan = scanVrml(withHeader( + 'EXTERNPROTO Tree [ field SFFloat h ] [ "http://example.com/tree.wrl#Tree" ]', + )); + + expect(scan.externProtoDefinitions).toEqual(['Tree']); + expect(scan.urls).toEqual([ + expect.objectContaining({ + node: 'EXTERNPROTO', + value: 'http://example.com/tree.wrl#Tree', + kind: 'external', + }), + ]); + expect(summariseNodeCounts(scan).EXTERNPROTO).toBe(1); + }); +}); + +describe('scanVrml - references', () => { + it('classifies url values', () => { + expect(classifyUrl('wood.jpg')).toBe('local'); + expect(classifyUrl('textures/wood.jpg')).toBe('relative'); + expect(classifyUrl('textures\\wood.jpg')).toBe('relative'); + expect(classifyUrl('/assets/wood.jpg')).toBe('absolute'); + expect(classifyUrl('http://example.com/wood.jpg')).toBe('external'); + expect(classifyUrl('https://example.com/wood.jpg')).toBe('external'); + expect(classifyUrl('data:image/png;base64,AAAA')).toBe('data'); + expect(classifyUrl(' ')).toBe('empty'); + }); + + it('classifies inline script bodies as script, not external', () => { + // Real Mall objects put ECMAScript source directly in a Script node's url. + // Treating that as a network reference would wrongly flag the object as + // breaking the "no external source" texture rule. + expect(classifyUrl('vrmlscript:\nfunction f() { return 1; }')).toBe('script'); + expect(classifyUrl('javascript: function f() {}')).toBe('script'); + expect(classifyUrl('ecmascript: var a = 1;')).toBe('script'); + }); + + it('excludes inline script bodies from external references', () => { + const scan = scanVrml(withHeader(` + DEF Anim Script { + url "vrmlscript: function activated(active, t) { state = TRUE; }" + } + `)); + + expect(externalReferences(scan)).toEqual([]); + expect(scan.urls[0].kind).toBe('script'); + }); + + it('collects a single local texture reference', () => { + const scan = scanVrml(withHeader( + 'Shape { appearance Appearance { texture ImageTexture { url "wood.jpg" } } }', + )); + + expect(textureReferences(scan)).toEqual([ + expect.objectContaining({ node: 'ImageTexture', value: 'wood.jpg', kind: 'local' }), + ]); + }); + + it('de-duplicates the same texture used twice', () => { + const scan = scanVrml(withHeader(` + Shape { appearance Appearance { texture ImageTexture { url "wood.jpg" } } } + Shape { appearance Appearance { texture ImageTexture { url "wood.jpg" } } } + `)); + + expect(summariseNodeCounts(scan).ImageTexture).toBe(2); + expect(textureReferences(scan)).toHaveLength(1); + }); + + it('reports multiple distinct textures separately', () => { + const scan = scanVrml(withHeader(` + Shape { appearance Appearance { texture ImageTexture { url "a.jpg" } } } + Shape { appearance Appearance { texture ImageTexture { url [ "b.jpg" "c.jpg" ] } } } + `)); + + expect(textureReferences(scan).map(reference => reference.value)) + .toEqual(['a.jpg', 'b.jpg', 'c.jpg']); + }); + + it('reports no textures when there are none', () => { + expect(textureReferences(scanVrml(withHeader('Shape {}')))).toEqual([]); + }); + + it('treats external and absolute references as escaping the object directory', () => { + const scan = scanVrml(withHeader(` + Shape { appearance Appearance { texture ImageTexture { url "http://x.test/a.jpg" } } } + Shape { appearance Appearance { texture ImageTexture { url "/assets/b.jpg" } } } + Shape { appearance Appearance { texture ImageTexture { url "c.jpg" } } } + `)); + + expect(externalReferences(scan).map(reference => reference.value)) + .toEqual(['http://x.test/a.jpg', '/assets/b.jpg']); + }); + + it('captures Background side urls, which also leave the directory', () => { + const scan = scanVrml(withHeader('Background { backUrl "http://x.test/back.jpg" }')); + + expect(externalReferences(scan)).toHaveLength(1); + }); +}); + +describe('scanVrml - viewpoints', () => { + it('records viewpoint DEF names and descriptions', () => { + const scan = scanVrml(withHeader(` + DEF Cockpit Viewpoint { description "Pilot seat" } + Viewpoint { position 0 0 10 } + `)); + + expect(scan.viewpoints).toEqual([ + { defName: 'Cockpit', description: 'Pilot seat' }, + { defName: null, description: null }, + ]); + expect(summariseNodeCounts(scan).Viewpoint).toBe(2); + }); +}); + +describe('scanVrml - malformed input', () => { + it('flags an unterminated string but still returns what it read', () => { + const scan = scanVrml(withHeader('WorldInfo { title "never closed')); + + expect(scan.warnings).toContain(FINDING_MALFORMED_VRML); + expect(scan.worldInfo[0].title).toBe('never closed'); + }); + + it('flags unbalanced braces', () => { + expect(scanVrml(withHeader('Group { children [ Shape {')).warnings) + .toContain(FINDING_MALFORMED_VRML); + expect(scanVrml(withHeader('Shape {} }')).warnings) + .toContain(FINDING_MALFORMED_VRML); + }); + + it('never throws on arbitrary non-VRML bytes', () => { + expect(() => scanVrml(' not vrml at all " [ { }')).not.toThrow(); + }); +}); + +describe('scanVrml - WorldInfo inside a PROTO is not the object metadata', () => { + const PROTO_AND_SCENE = withHeader(` +PROTO Example [] { + WorldInfo { + title "Prototype metadata" + } +} + +WorldInfo { + title "Actual object" +} +`); + + it('compares against the scene-level node, not the PROTO-local one', () => { + const scan = scanVrml(PROTO_AND_SCENE); + + expect(scan.worldInfo).toHaveLength(1); + expect(scan.worldInfo[0].title).toBe('Actual object'); + expect(scan.warnings).not.toContain(FINDING_MULTIPLE_WORLDINFO); + }); + + it('still reports the PROTO-local node separately', () => { + const scan = scanVrml(PROTO_AND_SCENE); + + expect(scan.protoWorldInfo).toHaveLength(1); + expect(scan.protoWorldInfo[0].title).toBe('Prototype metadata'); + }); + + it('treats a file whose only WorldInfo is PROTO-local as having none', () => { + const scan = scanVrml(withHeader(` +PROTO Example [] { + WorldInfo { + title "Prototype metadata" + } +} +Shape {} +`)); + + expect(scan.worldInfo).toHaveLength(0); + expect(scan.warnings).toContain(FINDING_NO_WORLDINFO); + }); + + it('still reports two scene-level nodes as multiple', () => { + const scan = scanVrml(withHeader(` +WorldInfo { title "One" } +WorldInfo { title "Two" } +`)); + + expect(scan.worldInfo).toHaveLength(2); + expect(scan.warnings).toContain(FINDING_MULTIPLE_WORLDINFO); + }); + + it('does not lose a scene WorldInfo nested inside ordinary grouping nodes', () => { + const scan = scanVrml(withHeader(` +Transform { + children [ + WorldInfo { title "Nested but real" } + ] +} +`)); + + expect(scan.worldInfo).toHaveLength(1); + expect(scan.worldInfo[0].title).toBe('Nested but real'); + }); +}); + +describe('externalReferences - a relative path can still leave the directory', () => { + function urlsOf(url: string) { + return externalReferences(scanVrml(withHeader( + `Shape { appearance Appearance { texture ImageTexture { url "${url}" } } }`, + ))).map(reference => reference.value); + } + + it('flags a parent-traversal reference', () => { + expect(urlsOf('../wood.jpg')).toEqual(['../wood.jpg']); + expect(urlsOf('textures/../../secret.jpg')).toEqual(['textures/../../secret.jpg']); + }); + + it('leaves an object-local subdirectory alone', () => { + expect(urlsOf('textures/wood.jpg')).toEqual([]); + expect(urlsOf('textures/sub/wood.jpg')).toEqual([]); + expect(urlsOf('wood.jpg')).toEqual([]); + }); + + it('does not flag a traversal that resolves back inside', () => { + expect(urlsOf('textures/../wood.jpg')).toEqual([]); + }); + + it('still flags absolute and off-host references', () => { + expect(urlsOf('/etc/passwd')).toEqual(['/etc/passwd']); + expect(urlsOf('http://example.com/wood.jpg')).toEqual(['http://example.com/wood.jpg']); + }); +}); + +describe('scanVrml - truncation is not malformed structure', () => { + it('does not call a file malformed just because scanning stopped early', () => { + // Three tokens per Shape, so this comfortably passes DEFAULT_MAX_TOKENS + // while leaving the Transform's brace and bracket legitimately unclosed. + const scan = scanVrml(withHeader(`Transform { children [\n${'Shape {}\n'.repeat(170000)}`)); + + expect(scan.truncated).toBe(true); + expect(scan.warnings).not.toContain(FINDING_MALFORMED_VRML); + }); + + it('still reports genuinely unbalanced structure that was read to the end', () => { + const scan = scanVrml(withHeader('Transform { children [ Shape {}')); + + expect(scan.truncated).toBe(false); + expect(scan.warnings).toContain(FINDING_MALFORMED_VRML); + }); +}); diff --git a/api/src/libs/vrml/vrml-scan.ts b/api/src/libs/vrml/vrml-scan.ts new file mode 100644 index 00000000..b5686b8a --- /dev/null +++ b/api/src/libs/vrml/vrml-scan.ts @@ -0,0 +1,478 @@ +import { tokenize, VrmlToken } from './vrml-tokenizer'; + +/** + * Extracts the lexical facts a Mall checker would otherwise read by hand, by + * walking the token stream produced by `tokenize`. + * + * Scope note: this is not a VRML engine. It does not instantiate PROTOs, resolve + * USE references, compose Transforms or compute geometry, so it deliberately + * reports nothing about an object's bounding box, lowest Y, or centring - those + * remain a human judgement against the Mall reference grid in the viewer. + */ + +export const VRML97_HEADER = '#VRML V2.0 utf8'; + +/** How a `url`-ish field value resolves, from the Mall rules' point of view. */ +export type VrmlUrlKind = + | 'local' + | 'relative' + | 'absolute' + | 'external' + | 'data' + | 'script' + | 'empty'; + +/** + * Schemes that carry an inline script body rather than a network reference. + * + * Real Mall objects use these heavily - a `Script` node's `url` is normally the + * ECMAScript source itself. Classifying them as external would wrongly accuse + * ordinary animated objects of breaking the "textures shall not be linked to an + * external source" rule. + */ +const INLINE_SCRIPT_SCHEMES = ['javascript', 'vrmlscript', 'ecmascript']; + +export interface VrmlUrlReference { + /** Enclosing node type, or null for a reference outside any node (e.g. EXTERNPROTO). */ + node: string | null; + field: string; + value: string; + kind: VrmlUrlKind; +} + +export interface WorldInfoNode { + title: string | null; + info: string[]; +} + +export interface ViewpointFact { + defName: string | null; + description: string | null; +} + +export interface VrmlScan { + /** The file's first line verbatim, or null for an empty file. */ + header: string | null; + headerIsVrml97: boolean; + /** + * Scene-level WorldInfo only. A PROTO body is a template, not part of the + * instantiated scene, so metadata declared inside one must never stand in as + * the object's own -- see `protoWorldInfo`. + */ + worldInfo: WorldInfoNode[]; + /** WorldInfo declared inside a PROTO body. Reported, never compared against. */ + protoWorldInfo: WorldInfoNode[]; + /** Raw count of every node type opened, keyed by type name. */ + nodeCounts: { [nodeType: string]: number }; + protoDefinitions: string[]; + externProtoDefinitions: string[]; + urls: VrmlUrlReference[]; + viewpoints: ViewpointFact[]; + /** Machine-readable findings; see FINDING_* below. */ + warnings: string[]; + truncated: boolean; +} + +/** + * The fixed key set the export contract and checker render. Kept explicit and + * ordered so the export stays deterministic across releases. + */ +export const SUMMARISED_NODE_TYPES = [ + 'ImageTexture', + 'PixelTexture', + 'MovieTexture', + 'PROTO', + 'EXTERNPROTO', + 'Inline', + 'Script', + 'Sound', + 'AudioClip', + 'DirectionalLight', + 'PointLight', + 'SpotLight', + 'Billboard', + 'Viewpoint', + 'TouchSensor', + 'ProximitySensor', + 'TimeSensor', + 'Anchor', +] as const; + +/** H-Anim container nodes, which the Mall rules forbid. Summed as `hAnim`. */ +export const H_ANIM_NODE_TYPES = ['Humanoid', 'Joint', 'Segment', 'Site', 'Displacer']; + +/** Node types whose `url` values are texture references. */ +const TEXTURE_NODE_TYPES = ['ImageTexture', 'MovieTexture']; + +export const FINDING_BAD_HEADER = 'bad_header'; +export const FINDING_NO_WORLDINFO = 'no_worldinfo'; +export const FINDING_MULTIPLE_WORLDINFO = 'multiple_worldinfo'; +export const FINDING_MALFORMED_VRML = 'malformed_vrml'; +export const FINDING_TRUNCATED = 'too_complex'; + +interface Frame { + /** null for a brace block that is not a node body (a PROTO body, for example). */ + type: string | null; + /** True when this frame is a PROTO body, or is nested anywhere inside one. */ + proto: boolean; +} + +function isNodeTypeName(value: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value); +} + +function isUrlField(name: string): boolean { + return /url$/i.test(name); +} + +/** Classifies a url value the way the Mall texture rules care about. */ +export function classifyUrl(value: string): VrmlUrlKind { + const trimmed = value.trim(); + if (trimmed === '') { + return 'empty'; + } + const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):/.exec(trimmed); + if (scheme) { + const name = scheme[1].toLowerCase(); + if (INLINE_SCRIPT_SCHEMES.indexOf(name) !== -1) { + return 'script'; + } + return name === 'data' ? 'data' : 'external'; + } + if (trimmed.charAt(0) === '/' || trimmed.charAt(0) === '\\') { + return 'absolute'; + } + if (trimmed.indexOf('/') !== -1 || trimmed.indexOf('\\') !== -1) { + return 'relative'; + } + return 'local'; +} + +/** + * Whether a relative reference resolves outside the object's own directory. + * + * `classifyUrl` calls both `textures/wood.jpg` and `../wood.jpg` relative, but + * only the second one leaves the directory, and that difference is the whole + * point of the external-reference rule. + */ +export function escapesObjectDirectory(value: string): boolean { + const parts = value.trim().split(/[\\/]+/); + let depth = 0; + for (let i = 0; i < parts.length; i += 1) { + const part = parts[i]; + if (part === '' || part === '.') { + continue; + } + if (part === '..') { + depth -= 1; + if (depth < 0) { + return true; + } + } else { + depth += 1; + } + } + return false; +} + +function isPunct(token: VrmlToken | undefined, value: string): boolean { + return !!token && token.kind === 'punct' && token.value === value; +} + +/** + * Reads either a single SFString or an MFString `[ ... ]` block starting at + * `start`, returning the strings found and the index just past the value. + * Non-string tokens inside a list are skipped rather than treated as an error. + */ +function readStringValue( + tokens: VrmlToken[], + start: number, +): { values: string[]; next: number } { + const token = tokens[start]; + if (!token) { + return { values: [], next: start }; + } + + if (token.kind === 'string') { + return { values: [token.value], next: start + 1 }; + } + + if (isPunct(token, '[')) { + const values: string[] = []; + let depth = 0; + let position = start; + + while (position < tokens.length) { + const current = tokens[position]; + if (current.kind === 'punct' && current.value === '[') { + depth += 1; + } else if (current.kind === 'punct' && current.value === ']') { + depth -= 1; + if (depth === 0) { + return { values, next: position + 1 }; + } + } else if (current.kind === 'string') { + values.push(current.value); + } + position += 1; + } + + return { values, next: position }; + } + + return { values: [], next: start }; +} + +/** Skips a balanced `[ ... ]` block, used to step over a PROTO interface. */ +function skipBracketBlock(tokens: VrmlToken[], start: number): number { + if (!isPunct(tokens[start], '[')) { + return start; + } + let depth = 0; + let position = start; + while (position < tokens.length) { + const current = tokens[position]; + if (current.kind === 'punct' && current.value === '[') { + depth += 1; + } else if (current.kind === 'punct' && current.value === ']') { + depth -= 1; + if (depth === 0) { + return position + 1; + } + } + position += 1; + } + return position; +} + +function readHeader(text: string): string | null { + if (text === '') { + return null; + } + const withoutBom = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; + const newline = withoutBom.search(/\r?\n/); + const line = newline === -1 ? withoutBom : withoutBom.slice(0, newline); + return line.replace(/\s+$/, ''); +} + +export function scanVrml(text: string): VrmlScan { + const { tokens, truncated, unterminatedString } = tokenize(text); + + const header = readHeader(text); + const worldInfo: WorldInfoNode[] = []; + // Parallel to `worldInfo`: field writes still target the most recent record, + // so PROTO-local nodes have to be collected, then separated at the end. + const worldInfoIsProto: boolean[] = []; + let pendingProtoBody = false; + const nodeCounts: { [nodeType: string]: number } = {}; + const protoDefinitions: string[] = []; + const externProtoDefinitions: string[] = []; + const urls: VrmlUrlReference[] = []; + const viewpoints: ViewpointFact[] = []; + const stack: Frame[] = []; + const inProtoBody = (): boolean => stack.length > 0 && stack[stack.length - 1].proto; + + let pendingDefName: string | null = null; + let unbalanced = false; + let index = 0; + + const countNode = (type: string): void => { + nodeCounts[type] = (nodeCounts[type] || 0) + 1; + }; + + while (index < tokens.length) { + const token = tokens[index]; + + if (token.kind === 'punct') { + if (token.value === '{') { + // A brace not introduced by a node name - a PROTO body, for instance. + stack.push({ type: null, proto: pendingProtoBody || inProtoBody() }); + pendingProtoBody = false; + } else if (token.value === '}') { + if (stack.length === 0) { + unbalanced = true; + } else { + stack.pop(); + } + } + index += 1; + continue; + } + + if (token.kind === 'string') { + index += 1; + continue; + } + + const word = token.value; + + if (word === 'DEF') { + const next = tokens[index + 1]; + pendingDefName = next && next.kind === 'word' ? next.value : null; + index += next ? 2 : 1; + continue; + } + + if (word === 'USE') { + index += tokens[index + 1] ? 2 : 1; + continue; + } + + if (word === 'PROTO' || word === 'EXTERNPROTO') { + const nameToken = tokens[index + 1]; + const name = nameToken && nameToken.kind === 'word' ? nameToken.value : ''; + countNode(word); + let position = nameToken ? index + 2 : index + 1; + position = skipBracketBlock(tokens, position); + + if (word === 'PROTO') { + protoDefinitions.push(name); + // The brace that follows the interface opens the template body. + pendingProtoBody = true; + } else { + externProtoDefinitions.push(name); + // An EXTERNPROTO's url list follows its interface with no field name, + // and is exactly the kind of external reference the rules forbid. + const external = readStringValue(tokens, position); + external.values.forEach(value => { + urls.push({ node: word, field: 'url', value, kind: classifyUrl(value) }); + }); + position = external.next; + } + + index = position; + continue; + } + + if (isPunct(tokens[index + 1], '{')) { + if (isNodeTypeName(word)) { + countNode(word); + } + if (word === 'Viewpoint') { + viewpoints.push({ defName: pendingDefName, description: null }); + } + if (word === 'WorldInfo') { + worldInfo.push({ title: null, info: [] }); + worldInfoIsProto.push(inProtoBody()); + } + stack.push({ type: word, proto: inProtoBody() }); + pendingDefName = null; + index += 2; + continue; + } + + const frame = stack.length > 0 ? stack[stack.length - 1] : null; + const frameType = frame ? frame.type : null; + + if (frameType === 'WorldInfo' && (word === 'title' || word === 'info')) { + const value = readStringValue(tokens, index + 1); + const current = worldInfo[worldInfo.length - 1]; + if (current) { + if (word === 'title') { + current.title = value.values.length > 0 ? value.values[0] : null; + } else { + current.info = current.info.concat(value.values); + } + } + index = value.next > index ? value.next : index + 1; + continue; + } + + if (frameType === 'Viewpoint' && word === 'description') { + const value = readStringValue(tokens, index + 1); + const current = viewpoints[viewpoints.length - 1]; + if (current) { + current.description = value.values.length > 0 ? value.values[0] : null; + } + index = value.next > index ? value.next : index + 1; + continue; + } + + if (isUrlField(word)) { + const value = readStringValue(tokens, index + 1); + value.values.forEach(entry => { + urls.push({ node: frameType, field: word, value: entry, kind: classifyUrl(entry) }); + }); + index = value.next > index ? value.next : index + 1; + continue; + } + + index += 1; + } + + const sceneWorldInfo = worldInfo.filter((_node, i) => !worldInfoIsProto[i]); + const protoWorldInfo = worldInfo.filter((_node, i) => worldInfoIsProto[i]); + + const warnings: string[] = []; + if (header === null || header !== VRML97_HEADER) { + warnings.push(FINDING_BAD_HEADER); + } + if (sceneWorldInfo.length === 0) { + warnings.push(FINDING_NO_WORLDINFO); + } + if (sceneWorldInfo.length > 1) { + warnings.push(FINDING_MULTIPLE_WORLDINFO); + } + // An open stack is expected when scanning stopped at the token budget, so it + // only means malformed input if the file was read to the end. + if (unterminatedString || unbalanced || (stack.length > 0 && !truncated)) { + warnings.push(FINDING_MALFORMED_VRML); + } + if (truncated) { + warnings.push(FINDING_TRUNCATED); + } + + return { + header, + headerIsVrml97: header === VRML97_HEADER, + worldInfo: sceneWorldInfo, + protoWorldInfo, + nodeCounts, + protoDefinitions, + externProtoDefinitions, + urls, + viewpoints, + warnings, + truncated, + }; +} + +/** + * Projects the raw node counts onto the fixed, ordered key set the checker and + * the export contract publish, so both stay deterministic as the scanner grows. + */ +export function summariseNodeCounts(scan: VrmlScan): { [nodeType: string]: number } { + const summary: { [nodeType: string]: number } = {}; + SUMMARISED_NODE_TYPES.forEach(type => { + summary[type] = scan.nodeCounts[type] || 0; + }); + summary.hAnim = H_ANIM_NODE_TYPES.reduce( + (total, type) => total + (scan.nodeCounts[type] || 0), + 0, + ); + return summary; +} + +/** Distinct texture references, in first-seen order. */ +export function textureReferences(scan: VrmlScan): VrmlUrlReference[] { + const seen: { [value: string]: true } = {}; + return scan.urls.filter(reference => { + if (TEXTURE_NODE_TYPES.indexOf(reference.node || '') === -1) { + return false; + } + if (seen[reference.value]) { + return false; + } + seen[reference.value] = true; + return true; + }); +} + +/** Every reference that leaves the object's own directory. */ +export function externalReferences(scan: VrmlScan): VrmlUrlReference[] { + return scan.urls.filter( + reference => reference.kind === 'external' + || reference.kind === 'absolute' + || (reference.kind === 'relative' && escapesObjectDirectory(reference.value)), + ); +} diff --git a/api/src/libs/vrml/vrml-tokenizer.spec.ts b/api/src/libs/vrml/vrml-tokenizer.spec.ts new file mode 100644 index 00000000..bc31b6dc --- /dev/null +++ b/api/src/libs/vrml/vrml-tokenizer.spec.ts @@ -0,0 +1,127 @@ +import { DEFAULT_MAX_TOKENS, tokenize } from './vrml-tokenizer'; + +function values(text: string): string[] { + return tokenize(text).tokens.map(token => token.value); +} + +function kinds(text: string): string[] { + return tokenize(text).tokens.map(token => token.kind); +} + +describe('tokenize', () => { + it('splits words, strings and structural punctuation', () => { + expect(values('Shape { appearance "x" }')).toEqual(['Shape', '{', 'appearance', 'x', '}']); + expect(kinds('Shape { "x" }')).toEqual(['word', 'punct', 'string', 'punct']); + }); + + it('treats commas as whitespace, as VRML97 does', () => { + expect(values('translation 0, -1.75, 0')).toEqual(['translation', '0', '-1.75', '0']); + }); + + it('drops comments', () => { + expect(values('# a Sound node lives here\nShape {}')).toEqual(['Shape', '{', '}']); + }); + + it('drops a trailing comment with no newline after it', () => { + expect(values('Shape {} # Sound')).toEqual(['Shape', '{', '}']); + }); + + it('preserves a # that appears inside a string', () => { + expect(values('info "colour #ff0000 and a Sound"')) + .toEqual(['info', 'colour #ff0000 and a Sound']); + }); + + it('resolves \\" and \\\\ escapes and strips the surrounding quotes', () => { + expect(values('info "he said \\"hi\\""')).toEqual(['info', 'he said "hi"']); + expect(values('info "back\\\\slash"')).toEqual(['info', 'back\\slash']); + }); + + it('leaves any other backslash literal, as Windows texture paths rely on', () => { + expect(values('url "textures\\wood.jpg"')).toEqual(['url', 'textures\\wood.jpg']); + }); + + it('reports an unterminated string without throwing', () => { + const result = tokenize('info "never closed'); + + expect(result.unterminatedString).toBe(true); + expect(result.tokens[1]).toEqual( + expect.objectContaining({ kind: 'string', value: 'never closed' }), + ); + }); + + it('handles CRLF and LF line endings identically', () => { + expect(values('#VRML V2.0 utf8\r\nShape {}\r\n')) + .toEqual(values('#VRML V2.0 utf8\nShape {}\n')); + }); + + it('strips a leading byte order mark', () => { + expect(values('Shape {}')).toEqual(['Shape', '{', '}']); + }); + + it('does not treat unbalanced braces as an error', () => { + expect(values('Group { children [ Shape {')).toEqual( + ['Group', '{', 'children', '[', 'Shape', '{'], + ); + }); + + it('records the source offset of each token', () => { + const [first, second] = tokenize(' Shape {').tokens; + + expect(first.index).toBe(2); + expect(second.index).toBe(8); + }); + + it('stops cleanly at maxTokens instead of running unbounded', () => { + const result = tokenize('a b c d e f', { maxTokens: 3 }); + + expect(result.truncated).toBe(true); + expect(result.tokens).toHaveLength(3); + }); + + it('defaults to a bounded token budget', () => { + expect(DEFAULT_MAX_TOKENS).toBeGreaterThan(0); + expect(tokenize('Shape {}').truncated).toBe(false); + }); + + describe('the budget check ignores trailing non-tokens', () => { + // `a b c` is exactly 3 tokens with maxTokens: 3, so nothing was cut off + // in any of these -- the budget was reached exactly at EOF, not before + // it, and reporting `truncated` for any of them would be a false + // positive a checker page or export would show as data loss that never + // happened. + it('reports complete at exactly maxTokens with nothing following', () => { + const result = tokenize('a b c', { maxTokens: 3 }); + + expect(result.truncated).toBe(false); + expect(result.tokens).toHaveLength(3); + }); + + it('reports complete at exactly maxTokens followed only by whitespace', () => { + const result = tokenize('a b c \n\t ', { maxTokens: 3 }); + + expect(result.truncated).toBe(false); + expect(result.tokens).toHaveLength(3); + }); + + it('reports complete at exactly maxTokens followed only by a comment', () => { + const result = tokenize('a b c # trailing comment, not a token', { maxTokens: 3 }); + + expect(result.truncated).toBe(false); + expect(result.tokens).toHaveLength(3); + }); + + it('still reports truncated when one real token follows the budget', () => { + const result = tokenize('a b c d', { maxTokens: 3 }); + + expect(result.truncated).toBe(true); + expect(result.tokens).toHaveLength(3); + }); + + it('still reports truncated when a real token follows trailing whitespace', () => { + const result = tokenize('a b c d', { maxTokens: 3 }); + + expect(result.truncated).toBe(true); + expect(result.tokens).toHaveLength(3); + }); + }); +}); diff --git a/api/src/libs/vrml/vrml-tokenizer.ts b/api/src/libs/vrml/vrml-tokenizer.ts new file mode 100644 index 00000000..95460360 --- /dev/null +++ b/api/src/libs/vrml/vrml-tokenizer.ts @@ -0,0 +1,176 @@ +/** + * A minimal, dependency-free lexer for VRML97 source text. + * + * This exists because the Mall checker needs to answer questions like "does this + * object contain a Sound node?" and "what is in its WorldInfo?" without shipping + * staff to an external VRML editor. Answering those with substring or regular + * expression matching over the raw file is wrong in both directions: `#` comments + * and quoted strings (notably `Script` `url` payloads and `WorldInfo` `info` + * entries) routinely contain node names, and a real node can be split across + * lines in ways a pattern will miss. + * + * So we tokenise first and answer questions over the token stream instead. This + * is deliberately NOT a parser - it builds no scene graph, resolves no PROTO, and + * computes no geometry. It only produces the lexical facts a human checker would + * otherwise read by eye. + */ + +/** The lexical classes this tokenizer distinguishes. */ +export type VrmlTokenKind = 'word' | 'string' | 'punct'; + +export interface VrmlToken { + kind: VrmlTokenKind; + /** + * For `string` tokens this is the decoded value with surrounding quotes removed + * and `\"` / `\\` escapes resolved - never the raw source slice. + */ + value: string; + /** Character offset of the token's first character in the source text. */ + index: number; +} + +export interface TokenizeOptions { + /** + * Upper bound on emitted tokens. Guards against a hostile or pathological + * upload consuming unbounded CPU. Exceeding it stops tokenisation cleanly + * rather than throwing. + */ + maxTokens?: number; +} + +export interface TokenizeResult { + tokens: VrmlToken[]; + /** True when `maxTokens` was hit and the token stream is incomplete. */ + truncated: boolean; + /** Set when the source ended inside an unterminated quoted string. */ + unterminatedString: boolean; +} + +export const DEFAULT_MAX_TOKENS = 500000; + +const PUNCTUATION = '{}[]'; + +/** + * VRML97 treats the comma as whitespace, so it never becomes a token. + * Everything else here is ordinary whitespace. + */ +function isWhitespace(character: string): boolean { + return character === ' ' + || character === '\t' + || character === '\n' + || character === '\r' + || character === '\f' + || character === ',' + || character === '\v'; +} + +function isWordBoundary(character: string): boolean { + return isWhitespace(character) + || PUNCTUATION.indexOf(character) !== -1 + || character === '"' + || character === '#'; +} + +/** + * Reads a quoted string starting at `start` (the opening quote). + * + * Only `\"` and `\\` are treated as escapes, matching VRML97. Any other + * backslash is a literal backslash, which matters because Windows-authored + * texture paths are full of them. + */ +function readString(text: string, start: number): { value: string; end: number; closed: boolean } { + let value = ''; + let position = start + 1; + + while (position < text.length) { + const character = text[position]; + + if (character === '\\') { + const next = text[position + 1]; + if (next === '"' || next === '\\') { + value += next; + position += 2; + continue; + } + value += character; + position += 1; + continue; + } + + if (character === '"') { + return { value, end: position + 1, closed: true }; + } + + value += character; + position += 1; + } + + return { value, end: position, closed: false }; +} + +/** + * Splits VRML97 source into words, strings and structural punctuation. + * + * Comments are dropped, but only when they genuinely start a comment - a `#` + * inside a quoted string is data, not a comment, and is preserved. + */ +export function tokenize(text: string, options: TokenizeOptions = {}): TokenizeResult { + const maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS; + const tokens: VrmlToken[] = []; + let unterminatedString = false; + let position = 0; + + // A byte order mark would otherwise become part of the first word token. + if (text.charCodeAt(0) === 0xfeff) { + position = 1; + } + + while (position < text.length) { + const character = text[position]; + + if (isWhitespace(character)) { + position += 1; + continue; + } + + if (character === '#') { + while (position < text.length && text[position] !== '\n') { + position += 1; + } + continue; + } + + // Checked only once whitespace and comments are already skipped, so the + // budget is spent on tokens, not on how much trailing filler happens to + // follow the last one. A file with exactly `maxTokens` real tokens must + // not be reported truncated just because a blank line or a comment comes + // after the last of them. + if (tokens.length >= maxTokens) { + return { tokens, truncated: true, unterminatedString }; + } + + if (character === '"') { + const result = readString(text, position); + tokens.push({ kind: 'string', value: result.value, index: position }); + if (!result.closed) { + unterminatedString = true; + } + position = result.end; + continue; + } + + if (PUNCTUATION.indexOf(character) !== -1) { + tokens.push({ kind: 'punct', value: character, index: position }); + position += 1; + continue; + } + + const start = position; + while (position < text.length && !isWordBoundary(text[position])) { + position += 1; + } + tokens.push({ kind: 'word', value: text.slice(start, position), index: start }); + } + + return { tokens, truncated: false, unterminatedString }; +} diff --git a/api/src/libs/vrml/worldinfo-compare.spec.ts b/api/src/libs/vrml/worldinfo-compare.spec.ts new file mode 100644 index 00000000..6fa7fcd5 --- /dev/null +++ b/api/src/libs/vrml/worldinfo-compare.spec.ts @@ -0,0 +1,261 @@ +import { scanVrml } from './vrml-scan'; +import { + ComparisonField, + ComparisonVerdict, + compareWorldInfo, + MallObjectFacts, +} from './worldinfo-compare'; + +const HEADER = '#VRML V2.0 utf8'; + +function scanOf(info: string[], title = 'Pocket Moon Playset') { + const entries = info.map(line => ` "${line}"`).join('\n'); + return scanVrml(`${HEADER}\nWorldInfo {\n title "${title}"\n info [\n${entries}\n ]\n}\n`); +} + +const FACTS: MallObjectFacts = { + name: 'Pocket Moon Playset', + creatorUsername: 'BassMekanik', + price: 75, + limit: null, + storeName: 'Toy Store', +}; + +function verdictFor(comparisons: { field: ComparisonField; verdict: ComparisonVerdict }[], + field: ComparisonField): ComparisonVerdict { + const found = comparisons.find(comparison => comparison.field === field); + return found ? found.verdict : ('NOT_FOUND' as ComparisonVerdict); +} + +describe('compareWorldInfo - the real production object', () => { + it('matches every field of Pocket Moon Playset', () => { + const scan = scanOf([ + 'Made By: BassMekanik', + 'Uploaded: August, 2026', + 'Store: Toy Store', + 'Limited To: UNLIMITED', + 'Mall Price: 75 CC', + 'Collection: No. 1 of 5', + ]); + + const { comparisons, interpreted } = compareWorldInfo(scan, FACTS); + + expect(verdictFor(comparisons, 'name')).toBe('MATCH'); + expect(verdictFor(comparisons, 'creator')).toBe('MATCH'); + expect(verdictFor(comparisons, 'price')).toBe('MATCH'); + expect(verdictFor(comparisons, 'limit')).toBe('MATCH'); + expect(verdictFor(comparisons, 'store')).toBe('MATCH'); + expect(interpreted.uploaded).toBe('August, 2026'); + }); +}); + +describe('compareWorldInfo - verdicts', () => { + it('reports MISMATCH on a differing creator', () => { + const scan = scanOf(['Made By: SomeoneElse']); + + expect(verdictFor(compareWorldInfo(scan, FACTS).comparisons, 'creator')).toBe('MISMATCH'); + }); + + it('reads a price out of "75 CC" and matches it', () => { + expect(verdictFor(compareWorldInfo(scanOf(['Mall Price: 75 CC']), FACTS).comparisons, 'price')) + .toBe('MATCH'); + }); + + it('reports MISMATCH on a differing price', () => { + expect(verdictFor(compareWorldInfo(scanOf(['Mall Price: 90 CC']), FACTS).comparisons, 'price')) + .toBe('MISMATCH'); + }); + + it('treats UNLIMITED wording as matching a NULL limit', () => { + ['UNLIMITED', 'unlimited', 'None', 'no limit'].forEach(word => { + expect(verdictFor(compareWorldInfo(scanOf([`Limited To: ${word}`]), FACTS).comparisons, + 'limit')).toBe('MATCH'); + }); + }); + + it('reports MISMATCH when WorldInfo says a number but CTR holds no limit', () => { + expect(verdictFor(compareWorldInfo(scanOf(['Limited To: 25']), FACTS).comparisons, 'limit')) + .toBe('MISMATCH'); + }); + + it('reports MISMATCH when WorldInfo says UNLIMITED but CTR holds a number', () => { + const facts = { ...FACTS, limit: 40 }; + + expect(verdictFor(compareWorldInfo(scanOf(['Limited To: UNLIMITED']), facts).comparisons, + 'limit')).toBe('MISMATCH'); + }); + + it('matches a numeric limit against the stored limit', () => { + const facts = { ...FACTS, limit: 40 }; + + expect(verdictFor(compareWorldInfo(scanOf(['Limited To: 40']), facts).comparisons, 'limit')) + .toBe('MATCH'); + }); + + it('reports NOT_FOUND when a field has no recognised entry', () => { + const scan = scanOf(['(c) August 2026 by Morning.star', 'A nice object']); + const { comparisons } = compareWorldInfo(scan, FACTS); + + expect(verdictFor(comparisons, 'creator')).toBe('NOT_FOUND'); + expect(verdictFor(comparisons, 'price')).toBe('NOT_FOUND'); + expect(verdictFor(comparisons, 'store')).toBe('NOT_FOUND'); + }); + + it('reports NOT_FOUND for the name when the object has no WorldInfo at all', () => { + const scan = scanVrml(`${HEADER}\nShape {}\n`); + + expect(verdictFor(compareWorldInfo(scan, FACTS).comparisons, 'name')).toBe('NOT_FOUND'); + }); +}); + +describe('compareWorldInfo - the Vivaty template creators leave blank', () => { + // Object 3341 on production is stocked with exactly this unfilled template. + it('reports UNPARSED rather than MISMATCH for blank template values', () => { + const scan = scanOf( + ['This Web3D Content was created with Vivaty Studio', 'Price:', 'Limit:', 'Artist:', 'Date:'], + 'Title', + ); + const { comparisons } = compareWorldInfo(scan, FACTS); + + expect(verdictFor(comparisons, 'price')).toBe('UNPARSED'); + expect(verdictFor(comparisons, 'limit')).toBe('UNPARSED'); + expect(verdictFor(comparisons, 'creator')).toBe('UNPARSED'); + // The title genuinely differs, so that one is a real mismatch. + expect(verdictFor(comparisons, 'name')).toBe('MISMATCH'); + }); +}); + +describe('compareWorldInfo - prefix handling', () => { + it('prefers the longer "Mall Price" over "Price"', () => { + const scan = scanOf(['Price: 10', 'Mall Price: 75 CC']); + const { interpreted } = compareWorldInfo(scan, FACTS); + + expect(interpreted.price).toBe('75 CC'); + }); + + it('prefers the longer "Limited To" over "Limit"', () => { + const scan = scanOf(['Limit: 10', 'Limited To: UNLIMITED']); + + expect(compareWorldInfo(scan, FACTS).interpreted.limit).toBe('UNLIMITED'); + }); + + it('is case-insensitive and tolerant of spacing around the colon', () => { + const scan = scanOf([' MADE BY : BassMekanik ']); + + expect(verdictFor(compareWorldInfo(scan, FACTS).comparisons, 'creator')).toBe('MATCH'); + }); + + it('accepts the Vivaty "Artist:" convention for the creator', () => { + expect(verdictFor(compareWorldInfo(scanOf(['Artist: BassMekanik']), FACTS).comparisons, + 'creator')).toBe('MATCH'); + }); +}); + +describe('compareWorldInfo - unresolved CTR semantics', () => { + it('refuses to compare a stored limit of 0 and explains why', () => { + const facts = { ...FACTS, limit: 0 }; + const comparison = compareWorldInfo(scanOf(['Limited To: UNLIMITED']), facts) + .comparisons.find(entry => entry.field === 'limit'); + + expect(comparison.verdict).toBe('UNPARSED'); + expect(comparison.note).toContain('unresolved'); + }); + + it('never compares the uploaded date, only extracts it', () => { + const { interpreted, comparisons } = compareWorldInfo(scanOf(['Uploaded: August, 2026']), + FACTS); + + expect(interpreted.uploaded).toBe('August, 2026'); + expect(comparisons.map(entry => entry.field)).not.toContain('uploaded'); + }); +}); + +describe('compareWorldInfo - deleted creators', () => { + it('reports UNPARSED, never a mismatch, when CTR has no creator', () => { + const facts = { ...FACTS, creatorUsername: null }; + const comparison = compareWorldInfo(scanOf(['Made By: BassMekanik']), facts) + .comparisons.find(entry => entry.field === 'creator'); + + expect(comparison.verdict).toBe('UNPARSED'); + expect(comparison.ctrValue).toBeNull(); + }); +}); + +describe('compareWorldInfo - multiple WorldInfo nodes', () => { + it('uses the first node, leaving the scanner to flag that there are several', () => { + const scan = scanVrml( + `${HEADER}\nWorldInfo { title "First" }\nWorldInfo { title "Second" }\n`, + ); + const facts = { ...FACTS, name: 'First' }; + + expect(verdictFor(compareWorldInfo(scan, facts).comparisons, 'name')).toBe('MATCH'); + expect(scan.warnings).toContain('multiple_worldinfo'); + }); +}); + +describe('compareWorldInfo - the label has to actually be the label', () => { + it('does not let a longer word register as a recognised field', () => { + const scan = scanOf(['Storehouse: Somewhere Else', 'Pricey: 5']); + const { comparisons, interpreted } = compareWorldInfo(scan, FACTS); + + expect(verdictFor(comparisons, 'store')).toBe('NOT_FOUND'); + expect(verdictFor(comparisons, 'price')).toBe('NOT_FOUND'); + expect(interpreted.store).toBeNull(); + expect(interpreted.price).toBeNull(); + }); + + it('still reads the label when the colon is absent', () => { + const scan = scanOf(['Store Toy Store']); + + expect(verdictFor(compareWorldInfo(scan, FACTS).comparisons, 'store')).toBe('MATCH'); + }); +}); + +describe('compareWorldInfo - numeric entries are read strictly', () => { + it('refuses trailing rubbish rather than reporting a confident match', () => { + ['75xyz', 'USD 75', '75.5', 'not 75'].forEach(value => { + expect(verdictFor(compareWorldInfo(scanOf([`Mall Price: ${value}`]), FACTS).comparisons, + 'price')).toBe('UNPARSED'); + }); + }); + + it('reads a group-separated number as one value', () => { + const facts = { ...FACTS, price: 1500 }; + + expect(verdictFor(compareWorldInfo(scanOf(['Mall Price: 1,500 CC']), facts).comparisons, + 'price')).toBe('MATCH'); + }); + + it('reports UNPARSED rather than MISMATCH when CTR holds no price', () => { + const facts = { ...FACTS, price: null }; + const comparison = compareWorldInfo(scanOf(['Mall Price: 75 CC']), facts) + .comparisons.find(entry => entry.field === 'price'); + + expect(comparison?.verdict).toBe('UNPARSED'); + expect(comparison?.note).toMatch(/no price to compare/i); + }); +}); + +describe('compareWorldInfo - a field declared twice', () => { + it('refuses to pick a winner when two entries disagree', () => { + const scan = scanOf(['Mall Price: 75 CC', 'Mall Price: 100 CC']); + const comparison = compareWorldInfo(scan, FACTS) + .comparisons.find(entry => entry.field === 'price'); + + expect(comparison?.verdict).toBe('UNPARSED'); + expect(comparison?.note).toMatch(/declares this field 2 times/i); + expect(comparison?.note).toMatch(/staff review/i); + }); + + it('treats identical repeated entries as the one value they agree on', () => { + const scan = scanOf(['Mall Price: 75 CC', 'Mall Price: 75 CC']); + + expect(verdictFor(compareWorldInfo(scan, FACTS).comparisons, 'price')).toBe('MATCH'); + }); + + it('keeps every literal entry available regardless of the verdict', () => { + const scan = scanOf(['Mall Price: 75 CC', 'Mall Price: 100 CC']); + + expect(scan.worldInfo[0].info).toEqual(['Mall Price: 75 CC', 'Mall Price: 100 CC']); + }); +}); diff --git a/api/src/libs/vrml/worldinfo-compare.ts b/api/src/libs/vrml/worldinfo-compare.ts new file mode 100644 index 00000000..50497096 --- /dev/null +++ b/api/src/libs/vrml/worldinfo-compare.ts @@ -0,0 +1,417 @@ +import { VrmlScan, WorldInfoNode } from './vrml-scan'; + +/** + * Compares the `WorldInfo` a creator embedded in their object against the record + * CTR actually holds, so a Mall checker can see agreement or disagreement at a + * glance instead of reading the WRL in an external editor. + * + * Everything here is ADVISORY. No verdict blocks, triggers, or influences any + * moderation action - accept and reject remain entirely a human decision, and a + * MISMATCH is a prompt to look, not a reason to refuse. + */ + +export type ComparisonVerdict = 'MATCH' | 'MISMATCH' | 'NOT_FOUND' | 'UNPARSED'; + +export type ComparisonField = 'name' | 'creator' | 'price' | 'limit' | 'store'; + +export interface FieldComparison { + field: ComparisonField; + verdict: ComparisonVerdict; + /** The full `info[]` entry the value came from, verbatim, or null. */ + worldInfoLine: string | null; + /** The portion after the recognised prefix, verbatim, or null. */ + worldInfoValue: string | null; + /** What CTR holds, for side-by-side display. */ + ctrValue: string | number | null; + /** Present when a verdict needs explaining rather than acting on. */ + note?: string; +} + +export interface InterpretedWorldInfo { + title: string | null; + creator: string | null; + price: string | null; + limit: string | null; + store: string | null; + /** + * Extracted for display only. Deliberately never compared against + * `object.created_at`: CTR's MySQL/Node timezone configuration is unpinned, so + * a month/year comparison would be unreliable at month boundaries. + */ + uploaded: string | null; +} + +export interface MallObjectFacts { + name: string | null; + creatorUsername: string | null; + price: number | null; + limit: number | null; + storeName: string | null; +} + +export interface WorldInfoComparison { + interpreted: InterpretedWorldInfo; + comparisons: FieldComparison[]; +} + +/** + * Recognised `info[]` prefixes, longest-first within each group so that + * "Mall Price:" wins over "Price:". + * + * These conventions were read off real Mall objects (the "Made By:" family) and + * off the Vivaty Studio template many creators start from (the "Artist:" / + * "Price:" / "Limit:" family). Editing this table is the single place to change + * which conventions CTR recognises. + */ +const PREFIXES: { [key: string]: string[] } = { + creator: ['made by', 'created by', 'creator', 'artist'], + price: ['mall price', 'price'], + limit: ['limited to', 'limit'], + store: ['store'], + uploaded: ['uploaded', 'date'], +}; + +const UNLIMITED_WORDS = ['unlimited', 'none', 'no limit', 'n/a']; + +interface PrefixMatch { + line: string; + value: string; +} + +interface PrefixResolution { + /** First entry carrying the highest-priority label present, or null. */ + match: PrefixMatch | null; + /** Every entry carrying that same label, in file order. */ + all: PrefixMatch[]; + /** True when those entries disagree, so no single value can be compared. */ + conflicting: boolean; +} + +/** + * Finds every `info[]` entry carrying one of `prefixes`, allowing any + * surrounding whitespace and any case. The separator colon is optional because + * real objects are inconsistent about it. + * + * Prefixes are tried in order and the first one that matches anything wins, so + * a more specific label ("Mall Price") still beats a general one ("Price") on + * the same object. The label must actually end where the prefix does -- + * matching on a bare string prefix let "Storehouse:" register as "Store:". + */ +function findPrefixed(info: string[], prefixes: string[]): PrefixMatch[] { + for (const prefix of prefixes) { + const matches: PrefixMatch[] = []; + for (const line of info) { + const trimmed = line.trim(); + const lower = trimmed.toLowerCase(); + if (lower.indexOf(prefix) !== 0) { + continue; + } + const rest = trimmed.slice(prefix.length); + if (rest !== '' && !/^\s*:/.test(rest) && !/^\s/.test(rest)) { + continue; + } + matches.push({ line, value: rest.replace(/^\s*:?\s*/, '') }); + } + if (matches.length > 0) { + return matches; + } + } + return []; +} + +function resolvePrefixed(info: string[], prefixes: string[]): PrefixResolution { + const all = findPrefixed(info, prefixes); + if (all.length === 0) { + return { match: null, all, conflicting: false }; + } + const first = normalise(all[0].value); + return { + match: all[0], + all, + conflicting: all.some(entry => normalise(entry.value) !== first), + }; +} + +/** + * A field declared twice with different values has no answer, only a question. + * Picking the first silently turns that into a confident verdict, so it is + * reported as unparseable and handed to staff instead. + */ +function conflicted( + field: ComparisonField, + resolution: PrefixResolution, + ctrValue: string | number | null, +): FieldComparison | null { + if (!resolution.conflicting) { + return null; + } + const values = resolution.all.map(entry => `"${entry.value}"`).join(', '); + return { + field, + verdict: 'UNPARSED', + worldInfoLine: resolution.all[0].line, + worldInfoValue: resolution.all[0].value, + ctrValue, + note: `The object declares this field ${resolution.all.length} times with different ` + + `values (${values}), so there is no single value to compare. Staff review required.`, + }; +} + +function normalise(value: string): string { + return value.trim().replace(/\s+/g, ' ').toLowerCase(); +} + +/** + * Reads the integer out of a value such as "75", "75 CC", "1,500 CC" or "25 max". + * + * Deliberately strict. Scanning for the first digit run reads 75 out of + * "USD 75.50" and then reports a confident MATCH against a stored 75, which is + * worse than admitting the entry could not be read. Group separators are + * accepted because "1,500" otherwise parsed as 1. + */ +const INTEGER_ENTRY = /^([+-]?\d{1,3}(?:,\d{3})+|[+-]?\d+)\s*(?:cc|credits?|max)?$/i; + +function parseInteger(value: string): number | null { + const match = INTEGER_ENTRY.exec(value.trim()); + if (!match) { + return null; + } + return Number.parseInt(match[1].replace(/,/g, ''), 10); +} + +function compareText( + field: ComparisonField, + resolution: PrefixResolution, + ctrValue: string | null, +): FieldComparison { + const conflict = conflicted(field, resolution, ctrValue); + if (conflict) { + return conflict; + } + const match = resolution.match; + if (!match) { + return { field, verdict: 'NOT_FOUND', worldInfoLine: null, worldInfoValue: null, ctrValue }; + } + if (match.value.trim() === '') { + return { + field, + verdict: 'UNPARSED', + worldInfoLine: match.line, + worldInfoValue: match.value, + ctrValue, + note: 'The entry is present but its value is blank.', + }; + } + if (ctrValue === null) { + return { + field, + verdict: 'UNPARSED', + worldInfoLine: match.line, + worldInfoValue: match.value, + ctrValue, + note: 'CTR holds no value to compare against.', + }; + } + return { + field, + verdict: normalise(match.value) === normalise(ctrValue) ? 'MATCH' : 'MISMATCH', + worldInfoLine: match.line, + worldInfoValue: match.value, + ctrValue, + }; +} + +function comparePrice( + resolution: PrefixResolution, + ctrPrice: number | null, +): FieldComparison { + const conflict = conflicted('price', resolution, ctrPrice); + if (conflict) { + return conflict; + } + const match = resolution.match; + if (!match) { + return { + field: 'price', + verdict: 'NOT_FOUND', + worldInfoLine: null, + worldInfoValue: null, + ctrValue: ctrPrice, + }; + } + const parsed = parseInteger(match.value); + if (parsed === null) { + return { + field: 'price', + verdict: 'UNPARSED', + worldInfoLine: match.line, + worldInfoValue: match.value, + ctrValue: ctrPrice, + note: match.value.trim() === '' + ? 'The entry is present but its value is blank.' + : 'No number could be read from the entry.', + }; + } + if (ctrPrice === null) { + return { + field: 'price', + verdict: 'UNPARSED', + worldInfoLine: match.line, + worldInfoValue: match.value, + ctrValue: ctrPrice, + note: 'CTR holds no price to compare against.', + }; + } + return { + field: 'price', + verdict: parsed === ctrPrice ? 'MATCH' : 'MISMATCH', + worldInfoLine: match.line, + worldInfoValue: match.value, + ctrValue: ctrPrice, + }; +} + +/** + * Limit needs its own comparison because CTR's own `limit = 0` semantics are + * unresolved: the Update Limit prompt says "0 makes it Unlimited", but the + * Out-of-Stock view only treats NULL that way. Rather than pick a side, a stored + * 0 is reported as UNPARSED with an explanation. + */ +function compareLimit( + resolution: PrefixResolution, + ctrLimit: number | null, +): FieldComparison { + const ctrValue = ctrLimit; + + const conflict = conflicted('limit', resolution, ctrValue); + if (conflict) { + return conflict; + } + const match = resolution.match; + if (!match) { + return { + field: 'limit', + verdict: 'NOT_FOUND', + worldInfoLine: null, + worldInfoValue: null, + ctrValue, + }; + } + + if (ctrLimit === 0) { + return { + field: 'limit', + verdict: 'UNPARSED', + worldInfoLine: match.line, + worldInfoValue: match.value, + ctrValue, + note: 'CTR stores limit = 0, whose meaning is unresolved (the Update Limit prompt ' + + 'calls it unlimited, the Out of Stock view does not). Not compared.', + }; + } + + const value = match.value.trim(); + if (value === '') { + return { + field: 'limit', + verdict: 'UNPARSED', + worldInfoLine: match.line, + worldInfoValue: match.value, + ctrValue, + note: 'The entry is present but its value is blank.', + }; + } + + const saysUnlimited = UNLIMITED_WORDS.indexOf(normalise(value)) !== -1; + if (saysUnlimited) { + return { + field: 'limit', + verdict: ctrLimit === null ? 'MATCH' : 'MISMATCH', + worldInfoLine: match.line, + worldInfoValue: match.value, + ctrValue, + }; + } + + const parsed = parseInteger(value); + if (parsed === null) { + return { + field: 'limit', + verdict: 'UNPARSED', + worldInfoLine: match.line, + worldInfoValue: match.value, + ctrValue, + note: 'No number and no "unlimited" wording could be read from the entry.', + }; + } + + return { + field: 'limit', + verdict: parsed === ctrLimit ? 'MATCH' : 'MISMATCH', + worldInfoLine: match.line, + worldInfoValue: match.value, + ctrValue, + }; +} + +function compareTitle(title: string | null, ctrName: string | null): FieldComparison { + if (title === null) { + return { + field: 'name', + verdict: 'NOT_FOUND', + worldInfoLine: null, + worldInfoValue: null, + ctrValue: ctrName, + }; + } + if (ctrName === null) { + return { + field: 'name', + verdict: 'UNPARSED', + worldInfoLine: title, + worldInfoValue: title, + ctrValue: null, + note: 'CTR holds no value to compare against.', + }; + } + return { + field: 'name', + verdict: normalise(title) === normalise(ctrName) ? 'MATCH' : 'MISMATCH', + worldInfoLine: title, + worldInfoValue: title, + ctrValue: ctrName, + }; +} + +/** + * Uses the FIRST WorldInfo node when an object contains more than one. The + * `multiple_worldinfo` finding from the scanner tells the checker to say so. + */ +export function compareWorldInfo(scan: VrmlScan, facts: MallObjectFacts): WorldInfoComparison { + const node: WorldInfoNode = scan.worldInfo[0] || { title: null, info: [] }; + const info = node.info; + + const creator = resolvePrefixed(info, PREFIXES.creator); + const price = resolvePrefixed(info, PREFIXES.price); + const limit = resolvePrefixed(info, PREFIXES.limit); + const store = resolvePrefixed(info, PREFIXES.store); + const uploaded = resolvePrefixed(info, PREFIXES.uploaded); + + return { + interpreted: { + title: node.title, + creator: creator.match ? creator.match.value : null, + price: price.match ? price.match.value : null, + limit: limit.match ? limit.match.value : null, + store: store.match ? store.match.value : null, + uploaded: uploaded.match ? uploaded.match.value : null, + }, + comparisons: [ + compareTitle(node.title, facts.name), + compareText('creator', creator, facts.creatorUsername), + comparePrice(price, facts.price), + compareLimit(limit, facts.limit), + compareText('store', store, facts.storeName), + ], + }; +} diff --git a/api/src/repositories/mall-object/mall-object.repository.spec.ts b/api/src/repositories/mall-object/mall-object.repository.spec.ts new file mode 100644 index 00000000..0e7fcfd4 --- /dev/null +++ b/api/src/repositories/mall-object/mall-object.repository.spec.ts @@ -0,0 +1,194 @@ +import dotenv from 'dotenv'; + +// `knexfile` reads `../.env`, which resolves correctly for the running API but +// not for jest, whose cwd is `api/`. Loaded here so these tests talk to the +// same database the API does. +dotenv.config(); + +import { Db } from '../../db/db.class'; +import { MallRepository } from './mall-object.repository'; + +/** + * `getStoresByObjectIds`'s placement columns, against a real MySQL. + * + * `getAllStoresByObjectId` and `getStoresByObjectIds` must expose the exact + * same shape -- `mall_position`/`mall_rotation` aliases included -- since the + * export's `buildObject` reads those fields regardless of which one supplied + * the store map. A mocked repository cannot catch a query that silently + * dropped two selected columns; only running the real SQL can. + * + * These tests write fixture rows and delete them again, so a reachable + * database is deliberately not enough to run them -- see + * `integrationDbAuthorized`. Without the explicit opt-in they register as + * skipped, never as a silent pass. + */ + +/** + * Whether this spec may write fixture rows to the configured database. + * + * `DB_HOST` and `DB_DATABASE` only prove a database is reachable. The API's + * ordinary environment defines both, and they can name a shared or production + * database where fixture INSERTs and cleanup DELETEs must never run. The + * destructive path therefore additionally requires `CTR_INTEGRATION_TEST_DB` + * to name the configured database exactly -- an explicit, per-environment + * assertion that this specific database is disposable and dedicated to + * integration testing, not merely present in the environment. + */ +function integrationDbAuthorized(env: NodeJS.ProcessEnv): boolean { + return Boolean( + env.DB_HOST + && env.DB_DATABASE + && env.CTR_INTEGRATION_TEST_DB + && env.CTR_INTEGRATION_TEST_DB === env.DB_DATABASE, + ); +} + +const PLACEMENT = { + position: '{"x":1,"y":2,"z":3}', + rotation: '{"x":0,"y":1,"z":0,"angle":1.5}', +}; + +/** + * The opt-in itself, provable without any database: ordinary configuration + * must never arm the destructive path. + */ +describe('integration opt-in for MallRepository fixtures', () => { + it('is never granted by ordinary database configuration alone', () => { + expect(integrationDbAuthorized({ DB_HOST: 'db', DB_DATABASE: 'cybertown' })).toBe(false); + }); + + it('is not granted when the opt-in names a different database', () => { + expect(integrationDbAuthorized({ + DB_HOST: 'db', + DB_DATABASE: 'cybertown', + CTR_INTEGRATION_TEST_DB: 'cybertown_test', + })).toBe(false); + }); + + it('is not granted by an empty opt-in', () => { + expect(integrationDbAuthorized({ + DB_HOST: 'db', + DB_DATABASE: 'cybertown', + CTR_INTEGRATION_TEST_DB: '', + })).toBe(false); + }); + + it('is granted only when the opt-in names the configured database exactly', () => { + expect(integrationDbAuthorized({ + DB_HOST: 'db', + DB_DATABASE: 'cybertown_itest', + CTR_INTEGRATION_TEST_DB: 'cybertown_itest', + })).toBe(true); + }); +}); + +describe('MallRepository.getStoresByObjectIds (real database)', () => { + let db: Db; + let repository: MallRepository; + const authorized = integrationDbAuthorized(process.env); + + /** + * Ids the database itself minted for this run's fixtures -- the only rows + * cleanup may delete. Auto-generated instead of predictable constants, so a + * run can never collide with, or delete, a row it did not insert. + */ + let fixturePlaceIds: number[] = []; + let fixtureObjectIds: number[] = []; + let placeId: number; + let placedObjectId: number; + let unplacedObjectId: number; + + beforeAll(async () => { + if (!authorized) { + return; + } + db = new Db(); + await db.knex.raw('select 1'); + }); + + afterAll(async () => { + if (db) { + await db.knex.destroy(); + } + }); + + beforeEach(async () => { + if (!authorized) { + return; + } + repository = new MallRepository(db); + // Inserted one row at a time because MySQL returns only the first + // auto-generated id of a batch, and every minted id must be captured. + [placeId] = await db.knex('place').insert({ + name: 'Fixture Store', + type: 'shop', + status: 1, + }); + fixturePlaceIds.push(placeId); + [placedObjectId] = await db.knex('object').insert({ + filename: 'placed-fixture.wrl', + name: 'Placed Fixture', + }); + fixtureObjectIds.push(placedObjectId); + [unplacedObjectId] = await db.knex('object').insert({ + filename: 'unplaced-fixture.wrl', + name: 'Unplaced Fixture', + }); + fixtureObjectIds.push(unplacedObjectId); + await db.knex('mall_object').insert({ + object_id: placedObjectId, + place_id: placeId, + position: PLACEMENT.position, + rotation: PLACEMENT.rotation, + }); + }); + + afterEach(async () => { + if (!authorized) { + return; + } + // Scoped to the ids this run's own inserts returned, never to a + // predictable constant an unrelated row could happen to occupy. + if (fixtureObjectIds.length > 0) { + await db.knex('mall_object').whereIn('object_id', fixtureObjectIds).del(); + await db.knex('object').whereIn('id', fixtureObjectIds).del(); + } + if (fixturePlaceIds.length > 0) { + await db.knex('place').whereIn('id', fixturePlaceIds).del(); + } + fixtureObjectIds = []; + fixturePlaceIds = []; + }); + + /** Registers as a real test, or as a visible skip -- never a silent pass. */ + const dbTest = authorized ? it : it.skip; + + // Runs precisely when the fixtures above may not: without the opt-in this + // proves the destructive path was never armed -- no connection was even + // opened, so no INSERT or DELETE can have happened. + (authorized ? it.skip : it)('opens no database connection without the opt-in', () => { + expect(db).toBeUndefined(); + }); + + dbTest( + 'exposes the same mall_position/mall_rotation aliases as getAllStoresByObjectId', + async () => { + const scoped = await repository.getStoresByObjectIds([placedObjectId, unplacedObjectId]); + const all = await repository.getAllStoresByObjectId(); + + expect(scoped[placedObjectId]).toBeDefined(); + expect(scoped[placedObjectId].mall_position).toBe(PLACEMENT.position); + expect(scoped[placedObjectId].mall_rotation).toBe(PLACEMENT.rotation); + expect(scoped[unplacedObjectId]).toBeUndefined(); + + expect(all[placedObjectId].mall_position).toBe(scoped[placedObjectId].mall_position); + expect(all[placedObjectId].mall_rotation).toBe(scoped[placedObjectId].mall_rotation); + }, + ); + + dbTest('returns nothing for ids outside the requested set', async () => { + const scoped = await repository.getStoresByObjectIds([unplacedObjectId]); + + expect(Object.keys(scoped)).toEqual([]); + }); +}); diff --git a/api/src/repositories/mall-object/mall-object.repository.ts b/api/src/repositories/mall-object/mall-object.repository.ts index d9abd970..716cedf8 100644 --- a/api/src/repositories/mall-object/mall-object.repository.ts +++ b/api/src/repositories/mall-object/mall-object.repository.ts @@ -1,61 +1,148 @@ -import { Service } from 'typedi'; - -import { Db } from '../../db/db.class'; -import { MallObject } from '../../types/models'; - -/** Repository for fetching/interacting with mall data in the database. */ -@Service() -export class MallRepository { - - constructor(private db: Db) {} - - public async addToMallObjects(objectId: number): Promise { - await this.db.mallObject.insert({object_id: objectId}); - } - - public async getMallForSale( - placeId: number): Promise { - const objects = await this.db.mallObject - .select('object.*', 'mall_object.place_id', 'mall_object.position', 'mall_object.rotation') - .where('place_id', placeId) - .where('object.status', 1) - .join('object', 'object.id', 'mall_object.object_id') - .join('place', 'place.id', 'mall_object.place_id'); - return objects; - } - - public async getStore(objectId: number): Promise { - const place = await this.db.mallObject - .select('place.*') - .where('mall_object.object_id', objectId) - .join('place', 'place.id', 'mall_object.place_id'); - return place; - } - - public async findByObjectId(objectId: number): Promise { - const object = await this.db.mallObject.where({object_id: objectId}); - return object; - } - - public async updateObjectPlace( - mallObjectId: number, - shopId: number, - ): Promise { - await this.db.mallObject.where({ object_id: mallObjectId }).update({ - place_id: shopId, - position: '{"x":0.0,"y":1.75,"z":0.0}', - rotation: '{"x":0,"y":0,"z":0,"angle":0}', - }); - } - - public async updateObjectPlacement( - mallObjectId: number, - positionStr: string, - rotationStr: string, - ): Promise { - await this.db.mallObject.where({ object_id: mallObjectId }).update({ - position: positionStr, - rotation: rotationStr, - }); - } -} +import { Knex } from 'knex'; +import { Service } from 'typedi'; + +import { Db } from '../../db/db.class'; +import { MallObject, Object as ObjectModel, Place } from 'models'; + +/** A place row carrying the object it is the store for, plus its placement. */ +export interface StoreRow extends Place { + object_id: number; + mall_position?: string; + mall_rotation?: string; +} + +/** An object on sale in a store, with the place and placement columns joined on. */ +export interface MallForSaleRow extends ObjectModel { + place_id: number; + position: string; + rotation: string; +} + +/** Repository for fetching/interacting with mall data in the database. */ +@Service() +export class MallRepository { + + constructor(private db: Db) {} + + /** + * Places an object in the Mall. + * + * Joins the caller's transaction when given one. Approval holds a lock on + * the parent `object` row, and this insert takes a foreign-key lock on that + * same row -- on a separate connection it would simply wait for a + * transaction that is waiting for it. + */ + public async addToMallObjects(objectId: number, trx?: Knex.Transaction): Promise { + const query = this.db.mallObject; + if (trx) { + query.transacting(trx); + } + await query.insert({object_id: objectId}); + } + + public async getMallForSale( + placeId: number): Promise { + const objects = await this.db.mallObject + .select('object.*', 'mall_object.place_id', 'mall_object.position', 'mall_object.rotation') + .where('place_id', placeId) + .where('object.status', 1) + .join('object', 'object.id', 'mall_object.object_id') + .join('place', 'place.id', 'mall_object.place_id'); + return objects; + } + + public async getStore(objectId: number): Promise { + const place = await this.db.mallObject + .select('place.*') + .where('mall_object.object_id', objectId) + .join('place', 'place.id', 'mall_object.place_id'); + return place; + } + + + /** + * The store each of many objects sits in, in one query, for list pages that + * would otherwise ask once per row. + */ + public async getStoresByObjectIds( + objectIds: number[], + ): Promise<{ [objectId: number]: StoreRow }> { + const stores: { [objectId: number]: StoreRow } = {}; + if (!objectIds.length) { + return stores; + } + // Placement rides along here rather than being joined onto the export's + // page query, same as `getAllStoresByObjectId`: this already collapses to + // one row per object, so it cannot fan the page out. + const rows = await this.db.mallObject + .select( + 'place.*', + 'mall_object.object_id', + 'mall_object.position as mall_position', + 'mall_object.rotation as mall_rotation', + ) + .whereIn('mall_object.object_id', objectIds) + .join('place', 'place.id', 'mall_object.place_id'); + rows.forEach((row: StoreRow) => { + if (!stores[row.object_id]) { + stores[row.object_id] = row; + } + }); + return stores; + } + + /** The store every placed object sits in, in one query. */ + public async getAllStoresByObjectId(): Promise<{ [objectId: number]: StoreRow }> { + const stores: { [objectId: number]: StoreRow } = {}; + // Placement rides along here rather than being joined onto the export's + // page query: this already collapses to one row per object, so it cannot + // fan the page out. The columns are aliased because `place.*` owns the + // unprefixed names. + const rows = await this.db.mallObject + .select( + 'place.*', + 'mall_object.object_id', + 'mall_object.position as mall_position', + 'mall_object.rotation as mall_rotation', + ) + .join('place', 'place.id', 'mall_object.place_id'); + rows.forEach((row: StoreRow) => { + if (!stores[row.object_id]) { + stores[row.object_id] = row; + } + }); + return stores; + } + public async findByObjectId( + objectId: number, + trx?: Knex.Transaction, + ): Promise { + const query = this.db.mallObject.where({object_id: objectId}); + if (trx) { + query.transacting(trx); + } + return await query; + } + + public async updateObjectPlace( + mallObjectId: number, + shopId: number, + ): Promise { + await this.db.mallObject.where({ object_id: mallObjectId }).update({ + place_id: shopId, + position: '{"x":0.0,"y":1.75,"z":0.0}', + rotation: '{"x":0,"y":0,"z":0,"angle":0}', + }); + } + + public async updateObjectPlacement( + mallObjectId: number, + positionStr: string, + rotationStr: string, + ): Promise { + await this.db.mallObject.where({ object_id: mallObjectId }).update({ + position: positionStr, + rotation: rotationStr, + }); + } +} diff --git a/api/src/repositories/member/member.repository.ts b/api/src/repositories/member/member.repository.ts index d4c3396b..87605f92 100644 --- a/api/src/repositories/member/member.repository.ts +++ b/api/src/repositories/member/member.repository.ts @@ -1,8 +1,34 @@ +import { Knex } from 'knex'; import { Service } from 'typedi'; import { Db } from '../../db/db.class'; +import { CountRow } from '../row.types'; import { Member, Wallet } from 'models'; import { knex } from '../../db'; +/** + * A place id from the member table, plus the fields `MemberService` attaches to + * it afterwards while building the active-places list. + */ +/** + * An online member, plus the flags `MemberController` attaches while building + * the online list. + */ +export interface OnlineUserRow { + id: number | null; + username: string; + hasHome?: boolean; + security?: boolean; +} + +export interface ActivePlaceRow { + place_id: number; + name?: string; + slug?: string; + type?: string; + username?: string; + count?: number; +} + /** Repository for interacting with member table data in the database. */ @Service() export class MemberRepository { @@ -39,52 +65,74 @@ export class MemberRepository { * @param memberId id of member to search for * @returns promise resolving in the found member object, or rejecting on error */ - public async findById(memberId: number): Promise { - return this.find({ id: memberId }); + public async findById(memberId: number, trx?: Knex.Transaction): Promise { + if (!trx) { + return this.find({ id: memberId }); + } + // Read inside the caller's transaction so the wallet id used for a refund + // is the one that transaction will actually write against. + const [member] = await this.db.member.transacting(trx).where({ id: memberId }); + return member; + } + + /** + * Usernames for many members in one query, for list pages that would + * otherwise call `findById` once per row. + */ + public async findByIds(memberIds: number[]): Promise<{ [memberId: number]: Member }> { + const members: { [memberId: number]: Member } = {}; + if (!memberIds.length) { + return members; + } + const rows = await this.db.member.whereIn('id', memberIds); + rows.forEach((row: Member) => { + members[row.id] = row; + }); + return members; } - public async findIdByUsername(username: string): Promise { + public async findIdByUsername(username: string): Promise[]> { return this.db.knex .select('id') .from('member') .where('username', username); } - public async getMemberTotal(): Promise { + public async getMemberTotal(): Promise { return this.db.knex - .count('id as count') + .count('id as count') .from('member'); } - public async countByDuration(time: Date): Promise { + public async countByDuration(time: Date): Promise { return this.db.knex - .count('id as count') + .count('id as count') .from('member') .where('last_activity', '>=', time); } - public async getNewestMembers(): Promise { + public async getNewestMembers(): Promise { return this.db.knex .from('member') .limit(5) .orderBy('id', 'desc'); } - public async countNewUsers(time: Date): Promise { + public async countNewUsers(time: Date): Promise { return this.db.knex - .count('id as count') + .count('id as count') .from('member') .where('created_at', '>=', time); } - public async check3d(username: string): Promise { + public async check3d(username: string): Promise[]> { return this.db.knex .select('is_3d') .from('member') .where('username', username); } - public async findOnlineUsers(current: Date): Promise { + public async findOnlineUsers(current: Date): Promise { return this.db.knex .select('id', 'username') .from('member') @@ -92,14 +140,14 @@ export class MemberRepository { .orderBy('username', 'ASC'); } - public async findByWalletId(walletID: number): Promise { + public async findByWalletId(walletID: number): Promise[]> { return this.db.knex .select('username') .from('member') .where('wallet_id', walletID); } - public async getActivePlaces(current: Date): Promise { + public async getActivePlaces(current: Date): Promise { return this.db.knex .select('place_id') .from('member') @@ -132,22 +180,26 @@ export class MemberRepository { * @param search * @return number */ - public async getTotal(search: string): Promise { + public async getTotal(search: string): Promise { return knex - .count('id as count') + .count('id as count') .from('member') .where(this.like('username', search)); } - public async countByPlaceId(placeId: number, active: Date): Promise { + public async countByPlaceId(placeId: number, active: Date): Promise { return knex - .count('id as count') + .count('id as count') .from('member') .where('place_id', placeId) .where('last_activity', '>=', active); } - public async searchUsers(search: string, limit: number, offset: number): Promise { + public async searchUsers( + search: string, + limit: number, + offset: number, + ): Promise[]> { return knex .select( 'id', @@ -169,7 +221,7 @@ export class MemberRepository { await this.db.member.where({ id: memberId }).update(props); } - public async removeAccount(id: number): Promise { + public async removeAccount(id: number): Promise { await this.db.member .where('id', id) .del(); diff --git a/api/src/repositories/object-instance/object-instance.repository.ts b/api/src/repositories/object-instance/object-instance.repository.ts index e85fd1fe..a3075520 100644 --- a/api/src/repositories/object-instance/object-instance.repository.ts +++ b/api/src/repositories/object-instance/object-instance.repository.ts @@ -1,7 +1,21 @@ import { Service } from 'typedi'; import {knex} from '../../db'; import { Db } from '../../db/db.class'; -import { ObjectInstance, Object } from 'models'; +import { CountRow } from '../row.types'; +import { ObjectInstance } from 'models'; + +/** One row of a `count(...) ... group by object_id` result. */ +interface GroupedCountRow { + object_id: number; + total: number | string; +} + +/** An owned object instance joined with the fields needed to render it. */ +export interface BackpackRow extends ObjectInstance { + filename: string; + directory: string; + name: string; +} @Service() export class ObjectInstanceRepository { @@ -95,7 +109,7 @@ export class ObjectInstanceRepository { }); } - public async seizedObjects(): Promise { + public async seizedObjects(): Promise { const seizedObjects = await this.db.objectInstance.where({ member_id: null, }); @@ -116,7 +130,7 @@ export class ObjectInstanceRepository { public async updateObjectInstanceOwner( objectId: number, buyerId: number, - ): Promise { + ): Promise { return knex('object_instance') .where('id', objectId) .update({ @@ -129,7 +143,7 @@ export class ObjectInstanceRepository { public async updateObjectInstanceName( objectId: number, objectName: string, - ): Promise { + ): Promise { return knex('object_instance') .where('id', objectId) .update({object_name: objectName}); @@ -138,7 +152,7 @@ export class ObjectInstanceRepository { public async updateObjectInstancePrice( objectId: number, objectPrice: string, - ): Promise { + ): Promise { return knex('object_instance') .where('id', objectId) .update({object_price: objectPrice}); @@ -147,7 +161,7 @@ export class ObjectInstanceRepository { public async updateObjectInstanceBuyer( objectId: number, objectBuyer: string, - ): Promise { + ): Promise { return knex('object_instance') .where('id', objectId) .update({object_buyer: objectBuyer}); @@ -160,21 +174,61 @@ export class ObjectInstanceRepository { return parseInt(Object.values(count[0])[0]); } - public async findForSale(): Promise { + /** + * Sold counts for many objects in one query. + * + * The single-object `countByObjectId` is fine for one row, but the Out of + * Stock view asks about every stocked object at once, which meant one query + * per object. Ids not present in the result have sold nothing and are absent + * from the map; callers should default those to zero. + */ + public async countByObjectIds(objectIds: number[]): Promise<{ [objectId: number]: number }> { + const counts: { [objectId: number]: number } = {}; + if (!objectIds.length) { + return counts; + } + const rows = await this.db.objectInstance + .select('object_id') + .count('id as total') + .whereIn('object_id', objectIds) + .groupBy('object_id'); + rows.forEach((row: GroupedCountRow) => { + counts[row.object_id] = Number.parseInt(String(row.total), 10); + }); + return counts; + } + + /** + * Sold counts for every object in one query, for whole-catalogue work such as + * the export and the Out of Stock view. + */ + public async countAllByObjectId(): Promise<{ [objectId: number]: number }> { + const counts: { [objectId: number]: number } = {}; + const rows = await this.db.objectInstance + .select('object_id') + .count('id as total') + .groupBy('object_id'); + rows.forEach((row: GroupedCountRow) => { + counts[row.object_id] = Number.parseInt(String(row.total), 10); + }); + return counts; + } + + public async findForSale(): Promise { return this.db.objectInstance - .count('id as count') + .count('id as count') .where('object_price', '!=', '') .orWhere('object_price', '!=', null); } - public async averageForSale(): Promise { + public async averageForSale(): Promise<{ price: number }[]> { return this.db.objectInstance .avg({price: 'object_price'}) .where('object_price', '!=', '') .orWhere('object_price', '!=', null); } - public async highestForSale(): Promise { + public async highestForSale(): Promise<{ price: number }[]> { return this.db.objectInstance .max({price: 'object_price'}) .where('object_price', '!=', '') @@ -183,20 +237,20 @@ export class ObjectInstanceRepository { public async totalCount(): Promise { const count = await this.db.objectInstance - .count('object_id as total') + .count('object_id as total'); return parseInt(Object.values(count[0])[0]); } public async totalSearchCount(id: number): Promise { const count = await this.db.objectInstance .count('object_id as total') - .where('member_id', id) + .where('member_id', id); return parseInt(Object.values(count[0])[0]); } public async countForSaleById(objectId: number): Promise { const count = await this.db.objectInstance - .count('id as count') + .count('id as count') .where('object_id', objectId) .andWhere('object_price', '>=', 0) .andWhere('object_buyer', null); @@ -206,7 +260,7 @@ export class ObjectInstanceRepository { public async countByPublicPlaces( objectId: number, fleamarket: number, blackmarket): Promise { const count = await this.db.objectInstance - .count('id as count') + .count('id as count') .where('object_id', objectId) .andWhere('place_id', fleamarket) .orWhere('object_id', objectId) @@ -214,7 +268,7 @@ export class ObjectInstanceRepository { return parseInt(Object.values(count[0])[0]); } - public async getMemberBackpack(memberId: number): Promise { + public async getMemberBackpack(memberId: number): Promise { return await this.db.objectInstance .select('object_instance.*', 'object.filename', 'object.directory', 'object.name') .join('object', 'object_instance.object_id', 'object.id') diff --git a/api/src/repositories/object/object.repository.ts b/api/src/repositories/object/object.repository.ts index 6daef666..a0985fb6 100644 --- a/api/src/repositories/object/object.repository.ts +++ b/api/src/repositories/object/object.repository.ts @@ -1,22 +1,59 @@ +import { Knex } from 'knex'; import { Service } from 'typedi'; import { Db } from '../../db/db.class'; -import { Object } from 'models'; +import { CountRow } from '../row.types'; +// Aliased: the model is literally named `Object`, and leaving it under that +// name shadows the global built-in inside this file. +import { Object as ObjectModel, Place } from 'models'; + +/** + * `ObjectService.STATUS_PENDING`, repeated rather than imported. + * + * A repository importing a service inverts the dependency direction every other + * repository here observes, and typedi would resolve the cycle at construction + * time. The value is part of the stored schema, not of the service. + */ +const PENDING_STATUS = 2; + +/** + * An object row as the Mall pages consume it. + * + * The stored columns, plus the fields that are attached after the query rather + * than selected by it: `username` from a join or from `MallService`, and the + * counts and store that `decorateObjects` fills in. They are optional because + * the same rows travel through code that runs before decoration. + */ +export interface ObjectWithUsername extends ObjectModel { + username?: string; + instances?: number; + store?: Place; + forSale?: number; + publicPlaces?: number; +} + +/** Just enough of an object row to derive the staff-panel view memberships. */ +export interface ObjectViewRow { + id: number; + status: number; + quantity: number; + limit: number | null; +} @Service() export class ObjectRepository { constructor(private db: Db) {} - public async find(objectSearchParams: Partial): Promise { + public async find(objectSearchParams: Partial): Promise { const [object] = await this.db.object.where(objectSearchParams); return object; } - public async findById(objectId: number): Promise { + public async findById(objectId: number): Promise { return this.find({ id: objectId }); } - public async removeAccount(userId: number): Promise { + public async removeAccount(userId: number): Promise { const objectInstanceIds = this.db.objectInstance .distinct('object_id') .whereNotNull('object_id'); @@ -84,29 +121,54 @@ export class ObjectRepository { return object; } - public async findByStatus(status: number): Promise { + public async findByStatus(status: number): Promise { const objects = await this.db.object.where('status', status); return objects; } - public async update(objectId: number, props: object): Promise { - await this.db.object.where({ id: objectId }).update(props); + /** + * Reads one object row inside a transaction, holding a row-level lock on it. + * + * The lock is what makes a rejection safe against a concurrent one: the second + * request blocks here until the first commits, and then sees the status the + * first one wrote rather than the status it read before either began. + */ + public async findByIdForUpdate( + objectId: number, + trx: Knex.Transaction, + ): Promise { + const [object] = await this.db.object + .transacting(trx) + .forUpdate() + .where({ id: objectId }); + return object; + } + + public async update(objectId: number, props: object, trx?: Knex.Transaction): Promise { + const query = this.db.object.where({ id: objectId }); + if (trx) { + query.transacting(trx); + } + await query.update(props); } - public async updateObjectLimit(objectId: number, limit: number): Promise { + public async updateObjectLimit(objectId: number, limit: number): Promise { await this.db.object.where({ id: objectId }).update('limit', limit); } public async increaseObjectQuantity( - objectId: number, props: object): Promise { + objectId: number, props: object): Promise { await this.db.object.where({ id: objectId }).update(props); } - public async updateObjectName(objectId: number, name: string): Promise { + public async updateObjectName(objectId: number, name: string): Promise { await this.db.object.where({ id: objectId }).update('name', name); } - public async getMallForSale(status: number, mallExpiration: string): Promise { + public async getMallForSale( + status: number, + mallExpiration: string, + ): Promise { const objects = await this.db.object .where('status', status) .where('mall_expiration', '>', mallExpiration); @@ -120,7 +182,7 @@ export class ObjectRepository { limit: number, offset: number, orderBy: string, - ): Promise { + ): Promise { const objects = await this.db.object .select('object.*') .where(column, compare, content) @@ -130,40 +192,44 @@ export class ObjectRepository { return objects; } - public async getMallObjectData(): Promise { + public async getMallObjectData(): Promise { return await this.db.object.where('status', 1); } - public async getUploadTotal(): Promise { - return await this.db.object.count('id as count'); + public async getUploadTotal(): Promise { + return await this.db.object.count('id as count'); } - public async getTotalByStatus(status: number): Promise { + public async getTotalByStatus(status: number): Promise { return await this.db.object - .count('id as count') + .count('id as count') .where('status', status); } - public async getAcceptedTotal(): Promise { + public async getAcceptedTotal(): Promise { return await this.db.object - .count('id as count') + .count('id as count') .where('status', '!=', '0') .where('status', '!=', '2'); } - public async getAverageMallPrice(): Promise { + public async getAverageMallPrice(): Promise<{ price: number }[]> { return await this.db.object .avg({price: 'price'}) .where('status', 1); } - public async getHighestMallPrice(): Promise { + public async getHighestMallPrice(): Promise<{ price: number }[]> { return await this.db.object .max({price: 'price'}) .where('status', 1); } - public async searchMallObjects(search: string, limit: number, offset: number): Promise { + public async searchMallObjects( + search: string, + limit: number, + offset: number, + ): Promise { return await this.db.object .where('status','!=', '0') .where('status','!=', '2') @@ -177,7 +243,7 @@ export class ObjectRepository { compare: string, status:number, limit: number, - offset: number): Promise { + offset: number): Promise { return await this.db.object .where('status',compare, status) .where(this.like('name', search)) @@ -185,7 +251,7 @@ export class ObjectRepository { .offset(offset); } - public async getObjectsCatalog(limit: number, offset: number): Promise { + public async getObjectsCatalog(limit: number, offset: number): Promise { return await this.db.object .select('object.*', 'member.username') .where('object.status','!=', '0') @@ -196,41 +262,80 @@ export class ObjectRepository { .offset(offset); } - public async catalogTotal(): Promise { + public async catalogTotal(): Promise { return await this.db.object - .count('id as count') + .count('id as count') .where('status','!=', '0') .where('status','!=', '2'); } - public async getTotal(search: string): Promise { + public async getTotal(search: string): Promise { return await this.db.object - .count('id as count') + .count('id as count') .where('status','!=', '0') .where('status','!=', '2') .where(this.like('object.name', search)); } - public async getSearchTotal(search: string, compare: string, status: number): Promise { + public async getSearchTotal( + search: string, + compare: string, + status: number, + ): Promise { return await this.db.object - .count('id as count') + .count('id as count') .where('status',compare, status) .where(this.like('object.name', search)); } - public async findMallSoldOut(): Promise { + public async findMallSoldOut(): Promise { const objects = await this.db.object - .select('object.*') - .where('status', '=', '1'); + .select('object.*', 'member.username') + .leftJoin('member', 'member.id', 'object.member_id') + .where('object.status', '=', '1'); return objects; } + /** + * Every object's id, status and stock fields, for deriving the staff-panel + * view memberships an export publishes. Deliberately narrow: the full rows are + * streamed a page at a time instead. + */ + public async findViewRows(): Promise { + return this.db.object + .select('id', 'status', 'quantity', 'limit') + .where('status', PENDING_STATUS) + .orderBy('id', 'asc'); + } + + /** + * Full object rows for exactly the given ids, in ascending id order. + * + * Deliberately id-scoped rather than a `status`/`OFFSET` page: the export + * takes its identity set from `findViewRows()` once, before streaming + * starts, and pages through THAT snapshot. A live `WHERE status = ... LIMIT + * ... OFFSET ...` page shifts under staff action -- approving or rejecting + * an object already emitted moves every later row's offset, which can skip + * an object the export already committed to including. Querying by id + * instead means a status change after the snapshot can change what a row + * looks like, but never which rows are visited. + */ + public async findRowsByIds(ids: number[]): Promise { + if (!ids.length) { + return []; + } + return this.db.object + .select('object.*') + .whereIn('id', ids) + .orderBy('id', 'asc'); + } + public async getUserUploadedObjects( userId: number, compare: string, content: string, limit: number, - offset: number): Promise { + offset: number): Promise { const object = await this.db.object .select('object.*', 'member.username') .where('object.member_id', userId) @@ -242,7 +347,7 @@ export class ObjectRepository { return object; } - public async getMallObject(objectId: number): Promise { + public async getMallObject(objectId: number): Promise { const object = await this.db.object .select('object.*', 'member.username') .where('object.id', objectId) @@ -251,13 +356,13 @@ export class ObjectRepository { return object; } - public async total(column: string, compare: string, content: string): Promise { - return this.db.object.count('id as count').where(column, compare, content); + public async total(column: string, compare: string, content: string): Promise { + return this.db.object.count('id as count').where(column, compare, content); } public async totalCreator( - column: string, compare: string, content: string, userId: number): Promise { - return this.db.object.count('id as count') + column: string, compare: string, content: string, userId: number): Promise { + return this.db.object.count('id as count') .where('member_id', userId) .where(column, compare, content); } diff --git a/api/src/repositories/role-assignment/role-assignment.repository.ts b/api/src/repositories/role-assignment/role-assignment.repository.ts index eac9abf0..6d46ad6f 100644 --- a/api/src/repositories/role-assignment/role-assignment.repository.ts +++ b/api/src/repositories/role-assignment/role-assignment.repository.ts @@ -1,15 +1,62 @@ import { Service } from 'typedi'; import { Db } from '../../db/db.class'; -import { knex } from 'knex'; +import { CountRow } from '../row.types'; import { RoleAssignment } from '../../types/models'; /** Repository for fetching/interacting with role assignment data in the database. */ +/** + * The four donor role ids, plus the level being granted. + * + * `AdminService.addDonor` resolves these from the role map before calling, so + * the repository is handed ids rather than names. + */ +export interface DonorRoleIds { + supporter: number; + advocate: number; + devotee: number; + champion: number; + donorLevel?: number; +} + +/** A username from a place's role assignments. */ +interface PlaceUsernameRow { + username: string; +} + +/** A member id from a place's role assignments. */ +interface PlaceMemberRow { + member_id: number; +} + +/** A member due their weekly role pay, with the role that pays best. */ +export interface RoleCreditRow { + member_id: number; + role_id: number; + wallet_id: number; + xp: number; + income_cc: number; + income_xp: number; +} + +/** The one column `getDonor` selects: a role's name, or no row at all. */ +export interface RoleNameRow { + name: string; +} + +/** A role assignment flattened for display: the role, and where it applies. */ +export interface RoleNameAndId { + id: number; + place_id: number | null; + name: string; + place: string | null; +} + @Service() export class RoleAssignmentRepository { constructor(private db: Db) {} - public async addDonor(member_id: number, roleId: any): Promise { + public async addDonor(member_id: number, roleId: DonorRoleIds): Promise { try{ await this.db.knex('role_assignment') .where('member_id', member_id) @@ -34,7 +81,7 @@ export class RoleAssignmentRepository { placeId: number, memberId: number, roleId: number, - ): Promise { + ): Promise { return this.db.knex('role_assignment') .insert( { @@ -48,15 +95,15 @@ export class RoleAssignmentRepository { public async getAccessInfoByID( placeId, ownerCode, - deputyCode): Promise<{ owner: any[]; deputies: any[] }> { - const owner: any[] = await this.db.knex + deputyCode): Promise<{ owner: PlaceMemberRow[]; deputies: PlaceMemberRow[] }> { + const owner: PlaceMemberRow[] = await this.db.knex .select( 'member_id', ) .from('role_assignment') .where('place_id', placeId) .where('role_id', ownerCode); - const deputies: any[] = await this.db.knex + const deputies: PlaceMemberRow[] = await this.db.knex .select( 'member_id', ) @@ -69,8 +116,8 @@ export class RoleAssignmentRepository { public async getAccessInfoByUsername( placeId, ownerCode, - deputyCode): Promise<{ owner: any[]; deputies: any[] }> { - const owner: any[] = await this.db.knex + deputyCode): Promise<{ owner: PlaceUsernameRow[]; deputies: PlaceUsernameRow[] }> { + const owner: PlaceUsernameRow[] = await this.db.knex .select( 'member.username', ) @@ -78,7 +125,7 @@ export class RoleAssignmentRepository { .where('role_assignment.place_id', placeId) .where('role_assignment.role_id', ownerCode) .innerJoin('member', 'role_assignment.member_id', 'member.id'); - const deputies: any[] = await this.db.knex + const deputies: PlaceUsernameRow[] = await this.db.knex .select( 'member.username', ) @@ -94,26 +141,26 @@ export class RoleAssignmentRepository { return roleResults; } - public async removeRoleAssignment(id: number): Promise { + public async removeRoleAssignment(id: number): Promise { await this.db.knex('role_assignment') .where('place_id', id) .del(); } - public async removeAllByUserId(id: number): Promise { + public async removeAllByUserId(id: number): Promise { await this.db.knex('role_assignment') .where('member_id', id) .del(); } - public async getUsernamesByRoleId(roleId: number): Promise { + public async getUsernamesByRoleId(roleId: number): Promise<{ username: string }[]> { return this.db.knex('role_assignment') .select('member.username') .where('role_assignment.role_id', '=', roleId) .leftJoin('member', 'role_assignment.member_id', 'member.id'); } - public async getLatest(): Promise { + public async getLatest(): Promise<{ username: string; roleName: string }[]> { return this.db.knex('role_assignment') .select('member.username', 'role.name as roleName') .leftJoin('member', 'role_assignment.member_id', 'member.id') @@ -122,7 +169,7 @@ export class RoleAssignmentRepository { .orderBy('role_assignment.id', 'desc'); } - public async getDonor(memberId: number, roleId: any): Promise { + public async getDonor(memberId: number, roleId: DonorRoleIds): Promise { return this.db.knex .select('role.name') .from('role_assignment') @@ -138,7 +185,7 @@ export class RoleAssignmentRepository { .first(); } - public async getRoleNameAndIdByMemberId(memberId: number): Promise { + public async getRoleNameAndIdByMemberId(memberId: number): Promise { return this.db.knex .distinct( 'role_assignment.role_id as id', @@ -160,7 +207,7 @@ export class RoleAssignmentRepository { * @param limit * @returns list of users with jobs that earned pay */ - public async getMembersDueRoleCredit(limit: number): Promise { + public async getMembersDueRoleCredit(limit: number): Promise { const query = await this.db.knex .select( 'member.id', @@ -207,7 +254,7 @@ export class RoleAssignmentRepository { placeId: number, memberId: number, roleId: number, - ): Promise { + ): Promise { return await this.db.knex('role_assignment') .where('place_id', placeId) .where('member_id', memberId) @@ -215,9 +262,9 @@ export class RoleAssignmentRepository { .del(); } - public async countByAssigned(id: number): Promise { + public async countByAssigned(id: number): Promise { return this.db.knex('role_assignment') - .count('id as count') + .count('id as count') .where('role_id', id); } } diff --git a/api/src/repositories/row.types.ts b/api/src/repositories/row.types.ts new file mode 100644 index 00000000..0ff645a1 --- /dev/null +++ b/api/src/repositories/row.types.ts @@ -0,0 +1,11 @@ +/** + * Row shapes that are not models. + * + * Aggregate queries return rows that no table owns, and typing them here keeps + * the repositories honest without inventing a fake model for each one. + */ + +/** What `count(' as count')` yields. */ +export interface CountRow { + count: number; +} diff --git a/api/src/repositories/transaction/transaction.repository.ts b/api/src/repositories/transaction/transaction.repository.ts index ad718a7d..b64a8c96 100644 --- a/api/src/repositories/transaction/transaction.repository.ts +++ b/api/src/repositories/transaction/transaction.repository.ts @@ -1,7 +1,18 @@ +import { Knex } from 'knex'; import { Service } from 'typedi'; import { Db } from '../../db/db.class'; -import { Transaction, TransactionReason, Wallet } from '../../types/models'; +import { CountRow } from '../row.types'; +import { Member, Transaction, TransactionReason, Wallet } from '../../types/models'; + +/** + * A transaction row as the admin pages consume it: the stored columns plus the + * usernames `AdminService` resolves onto them after the query. + */ +export interface TransactionRow extends Transaction { + recipient_username?: Pick[]; + sender_username?: Pick[]; +} /** Repository for creating/interacting with transaction/wallet data in the database. */ @Service() @@ -161,22 +172,64 @@ export class TransactionRepository { }); } - public async createObjectUploadRefundTransaction( + /** + * Credits a wallet and records the matching ledger row. + * + * Split out so a caller that is already inside a transaction can have the + * credit and the row commit together with its own writes, rather than + * committing separately and leaving a window where one landed and the other + * did not. + */ + private async creditWallet( + trx: Knex.Transaction, walletId: number, amount: number, + reason: TransactionReason, ): Promise { - return await this.db.knex.transaction(async trx => { - const wallet = await trx('wallet').where({ id: walletId }).first(); - await trx('wallet') - .where({ id: walletId }) - .update({ balance: wallet.balance + amount }); - const [transactionId] = await trx('transaction').insert({ - amount, - reason: TransactionReason.ObjectUploadRefund, - recipient_wallet_id: walletId, - }); - return this.find({ id: transactionId }); + // `balance = balance + ?` in SQL, not read-then-write in JavaScript. The + // object-row lock only serialises rejections of the same object; two + // different objects belonging to one uploader can be rejected at the same + // moment, and a read-modify-write would let both transactions read the same + // balance so the second overwrites the first -- losing a refund the ledger + // still says was paid. + const credited = await trx('wallet') + .where({ id: walletId }) + .increment('balance', amount); + if (!credited) { + // No such wallet. Raised rather than ignored: the caller is mid-refund and + // must not commit a ledger row for money that was never credited. + throw new Error(`Cannot credit unknown wallet ${walletId}`); + } + const [transactionId] = await trx('transaction').insert({ + amount, + reason, + recipient_wallet_id: walletId, }); + // Read back through the same `trx`, not `this.find()` -- that queries + // through the pool's own connection, which cannot see this row until the + // transaction commits, and holds the transaction open waiting on a second + // pool connection. Concurrent refunds could then contend the pool itself. + const [transaction] = await trx('transaction').where({ id: transactionId }); + return transaction; + } + + /** + * Refunds an upload fee. + * + * Joins the caller's transaction when one is supplied - the Mall rejection + * needs the refund and the object's status change to be the same commit - and + * otherwise opens its own, which is what every existing caller gets. + */ + public async createObjectUploadRefundTransaction( + walletId: number, + amount: number, + trx?: Knex.Transaction, + ): Promise { + if (trx) { + return this.creditWallet(trx, walletId, amount, TransactionReason.ObjectUploadRefund); + } + return await this.db.knex.transaction(async ownTrx => + this.creditWallet(ownTrx, walletId, amount, TransactionReason.ObjectUploadRefund)); } public async createUnsoldObjectRefundTransaction( @@ -257,7 +310,11 @@ export class TransactionRepository { }); } - public async getTransactions(type: string, limit: number, offset: number): Promise { + public async getTransactions( + type: string, + limit: number, + offset: number, + ): Promise { return this.db.knex .select( 'id', @@ -272,10 +329,13 @@ export class TransactionRepository { .limit(limit) .offset(offset) .orderBy('id', 'DESC'); - ; } - public async getTransactionsByWalletId(id: number, limit: number, offset: number): Promise { + public async getTransactionsByWalletId( + id: number, + limit: number, + offset: number, + ): Promise { return this.db.knex .select( 'id', @@ -291,35 +351,33 @@ export class TransactionRepository { .limit(limit) .offset(offset) .orderBy('id', 'DESC'); - ; } - public async getLatestTransactions(time: Date): Promise { + public async getLatestTransactions(time: Date): Promise { return this.db.knex .select('transaction.*') .from('transaction') .where('created_at', '>=', time) .limit(30) .orderBy('transaction.id', 'DESC'); - ; } - public async getTotal( type: string): Promise { + public async getTotal( type: string): Promise { return this.db.knex - .count('id as count') + .count('id as count') .from('transaction') .where('reason', type); } - public async getWalletTotal( id: number): Promise { + public async getWalletTotal( id: number): Promise { return this.db.knex - .count('id as count') + .count('id as count') .from('transaction') .where('recipient_wallet_id', id) .orWhere('sender_wallet_id', id); } - public async removeAllByWalletId(id: number): Promise { + public async removeAllByWalletId(id: number): Promise { await this.db.knex('transaction') .where('recipient_wallet_id', id) .orWhere('sender_wallet_id', id) diff --git a/api/src/routes/mall.routes.ts b/api/src/routes/mall.routes.ts index 9da11fbf..4df8acdb 100644 --- a/api/src/routes/mall.routes.ts +++ b/api/src/routes/mall.routes.ts @@ -37,6 +37,12 @@ mallRoutes.get('/objects/:id', (request, response) => mallController.objectsForSale(request, response)); mallRoutes.get('/object/:id', (request, response) => mallController.findByObjectId(request, response)); +mallRoutes.get('/export', (request, response) => + mallController.exportMallData(request, response)); +mallRoutes.get('/object/:id/inspection', (request, response) => + mallController.getObjectInspection(request, response)); +mallRoutes.get('/object/:id/source', (request, response) => + mallController.getObjectSource(request, response)); mallRoutes.get('/getObject/:id', (request, response) => mallController.getObject(request, response)); mallRoutes.get('/store/:id', (request, response) => diff --git a/api/src/services/admin/admin.services.ts b/api/src/services/admin/admin.services.ts index 9847c7c5..c35ae409 100644 --- a/api/src/services/admin/admin.services.ts +++ b/api/src/services/admin/admin.services.ts @@ -12,6 +12,7 @@ import { PlaceRepository, ObjectRepository, ObjectInstanceRepository, + RoleNameRow, TransactionRepository, WalletRepository, } from '../../repositories'; @@ -61,6 +62,7 @@ export class AdminService { } public async fireRole(member_id: number, role_id: number, place_id: number): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- pre-existing, out of scope const response: any = await this.memberRepository.getPrimaryRoleName(member_id); if (response.length !== 0) { const primaryRoleId = response[0].primary_role_id; @@ -72,11 +74,12 @@ export class AdminService { return; } + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- pre-existing, out of scope public async getBanHistory(ban_member_id: number): Promise { return await this.banRepository.getBanHistory(ban_member_id); } - public async getDonor(member_id: number): Promise { + public async getDonor(member_id: number): Promise { const donorId = { supporter: await this.roleRepository.roleMap.Supporter, advocate: await this.roleRepository.roleMap.Advocate, @@ -90,6 +93,7 @@ export class AdminService { } } + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- pre-existing, out of scope public async getRoleList(): Promise { return this.roleRepository.findAll(); } @@ -99,6 +103,7 @@ export class AdminService { return; } + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- pre-existing, out of scope public async searchUsers(search: string, limit: number, offset: number): Promise { const users = await this.memberRepository.searchUsers(search, limit, offset); const total = await this.memberRepository.getTotal(search); @@ -108,6 +113,7 @@ export class AdminService { }; } + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- pre-existing, out of scope public async getTransactions(type: string, limit: number, offset: number): Promise { const transactions = await this.transactionRepository .getTransactions(type, limit, offset); @@ -119,6 +125,7 @@ export class AdminService { } public async getTransactionsByWalletId( + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- pre-existing, out of scope id: number, limit: number, offset: number): Promise { const transactions = await this.transactionRepository .getTransactionsByWalletId(id, limit, offset); @@ -129,6 +136,7 @@ export class AdminService { }; } + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- pre-existing, out of scope public async getCommunityData(): Promise { const second = 1000; const minute = 60 * second; @@ -279,6 +287,7 @@ export class AdminService { user: number, limit: number, offset: number, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- pre-existing, out of scope ): Promise { const messages = await this.messageRepository.searchUserChat(search, user, limit, offset); const total = await this.messageRepository.getChatTotal(search, user); @@ -288,6 +297,7 @@ export class AdminService { }; } + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- pre-existing, out of scope public async searchAvatars(status: number, limit: number, offset: number): Promise { const avatars = await this.avatarRespository.findByStatus(status, limit, offset); const total = await this.avatarRespository.totalByStatus(status); @@ -307,6 +317,7 @@ export class AdminService { limit: number, quantity: number, status: number, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- pre-existing, out of scope ): Promise { await this.objectRepository .update(id, { @@ -321,6 +332,7 @@ export class AdminService { }); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- pre-existing, out of scope public async searchPlaces(type: string[], limit: number, offset: number): Promise { const places = await this.placeRepository.findByType(type, limit, offset, [0,1], 'id'); const total = await this.placeRepository.totalByType(type); diff --git a/api/src/services/index.ts b/api/src/services/index.ts index 9c0b10ec..0d6733d3 100644 --- a/api/src/services/index.ts +++ b/api/src/services/index.ts @@ -8,9 +8,12 @@ export * from './fleamarket/fleamarket.service'; export * from './home/home.service'; export * from './hood/hood.service'; export * from './mall/mall.service'; +export * from './mall-export/mall-export.service'; +export * from './mall-inspection/mall-inspection.service'; export * from './member/member.service'; export * from './message/message.service'; export * from './object/object.service'; +export * from './object-source/object-source.service'; export * from './object-instance/object-instance.service'; export * from './role/role.service'; export * from './role-assignment/role-assignment.service'; diff --git a/api/src/services/mall-export/mall-export.service.spec.ts b/api/src/services/mall-export/mall-export.service.spec.ts new file mode 100644 index 00000000..7914a069 --- /dev/null +++ b/api/src/services/mall-export/mall-export.service.spec.ts @@ -0,0 +1,983 @@ +import { EventEmitter } from 'events'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import zlib from 'zlib'; +import { createSpyObj } from 'jest-createspyobj'; + +import { + createResponseWriter, + ExportAborted, + exportFilename, + ExportWriter, + MallExportService, + MAX_DURATION_MS, + MAX_OBJECTS, + PAGE_SIZE, +} from './mall-export.service'; +import { ObjectSourceService } from '../object-source/object-source.service'; +import { + MallRepository, + MemberRepository, + ObjectInstanceRepository, + ObjectRepository, + PlaceRepository, +} from '../../repositories'; + +const VRML = `#VRML V2.0 utf8 +WorldInfo { + title "Pocket Moon Playset" + info [ "Made By: BassMekanik" "Mall Price: 75 CC" "Limited To: UNLIMITED" ] +} +Shape { appearance Appearance { texture ImageTexture { url "moon.jpg" } } } +`; + +/** + * Objects chosen so the fixture exercises every branch that matters: a placed + * object with a store and sales, an object with no creator, and one whose file + * is missing from disk. + * + * All pending, because pending is all the export ever sees now. + */ +/** CTR status 2. Mirrors `PENDING_STATUS` in ObjectRepository. */ +const PENDING_STATUS = 2; + +/** One object row as the fixtures build them. */ +type ExportObjectRow = typeof OBJECTS[number]; + +/** The parts of a per-object `derived` block these tests read. */ +interface DerivedBlock { + wrl: { + storedBytes: number | null; + decodedBytes: number | null; + encoding: string | null; + sha256: string | null; + }; + worldInfo: unknown; + nodeCounts: { [node: string]: number }; + comparisons: { field: string; verdict: string }[]; + sourceError?: string; + parseError?: string; +} + +/** One object entry, as far as these tests inspect it. */ +interface ExportEntry { + id: number; + assetDirectory: string | null; + name: string | null; + status: number; + statusName: string; + quantity: number | null; + limit: number | null; + creator: { memberId: number | null; username: string | null }; + store: { id: number; name: string } | null; + placement: { position: unknown; rotation: unknown } | null; + ctrViews: { + pending: boolean; + warehouse: boolean; + stocked: boolean; + outOfStock: boolean; + removed: boolean; + inactive: boolean; + }; + assets?: { [kind: string]: { filename: string; url: string } | null }; + derived?: DerivedBlock; +} + +/** The completion record written last. */ +interface ExportResultBlock { + status: string; + objectsWritten: number; + truncation: { + reason: string; + lastObjectId: number | null; + limit?: number; + limitMs?: number; + } | null; + counts: { + objects: number; + stores: number; + byStatus: { [status: string]: number }; + ctrViewSizes: { [view: string]: number }; + _definitions: { [key: string]: string }; + _takenAt: string; + }; + derived: { + attempted: number; + succeeded: number; + failed: number; + failuresByReason: { [reason: string]: number }; + }; +} + +/** The whole exported document. */ +interface ExportDocument { + schema: { + schemaVersion: string; + generator: string; + includesDerived: boolean; + scope: { objects: string; note: string }; + timestamps: { normalized: boolean; note: string }; + }; + stores: { id: number; name: string; slug: string; status: number }[]; + ctrViews: { + pending: number[]; + warehouse: number[]; + stocked: number[]; + outOfStock: number[]; + removed: number[]; + inactive: number[]; + _definitions: { [view: string]: string }; + _note: string; + }; + objects: ExportEntry[]; + result: ExportResultBlock; +} + +const OBJECTS = [ + { + id: 10, directory: 'uuid-a', filename: 'a.wrl', image: 'a.jpg', texture: null, + member_id: 100, name: 'Pocket Moon Playset', quantity: 25, limit: null, price: 75, + status: 2, created_at: '2026-08-20T08:02:43.000Z', updated_at: '2026-08-20T08:02:43.000Z', + mall_expiration: null, description: null, + }, + { + id: 11, directory: 'uuid-b', filename: 'b.wrl', image: 'b.jpg', texture: null, + member_id: null, name: 'Orphan', quantity: 5, limit: null, price: 20, + status: 2, created_at: '2026-08-21T09:00:00.000Z', updated_at: '2026-08-21T09:00:00.000Z', + mall_expiration: null, description: null, + }, + { + id: 12, directory: 'uuid-c', filename: 'gone.wrl', image: 'c.jpg', texture: null, + member_id: 100, name: 'Broken', quantity: 10, limit: null, price: 30, + status: 2, created_at: '2026-08-22T09:00:00.000Z', updated_at: '2026-08-22T09:00:00.000Z', + mall_expiration: null, description: null, + }, +]; + +const VIEW_ROWS = OBJECTS.map(object => ({ + id: object.id, + status: object.status, + quantity: object.quantity, + limit: object.limit, +})); + +/** Part-sold; a pending object is in the `pending` view whatever its sales. */ +const COUNTS = { 10: 5, 12: 3 }; + +// Shaped exactly as `getAllStoresByObjectId` returns it: `place.*` plus the +// object id and the aliased placement columns from `mall_object`. Putting +// position/rotation on an `object.*` row instead would test a query that does +// not exist. +const STORES = { + 10: { + id: 1205, + name: 'Toy Store', + object_id: 10, + mall_position: '{"x":0,"y":1.75,"z":0}', + mall_rotation: '{"x":0,"y":0,"z":0,"angle":0}', + }, +}; + +function collectingWriter(): ExportWriter & { body(): string } { + const chunks: string[] = []; + return { + write(chunk: string) { + chunks.push(chunk); + return Promise.resolve(); + }, + isClosed() { + return false; + }, + body() { + return chunks.join(''); + }, + }; +} + +describe('MallExportService', () => { + let assetsDir: string; + let objectRoot: string; + let originalAssetsDir: string | undefined; + let objectRepository: jest.Mocked; + let objectInstanceRepository: jest.Mocked; + let mallRepository: jest.Mocked; + let memberRepository: jest.Mocked; + let placeRepository: jest.Mocked; + let sourceService: ObjectSourceService; + let service: MallExportService; + + function writeAsset(directory: string, filename: string, contents: Buffer | string): void { + const target = path.join(objectRoot, directory); + fs.mkdirSync(target, { recursive: true }); + fs.writeFileSync(path.join(target, filename), contents); + } + + /** The parsed document plus the raw text, so tests can assert on either. */ + interface ExportRun { + status: string; + raw: string; + document: ExportDocument; + } + + async function runExport(includeDerived = false, now?: () => number): Promise { + const writer = collectingWriter(); + const preflight = await service.preflight(); + const status = await service.export(writer, { includeDerived, now }, preflight); + return { status, raw: writer.body(), document: JSON.parse(writer.body()) }; + } + + beforeEach(() => { + originalAssetsDir = process.env.ASSETS_DIR; + assetsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ctr-export-')); + objectRoot = path.join(assetsDir, 'object'); + fs.mkdirSync(objectRoot, { recursive: true }); + process.env.ASSETS_DIR = assetsDir; + + writeAsset('uuid-a', 'a.wrl', zlib.gzipSync(Buffer.from(VRML))); + writeAsset('uuid-a', 'a.jpg', Buffer.alloc(16)); + writeAsset('uuid-a', 'moon.jpg', Buffer.alloc(16)); + writeAsset('uuid-b', 'b.wrl', VRML); + writeAsset('uuid-b', 'b.jpg', Buffer.alloc(16)); + + objectRepository = createSpyObj(ObjectRepository); + objectInstanceRepository = createSpyObj(ObjectInstanceRepository); + mallRepository = createSpyObj(MallRepository); + memberRepository = createSpyObj(MemberRepository); + placeRepository = createSpyObj(PlaceRepository); + sourceService = new ObjectSourceService(); + + // Both queries filter to pending in SQL, so the mocks filter too: a mock that + // returned stocked rows would let a scope regression pass unnoticed. + objectRepository.findViewRows.mockResolvedValue( + VIEW_ROWS.filter(row => row.status === PENDING_STATUS) as never, + ); + // Id-scoped, not status-scoped: this is what makes the query stable + // against a status change on an already-captured id, exactly like the + // real `WHERE id IN (...)` query. + objectRepository.findRowsByIds.mockImplementation( + (ids: number[]) => + Promise.resolve( + OBJECTS.filter(object => ids.includes(object.id)), + ) as never, + ); + // Both queries filter to the ids handed in, so the mocks filter too: a + // mock that ignored the argument would let a whole-catalogue scan + // regression pass unnoticed. + objectInstanceRepository.countByObjectIds.mockImplementation( + (ids: number[]) => Promise.resolve( + Object.fromEntries( + Object.entries(COUNTS).filter(([id]) => ids.includes(Number(id))), + ), + ) as never, + ); + mallRepository.getStoresByObjectIds.mockImplementation( + (ids: number[]) => Promise.resolve( + Object.fromEntries( + Object.entries(STORES).filter(([id]) => ids.includes(Number(id))), + ), + ) as never, + ); + memberRepository.findByIds.mockResolvedValue( + { 100: { id: 100, username: 'BassMekanik' } } as never, + ); + placeRepository.findAllStores.mockResolvedValue([ + { id: 1205, name: 'Toy Store', slug: 'toystore', status: 1, assets_dir: '/srv/secret' }, + ] as never); + + service = new MallExportService( + objectRepository, + objectInstanceRepository, + mallRepository, + memberRepository, + placeRepository, + sourceService, + ); + }); + + afterEach(() => { + process.env.ASSETS_DIR = originalAssetsDir; + fs.rmSync(assetsDir, { recursive: true, force: true }); + }); + + describe('document shape', () => { + it('is valid JSON with result written last', async () => { + const { raw, document } = await runExport(); + const keys = Object.keys(document); + + expect(keys[keys.length - 1]).toBe('result'); + expect(raw.trimEnd().endsWith('}')).toBe(true); + expect(raw.indexOf('"result"')).toBeGreaterThan(raw.indexOf('"objects"')); + }); + + it('reports complete only after the work is done', async () => { + const { status, document } = await runExport(); + + expect(status).toBe('complete'); + expect(document.result.status).toBe('complete'); + expect(document.result.objectsWritten).toBe(3); + }); + + it('cannot be mistaken for complete if the stream is cut short', async () => { + const { raw } = await runExport(); + const truncated = raw.slice(0, Math.floor(raw.length * 0.8)); + + expect(() => JSON.parse(truncated)).toThrow(); + }); + + it('emits objects in ascending id order', async () => { + const { document } = await runExport(); + + expect(document.objects.map((object: ExportEntry) => object.id)).toEqual([10, 11, 12]); + }); + + it('names the schema version and generator', async () => { + const { document } = await runExport(); + + expect(document.schema.schemaVersion).toBe('2.0.0'); + expect(document.schema.generator).toMatch(/^ctr-mall-export\//); + }); + }); + + describe('ctrViews', () => { + it('declares its pending-only scope in the schema', async () => { + const { document } = await runExport(); + + expect(document.schema.scope.objects).toBe('pending'); + expect(document.schema.scope.note).toMatch(/status 2/); + // A consumer reading `stores` must not conclude the catalogue is here. + expect(document.schema.scope.note).toMatch(/stores/); + }); + + it('lists every exported object under pending and nothing under the rest', + async () => { + const { document } = await runExport(); + + expect(document.ctrViews.pending).toEqual([10, 11, 12]); + // Empty by construction, not by accident: a pending object cannot be + // stocked, warehoused or sold out. Keeping the keys means a consumer + // never has to special-case their absence. + ['stocked', 'warehouse', 'outOfStock', 'removed', 'inactive'].forEach(view => { + expect(document.ctrViews[view]).toEqual([]); + }); + }); + + it('publishes the predicate behind every view', async () => { + const { document } = await runExport(); + + expect(document.ctrViews._definitions.outOfStock).toContain('object.status = 1'); + expect(document.ctrViews._note).toContain('pending-only'); + }); + + it('matches the sizes reported in the trailing result', async () => { + const { document } = await runExport(); + const sizes = document.result.counts.ctrViewSizes; + + Object.keys(sizes).forEach(view => { + expect(document.ctrViews[view].length).toBe(sizes[view]); + }); + }); + + it('reports byStatus counts that match the repository rows', async () => { + const { document } = await runExport(); + + expect(document.result.counts.byStatus).toEqual({ '2': 3 }); + expect(document.result.counts.objects).toBe(3); + }); + + it('defines every aggregate it reports', async () => { + const { document } = await runExport(); + + expect(Object.keys(document.result.counts._definitions).sort()) + .toEqual(['byStatus', 'ctrViewSizes', 'objects', 'stores']); + }); + + it('states the pending-only predicate in its own count definitions', async () => { + // `viewRows` is `findViewRows()`, which filters to status = 2, so `objects` + // and `byStatus` are pending-only counts. Their machine-readable + // definitions must say so, or a consumer following them would read a + // pending count as a catalogue-wide total. + const { document } = await runExport(); + + expect(document.result.counts._definitions.objects).toContain('object.status = 2'); + expect(document.result.counts._definitions.byStatus).toContain('object.status = 2'); + }); + }); + + describe('field discipline', () => { + it('preserves a null creator instead of inventing "Deleted User"', async () => { + const { document, raw } = await runExport(); + const orphan = document.objects.find((object: ExportEntry) => object.id === 11); + + expect(orphan.creator).toEqual({ memberId: null, username: null }); + expect(raw).not.toContain('Deleted User'); + }); + + it('exports the raw limit and derives no unlimited flag', async () => { + const { document } = await runExport(); + const object = document.objects[0]; + + expect(object.limit).toBeNull(); + expect(object).not.toHaveProperty('unlimited'); + }); + + it('does not synthesize a remaining count', async () => { + const { document } = await runExport(); + + expect(document.objects[0]).not.toHaveProperty('remaining'); + }); + + it('calls the asset directory what it is, not a uuid', async () => { + const { document } = await runExport(); + + expect(document.objects[0].assetDirectory).toBe('uuid-a'); + expect(document.objects[0]).not.toHaveProperty('uuid'); + }); + + it('does not attach an ambiguous object count to stores', async () => { + const { document } = await runExport(); + + expect(document.stores[0]).not.toHaveProperty('objectCount'); + expect(Object.keys(document.stores[0]).sort()) + .toEqual(['id', 'name', 'slug', 'status']); + }); + + it('states that timestamps are not normalized', async () => { + const { document } = await runExport(); + + expect(document.schema.timestamps.normalized).toBe(false); + expect(document.schema.timestamps.note).toContain('NOT relabelled'); + }); + }); + + describe('privacy', () => { + it('leaks no filesystem paths or server internals', async () => { + const { raw } = await runExport(true); + + expect(raw).not.toContain(assetsDir); + expect(raw).not.toContain(os.tmpdir()); + expect(raw).not.toContain('/srv/secret'); + expect(raw).not.toMatch(/"password"|"email"|"token"|"wallet"/); + }); + + it('references assets by public url only', async () => { + const { document } = await runExport(); + + expect(document.objects[0].assets.wrl.url).toBe('/assets/object/uuid-a/a.wrl'); + }); + }); + + describe('derived=0 is genuinely cheap', () => { + it('omits every derived block', async () => { + const { document } = await runExport(false); + + document.objects.forEach((object: ExportEntry) => { + expect(object).not.toHaveProperty('derived'); + }); + expect(document.schema.includesDerived).toBe(false); + expect(document.result).not.toHaveProperty('derived'); + }); + + it('performs no filesystem access at all', async () => { + const readSource = jest.spyOn(sourceService, 'readSource'); + const readAsset = jest.spyOn(sourceService, 'readAssetMetadata'); + + await runExport(false); + + expect(readSource).not.toHaveBeenCalled(); + expect(readAsset).not.toHaveBeenCalled(); + }); + }); + + describe('derived=1', () => { + it('reports stored and decoded bytes separately, plus the encoding', async () => { + const { document } = await runExport(true); + const object = document.objects.find((entry: ExportEntry) => entry.id === 10); + + expect(object.derived.wrl.encoding).toBe('gzip'); + expect(object.derived.wrl.storedBytes).toBeLessThan(object.derived.wrl.decodedBytes); + expect(object.derived.wrl.sha256).toMatch(/^[0-9a-f]{64}$/); + }); + + it('carries WorldInfo, node counts and comparisons', async () => { + const { document } = await runExport(true); + const object = document.objects.find((entry: ExportEntry) => entry.id === 10); + + expect(object.derived.worldInfo[0].title).toBe('Pocket Moon Playset'); + expect(object.derived.nodeCounts.ImageTexture).toBe(1); + const priceComparison = object.derived.comparisons + .find((c: { field: string; verdict: string }) => c.field === 'price'); + expect(priceComparison.verdict) + .toBe('MATCH'); + }); + + it('keeps a broken object in the export and records why it failed', async () => { + const { document } = await runExport(true); + const broken = document.objects.find((entry: ExportEntry) => entry.id === 12); + + expect(broken).toBeDefined(); + expect(broken.derived.sourceError).toBe('missing'); + expect(broken.derived.worldInfo).toBeNull(); + expect(document.result.derived.failed).toBe(1); + expect(document.result.derived.failuresByReason.missing).toBe(1); + expect(document.result.status).toBe('complete'); + }); + + it('tallies attempts, successes and failures', async () => { + const { document } = await runExport(true); + + expect(document.result.derived.attempted).toBe(3); + expect(document.result.derived.succeeded).toBe(2); + expect(document.result.derived.failed).toBe(1); + }); + }); + + describe('budgets', () => { + it('reports truncation rather than silently returning a short dataset', + async () => { + // A clock that jumps past the budget as soon as the first page is done. + let calls = 0; + const now = () => { + calls += 1; + return calls > 2 ? MAX_DURATION_MS + 1000 : 0; + }; + + const { status, document } = await runExport(false, now); + + expect(status).toBe('truncated'); + expect(document.result.status).toBe('truncated'); + expect(document.result.truncation.reason).toBe('time_budget'); + expect(document.result.truncation.limitMs).toBe(MAX_DURATION_MS); + }); + + it('stops mid-page rather than finishing the page it is on', async () => { + // The deadline falls after the first object of the first page. A + // page-granular check would emit all three; a per-row check emits one. + // In derived mode each of those objects is read, decompressed, hashed and + // scanned, which is why finishing the page is not a rounding error. + let calls = 0; + const now = () => { + calls += 1; + return calls > 3 ? MAX_DURATION_MS + 1000 : 0; + }; + + const { document } = await runExport(false, now); + + expect(document.result.status).toBe('truncated'); + expect(document.result.truncation.reason).toBe('time_budget'); + expect(document.objects.length).toBeLessThan(OBJECTS.length); + // Whatever it did emit is still a usable cursor. + expect(document.result.truncation.lastObjectId) + .toBe(document.objects[document.objects.length - 1].id); + }); + + it('stops reading when the client goes away', async () => { + const writer: ExportWriter = { + write: () => Promise.resolve(), + isClosed: () => true, + }; + + const preflight = await service.preflight(); + const status = await service.export(writer, { includeDerived: false }, preflight); + + expect(status).toBe('failed'); + expect(objectRepository.findRowsByIds).not.toHaveBeenCalled(); + }); + }); + + describe('pretty-printed output', () => { + // Owner QA: the downloaded file was one enormous minified line and could + // not realistically be read by eye. Indentation is presentation only -- + // every assertion here that pins the shape is paired with one proving the + // parsed data did not change. + it('indents the document with two spaces at every level', async () => { + const { raw } = await runExport(); + + expect(raw.startsWith('{\n "schema": {\n')).toBe(true); + expect(raw).toContain('\n "stores": ['); + expect(raw).toContain('\n "ctrViews": {'); + expect(raw).toContain('\n "objects": ['); + expect(raw).toContain('\n "result": {'); + expect(raw.endsWith('\n}\n')).toBe(true); + }); + + it('indents each object entry inside the objects array', async () => { + const { raw } = await runExport(); + + expect(raw).toContain('\n "objects": [\n {\n "id":'); + expect(raw).toContain('\n }\n ],\n "result": {'); + }); + + it('never emits a minified run of the document', async () => { + const { raw } = await runExport(); + + // The old output had no newline at all between the opening brace and the + // result record. Any line long enough to be a whole serialised object is + // the regression this guards. + const longest = raw.split('\n').reduce((max, line) => Math.max(max, line.length), 0); + expect(longest).toBeLessThan(raw.length / 2); + }); + + it('parses to exactly the same data as the compact serialisation', async () => { + const { raw, document } = await runExport(true); + + // Whitespace is not part of the contract: re-serialising the parsed + // document compactly and parsing that again must be indistinguishable. + expect(JSON.parse(JSON.stringify(document))).toEqual(document); + expect(JSON.parse(raw)).toEqual(document); + expect(document.result.status).toBe('complete'); + expect(document.objects.length).toBe(document.result.objectsWritten); + }); + + it('writes an empty objects array without a blank line in it', async () => { + objectRepository.findViewRows.mockResolvedValue([]); + objectRepository.findRowsByIds.mockResolvedValue([]); + objectInstanceRepository.countByObjectIds.mockResolvedValue({}); + mallRepository.getStoresByObjectIds.mockResolvedValue({}); + + const { raw, document } = await runExport(); + + expect(raw).toContain('"objects": [],'); + expect(document.objects).toEqual([]); + expect(document.result.status).toBe('complete'); + expect(document.result.objectsWritten).toBe(0); + }); + + it('still streams incrementally rather than buffering the document', async () => { + // The point of the export is that it never holds the whole document in + // memory. Indentation is applied per bounded value, so the number of + // writes must still scale with the content, not collapse to one. + const chunks: string[] = []; + const writer: ExportWriter = { + write(chunk: string) { + chunks.push(chunk); + return Promise.resolve(); + }, + isClosed() { + return false; + }, + }; + + const preflight = await service.preflight(); + await service.export(writer, { includeDerived: false }, preflight); + + // schema, stores, ctrViews, objects-open, one per object, objects-close, + // result. Never a single write carrying everything. + expect(chunks.length).toBeGreaterThanOrEqual(OBJECTS.length + 6); + const document = JSON.parse(chunks.join('')); + expect(document.objects.length).toBe(OBJECTS.length); + }); + }); + + describe('export identity snapshot', () => { + it('does not skip a later pending object when an earlier one is mutated mid-export', + async () => { + // Enough rows to span two pages, so the second page's fetch happens + // only after the first page has already been written -- exactly where + // a mutable `WHERE status = ... OFFSET ...` page would have shifted + // under a concurrent status change on an earlier row. + const template = OBJECTS[1]; + const count = PAGE_SIZE + 5; + const rows: ExportObjectRow[] = new Array(count); + for (let index = 0; index < count; index += 1) { + rows[index] = { ...template, id: 2000 + index, name: `Snapshot ${index}` }; + } + const byId = new Map(rows.map(row => [row.id, row])); + + objectRepository.findViewRows.mockResolvedValue( + rows.map(row => ( + { id: row.id, status: row.status, quantity: row.quantity, limit: row.limit } + )) as never, + ); + + let firstPageFetched = false; + objectRepository.findRowsByIds.mockImplementation((ids: number[]) => { + if (!firstPageFetched) { + firstPageFetched = true; + } else { + // Staff approve the very first object in the snapshot between the + // first and second page's fetches. A live `status = 2` OFFSET + // query would now see one fewer pending row ahead of every later + // id, shifting each of them one slot earlier and skipping the + // last one. + byId.get(rows[0].id).status = 1; + } + return Promise.resolve(ids.map(id => byId.get(id)).filter(Boolean)) as never; + }); + + const { document } = await runExport(); + + const ids = document.objects.map((object: ExportEntry) => object.id); + expect(ids).toEqual(rows.map(row => row.id)); + expect(ids).toContain(rows[rows.length - 1].id); + expect(new Set(ids).size).toBe(ids.length); + expect(document.result.status).toBe('complete'); + }); + + it('does not admit an object added to Pending after the snapshot was taken', async () => { + const { document } = await runExport(); + + // The fixture's `findViewRows` mock returns exactly ids [10, 11, 12]; + // nothing else may appear even though `findRowsByIds` would happily + // return whatever ids it's asked for. + expect(document.objects.map((object: ExportEntry) => object.id)).toEqual([10, 11, 12]); + }); + + it('reports truncated, not complete, when a captured id no longer resolves to a row', + async () => { + objectRepository.findRowsByIds.mockImplementation( + (ids: number[]) => + Promise.resolve( + OBJECTS.filter(object => ids.includes(object.id) && object.id !== 12), + ) as never, + ); + + const { document } = await runExport(); + + expect(document.result.status).toBe('truncated'); + expect(document.result.truncation.reason).toBe('snapshot_rows_missing'); + }); + + it('serializes status, statusName, quantity, limit and ctrViews from the ' + + 'snapshot, not a later live read', async () => { + // Object 10 is captured Pending (status 2) in `findViewRows`, but the + // full-row fetch that happens later returns it already approved + // (status 1) -- exactly what a concurrent Accept produces between + // preflight and this page's fetch. + objectRepository.findRowsByIds.mockImplementation( + (ids: number[]) => + Promise.resolve( + OBJECTS.filter(object => ids.includes(object.id)) + .map(object => (object.id === 10 ? { ...object, status: 1 } : object)), + ) as never, + ); + + const { document } = await runExport(); + + const entry = document.objects.find((object: ExportEntry) => object.id === 10); + // Snapshot-sourced: still the Pending values `findViewRows` captured, + // not the live "already approved" row the page fetch returned. + expect(entry.status).toBe(PENDING_STATUS); + expect(entry.statusName).not.toBe('accepted'); + expect(entry.ctrViews.pending).toBe(true); + expect(entry.ctrViews.stocked).toBe(false); + // The document's own top-level ctrViews and byStatus, also built from + // the snapshot, must agree with what the entry itself claims. + expect(document.ctrViews.pending).toContain(10); + expect(document.result.counts.byStatus).toEqual({ '2': 3 }); + }); + }); + + describe('MallExportService - placement is a mall_object fact', () => { + it('emits the stored placement for a placed object', async () => { + const { document } = await runExport(); + const placed = document.objects.find((entry: ExportEntry) => entry.id === 10); + + expect(placed.store).toEqual({ id: 1205, name: 'Toy Store' }); + expect(placed.placement).toEqual({ + position: { x: 0, y: 1.75, z: 0 }, + rotation: { x: 0, y: 0, z: 0, angle: 0 }, + }); + }); + + it('emits null placement for an object that is in no store', async () => { + const { document } = await runExport(); + const unplaced = document.objects.find((entry: ExportEntry) => entry.id === 11); + + expect(unplaced.store).toBeNull(); + expect(unplaced.placement).toBeNull(); + }); + + it('emits exactly one row per object', async () => { + // Placement is read from the keyed store map rather than joined onto the + // page query, so a second mall_object row for one object cannot duplicate + // an export entry. + const { document } = await runExport(); + const ids = document.objects.map((entry: ExportEntry) => entry.id); + + expect(ids).toEqual(OBJECTS.map(object => object.id)); + expect(new Set(ids).size).toBe(ids.length); + }); + }); + + /** + * The cap is the one place an export can quietly become a partial dataset, so + * all three sides of the boundary are pinned. A catalogue of exactly + * MAX_OBJECTS is complete -- nothing was left behind -- and reporting it as + * truncated would send staff hunting for objects that do not exist. + */ + describe('the MAX_OBJECTS boundary', () => { + function catalogueOf(count: number): ExportObjectRow[] { + const template = OBJECTS[1]; // no stores, no counts: the cheapest row to build + const rows = new Array(count); + for (let index = 0; index < count; index += 1) { + rows[index] = { ...template, id: 1000 + index, name: `Object ${index}` }; + } + return rows; + } + + async function exportCatalogue(count: number): Promise { + const rows = catalogueOf(count); + const byId = new Map(rows.map(row => [row.id, row])); + objectRepository.findRowsByIds.mockImplementation( + (ids: number[]) => + Promise.resolve(ids.map(id => byId.get(id)).filter(Boolean)) as never, + ); + objectRepository.findViewRows.mockResolvedValue( + rows.map(row => ( + { id: row.id, status: row.status, quantity: row.quantity, limit: row.limit } + )) as never, + ); + return runExport(); + } + + it('reports complete one object below the cap', async () => { + const { document } = await exportCatalogue(MAX_OBJECTS - 1); + expect(document.result.status).toBe('complete'); + expect(document.result.truncation).toBeNull(); + expect(document.objects.length).toBe(MAX_OBJECTS - 1); + }); + + it('reports complete at exactly the cap', async () => { + const { document } = await exportCatalogue(MAX_OBJECTS); + expect(document.result.status).toBe('complete'); + expect(document.result.truncation).toBeNull(); + expect(document.objects.length).toBe(MAX_OBJECTS); + }); + + it('reports truncated one object above the cap, with a usable cursor', async () => { + const { document } = await exportCatalogue(MAX_OBJECTS + 1); + expect(document.result.status).toBe('truncated'); + expect(document.result.truncation.reason).toBe('object_cap'); + expect(document.result.truncation.limit).toBe(MAX_OBJECTS); + expect(document.objects.length).toBe(MAX_OBJECTS); + // The id of the last object actually emitted, not a count of them. + expect(document.result.truncation.lastObjectId) + .toBe(document.objects[document.objects.length - 1].id); + }); + }); +}); + +describe('createResponseWriter', () => { + /** + * A real EventEmitter, because the bug this guards against is precisely that + * the writer waited on an event a dead socket never emits. A hand-rolled stub + * that only records `drain` handlers cannot express that. + */ + class FakeResponse extends EventEmitter { + public written: string[] = []; + public writableEnded = false; + public destroyed = false; + private results: boolean[]; + + constructor(writeResults: boolean[] = []) { + super(); + this.results = writeResults; + } + + public write(chunk: string): boolean { + this.written.push(chunk); + return this.results.length ? (this.results.shift() as boolean) : true; + } + + /** Every listener the writer could have left behind. */ + public waiters(): number { + return this.listenerCount('drain') + + this.listenerCount('close') + + this.listenerCount('error'); + } + } + + it('resolves immediately when the socket accepts the write', async () => { + const response = new FakeResponse([true]); + + await createResponseWriter(response).write('chunk'); + + expect(response.written).toEqual(['chunk']); + expect(response.waiters()).toBe(0); + }); + + it('continues once the socket drains', async () => { + const response = new FakeResponse([false]); + let settled = false; + + const pending = createResponseWriter(response).write('big chunk').then(() => { + settled = true; + }); + + await Promise.resolve(); + expect(settled).toBe(false); + expect(response.waiters()).toBe(3); + + response.emit('drain'); + await pending; + + expect(settled).toBe(true); + expect(response.waiters()).toBe(0); + }); + + it('aborts when the client disconnects instead of draining', async () => { + const response = new FakeResponse([false]); + + const pending = createResponseWriter(response).write('big chunk'); + await Promise.resolve(); + expect(response.waiters()).toBe(3); + + response.emit('close'); + + await expect(pending).rejects.toBeInstanceOf(ExportAborted); + expect(response.waiters()).toBe(0); + }); + + it('aborts when the socket errors instead of draining', async () => { + const response = new FakeResponse([false]); + + const pending = createResponseWriter(response).write('big chunk'); + await Promise.resolve(); + + response.emit('error', new Error('ECONNRESET')); + + await expect(pending).rejects.toBeInstanceOf(ExportAborted); + expect(response.waiters()).toBe(0); + }); + + it('never leaves a write pending on a socket that will not drain', async () => { + const response = new FakeResponse([false]); + let settled = false; + + createResponseWriter(response).write('big chunk') + .then(() => { settled = true; }, () => { settled = true; }); + + response.emit('close'); + await new Promise(resolve => setImmediate(resolve)); + + expect(settled).toBe(true); + }); + + it('refuses to write to an already destroyed response', async () => { + const response = new FakeResponse([]); + response.destroyed = true; + + await expect(createResponseWriter(response).write('chunk')) + .rejects.toBeInstanceOf(ExportAborted); + expect(response.written).toEqual([]); + }); + + it('reports a finished or destroyed response as closed', () => { + const response = new FakeResponse([]); + expect(createResponseWriter(response).isClosed()).toBe(false); + + response.destroyed = true; + expect(createResponseWriter(response).isClosed()).toBe(true); + }); +}); + +describe('exportFilename', () => { + it('stamps the download in UTC with no character illegal in a filename', () => { + const name = exportFilename(new Date(Date.UTC(2026, 7, 23, 12, 19, 22, 500))); + + expect(name).toBe('ctr-mall-export-2026-08-23T121922Z.json'); + expect(name).not.toMatch(/[:\\/]/); + }); + +}); diff --git a/api/src/services/mall-export/mall-export.service.ts b/api/src/services/mall-export/mall-export.service.ts new file mode 100644 index 00000000..36e8c123 --- /dev/null +++ b/api/src/services/mall-export/mall-export.service.ts @@ -0,0 +1,822 @@ +import { Service } from 'typedi'; + +import { + MallRepository, + MemberRepository, + ObjectInstanceRepository, + ObjectRepository, + PlaceRepository, +} from '../../repositories'; +import { + compareWorldInfo, + CTR_VIEW_DEFINITIONS, + ctrViewsFor, + externalReferences, + scanVrml, + statusName, + summariseNodeCounts, + textureReferences, +} from '../../libs'; +import { ObjectSourceService } from '../object-source/object-source.service'; +import { + ObjectViewRow, + ObjectWithUsername, +} from '../../repositories/object/object.repository'; +import { StoreRow } from '../../repositories/mall-object/mall-object.repository'; +import { Member, Place } from '../../types/models'; + +/** + * Streams CTR's authoritative Mall dataset as one deterministic JSON document. + * + * Two shape decisions are load-bearing: + * + * 1. The completion record is written LAST. Counts, per-object failures and the + * overall outcome are not known until the work is done, so putting them in a + * header streamed first would mean either guessing or lying. A consumer must + * check `result.status === 'complete'`; a stream that was cut off has no + * `result` at all and will not even parse, so a partial file can never be + * mistaken for a dataset. + * + * 2. `derived=0` touches the filesystem zero times. It is pure SQL, so a missing + * or corrupt upload cannot affect it and it stays fast over the whole + * catalogue. + */ + +export const EXPORT_SCHEMA_VERSION = '2.0.0'; +export const EXPORT_GENERATOR = 'ctr-mall-export/1.0.0'; + +/** Objects read per page while streaming. Bounds peak memory, not total output. */ +export const PAGE_SIZE = 200; + +/** Wall-clock budget for one export. Exceeding it truncates rather than hangs. */ +export const MAX_DURATION_MS = 120000; + +/** Backstop against an unbounded catalogue. Exceeding it truncates. */ +export const MAX_OBJECTS = 50000; + +export type ExportStatus = 'complete' | 'truncated' | 'failed'; + +export interface ExportWriter { + write(chunk: string): Promise; + isClosed(): boolean; +} + +/** Everything global the document needs, gathered before the body opens. */ +export interface ExportPreflight { + stores: Place[]; + viewRows: ObjectViewRow[]; + allCounts: { [objectId: string]: number }; + allStores: { [objectId: string]: StoreRow }; +} + +/** + * The part of a Node response this writer touches. + * + * Narrower than `ServerResponse` on purpose: the specs drive it with a plain + * EventEmitter, and naming exactly what is used keeps that honest. + */ +export interface ExportResponse { + write(chunk: string): boolean; + once(event: string, listener: () => void): unknown; + removeListener(event: string, listener: () => void): unknown; + writableEnded?: boolean; + destroyed?: boolean; +} + +/** Why a document stopped early, and where it got to. */ +export interface ExportTruncation { + reason: string; + lastObjectId: number | null; + limit?: number; + limitMs?: number; +} + +/** Per-object counters the derived pass keeps. */ +export interface DerivedTally { + attempted: number; + succeeded: number; + failed: number; + failuresByReason: { [reason: string]: number }; +} + +/** A JSON object in the document whose keys are not read back. */ +export type JsonObject = { [key: string]: unknown }; + +/** + * One object's entry in the document. + * + * The fields the derived pass reads back are declared; the rest of the entry is + * assembled dynamically and only ever serialised. + */ +export interface ExportObject extends JsonObject { + id: number; + name: string | null; + creator: { memberId: number | null; username: string | null }; + price: number | null; + limit: number | null; + store: { id: number; name: string } | null; +} + +/** What `buildObject` needs to know about a row beyond the row itself. */ +export interface ExportObjectContext { + member: Member | null; + sold: number; + store: StoreRow | null; + includeDerived: boolean; + derivedTally: DerivedTally; + /** + * This object's own preflight snapshot row: the status, quantity and limit + * captured before streaming began. `status`, `statusName`, `quantity`, + * `limit` and `ctrViews` are all built from this, not from the live row a + * later page fetch returns, so a status change mid-export cannot make one + * object's entry disagree with the document's own top-level `ctrViews` and + * counts -- both of which are also built from the snapshot. + */ + snapshot: ObjectViewRow; +} + +/** What `buildResult` measures once the body is written. */ +export interface ExportResultContext { + status: ExportStatus; + now: () => number; + startedAt: number; + startedIso: string; + objectsWritten: number; + viewRows: ObjectViewRow[]; + allCounts: { [objectId: string]: number }; + storesCount: number; + truncation: ExportTruncation | null; + derivedTally: DerivedTally; + includeDerived: boolean; +} + +export interface ExportOptions { + includeDerived: boolean; + /** Injected so specs can drive the clock rather than wait on it. */ + now?: () => number; + /** + * When the export's wall-clock budget actually began. + * + * `preflight()` runs before this method is called, and it is not free: an + * expensive preflight must count against `MAX_DURATION_MS` too, or the + * advertised budget only bounds the streaming half of the work. The + * controller captures this before calling `preflight()` and passes it + * through unchanged; defaulting to `now()` here only covers callers (and + * specs) that do not care about preflight's own cost. + */ + startedAt?: number; +} + +/** + * Wraps an Express response so writes respect Node's backpressure signal. + * + * `response.write` returning false means the outbound buffer is full; ignoring + * it lets a slow client drive the process's memory up while the export keeps + * reading files as fast as it can. + */ +/** + * Raised when the client goes away mid-export. + * + * Distinct from a server fault: it carries a stable public code and tells the + * export loop to stop rather than keep reading files for a socket nobody is + * listening to. + */ +export class ExportAborted extends Error { + public readonly code: string; + + constructor(code = 'export_aborted') { + super(code); + this.name = 'ExportAborted'; + this.code = code; + } +} + +/** + * Client-visible failure codes. + * + * Export payloads get downloaded and passed around, so they carry a stable code + * rather than an exception message: raw messages here have been observed to + * contain absolute filesystem paths. + */ +export const EXPORT_ERROR_CODES = { + aborted: 'export_aborted', + preflightFailed: 'export_preflight_failed', + budgetExceeded: 'export_budget_exceeded', + sourceUnreadable: 'source_unreadable', + failed: 'export_failed', +}; + +/** Maps any thrown value onto a code that is safe to put in the document. */ +export function publicErrorCode(error: unknown): string { + if (error instanceof ExportAborted) { + return error.code; + } + return EXPORT_ERROR_CODES.failed; +} + +export function createResponseWriter(response: ExportResponse): ExportWriter { + const isClosed = (): boolean => !!(response.writableEnded || response.destroyed); + + return { + write(chunk: string): Promise { + if (isClosed()) { + return Promise.reject(new ExportAborted()); + } + if (response.write(chunk)) { + return Promise.resolve(); + } + // A destroyed or ended response never emits `drain`, so waiting on that + // alone leaks this promise -- and with it the whole export handler -- for + // every client that disconnects while the buffer is full. + return new Promise((resolve, reject) => { + const cleanup = (): void => { + response.removeListener('drain', onDrain); + response.removeListener('close', onTerminate); + response.removeListener('error', onTerminate); + }; + const onDrain = (): void => { + cleanup(); + resolve(); + }; + const onTerminate = (): void => { + cleanup(); + reject(new ExportAborted()); + }; + response.once('drain', onDrain); + response.once('close', onTerminate); + response.once('error', onTerminate); + }); + }, + isClosed, + }; +} + +/** + * Download name for one export. + * + * Colons are legal in a URL but not in a Windows filename, so the ISO time is + * emitted without them. UTC throughout -- this stamps when the download was + * made, which is a separate question from how the database's own timestamps + * should be read. + */ +export function exportFilename(at: Date): string { + const stamp = at.toISOString().split('.')[0].replace(/:/g, ''); + return `ctr-mall-export-${stamp}Z.json`; +} + +/** One level of indentation in the streamed document. */ +const INDENT = ' '; + +/** + * Indents an already-serialised JSON value so it can be streamed into a + * pretty-printed document without ever holding the whole document in memory. + * + * Only the value's own continuation lines are padded; its first line is left + * bare because the caller has already written the key and the space after it. + * Every value passed here is bounded -- one object entry, one store list, one + * result record -- so this never sees the document as a whole and the export's + * memory profile is unchanged. + */ +export function indentJson(value: unknown, depth: number): string { + const serialised = JSON.stringify(value, null, 2); + if (serialised === undefined) { + return 'null'; + } + if (depth === 0) { + return serialised; + } + return serialised.split('\n').join(`\n${INDENT.repeat(depth)}`); +} + +function assetUrl(directory: string | null, filename: string | null): string | null { + if (!directory || !filename) { + return null; + } + return `/assets/object/${directory}/${filename}`; +} + +@Service() +export class MallExportService { + constructor( + private objectRepository: ObjectRepository, + private objectInstanceRepository: ObjectInstanceRepository, + private mallRepository: MallRepository, + private memberRepository: MemberRepository, + private placeRepository: PlaceRepository, + private objectSourceService: ObjectSourceService, + ) {} + + /** + * Every global query the document depends on, run before a byte is written. + * + * Keeping these ahead of the body is what lets a failure here surface as an + * ordinary HTTP error. Once the response has started, the only options left + * are a document that records its own failure or a truncated stream. + */ + public async preflight(): Promise { + const stores = await this.placeRepository.findAllStores('name'); + const viewRows = await this.objectRepository.findViewRows(); + // Scoped to the pending ids this export actually covers -- the export is + // pending-only, so scanning every object instance or every Mall placement + // in the catalogue would make preflight scale with the whole catalogue + // instead of with the queue this document is about. + const pendingIds = viewRows.map(row => row.id); + const allCounts = await this.objectInstanceRepository.countByObjectIds(pendingIds); + const allStores = await this.mallRepository.getStoresByObjectIds(pendingIds); + return { stores, viewRows, allCounts, allStores }; + } + + public async export( + writer: ExportWriter, + options: ExportOptions, + preflight: ExportPreflight, + ): Promise { + const now = options.now || (() => Date.now()); + const startedAt = options.startedAt ?? now(); + const startedIso = new Date(startedAt).toISOString(); + + const { stores, viewRows, allCounts, allStores } = preflight; + + let status: ExportStatus = 'complete'; + let truncation: ExportTruncation | null = null; + let objectsWritten = 0; + let bodyStarted = false; + let objectsOpened = false; + const derivedTally = { + attempted: 0, + succeeded: 0, + failed: 0, + failuresByReason: {} as { [reason: string]: number }, + }; + + try { + await writer.write( + `{\n${INDENT}"schema": ${indentJson(this.buildSchema(startedIso, options), 1)}`, + ); + bodyStarted = true; + + await writer.write(`,\n${INDENT}"stores": ${indentJson(stores.map((store: Place) => ({ + id: store.id, + name: store.name, + slug: store.slug, + status: store.status, + })), 1)}`); + + await writer.write( + `,\n${INDENT}"ctrViews": ${indentJson(this.buildViews(viewRows, allCounts), 1)}`, + ); + + await writer.write(`,\n${INDENT}"objects": [`); + objectsOpened = true; + + // The identity set this export commits to is fixed here, from the + // preflight snapshot -- not re-queried per page. Staff approving or + // rejecting an object mid-export changes what that id's row looks like, + // never which ids this export visits or how many pages remain. + const pendingIds = viewRows.map(row => row.id); + const snapshotById = new Map(viewRows.map(row => [row.id, row])); + let chunkStart = 0; + let first = true; + let lastObjectId: number | null = null; + let missingSnapshotRows = 0; + + for (;;) { + if (writer.isClosed()) { + status = 'failed'; + truncation = { reason: 'client_disconnected', lastObjectId: null }; + break; + } + if (now() - startedAt > MAX_DURATION_MS) { + status = 'truncated'; + truncation = { + reason: 'time_budget', + limitMs: MAX_DURATION_MS, + lastObjectId: null, + }; + break; + } + if (chunkStart >= pendingIds.length) { + break; + } + + // Checked only once a further page has actually been read, so a + // catalogue of exactly MAX_OBJECTS reports complete: nothing was left + // out, and the cap is a limit on what is omitted, not on what is sent. + if (objectsWritten >= MAX_OBJECTS) { + status = 'truncated'; + truncation = { reason: 'object_cap', limit: MAX_OBJECTS, lastObjectId: null }; + break; + } + + const idsPage = pendingIds.slice(chunkStart, chunkStart + PAGE_SIZE); + chunkStart += PAGE_SIZE; + const page = await this.objectRepository.findRowsByIds(idsPage); + missingSnapshotRows += idsPage.length - page.length; + + const members = await this.memberRepository.findByIds( + page.map(row => row.member_id).filter((id: number) => !!id), + ); + + for (const row of page) { + // Checked per row, not only per page. In derived mode each object is + // read, decompressed, hashed and scanned, so a page-granular check + // could run 200 of those after the deadline had already passed and + // overshoot the advertised cap by minutes. + if (now() - startedAt > MAX_DURATION_MS) { + status = 'truncated'; + truncation = { + reason: 'time_budget', + limitMs: MAX_DURATION_MS, + lastObjectId: null, + }; + break; + } + + const entry = await this.buildObject(row, { + sold: allCounts[row.id] || 0, + store: allStores[row.id] || null, + member: row.member_id ? members[row.member_id] : null, + includeDerived: options.includeDerived, + derivedTally, + // Always present: `row` came from a page fetched by an id drawn + // from `pendingIds`, and `pendingIds` is exactly `viewRows`' ids. + snapshot: snapshotById.get(row.id), + }); + await writer.write( + `${first ? '' : ','}\n${INDENT.repeat(2)}${indentJson(entry, 2)}`, + ); + first = false; + objectsWritten += 1; + lastObjectId = row.id; + } + + if (truncation) { + break; + } + } + + // A row from the preflight snapshot that no longer comes back by id is + // the one case none of the loop's own break conditions catch: nothing + // client-visible went wrong, but a captured identity was silently + // dropped, so `complete` would be a lie. This never happens in practice + // today -- Mall moderation only ever updates `object.status`, it never + // deletes the row -- but the check is what makes that an observed fact + // rather than an assumption the export quietly depends on. + if (!truncation && missingSnapshotRows > 0) { + status = 'truncated'; + truncation = { reason: 'snapshot_rows_missing', lastObjectId }; + } + + // Recorded once, here. Every branch that sets `truncation` above breaks + // out of the loop immediately, so the same assignment written inside the + // loop could never run -- it reported the object count instead of an id. + if (truncation && truncation.lastObjectId === null) { + truncation.lastObjectId = lastObjectId; + } + + // `[]` when nothing was written, rather than a bracket pair with a blank + // line between them. + await writer.write(first ? ']' : `\n${INDENT}]`); + await writer.write(`,\n${INDENT}"result": ${indentJson(this.buildResult({ + status, + truncation, + startedIso, + startedAt, + now, + objectsWritten, + storesCount: stores.length, + viewRows, + allCounts, + includeDerived: options.includeDerived, + derivedTally, + }), 1)}\n}\n`); + } catch (error) { + status = 'failed'; + + if (!bodyStarted) { + // Nothing reached the client, so there is no partial document to close + // and no way to make one parse. Let the controller answer instead. + throw error; + } + + // The document is already partially written, so the only honest thing left + // is to close it with a failed result rather than pretend it succeeded. + // `objects` may not have been opened yet, in which case an empty array is + // what keeps the document parseable. + try { + const tail = objectsOpened + ? (objectsWritten === 0 ? ']' : `\n${INDENT}]`) + : `,\n${INDENT}"objects": []`; + await writer.write(`${tail},\n${INDENT}"result": ${indentJson({ + status: 'failed', + reason: publicErrorCode(error), + finishedAt: new Date(now()).toISOString(), + objectsWritten, + }, 1)}\n}\n`); + } catch (writeError) { + // Nothing further can be reported to a broken stream. + } + } + + return status; + } + + private buildSchema(startedIso: string, options: ExportOptions): JsonObject { + return { + schemaVersion: EXPORT_SCHEMA_VERSION, + generator: EXPORT_GENERATOR, + startedAt: startedIso, + includesDerived: options.includeDerived, + scope: { + objects: 'pending', + note: 'Objects awaiting Mall review (CTR status 2) only. Stocked, warehoused, ' + + 'sold-out and removed objects are deliberately absent: this document is the ' + + 'submission queue the Mall Checker publishes, not the CTR catalogue. ' + + '`stores` remains the full Mall store list, as reference data a consumer ' + + 'needs to render a store name it may meet later.', + }, + timestamps: { + source: 'MySQL TIMESTAMP columns via the mysql driver', + connectionTimezoneOption: 'unset (driver default "local")', + normalized: false, + note: 'Emitted exactly as the CTR API already emits them, and NOT relabelled as ' + + 'UTC. The value depends on the API process timezone, which CTR does not ' + + 'currently pin. See the timezone follow-up.', + }, + fieldClassification: { + db: [ + 'id', 'assetDirectory', 'name', 'creator.memberId', 'price', 'quantity', 'limit', + 'status', 'store', 'placement', 'createdAt', 'updatedAt', 'mallExpiration', + 'description', 'assets.*.filename', + ], + count: ['sold'], + asset: ['derived.wrl.storedBytes', 'derived.wrl.decodedBytes', 'derived.*.sha256'], + derived: [ + 'statusName', 'ctrViews', 'derived.vrmlHeader', 'derived.worldInfo', + 'derived.interpreted', 'derived.comparisons', 'derived.nodeCounts', + 'derived.textureReferences', 'derived.externalReferences', 'derived.viewpoints', + 'derived.warnings', + ], + absent: [ + 'Mall Object Excellence / awards', 'reviewer or checked-by attribution', + 'category or object type', 'rejection reason', 'editorial catalog copy', + ], + }, + }; + } + + /** + * The staff panel's six views as independent id lists. + * + * They overlap on purpose - a sold-out object is in both `stocked` and + * `outOfStock` - so they are never collapsed into a single status label. + */ + private buildViews( + viewRows: ObjectViewRow[], + counts: { [id: number]: number }, + ): JsonObject { + const views: JsonObject = { + _definitions: CTR_VIEW_DEFINITIONS, + _note: 'Current CTR staff-panel view memberships, derived rather than stored. ' + + 'Scoped to the objects in this document, which is pending-only -- so `pending` ' + + 'lists every exported object and the other five views are empty by ' + + 'construction rather than by accident. They are kept so a consumer never has ' + + 'to infer membership from `status`, and so the shape does not change if the ' + + 'export scope is ever widened again.', + pending: [], + warehouse: [], + stocked: [], + outOfStock: [], + removed: [], + inactive: [], + }; + + viewRows.forEach(row => { + const membership = ctrViewsFor({ + status: row.status, + sold: counts[row.id] || 0, + quantity: row.quantity, + limit: row.limit === undefined ? null : row.limit, + }); + Object.keys(membership).forEach(view => { + if (membership[view as keyof typeof membership]) { + (views[view] as number[]).push(row.id); + } + }); + }); + + return views; + } + + private async buildObject( + row: ObjectWithUsername, + context: ExportObjectContext, + ): Promise { + // Snapshot-sourced, not row-sourced: this is what the document's own + // schema.scope, top-level ctrViews and counts already describe this + // object as, so a status change between preflight and this page's fetch + // must not make this entry disagree with them. + const { snapshot } = context; + const limit = snapshot.limit === undefined ? null : snapshot.limit; + const entry: ExportObject = { + id: row.id, + assetDirectory: row.directory ?? null, + name: row.name ?? null, + creator: { + memberId: row.member_id ?? null, + username: context.member ? context.member.username : null, + }, + price: row.price ?? null, + quantity: snapshot.quantity, + limit, + sold: context.sold, + status: snapshot.status, + statusName: statusName(snapshot.status), + store: context.store ? { id: context.store.id, name: context.store.name } : null, + // `position`/`rotation` are columns of `mall_object`, not `object`, so they + // arrive with the store rather than on the object row. + placement: context.store + ? { + position: this.parseJson(context.store.mall_position), + rotation: this.parseJson(context.store.mall_rotation), + } + : null, + ctrViews: ctrViewsFor({ + status: snapshot.status, + sold: context.sold, + quantity: snapshot.quantity, + limit, + }), + createdAt: row.created_at ?? null, + updatedAt: row.updated_at ?? null, + mallExpiration: row.mall_expiration ?? null, + description: row.description ?? null, + assets: { + thumbnail: { + filename: row.image ?? null, + url: assetUrl(row.directory, row.image), + }, + wrl: { + filename: row.filename ?? null, + url: assetUrl(row.directory, row.filename), + }, + texture: row.texture + ? { filename: row.texture, url: assetUrl(row.directory, row.texture) } + : null, + }, + }; + + if (context.includeDerived) { + entry.derived = await this.buildDerived(row, context.derivedTally, entry); + } + + return entry; + } + + private async buildDerived( + row: ObjectWithUsername, + tally: DerivedTally, + entry: ExportObject, + ): Promise { + tally.attempted += 1; + + const source = await this.objectSourceService.readSource({ + directory: row.directory, + filename: row.filename, + }); + + const derived: JsonObject = { + wrl: { + storedBytes: source.storedBytes, + encoding: source.encoding, + decodedBytes: source.decodedBytes, + sha256: source.sha256, + }, + thumbnail: null, + texture: null, + vrmlHeader: null, + worldInfo: null, + interpreted: null, + comparisons: null, + nodeCounts: null, + textureReferences: null, + externalReferences: null, + viewpoints: null, + warnings: [], + sourceError: source.error, + parseError: null, + }; + + if (row.image) { + const thumbnail = await this.objectSourceService.readAssetMetadata({ + directory: row.directory, + filename: row.image, + }); + derived.thumbnail = { bytes: thumbnail.bytes, sha256: thumbnail.sha256, + error: thumbnail.error }; + } + if (row.texture) { + const texture = await this.objectSourceService.readAssetMetadata({ + directory: row.directory, + filename: row.texture, + }); + derived.texture = { bytes: texture.bytes, sha256: texture.sha256, error: texture.error }; + } + + if (source.error !== null || source.text === null) { + tally.failed += 1; + const reason = source.error || 'unreadable'; + tally.failuresByReason[reason] = (tally.failuresByReason[reason] || 0) + 1; + return derived; + } + + try { + const scan = scanVrml(source.text); + const comparison = compareWorldInfo(scan, { + name: entry.name, + creatorUsername: entry.creator.username, + price: entry.price, + limit: entry.limit, + storeName: entry.store ? entry.store.name : null, + }); + + derived.vrmlHeader = scan.header; + derived.worldInfo = scan.worldInfo; + derived.interpreted = comparison.interpreted; + derived.comparisons = comparison.comparisons; + derived.nodeCounts = summariseNodeCounts(scan); + derived.textureReferences = textureReferences(scan); + derived.externalReferences = externalReferences(scan); + derived.viewpoints = scan.viewpoints; + derived.warnings = scan.warnings; + tally.succeeded += 1; + } catch (error) { + derived.parseError = EXPORT_ERROR_CODES.sourceUnreadable; + tally.failed += 1; + tally.failuresByReason.parse_error = (tally.failuresByReason.parse_error || 0) + 1; + } + + return derived; + } + + /** Written last, so every number in it is measured rather than predicted. */ + private buildResult(context: ExportResultContext): JsonObject { + const byStatus: { [status: string]: number } = {}; + const viewSizes: { [view: string]: number } = { + pending: 0, warehouse: 0, stocked: 0, outOfStock: 0, removed: 0, inactive: 0, + }; + + context.viewRows.forEach((row: ObjectViewRow) => { + byStatus[String(row.status)] = (byStatus[String(row.status)] || 0) + 1; + const membership = ctrViewsFor({ + status: row.status, + sold: context.allCounts[row.id] || 0, + quantity: row.quantity, + limit: row.limit === undefined ? null : row.limit, + }); + Object.keys(viewSizes).forEach(view => { + if (membership[view as keyof typeof membership]) { + viewSizes[view] += 1; + } + }); + }); + + const result: JsonObject = { + status: context.status, + finishedAt: new Date(context.now()).toISOString(), + durationMs: context.now() - context.startedAt, + objectsWritten: context.objectsWritten, + counts: { + _takenAt: context.startedIso, + _definitions: { + stores: 'place WHERE type = \'shop\' AND status = 1', + objects: 'COUNT(object) WHERE object.status = 2', + byStatus: 'COUNT(object) WHERE object.status = 2 GROUP BY object.status', + ctrViewSizes: 'length of each ctrViews list; predicates in ctrViews._definitions', + }, + stores: context.storesCount, + objects: context.viewRows.length, + byStatus, + ctrViewSizes: viewSizes, + }, + truncation: context.truncation, + }; + + if (context.includeDerived) { + result.derived = context.derivedTally; + } + + return result; + } + + private parseJson(value: unknown): unknown { + if (typeof value !== 'string' || value === '') { + return null; + } + try { + return JSON.parse(value); + } catch (error) { + return null; + } + } +} diff --git a/api/src/services/mall-inspection/mall-inspection.service.spec.ts b/api/src/services/mall-inspection/mall-inspection.service.spec.ts new file mode 100644 index 00000000..e5e01851 --- /dev/null +++ b/api/src/services/mall-inspection/mall-inspection.service.spec.ts @@ -0,0 +1,427 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import zlib from 'zlib'; +import { createSpyObj } from 'jest-createspyobj'; + +import { findingSeverity, MallInspectionService } from './mall-inspection.service'; +import { ObjectSourceService } from '../object-source/object-source.service'; +import { + MallRepository, + MemberRepository, + ObjectInstanceRepository, + ObjectRepository, +} from '../../repositories'; + +const POCKET_MOON = `#VRML V2.0 utf8 + +WorldInfo { + title "Pocket Moon Playset" + info [ + "Made By: BassMekanik" + "Uploaded: August, 2026" + "Store: Toy Store" + "Limited To: UNLIMITED" + "Mall Price: 75 CC" + ] +} +DEF Lid Group { children [ Shape { geometry Box {} } ] } +DEF Open TouchSensor {} +DEF Spin TimeSensor {} +`; + +function record(overrides: { [key: string]: unknown } = {}) { + return { + id: 3339, + name: 'Pocket Moon Playset', + directory: 'uuid-moon', + filename: 'moon.wrl', + image: 'moon.jpg', + texture: null, + member_id: 812, + price: 75, + quantity: 25, + limit: null, + status: 2, + mall_expiration: null, + created_at: '2026-08-20T08:02:43.000Z', + updated_at: '2026-08-20T08:02:43.000Z', + description: null, + ...overrides, + }; +} + +describe('MallInspectionService', () => { + let assetsDir: string; + let objectRoot: string; + let originalAssetsDir: string | undefined; + let objectRepository: jest.Mocked; + let memberRepository: jest.Mocked; + let mallRepository: jest.Mocked; + let objectInstanceRepository: jest.Mocked; + let service: MallInspectionService; + + function writeAsset(directory: string, filename: string, contents: Buffer | string): void { + const target = path.join(objectRoot, directory); + fs.mkdirSync(target, { recursive: true }); + fs.writeFileSync(path.join(target, filename), contents); + } + + function findingCodes(findings: { code: string }[]): string[] { + return findings.map(finding => finding.code); + } + + beforeEach(() => { + originalAssetsDir = process.env.ASSETS_DIR; + assetsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ctr-inspection-')); + objectRoot = path.join(assetsDir, 'object'); + fs.mkdirSync(objectRoot, { recursive: true }); + process.env.ASSETS_DIR = assetsDir; + + objectRepository = createSpyObj(ObjectRepository); + memberRepository = createSpyObj(MemberRepository); + mallRepository = createSpyObj(MallRepository); + objectInstanceRepository = createSpyObj(ObjectInstanceRepository); + + memberRepository.findById.mockResolvedValue({ username: 'BassMekanik' } as never); + mallRepository.getStore.mockResolvedValue([]); + objectInstanceRepository.countByObjectId.mockResolvedValue(0); + + service = new MallInspectionService( + objectRepository, + memberRepository, + mallRepository, + objectInstanceRepository, + new ObjectSourceService(), + ); + }); + + afterEach(() => { + process.env.ASSETS_DIR = originalAssetsDir; + fs.rmSync(assetsDir, { recursive: true, force: true }); + }); + + it('returns null for an object that does not exist', async () => { + objectRepository.findById.mockResolvedValue(undefined as never); + + expect(await service.inspect(1)).toBeNull(); + }); + + describe('a healthy gzip-compressed upload', () => { + beforeEach(() => { + objectRepository.findById.mockResolvedValue(record() as never); + mallRepository.getStore.mockResolvedValue([{ id: 1205, name: 'Toy Store' }] as never); + writeAsset('uuid-moon', 'moon.wrl', zlib.gzipSync(Buffer.from(POCKET_MOON))); + writeAsset('uuid-moon', 'moon.jpg', Buffer.alloc(32)); + }); + + it('reports the encoding and both byte counts separately', async () => { + const inspection = await service.inspect(3339); + + expect(inspection.source.error).toBeNull(); + expect(inspection.source.encoding).toBe('gzip'); + expect(inspection.source.decodedBytes).toBe(Buffer.byteLength(POCKET_MOON)); + expect(inspection.source.storedBytes).toBeLessThan(inspection.source.decodedBytes); + }); + + it('surfaces the WorldInfo without the checker downloading anything', async () => { + const inspection = await service.inspect(3339); + + expect(inspection.vrml.worldInfo[0].title).toBe('Pocket Moon Playset'); + expect(inspection.vrml.worldInfo[0].info).toContain('Made By: BassMekanik'); + }); + + it('compares WorldInfo against the CTR record', async () => { + const inspection = await service.inspect(3339); + const verdicts: { [field: string]: string } = {}; + inspection.comparisons.forEach(comparison => { + verdicts[comparison.field] = comparison.verdict; + }); + + expect(verdicts).toEqual({ + name: 'MATCH', + creator: 'MATCH', + price: 'MATCH', + limit: 'MATCH', + store: 'MATCH', + }); + }); + + it('raises no findings for a clean object', async () => { + expect((await service.inspect(3339)).findings).toEqual([]); + }); + + it('exposes public asset urls and never a filesystem path', async () => { + const inspection = await service.inspect(3339); + const serialised = JSON.stringify(inspection); + + expect(inspection.object.assets.wrl.url).toBe('/assets/object/uuid-moon/moon.wrl'); + expect(serialised).not.toContain(assetsDir); + expect(serialised).not.toContain(os.tmpdir()); + }); + + it('reports the CTR views the object belongs to', async () => { + const inspection = await service.inspect(3339); + + expect(inspection.object.ctrViews.pending).toBe(true); + expect(inspection.object.ctrViews.stocked).toBe(false); + expect(inspection.object.statusLabel).toBe('Pending'); + }); + }); + + describe('rule observations', () => { + beforeEach(() => { + objectRepository.findById.mockResolvedValue(record() as never); + }); + + it('reports forbidden nodes that are genuinely present', async () => { + writeAsset('uuid-moon', 'moon.wrl', `#VRML V2.0 utf8 +WorldInfo { title "Pocket Moon Playset" } +Sound { source AudioClip { url "beep.wav" } } +Inline { url "other.wrl" } +DirectionalLight {} +`); + + const findings = (await service.inspect(3339)).findings; + + expect(findingCodes(findings).filter(code => code === 'forbidden_node')).toHaveLength(3); + }); + + it('does not report forbidden nodes that only appear inside comments', async () => { + writeAsset('uuid-moon', 'moon.wrl', `#VRML V2.0 utf8 +WorldInfo { title "Pocket Moon Playset" } +# there is no Sound { } and no Inline { } in this object +Shape {} +`); + + expect(findingCodes((await service.inspect(3339)).findings)) + .not.toContain('forbidden_node'); + }); + + it('reports more than one distinct texture', async () => { + writeAsset('uuid-moon', 'moon.wrl', `#VRML V2.0 utf8 +WorldInfo { title "Pocket Moon Playset" } +Shape { appearance Appearance { texture ImageTexture { url "a.jpg" } } } +Shape { appearance Appearance { texture ImageTexture { url "b.jpg" } } } +`); + writeAsset('uuid-moon', 'a.jpg', Buffer.alloc(8)); + writeAsset('uuid-moon', 'b.jpg', Buffer.alloc(8)); + + expect(findingCodes((await service.inspect(3339)).findings)) + .toContain('multiple_textures'); + }); + + it('reports a texture the object references but does not ship', async () => { + writeAsset('uuid-moon', 'moon.wrl', `#VRML V2.0 utf8 +WorldInfo { title "Pocket Moon Playset" } +Shape { appearance Appearance { texture ImageTexture { url "absent.jpg" } } } +`); + + expect(findingCodes((await service.inspect(3339)).findings)).toContain('texture_missing'); + }); + + it('classifies an unreadable source as needing staff review', async () => { + // Deliberately writes no WRL, so the stored file is genuinely absent. + const findings = (await service.inspect(3339)).findings; + const missing = findings.find(finding => finding.code === 'missing'); + expect(missing).toBeDefined(); + // The page could not establish anything below it, so it must not read as a + // mere warning about the object. + expect(missing.severity).toBe('needs_staff_review'); + }); + + it('classifies an established rule breach as a warning', async () => { + writeAsset('uuid-moon', 'moon.wrl', `#VRML V2.0 utf8 +WorldInfo { title "Pocket Moon Playset" } +Shape { appearance Appearance { texture ImageTexture { url "http://x.test/a.jpg" } } } +`); + + const findings = (await service.inspect(3339)).findings; + const external = findings.find(finding => finding.code === 'external_reference'); + expect(external.severity).toBe('warning'); + }); + + it('classifies a fact that decides nothing as information', async () => { + writeAsset('uuid-moon', 'moon.wrl', `#VRML V2.0 utf8 +WorldInfo { title "Pocket Moon Playset" } +Shape { appearance Appearance { texture ImageTexture { url "a.jpg" } } } +Shape { appearance Appearance { texture ImageTexture { url "b.jpg" } } } +`); + writeAsset('uuid-moon', 'a.jpg', Buffer.alloc(8)); + writeAsset('uuid-moon', 'b.jpg', Buffer.alloc(8)); + + const findings = (await service.inspect(3339)).findings; + const multiple = findings.find(finding => finding.code === 'multiple_textures'); + expect(multiple.severity).toBe('info'); + }); + + it('gives every finding a severity', async () => { + writeAsset('uuid-moon', 'moon.wrl', `#VRML V2.0 utf8 +Shape { appearance Appearance { texture ImageTexture { url "../escape.jpg" } } } +`); + + const findings = (await service.inspect(3339)).findings; + expect(findings.length).toBeGreaterThan(0); + findings.forEach(finding => { + expect(['info', 'warning', 'needs_staff_review']).toContain(finding.severity); + }); + }); + + it('falls back to needing staff review for an unrecognised code', () => { + expect(findingSeverity('something_the_scanner_invented')).toBe('needs_staff_review'); + }); + + it('reports a texture referenced through a subdirectory that does not exist', async () => { + writeAsset('uuid-moon', 'moon.wrl', `#VRML V2.0 utf8 +WorldInfo { title "Pocket Moon Playset" } +Shape { appearance Appearance { texture ImageTexture { url "textures/wood.jpg" } } } +`); + + const codes = findingCodes((await service.inspect(3339)).findings); + expect(codes).toContain('texture_subdirectory'); + // In-directory, so it is not an escape and must not be reported as one. + expect(codes).not.toContain('external_reference'); + }); + + it('stays quiet when a subdirectory texture really is on disk', async () => { + writeAsset('uuid-moon', 'moon.wrl', `#VRML V2.0 utf8 +WorldInfo { title "Pocket Moon Playset" } +Shape { appearance Appearance { texture ImageTexture { url "textures/wood.jpg" } } } +`); + writeAsset(path.join('uuid-moon', 'textures'), 'wood.jpg', Buffer.alloc(8)); + + const codes = findingCodes((await service.inspect(3339)).findings); + expect(codes).not.toContain('texture_subdirectory'); + expect(codes).not.toContain('texture_missing'); + }); + + it('reports a parent-traversal texture as external, not as a subdirectory', async () => { + writeAsset('uuid-moon', 'moon.wrl', `#VRML V2.0 utf8 +WorldInfo { title "Pocket Moon Playset" } +Shape { appearance Appearance { texture ImageTexture { url "../uuid-other/wood.jpg" } } } +`); + + const codes = findingCodes((await service.inspect(3339)).findings); + expect(codes).toContain('external_reference'); + expect(codes).not.toContain('texture_subdirectory'); + }); + + it('reports an external reference', async () => { + writeAsset('uuid-moon', 'moon.wrl', `#VRML V2.0 utf8 +WorldInfo { title "Pocket Moon Playset" } +Shape { appearance Appearance { texture ImageTexture { url "http://x.test/a.jpg" } } } +`); + + expect(findingCodes((await service.inspect(3339)).findings)) + .toContain('external_reference'); + }); + + it('does not treat an inline Script body as an external reference', async () => { + writeAsset('uuid-moon', 'moon.wrl', `#VRML V2.0 utf8 +WorldInfo { title "Pocket Moon Playset" } +DEF Anim Script { url "vrmlscript: function f(a, t) { return a; }" } +`); + + expect(findingCodes((await service.inspect(3339)).findings)) + .not.toContain('external_reference'); + }); + + it('reports when the decompressed VRML exceeds the 80 KB rule the upload passed', + async () => { + const padding = `\n# ${'x'.repeat(90000)}\n`; + writeAsset('uuid-moon', 'moon.wrl', zlib.gzipSync(Buffer.from(POCKET_MOON + padding))); + + const findings = (await service.inspect(3339)).findings; + + expect(findingCodes(findings)).toContain('decoded_exceeds_upload_limit'); + }); + + it('reports a missing WorldInfo without refusing to render the rest', async () => { + writeAsset('uuid-moon', 'moon.wrl', '#VRML V2.0 utf8\nShape {}\n'); + + const inspection = await service.inspect(3339); + + expect(findingCodes(inspection.findings)).toContain('no_worldinfo'); + expect(inspection.object.name).toBe('Pocket Moon Playset'); + expect(inspection.vrml).not.toBeNull(); + }); + }); + + describe('UTF-8 validity', () => { + beforeEach(() => { + objectRepository.findById.mockResolvedValue(record() as never); + }); + + it('does not raise an encoding finding for a file that legitimately contains U+FFFD', + async () => { + // U+FFFD has a valid UTF-8 encoding of its own (EF BF BD); a creator + // is allowed to include it, so its mere presence must not be read as + // proof the stored bytes were malformed. + writeAsset('uuid-moon', 'moon.wrl', `#VRML V2.0 utf8 +WorldInfo { title "Pocket Moon Playset" } +# comment containing a literal replacement character: � +`); + + const inspection = await service.inspect(3339); + + expect(inspection.source.replacementCharacters).toBeGreaterThan(0); + expect(inspection.source.utf8Valid).toBe(true); + expect(findingCodes(inspection.findings)).not.toContain('encoding_warnings'); + }); + + it('raises the encoding finding for genuinely malformed UTF-8 bytes', async () => { + writeAsset('uuid-moon', 'moon.wrl', Buffer.concat([ + Buffer.from('#VRML V2.0 utf8\nWorldInfo { title "Pocket Moon Playset" }\n'), + Buffer.from([0xff, 0xfe, 0xfd]), + ])); + + const inspection = await service.inspect(3339); + + expect(inspection.source.utf8Valid).toBe(false); + const finding = inspection.findings.find(f => f.code === 'encoding_warnings'); + expect(finding).toBeDefined(); + expect(finding.severity).toBe('needs_staff_review'); + }); + }); + + describe('broken uploads still produce a usable page', () => { + beforeEach(() => { + objectRepository.findById.mockResolvedValue(record() as never); + }); + + it('reports a missing file and still returns the CTR record', async () => { + const inspection = await service.inspect(3339); + + expect(inspection.source.error).toBe('missing'); + expect(inspection.vrml).toBeNull(); + expect(inspection.comparisons).toBeNull(); + expect(findingCodes(inspection.findings)).toEqual(['missing']); + expect(inspection.object.name).toBe('Pocket Moon Playset'); + expect(inspection.object.assets.thumbnail.url).toBe('/assets/object/uuid-moon/moon.jpg'); + }); + + it('reports corrupt gzip and still returns the CTR record', async () => { + const compressed = zlib.gzipSync(Buffer.from(POCKET_MOON)); + writeAsset('uuid-moon', 'moon.wrl', compressed.slice(0, 20)); + + const inspection = await service.inspect(3339); + + expect(inspection.source.error).toBe('gzip_corrupt'); + expect(findingCodes(inspection.findings)).toEqual(['gzip_corrupt']); + expect(inspection.object.price).toBe(75); + }); + }); + + describe('creator handling', () => { + it('keeps a null creator null rather than inventing "Deleted User"', async () => { + objectRepository.findById.mockResolvedValue(record({ member_id: null }) as never); + writeAsset('uuid-moon', 'moon.wrl', POCKET_MOON); + + const inspection = await service.inspect(3339); + + expect(inspection.object.creator).toEqual({ memberId: null, username: null }); + expect(memberRepository.findById).not.toHaveBeenCalled(); + expect(JSON.stringify(inspection)).not.toContain('Deleted User'); + }); + }); +}); diff --git a/api/src/services/mall-inspection/mall-inspection.service.ts b/api/src/services/mall-inspection/mall-inspection.service.ts new file mode 100644 index 00000000..647bdf27 --- /dev/null +++ b/api/src/services/mall-inspection/mall-inspection.service.ts @@ -0,0 +1,499 @@ +import { Service } from 'typedi'; + +import { + MallRepository, + MemberRepository, + ObjectInstanceRepository, + ObjectRepository, +} from '../../repositories'; +import { + compareWorldInfo, + ctrViewsFor, + CtrViews, + externalReferences, + escapesObjectDirectory, + FieldComparison, + InterpretedWorldInfo, + scanVrml, + statusLabel, + statusName, + summariseNodeCounts, + textureReferences, + ViewpointFact, + VrmlUrlReference, + WorldInfoNode, +} from '../../libs'; +import { + ObjectSourceEncoding, + ObjectSourceError, + ObjectSourceService, +} from '../object-source/object-source.service'; + +/** + * Assembles everything a Mall staff member needs to review one uploaded object + * on a single screen: the CTR record, the counts, the stored file's real shape, + * its WorldInfo, and where the two disagree. + * + * All of it is read-only, and every finding is advisory. Nothing here decides, + * blocks or performs a moderation action. + */ + +/** The 80 KB ceiling `ObjectController.add` enforces, for reference in findings. */ +const WRL_UPLOAD_LIMIT_BYTES = 81920; + +/** Nodes the Mall rules forbid outright, so their mere presence is worth saying. */ +const FORBIDDEN_NODES = ['Inline', 'EXTERNPROTO', 'Sound', 'DirectionalLight']; + +/** Cap on how many referenced textures we check for existence on disk. */ +const MAX_TEXTURE_EXISTENCE_CHECKS = 10; + +export interface InspectionAsset { + filename: string | null; + url: string | null; +} + +/** + * How much a finding should move a checker. + * + * The distinction that matters on this page is not "how bad" but "can the page + * be believed". `needs_staff_review` means the inspection could not establish + * the facts shown below it, so the staff member has to look at the file or the + * asset themselves rather than trusting this screen. `warning` means the facts + * WERE established and they show a problem with the object. `info` is a fact + * worth surfacing that decides nothing on its own. + * + * None of these gate an action; the checker still accepts or rejects by hand. + */ +export type InspectionSeverity = 'info' | 'warning' | 'needs_staff_review'; + +export interface InspectionFinding { + code: string; + message: string; + severity: InspectionSeverity; +} + +/** + * Severity per finding code, in one place so it cannot drift between the sites + * that raise findings. + * + * Anything absent is treated as `needs_staff_review` -- an unrecognised scanner + * warning is precisely the case where we do not know what we are looking at, + * and silently downgrading it to `info` would be the one dishonest option. + */ +const FINDING_SEVERITY: { [code: string]: InspectionSeverity } = { + // The source could not be read, so nothing below it was established. + not_configured: 'needs_staff_review', + outside_assets_root: 'needs_staff_review', + missing: 'needs_staff_review', + too_large: 'needs_staff_review', + gzip_corrupt: 'needs_staff_review', + gzip_too_large: 'needs_staff_review', + unreadable: 'needs_staff_review', + // The scan ran but could not finish, so the facts shown are incomplete. + malformed_vrml: 'needs_staff_review', + too_complex: 'needs_staff_review', + encoding_warnings: 'needs_staff_review', + // The comparison had to pick one of several WorldInfo nodes; which one is + // authoritative is a judgement only a person can make. + multiple_worldinfo: 'needs_staff_review', + + // Established facts that show something wrong with the object itself. + bad_header: 'warning', + no_worldinfo: 'warning', + forbidden_node: 'warning', + external_reference: 'warning', + decoded_exceeds_upload_limit: 'warning', + texture_missing: 'warning', + texture_subdirectory: 'warning', + texture_not_recorded: 'warning', + + // True, worth seeing, and not a violation on its own. + multiple_textures: 'info', +}; + +export function findingSeverity(code: string): InspectionSeverity { + return FINDING_SEVERITY[code] || 'needs_staff_review'; +} + +/** + * What the helpers below produce. Severity is stamped once, at the point the + * findings are assembled, so no site that raises a finding can pick a severity + * that disagrees with `FINDING_SEVERITY`. + */ +type RawFinding = Pick; + +export interface InspectionSource { + encoding: ObjectSourceEncoding | null; + storedBytes: number | null; + decodedBytes: number | null; + sha256: string | null; + replacementCharacters: number; + utf8Valid: boolean; + error: ObjectSourceError | null; +} + +export interface InspectionVrml { + header: string | null; + headerIsVrml97: boolean; + worldInfo: WorldInfoNode[]; + nodeCounts: { [nodeType: string]: number }; + protoDefinitions: string[]; + externProtoDefinitions: string[]; + textureReferences: VrmlUrlReference[]; + externalReferences: VrmlUrlReference[]; + viewpoints: ViewpointFact[]; + warnings: string[]; +} + +export interface MallObjectInspection { + object: { + id: number; + name: string | null; + assetDirectory: string | null; + creator: { memberId: number | null; username: string | null }; + price: number | null; + quantity: number | null; + limit: number | null; + sold: number; + status: number; + statusName: string; + statusLabel: string; + store: { id: number; name: string } | null; + ctrViews: CtrViews; + createdAt: Date | string | null; + updatedAt: Date | string | null; + mallExpiration: Date | string | null; + description: string | null; + assets: { + thumbnail: InspectionAsset; + wrl: InspectionAsset; + texture: InspectionAsset | null; + }; + }; + source: InspectionSource; + /** Null when the source could not be decoded, so there was nothing to scan. */ + vrml: InspectionVrml | null; + interpreted: InterpretedWorldInfo | null; + comparisons: FieldComparison[] | null; + findings: InspectionFinding[]; +} + +function assetUrl(directory: string | null, filename: string | null): string | null { + if (!directory || !filename) { + return null; + } + return `/assets/object/${directory}/${filename}`; +} + +@Service() +export class MallInspectionService { + constructor( + private objectRepository: ObjectRepository, + private memberRepository: MemberRepository, + private mallRepository: MallRepository, + private objectInstanceRepository: ObjectInstanceRepository, + private objectSourceService: ObjectSourceService, + ) {} + + /** + * Builds the full inspection payload, or null when no such object exists. + * + * A file that cannot be read or parsed never fails the call - the CTR record, + * the thumbnail and the viewer must still be usable when an upload is broken, + * because that is exactly when a checker most needs to look at it. + */ + public async inspect(objectId: number): Promise { + const record = await this.objectRepository.findById(objectId); + if (!record) { + return null; + } + + const [member, stores, sold] = await Promise.all([ + record.member_id ? this.memberRepository.findById(record.member_id) : Promise.resolve(null), + this.mallRepository.getStore(record.id), + this.objectInstanceRepository.countByObjectId(record.id), + ]); + + const store = stores && stores[0] ? { id: stores[0].id, name: stores[0].name } : null; + const limit = record.limit === undefined ? null : record.limit; + + const source = await this.objectSourceService.readSource({ + directory: record.directory, + filename: record.filename, + }); + + const findings: RawFinding[] = []; + let vrml: InspectionVrml | null = null; + let interpreted: InterpretedWorldInfo | null = null; + let comparisons: FieldComparison[] | null = null; + + if (source.error !== null) { + findings.push(this.describeSourceError(source.error)); + } else if (source.text !== null) { + const scan = scanVrml(source.text); + const textures = textureReferences(scan); + + vrml = { + header: scan.header, + headerIsVrml97: scan.headerIsVrml97, + worldInfo: scan.worldInfo, + nodeCounts: summariseNodeCounts(scan), + protoDefinitions: scan.protoDefinitions, + externProtoDefinitions: scan.externProtoDefinitions, + textureReferences: textures, + externalReferences: externalReferences(scan), + viewpoints: scan.viewpoints, + warnings: scan.warnings, + }; + + const comparison = compareWorldInfo(scan, { + name: record.name ?? null, + creatorUsername: member ? member.username : null, + price: record.price ?? null, + limit, + storeName: store ? store.name : null, + }); + interpreted = comparison.interpreted; + comparisons = comparison.comparisons; + + findings.push(...this.describeScanWarnings(scan.warnings)); + findings.push(...this.describeRuleObservations(vrml, source.decodedBytes)); + findings.push(...await this.checkTextureFiles(record.directory, textures, record.texture)); + + if (!source.utf8Valid) { + // `replacementCharacters` is reported alongside as context, not as the + // proof: U+FFFD has a valid UTF-8 encoding of its own, so a creator + // legitimately including it would not make `utf8Valid` false. + findings.push({ + code: 'encoding_warnings', + message: source.replacementCharacters > 0 + ? `The file is not valid UTF-8: ${source.replacementCharacters} ` + + 'character(s) could not be decoded.' + : 'The file is not valid UTF-8.', + }); + } + } + + return { + object: { + id: record.id, + name: record.name ?? null, + assetDirectory: record.directory ?? null, + creator: { + memberId: record.member_id ?? null, + username: member ? member.username : null, + }, + price: record.price ?? null, + quantity: record.quantity ?? null, + limit, + sold, + status: record.status, + statusName: statusName(record.status), + statusLabel: statusLabel(record.status), + store, + ctrViews: ctrViewsFor({ + status: record.status, + sold, + quantity: record.quantity, + limit, + }), + createdAt: (record as never)['created_at'] ?? null, + updatedAt: (record as never)['updated_at'] ?? null, + mallExpiration: record.mall_expiration ?? null, + description: (record as never)['description'] ?? null, + assets: { + thumbnail: { + filename: record.image ?? null, + url: assetUrl(record.directory, record.image), + }, + wrl: { + filename: record.filename ?? null, + url: assetUrl(record.directory, record.filename), + }, + texture: record.texture + ? { filename: record.texture, url: assetUrl(record.directory, record.texture) } + : null, + }, + }, + source: { + encoding: source.encoding, + storedBytes: source.storedBytes, + decodedBytes: source.decodedBytes, + sha256: source.sha256, + replacementCharacters: source.replacementCharacters, + utf8Valid: source.utf8Valid, + error: source.error, + }, + vrml, + interpreted, + comparisons, + findings: findings.map(finding => ({ + ...finding, + severity: findingSeverity(finding.code), + })), + }; + } + + /** The decoded VRML text, for the raw-source pane and the download action. */ + public async readSourceText(objectId: number): Promise<{ + text: string | null; + error: ObjectSourceError | 'not_found' | null; + }> { + const record = await this.objectRepository.findById(objectId); + if (!record) { + return { text: null, error: 'not_found' }; + } + + const source = await this.objectSourceService.readSource({ + directory: record.directory, + filename: record.filename, + }); + + return { text: source.text, error: source.error }; + } + + private describeSourceError(error: ObjectSourceError): RawFinding { + const messages: { [key in ObjectSourceError]: string } = { + not_configured: 'The server asset directory is not configured, so the file ' + + 'could not be read.', + outside_assets_root: 'The stored path for this object resolves outside the asset ' + + 'directory and was refused.', + missing: 'The stored WRL file is missing from disk.', + too_large: 'The stored WRL file is larger than the inspection limit and was not read.', + gzip_corrupt: 'The stored WRL looks gzip-compressed but could not be decompressed.', + gzip_too_large: 'The stored WRL decompresses to more than the inspection limit ' + + 'and was refused.', + unreadable: 'The stored WRL file could not be read.', + }; + return { code: error, message: messages[error] }; + } + + private describeScanWarnings(warnings: string[]): RawFinding[] { + const messages: { [code: string]: string } = { + bad_header: 'The first line is not "#VRML V2.0 utf8".', + no_worldinfo: 'The object has no WorldInfo node. The Mall rules require one.', + multiple_worldinfo: 'The object has more than one WorldInfo node. The comparison ' + + 'below uses the first.', + malformed_vrml: 'The VRML is malformed (an unterminated string or unbalanced braces). ' + + 'The facts below may be incomplete.', + too_complex: 'The file exceeded the scanner budget, so the facts below are incomplete.', + }; + return warnings.map(code => ({ + code, + message: messages[code] || `Scanner reported: ${code}.`, + })); + } + + /** + * Observations against documented Mall rules that can be established from the + * file alone. These are statements about what is in the object; they are not + * verdicts, and they never gate an action. + */ + private describeRuleObservations( + vrml: InspectionVrml, + decodedBytes: number | null, + ): RawFinding[] { + const findings: RawFinding[] = []; + + FORBIDDEN_NODES.forEach(node => { + const count = vrml.nodeCounts[node] || 0; + if (count > 0) { + findings.push({ + code: 'forbidden_node', + message: `Contains ${count} ${node} node(s), which the Mall rules do not allow.`, + }); + } + }); + + if ((vrml.nodeCounts.hAnim || 0) > 0) { + findings.push({ + code: 'forbidden_node', + message: `Contains ${vrml.nodeCounts.hAnim} H-Anim node(s), which the Mall rules ` + + 'do not allow.', + }); + } + + if (vrml.textureReferences.length > 1) { + const names = vrml.textureReferences.map(reference => reference.value).join(', '); + findings.push({ + code: 'multiple_textures', + message: `References ${vrml.textureReferences.length} distinct textures (${names}). ` + + 'The Mall rules allow one.', + }); + } + + vrml.externalReferences.forEach(reference => { + findings.push({ + code: 'external_reference', + message: `References "${reference.value}" outside the object's own directory.`, + }); + }); + + if (decodedBytes !== null && decodedBytes > WRL_UPLOAD_LIMIT_BYTES) { + findings.push({ + code: 'decoded_exceeds_upload_limit', + message: `The VRML is ${decodedBytes} bytes once decompressed, above the ` + + `${WRL_UPLOAD_LIMIT_BYTES}-byte rule. Upload validation measures the compressed ` + + 'size, so this passed on upload. Reported for information only.', + }); + } + + return findings; + } + + /** Confirms that locally-referenced textures actually sit beside the WRL. */ + private async checkTextureFiles( + directory: string, + textures: VrmlUrlReference[], + recordedTexture: string | null, + ): Promise { + const findings: RawFinding[] = []; + + // A subdirectory reference such as `textures/wood.jpg` is still inside the + // object's own directory, so it is not an external reference -- but uploads + // are stored flat (`ObjectService.uploadObjectFiles` creates the one object + // directory and writes every file directly into it), so nothing ever puts a + // file there. Checking it alongside the bare filenames is what turns that + // into a finding instead of silence. + const checkable = textures + .filter(reference => reference.kind === 'local' + || (reference.kind === 'relative' && !escapesObjectDirectory(reference.value))) + .slice(0, MAX_TEXTURE_EXISTENCE_CHECKS); + + for (const reference of checkable) { + const metadata = await this.objectSourceService.readAssetMetadata({ + directory, + filename: reference.value, + }); + // Only absence is reported. An unreadable or misconfigured asset root is + // a server problem, not something the uploader can fix, and saying "not + // stored with it" there would be a false accusation. + if (metadata.error !== 'missing') { + continue; + } + if (reference.kind === 'relative') { + findings.push({ + code: 'texture_subdirectory', + message: `The object references "${reference.value}" in a subdirectory. Object ` + + 'files are stored side by side, so this texture will not be found.', + }); + } else { + findings.push({ + code: 'texture_missing', + message: `The object references "${reference.value}" but that file is not stored ` + + 'with it.', + }); + } + } + + if (textures.length > 0 && !recordedTexture) { + findings.push({ + code: 'texture_not_recorded', + message: 'The object references a texture but no texture file was uploaded ' + + 'alongside it.', + }); + } + + return findings; + } +} diff --git a/api/src/services/mall/mall.service.spec.ts b/api/src/services/mall/mall.service.spec.ts new file mode 100644 index 00000000..2665321b --- /dev/null +++ b/api/src/services/mall/mall.service.spec.ts @@ -0,0 +1,195 @@ +import { createSpyObj } from 'jest-createspyobj'; +import { ObjectWithUsername } from '../../repositories/object/object.repository'; + +/** + * The fixtures carry only the columns decoration reads, not whole object rows, + * so the helper takes what the tests actually build. + */ +type FixtureObject = Partial & { id: number }; +import { StoreRow } from '../../repositories/mall-object/mall-object.repository'; +import { Member } from '../../types/models'; + +import { MallService } from './mall.service'; +import { + MallRepository, + MemberRepository, + ObjectInstanceRepository, + ObjectRepository, + PlaceRepository, + RoleAssignmentRepository, + RoleRepository, +} from '../../repositories'; + +/** + * The per-object loop these tests replace, transcribed from the previous + * implementation. Every batched result is checked against it so the change is + * provably output-preserving rather than merely plausible. + */ +async function legacyDecorate( + objects: FixtureObject[], + members: { [id: number]: Pick }, + stores: { [id: number]: Partial }, + counts: { [id: number]: number }, +): Promise { + const decorated = []; + for (const object of objects) { + const user = object.member_id ? members[object.member_id] : null; + const store = stores[object.id] ? [stores[object.id]] : []; + decorated.push({ + ...object, + username: user?.username || 'Deleted User', + store: store[0], + instances: counts[object.id] || 0, + }); + } + return decorated; +} + +const OBJECTS = [ + { id: 10, member_id: 100, name: 'Lamp', quantity: 25, limit: null, status: 1 }, + { id: 11, member_id: 101, name: 'Chair', quantity: 25, limit: 25, status: 1 }, + { id: 12, member_id: null, name: 'Orphan', quantity: 5, limit: null, status: 1 }, + { id: 13, member_id: 100, name: 'Unsold', quantity: 30, limit: null, status: 1 }, +]; + +const MEMBERS = { + 100: { id: 100, username: 'BassMekanik' }, + 101: { id: 101, username: 'Morning.star' }, +}; + +const STORES = { + 10: { id: 1205, name: 'Toy Store', object_id: 10 }, + 11: { id: 1191, name: 'Furniture Store', object_id: 11 }, + 12: { id: 1193, name: 'General Store', object_id: 12 }, +}; + +const COUNTS = { 10: 25, 11: 25, 12: 5 }; + +describe('MallService - batched list decoration', () => { + let roleAssignmentRepository: jest.Mocked; + let roleRepository: jest.Mocked; + let objectRepository: jest.Mocked; + let objectInstanceRepository: jest.Mocked; + let placeRepository: jest.Mocked; + let mallRepository: jest.Mocked; + let memberRepository: jest.Mocked; + let service: MallService; + + beforeEach(() => { + roleAssignmentRepository = createSpyObj(RoleAssignmentRepository); + roleRepository = createSpyObj(RoleRepository); + objectRepository = createSpyObj(ObjectRepository); + objectInstanceRepository = createSpyObj(ObjectInstanceRepository); + placeRepository = createSpyObj(PlaceRepository); + mallRepository = createSpyObj(MallRepository); + memberRepository = createSpyObj(MemberRepository); + + memberRepository.findByIds.mockResolvedValue(MEMBERS as never); + mallRepository.getStoresByObjectIds.mockResolvedValue(STORES as never); + objectInstanceRepository.countByObjectIds.mockResolvedValue(COUNTS as never); + + service = new MallService( + roleAssignmentRepository, + roleRepository, + objectRepository, + objectInstanceRepository, + placeRepository, + mallRepository, + memberRepository, + ); + }); + + describe('findSoldOut', () => { + beforeEach(() => { + objectRepository.findMallSoldOut.mockResolvedValue( + OBJECTS.map(object => ({ ...object })) as never, + ); + }); + + it('produces exactly what the previous per-object loop produced', async () => { + const result = await service.findSoldOut(); + const expected = await legacyDecorate(OBJECTS, MEMBERS, STORES, COUNTS); + + expect(result.objects).toEqual(expected); + }); + + it('preserves object order', async () => { + const result = await service.findSoldOut(); + + expect(result.objects.map((object: FixtureObject) => object.id)).toEqual([10, 11, 12, 13]); + }); + + it('keeps the "Deleted User" placeholder for an object with no creator', async () => { + const result = await service.findSoldOut(); + const orphan = result.objects.find((object: FixtureObject) => object.id === 12); + + expect(orphan.username).toBe('Deleted User'); + }); + + it('defaults an object with no instances to a sold count of zero', async () => { + const result = await service.findSoldOut(); + const unsold = result.objects.find((object: FixtureObject) => object.id === 13); + + expect(unsold.instances).toBe(0); + expect(unsold.store).toBeUndefined(); + }); + + it('asks for the creators, stores and counts once each, not once per object', + async () => { + await service.findSoldOut(); + + expect(memberRepository.findByIds).toHaveBeenCalledTimes(1); + expect(mallRepository.getStoresByObjectIds).toHaveBeenCalledTimes(1); + expect(objectInstanceRepository.countByObjectIds).toHaveBeenCalledTimes(1); + expect(memberRepository.findById).not.toHaveBeenCalled(); + expect(mallRepository.getStore).not.toHaveBeenCalled(); + expect(objectInstanceRepository.countByObjectId).not.toHaveBeenCalled(); + }); + + it('does not query at all for an empty result set', async () => { + objectRepository.findMallSoldOut.mockResolvedValue([] as never); + + const result = await service.findSoldOut(); + + expect(result.objects).toEqual([]); + expect(memberRepository.findByIds).not.toHaveBeenCalled(); + }); + + it('excludes null creator ids from the member lookup', async () => { + await service.findSoldOut(); + + expect(memberRepository.findByIds).toHaveBeenCalledWith([100, 101, 100]); + }); + }); + + describe('searchMallObjects', () => { + it('now carries the store, which the search page could not show before', + async () => { + objectRepository.searchMallObjects.mockResolvedValue( + [{ ...OBJECTS[0] }] as never, + ); + objectRepository.getTotal.mockResolvedValue([{ count: 1 }] as never); + + const result = await service.searchMallObjects('lamp', 10, 0); + + expect(result.objects[0].store).toEqual(STORES[10]); + expect(result.objects[0].instances).toBe(25); + expect(result.objects[0].username).toBe('BassMekanik'); + }); + }); + + describe('getAllObjects', () => { + it('decorates a page the same way, in one round of queries', async () => { + objectRepository.findAllObjects.mockResolvedValue( + OBJECTS.map(object => ({ ...object })) as never, + ); + objectRepository.total.mockResolvedValue([{ count: 4 }] as never); + + const result = await service.getAllObjects('status', '=', '1', 10, 0, 'ASC'); + const expected = await legacyDecorate(OBJECTS, MEMBERS, STORES, COUNTS); + + expect(result.objects).toEqual(expected); + expect(objectInstanceRepository.countByObjectIds).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/api/src/services/mall/mall.service.ts b/api/src/services/mall/mall.service.ts index 788e228c..7a31e4d8 100644 --- a/api/src/services/mall/mall.service.ts +++ b/api/src/services/mall/mall.service.ts @@ -10,7 +10,8 @@ import { MemberRepository, } from '../../repositories'; import { MallObjectPosition, MallObjectRotation } from 'models'; -import {orderBy} from 'lodash'; +import { ObjectWithUsername } from '../../repositories/object/object.repository'; +import { CountRow } from '../../repositories/row.types'; /** Service for dealing with the mall */ @Service() @@ -65,22 +66,53 @@ export class MallService { return await this.placeRepository.findAllStores(orderBy); } + /** + * Attaches the creator name, the store and the sold count to a page of + * objects, using one query per fact for the whole page rather than one per + * object. + * + * The previous per-object loop issued three queries for every row, which the + * Out of Stock view multiplied by every stocked object in the mall. Output is + * unchanged, including the 'Deleted User' placeholder for objects whose + * creator no longer exists. + */ + private async decorateObjects( + objects: ObjectWithUsername[], + ): Promise { + if (!objects.length) { + return objects; + } + + const objectIds = objects.map(object => object.id); + const memberIds = objects + .map(object => object.member_id) + .filter(memberId => !!memberId); + + const [members, stores, counts] = await Promise.all([ + this.memberRepository.findByIds(memberIds), + this.mallRepository.getStoresByObjectIds(objectIds), + this.objectInstanceRepository.countByObjectIds(objectIds), + ]); + + objects.forEach(object => { + const member = object.member_id ? members[object.member_id] : null; + object.username = (member && member.username) || 'Deleted User'; + object.store = stores[object.id]; + object.instances = counts[object.id] || 0; + }); + + return objects; + } + public async findSoldOut(){ - const returnObjects= []; const objects = await this.objectRepository.findMallSoldOut(); - for (const obj of objects) { - const user = obj.member_id ? await this.memberRepository.findById(obj.member_id) : null; - const store = await this.mallRepository.getStore(obj.id); - const instances = await this.objectInstanceRepository.countByObjectId(obj.id); - obj.username = user?.username || 'Deleted User'; - obj.store = store[0]; - obj.instances = instances; - returnObjects.push(obj); - } - return {objects: returnObjects}; + return {objects: await this.decorateObjects(objects)}; } - public async getObjectsCatalog(limit: number, offset: number): Promise { + public async getObjectsCatalog( + limit: number, + offset: number, + ): Promise<{ objects: ObjectWithUsername[]; total: CountRow[] }> { const returnObjects = []; const fleamarket = await this.placeRepository.findBySlug('fleamarket'); const blackmarket = await this.placeRepository.findBySlug('blackmarket'); @@ -88,7 +120,7 @@ export class MallService { for (const obj of objects) { obj.forSale = await this.objectInstanceRepository.countForSaleById(obj.id); obj.publicPlaces = await this.objectInstanceRepository - .countByPublicPlaces(obj.id, fleamarket.id, blackmarket.id) + .countByPublicPlaces(obj.id, fleamarket.id, blackmarket.id); obj.instances = await this.objectInstanceRepository.countByObjectId(obj.id); returnObjects.push(obj); } @@ -99,19 +131,15 @@ export class MallService { }; } - public async searchMallObjects(search: string, limit: number, offset: number): Promise { - const returnObjects = []; + public async searchMallObjects( + search: string, + limit: number, + offset: number, + ): Promise<{ objects: ObjectWithUsername[]; total: CountRow[] }> { const objects = await this.objectRepository.searchMallObjects(search, limit, offset); - for (const obj of objects) { - const user = obj.member_id ? await this.memberRepository.findById(obj.member_id) : null; - const instances = await this.objectInstanceRepository.countByObjectId(obj.id); - obj.username = user?.username || 'Deleted User'; - obj.instances = instances; - returnObjects.push(obj); - } const total = await this.objectRepository.getTotal(search); return { - objects: returnObjects, + objects: await this.decorateObjects(objects), total: total, }; } @@ -121,20 +149,12 @@ export class MallService { compare: string, status: number, limit: number, - offset: number): Promise { - const returnObjects = []; + offset: number): Promise<{ objects: ObjectWithUsername[]; total: CountRow[] }> { const objects = await this.objectRepository.searchAllObjects( search, compare, status, limit, offset); - for (const obj of objects) { - const user = obj.member_id ? await this.memberRepository.findById(obj.member_id) : null; - const instances = await this.objectInstanceRepository.countByObjectId(obj.id); - obj.username = user?.username || 'Deleted User'; - obj.instances = instances; - returnObjects.push(obj); - } const total = await this.objectRepository.getSearchTotal(search, compare, status); return { - objects: returnObjects, + objects: await this.decorateObjects(objects), total: total, }; } @@ -146,22 +166,11 @@ export class MallService { limit: number, offset: number, orderBy: string){ - const returnObjects= []; const objects = await this.objectRepository .findAllObjects(column, compare, content, limit, offset, orderBy); - for (const obj of objects) { - const user = obj.member_id ? await this.memberRepository.findById(obj.member_id) : null; - const store = await this.mallRepository.getStore(obj.id); - const instances = await this.objectInstanceRepository.countByObjectId(obj.id); - obj.username = user?.username || 'Deleted User'; - obj.store = store[0]; - obj.instances = instances; - returnObjects.push(obj); - } - const total = await this.objectRepository.total(column, compare, content); return { - objects: returnObjects, + objects: await this.decorateObjects(objects), total: total, }; } diff --git a/api/src/services/member/member.service.ts b/api/src/services/member/member.service.ts index 8f033d45..070de5d5 100644 --- a/api/src/services/member/member.service.ts +++ b/api/src/services/member/member.service.ts @@ -17,7 +17,34 @@ import { ObjectInstanceRepository, VoteRepository, } from '../../repositories'; -import { Member } from '../../types/models'; +import { Member, Place } from '../../types/models'; +import { + ActivePlaceRow, + OnlineUserRow, +} from '../../repositories/member/member.repository'; +import { BackpackRow } from '../../repositories/object-instance/object-instance.repository'; +import { + RoleNameAndId, + RoleNameRow, +} from '../../repositories/role-assignment/role-assignment.repository'; + +/** The active ban row `BanRepository.getBanMaxDate` returns, if there is one. */ +interface BanRow { + end_date: Date; + reason: string; + type: number; +} + +/** Whether a member is banned, and the ban that says so. */ +interface BanStatus { + banned: boolean; + banInfo: BanRow | undefined; +} + +/** A storage place with the number of objects it holds. */ +interface StorageUnit extends Place { + count?: number; +} import { MemberInfoView, MemberAdminView } from '../../types/views'; import { SessionInfo } from 'session-info.interface'; import { Request, Response } from 'express'; @@ -101,7 +128,7 @@ export class MemberService { }); } - public async getAccessLevel(memberId: number): Promise { + public async getAccessLevel(memberId: number): Promise { const security = await this.canAdmin(memberId); const roleAssignments = await this.roleAssignmentRepository.getByMemberId(memberId); const leader = await this.canLeader(memberId); @@ -189,7 +216,7 @@ export class MemberService { return this.memberRepository.findByPasswordResetToken(resetToken); } - public async getDonorLevel(memberId: number): Promise { + public async getDonorLevel(memberId: number): Promise { const donorId = { supporter: await this.roleRepository.roleMap.Supporter, advocate: await this.roleRepository.roleMap.Advocate, @@ -301,7 +328,7 @@ export class MemberService { return this.memberRepository.getPrimaryRoleName(memberId); } - public async getRoles(memberId: number): Promise { + public async getRoles(memberId: number): Promise { const roles = await this.roleAssignmentRepository.getRoleNameAndIdByMemberId(memberId); return roles; } @@ -332,7 +359,7 @@ export class MemberService { * @param memberId * @return banned boolean true if banned */ - public async isBanned(memberId: number): Promise { + public async isBanned(memberId: number): Promise { let banned = false; const member = await this.memberRepository.findById(memberId); const banInfo = await this.banRepository.getBanMaxDate(memberId); @@ -497,12 +524,16 @@ export class MemberService { await this.transactionRepository.createHomeRefundTransaction(member.wallet_id, amount); } - public async getMemberId(username: string): Promise { + /** + * Rows, not an id, despite the name. Every caller already reads `[0].id`; the + * previous `Promise` annotation was never what this returned. + */ + public async getMemberId(username: string): Promise[]> { const userId = await this.memberRepository.findIdByUsername(username); return userId; } - public async check3d(username: string): Promise { + public async check3d(username: string): Promise[]> { const user = await this.memberRepository.check3d(username); return user; } @@ -514,7 +545,7 @@ export class MemberService { }); } - public async getActivePlaces(): Promise { + public async getActivePlaces(): Promise { const returnPlaces = []; const placeIds = []; const activeTime = new Date(Date.now() - 5 * 60000); @@ -575,13 +606,13 @@ export class MemberService { } } - public async getOnlineUsers(): Promise { + public async getOnlineUsers(): Promise { const activeTime = new Date(Date.now() - 5 * 60000); const users = await this.memberRepository.findOnlineUsers(activeTime); return users; } - public async getBackpack(username: string): Promise { + public async getBackpack(username: string): Promise { let memberId = null; let userId = null; try { @@ -595,7 +626,7 @@ export class MemberService { } } - public async getStorage(memberId: number): Promise { + public async getStorage(memberId: number): Promise { const units = []; const unit = await this.placeRepository.findStorageByUserID(memberId); for (const storage of unit) { @@ -606,17 +637,17 @@ export class MemberService { return units; } - public async getStorageById(placeId: number): Promise { + public async getStorageById(placeId: number): Promise { const unit = await this.placeRepository.findById(placeId); return unit; } - public async getMemberByWalletId(walletId: number): Promise { + public async getMemberByWalletId(walletId: number): Promise[]> { const user = await this.memberRepository.findByWalletId(walletId); return user; } - public async removeAccount(id: number): Promise { + public async removeAccount(id: number): Promise { const user = await this.memberRepository.findById(id); await this.roleAssignmentRepository.removeAllByUserId(id); await this.banRepository.removeAllByUserId(id); diff --git a/api/src/services/object-source/object-source.service.spec.ts b/api/src/services/object-source/object-source.service.spec.ts new file mode 100644 index 00000000..2911b4d5 --- /dev/null +++ b/api/src/services/object-source/object-source.service.spec.ts @@ -0,0 +1,451 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import zlib from 'zlib'; + +import { + MAX_DECODED_BYTES, + MAX_STORED_BYTES, + ObjectSourceService, +} from './object-source.service'; + +const VRML = '#VRML V2.0 utf8\nWorldInfo { title "Fixture" }\n'; + +describe('ObjectSourceService', () => { + let assetsDir: string; + let objectRoot: string; + let originalAssetsDir: string | undefined; + let service: ObjectSourceService; + + function writeObject(directory: string, filename: string, contents: Buffer | string): void { + const target = path.join(objectRoot, directory); + fs.mkdirSync(target, { recursive: true }); + fs.writeFileSync(path.join(target, filename), contents); + } + + beforeEach(() => { + originalAssetsDir = process.env.ASSETS_DIR; + assetsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ctr-object-source-')); + objectRoot = path.join(assetsDir, 'object'); + fs.mkdirSync(objectRoot, { recursive: true }); + process.env.ASSETS_DIR = assetsDir; + service = new ObjectSourceService(); + }); + + afterEach(() => { + process.env.ASSETS_DIR = originalAssetsDir; + fs.rmSync(assetsDir, { recursive: true, force: true }); + }); + + describe('readSource - plain VRML', () => { + it('reads an uncompressed .wrl and reports identity encoding', async () => { + writeObject('uuid-a', 'a.wrl', VRML); + + const result = await service.readSource({ directory: 'uuid-a', filename: 'a.wrl' }); + + expect(result.error).toBeNull(); + expect(result.encoding).toBe('identity'); + expect(result.text).toBe(VRML); + expect(result.storedBytes).toBe(Buffer.byteLength(VRML)); + expect(result.decodedBytes).toBe(Buffer.byteLength(VRML)); + expect(result.replacementCharacters).toBe(0); + expect(result.utf8Valid).toBe(true); + expect(result.sha256).toMatch(/^[0-9a-f]{64}$/); + }); + }); + + describe('readSource - gzip stored under a .wrl name', () => { + it('decompresses transparently and reports both sizes separately', async () => { + const compressed = zlib.gzipSync(Buffer.from(VRML)); + writeObject('uuid-b', 'b.wrl', compressed); + + const result = await service.readSource({ directory: 'uuid-b', filename: 'b.wrl' }); + + expect(result.error).toBeNull(); + expect(result.encoding).toBe('gzip'); + expect(result.text).toBe(VRML); + expect(result.storedBytes).toBe(compressed.length); + expect(result.decodedBytes).toBe(Buffer.byteLength(VRML)); + expect(result.storedBytes).not.toBe(result.decodedBytes); + }); + + it('hashes the stored bytes, not the decompressed ones', async () => { + const compressed = zlib.gzipSync(Buffer.from(VRML)); + writeObject('uuid-c', 'c.wrl', compressed); + writeObject('uuid-d', 'd.wrl', VRML); + + const gzipResult = await service.readSource({ directory: 'uuid-c', filename: 'c.wrl' }); + const plainResult = await service.readSource({ directory: 'uuid-d', filename: 'd.wrl' }); + + expect(gzipResult.text).toBe(plainResult.text); + expect(gzipResult.sha256).not.toBe(plainResult.sha256); + }); + }); + + describe('readSource - failure modes are values, never throws', () => { + it('reports a missing file', async () => { + const result = await service.readSource({ directory: 'nope', filename: 'nope.wrl' }); + + expect(result.error).toBe('missing'); + expect(result.text).toBeNull(); + }); + + it('reports a corrupt gzip member', async () => { + const compressed = zlib.gzipSync(Buffer.from(VRML)); + writeObject('uuid-e', 'e.wrl', compressed.slice(0, compressed.length - 12)); + + const result = await service.readSource({ directory: 'uuid-e', filename: 'e.wrl' }); + + expect(result.error).toBe('gzip_corrupt'); + expect(result.encoding).toBe('gzip'); + expect(result.storedBytes).toBeGreaterThan(0); + expect(result.text).toBeNull(); + }); + + it('reports gzip magic followed by garbage as corrupt', async () => { + writeObject('uuid-f', 'f.wrl', Buffer.from([0x1f, 0x8b, 0x08, 0x00, 0x01, 0x02, 0x03])); + + expect((await service.readSource({ directory: 'uuid-f', filename: 'f.wrl' })).error) + .toBe('gzip_corrupt'); + }); + + it('refuses a small gzip that inflates past the ceiling', async () => { + const bomb = zlib.gzipSync(Buffer.alloc(MAX_DECODED_BYTES + 1024, 0x41)); + expect(bomb.length).toBeLessThan(MAX_STORED_BYTES); + writeObject('uuid-g', 'g.wrl', bomb); + + const result = await service.readSource({ directory: 'uuid-g', filename: 'g.wrl' }); + + expect(result.error).toBe('gzip_too_large'); + expect(result.text).toBeNull(); + }); + + it('refuses an oversized stored file without reading it', async () => { + writeObject('uuid-h', 'h.wrl', Buffer.alloc(MAX_STORED_BYTES + 1, 0x41)); + + const result = await service.readSource({ directory: 'uuid-h', filename: 'h.wrl' }); + + expect(result.error).toBe('too_large'); + expect(result.storedBytes).toBe(MAX_STORED_BYTES + 1); + expect(result.text).toBeNull(); + }); + + it('reports a directory where a file was expected', async () => { + fs.mkdirSync(path.join(objectRoot, 'uuid-i', 'i.wrl'), { recursive: true }); + + expect((await service.readSource({ directory: 'uuid-i', filename: 'i.wrl' })).error) + .toBe('missing'); + }); + + it('reports a missing ASSETS_DIR rather than resolving against the process cwd', + async () => { + delete process.env.ASSETS_DIR; + + expect((await service.readSource({ directory: 'uuid-a', filename: 'a.wrl' })).error) + .toBe('not_configured'); + }); + + it('counts replacement characters for invalid UTF-8 but still returns the text', + async () => { + writeObject('uuid-j', 'j.wrl', Buffer.concat([ + Buffer.from('#VRML V2.0 utf8\n'), + Buffer.from([0xff, 0xfe, 0xfd]), + ])); + + const result = await service.readSource({ directory: 'uuid-j', filename: 'j.wrl' }); + + expect(result.error).toBeNull(); + expect(result.replacementCharacters).toBeGreaterThan(0); + expect(result.text).toContain('#VRML V2.0 utf8'); + }); + + it('reports genuinely malformed bytes as invalid UTF-8', async () => { + // 0xff, 0xfe and 0xfd are not valid UTF-8 lead bytes at all -- there is + // no legitimate character these bytes could be encoding. + writeObject('uuid-j', 'j.wrl', Buffer.concat([ + Buffer.from('#VRML V2.0 utf8\n'), + Buffer.from([0xff, 0xfe, 0xfd]), + ])); + + const result = await service.readSource({ directory: 'uuid-j', filename: 'j.wrl' }); + + expect(result.utf8Valid).toBe(false); + }); + + it('does not mistake a literal U+FFFD for invalid UTF-8', async () => { + // The replacement character itself has a valid UTF-8 encoding (EF BF + // BD), so a creator including it on purpose must not be flagged as if + // the file were malformed. + const withReplacementCharacter = `${VRML}# � literal replacement character\n`; + writeObject('uuid-k', 'k.wrl', withReplacementCharacter); + + const result = await service.readSource({ directory: 'uuid-k', filename: 'k.wrl' }); + + expect(result.error).toBeNull(); + expect(result.replacementCharacters).toBeGreaterThan(0); + expect(result.utf8Valid).toBe(true); + expect(result.text).toBe(withReplacementCharacter); + }); + + it('reports malformed UTF-8 the same way through a gzip-decoded source', async () => { + const malformed = Buffer.concat([ + Buffer.from('#VRML V2.0 utf8\n'), + Buffer.from([0xff, 0xfe, 0xfd]), + ]); + writeObject('uuid-l', 'l.wrl', zlib.gzipSync(malformed)); + + const result = await service.readSource({ directory: 'uuid-l', filename: 'l.wrl' }); + + expect(result.error).toBeNull(); + expect(result.encoding).toBe('gzip'); + expect(result.utf8Valid).toBe(false); + }); + + it('confirms a literal U+FFFD stays valid through a gzip-decoded source', async () => { + const withReplacementCharacter = Buffer.from(`${VRML}# � literal\n`, 'utf8'); + writeObject('uuid-m', 'm.wrl', zlib.gzipSync(withReplacementCharacter)); + + const result = await service.readSource({ directory: 'uuid-m', filename: 'm.wrl' }); + + expect(result.error).toBeNull(); + expect(result.encoding).toBe('gzip'); + expect(result.utf8Valid).toBe(true); + }); + }); + + describe('resolveAssetPath - containment', () => { + it('resolves an ordinary object path inside the asset root', () => { + const resolved = service.resolveAssetPath({ directory: 'uuid-a', filename: 'a.wrl' }); + + expect(resolved).toEqual({ path: path.join(objectRoot, 'uuid-a', 'a.wrl'), error: null }); + }); + + it('never returns a path alongside an error, so callers cannot confuse the two', () => { + const refused = service.resolveAssetPath({ directory: '../../etc', filename: 'passwd' }); + + expect(refused.path).toBeNull(); + expect(refused.error).toBe('outside_assets_root'); + }); + + it('refuses a directory that climbs out with ..', () => { + expect(service.resolveAssetPath({ directory: '../../etc', filename: 'passwd' }).error) + .toBe('outside_assets_root'); + expect(service.resolveAssetPath({ directory: '..', filename: 'x.wrl' }).error) + .toBe('outside_assets_root'); + }); + + it('refuses a filename that climbs out with ..', () => { + expect( + service.resolveAssetPath({ directory: 'uuid-a', filename: '../../../etc/passwd' }).error, + ).toBe('outside_assets_root'); + }); + + it('refuses an absolute filename that escapes the root entirely', () => { + expect(service.resolveAssetPath({ directory: 'uuid-a', filename: '/etc/passwd' }).error) + .toBe('outside_assets_root'); + }); + + it('refuses a SIBLING directory whose name merely starts with the root name', () => { + // `/object-evil/x.wrl` begins with `/object`, so a naive + // startsWith containment check would wrongly admit it. + const escape = service.resolveAssetPath({ + directory: `..${path.sep}object-evil`, + filename: 'x.wrl', + }); + + expect(escape.error).toBe('outside_assets_root'); + }); + + it('refuses the asset root itself', () => { + expect(service.resolveAssetPath({ directory: '.', filename: '.' }).error) + .toBe('outside_assets_root'); + }); + + it('refuses an empty directory or filename', () => { + expect(service.resolveAssetPath({ directory: '', filename: 'a.wrl' }).error).toBe('missing'); + expect(service.resolveAssetPath({ directory: 'uuid-a', filename: '' }).error) + .toBe('missing'); + }); + + it('does not read anything outside the root even via readSource', async () => { + const outside = path.join(assetsDir, 'object-evil'); + fs.mkdirSync(outside, { recursive: true }); + fs.writeFileSync(path.join(outside, 'x.wrl'), 'SECRET'); + + const result = await service.readSource({ + directory: `..${path.sep}object-evil`, + filename: 'x.wrl', + }); + + expect(result.error).toBe('outside_assets_root'); + expect(result.text).toBeNull(); + }); + }); + + describe('readAssetMetadata', () => { + it('returns size and hash for a thumbnail', async () => { + writeObject('uuid-k', 'k.jpg', Buffer.alloc(64, 0x7f)); + + const result = await service.readAssetMetadata({ + directory: 'uuid-k', + filename: 'k.jpg', + }); + + expect(result.error).toBeNull(); + expect(result.bytes).toBe(64); + expect(result.sha256).toMatch(/^[0-9a-f]{64}$/); + }); + + it('reports a missing asset without throwing', async () => { + const result = await service.readAssetMetadata({ + directory: 'uuid-k', + filename: 'absent.jpg', + }); + + expect(result.error).toBe('missing'); + expect(result.bytes).toBeNull(); + }); + + it('applies the same containment rule', async () => { + const result = await service.readAssetMetadata({ + directory: '../../etc', + filename: 'passwd', + }); + + expect(result.error).toBe('outside_assets_root'); + }); + }); + + describe('containment - the filesystem, not just the string', () => { + /** A file deliberately outside the assets root, the thing an escape reaches. */ + function writeSecret(contents = 'TOP SECRET\n'): string { + const secret = path.join(assetsDir, 'secret.wrl'); + fs.writeFileSync(secret, contents); + return secret; + } + + it('still rejects lexical traversal out of the root', async () => { + writeSecret(); + + const result = await service.readSource({ directory: '..', filename: 'secret.wrl' }); + + expect(result.error).toBe('outside_assets_root'); + expect(result.text).toBeNull(); + }); + + it('still rejects a sibling directory that merely shares the root prefix', async () => { + const sibling = `${objectRoot}-evil`; + fs.mkdirSync(sibling, { recursive: true }); + fs.writeFileSync(path.join(sibling, 'x.wrl'), VRML); + + const result = await service.readSource({ + directory: `../${path.basename(objectRoot)}-evil`, + filename: 'x.wrl', + }); + + expect(result.error).toBe('outside_assets_root'); + }); + + it('rejects a file symlink pointing outside the root', async () => { + const secret = writeSecret(); + fs.mkdirSync(path.join(objectRoot, 'uuid-link'), { recursive: true }); + fs.symlinkSync(secret, path.join(objectRoot, 'uuid-link', 'a.wrl')); + + const result = await service.readSource({ directory: 'uuid-link', filename: 'a.wrl' }); + + expect(result.error).toBe('outside_assets_root'); + expect(result.text).toBeNull(); + }); + + it('rejects a directory symlink pointing outside the root', async () => { + const outside = path.join(assetsDir, 'elsewhere'); + fs.mkdirSync(outside, { recursive: true }); + fs.writeFileSync(path.join(outside, 'a.wrl'), 'TOP SECRET\n'); + fs.symlinkSync(outside, path.join(objectRoot, 'uuid-dir')); + + const result = await service.readSource({ directory: 'uuid-dir', filename: 'a.wrl' }); + + expect(result.error).toBe('outside_assets_root'); + }); + + it('allows a symlink that stays inside the root', async () => { + writeObject('uuid-real', 'a.wrl', VRML); + fs.mkdirSync(path.join(objectRoot, 'uuid-alias'), { recursive: true }); + fs.symlinkSync( + path.join(objectRoot, 'uuid-real', 'a.wrl'), + path.join(objectRoot, 'uuid-alias', 'a.wrl'), + ); + + const result = await service.readSource({ directory: 'uuid-alias', filename: 'a.wrl' }); + + expect(result.error).toBeNull(); + expect(result.text).toBe(VRML); + }); + + it('keeps working when ASSETS_DIR is itself a symlink', async () => { + // The deployment mounts assets through a link; the configured path and the + // real path differ for every file, which must not read as an escape. + const realAssets = fs.mkdtempSync(path.join(os.tmpdir(), 'ctr-real-assets-')); + fs.mkdirSync(path.join(realAssets, 'object', 'uuid-a'), { recursive: true }); + fs.writeFileSync(path.join(realAssets, 'object', 'uuid-a', 'a.wrl'), VRML); + + const linked = path.join(os.tmpdir(), `ctr-linked-assets-${process.pid}`); + fs.rmSync(linked, { recursive: true, force: true }); + fs.symlinkSync(realAssets, linked); + process.env.ASSETS_DIR = linked; + + try { + const result = await new ObjectSourceService() + .readSource({ directory: 'uuid-a', filename: 'a.wrl' }); + + expect(result.error).toBeNull(); + expect(result.text).toBe(VRML); + } finally { + fs.rmSync(linked, { recursive: true, force: true }); + fs.rmSync(realAssets, { recursive: true, force: true }); + } + }); + + it('reports an absent file as missing rather than as an escape', async () => { + const result = await service.readSource({ directory: 'uuid-a', filename: 'nope.wrl' }); + + expect(result.error).toBe('missing'); + }); + + it('reports a dangling symlink as missing rather than as an escape', async () => { + fs.mkdirSync(path.join(objectRoot, 'uuid-dangle'), { recursive: true }); + fs.symlinkSync( + path.join(assetsDir, 'does-not-exist.wrl'), + path.join(objectRoot, 'uuid-dangle', 'a.wrl'), + ); + + const result = await service.readSource({ directory: 'uuid-dangle', filename: 'a.wrl' }); + + expect(result.error).toBe('missing'); + }); + + it('reads an ordinary file inside the root exactly as before', async () => { + writeObject('uuid-a', 'a.wrl', VRML); + + const result = await service.readSource({ directory: 'uuid-a', filename: 'a.wrl' }); + + expect(result.error).toBeNull(); + expect(result.text).toBe(VRML); + }); + + it('applies the same containment to asset metadata reads', async () => { + const secret = writeSecret(); + fs.mkdirSync(path.join(objectRoot, 'uuid-link'), { recursive: true }); + fs.symlinkSync(secret, path.join(objectRoot, 'uuid-link', 't.jpg')); + + const meta = await service.readAssetMetadata({ + directory: 'uuid-link', + filename: 't.jpg', + }); + + expect(meta.error).toBe('outside_assets_root'); + expect(meta.sha256).toBeNull(); + }); + }); + +}); diff --git a/api/src/services/object-source/object-source.service.ts b/api/src/services/object-source/object-source.service.ts new file mode 100644 index 00000000..b65b918f --- /dev/null +++ b/api/src/services/object-source/object-source.service.ts @@ -0,0 +1,385 @@ +import crypto from 'crypto'; +import { promises as fs } from 'fs'; +import path from 'path'; +import util, { TextDecoder } from 'util'; +import zlib from 'zlib'; +import { Service } from 'typedi'; + +/** + * Decompression off the event loop. + * + * The synchronous form blocks the whole API for the duration of every inflate, + * and the export walks the entire catalogue one object at a time -- thousands of + * consecutive stalls that no other request can interleave with. The async form + * enforces `maxOutputLength` exactly the same way (verified on the deployed + * node 14.21.3: a 64 MiB zero-fill bomb is refused with ERR_BUFFER_TOO_LARGE + * before allocation), so the gzip-bomb guard below is unchanged. + */ +const gunzip = util.promisify(zlib.gunzip); + +/** + * Reads the bytes CTR already stores for a Mall object, so staff can inspect an + * upload without downloading it and decompressing it by hand. + * + * Why this exists at all: `ObjectService.uploadObjectFiles` always writes the + * upload with a `.wrl` extension, but many creators export gzip-compressed VRML + * (every pending object on production currently is). The name says `.wrl`, the + * bytes say gzip, and nothing server-side has ever looked. This service is the + * one place that resolves that. + * + * Nothing here ever modifies, recompresses or rewrites an upload. The stored + * original is preserved exactly; decompression is a read-time projection only. + */ + +export type ObjectSourceError = + | 'not_configured' + | 'outside_assets_root' + | 'missing' + | 'too_large' + | 'gzip_corrupt' + | 'gzip_too_large' + | 'unreadable'; + +export type ObjectSourceEncoding = 'identity' | 'gzip'; + +export interface ObjectAssetReference { + directory: string; + filename: string; +} + +export interface ObjectSourceResult { + /** How the bytes are stored on disk. Null when the source could not be read. */ + encoding: ObjectSourceEncoding | null; + /** Size on disk - the same number upload validation measured. */ + storedBytes: number | null; + /** Size of the actual VRML payload. Differs from storedBytes for gzip uploads. */ + decodedBytes: number | null; + /** Hex SHA-256 of the STORED bytes, so it identifies the file as uploaded. */ + sha256: string | null; + text: string | null; + /** + * Count of literal U+FFFD in the decoded text. Kept as neutral information + * only: U+FFFD has a valid UTF-8 encoding (`EF BF BD`) and a creator is + * allowed to include it on purpose, so this count is NOT proof the source + * bytes were malformed -- `utf8Valid` is. + */ + replacementCharacters: number; + /** + * Whether the STORED bytes are valid UTF-8, decided from the bytes + * themselves via a fatal decode, not from whether U+FFFD shows up in the + * result. `true` whenever no bytes were decoded at all (failure results), + * since nothing was found invalid. + */ + utf8Valid: boolean; + error: ObjectSourceError | null; +} + +export interface ObjectAssetMetadata { + bytes: number | null; + sha256: string | null; + error: ObjectSourceError | null; +} + +/** + * Deliberately a discriminated result rather than `string | ObjectSourceError`. + * The error type is itself a union of string literals, so a `typeof === 'string'` + * guard on a combined return type is always true and silently turns an error + * sentinel into a filesystem path. + */ +export interface ResolvedAssetPath { + path: string | null; + error: ObjectSourceError | null; +} + +/** + * Upload validation already caps a `.wrl` at 80 KB, so a stored file above 1 MiB + * means something is wrong rather than merely large. + */ +export const MAX_STORED_BYTES = 1024 * 1024; + +/** + * Decompression ceiling. The largest real object observed inflates about 5.4x + * (79,639 stored to 429,379 decoded); a deliberately crafted upload could reach + * three orders of magnitude, so this is the guard against that. + */ +export const MAX_DECODED_BYTES = 4 * 1024 * 1024; + +const GZIP_MAGIC_0 = 0x1f; +const GZIP_MAGIC_1 = 0x8b; + +const REPLACEMENT_CHARACTER = '�'; + +function failure(error: ObjectSourceError): ObjectSourceResult { + return { + encoding: null, + storedBytes: null, + decodedBytes: null, + sha256: null, + text: null, + replacementCharacters: 0, + utf8Valid: true, + error, + }; +} + +function countReplacementCharacters(text: string): number { + let count = 0; + let index = text.indexOf(REPLACEMENT_CHARACTER); + while (index !== -1) { + count += 1; + index = text.indexOf(REPLACEMENT_CHARACTER, index + 1); + } + return count; +} + +/** + * Decodes bytes as UTF-8 and reports whether they actually were UTF-8. + * + * `fatal: true` is what makes this trustworthy: a plain `buffer.toString('utf8')` + * silently substitutes U+FFFD for anything it cannot decode and never reports + * that it happened, which is indistinguishable from a file that legitimately + * contains that character. A fatal decode throws instead, so validity comes + * from the bytes, not from counting a character that has a valid encoding of + * its own. On failure the text is still produced (non-fatal) so a checker can + * see it and decide, but `valid` is what the finding must key off. + */ +function decodeUtf8(buffer: Buffer): { text: string; valid: boolean } { + try { + const text = new TextDecoder('utf-8', { fatal: true }).decode(buffer); + return { text, valid: true }; + } catch (error) { + return { text: buffer.toString('utf8'), valid: false }; + } +} + +@Service() +export class ObjectSourceService { + + private getAssetsRoot(): string { + return process.env.ASSETS_DIR || ''; + } + + /** + * Resolves an object asset path and proves it stays inside the object asset + * root. + * + * Containment is checked with `path.relative`, deliberately NOT with a + * `startsWith` prefix comparison. A prefix check is wrong twice over: it admits + * a sibling directory whose name merely begins with the root's name + * (`.../object-evil/x` starts with `.../object`), and it is not separator-aware. + * + * Callers only ever pass values taken from the database, never from a request, + * but the check is unconditional so that stays true no matter who calls it. + */ + public resolveAssetPath(reference: ObjectAssetReference): ResolvedAssetPath { + const assetsRoot = this.getAssetsRoot(); + if (!assetsRoot) { + return { path: null, error: 'not_configured' }; + } + if (!reference.directory || !reference.filename) { + return { path: null, error: 'missing' }; + } + + const root = path.resolve(assetsRoot, 'object'); + const target = path.resolve(root, reference.directory, reference.filename); + const relative = path.relative(root, target); + + if ( + relative === '' + || relative === '..' + || relative.indexOf(`..${path.sep}`) === 0 + || path.isAbsolute(relative) + ) { + return { path: null, error: 'outside_assets_root' }; + } + + return { path: target, error: null }; + } + + /** + * Containment as the filesystem actually sees it. + * + * `resolveAssetPath` only compares strings, so it stops `../` in a database + * value but not a symlink sitting inside the root that points somewhere else + * entirely. Both paths are canonicalised and compared, which also means a + * legitimately symlinked ASSETS_DIR keeps working: the root is resolved the + * same way the candidate is, so the two agree. + * + * A target that does not exist is reported as missing, not as an escape -- + * absence is not an attack. + */ + public async resolveRealAssetPath( + reference: ObjectAssetReference, + ): Promise { + const lexical = this.resolveAssetPath(reference); + if (lexical.error !== null || lexical.path === null) { + return lexical; + } + + const realRoot = await this.canonicalise(path.resolve(this.getAssetsRoot(), 'object')); + if (realRoot.error !== null || realRoot.path === null) { + return { path: null, error: realRoot.error }; + } + + const realTarget = await this.canonicalise(lexical.path); + if (realTarget.error !== null || realTarget.path === null) { + return { path: null, error: realTarget.error }; + } + + const relative = path.relative(realRoot.path, realTarget.path); + if ( + relative === '' + || relative === '..' + || relative.indexOf(`..${path.sep}`) === 0 + || path.isAbsolute(relative) + ) { + return { path: null, error: 'outside_assets_root' }; + } + + return { path: realTarget.path, error: null }; + } + + private async canonicalise(target: string): Promise { + try { + return { path: await fs.realpath(target), error: null }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + return { + path: null, + error: code === 'ENOENT' || code === 'ENOTDIR' ? 'missing' : 'unreadable', + }; + } + } + + /** Size and hash of any object asset (thumbnail, texture) without decoding it. */ + public async readAssetMetadata(reference: ObjectAssetReference): Promise { + const resolved = await this.resolveRealAssetPath(reference); + if (resolved.error !== null) { + return { bytes: null, sha256: null, error: resolved.error }; + } + + let stats; + try { + stats = await fs.stat(resolved.path); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + return { + bytes: null, + sha256: null, + error: code === 'ENOENT' || code === 'ENOTDIR' ? 'missing' : 'unreadable', + }; + } + + if (!stats.isFile()) { + return { bytes: null, sha256: null, error: 'missing' }; + } + if (stats.size > MAX_STORED_BYTES) { + return { bytes: stats.size, sha256: null, error: 'too_large' }; + } + + try { + const buffer = await fs.readFile(resolved.path); + return { + bytes: buffer.length, + sha256: crypto.createHash('sha256').update(buffer).digest('hex'), + error: null, + }; + } catch (error) { + return { bytes: stats.size, sha256: null, error: 'unreadable' }; + } + } + + /** + * Reads a stored WRL, transparently decompressing it when the bytes are gzip, + * and reports both sizes separately so the difference stays visible. + * + * Every failure mode is returned as a value. Nothing throws, because a single + * unreadable upload must never take down a checker page or an export. + */ + public async readSource(reference: ObjectAssetReference): Promise { + const resolved = await this.resolveRealAssetPath(reference); + if (resolved.error !== null) { + return failure(resolved.error); + } + + let stats; + try { + stats = await fs.stat(resolved.path); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + return failure(code === 'ENOENT' || code === 'ENOTDIR' ? 'missing' : 'unreadable'); + } + + if (!stats.isFile()) { + return failure('missing'); + } + if (stats.size > MAX_STORED_BYTES) { + const result = failure('too_large'); + result.storedBytes = stats.size; + return result; + } + + let stored: Buffer; + try { + stored = await fs.readFile(resolved.path); + } catch (error) { + return failure('unreadable'); + } + + const sha256 = crypto.createHash('sha256').update(stored).digest('hex'); + const isGzip = stored.length >= 2 + && stored[0] === GZIP_MAGIC_0 + && stored[1] === GZIP_MAGIC_1; + + let decoded: Buffer; + if (isGzip) { + try { + decoded = await gunzip(stored, { maxOutputLength: MAX_DECODED_BYTES }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + const tooLarge = code === 'ERR_BUFFER_TOO_LARGE' + || /maxOutputLength|Cannot create a string longer/i.test(String(error)); + return { + encoding: 'gzip', + storedBytes: stored.length, + decodedBytes: null, + sha256, + text: null, + replacementCharacters: 0, + utf8Valid: true, + error: tooLarge ? 'gzip_too_large' : 'gzip_corrupt', + }; + } + + // Belt and braces: if a runtime ever ignores maxOutputLength, refuse the + // oversized result rather than passing it on to the scanner. + if (decoded.length > MAX_DECODED_BYTES) { + return { + encoding: 'gzip', + storedBytes: stored.length, + decodedBytes: decoded.length, + sha256, + text: null, + replacementCharacters: 0, + utf8Valid: true, + error: 'gzip_too_large', + }; + } + } else { + decoded = stored; + } + + const { text, valid } = decodeUtf8(decoded); + + return { + encoding: isGzip ? 'gzip' : 'identity', + storedBytes: stored.length, + decodedBytes: decoded.length, + sha256, + text, + replacementCharacters: countReplacementCharacters(text), + utf8Valid: valid, + error: null, + }; + } +} diff --git a/api/src/services/object/object.service.atomic.spec.ts b/api/src/services/object/object.service.atomic.spec.ts new file mode 100644 index 00000000..da07d153 --- /dev/null +++ b/api/src/services/object/object.service.atomic.spec.ts @@ -0,0 +1,351 @@ +import dotenv from 'dotenv'; +import { Knex } from 'knex'; + +// `knexfile` reads `../.env`, which resolves correctly for the running API but +// not for jest, whose cwd is `api/`. Loaded here so these tests talk to the same +// database the API does. +dotenv.config(); + +import { Db } from '../../db/db.class'; +import { + MallRepository, + MemberRepository, + ObjectInstanceRepository, + ObjectRepository, + TransactionRepository, +} from '../../repositories'; +import { ObjectService } from './object.service'; +import { Transaction } from '../../types/models'; + +/** + * The rejection's transactional guarantees, against a real MySQL. + * + * These cannot be proven with mocks. Whether a wallet credit is rolled back when + * a later write fails, and whether two concurrent rejections produce one refund + * instead of two, are properties of the database and of `FOR UPDATE` - a mocked + * repository would happily "prove" them either way. + * + * Skipped, loudly, when no database is reachable, so the ordinary unit suite + * stays runnable without one. + */ +const FIXTURE = { + walletId: 990001, + memberId: 990001, + objectId: 990001, + /** A second object, same uploader and wallet, for the cross-object race. */ + secondObjectId: 990002, + quantity: 10, + price: 25, +}; + +/** 10 * 25 * 0.2 */ +const EXPECTED_REFUND = FIXTURE.quantity * FIXTURE.price * ObjectService.SELLER_FEE_PERCENT; + +describe('ObjectService rejection atomicity (real database)', () => { + let db: Db; + let service: ObjectService; + let objectRepository: ObjectRepository; + let transactionRepository: TransactionRepository; + /** + * Decided synchronously, at registration time, from whether the database is + * even configured. When it is, the tests run for real and a connection failure + * is an error rather than a shrug; when it is not, they register as skipped so + * the ordinary unit suite still runs without a database. What they never do is + * pass without having touched one. + */ + const configured = !!(process.env.DB_HOST && process.env.DB_DATABASE); + + beforeAll(async () => { + if (!configured) { + return; + } + db = new Db(); + await db.knex.raw('select 1'); + }); + + afterAll(async () => { + if (db) { + await db.knex.destroy(); + } + }); + + beforeEach(async () => { + if (!configured) { + return; + } + objectRepository = new ObjectRepository(db); + transactionRepository = new TransactionRepository(db); + service = new ObjectService( + db, + objectRepository, + new MemberRepository(db), + transactionRepository, + new ObjectInstanceRepository(db), + new MallRepository(db), + ); + + await cleanup(); + await db.knex('wallet').insert({ id: FIXTURE.walletId, balance: 0 }); + await db.knex('member').insert({ + id: FIXTURE.memberId, + email: 'atomicity-fixture@example.test', + password: 'x', + username: 'atomicityFixture', + wallet_id: FIXTURE.walletId, + }); + await db.knex('object').insert([{ + id: FIXTURE.objectId, + filename: 'fixture.wrl', + member_id: FIXTURE.memberId, + name: 'Atomicity Fixture', + quantity: FIXTURE.quantity, + price: FIXTURE.price, + status: ObjectService.STATUS_PENDING, + directory: 'atomicity-fixture', + }, { + id: FIXTURE.secondObjectId, + filename: 'fixture-2.wrl', + member_id: FIXTURE.memberId, + name: 'Atomicity Fixture 2', + quantity: FIXTURE.quantity, + price: FIXTURE.price, + status: ObjectService.STATUS_PENDING, + directory: 'atomicity-fixture-2', + }]); + }); + + afterEach(async () => { + if (configured) { + await cleanup(); + } + }); + + async function cleanup(): Promise { + await db.knex('mall_object') + .whereIn('object_id', [FIXTURE.objectId, FIXTURE.secondObjectId]).del(); + await db.knex('transaction').where({ recipient_wallet_id: FIXTURE.walletId }).del(); + await db.knex('object').whereIn('id', [FIXTURE.objectId, FIXTURE.secondObjectId]).del(); + await db.knex('member').where({ id: FIXTURE.memberId }).del(); + await db.knex('wallet').where({ id: FIXTURE.walletId }).del(); + } + + async function state(): Promise<{ status: number; balance: number; refunds: number }> { + const [object] = await db.knex('object').where({ id: FIXTURE.objectId }); + const [wallet] = await db.knex('wallet').where({ id: FIXTURE.walletId }); + const refunds = await db.knex('transaction') + .where({ recipient_wallet_id: FIXTURE.walletId, reason: 'object-upload-refund' }); + return { + status: object ? object.status : -1, + balance: wallet ? Number(wallet.balance) : -1, + refunds: refunds.length, + }; + } + + /** Registers as a real test, or as a visible skip - never as a silent pass. */ + const dbTest = configured ? it : it.skip; + + dbTest('refunds once and marks the object rejected', async () => { + const result = await service.rejectPendingObject(FIXTURE.objectId); + + expect(result.outcome).toBe(ObjectService.REJECT_REJECTED); + expect(await state()).toEqual({ + status: ObjectService.STATUS_DELETED, + balance: EXPECTED_REFUND, + refunds: 1, + }); + }); + + dbTest('rolls the wallet credit back when the ledger insert fails', async () => { + // Credits inside the caller's transaction and then fails, which is exactly + // the shape of a ledger insert that violates a constraint. + transactionRepository.createObjectUploadRefundTransaction = async ( + walletId: number, amount: number, trx?: Knex.Transaction, + ): Promise => { + const wallet = await trx('wallet').where({ id: walletId }).first(); + await trx('wallet').where({ id: walletId }).update({ balance: wallet.balance + amount }); + throw new Error('ledger insert failed'); + }; + + await expect(service.rejectPendingObject(FIXTURE.objectId)).rejects.toThrow(); + + expect(await state()).toEqual({ + status: ObjectService.STATUS_PENDING, + balance: 0, + refunds: 0, + }); + }); + + dbTest('rolls the refund back when the status update fails', async () => { + objectRepository.update = async (): Promise => { + throw new Error('status update failed'); + }; + + await expect(service.rejectPendingObject(FIXTURE.objectId)).rejects.toThrow(); + + // The money must not move for an object that is still pending: a retry would + // otherwise pay the uploader a second time. + expect(await state()).toEqual({ + status: ObjectService.STATUS_PENDING, + balance: 0, + refunds: 0, + }); + }); + + dbTest('refunds exactly once across a rolled-back attempt and a retry', async () => { + const realUpdate = objectRepository.update.bind(objectRepository); + objectRepository.update = async (): Promise => { + throw new Error('status update failed'); + }; + + await expect(service.rejectPendingObject(FIXTURE.objectId)).rejects.toThrow(); + + objectRepository.update = realUpdate; + const retry = await service.rejectPendingObject(FIXTURE.objectId); + + expect(retry.outcome).toBe(ObjectService.REJECT_REJECTED); + expect(await state()).toEqual({ + status: ObjectService.STATUS_DELETED, + balance: EXPECTED_REFUND, + refunds: 1, + }); + }); + + dbTest('refuses an object that is already rejected, without paying again', async () => { + await service.rejectPendingObject(FIXTURE.objectId); + const before = await state(); + + const second = await service.rejectPendingObject(FIXTURE.objectId); + + expect(second.outcome).toBe(ObjectService.REJECT_ALREADY_REJECTED); + expect(await state()).toEqual(before); + }); + + dbTest('refuses an object that is not a pending submission', async () => { + await db.knex('object') + .where({ id: FIXTURE.objectId }) + .update({ status: ObjectService.STATUS_APPROVED }); + + const result = await service.rejectPendingObject(FIXTURE.objectId); + + expect(result.outcome).toBe(ObjectService.REJECT_INVALID_STATE); + expect(await state()).toEqual({ + status: ObjectService.STATUS_APPROVED, + balance: 0, + refunds: 0, + }); + }); + + dbTest('refuses an object id that does not exist', async () => { + const result = await service.rejectPendingObject(FIXTURE.objectId + 12345); + + expect(result.outcome).toBe(ObjectService.REJECT_NOT_FOUND); + }); + + dbTest('credits both refunds when two objects of one uploader are rejected at once', + async () => { + // The object-row lock serialises rejections of the SAME object. Two + // DIFFERENT objects of the same uploader are not serialised by it, so the + // wallet credit itself has to be safe: a read-modify-write would let both + // transactions read the same balance and the second would overwrite the + // first, losing a refund that its ledger row says was paid. + const [first, second] = await Promise.all([ + service.rejectPendingObject(FIXTURE.objectId), + service.rejectPendingObject(FIXTURE.secondObjectId), + ]); + + expect(first.outcome).toBe(ObjectService.REJECT_REJECTED); + expect(second.outcome).toBe(ObjectService.REJECT_REJECTED); + + const [wallet] = await db.knex('wallet').where({ id: FIXTURE.walletId }); + const refunds = await db.knex('transaction') + .where({ recipient_wallet_id: FIXTURE.walletId, reason: 'object-upload-refund' }); + + expect(refunds.length).toBe(2); + // The ledger and the balance must agree. + expect(Number(wallet.balance)).toBe(EXPECTED_REFUND * 2); + }); + + dbTest('approves a pending object and places it in the Mall', async () => { + const result = await service.approvePendingObject(FIXTURE.objectId); + + expect(result.outcome).toBe(ObjectService.REJECT_REJECTED); + const [object] = await db.knex('object').where({ id: FIXTURE.objectId }); + expect(object.status).toBe(ObjectService.STATUS_APPROVED); + // `addToMallObjects` used to be fired without being awaited, so the status + // could commit before the object was placed at all. + const placed = await db.knex('mall_object').where({ object_id: FIXTURE.objectId }); + expect(placed.length).toBe(1); + }); + + dbTest('refuses to approve an object that is not a pending submission', async () => { + await db.knex('object') + .where({ id: FIXTURE.objectId }) + .update({ status: ObjectService.STATUS_DELETED }); + + const result = await service.approvePendingObject(FIXTURE.objectId); + + expect(result.outcome).toBe(ObjectService.REJECT_INVALID_STATE); + const [object] = await db.knex('object').where({ id: FIXTURE.objectId }); + expect(object.status).toBe(ObjectService.STATUS_DELETED); + expect(await db.knex('mall_object').where({ object_id: FIXTURE.objectId })).toEqual([]); + }); + + dbTest('places an object in the Mall exactly once when two approvals race', async () => { + const results = await Promise.all([ + service.approvePendingObject(FIXTURE.objectId), + service.approvePendingObject(FIXTURE.objectId), + ]); + + const placed = await db.knex('mall_object').where({ object_id: FIXTURE.objectId }); + expect(placed.length).toBe(1); + expect(results.filter(r => r.outcome === ObjectService.REJECT_REJECTED).length).toBe(1); + }); + + dbTest('reads the refund row back through the caller\'s own trx, not a pool connection', + async () => { + // `creditWallet` used to read the just-inserted row back through the + // repository's own pool connection (`this.find()`) rather than the trx + // that inserted it. Under REPEATABLE READ, a query on a different + // connection opens its own snapshot and cannot see a row a still-open + // transaction has not committed yet, so that lookup deterministically + // returned undefined even though the insert had already succeeded -- + // reproduced directly against this database before writing the fix. + // Calling the caller-supplied-trx path here, exactly as + // `rejectPendingObject` does, and asserting on what it resolves to + // *before* the transaction commits is what a mock cannot demonstrate: + // the row's visibility is a property of MySQL's isolation level, not of + // this repository's code shape. + let resolvedInsideTrx: Transaction; + await db.knex.transaction(async trx => { + resolvedInsideTrx = await transactionRepository.createObjectUploadRefundTransaction( + FIXTURE.walletId, EXPECTED_REFUND, trx, + ); + }); + + expect(resolvedInsideTrx).toBeDefined(); + expect(resolvedInsideTrx.reason).toBe('object-upload-refund'); + expect(Number(resolvedInsideTrx.amount)).toBe(EXPECTED_REFUND); + expect(resolvedInsideTrx.recipient_wallet_id).toBe(FIXTURE.walletId); + }); + + dbTest('pays exactly one refund when two rejections race', async () => { + // Both start against a pending object. Without the row lock both would read + // STATUS_PENDING and both would credit the wallet. + const [first, second] = await Promise.all([ + service.rejectPendingObject(FIXTURE.objectId), + service.rejectPendingObject(FIXTURE.objectId), + ]); + + const outcomes = [first.outcome, second.outcome].sort(); + expect(outcomes).toEqual([ + ObjectService.REJECT_ALREADY_REJECTED, + ObjectService.REJECT_REJECTED, + ].sort()); + + expect(await state()).toEqual({ + status: ObjectService.STATUS_DELETED, + balance: EXPECTED_REFUND, + refunds: 1, + }); + }); +}); diff --git a/api/src/services/object/object.service.ts b/api/src/services/object/object.service.ts index b99dd80d..f6b31ba0 100644 --- a/api/src/services/object/object.service.ts +++ b/api/src/services/object/object.service.ts @@ -1,7 +1,12 @@ import crypto from 'crypto'; -const fs = require('fs'); +import fs from 'fs'; +import path from 'path'; import { Service } from 'typedi'; -import { Object } from '../../types/models'; +import { Db } from '../../db/db.class'; +// Aliased so the model stops shadowing the global built-in inside this file. +import { Object as ObjectModel } from '../../types/models'; +import { ObjectWithUsername } from '../../repositories/object/object.repository'; +import { CountRow } from '../../repositories/row.types'; import { ObjectRepository, @@ -11,10 +16,67 @@ import { MallRepository, } from '../../repositories'; +/** What `ObjectService.rejectPendingObject` decided, and the row it decided on. */ +/** A page of objects with the total the query counted alongside it. */ +export interface ObjectListPage { + objects: ObjectWithUsername[]; + total: CountRow[]; +} + +/** The stored filenames `uploadObjectFiles` wrote for one upload. */ +export interface UploadedAssets { + filename: string | null; + image: string | null; + texture: string | null; +} + +/** + * The final path segment of a client-supplied filename, with both `/` and + * `\` treated as separators regardless of the server's own OS. + * + * A WRL commonly references its texture by filename, so a legitimate name + * like `wood.jpg` must come back byte-for-byte -- this only strips a + * traversal or directory prefix down to its basename, it does not replace + * the name with something generated. `..`, `.` and an empty result are + * rejected outright rather than silently coerced to something else. + */ +export function safeUploadBasename(rawName: string): string { + const segments = String(rawName || '').split(/[\\/]+/); + const base = segments[segments.length - 1]; + if (!base || base === '.' || base === '..') { + throw new Error('Invalid upload filename'); + } + return base; +} + +/** + * Resolves `filename` under `directory` and throws if the result would land + * outside it. + * + * Belt-and-braces alongside `safeUploadBasename`: a single sanitized path + * segment can't itself escape, but this is the actual guarantee the upload + * path depends on, checked against the real resolved filesystem path rather + * than inferred from the string shape. + */ +export function resolveWithinUploadPath(directory: string, filename: string): string { + const base = path.resolve(directory); + const target = path.resolve(base, filename); + if (target !== base && !target.startsWith(base + path.sep)) { + throw new Error('Upload destination escapes its directory'); + } + return target; +} + +export interface ObjectRejection { + outcome: string; + object: ObjectModel | null; +} + /** Service for dealing with blocks */ @Service() export class ObjectService { constructor( + private db: Db, private objectRepository: ObjectRepository, private memberRepository: MemberRepository, private transactionRepository: TransactionRepository, @@ -29,23 +91,29 @@ export class ObjectService { public static readonly STATUS_DELETED = 0; public static readonly STATUS_ACTIVE = 1; public static readonly STATUS_PENDING = 2; + + /** Outcomes of `rejectPendingObject`, which never throws for a refusal. */ + public static readonly REJECT_REJECTED = 'rejected'; + public static readonly REJECT_ALREADY_REJECTED = 'already_rejected'; + public static readonly REJECT_INVALID_STATE = 'invalid_state'; + public static readonly REJECT_NOT_FOUND = 'not_found'; public static readonly STATUS_APPROVED = 3; public static readonly STATUS_INACTIVE = 4; public static readonly MALL_EXPIRATION_DAYS = 7; - public async find(objectSearchParams: Partial): Promise { + public async find(objectSearchParams: Partial): Promise { return this.objectRepository.find(objectSearchParams); } - public async findById(objectId: number): Promise { + public async findById(objectId: number): Promise { return this.objectRepository.findById(objectId); } - public async removeAccount(userId: number): Promise { + public async removeAccount(userId: number): Promise { return this.objectRepository.removeAccount(userId); } - public async findByObjectId(objectId: number): Promise { + public async findByObjectId(objectId: number): Promise { const returnObjects = []; const object = await this.objectRepository.getMallObject(objectId); for (const obj of object) { @@ -61,7 +129,7 @@ export class ObjectService { compare: string, content: string, limit: number, - offset: number): Promise { + offset: number): Promise { const returnObjects = []; const user = await this.memberRepository.findIdByUsername(username); const object = await this.objectRepository @@ -82,7 +150,7 @@ export class ObjectService { compare: string, content: string, limit: number, - offset: number): Promise { + offset: number): Promise { const returnObjects = []; const object = await this.objectRepository .getUserUploadedObjects(id, compare, content, limit, offset); @@ -108,10 +176,121 @@ export class ObjectService { return objects; } + /** + * Rejects a pending object and refunds its upload fee as a single commit. + * + * The refund moves real money, so the wallet credit, the ledger row and the + * object's status change all have to land together or not at all. Two separate + * commits leave a window either way round: reject-then-refund can strand an + * object marked deleted whose uploader was never paid, and refund-then-reject + * can pay an uploader for an object that is still pending, which a retry then + * pays for again. + * + * The row is read `FOR UPDATE` and its status re-checked inside the + * transaction, so a second concurrent rejection blocks until the first commits + * and then observes STATUS_DELETED rather than the pending status it saw + * before either began. That is what makes "exactly one refund" true under + * concurrency rather than only in sequence. + * + * The uploader's notification is deliberately NOT part of this: it is attempted + * by the caller after the commit, because a mail failure must not roll back a + * completed refund. + */ + public async rejectPendingObject(objectId: number): Promise { + return this.db.knex.transaction(async trx => { + const object = await this.objectRepository.findByIdForUpdate(objectId, trx); + + if (!object) { + return { outcome: ObjectService.REJECT_NOT_FOUND, object: null }; + } + if (object.status === ObjectService.STATUS_DELETED) { + return { outcome: ObjectService.REJECT_ALREADY_REJECTED, object }; + } + if (object.status !== ObjectService.STATUS_PENDING) { + // Not a pending submission, so there is no upload fee to hand back and + // nothing to triage. Refusing is the only safe answer: the alternative + // is refunding a stocked object because a stale page asked us to. + return { outcome: ObjectService.REJECT_INVALID_STATE, object }; + } + + // An object whose uploader no longer exists is still rejectable; there is + // simply no wallet to credit. + if (object.member_id) { + const member = await this.memberRepository.findById(object.member_id, trx); + if (member && member.wallet_id) { + await this.transactionRepository.createObjectUploadRefundTransaction( + member.wallet_id, + this.getSellerFee(object.quantity, object.price), + trx, + ); + } + } + + await this.objectRepository.update( + objectId, + { status: ObjectService.STATUS_DELETED }, + trx, + ); + + return { outcome: ObjectService.REJECT_REJECTED, object }; + }); + } + + /** + * Approves a pending object. + * + * Like the rejection, this reads the row `FOR UPDATE` and re-checks the status + * inside the transaction, so an approval cannot be applied to an object that + * is not a pending submission and two concurrent approvals cannot both add the + * same mall_object row. There is no money involved, but the state transition + * is just as much the server's to decide as the browser's. + * + * `addToMallObjects` was previously fired without being awaited, so the status + * update could commit -- and the request report success -- before the object + * had been placed in the Mall at all. + */ + public async approvePendingObject(objectId: number): Promise { + return this.db.knex.transaction(async trx => { + const object = await this.objectRepository.findByIdForUpdate(objectId, trx); + + if (!object) { + return { outcome: ObjectService.REJECT_NOT_FOUND, object: null }; + } + if (object.status === ObjectService.STATUS_APPROVED) { + return { outcome: ObjectService.REJECT_ALREADY_REJECTED, object }; + } + if (object.status !== ObjectService.STATUS_PENDING) { + return { outcome: ObjectService.REJECT_INVALID_STATE, object }; + } + + // Both inside the transaction: the insert takes a foreign-key lock on the + // object row this transaction already holds, so on another connection it + // would deadlock against itself. + const existing = await this.mallRepository.findByObjectId(objectId, trx); + if (existing.length === 0) { + await this.mallRepository.addToMallObjects(objectId, trx); + } + + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + ObjectService.MALL_EXPIRATION_DAYS); + + await this.objectRepository.update( + objectId, + { + status: ObjectService.STATUS_APPROVED, + mall_expiration: expirationDate.toJSON().slice(0, 19).replace('T', ' '), + }, + trx, + ); + + return { outcome: ObjectService.REJECT_REJECTED, object }; + }); + } + public async updateStatusApproved(objectId: number) { const checkExist = await this.mallRepository.findByObjectId(objectId); if(checkExist.length === 0) { - this.mallRepository.addToMallObjects(objectId); + await this.mallRepository.addToMallObjects(objectId); } const expirationDate = new Date(); expirationDate.setDate(expirationDate.getDate() + ObjectService.MALL_EXPIRATION_DAYS); @@ -183,31 +362,60 @@ export class ObjectService { }); } + /** + * Object upload directory for a given directory name (== the row's uuid). + * + * Split out so `create()` can find the same path for cleanup after this + * has already run, without recomputing the join logic differently in two + * places. + */ + private objectUploadPath(directoryName: string): string { + return `${process.env.ASSETS_DIR}/object/${directoryName}`; + } + public async uploadObjectFiles( directoryName, fileName, wrlFile, imageFile, textureFile?, - ): Promise { - let uploadPath = process.env.ASSETS_DIR + '/object/' + directoryName; - const response = { + ): Promise { + const uploadPath = this.objectUploadPath(directoryName); + const response: UploadedAssets = { filename: null, image: null, texture: null, }; fs.mkdirSync(uploadPath); - wrlFile.mv(uploadPath + '/' + fileName + '.wrl'); - response.filename = fileName + '.wrl'; - - let imageExtension = imageFile.name.split('.').pop(); - imageFile.mv(uploadPath + '/' + fileName + '.' + imageExtension); - response.image = fileName + '.' + imageExtension; - - if (textureFile) { - textureFile.mv(uploadPath + '/' + textureFile.name); - response.texture = textureFile.name; + try { + const wrlFilename = `${fileName}.wrl`; + await wrlFile.mv(resolveWithinUploadPath(uploadPath, wrlFilename)); + response.filename = wrlFilename; + + // The extension is derived from a sanitized basename, not the raw + // client name, so a dot-free traversal payload can't smuggle a `/` + // into what gets appended after `fileName`. + const imageExtension = safeUploadBasename(imageFile.name).split('.').pop(); + const imageFilename = `${fileName}.${imageExtension}`; + await imageFile.mv(resolveWithinUploadPath(uploadPath, imageFilename)); + response.image = imageFilename; + + if (textureFile) { + // Kept byte-for-byte when it's already a safe ordinary filename -- + // a WRL commonly references its texture by name -- and only + // stripped to its basename when it carries a directory or + // traversal prefix. + const textureFilename = safeUploadBasename(textureFile.name); + await textureFile.mv(resolveWithinUploadPath(uploadPath, textureFilename)); + response.texture = textureFilename; + } + } catch (error) { + // Best-effort: an upload that fails partway through must not leave + // orphaned files behind for a directory name that will never be + // recorded in the database. + fs.rmSync(uploadPath, { recursive: true, force: true }); + throw error; } return response; } @@ -233,9 +441,11 @@ export class ObjectService { * @param price * @param memberId */ - public async create(wrlFile, imageFile, textureFile, name, quantity, price, memberId) { - let uuid = crypto.randomUUID(); - let fileName = crypto.randomBytes(8).toString('hex'); + public async create( + wrlFile, imageFile, textureFile, name, quantity, price, memberId, + ): Promise { + const uuid = crypto.randomUUID(); + const fileName = crypto.randomBytes(8).toString('hex'); const assets = await this.uploadObjectFiles( uuid, @@ -245,16 +455,26 @@ export class ObjectService { textureFile ?? null, ); - this.objectRepository.create( - uuid, - assets.filename, - assets.image, - assets.texture, - name, - quantity, - price, - memberId, - ); + try { + // Awaited and returned, not fired-and-forgotten: the caller charges + // the upload fee and reports success right after this resolves, and + // must not be able to do either before the row actually exists. + return await this.objectRepository.create( + uuid, + assets.filename, + assets.image, + assets.texture, + name, + quantity, + price, + memberId, + ); + } catch (error) { + // The files wrote successfully but the row never will, so they would + // otherwise be orphaned under a uuid nothing references. + fs.rmSync(this.objectUploadPath(uuid), { recursive: true, force: true }); + throw error; + } } /** diff --git a/api/src/services/object/object.service.upload.spec.ts b/api/src/services/object/object.service.upload.spec.ts new file mode 100644 index 00000000..0cf555e4 --- /dev/null +++ b/api/src/services/object/object.service.upload.spec.ts @@ -0,0 +1,234 @@ +import dotenv from 'dotenv'; + +// `knexfile` reads `../.env`, which resolves correctly for the running API but +// not for jest, whose cwd is `api/`. +dotenv.config(); + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { Db } from '../../db/db.class'; +import { ObjectRepository } from '../../repositories/object/object.repository'; +import { + ObjectService, + safeUploadBasename, + resolveWithinUploadPath, +} from './object.service'; + +/** A `Db`-shaped stub; `ObjectService`'s upload path never touches it directly. */ +const fakeDb = {} as unknown as Db; + +/** A file as `express-fileupload` hands one to a controller: name plus a movable Promise. */ +interface FakeUploadedFile { + name: string; + mv: (destination: string) => Promise; +} + +function fakeFile(name: string, mv: (destination: string) => Promise): FakeUploadedFile { + return { name, mv }; +} + +describe('ObjectService upload completion', () => { + let tempAssetsDir: string; + const previousAssetsDir = process.env.ASSETS_DIR; + + beforeEach(() => { + tempAssetsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ctr-object-upload-')); + // `uploadObjectFiles` only creates the uuid leaf; `ASSETS_DIR/object` + // itself is provisioned once at deploy time in the real environment. + fs.mkdirSync(path.join(tempAssetsDir, 'object')); + process.env.ASSETS_DIR = tempAssetsDir; + }); + + afterEach(() => { + process.env.ASSETS_DIR = previousAssetsDir; + fs.rmSync(tempAssetsDir, { recursive: true, force: true }); + }); + + /** A repository stub whose `create` resolves/rejects on command. */ + function buildService(objectRepositoryCreate: ObjectRepository['create']) { + const objectRepository = { create: objectRepositoryCreate } as unknown as ObjectRepository; + return new ObjectService(fakeDb, objectRepository, null, null, null, null); + } + + it('does not resolve create() until a delayed wrl move finishes', async () => { + let mvResolve: () => void; + const mvPromise = new Promise((resolve) => { mvResolve = resolve; }); + const wrlFile = fakeFile('object.wrl', async () => mvPromise); + const imageFile = fakeFile('thumb.jpg', async () => undefined); + const service = buildService(async () => 1); + + let resolved = false; + const createPromise = service.create(wrlFile, imageFile, null, 'Name', 1, 10, 5) + .then((id) => { resolved = true; return id; }); + + // Give any wrongly-unawaited microtasks a chance to resolve create() early. + await new Promise((r) => setImmediate(r)); + expect(resolved).toBe(false); + + mvResolve(); + await createPromise; + expect(resolved).toBe(true); + }); + + it('awaits the wrl move, the image move, and the optional texture move', async () => { + const calls: string[] = []; + const wrlFile = fakeFile('object.wrl', async () => { calls.push('wrl'); }); + const imageFile = fakeFile('thumb.jpg', async () => { calls.push('image'); }); + const textureFile = fakeFile('wood.jpg', async () => { calls.push('texture'); }); + const service = buildService(async () => 1); + + await service.create(wrlFile, imageFile, textureFile, 'Name', 1, 10, 5); + + expect(calls).toEqual(['wrl', 'image', 'texture']); + }); + + it('rejects the upload when a move rejects, and cleans up the directory', async () => { + const wrlFile = fakeFile('object.wrl', async () => { throw new Error('disk full'); }); + const imageFile = fakeFile('thumb.jpg', async () => undefined); + const service = buildService(async () => 1); + + await expect( + service.create(wrlFile, imageFile, null, 'Name', 1, 10, 5), + ).rejects.toThrow('disk full'); + + // The uuid directory this attempt created must not survive a failed move. + const remaining = fs.readdirSync(path.join(tempAssetsDir, 'object')); + expect(remaining).toEqual([]); + }); + + it('awaits objectRepository.create and propagates its rejection', async () => { + const wrlFile = fakeFile('object.wrl', async () => undefined); + const imageFile = fakeFile('thumb.jpg', async () => undefined); + let createCalled = false; + const service = buildService(async () => { + createCalled = true; + throw new Error('duplicate key'); + }); + + await expect( + service.create(wrlFile, imageFile, null, 'Name', 1, 10, 5), + ).rejects.toThrow('duplicate key'); + expect(createCalled).toBe(true); + + // Files wrote successfully but the row never will -- the directory must + // not be left behind under a uuid nothing will ever reference. + const remaining = fs.readdirSync(path.join(tempAssetsDir, 'object')); + expect(remaining).toEqual([]); + }); + + it('resolves create() only after objectRepository.create resolves, not before', async () => { + const wrlFile = fakeFile('object.wrl', async () => undefined); + const imageFile = fakeFile('thumb.jpg', async () => undefined); + let dbResolve: (id: number) => void; + const dbPromise = new Promise((resolve) => { dbResolve = resolve; }); + const service = buildService(() => dbPromise); + + let resolved = false; + const createPromise = service.create(wrlFile, imageFile, null, 'Name', 1, 10, 5) + .then((id) => { resolved = true; return id; }); + + await new Promise((r) => setImmediate(r)); + // This is the property the controller's fee-charging step depends on: + // it awaits `create()` and only then charges the upload fee, so + // `create()` resolving early would let the fee be charged for a row + // that does not exist yet. + expect(resolved).toBe(false); + + dbResolve(42); + expect(await createPromise).toBe(42); + expect(resolved).toBe(true); + }); +}); + +describe('ObjectService upload filename safety', () => { + describe('safeUploadBasename', () => { + it('keeps an ordinary filename byte-for-byte', () => { + expect(safeUploadBasename('wood.jpg')).toBe('wood.jpg'); + }); + + it('reduces a relative traversal to its basename', () => { + expect(safeUploadBasename('../wood.jpg')).toBe('wood.jpg'); + }); + + it('reduces a deep relative traversal to its basename', () => { + expect(safeUploadBasename('../../../../evil.js')).toBe('evil.js'); + }); + + it('reduces a Windows-style traversal to its basename', () => { + expect(safeUploadBasename('..\\evil.jpg')).toBe('evil.jpg'); + }); + + it('reduces an absolute path to its basename', () => { + expect(safeUploadBasename('/etc/passwd')).toBe('passwd'); + }); + + it('rejects a name that is only traversal syntax', () => { + expect(() => safeUploadBasename('../..')).toThrow(); + expect(() => safeUploadBasename('')).toThrow(); + }); + }); + + describe('resolveWithinUploadPath', () => { + let directory: string; + + beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ctr-upload-path-')); + }); + + afterEach(() => { + fs.rmSync(directory, { recursive: true, force: true }); + }); + + it('resolves an ordinary filename inside the directory', () => { + const resolved = resolveWithinUploadPath(directory, 'wood.jpg'); + expect(resolved).toBe(path.join(directory, 'wood.jpg')); + expect(resolved.startsWith(path.resolve(directory) + path.sep)).toBe(true); + }); + + it('throws rather than resolve a path that escapes the directory', () => { + expect(() => resolveWithinUploadPath(directory, '../evil.js')).toThrow(); + expect(() => resolveWithinUploadPath(directory, '../../evil.js')).toThrow(); + }); + }); + + it('an end-to-end malicious texture name cannot land outside the object directory', + async () => { + const tempAssetsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ctr-object-upload-e2e-')); + fs.mkdirSync(path.join(tempAssetsDir, 'object')); + const previousAssetsDir = process.env.ASSETS_DIR; + process.env.ASSETS_DIR = tempAssetsDir; + try { + const mvCalls: string[] = []; + const wrlFile = fakeFile('object.wrl', async () => undefined); + const imageFile = fakeFile('thumb.jpg', async () => undefined); + // A filename with no dot, so a naive `.split('.').pop()` extension + // strategy would forward the whole traversal payload unchanged. + const textureFile = fakeFile( + '../../../../etc/passwd', + async (destination: string) => { mvCalls.push(destination); }, + ); + const objectRepository = { + create: async () => 1, + } as unknown as ObjectRepository; + const service = new ObjectService( + fakeDb, objectRepository, null, null, null, null, + ); + + await service.create(wrlFile, imageFile, textureFile, 'Name', 1, 10, 5); + + // The move must have been attempted only inside this upload's own + // object/ directory -- never anywhere under a path containing + // `..`, and never outside tempAssetsDir/object at all. + expect(mvCalls).toHaveLength(1); + const [actualDestination] = mvCalls; + const objectRoot = path.resolve(tempAssetsDir, 'object'); + expect(path.resolve(actualDestination).startsWith(objectRoot + path.sep)).toBe(true); + expect(actualDestination).not.toContain('..'); + } finally { + process.env.ASSETS_DIR = previousAssetsDir; + fs.rmSync(tempAssetsDir, { recursive: true, force: true }); + } + }); +}); diff --git a/api/src/services/role-assignment/role-assignment.service.ts b/api/src/services/role-assignment/role-assignment.service.ts index 954e62e6..28d55e6e 100644 --- a/api/src/services/role-assignment/role-assignment.service.ts +++ b/api/src/services/role-assignment/role-assignment.service.ts @@ -1,4 +1,6 @@ import { Service } from 'typedi'; +import { CountRow } from '../../repositories/row.types'; +import { RoleCreditRow } from '../../repositories/role-assignment/role-assignment.repository'; import { RoleAssignment } from '../../types/models'; import { @@ -26,7 +28,7 @@ export class RoleAssignmentService { * places them in response array sorts respone into highest cc payout * then drops all other payouts to the same user */ - public async getMembersDueRoleCredit(limit: number): Promise { + public async getMembersDueRoleCredit(limit: number): Promise { const response = await this.roleAssignmentRepository.getMembersDueRoleCredit(limit); return response; } @@ -51,7 +53,8 @@ export class RoleAssignmentService { }); } - public async countByAssigned(id: number): Promise { + /** A count, despite the previous `RoleAssignment[]` annotation. */ + public async countByAssigned(id: number): Promise { return await this.roleAssignmentRepository.countByAssigned(id); } } diff --git a/api/src/types/models/object.model.ts b/api/src/types/models/object.model.ts index f7be67b2..2040fc44 100644 --- a/api/src/types/models/object.model.ts +++ b/api/src/types/models/object.model.ts @@ -13,5 +13,6 @@ export interface Object extends Model { price: number; status: number; directory: string; + description: string | null; mall_expiration: Date; } diff --git a/docs/mall-export-schema.md b/docs/mall-export-schema.md new file mode 100644 index 00000000..56c630d7 --- /dev/null +++ b/docs/mall-export-schema.md @@ -0,0 +1,232 @@ +# CTR Mall export schema + +`GET /api/mall/export?derived=0|1` + +One deterministic JSON document containing the Mall submission queue CTR is +authoritative for. Intended both for staff who want the data and for downstream +tooling such as the separate Cybertown Mall site's importer. + +Requires Mall staff authorisation (`Admin`, `Mall Deputy` or `Mall Manager`) and +is strictly read-only. + +- **Schema version:** `2.0.0` +- **Object scope:** **pending only** — see below. +- **Ordering:** objects ascend by `object.id`. The order is stable across runs. +- **Filename:** the server sends `Content-Disposition` with a UTC stamp precise + to the second, e.g. `ctr-mall-export-2026-08-24T012138Z.json`, so two exports + taken minutes apart are distinct files. + +--- + +## Scope: pending objects only + +**`objects` contains only objects awaiting Mall review (CTR `status = 2`).** This +document is the submission queue the Mall Checker publishes to the Mall's own +site. It is **not** a complete-Mall catalogue export: stocked, warehoused, +sold-out and removed objects are deliberately absent. + +The document says so itself, in `schema.scope`: + +```json +"scope": { + "objects": "pending", + "note": "Objects awaiting Mall review (CTR status 2) only. ..." +} +``` + +Two consequences worth stating plainly: + +- **`stores` is still the full Mall store list**, kept as reference data so a + consumer can render a store name it may meet later. Do not infer from it that + the objects of those stores are present. +- **`derived=1` enriches the same object set as `derived=0`.** The mode changes + how much is said about each object, never which objects appear. + +Version `1.0.0` of this schema exported every object regardless of status. A +consumer written against it must check `schema.scope`. + +--- + +## The one rule a consumer must follow + +**Check `result.status === "complete"` before treating the document as data.** + +The completion record is written at the *end* of the document, because counts, +per-object failures and the outcome are not known until the work is finished. A +run that was truncated says so; a stream that was cut off has no `result` at all +and will not parse. Anything other than `complete` is a diagnostic artefact. + +``` +{ "schema": {...}, <- static, known before any work + "stores": [...], + "ctrViews": {...}, + "objects": [ ... ], <- streamed one at a time + "result": {...} } <- WRITTEN LAST: real counts, real errors, real status +``` + +--- + +## Field classification + +Every field is one of: + +| Class | Meaning | +| --- | --- | +| **DB** | An authoritative database column, verbatim. | +| **COUNT** | An authoritative count derived by query (`sold`). | +| **ASSET** | An authoritative fact about the stored file (size, encoding, hash). | +| **DERIVED** | A deterministic function of stored bytes or of DB fields. | + +`schema.fieldClassification` carries the same mapping in machine-readable form. + +--- + +## `schema` + +| Field | Notes | +| --- | --- | +| `schemaVersion` | Bumped on any breaking change. | +| `generator` | `ctr-mall-export/`. | +| `startedAt` | When the run began. Not an outcome. | +| `includesDerived` | Echo of `?derived=`. | +| `timestamps` | See below. | +| `fieldClassification` | DB / COUNT / ASSET / DERIVED / absent. | + +### `schema.timestamps` + +`normalized` is **`false`**, and timestamps are emitted exactly as the existing +CTR API emits them. They are **not** relabelled UTC and are **not** shifted. + +CTR does not currently pin a timezone: `knexfile.ts` sets no `timezone` option, so +the MySQL driver defaults to `local` and builds dates using the API process's +zone. Measured behaviour for a value stored as `2026-08-20 08:02:43` in a UTC +MySQL: + +``` +API process TZ = UTC -> 2026-08-20T08:02:43.000Z +API process TZ = America/New_York -> 2026-08-20T12:02:43.000Z +``` + +Production runs the stock `node:14` image (UTC) against a UTC MySQL, so values are +correct there today - but by coincidence of two defaults rather than by design. +Pinning it is a CTR-wide change and is tracked separately; this export refuses to +paper over it. + +--- + +## `stores` + +`id`, `name`, `slug`, `status` - all **DB**, from +`place WHERE type = 'shop' AND status = 1`. + +No object count is attached: "how many objects does this store have" is ambiguous +(stocked only? every status? counting `mall_object` rows or `object` rows?). Per +view counts live in `result.counts.ctrViewSizes` with explicit predicates. + +--- + +## `ctrViews` + +The Mall staff panel's six views, as independent id lists. + +**These are current CTR view memberships, not stored states.** They are scoped to +the objects in this document, which is pending-only — so `pending` lists every +exported object and the other five are empty by construction rather than by +accident. The keys are kept so a consumer never has to special-case their absence +or infer membership from `status`. + +The predicates below still describe CTR's real, overlapping view model — a +sold-out object is in `stocked` *and* `outOfStock` — which is why the lists are +never collapsed into a single status field. In a pending-only document that +overlap simply has nothing to act on. + +`ctrViews._definitions` gives the predicate behind each list: + +| View | Predicate | +| --- | --- | +| `pending` | `object.status = 2` | +| `warehouse` | `object.status = 3` | +| `stocked` | `object.status = 1` | +| `outOfStock` | `object.status = 1 AND sold = quantity AND (limit = quantity OR limit IS NULL)` | +| `removed` | `object.status = 0` | +| `inactive` | `object.status = 4` | + +`outOfStock` reproduces the Out of Stock page exactly, **including** the fact that +an object whose limit is `0` is excluded. That is a known Mall policy question +rather than a display bug, and the export describes CTR as it is rather than as it +might become. + +--- + +## `objects[]` + +| Field | Class | Notes | +| --- | --- | --- | +| `id` | DB | The identity key. Use this for joins. | +| `assetDirectory` | DB | `object.directory` - the on-disk asset **directory name**. UUID-format for uploads made through the current path, but legacy rows differ (object 2 is `"2"`). Uniqueness is not guaranteed. **Not an identity key.** | +| `name` | DB | Verbatim, unescaped. | +| `creator.memberId` / `creator.username` | DB | Both `null` when the creator's account is gone. The UI's "Deleted User" wording is presentation and never appears here. | +| `price`, `quantity` | DB | | +| `limit` | DB | Raw value. No `unlimited` flag is derived, because CTR's `limit = 0` semantics are unresolved. | +| `sold` | COUNT | `COUNT(object_instance WHERE object_id = ?)`. No `remaining` is synthesized; compute it if you want it and own that choice. | +| `status` / `statusName` | DB / DERIVED | | +| `store` | DB | `mall_object` joined to `place`; `null` when unplaced. | +| `placement` | DB | Parsed `position` / `rotation`; `null` when unplaced. | +| `ctrViews` | DERIVED | Per-object form of the lists above. | +| `createdAt`, `updatedAt`, `mallExpiration` | DB | See `schema.timestamps`. `mallExpiration` is written on approval but never read by CTR. | +| `description` | DB | `NULL` for every production row. | +| `assets.*` | DB | Filenames and public urls only. `derived=0` performs no filesystem access, so no sizes or hashes appear. | + +### `objects[].derived` (only when `?derived=1`) + +| Field | Class | Notes | +| --- | --- | --- | +| `wrl.storedBytes` | ASSET | Size on disk - the number upload validation measured. | +| `wrl.encoding` | ASSET | `identity` or `gzip`. Many uploads are gzip stored under a `.wrl` name. | +| `wrl.decodedBytes` | ASSET | Size of the actual VRML. **Always reported separately from `storedBytes`.** | +| `wrl.sha256` | ASSET | Hash of the stored bytes. | +| `thumbnail`, `texture` | ASSET | Size and hash, or an error. | +| `vrmlHeader` | DERIVED | First line, verbatim. | +| `worldInfo` | DERIVED | Every WorldInfo node, verbatim. | +| `interpreted` | DERIVED | Best-effort reading of recognised `info[]` prefixes. | +| `comparisons` | DERIVED | `MATCH` / `MISMATCH` / `NOT_FOUND` / `UNPARSED` against the CTR record. **Advisory only.** | +| `nodeCounts` | DERIVED | Fixed, ordered key set, so it stays stable across releases. | +| `textureReferences`, `externalReferences`, `viewpoints`, `warnings` | DERIVED | | +| `sourceError`, `parseError` | DERIVED | Non-null means the scan fields are `null`. **The object is still exported.** | + +--- + +## `result` + +Written last. Every number here is measured, not predicted. + +| Field | Notes | +| --- | --- | +| `status` | `complete` \| `truncated` \| `failed`. Only `complete` is a dataset. | +| `finishedAt`, `durationMs` | | +| `objectsWritten` | Entries actually emitted. | +| `counts` | `stores`, `objects`, `byStatus`, `ctrViewSizes`, each with its predicate in `counts._definitions`. | +| `truncation` | `null` when complete; otherwise the reason, the limit, and the last object reached. | +| `derived` | Only when `includesDerived`: `attempted`, `succeeded`, `failed`, `failuresByReason`. | + +--- + +## What CTR does not have + +Deliberately absent, because no column or file holds them. They belong to an +editorial layer, not to CTR's authoritative data: + +- Mall Object Excellence and any other award +- reviewer / checked-by attribution +- category or object type (the store is the only classification) +- rejection reason +- editorial catalog copy +- drop-event grouping + +## Privacy + +The document contains no credentials, no session tokens, no member data beyond +`memberId` and `username` (both already public throughout the Mall UI), no +filesystem paths, and no server configuration. Assets are referenced by public URL +only. Asset bytes are not bundled; the per-asset `sha256` lets an importer fetch +and verify them lazily. diff --git a/spa/src/components/mall/CheckerModal.vue b/spa/src/components/mall/CheckerModal.vue new file mode 100644 index 00000000..f7668cbb --- /dev/null +++ b/spa/src/components/mall/CheckerModal.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/spa/src/components/mall/MallObjectRow.vue b/spa/src/components/mall/MallObjectRow.vue new file mode 100644 index 00000000..6d869161 --- /dev/null +++ b/spa/src/components/mall/MallObjectRow.vue @@ -0,0 +1,143 @@ + + + diff --git a/spa/src/components/mall/ObjectViewer.vue b/spa/src/components/mall/ObjectViewer.vue new file mode 100644 index 00000000..71a66073 --- /dev/null +++ b/spa/src/components/mall/ObjectViewer.vue @@ -0,0 +1,373 @@ + + + diff --git a/spa/src/pages/mall/checker.vue b/spa/src/pages/mall/checker.vue index d0bf8c36..48524d2f 100644 --- a/spa/src/pages/mall/checker.vue +++ b/spa/src/pages/mall/checker.vue @@ -1,62 +1,1697 @@ + + diff --git a/spa/src/pages/mall/staff/StaffPage.vue b/spa/src/pages/mall/staff/StaffPage.vue index a28e87cc..cc70af18 100644 --- a/spa/src/pages/mall/staff/StaffPage.vue +++ b/spa/src/pages/mall/staff/StaffPage.vue @@ -1,52 +1,44 @@ - - - + + + diff --git a/spa/src/pages/mall/staff/StaffTools.vue b/spa/src/pages/mall/staff/StaffTools.vue new file mode 100644 index 00000000..ee499ecf --- /dev/null +++ b/spa/src/pages/mall/staff/StaffTools.vue @@ -0,0 +1,211 @@ + + + diff --git a/spa/src/pages/mall/staff/list-query.ts b/spa/src/pages/mall/staff/list-query.ts new file mode 100644 index 00000000..0ae84856 --- /dev/null +++ b/spa/src/pages/mall/staff/list-query.ts @@ -0,0 +1,92 @@ +/** + * Canonical URL state for the Mall staff lists. + * + * Page, limit and sort are kept in the URL so a review session survives + * navigation, a browser Back, and the round trip out to the checker and back. + * They do not need to be *written* when they are the defaults: a first visit to + * the Warehouse produced + * + * #/mall/warehouse?page=1&limit=10&order=ASC + * + * which is three parameters saying "no change from normal" and makes a shared + * or bookmarked link look like a filtered view when it is not. + * + * Only what differs from the list's own defaults is written. Explicit values + * are still read back, so every URL that already exists keeps working. + */ + +export interface ListDefaults { + limit: number; + order: string; +} + +export interface ListState { + page: number; + limit: number; + order: string; +} + +/** The page sizes the lists offer. Anything else in a URL is ignored. */ +export const LIST_LIMITS = [10, 20, 50, 100]; + +/** + * Each staff list's own defaults. + * + * Deliberately not one shared constant: Stocked shows newest first, the others + * oldest first, and collapsing that would silently re-sort one of them. + */ +export const LIST_DEFAULTS: { [list: string]: ListDefaults } = { + pending: { limit: 10, order: "ASC" }, + warehouse: { limit: 10, order: "ASC" }, + stocked: { limit: 10, order: "DESC" }, + soldout: { limit: 10, order: "ASC" }, +}; + +/** The defaults for a named list, or the common ones for an unknown name. */ +export function listDefaults(list: string): ListDefaults { + return LIST_DEFAULTS[list] || { limit: 10, order: "ASC" }; +} + +/** + * The query to put in the URL: only what is not already the default. + * + * Values are strings because that is what they will be when they are read back + * out of a URL, and a route whose query is `{page: 2}` and one whose query is + * `{page: '2'}` are not equal to vue-router's duplicate-navigation check. + */ +export function canonicalListQuery( + state: ListState, + defaults: ListDefaults, +): { [key: string]: string } { + const query: { [key: string]: string } = {}; + if (state.page > 1) { + query.page = String(state.page); + } + if (state.limit !== defaults.limit) { + query.limit = String(state.limit); + } + if (state.order !== defaults.order) { + query.order = state.order; + } + return query; +} + +/** + * Reads list state back out of a URL, falling back to the list's defaults. + * + * Anything malformed is ignored rather than rejected: a hand-edited or + * truncated link should land the checker on a sane list, not an error. + */ +export function readListState( + query: { [key: string]: unknown }, + defaults: ListDefaults, +): ListState { + const limit = Number.parseInt(String(query.limit || ""), 10); + const page = Number.parseInt(String(query.page || ""), 10); + const order = String(query.order || ""); + return { + page: Number.isFinite(page) && page > 0 ? page : 1, + limit: LIST_LIMITS.indexOf(limit) !== -1 ? limit : defaults.limit, + order: order === "ASC" || order === "DESC" ? order : defaults.order, + }; +} diff --git a/spa/src/pages/mall/staff/mall-actions.mixin.ts b/spa/src/pages/mall/staff/mall-actions.mixin.ts new file mode 100644 index 00000000..e4b4fb98 --- /dev/null +++ b/spa/src/pages/mall/staff/mall-actions.mixin.ts @@ -0,0 +1,137 @@ +import Vue from "vue"; + +/** + * The three staff actions that are byte-identical across every Mall staff list + * today: the access probe, Edit Name and Update Limit. + * + * Deliberately narrow. Each list's `getResults` and pagination differ in real + * ways - Out of Stock fetches a different endpoint and has no paging at all, + * Search pages by raw offset while the others page by page number - so forcing + * one abstraction over them would add risk rather than remove it. + * + * Consumers must provide a `getResults()` method; it is called after a + * successful edit so the list reflects the change. + */ +/** + * Longest rejection reason the API accepts. + * + * Mirrored from the server so staff are stopped before a request rather than + * after one. The server stays the authority. + */ +export const REJECT_REASON_MAX = 2000; + +/** + * Shared so the checker and the Pending list refuse exactly the same input. + * + * Returns the message to show, or null when the reason is acceptable. + */ +export function rejectReasonError(reason: string): string | null { + const trimmed = (reason || "").trim(); + if (!trimmed) { + return "A reason for rejection is required."; + } + if (trimmed.length > REJECT_REASON_MAX) { + return `A rejection reason may be at most ${REJECT_REASON_MAX} characters.`; + } + return null; +} + +/** + * A name for an object safe to put in a staff-facing sentence. + * + * Objects can reach staff with a null or blank name, and "was rejected" with + * nothing in front of it reads as a bug rather than as a warning. + */ +export function objectDisplayName(object: any): string { + const name = String((object && object.name) || "").trim(); + if (name) { + return name; + } + const id = object && object.id; + return id ? `Object #${id}` : "The object"; +} + +export default Vue.extend({ + data() { + return { + canAdmin: false, + error: "", + showError: false, + success: "", + showSuccess: false, + }; + }, + methods: { + async isMallStaff(): Promise { + try { + await this.$http.get("/mall/can_admin"); + this.canAdmin = true; + } catch (error) { + this.canAdmin = false; + } + }, + + reportError(errorResponse: any): void { + const data = errorResponse && errorResponse.response && errorResponse.response.data; + this.error = (data && data.error) || "An unknown error occurred"; + this.showError = true; + }, + + async updateName(objectId: number, name: string): Promise { + this.showSuccess = false; + this.showError = false; + const newName = window.prompt(`Current Name:\n ${name}\n\nNew Name:`, name); + if (newName === null || newName === "") { + return; + } + try { + this.error = ""; + this.showError = false; + await this.$http.post("/mall/updateObjectName", { + objectId, + name: newName, + }); + this.success = "Object name updated!"; + this.showSuccess = true; + await (this as any).getResults(); + } catch (errorResponse: any) { + this.reportError(errorResponse); + } + }, + + async updateLimit(objectId: number, quantity: number): Promise { + this.showSuccess = false; + this.showError = false; + const entered = window.prompt( + "Update limit to this object\n NOTE: Setting the limit to 0 makes it Unlimited\n", + ); + if (entered === null || entered === "") { + return; + } + const digits = entered.replace(/[^0-9]/g, ""); + if (digits !== entered) { + this.error = "Use whole numbers only!"; + this.showError = true; + return; + } + if (digits !== "0" && Number.parseInt(digits, 10) < quantity) { + this.error = "Limit cannot be less than the uploaded quantity."; + this.showError = true; + return; + } + try { + this.error = ""; + this.showError = false; + await this.$http.post("/mall/limit", { + objectId, + limit: digits, + }); + this.success = "Object limit updated!"; + this.showSuccess = true; + await (this as any).getResults(); + } catch (errorResponse: any) { + this.reportError(errorResponse); + } + }, + }, +}); diff --git a/spa/src/pages/mall/staff/mall-staff-state.ts b/spa/src/pages/mall/staff/mall-staff-state.ts new file mode 100644 index 00000000..88a0c2dd --- /dev/null +++ b/spa/src/pages/mall/staff/mall-staff-state.ts @@ -0,0 +1,21 @@ +import Vue from "vue"; + +/** + * The Pending queue's size, shared between the Pending list and the staff + * controls in the site's right-hand panel. + * + * The export control lives in the control panel but describes the Pending list, + * so it has to know whether that list is empty. The list already counts the + * queue on every load and after every moderation action; publishing that number + * here keeps the control in step without a second, separately-timed count that + * could disagree with what staff can see on screen. + * + * `null` means "not counted yet" and is deliberately distinct from `0`, so the + * control can stay hidden until the real number is known rather than flickering + * in and out on first paint. + */ +const mallStaffState = Vue.observable<{ pendingCount: number | null }>({ + pendingCount: null, +}); + +export default mallStaffState; diff --git a/spa/src/pages/mall/staff/pending.vue b/spa/src/pages/mall/staff/pending.vue index 23400260..18ed6172 100644 --- a/spa/src/pages/mall/staff/pending.vue +++ b/spa/src/pages/mall/staff/pending.vue @@ -1,137 +1,204 @@ diff --git a/spa/src/pages/mall/staff/search.vue b/spa/src/pages/mall/staff/search.vue index 6513c3ef..af8372b5 100644 --- a/spa/src/pages/mall/staff/search.vue +++ b/spa/src/pages/mall/staff/search.vue @@ -1,95 +1,63 @@ diff --git a/spa/src/pages/mall/staff/soldout.vue b/spa/src/pages/mall/staff/soldout.vue index 2bccc2ed..3bb50b01 100644 --- a/spa/src/pages/mall/staff/soldout.vue +++ b/spa/src/pages/mall/staff/soldout.vue @@ -1,115 +1,219 @@ diff --git a/spa/src/pages/mall/staff/stocked.vue b/spa/src/pages/mall/staff/stocked.vue index 5552efec..f8e1a608 100644 --- a/spa/src/pages/mall/staff/stocked.vue +++ b/spa/src/pages/mall/staff/stocked.vue @@ -1,133 +1,142 @@ diff --git a/spa/src/pages/mall/staff/warehouse.vue b/spa/src/pages/mall/staff/warehouse.vue index 41f13baf..6d4f8de5 100644 --- a/spa/src/pages/mall/staff/warehouse.vue +++ b/spa/src/pages/mall/staff/warehouse.vue @@ -1,146 +1,160 @@ diff --git a/spa/src/pages/world-browser/WorldBrowserTools.vue b/spa/src/pages/world-browser/WorldBrowserTools.vue index be3875da..df3cadf8 100644 --- a/spa/src/pages/world-browser/WorldBrowserTools.vue +++ b/spa/src/pages/world-browser/WorldBrowserTools.vue @@ -30,11 +30,15 @@

- Upload
+ Mall Check +
@@ -64,13 +68,32 @@ export default Vue.extend({ adminCheck: false, loaded: false, canAdmin: false, + isMallStaff: false, data: null, mallId: null, }; }, methods: { async getMallId(){ - this.mallId = await this.$http.get('/place/mall'); + this.mallId = await this.$http.get("/place/mall"); + }, + /** + * Server-authoritative, mirroring the check the Mall staff pages themselves + * gate on (`/mall/can_admin`). This is deliberately a different, narrower + * check than the generic shop `checkAdmin()` above -- it answers "is this + * member Mall staff", not "can this member administer this specific shop". + */ + async checkMallStaff() { + if (this.$store.data.place.slug !== "mall") { + this.isMallStaff = false; + return; + } + try { + await this.$http.get("/mall/can_admin"); + this.isMallStaff = true; + } catch (error) { + this.isMallStaff = false; + } }, async checkAdmin() { let endpoint; @@ -98,12 +121,14 @@ export default Vue.extend({ }, mounted() { this.checkAdmin(); + this.checkMallStaff(); this.getMallId(); }, watch: { - async $route(to, from) { + async $route() { console.log("Place Change"); await this.checkAdmin(); + await this.checkMallStaff(); this.loaded = true; }, }, diff --git a/spa/src/routes.ts b/spa/src/routes.ts index a32e989c..de553a17 100644 --- a/spa/src/routes.ts +++ b/spa/src/routes.ts @@ -1,6 +1,6 @@ import HomePage from "./pages/HomePage.vue"; import AboutPage from "./pages/AboutPage.vue"; -import ConstitutionPage from './pages/Constitution.vue'; +import ConstitutionPage from "./pages/Constitution.vue"; import RulesRegulationsPage from "./pages/RulesandRegulationPage.vue"; import PrivacyPolicyPage from "./pages/PrivacyPolicyPage.vue"; import BannedNotice from "./pages/Banned.vue"; @@ -77,6 +77,7 @@ import CreatorStocked from "@/pages/mall/creator/stocked.vue"; import CreatorRestock from "@/pages/mall/creator/restock.vue"; import CreatorCatalog from "@/pages/mall/creator/catalog.vue"; import MallStaffPage from "@/pages/mall/staff/StaffPage.vue"; +import MallStaffTools from "@/pages/mall/staff/StaffTools.vue"; import MallWarehouse from "@/pages/mall/staff/warehouse.vue"; import MallPending from "@/pages/mall/staff/pending.vue"; import MallStocked from "@/pages/mall/staff/stocked.vue"; @@ -94,7 +95,7 @@ import ClubMemberList from "./pages/club/Members.vue"; import ClubDoor from "@/pages/club/ClubDoor.vue"; import ClubUpdate from "@/pages/club/ClubUpdate.vue"; -import MayorElection from '@/pages/MayorElection.vue'; +import MayorElection from "@/pages/MayorElection.vue"; export default [ { @@ -647,29 +648,31 @@ export default [ }, }, { - path: "/mall/checker/:object_id", - component: MallChecker, - name: "mall-checker", - meta: { - title: "Mall Checker", - wrapper: false, - }, - }, - { + // The staff tools render inside the normal Cybertown shell, so `tools` + // supplies their navigation to the site's own right-hand control panel + // rather than a second, detached application shell with its own sidebar. path: "/mall/staff", - component: MallStaffPage, + components: { + default: MallStaffPage, + tools: MallStaffTools, + }, name: "mall-staff", meta: { title: "Mall Staff Panel", - wrapper: false, + wrapper: true, }, children: [ { + // Deliberately the one staff route that stays bare: the dropper opens + // the warehouse as a popup utility and keeps the main window in the + // Mall, so this route is rendered in a window that has no room for -- + // and no use for -- a second copy of the site chrome. path: "/mall/warehouse", component: MallWarehouse, name: "MallWarehouse", meta: { title: "Mall Object Warehouse - Mall Staff Panel", + wrapper: false, }, }, { @@ -678,6 +681,7 @@ export default [ name: "MallPending", meta: { title: "Mall Object Pending - Mall Staff Panel", + wrapper: true, }, }, { @@ -686,6 +690,7 @@ export default [ name: "MallStocked", meta: { title: "Mall Object Stocked - Mall Staff Panel", + wrapper: true, }, }, { @@ -694,6 +699,7 @@ export default [ name: "MallSoldOut", meta: { title: "Mall Object Sold Out - Mall Staff Panel", + wrapper: true, }, }, { @@ -702,6 +708,19 @@ export default [ name: "MallObjectSearch", meta: { title: "Mall Object Search - Mall Staff Panel", + wrapper: true, + }, + }, + { + // A child of the staff panel so the checker inherits its can_admin gate + // and its right-panel navigation, rather than opening as a bare popup + // with no way back to the list. + path: "/mall/checker/:object_id", + component: MallChecker, + name: "mall-checker", + meta: { + title: "Mall Checker - Mall Staff Panel", + wrapper: true, }, }, ], diff --git a/spa/test/blob-download-revocation.test.js b/spa/test/blob-download-revocation.test.js new file mode 100644 index 00000000..d5d548bb --- /dev/null +++ b/spa/test/blob-download-revocation.test.js @@ -0,0 +1,189 @@ +/** + * Regression test for deferred blob-URL revocation in both of the Mall's + * client-side downloads: the checker's decoded-WRL download and the staff + * export's JSON download. + * + * Both revoke with `window.setTimeout(() => revokeObjectURL(url), 0)` + * instead of revoking immediately after `link.click()`, because some + * browsers fetch a blob url on a later tick and an immediate revoke can + * cancel the download. There was no regression test proving that shape, so + * this exercises the real methods against fake `document`/`URL`/`setTimeout` + * globals injected into the sandbox they run in (see + * test/support/load-vue-options.js), and asserts on ordering: revoked only + * after the deferred callback runs, never synchronously after click(). + */ + +const path = require("path"); +const assert = require("assert"); +const { loadComponentOptions } = require("./support/load-vue-options"); + +const CHECKER_PATH = path.join(__dirname, "..", "src", "pages", "mall", "checker.vue"); +const STAFF_TOOLS_PATH = path.join( + __dirname, "..", "src", "pages", "mall", "staff", "StaffTools.vue", +); + +function checkerResolveImport(specifier) { + if (specifier.endsWith("list-query")) { + return { + LIST_LIMITS: [10, 20, 50, 100], + listDefaults: () => ({ limit: 10, order: "ASC" }), + canonicalListQuery: (state, defaults) => { + const query = {}; + if (state.page > 1) query.page = String(state.page); + if (state.limit !== defaults.limit) query.limit = String(state.limit); + if (state.order !== defaults.order) query.order = state.order; + return query; + }, + readListState: () => ({ page: 1, limit: 10, order: "ASC" }), + }; + } + if (specifier.endsWith("ObjectViewer.vue") || specifier.endsWith("CheckerModal.vue")) { + return {}; + } + if (specifier.endsWith("mall-actions.mixin")) { + return { + REJECT_REASON_MAX: 2000, + objectDisplayName: (object) => (object && object.name) || "(unnamed)", + rejectReasonError: () => null, + }; + } + return undefined; +} + +/** A fake DOM sufficient for `createObjectURL` -> `` -> `click()` -> deferred revoke. */ +function buildDomFakes() { + const createCalls = []; + const revokeCalls = []; + const createdLinks = []; + const pendingTimers = []; + let counter = 0; + + const URL = { + createObjectURL(blob) { + const url = `blob:fake-${++counter}`; + createCalls.push({ url, blob }); + return url; + }, + revokeObjectURL(url) { + revokeCalls.push(url); + }, + }; + + const document = { + createElement() { + const link = { + href: "", + download: "", + clicked: false, + click() { link.clicked = true; }, + }; + createdLinks.push(link); + return link; + }, + body: { + appendChild() {}, + removeChild() {}, + }, + }; + + function fakeSetTimeout(fn) { + pendingTimers.push(fn); + return pendingTimers.length; + } + + class FakeBlob { + constructor(parts, options) { + this.parts = parts; + this.options = options; + } + } + + const window = { URL, document, setTimeout: fakeSetTimeout }; + + return { + window, + URL, + document, + setTimeout: fakeSetTimeout, + Blob: FakeBlob, + createCalls, + revokeCalls, + createdLinks, + /** Runs every deferred callback queued so far, in order. */ + runDeferred() { + pendingTimers.splice(0).forEach((fn) => fn()); + }, + }; +} + +async function testCheckerDownload() { + const dom = buildDomFakes(); + const options = loadComponentOptions(CHECKER_PATH, checkerResolveImport, dom); + + const self = { + isDownloading: false, + rawSourceError: "", + objectId: 42, + $http: { + async get() { + return { data: "fake wrl bytes" }; + }, + }, + }; + + await options.methods.downloadSource.call(self); + + assert.strictEqual(dom.createCalls.length, 1, "createObjectURL should be called once"); + const { url } = dom.createCalls[0]; + assert.strictEqual(dom.createdLinks.length, 1, "one should be created"); + assert.strictEqual(dom.createdLinks[0].clicked, true, "the link must have been clicked"); + assert.deepStrictEqual(dom.revokeCalls, [], + "revokeObjectURL must not run synchronously after click()"); + + dom.runDeferred(); + + assert.deepStrictEqual(dom.revokeCalls, [url], + "revokeObjectURL must run exactly once, with the created url, once the deferred tick runs"); + + console.log("PASS: checker.vue downloadSource defers blob revocation"); +} + +async function testStaffToolsExportDownload() { + const dom = buildDomFakes(); + const options = loadComponentOptions( + STAFF_TOOLS_PATH, + (specifier) => (specifier.endsWith("mall-staff-state") ? { pendingCount: null } : undefined), + dom, + ); + + const self = { exportFilename: options.methods.exportFilename }; + const payload = { result: { status: "complete" } }; + const headers = {}; + + options.methods.saveExport.call(self, payload, headers); + + assert.strictEqual(dom.createCalls.length, 1, "createObjectURL should be called once"); + const { url } = dom.createCalls[0]; + assert.strictEqual(dom.createdLinks.length, 1, "one should be created"); + assert.strictEqual(dom.createdLinks[0].clicked, true, "the link must have been clicked"); + assert.deepStrictEqual(dom.revokeCalls, [], + "revokeObjectURL must not run synchronously after click()"); + + dom.runDeferred(); + + assert.deepStrictEqual(dom.revokeCalls, [url], + "revokeObjectURL must run exactly once, with the created url, once the deferred tick runs"); + + console.log("PASS: StaffTools.vue saveExport defers blob revocation"); +} + +async function run() { + await testCheckerDownload(); + await testStaffToolsExportDownload(); + console.log("PASS: blob-download-revocation.test.js"); +} + +run().catch((error) => { + console.error("FAIL:", error.stack || error.message); + process.exitCode = 1; +}); diff --git a/spa/test/checker-accept-messaging.test.js b/spa/test/checker-accept-messaging.test.js new file mode 100644 index 00000000..4ac8f11c --- /dev/null +++ b/spa/test/checker-accept-messaging.test.js @@ -0,0 +1,127 @@ +/** + * Regression test for the checker's acceptance-outcome messaging. + * + * Owner QA finding: Reject told the uploader what happened, Accept was silent. + * `/mall/approve` now notifies the uploader after the Pending -> Warehouse + * transition commits, and reports the same three outcomes Reject does. They + * must not be conflated: + * + * notified: true -> accepted and the uploader told + * notified: false -> accepted, the notice failed, follow up + * alreadyAccepted, notified: false -> a concurrent Accept already won; this + * request moved nothing and attempted + * no notification, so there is nothing + * to follow up on + * + * See checker-navigation.test.js for why this loads the component this way. + * + * Run with: node test/checker-accept-messaging.test.js + */ + +const path = require("path"); +const assert = require("assert"); +const { loadComponentOptions } = require("./support/load-vue-options"); + +const CHECKER_PATH = path.join(__dirname, "..", "src", "pages", "mall", "checker.vue"); + +function resolveImport(specifier) { + if (specifier.endsWith("list-query")) { + return { + LIST_LIMITS: [10, 20, 50, 100], + listDefaults: () => ({ limit: 10, order: "ASC" }), + canonicalListQuery: (state, defaults) => { + const query = {}; + if (state.page > 1) query.page = String(state.page); + if (state.limit !== defaults.limit) query.limit = String(state.limit); + if (state.order !== defaults.order) query.order = state.order; + return query; + }, + readListState: () => ({ page: 1, limit: 10, order: "ASC" }), + }; + } + if (specifier.endsWith("ObjectViewer.vue") || specifier.endsWith("CheckerModal.vue")) { + return {}; + } + if (specifier.endsWith("mall-actions.mixin")) { + return { + REJECT_REASON_MAX: 2000, + objectDisplayName: (object) => (object && object.name) || "(unnamed)", + rejectReasonError: () => null, + }; + } + return undefined; +} + +/** + * `approveObject` does not set the default success message itself -- + * `performAction` does -- so the stub replicates that one side effect. + */ +function buildSelf(responseData) { + return { + object: { id: 42, name: "Fixture Object" }, + isProcessing: false, + actionError: "", + actionSuccess: "", + actionWarning: "", + async performAction(endpoint, body, success) { + this.actionSuccess = success; + return responseData; + }, + }; +} + +async function run() { + const options = loadComponentOptions(CHECKER_PATH, resolveImport); + assert.strictEqual(typeof options.methods.approveObject, "function"); + + // --- Normal successful acceptance, uploader notified. --- + { + const self = buildSelf({ status: "success", notified: true }); + await options.methods.approveObject.call(self); + assert.strictEqual(self.actionSuccess, "Object accepted and the uploader notified."); + assert.strictEqual(self.actionWarning, ""); + } + + // --- Genuine notification failure: the acceptance completed, the notice didn't. --- + { + const self = buildSelf({ status: "success", notified: false }); + await options.methods.approveObject.call(self); + assert.strictEqual(self.actionSuccess, "Object accepted."); + assert.ok( + self.actionWarning.includes("could not be notified"), + `expected a notification-failure warning, got: ${self.actionWarning}`, + ); + } + + // --- Race loser: alreadyAccepted, notified: false, but nothing failed. --- + { + const self = buildSelf({ status: "success", notified: false, alreadyAccepted: true }); + await options.methods.approveObject.call(self); + assert.strictEqual(self.actionSuccess, "Object was already accepted."); + assert.strictEqual(self.actionWarning, "", + "alreadyAccepted must not be reported as a notification failure"); + assert.ok( + !self.actionSuccess.toLowerCase().includes("notified"), + "alreadyAccepted must not claim this request notified anyone", + ); + } + + // --- A failed request must not claim an acceptance happened. --- + { + const self = buildSelf(null); + self.performAction = async function performAction() { + this.actionError = "Only a pending object can be accepted."; + return null; + }; + await options.methods.approveObject.call(self); + assert.strictEqual(self.actionSuccess, ""); + assert.strictEqual(self.actionWarning, ""); + } + + console.log("PASS: checker-accept-messaging.test.js"); +} + +run().catch((error) => { + console.error("FAIL:", error.stack || error.message); + process.exitCode = 1; +}); diff --git a/spa/test/checker-navigation.test.js b/spa/test/checker-navigation.test.js new file mode 100644 index 00000000..ebd00c78 --- /dev/null +++ b/spa/test/checker-navigation.test.js @@ -0,0 +1,160 @@ +/** + * Regression test for the Mall checker's stale-inspection-during-navigation bug. + * + * The SPA has no test runner (no Jest, no vue-test-utils, no jsdom) anywhere + * in this project, and adding one is out of scope for this single fix. This + * script instead extracts the component's