From cd503bd8a8acd58ee601d940dda840fc6fa57529 Mon Sep 17 00:00:00 2001 From: Ryan Bundy Date: Sun, 23 Aug 2026 08:25:14 -0400 Subject: [PATCH 01/19] =?UTF-8?q?feat:=20mall=20staff=20workflow=20?= =?UTF-8?q?=E2=80=94=20object=20checker,=20inspection=20and=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds staff-facing tooling for the Mall moderation workflow: - Shared MallObjectRow / ObjectViewer components and a mall-actions mixin, replacing the duplicated action wiring across the five staff pages (pending, search, soldout, stocked, warehouse). - Object checker page with X_ITE-backed preview and technical facts pane. - VRML libs: tokenizer, scene scanner and WorldInfo comparison. - Mall inspection service producing structured per-object findings. - Mall export service streaming a JSON export of Mall objects, with a cheap (derived=0) mode that performs no source reads. - Object source service for reading stored object files. - Repository/controller/route wiring for the three new staff endpoints. No schema migration. Export schema documented in docs/mall-export-schema.md. --- api/spec/mocks/db-module.mock.ts | 47 ++ api/spec/mocks/index.ts | 1 + api/src/controllers/mall.controller.spec.ts | 359 +++++++++ api/src/controllers/mall.controller.ts | 149 +++- api/src/libs/index.ts | 2 + api/src/libs/mall/index.ts | 1 + api/src/libs/mall/mall-object-views.spec.ts | 148 ++++ api/src/libs/mall/mall-object-views.ts | 109 +++ api/src/libs/vrml/index.ts | 3 + api/src/libs/vrml/vrml-scan.spec.ts | 355 +++++++++ api/src/libs/vrml/vrml-scan.ts | 425 ++++++++++ api/src/libs/vrml/vrml-tokenizer.spec.ts | 85 ++ api/src/libs/vrml/vrml-tokenizer.ts | 171 ++++ api/src/libs/vrml/worldinfo-compare.spec.ts | 194 +++++ api/src/libs/vrml/worldinfo-compare.ts | 315 ++++++++ .../mall-object/mall-object.repository.ts | 35 + .../repositories/member/member.repository.ts | 16 + .../object-instance.repository.ts | 40 + .../repositories/object/object.repository.ts | 33 +- api/src/routes/mall.routes.ts | 6 + api/src/services/index.ts | 3 + .../mall-export/mall-export.service.spec.ts | 453 +++++++++++ .../mall-export/mall-export.service.ts | 495 ++++++++++++ .../mall-inspection.service.spec.ts | 304 +++++++ .../mall-inspection.service.ts | 403 ++++++++++ api/src/services/mall/mall.service.spec.ts | 186 +++++ api/src/services/mall/mall.service.ts | 81 +- .../object-source.service.spec.ts | 264 +++++++ .../object-source/object-source.service.ts | 285 +++++++ docs/mall-export-schema.md | 193 +++++ spa/src/components/mall/MallObjectRow.vue | 125 +++ spa/src/components/mall/ObjectViewer.vue | 297 +++++++ spa/src/pages/mall/checker.vue | 747 ++++++++++++++++-- spa/src/pages/mall/staff/StaffPage.vue | 220 ++++-- .../pages/mall/staff/mall-actions.mixin.ts | 98 +++ spa/src/pages/mall/staff/pending.vue | 363 ++++----- spa/src/pages/mall/staff/search.vue | 249 ++---- spa/src/pages/mall/staff/soldout.vue | 227 ++++-- spa/src/pages/mall/staff/stocked.vue | 301 +++---- spa/src/pages/mall/staff/warehouse.vue | 345 +++----- spa/src/routes.ts | 24 +- 41 files changed, 7127 insertions(+), 1030 deletions(-) create mode 100644 api/spec/mocks/db-module.mock.ts create mode 100644 api/src/controllers/mall.controller.spec.ts create mode 100644 api/src/libs/mall/index.ts create mode 100644 api/src/libs/mall/mall-object-views.spec.ts create mode 100644 api/src/libs/mall/mall-object-views.ts create mode 100644 api/src/libs/vrml/index.ts create mode 100644 api/src/libs/vrml/vrml-scan.spec.ts create mode 100644 api/src/libs/vrml/vrml-scan.ts create mode 100644 api/src/libs/vrml/vrml-tokenizer.spec.ts create mode 100644 api/src/libs/vrml/vrml-tokenizer.ts create mode 100644 api/src/libs/vrml/worldinfo-compare.spec.ts create mode 100644 api/src/libs/vrml/worldinfo-compare.ts create mode 100644 api/src/services/mall-export/mall-export.service.spec.ts create mode 100644 api/src/services/mall-export/mall-export.service.ts create mode 100644 api/src/services/mall-inspection/mall-inspection.service.spec.ts create mode 100644 api/src/services/mall-inspection/mall-inspection.service.ts create mode 100644 api/src/services/mall/mall.service.spec.ts create mode 100644 api/src/services/object-source/object-source.service.spec.ts create mode 100644 api/src/services/object-source/object-source.service.ts create mode 100644 docs/mall-export-schema.md create mode 100644 spa/src/components/mall/MallObjectRow.vue create mode 100644 spa/src/components/mall/ObjectViewer.vue create mode 100644 spa/src/pages/mall/staff/mall-actions.mixin.ts diff --git a/api/spec/mocks/db-module.mock.ts b/api/spec/mocks/db-module.mock.ts new file mode 100644 index 00000000..2e5e0116 --- /dev/null +++ b/api/spec/mocks/db-module.mock.ts @@ -0,0 +1,47 @@ +/** + * 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. + */ +function makeBuilder(): 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/mall.controller.spec.ts b/api/src/controllers/mall.controller.spec.ts new file mode 100644 index 00000000..ff7f7989 --- /dev/null +++ b/api/src/controllers/mall.controller.spec.ts @@ -0,0 +1,359 @@ +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 { + MallExportService, + MallInspectionService, + MallService, + MemberService, + ObjectInstanceService, + ObjectService, + WalletService, +} from '../services'; + +function mockResponse() { + const response: any = {}; + 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; +} + +function request(params: any = {}, query: any = {}, apitoken = 'staff-token'): any { + return { params, query, headers: { apitoken } }; +} + +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 controller: MallController; + + beforeEach(() => { + memberService = createSpyObj(MemberService); + mallService = createSpyObj(MallService); + objectService = createSpyObj(ObjectService); + walletService = createSpyObj(WalletService); + objectInstanceService = createSpyObj(ObjectInstanceService); + mallInspectionService = createSpyObj(MallInspectionService); + mallExportService = createSpyObj(MallExportService); + controller = new MallController( + memberService, + mallService, + objectService, + walletService, + objectInstanceService, + mallInspectionService, + mallExportService, + ); + + 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(); + }); + }); +}); diff --git a/api/src/controllers/mall.controller.ts b/api/src/controllers/mall.controller.ts index c25cd510..42c3c863 100644 --- a/api/src/controllers/mall.controller.ts +++ b/api/src/controllers/mall.controller.ts @@ -4,20 +4,163 @@ import { Container } from 'typedi'; import { MemberService, MallService, + MallExportService, + MallInspectionService, ObjectService, WalletService, ObjectInstanceService, } from '../services'; +import { createResponseWriter } from '../services/mall-export/mall-export.service'; // Removed unused import -class MallController { +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, ) {} + /** + * 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 = Number.parseInt(request.params.id, 10); + if (!Number.isFinite(objectId)) { + 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'; + + response.setHeader('Content-Type', 'application/json; charset=utf-8'); + response.setHeader('X-Content-Type-Options', 'nosniff'); + response.status(200); + + try { + await this.mallExportService.export(createResponseWriter(response), { includeDerived }); + } 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 = Number.parseInt(request.params.id, 10); + if (!Number.isFinite(objectId)) { + 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; @@ -610,11 +753,15 @@ 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); export const mallController = new MallController( memberService, mallService, objectService, walletService, objectInstanceService, + mallInspectionService, + mallExportService, ); 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..c9a03d6b --- /dev/null +++ b/api/src/libs/vrml/vrml-scan.spec.ts @@ -0,0 +1,355 @@ +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(); + }); +}); diff --git a/api/src/libs/vrml/vrml-scan.ts b/api/src/libs/vrml/vrml-scan.ts new file mode 100644 index 00000000..85eadfa9 --- /dev/null +++ b/api/src/libs/vrml/vrml-scan.ts @@ -0,0 +1,425 @@ +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; + worldInfo: 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; +} + +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'; +} + +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[] = []; + const nodeCounts: { [nodeType: string]: number } = {}; + const protoDefinitions: string[] = []; + const externProtoDefinitions: string[] = []; + const urls: VrmlUrlReference[] = []; + const viewpoints: ViewpointFact[] = []; + const stack: Frame[] = []; + + 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 }); + } 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); + } 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: [] }); + } + stack.push({ type: word }); + 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 warnings: string[] = []; + if (header === null || header !== VRML97_HEADER) { + warnings.push(FINDING_BAD_HEADER); + } + if (worldInfo.length === 0) { + warnings.push(FINDING_NO_WORLDINFO); + } + if (worldInfo.length > 1) { + warnings.push(FINDING_MULTIPLE_WORLDINFO); + } + if (unterminatedString || unbalanced || stack.length > 0) { + warnings.push(FINDING_MALFORMED_VRML); + } + if (truncated) { + warnings.push(FINDING_TRUNCATED); + } + + return { + header, + headerIsVrml97: header === VRML97_HEADER, + worldInfo, + 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', + ); +} 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..bbcd88d7 --- /dev/null +++ b/api/src/libs/vrml/vrml-tokenizer.spec.ts @@ -0,0 +1,85 @@ +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); + }); +}); diff --git a/api/src/libs/vrml/vrml-tokenizer.ts b/api/src/libs/vrml/vrml-tokenizer.ts new file mode 100644 index 00000000..8baf0730 --- /dev/null +++ b/api/src/libs/vrml/vrml-tokenizer.ts @@ -0,0 +1,171 @@ +/** + * 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) { + if (tokens.length >= maxTokens) { + return { tokens, truncated: true, unterminatedString }; + } + + const character = text[position]; + + if (isWhitespace(character)) { + position += 1; + continue; + } + + if (character === '#') { + while (position < text.length && text[position] !== '\n') { + position += 1; + } + continue; + } + + 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..e9f1a17f --- /dev/null +++ b/api/src/libs/vrml/worldinfo-compare.spec.ts @@ -0,0 +1,194 @@ +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'); + }); +}); diff --git a/api/src/libs/vrml/worldinfo-compare.ts b/api/src/libs/vrml/worldinfo-compare.ts new file mode 100644 index 00000000..123401d5 --- /dev/null +++ b/api/src/libs/vrml/worldinfo-compare.ts @@ -0,0 +1,315 @@ +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; +} + +/** + * Finds the first `info[]` entry beginning with one of `prefixes`, allowing any + * surrounding whitespace and any case. The separator colon is optional because + * real objects are inconsistent about it. + */ +function findPrefixed(info: string[], prefixes: string[]): PrefixMatch | null { + for (const prefix of prefixes) { + for (const line of info) { + const trimmed = line.trim(); + const lower = trimmed.toLowerCase(); + if (lower.indexOf(prefix) !== 0) { + continue; + } + const remainder = trimmed.slice(prefix.length).replace(/^\s*:?\s*/, ''); + return { line, value: remainder }; + } + } + return null; +} + +function normalise(value: string): string { + return value.trim().replace(/\s+/g, ' ').toLowerCase(); +} + +/** Pulls the first integer out of a value such as "75 CC" or "25 max". */ +function parseInteger(value: string): number | null { + const match = /-?\d+/.exec(value); + return match ? Number.parseInt(match[0], 10) : null; +} + +function compareText( + field: ComparisonField, + match: PrefixMatch | null, + ctrValue: string | null, +): FieldComparison { + 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(match: PrefixMatch | null, ctrPrice: number | null): FieldComparison { + 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.', + }; + } + 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(match: PrefixMatch | null, ctrLimit: number | null): FieldComparison { + const ctrValue = ctrLimit; + + 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 = findPrefixed(info, PREFIXES.creator); + const price = findPrefixed(info, PREFIXES.price); + const limit = findPrefixed(info, PREFIXES.limit); + const store = findPrefixed(info, PREFIXES.store); + const uploaded = findPrefixed(info, PREFIXES.uploaded); + + return { + interpreted: { + title: node.title, + creator: creator ? creator.value : null, + price: price ? price.value : null, + limit: limit ? limit.value : null, + store: store ? store.value : null, + uploaded: uploaded ? uploaded.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.ts b/api/src/repositories/mall-object/mall-object.repository.ts index d9abd970..e4a75568 100644 --- a/api/src/repositories/mall-object/mall-object.repository.ts +++ b/api/src/repositories/mall-object/mall-object.repository.ts @@ -32,6 +32,41 @@ export class MallRepository { 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]: any }> { + const stores: { [objectId: number]: any } = {}; + if (!objectIds.length) { + return stores; + } + const rows = await this.db.mallObject + .select('place.*', 'mall_object.object_id') + .whereIn('mall_object.object_id', objectIds) + .join('place', 'place.id', 'mall_object.place_id'); + rows.forEach((row: any) => { + 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]: any }> { + const stores: { [objectId: number]: any } = {}; + const rows = await this.db.mallObject + .select('place.*', 'mall_object.object_id') + .join('place', 'place.id', 'mall_object.place_id'); + rows.forEach((row: any) => { + if (!stores[row.object_id]) { + stores[row.object_id] = row; + } + }); + return stores; + } public async findByObjectId(objectId: number): Promise { const object = await this.db.mallObject.where({object_id: objectId}); return object; diff --git a/api/src/repositories/member/member.repository.ts b/api/src/repositories/member/member.repository.ts index d4c3396b..462b5f85 100644 --- a/api/src/repositories/member/member.repository.ts +++ b/api/src/repositories/member/member.repository.ts @@ -43,6 +43,22 @@ export class MemberRepository { return this.find({ id: memberId }); } + /** + * 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 { return this.db.knex .select('id') diff --git a/api/src/repositories/object-instance/object-instance.repository.ts b/api/src/repositories/object-instance/object-instance.repository.ts index e85fd1fe..a3bdc281 100644 --- a/api/src/repositories/object-instance/object-instance.repository.ts +++ b/api/src/repositories/object-instance/object-instance.repository.ts @@ -160,6 +160,46 @@ export class ObjectInstanceRepository { return parseInt(Object.values(count[0])[0]); } + /** + * 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: any) => { + 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: any) => { + counts[row.object_id] = Number.parseInt(String(row.total), 10); + }); + return counts; + } + public async findForSale(): Promise { return this.db.objectInstance .count('id as count') diff --git a/api/src/repositories/object/object.repository.ts b/api/src/repositories/object/object.repository.ts index 6daef666..36147bbf 100644 --- a/api/src/repositories/object/object.repository.ts +++ b/api/src/repositories/object/object.repository.ts @@ -220,11 +220,40 @@ export class ObjectRepository { 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') + .orderBy('id', 'asc'); + } + + /** One page of full object rows, in the export's deterministic id order. */ + public async findPageForExport(limit: number, offset: number): Promise { + return this.db.object + .select('object.*') + .orderBy('id', 'asc') + .limit(limit) + .offset(offset); + } + + /** Object counts grouped by status, for the export's reported totals. */ + public async countGroupedByStatus(): Promise { + return this.db.object + .select('status') + .count('id as total') + .groupBy('status'); + } + public async getUserUploadedObjects( userId: number, compare: string, 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/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..38dc03ab --- /dev/null +++ b/api/src/services/mall-export/mall-export.service.spec.ts @@ -0,0 +1,453 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import zlib from 'zlib'; +import { createSpyObj } from 'jest-createspyobj'; + +import { + createResponseWriter, + ExportWriter, + MallExportService, + MAX_DURATION_MS, +} 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 sold-out + * object that must appear in two views at once, an object with no creator, and + * one whose file is missing from disk. + */ +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: 1, created_at: '2026-08-20T08:02:43.000Z', updated_at: '2026-08-20T08:02:43.000Z', + mall_expiration: null, description: null, position: '{"x":0,"y":1.75,"z":0}', + rotation: '{"x":0,"y":0,"z":0,"angle":0}', + }, + { + 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, position: null, rotation: 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: 1, created_at: '2026-08-22T09:00:00.000Z', updated_at: '2026-08-22T09:00:00.000Z', + mall_expiration: null, description: null, position: null, rotation: null, + }, +]; + +const VIEW_ROWS = OBJECTS.map(object => ({ + id: object.id, + status: object.status, + quantity: object.quantity, + limit: object.limit, +})); + +/** Object 10 is fully sold, so it belongs to stocked AND outOfStock. */ +const COUNTS = { 10: 25, 12: 3 }; + +const STORES = { 10: { id: 1205, name: 'Toy Store', object_id: 10 } }; + +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); + } + + async function runExport(includeDerived = false, now?: () => number): Promise { + const writer = collectingWriter(); + const status = await service.export(writer, { includeDerived, now }); + 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(); + + objectRepository.findViewRows.mockResolvedValue(VIEW_ROWS as never); + objectRepository.findPageForExport.mockImplementation( + (limit: number, offset: number) => + Promise.resolve(OBJECTS.slice(offset, offset + limit)) as never, + ); + objectInstanceRepository.countAllByObjectId.mockResolvedValue(COUNTS as never); + mallRepository.getAllStoresByObjectId.mockResolvedValue(STORES 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: any) => object.id)).toEqual([10, 11, 12]); + }); + + it('names the schema version and generator', async () => { + const { document } = await runExport(); + + expect(document.schema.schemaVersion).toBe('1.0.0'); + expect(document.schema.generator).toMatch(/^ctr-mall-export\//); + }); + }); + + describe('ctrViews', () => { + it('keeps stocked and outOfStock as independent, overlapping memberships', + async () => { + const { document } = await runExport(); + + expect(document.ctrViews.stocked).toContain(10); + expect(document.ctrViews.outOfStock).toContain(10); + expect(document.ctrViews.pending).toEqual([11]); + }); + + 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('overlap'); + }); + + 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({ '1': 2, '2': 1 }); + 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']); + }); + }); + + 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: any) => 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: any) => { + 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: any) => 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: any) => entry.id === 10); + + expect(object.derived.worldInfo[0].title).toBe('Pocket Moon Playset'); + expect(object.derived.nodeCounts.ImageTexture).toBe(1); + expect(object.derived.comparisons.find((c: any) => c.field === 'price').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: any) => 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 reading when the client goes away', async () => { + const writer: ExportWriter = { + write: () => Promise.resolve(), + isClosed: () => true, + }; + + const status = await service.export(writer, { includeDerived: false }); + + expect(status).toBe('failed'); + expect(objectRepository.findPageForExport).not.toHaveBeenCalled(); + }); + }); +}); + +describe('createResponseWriter', () => { + function fakeResponse(writeResults: boolean[]) { + const drains: (() => void)[] = []; + return { + written: [] as string[], + writableEnded: false, + destroyed: false, + write(chunk: string) { + this.written.push(chunk); + return writeResults.length ? writeResults.shift() : true; + }, + once(event: string, handler: () => void) { + if (event === 'drain') { + drains.push(handler); + } + }, + flush() { + drains.splice(0).forEach(handler => handler()); + }, + pendingDrains: () => drains.length, + }; + } + + it('resolves immediately when the socket accepts the write', async () => { + const response = fakeResponse([true]); + + await createResponseWriter(response).write('chunk'); + + expect(response.written).toEqual(['chunk']); + }); + + it('waits for drain when the socket signals backpressure', async () => { + const response = fakeResponse([false]); + const writer = createResponseWriter(response); + let settled = false; + + const pending = writer.write('big chunk').then(() => { + settled = true; + }); + + await Promise.resolve(); + expect(settled).toBe(false); + expect(response.pendingDrains()).toBe(1); + + response.flush(); + await pending; + + expect(settled).toBe(true); + }); + + it('reports a finished or destroyed response as closed', () => { + const response = fakeResponse([]); + expect(createResponseWriter(response).isClosed()).toBe(false); + + response.destroyed = true; + expect(createResponseWriter(response).isClosed()).toBe(true); + }); +}); 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..41c398e4 --- /dev/null +++ b/api/src/services/mall-export/mall-export.service.ts @@ -0,0 +1,495 @@ +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'; + +/** + * 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 = '1.0.0'; +export const EXPORT_GENERATOR = 'ctr-mall-export/1.0.0'; + +/** Objects read per page while streaming. Bounds peak memory, not total output. */ +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; +} + +export interface ExportOptions { + includeDerived: boolean; + /** Injected so specs can drive the clock rather than wait on it. */ + now?: () => 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. + */ +export function createResponseWriter(response: any): ExportWriter { + return { + write(chunk: string): Promise { + if (response.write(chunk)) { + return Promise.resolve(); + } + return new Promise(resolve => response.once('drain', resolve)); + }, + isClosed(): boolean { + return !!(response.writableEnded || response.destroyed); + }, + }; +} + +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, + ) {} + + public async export(writer: ExportWriter, options: ExportOptions): Promise { + const now = options.now || (() => Date.now()); + const startedAt = now(); + const startedIso = new Date(startedAt).toISOString(); + + let status: ExportStatus = 'complete'; + let truncation: any = null; + let objectsWritten = 0; + const derivedTally = { + attempted: 0, + succeeded: 0, + failed: 0, + failuresByReason: {} as { [reason: string]: number }, + }; + + try { + await writer.write(`{"schema":${JSON.stringify(this.buildSchema(startedIso, options))}`); + + const stores = await this.placeRepository.findAllStores('name'); + await writer.write(`,"stores":${JSON.stringify(stores.map((store: any) => ({ + id: store.id, + name: store.name, + slug: store.slug, + status: store.status, + })))}`); + + const viewRows = await this.objectRepository.findViewRows(); + const allCounts = await this.objectInstanceRepository.countAllByObjectId(); + await writer.write(`,"ctrViews":${JSON.stringify(this.buildViews(viewRows, allCounts))}`); + + const allStores = await this.mallRepository.getAllStoresByObjectId(); + + await writer.write(',"objects":['); + let offset = 0; + let first = true; + + 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 (objectsWritten >= MAX_OBJECTS) { + status = 'truncated'; + truncation = { reason: 'object_cap', limit: MAX_OBJECTS, lastObjectId: null }; + break; + } + + const page = await this.objectRepository.findPageForExport(PAGE_SIZE, offset); + if (!page.length) { + break; + } + + const members = await this.memberRepository.findByIds( + page.map((row: any) => row.member_id).filter((id: any) => !!id), + ); + + for (const row of page) { + 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, + }); + await writer.write(`${first ? '' : ','}\n${JSON.stringify(entry)}`); + first = false; + objectsWritten += 1; + if (truncation) { + truncation.lastObjectId = row.id; + } + } + + if (truncation) { + truncation.lastObjectId = page[page.length - 1].id; + } + offset += PAGE_SIZE; + } + + if (truncation && truncation.lastObjectId === null && objectsWritten > 0) { + truncation.lastObjectId = objectsWritten; + } + + await writer.write(']'); + await writer.write(`,"result":${JSON.stringify(this.buildResult({ + status, + truncation, + startedIso, + startedAt, + now, + objectsWritten, + storesCount: stores.length, + viewRows, + allCounts, + includeDerived: options.includeDerived, + derivedTally, + }))}}`); + } catch (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. + status = 'failed'; + try { + await writer.write(`],"result":${JSON.stringify({ + status: 'failed', + reason: String((error as Error).message || error), + finishedAt: new Date(now()).toISOString(), + objectsWritten, + })}}`); + } catch (writeError) { + // Nothing further can be reported to a broken stream. + } + } + + return status; + } + + private buildSchema(startedIso: string, options: ExportOptions): any { + return { + schemaVersion: EXPORT_SCHEMA_VERSION, + generator: EXPORT_GENERATOR, + startedAt: startedIso, + includesDerived: options.includeDerived, + 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: any[], counts: { [id: number]: number }): any { + const views: any = { + _definitions: CTR_VIEW_DEFINITIONS, + _note: 'Current CTR staff-panel view memberships, derived rather than stored. They ' + + 'overlap by design. outOfStock reproduces the Out of Stock page exactly, ' + + 'including its treatment of a zero limit.', + 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 as any)[view]) { + views[view].push(row.id); + } + }); + }); + + return views; + } + + private async buildObject(row: any, context: any): Promise { + const limit = row.limit === undefined ? null : row.limit; + const entry: any = { + 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: row.quantity ?? null, + limit, + sold: context.sold, + status: row.status, + statusName: statusName(row.status), + store: context.store ? { id: context.store.id, name: context.store.name } : null, + placement: context.store + ? { position: this.parseJson(row.position), rotation: this.parseJson(row.rotation) } + : null, + ctrViews: ctrViewsFor({ + status: row.status, + sold: context.sold, + quantity: row.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: any, tally: any, entry: any): Promise { + tally.attempted += 1; + + const source = await this.objectSourceService.readSource({ + directory: row.directory, + filename: row.filename, + }); + + const derived: any = { + 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 = String((error as Error).message || error); + 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: any): any { + 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: any) => { + 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 as any)[view]) { + viewSizes[view] += 1; + } + }); + }); + + const result: any = { + 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)', + byStatus: 'COUNT(object) 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: any): any { + 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..e765dc04 --- /dev/null +++ b/api/src/services/mall-inspection/mall-inspection.service.spec.ts @@ -0,0 +1,304 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import zlib from 'zlib'; +import { createSpyObj } from 'jest-createspyobj'; + +import { 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: any = {}) { + 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: any = {}; + 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('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('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..d0b697da --- /dev/null +++ b/api/src/services/mall-inspection/mall-inspection.service.ts @@ -0,0 +1,403 @@ +import { Service } from 'typedi'; + +import { + MallRepository, + MemberRepository, + ObjectInstanceRepository, + ObjectRepository, +} from '../../repositories'; +import { + compareWorldInfo, + ctrViewsFor, + CtrViews, + externalReferences, + 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; +} + +export interface InspectionFinding { + code: string; + message: string; +} + +export interface InspectionSource { + encoding: ObjectSourceEncoding | null; + storedBytes: number | null; + decodedBytes: number | null; + sha256: string | null; + replacementCharacters: number; + 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: InspectionFinding[] = []; + 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.replacementCharacters > 0) { + findings.push({ + code: 'encoding_warnings', + message: `The file is not valid UTF-8: ${source.replacementCharacters} ` + + 'character(s) could not be decoded.', + }); + } + } + + 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, + error: source.error, + }, + vrml, + interpreted, + comparisons, + findings, + }; + } + + /** 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): InspectionFinding { + 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[]): InspectionFinding[] { + 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, + ): InspectionFinding[] { + const findings: InspectionFinding[] = []; + + 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: InspectionFinding[] = []; + const local = textures + .filter(reference => reference.kind === 'local') + .slice(0, MAX_TEXTURE_EXISTENCE_CHECKS); + + for (const reference of local) { + const metadata = await this.objectSourceService.readAssetMetadata({ + directory, + filename: reference.value, + }); + if (metadata.error === 'missing') { + 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..cc770496 --- /dev/null +++ b/api/src/services/mall/mall.service.spec.ts @@ -0,0 +1,186 @@ +import { createSpyObj } from 'jest-createspyobj'; + +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: any[], + members: { [id: number]: any }, + stores: { [id: number]: any }, + 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: any) => 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: any) => 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: any) => 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..d9cb2f64 100644 --- a/api/src/services/mall/mall.service.ts +++ b/api/src/services/mall/mall.service.ts @@ -65,19 +65,45 @@ 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: any[]): 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 { @@ -100,18 +126,10 @@ export class MallService { } public async searchMallObjects(search: string, limit: number, offset: number): Promise { - const returnObjects = []; 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, }; } @@ -122,19 +140,11 @@ export class MallService { status: number, limit: number, offset: number): Promise { - const returnObjects = []; 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 +156,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/object-source/object-source.service.spec.ts b/api/src/services/object-source/object-source.service.spec.ts new file mode 100644 index 00000000..f9b4bdc7 --- /dev/null +++ b/api/src/services/object-source/object-source.service.spec.ts @@ -0,0 +1,264 @@ +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.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'); + }); + }); + + 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'); + }); + }); +}); 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..053e3a2f --- /dev/null +++ b/api/src/services/object-source/object-source.service.ts @@ -0,0 +1,285 @@ +import crypto from 'crypto'; +import { promises as fs } from 'fs'; +import path from 'path'; +import zlib from 'zlib'; +import { Service } from 'typedi'; + +/** + * 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 U+FFFD in the decoded text. A non-zero value usually means the file + * is not valid UTF-8; a file could in principle contain the character legitimately, + * so this is reported rather than treated as an error. + */ + replacementCharacters: number; + 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, + 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; +} + +@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 }; + } + + /** Size and hash of any object asset (thumbnail, texture) without decoding it. */ + public async readAssetMetadata(reference: ObjectAssetReference): Promise { + const resolved = this.resolveAssetPath(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 = this.resolveAssetPath(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 = zlib.gunzipSync(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, + 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, + error: 'gzip_too_large', + }; + } + } else { + decoded = stored; + } + + const text = decoded.toString('utf8'); + + return { + encoding: isGzip ? 'gzip' : 'identity', + storedBytes: stored.length, + decodedBytes: decoded.length, + sha256, + text, + replacementCharacters: countReplacementCharacters(text), + error: null, + }; + } +} diff --git a/docs/mall-export-schema.md b/docs/mall-export-schema.md new file mode 100644 index 00000000..b7126477 --- /dev/null +++ b/docs/mall-export-schema.md @@ -0,0 +1,193 @@ +# CTR Mall export schema + +`GET /api/mall/export?derived=0|1` + +One deterministic JSON document containing the Mall dataset 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:** `1.0.0` +- **Ordering:** objects ascend by `object.id`. The order is stable across runs. +- **Suggested filename:** `ctr-mall-export-YYYY-MM-DD.json` + +--- + +## 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, and they overlap by +design.** A sold-out object appears in `stocked` *and* `outOfStock`. Never collapse +them into a single status field. + +`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/MallObjectRow.vue b/spa/src/components/mall/MallObjectRow.vue new file mode 100644 index 00000000..7f45e303 --- /dev/null +++ b/spa/src/components/mall/MallObjectRow.vue @@ -0,0 +1,125 @@ + + + diff --git a/spa/src/components/mall/ObjectViewer.vue b/spa/src/components/mall/ObjectViewer.vue new file mode 100644 index 00000000..483715ba --- /dev/null +++ b/spa/src/components/mall/ObjectViewer.vue @@ -0,0 +1,297 @@ + + + diff --git a/spa/src/pages/mall/checker.vue b/spa/src/pages/mall/checker.vue index d0bf8c36..25c00442 100644 --- a/spa/src/pages/mall/checker.vue +++ b/spa/src/pages/mall/checker.vue @@ -1,62 +1,719 @@ diff --git a/spa/src/pages/mall/staff/StaffPage.vue b/spa/src/pages/mall/staff/StaffPage.vue index a28e87cc..e395aa87 100644 --- a/spa/src/pages/mall/staff/StaffPage.vue +++ b/spa/src/pages/mall/staff/StaffPage.vue @@ -1,52 +1,168 @@ - - - + + + 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..0bb848f4 --- /dev/null +++ b/spa/src/pages/mall/staff/mall-actions.mixin.ts @@ -0,0 +1,98 @@ +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. + */ +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/pending.vue b/spa/src/pages/mall/staff/pending.vue index 23400260..bad498d1 100644 --- a/spa/src/pages/mall/staff/pending.vue +++ b/spa/src/pages/mall/staff/pending.vue @@ -1,137 +1,155 @@ diff --git a/spa/src/pages/mall/staff/search.vue b/spa/src/pages/mall/staff/search.vue index 6513c3ef..7bde1392 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..83dc1846 100644 --- a/spa/src/pages/mall/staff/soldout.vue +++ b/spa/src/pages/mall/staff/soldout.vue @@ -1,115 +1,198 @@ diff --git a/spa/src/pages/mall/staff/stocked.vue b/spa/src/pages/mall/staff/stocked.vue index 5552efec..63198553 100644 --- a/spa/src/pages/mall/staff/stocked.vue +++ b/spa/src/pages/mall/staff/stocked.vue @@ -1,133 +1,136 @@ diff --git a/spa/src/pages/mall/staff/warehouse.vue b/spa/src/pages/mall/staff/warehouse.vue index 41f13baf..a3bcbf9f 100644 --- a/spa/src/pages/mall/staff/warehouse.vue +++ b/spa/src/pages/mall/staff/warehouse.vue @@ -1,146 +1,148 @@ diff --git a/spa/src/routes.ts b/spa/src/routes.ts index a32e989c..3435be43 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"; @@ -94,7 +94,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 [ { @@ -646,15 +646,6 @@ export default [ wrapper: false, }, }, - { - path: "/mall/checker/:object_id", - component: MallChecker, - name: "mall-checker", - meta: { - title: "Mall Checker", - wrapper: false, - }, - }, { path: "/mall/staff", component: MallStaffPage, @@ -704,6 +695,17 @@ export default [ title: "Mall Object Search - Mall Staff Panel", }, }, + { + // A child of the staff panel so the checker keeps the panel chrome and + // inherits its can_admin gate, 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", + }, + }, ], }, { From 595228867ae121bd2f6551c850ce5d117b6eca12 Mon Sep 17 00:00:00 2001 From: Ryan Bundy Date: Sun, 23 Aug 2026 22:09:00 -0400 Subject: [PATCH 02/19] fix: harden Mall staff workflow after review Remediation of the review findings on PR #13, plus restoration of the uploader rejection notice that the historical Mall had and CTR had lost. Export - Stream settles on drain/close/error and removes its listeners on every path, so a client that disconnects while backpressured no longer leaves export() pending forever. - All global queries moved into a preflight that runs before any header is sent, so a failure there is a clean 500 instead of a truncated body. - Failures emit a stable public error code; raw Error.message could carry absolute asset paths into a document that gets passed around. - Truncation reports the last emitted object id rather than a row count. - A catalogue of exactly MAX_OBJECTS reports complete; only a genuine overflow is truncated. Covered at MAX-1, MAX and MAX+1. - Scope is now Pending objects only (schemaVersion 2.0.0). The document is the submission queue the Mall Checker publishes to the Mall's own site, not the CTR catalogue; it says so in schema.scope. Stores stay as reference data. The export control is offered only on the Pending list. - Placement comes from the keyed store map, which already collapses to one row per object, so it cannot fan the export page out. Inspection and source - realpath containment on both the configured root and the candidate, so a symlink inside the assets root can no longer read outside it. A missing target is reported as missing rather than as an escape, and a legitimately symlinked ASSETS_DIR keeps working. - Textures referenced through a subdirectory are now checked and reported: uploads are stored flat, so such a reference can never resolve. - Findings carry a severity of info / warning / needs_staff_review, derived from the finding code in one place. needs_staff_review means the page could not establish the facts below it, not "worst"; an unrecognised code defaults there rather than being quietly downgraded. - Decompression moved off the event loop. The async form enforces maxOutputLength identically on the deployed node 14.21.3. VRML - WorldInfo declared inside a PROTO body no longer satisfies the scene-level requirement or drive comparisons. - A relative url that climbs out of the object directory is reported as an external reference; an in-directory subpath still is not. - Field prefixes must actually end at the label, so "Pricey:" is no longer read as "Price". Longer prefixes still win over shorter ones. - Numeric fields are anchored, so "USD 75" and "not 25" no longer parse. - A field declared twice with different values is UNPARSED with a note rather than silently resolving to whichever came first. Staff UI - The viewer releases the previous LoadSensor before installing the next and disposes the browser on teardown, so a long review session stops accumulating sensors watching the same Inline. - Inspection and raw-source responses are discarded if staff have already moved on, so a slow reply cannot render under another object's id. - A failed raw-source fetch is no longer cached as if it had succeeded. - The decompressed .wrl download goes through the authenticated client; a bare cannot carry the apitoken header and was rejected with 400. - Queue extension subtracts consumed objects from the offset, so acting on the last row of a page no longer skips the row it exposes. - Rows without a stored thumbnail render a placeholder instead of requesting /assets/object/undefined/undefined. - Search restores its term, limit and offset; Out of Stock clamps the page after the list shrinks; the Warehouse store lookup reports its failures. - Staff edits set isProcessing, which their buttons already bound. - The export download takes the server's timestamped filename and no longer re-serialises an already-parsed payload. Housekeeping in the files this feature touches: dead declarations removed, missing semicolons added, the `Object` model aliased so it stops shadowing the global built-in, and mall-object.repository.ts normalised to LF (it was CRLF, which was 60 linebreak-style errors on its own). No schema migration. No production state was read or written. --- api/src/controllers/mall.controller.spec.ts | 228 ++++++++++++++ api/src/controllers/mall.controller.ts | 152 ++++++++- api/src/libs/vrml/vrml-scan.spec.ts | 112 +++++++ api/src/libs/vrml/vrml-scan.ts | 67 +++- api/src/libs/vrml/worldinfo-compare.spec.ts | 67 ++++ api/src/libs/vrml/worldinfo-compare.ts | 144 +++++++-- .../mall-object/mall-object.repository.ts | 132 ++++---- .../object-instance.repository.ts | 4 +- .../repositories/object/object.repository.ts | 28 +- .../mall-export/mall-export.service.spec.ts | 295 +++++++++++++++--- .../mall-export/mall-export.service.ts | 197 ++++++++++-- .../mall-inspection.service.spec.ts | 88 +++++- .../mall-inspection.service.ts | 113 ++++++- api/src/services/mall/mall.service.ts | 3 +- .../object-source.service.spec.ts | 133 ++++++++ .../object-source/object-source.service.ts | 74 ++++- spa/src/components/mall/MallObjectRow.vue | 24 +- spa/src/components/mall/ObjectViewer.vue | 84 ++++- spa/src/pages/mall/checker.vue | 287 +++++++++++++++-- spa/src/pages/mall/staff/StaffPage.vue | 54 +++- .../pages/mall/staff/mall-actions.mixin.ts | 39 +++ spa/src/pages/mall/staff/pending.vue | 90 +++++- spa/src/pages/mall/staff/search.vue | 37 ++- spa/src/pages/mall/staff/soldout.vue | 17 +- spa/src/pages/mall/staff/warehouse.vue | 18 +- 25 files changed, 2220 insertions(+), 267 deletions(-) diff --git a/api/src/controllers/mall.controller.spec.ts b/api/src/controllers/mall.controller.spec.ts index ff7f7989..c63cd553 100644 --- a/api/src/controllers/mall.controller.spec.ts +++ b/api/src/controllers/mall.controller.spec.ts @@ -9,6 +9,7 @@ jest.mock('../db/db.class', () => import { MallController } from './mall.controller'; import { + InboxService, MallExportService, MallInspectionService, MallService, @@ -17,6 +18,7 @@ import { ObjectService, WalletService, } from '../services'; +import { PlaceRepository } from '../repositories'; function mockResponse() { const response: any = {}; @@ -49,6 +51,8 @@ describe('MallController - staff-only inspection endpoints', () => { let objectInstanceService: jest.Mocked; let mallInspectionService: jest.Mocked; let mallExportService: jest.Mocked; + let inboxService: jest.Mocked; + let placeRepository: jest.Mocked; let controller: MallController; beforeEach(() => { @@ -59,6 +63,8 @@ describe('MallController - staff-only inspection endpoints', () => { objectInstanceService = createSpyObj(ObjectInstanceService); mallInspectionService = createSpyObj(MallInspectionService); mallExportService = createSpyObj(MallExportService); + inboxService = createSpyObj(InboxService); + placeRepository = createSpyObj(PlaceRepository); controller = new MallController( memberService, mallService, @@ -67,6 +73,8 @@ describe('MallController - staff-only inspection endpoints', () => { objectInstanceService, mallInspectionService, mallExportService, + inboxService, + placeRepository, ); memberService.decodeMemberToken.mockReturnValue({ id: 7 } as never); @@ -356,4 +364,224 @@ describe('MallController - staff-only inspection endpoints', () => { expect(response.end).toHaveBeenCalled(); }); }); + + 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: any = {}): any { + return { + headers: { apitoken: 'staff-token' }, + body: { id: '3339', reason: 'WorldInfo says unlimited, the Mall limit says 25.', ...body }, + }; + } + + beforeEach(() => { + objectService.findById.mockResolvedValue({ ...OBJECT } as never); + objectService.getSellerFee.mockReturnValue(100 as never); + objectService.updateStatusRejected.mockResolvedValue(undefined as never); + objectService.performObjectUploadRefundTransaction.mockResolvedValue(undefined 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.updateStatusRejected).not.toHaveBeenCalled(); + expect(objectService.performObjectUploadRefundTransaction).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.updateStatusRejected).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.updateStatusRejected).not.toHaveBeenCalled(); + expect(inboxService.postInboxMessage).not.toHaveBeenCalled(); + }); + + it('completes the rejection and the refund before reporting success', async () => { + const response = mockResponse(); + + await controller.rejectObject(rejectRequest(), response); + + expect(objectService.updateStatusRejected).toHaveBeenCalledWith(3339); + expect(objectService.performObjectUploadRefundTransaction).toHaveBeenCalledWith(42, 100); + expect(response.status).toHaveBeenCalledWith(200); + expect(response.json).toHaveBeenCalledWith({ status: 'success', notified: true }); + }); + + it('does not report success if the rejection itself fails, and sends no notice', async () => { + objectService.updateStatusRejected.mockRejectedValue(new Error('db down') as never); + const response = mockResponse(); + + await controller.rejectObject(rejectRequest(), response); + + 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.findById.mockResolvedValue( + { ...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.updateStatusRejected).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.findById.mockResolvedValue({ ...OBJECT, status: 0 } as never); + const response = mockResponse(); + + await controller.rejectObject(rejectRequest(), response); + + expect(objectService.updateStatusRejected).not.toHaveBeenCalled(); + expect(objectService.performObjectUploadRefundTransaction).not.toHaveBeenCalled(); + 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.findById).not.toHaveBeenCalled(); + expect(inboxService.postInboxMessage).not.toHaveBeenCalled(); + }); + }); }); diff --git a/api/src/controllers/mall.controller.ts b/api/src/controllers/mall.controller.ts index 42c3c863..074c2b17 100644 --- a/api/src/controllers/mall.controller.ts +++ b/api/src/controllers/mall.controller.ts @@ -9,9 +9,40 @@ import { ObjectService, WalletService, ObjectInstanceService, + InboxService, } from '../services'; -import { createResponseWriter } from '../services/mall-export/mall-export.service'; +import { PlaceRepository } from '../repositories'; +import { + createResponseWriter, + EXPORT_ERROR_CODES, + exportFilename, +} from '../services/mall-export/mall-export.service'; // Removed unused import +/** + * 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, @@ -21,6 +52,8 @@ export class MallController { private objectInstanceService: ObjectInstanceService, private mallInspectionService: MallInspectionService, private mallExportService: MallExportService, + private inboxService: InboxService, + private placeRepository: PlaceRepository, ) {} /** @@ -65,8 +98,8 @@ export class MallController { return; } - const objectId = Number.parseInt(request.params.id, 10); - if (!Number.isFinite(objectId)) { + const objectId = parseObjectId(request.params.id); + if (objectId === null) { response.status(400).json({ error: 'Invalid object id.' }); return; } @@ -110,12 +143,32 @@ export class MallController { const includeDerived = request.query.derived === '1'; + // 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; + } + 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 }); + await this.mallExportService.export( + createResponseWriter(response), + { includeDerived }, + preflight, + ); } catch (error) { console.error(error); } finally { @@ -128,8 +181,8 @@ export class MallController { return; } - const objectId = Number.parseInt(request.params.id, 10); - if (!Number.isFinite(objectId)) { + const objectId = parseObjectId(request.params.id); + if (objectId === null) { response.status(400).json({ error: 'Invalid object id.' }); return; } @@ -359,7 +412,7 @@ export class MallController { return; } - this.objectService.updateStatusApproved( + await this.objectService.updateStatusApproved( parseInt(request.body.objectId)); response.status(200).json({ status: 'success' }); } catch (error) { @@ -443,7 +496,7 @@ export 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) { @@ -464,7 +517,7 @@ export 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) { @@ -473,6 +526,44 @@ export 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: any, + 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; @@ -485,6 +576,22 @@ export class MallController { return; } + // 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 objectRecord = await this.objectService.findById(parseInt(request.body.id)); if (!objectRecord) { response.status(400).json({ @@ -493,15 +600,31 @@ export class MallController { return; } + // A repeat request for an object that is already rejected must not refund + // a second time or send a second notice. + if (objectRecord.status === ObjectService.STATUS_DELETED) { + response.status(200).json({ status: 'success', notified: false, alreadyRejected: true }); + return; + } + const sellersFee = await this.objectService.getSellerFee( objectRecord.quantity, objectRecord.price, ); - this.objectService.updateStatusRejected(objectRecord.id); + // Awaited: success has to mean the rejection happened, not that it started. + await this.objectService.updateStatusRejected(objectRecord.id); + await this.objectService.performObjectUploadRefundTransaction( + objectRecord.member_id, + sellersFee, + ); - this.objectService.performObjectUploadRefundTransaction(objectRecord.member_id, sellersFee); - response.status(200).json({ status: 'success' }); + // The refund above cannot be undone and carries no idempotency key, so a + // failed notification must not become a 500 that invites a retry and a + // second refund. It is reported honestly instead. + const notified = await this.notifyRejection(session.id, objectRecord, reason); + + response.status(200).json({ status: 'success', notified }); } catch (error) { console.error(error); response.status(400).json({ error }); @@ -548,7 +671,6 @@ export 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); @@ -755,6 +877,8 @@ 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, @@ -763,5 +887,7 @@ export const mallController = new MallController( objectInstanceService, mallInspectionService, mallExportService, + inboxService, + placeRepository, ); diff --git a/api/src/libs/vrml/vrml-scan.spec.ts b/api/src/libs/vrml/vrml-scan.spec.ts index c9a03d6b..b15f76b1 100644 --- a/api/src/libs/vrml/vrml-scan.spec.ts +++ b/api/src/libs/vrml/vrml-scan.spec.ts @@ -353,3 +353,115 @@ describe('scanVrml - malformed input', () => { 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 index 85eadfa9..b5686b8a 100644 --- a/api/src/libs/vrml/vrml-scan.ts +++ b/api/src/libs/vrml/vrml-scan.ts @@ -54,7 +54,14 @@ 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[]; @@ -106,6 +113,8 @@ 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 { @@ -139,6 +148,33 @@ export function classifyUrl(value: string): VrmlUrlKind { 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; } @@ -224,12 +260,17 @@ export function scanVrml(text: string): VrmlScan { 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; @@ -245,7 +286,8 @@ export function scanVrml(text: string): VrmlScan { if (token.kind === 'punct') { if (token.value === '{') { // A brace not introduced by a node name - a PROTO body, for instance. - stack.push({ type: null }); + stack.push({ type: null, proto: pendingProtoBody || inProtoBody() }); + pendingProtoBody = false; } else if (token.value === '}') { if (stack.length === 0) { unbalanced = true; @@ -285,6 +327,8 @@ export function scanVrml(text: string): VrmlScan { 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, @@ -309,8 +353,9 @@ export function scanVrml(text: string): VrmlScan { } if (word === 'WorldInfo') { worldInfo.push({ title: null, info: [] }); + worldInfoIsProto.push(inProtoBody()); } - stack.push({ type: word }); + stack.push({ type: word, proto: inProtoBody() }); pendingDefName = null; index += 2; continue; @@ -355,17 +400,22 @@ export function scanVrml(text: string): VrmlScan { 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 (worldInfo.length === 0) { + if (sceneWorldInfo.length === 0) { warnings.push(FINDING_NO_WORLDINFO); } - if (worldInfo.length > 1) { + if (sceneWorldInfo.length > 1) { warnings.push(FINDING_MULTIPLE_WORLDINFO); } - if (unterminatedString || unbalanced || stack.length > 0) { + // 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) { @@ -375,7 +425,8 @@ export function scanVrml(text: string): VrmlScan { return { header, headerIsVrml97: header === VRML97_HEADER, - worldInfo, + worldInfo: sceneWorldInfo, + protoWorldInfo, nodeCounts, protoDefinitions, externProtoDefinitions, @@ -420,6 +471,8 @@ export function textureReferences(scan: VrmlScan): VrmlUrlReference[] { /** 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 => reference.kind === 'external' + || reference.kind === 'absolute' + || (reference.kind === 'relative' && escapesObjectDirectory(reference.value)), ); } diff --git a/api/src/libs/vrml/worldinfo-compare.spec.ts b/api/src/libs/vrml/worldinfo-compare.spec.ts index e9f1a17f..6fa7fcd5 100644 --- a/api/src/libs/vrml/worldinfo-compare.spec.ts +++ b/api/src/libs/vrml/worldinfo-compare.spec.ts @@ -192,3 +192,70 @@ describe('compareWorldInfo - multiple WorldInfo nodes', () => { 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 index 123401d5..50497096 100644 --- a/api/src/libs/vrml/worldinfo-compare.ts +++ b/api/src/libs/vrml/worldinfo-compare.ts @@ -78,41 +78,117 @@ interface PrefixMatch { 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 the first `info[]` entry beginning with one of `prefixes`, allowing any + * 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 | null { +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 remainder = trimmed.slice(prefix.length).replace(/^\s*:?\s*/, ''); - return { line, value: remainder }; + 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; } - 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(); } -/** Pulls the first integer out of a value such as "75 CC" or "25 max". */ +/** + * 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 = /-?\d+/.exec(value); - return match ? Number.parseInt(match[0], 10) : null; + const match = INTEGER_ENTRY.exec(value.trim()); + if (!match) { + return null; + } + return Number.parseInt(match[1].replace(/,/g, ''), 10); } function compareText( field: ComparisonField, - match: PrefixMatch | null, + 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 }; } @@ -145,7 +221,15 @@ function compareText( }; } -function comparePrice(match: PrefixMatch | null, ctrPrice: number | null): FieldComparison { +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', @@ -168,6 +252,16 @@ function comparePrice(match: PrefixMatch | null, ctrPrice: number | null): Field : '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', @@ -183,9 +277,17 @@ function comparePrice(match: PrefixMatch | null, ctrPrice: number | null): Field * 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(match: PrefixMatch | null, ctrLimit: number | null): FieldComparison { +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', @@ -289,20 +391,20 @@ export function compareWorldInfo(scan: VrmlScan, facts: MallObjectFacts): WorldI const node: WorldInfoNode = scan.worldInfo[0] || { title: null, info: [] }; const info = node.info; - const creator = findPrefixed(info, PREFIXES.creator); - const price = findPrefixed(info, PREFIXES.price); - const limit = findPrefixed(info, PREFIXES.limit); - const store = findPrefixed(info, PREFIXES.store); - const uploaded = findPrefixed(info, PREFIXES.uploaded); + 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 ? creator.value : null, - price: price ? price.value : null, - limit: limit ? limit.value : null, - store: store ? store.value : null, - uploaded: uploaded ? uploaded.value : null, + 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), diff --git a/api/src/repositories/mall-object/mall-object.repository.ts b/api/src/repositories/mall-object/mall-object.repository.ts index e4a75568..8d638d32 100644 --- a/api/src/repositories/mall-object/mall-object.repository.ts +++ b/api/src/repositories/mall-object/mall-object.repository.ts @@ -1,37 +1,36 @@ -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; - } - +import { Service } from 'typedi'; + +import { Db } from '../../db/db.class'; + +/** 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; + } + /** * The store each of many objects sits in, in one query, for list pages that @@ -57,8 +56,17 @@ export class MallRepository { /** The store every placed object sits in, in one query. */ public async getAllStoresByObjectId(): Promise<{ [objectId: number]: any }> { const stores: { [objectId: number]: any } = {}; + // 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') + .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: any) => { if (!stores[row.object_id]) { @@ -67,30 +75,30 @@ export class MallRepository { }); return stores; } - 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, - }); - } -} + 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, + }); + } +} diff --git a/api/src/repositories/object-instance/object-instance.repository.ts b/api/src/repositories/object-instance/object-instance.repository.ts index a3bdc281..81632b8e 100644 --- a/api/src/repositories/object-instance/object-instance.repository.ts +++ b/api/src/repositories/object-instance/object-instance.repository.ts @@ -223,14 +223,14 @@ 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]); } diff --git a/api/src/repositories/object/object.repository.ts b/api/src/repositories/object/object.repository.ts index 36147bbf..83e126ed 100644 --- a/api/src/repositories/object/object.repository.ts +++ b/api/src/repositories/object/object.repository.ts @@ -1,18 +1,29 @@ import { Service } from 'typedi'; import { Db } from '../../db/db.class'; -import { Object } from 'models'; +// 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 } 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; @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 }); } @@ -234,13 +245,22 @@ export class ObjectRepository { public async findViewRows(): Promise { return this.db.object .select('id', 'status', 'quantity', 'limit') + .where('status', PENDING_STATUS) .orderBy('id', 'asc'); } - /** One page of full object rows, in the export's deterministic id order. */ + /** + * One page of full object rows, in the export's deterministic id order. + * + * Pending only. The export exists so the Mall Checker can publish the + * submission queue to the Mall's own site; objects already stocked, warehoused + * or removed are not part of that, and shipping them would put the whole CTR + * catalogue in a document meant for one queue. + */ public async findPageForExport(limit: number, offset: number): Promise { return this.db.object .select('object.*') + .where('status', PENDING_STATUS) .orderBy('id', 'asc') .limit(limit) .offset(offset); diff --git a/api/src/services/mall-export/mall-export.service.spec.ts b/api/src/services/mall-export/mall-export.service.spec.ts index 38dc03ab..91c6f878 100644 --- a/api/src/services/mall-export/mall-export.service.spec.ts +++ b/api/src/services/mall-export/mall-export.service.spec.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'events'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -6,9 +7,12 @@ import { createSpyObj } from 'jest-createspyobj'; import { createResponseWriter, + ExportAborted, + exportFilename, ExportWriter, MallExportService, MAX_DURATION_MS, + MAX_OBJECTS, } from './mall-export.service'; import { ObjectSourceService } from '../object-source/object-source.service'; import { @@ -28,29 +32,33 @@ Shape { appearance Appearance { texture ImageTexture { url "moon.jpg" } } } `; /** - * Objects chosen so the fixture exercises every branch that matters: a sold-out - * object that must appear in two views at once, an object with no creator, and - * one whose file is missing from disk. + * 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; + 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: 1, created_at: '2026-08-20T08:02:43.000Z', updated_at: '2026-08-20T08:02:43.000Z', - mall_expiration: null, description: null, position: '{"x":0,"y":1.75,"z":0}', - rotation: '{"x":0,"y":0,"z":0,"angle":0}', + 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, position: null, rotation: null, + 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: 1, created_at: '2026-08-22T09:00:00.000Z', updated_at: '2026-08-22T09:00:00.000Z', - mall_expiration: null, description: null, position: null, rotation: null, + status: 2, created_at: '2026-08-22T09:00:00.000Z', updated_at: '2026-08-22T09:00:00.000Z', + mall_expiration: null, description: null, }, ]; @@ -61,10 +69,22 @@ const VIEW_ROWS = OBJECTS.map(object => ({ limit: object.limit, })); -/** Object 10 is fully sold, so it belongs to stocked AND outOfStock. */ -const COUNTS = { 10: 25, 12: 3 }; - -const STORES = { 10: { id: 1205, name: 'Toy Store', object_id: 10 } }; +/** 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[] = []; @@ -102,7 +122,8 @@ describe('MallExportService', () => { async function runExport(includeDerived = false, now?: () => number): Promise { const writer = collectingWriter(); - const status = await service.export(writer, { includeDerived, now }); + const preflight = await service.preflight(); + const status = await service.export(writer, { includeDerived, now }, preflight); return { status, raw: writer.body(), document: JSON.parse(writer.body()) }; } @@ -126,10 +147,17 @@ describe('MallExportService', () => { placeRepository = createSpyObj(PlaceRepository); sourceService = new ObjectSourceService(); - objectRepository.findViewRows.mockResolvedValue(VIEW_ROWS as never); + // 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, + ); objectRepository.findPageForExport.mockImplementation( (limit: number, offset: number) => - Promise.resolve(OBJECTS.slice(offset, offset + limit)) as never, + Promise.resolve( + OBJECTS.filter(object => object.status === PENDING_STATUS) + .slice(offset, offset + limit), + ) as never, ); objectInstanceRepository.countAllByObjectId.mockResolvedValue(COUNTS as never); mallRepository.getAllStoresByObjectId.mockResolvedValue(STORES as never); @@ -189,26 +217,39 @@ describe('MallExportService', () => { it('names the schema version and generator', async () => { const { document } = await runExport(); - expect(document.schema.schemaVersion).toBe('1.0.0'); + expect(document.schema.schemaVersion).toBe('2.0.0'); expect(document.schema.generator).toMatch(/^ctr-mall-export\//); }); }); describe('ctrViews', () => { - it('keeps stocked and outOfStock as independent, overlapping memberships', + 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.stocked).toContain(10); - expect(document.ctrViews.outOfStock).toContain(10); - expect(document.ctrViews.pending).toEqual([11]); + 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('overlap'); + expect(document.ctrViews._note).toContain('pending-only'); }); it('matches the sizes reported in the trailing result', async () => { @@ -223,7 +264,7 @@ describe('MallExportService', () => { it('reports byStatus counts that match the repository rows', async () => { const { document } = await runExport(); - expect(document.result.counts.byStatus).toEqual({ '1': 2, '2': 1 }); + expect(document.result.counts.byStatus).toEqual({ '2': 3 }); expect(document.result.counts.objects).toBe(3); }); @@ -385,69 +426,219 @@ describe('MallExportService', () => { isClosed: () => true, }; - const status = await service.export(writer, { includeDerived: false }); + const preflight = await service.preflight(); + const status = await service.export(writer, { includeDerived: false }, preflight); expect(status).toBe('failed'); expect(objectRepository.findPageForExport).not.toHaveBeenCalled(); }); }); + + 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: any) => 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: any) => 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: any) => 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): any[] { + 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); + objectRepository.findPageForExport.mockImplementation( + (limit: number, offset: number) => + Promise.resolve(rows.slice(offset, offset + limit)) as never, + ); + objectRepository.findViewRows.mockResolvedValue([] 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', () => { - function fakeResponse(writeResults: boolean[]) { - const drains: (() => void)[] = []; - return { - written: [] as string[], - writableEnded: false, - destroyed: false, - write(chunk: string) { - this.written.push(chunk); - return writeResults.length ? writeResults.shift() : true; - }, - once(event: string, handler: () => void) { - if (event === 'drain') { - drains.push(handler); - } - }, - flush() { - drains.splice(0).forEach(handler => handler()); - }, - pendingDrains: () => drains.length, - }; + /** + * 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 = fakeResponse([true]); + const response = new FakeResponse([true]); await createResponseWriter(response).write('chunk'); expect(response.written).toEqual(['chunk']); + expect(response.waiters()).toBe(0); }); - it('waits for drain when the socket signals backpressure', async () => { - const response = fakeResponse([false]); - const writer = createResponseWriter(response); + it('continues once the socket drains', async () => { + const response = new FakeResponse([false]); let settled = false; - const pending = writer.write('big chunk').then(() => { + const pending = createResponseWriter(response).write('big chunk').then(() => { settled = true; }); await Promise.resolve(); expect(settled).toBe(false); - expect(response.pendingDrains()).toBe(1); + expect(response.waiters()).toBe(3); - response.flush(); + 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 = fakeResponse([]); + 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 index 41c398e4..a3faa8ff 100644 --- a/api/src/services/mall-export/mall-export.service.ts +++ b/api/src/services/mall-export/mall-export.service.ts @@ -36,7 +36,7 @@ import { ObjectSourceService } from '../object-source/object-source.service'; * catalogue. */ -export const EXPORT_SCHEMA_VERSION = '1.0.0'; +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. */ @@ -55,6 +55,14 @@ export interface ExportWriter { isClosed(): boolean; } +/** Everything global the document needs, gathered before the body opens. */ +export interface ExportPreflight { + stores: any[]; + viewRows: any[]; + allCounts: { [objectId: string]: number }; + allStores: { [objectId: string]: any }; +} + export interface ExportOptions { includeDerived: boolean; /** Injected so specs can drive the clock rather than wait on it. */ @@ -68,20 +76,96 @@ export interface ExportOptions { * 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: any): 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(); } - return new Promise(resolve => response.once('drain', resolve)); - }, - isClosed(): boolean { - return !!(response.writableEnded || response.destroyed); + // 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`; +} + function assetUrl(directory: string | null, filename: string | null): string | null { if (!directory || !filename) { return null; @@ -100,14 +184,37 @@ export class MallExportService { private objectSourceService: ObjectSourceService, ) {} - public async export(writer: ExportWriter, options: ExportOptions): Promise { + /** + * 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(); + const allCounts = await this.objectInstanceRepository.countAllByObjectId(); + const allStores = await this.mallRepository.getAllStoresByObjectId(); + return { stores, viewRows, allCounts, allStores }; + } + + public async export( + writer: ExportWriter, + options: ExportOptions, + preflight: ExportPreflight, + ): Promise { const now = options.now || (() => Date.now()); const startedAt = now(); const startedIso = new Date(startedAt).toISOString(); + const { stores, viewRows, allCounts, allStores } = preflight; + let status: ExportStatus = 'complete'; let truncation: any = null; let objectsWritten = 0; + let bodyStarted = false; + let objectsOpened = false; const derivedTally = { attempted: 0, succeeded: 0, @@ -117,8 +224,8 @@ export class MallExportService { try { await writer.write(`{"schema":${JSON.stringify(this.buildSchema(startedIso, options))}`); + bodyStarted = true; - const stores = await this.placeRepository.findAllStores('name'); await writer.write(`,"stores":${JSON.stringify(stores.map((store: any) => ({ id: store.id, name: store.name, @@ -126,15 +233,14 @@ export class MallExportService { status: store.status, })))}`); - const viewRows = await this.objectRepository.findViewRows(); - const allCounts = await this.objectInstanceRepository.countAllByObjectId(); await writer.write(`,"ctrViews":${JSON.stringify(this.buildViews(viewRows, allCounts))}`); - const allStores = await this.mallRepository.getAllStoresByObjectId(); - await writer.write(',"objects":['); + objectsOpened = true; + let offset = 0; let first = true; + let lastObjectId: number | null = null; for (;;) { if (writer.isClosed()) { @@ -151,14 +257,17 @@ export class MallExportService { }; break; } - if (objectsWritten >= MAX_OBJECTS) { - status = 'truncated'; - truncation = { reason: 'object_cap', limit: MAX_OBJECTS, lastObjectId: null }; + const page = await this.objectRepository.findPageForExport(PAGE_SIZE, offset); + if (!page.length) { break; } - const page = await this.objectRepository.findPageForExport(PAGE_SIZE, offset); - if (!page.length) { + // 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; } @@ -177,19 +286,17 @@ export class MallExportService { await writer.write(`${first ? '' : ','}\n${JSON.stringify(entry)}`); first = false; objectsWritten += 1; - if (truncation) { - truncation.lastObjectId = row.id; - } + lastObjectId = row.id; } - if (truncation) { - truncation.lastObjectId = page[page.length - 1].id; - } offset += PAGE_SIZE; } - if (truncation && truncation.lastObjectId === null && objectsWritten > 0) { - truncation.lastObjectId = objectsWritten; + // 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 = lastObjectId; } await writer.write(']'); @@ -207,13 +314,23 @@ export class MallExportService { derivedTally, }))}}`); } 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. - status = 'failed'; + // `objects` may not have been opened yet, in which case an empty array is + // what keeps the document parseable. try { - await writer.write(`],"result":${JSON.stringify({ + const tail = objectsOpened ? ']' : ',"objects":[]'; + await writer.write(`${tail},"result":${JSON.stringify({ status: 'failed', - reason: String((error as Error).message || error), + reason: publicErrorCode(error), finishedAt: new Date(now()).toISOString(), objectsWritten, })}}`); @@ -231,6 +348,14 @@ export class MallExportService { 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")', @@ -270,9 +395,12 @@ export class MallExportService { private buildViews(viewRows: any[], counts: { [id: number]: number }): any { const views: any = { _definitions: CTR_VIEW_DEFINITIONS, - _note: 'Current CTR staff-panel view memberships, derived rather than stored. They ' - + 'overlap by design. outOfStock reproduces the Out of Stock page exactly, ' - + 'including its treatment of a zero limit.', + _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: [], @@ -315,8 +443,13 @@ export class MallExportService { status: row.status, statusName: statusName(row.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(row.position), rotation: this.parseJson(row.rotation) } + ? { + position: this.parseJson(context.store.mall_position), + rotation: this.parseJson(context.store.mall_rotation), + } : null, ctrViews: ctrViewsFor({ status: row.status, @@ -424,7 +557,7 @@ export class MallExportService { derived.warnings = scan.warnings; tally.succeeded += 1; } catch (error) { - derived.parseError = String((error as Error).message || error); + derived.parseError = EXPORT_ERROR_CODES.sourceUnreadable; tally.failed += 1; tally.failuresByReason.parse_error = (tally.failuresByReason.parse_error || 0) + 1; } diff --git a/api/src/services/mall-inspection/mall-inspection.service.spec.ts b/api/src/services/mall-inspection/mall-inspection.service.spec.ts index e765dc04..ac76c940 100644 --- a/api/src/services/mall-inspection/mall-inspection.service.spec.ts +++ b/api/src/services/mall-inspection/mall-inspection.service.spec.ts @@ -4,7 +4,7 @@ import path from 'path'; import zlib from 'zlib'; import { createSpyObj } from 'jest-createspyobj'; -import { MallInspectionService } from './mall-inspection.service'; +import { findingSeverity, MallInspectionService } from './mall-inspection.service'; import { ObjectSourceService } from '../object-source/object-source.service'; import { MallRepository, @@ -220,6 +220,92 @@ 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" } diff --git a/api/src/services/mall-inspection/mall-inspection.service.ts b/api/src/services/mall-inspection/mall-inspection.service.ts index d0b697da..a03a01fd 100644 --- a/api/src/services/mall-inspection/mall-inspection.service.ts +++ b/api/src/services/mall-inspection/mall-inspection.service.ts @@ -11,6 +11,7 @@ import { ctrViewsFor, CtrViews, externalReferences, + escapesObjectDirectory, FieldComparison, InterpretedWorldInfo, scanVrml, @@ -51,11 +52,76 @@ export interface InspectionAsset { 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; @@ -155,7 +221,7 @@ export class MallInspectionService { filename: record.filename, }); - const findings: InspectionFinding[] = []; + const findings: RawFinding[] = []; let vrml: InspectionVrml | null = null; let interpreted: InterpretedWorldInfo | null = null; let comparisons: FieldComparison[] | null = null; @@ -254,7 +320,10 @@ export class MallInspectionService { vrml, interpreted, comparisons, - findings, + findings: findings.map(finding => ({ + ...finding, + severity: findingSeverity(finding.code), + })), }; } @@ -276,7 +345,7 @@ export class MallInspectionService { return { text: source.text, error: source.error }; } - private describeSourceError(error: ObjectSourceError): InspectionFinding { + 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.', @@ -292,7 +361,7 @@ export class MallInspectionService { return { code: error, message: messages[error] }; } - private describeScanWarnings(warnings: string[]): InspectionFinding[] { + 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.', @@ -316,8 +385,8 @@ export class MallInspectionService { private describeRuleObservations( vrml: InspectionVrml, decodedBytes: number | null, - ): InspectionFinding[] { - const findings: InspectionFinding[] = []; + ): RawFinding[] { + const findings: RawFinding[] = []; FORBIDDEN_NODES.forEach(node => { const count = vrml.nodeCounts[node] || 0; @@ -370,18 +439,38 @@ export class MallInspectionService { directory: string, textures: VrmlUrlReference[], recordedTexture: string | null, - ): Promise { - const findings: InspectionFinding[] = []; - const local = textures - .filter(reference => reference.kind === 'local') + ): 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 local) { + for (const reference of checkable) { const metadata = await this.objectSourceService.readAssetMetadata({ directory, filename: reference.value, }); - if (metadata.error === 'missing') { + // 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 ` diff --git a/api/src/services/mall/mall.service.ts b/api/src/services/mall/mall.service.ts index d9cb2f64..fb59c2a5 100644 --- a/api/src/services/mall/mall.service.ts +++ b/api/src/services/mall/mall.service.ts @@ -10,7 +10,6 @@ import { MemberRepository, } from '../../repositories'; import { MallObjectPosition, MallObjectRotation } from 'models'; -import {orderBy} from 'lodash'; /** Service for dealing with the mall */ @Service() @@ -114,7 +113,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); } diff --git a/api/src/services/object-source/object-source.service.spec.ts b/api/src/services/object-source/object-source.service.spec.ts index f9b4bdc7..0f32bbdf 100644 --- a/api/src/services/object-source/object-source.service.spec.ts +++ b/api/src/services/object-source/object-source.service.spec.ts @@ -261,4 +261,137 @@ describe('ObjectSourceService', () => { 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 index 053e3a2f..0bf88a25 100644 --- a/api/src/services/object-source/object-source.service.ts +++ b/api/src/services/object-source/object-source.service.ts @@ -1,9 +1,22 @@ import crypto from 'crypto'; import { promises as fs } from 'fs'; import path from 'path'; +import util 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. @@ -154,9 +167,64 @@ export class ObjectSourceService { 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 = this.resolveAssetPath(reference); + const resolved = await this.resolveRealAssetPath(reference); if (resolved.error !== null) { return { bytes: null, sha256: null, error: resolved.error }; } @@ -200,7 +268,7 @@ export class ObjectSourceService { * unreadable upload must never take down a checker page or an export. */ public async readSource(reference: ObjectAssetReference): Promise { - const resolved = this.resolveAssetPath(reference); + const resolved = await this.resolveRealAssetPath(reference); if (resolved.error !== null) { return failure(resolved.error); } @@ -237,7 +305,7 @@ export class ObjectSourceService { let decoded: Buffer; if (isGzip) { try { - decoded = zlib.gunzipSync(stored, { maxOutputLength: MAX_DECODED_BYTES }); + decoded = await gunzip(stored, { maxOutputLength: MAX_DECODED_BYTES }); } catch (error) { const code = (error as NodeJS.ErrnoException).code; const tooLarge = code === 'ERR_BUFFER_TOO_LARGE' diff --git a/spa/src/components/mall/MallObjectRow.vue b/spa/src/components/mall/MallObjectRow.vue index 7f45e303..6d869161 100644 --- a/spa/src/components/mall/MallObjectRow.vue +++ b/spa/src/components/mall/MallObjectRow.vue @@ -3,9 +3,15 @@
- +
+ No thumbnail stored +
@@ -94,8 +100,20 @@ export default Vue.extend({ }, }, computed: { - thumbnailUrl(): string { - return `/assets/object/${this.object.directory}/${this.object.image}`; + /** + * Null when either half of the path is absent. + * + * Interpolating regardless produces `/assets/object/undefined/undefined`, + * which is a real request that 404s and leaves a broken-image icon in the + * row -- indistinguishable, at a glance, from an object whose thumbnail + * failed to upload. + */ + thumbnailUrl(): string | null { + const { directory, image } = this.object; + if (!directory || !image) { + return null; + } + return `/assets/object/${directory}/${image}`; }, storeName(): string { return this.object.store ? this.object.store.name : "-"; diff --git a/spa/src/components/mall/ObjectViewer.vue b/spa/src/components/mall/ObjectViewer.vue index 483715ba..71a66073 100644 --- a/spa/src/components/mall/ObjectViewer.vue +++ b/spa/src/components/mall/ObjectViewer.vue @@ -61,6 +61,15 @@ interface ViewerInternals { element: HTMLElement | null; /** The Inline whose url is swapped as the checker moves between objects. */ objectInline: any; + /** + * The LoadSensor watching `objectInline`, and the key its callback is + * registered under. Held so the previous one can be released before the next + * is installed -- a checker session walks through many objects, and without + * this every one of them leaves a sensor in the scene graph watching the same + * Inline, plus a retained callback closing over this component. + */ + objectSensor: any; + objectSensorKey: string | null; backstop: number | null; /** * Incremented on every load. Callbacks already in flight for a previous object @@ -84,6 +93,8 @@ function internalsFor(component: Vue): ViewerInternals { browser: null, element: null, objectInline: null, + objectSensor: null, + objectSensorKey: null, backstop: null, generation: 0, }; @@ -237,10 +248,17 @@ export default Vue.extend({ }, watchObject(generation: number, scene: any, object: any) { + // The generation check below suppresses a stale callback's *effects*; it + // does not release the node. Both have to happen or the scene grows for + // the length of the review session. + this.releaseSensor(scene); + + const own = internalsFor(this); + const key = `${this.callbackKey}-${generation}`; const sensor = scene.createNode("LoadSensor"); sensor.timeOut = LOAD_TIMEOUT_SECONDS; sensor.watchList = new X3D.MFNode(object); - sensor.addFieldCallback("isLoaded", `${this.callbackKey}-${generation}`, (loaded: any) => { + sensor.addFieldCallback("isLoaded", key, (loaded: any) => { if (!this.isCurrent(generation)) { return; // belongs to an object the checker has already moved past } @@ -251,6 +269,40 @@ export default Vue.extend({ } }); scene.addRootNode(sensor); + own.objectSensor = sensor; + own.objectSensorKey = key; + }, + + /** + * Detaches and disposes the sensor installed by the previous load. + * + * Wrapped because this runs during teardown as well, when the browser may + * already be tearing its own scene down; a throw here would abandon the rest + * of the cleanup, which is worse than the leak it is trying to prevent. + */ + releaseSensor(scene: any) { + const own = internalsFor(this); + const sensor = own.objectSensor; + if (!sensor) { + return; + } + own.objectSensor = null; + const key = own.objectSensorKey; + own.objectSensorKey = null; + + try { + if (key) { + sensor.removeFieldCallback("isLoaded", key); + } + if (scene) { + scene.removeRootNode(sensor); + } + if (typeof sensor.dispose === "function") { + sensor.dispose(); + } + } catch (error) { + // Already gone, which is the state we wanted. + } }, succeed(generation: number) { @@ -280,17 +332,41 @@ export default Vue.extend({ } }, - /** Drops the canvas when the checker itself goes away. */ + /** + * Drops the canvas when the checker itself goes away. + * + * Removing the element alone leaves X_ITE's browser, its scene graph and its + * render loop alive with nothing pointing at them. The checker is a + * long-lived page that mounts this component per object, so that is a real + * accumulation rather than a theoretical one. + */ teardown() { this.clearBackstop(); const own = internalsFor(this); own.generation += 1; + + const browser = own.browser; + try { + this.releaseSensor(browser && browser.currentScene); + } catch (error) { + // Nothing left to release. + } + + own.objectInline = null; + own.browser = null; + + if (browser && typeof browser.dispose === "function") { + try { + browser.dispose(); + } catch (error) { + // The browser never finished starting up; there is nothing to dispose. + } + } + if (own.element && own.element.parentNode) { own.element.parentNode.removeChild(own.element); } own.element = null; - own.browser = null; - own.objectInline = null; }, }, }); diff --git a/spa/src/pages/mall/checker.vue b/spa/src/pages/mall/checker.vue index 25c00442..f8ad8af3 100644 --- a/spa/src/pages/mall/checker.vue +++ b/spa/src/pages/mall/checker.vue @@ -74,12 +74,17 @@
Nothing flagged. Size, position and content still need your eye.
-
    -
  • - {{ finding.message }} - ({{ finding.code }}) -
  • -
+
Advisory only. Nothing here accepts or rejects anything.
@@ -191,9 +196,9 @@ - - Download decompressed .wrl - + Original stored bytes -
{{ rawSourceError }}

+
{{ rawSource }}
-
+
+ + + +
Staff actions @@ -227,6 +251,13 @@ {{ actionError }} {{ actionSuccess }} +
+ +

{{ actionWarning }}

@@ -238,6 +269,11 @@ import Vue from "vue"; import ObjectViewer from "@/components/mall/ObjectViewer.vue"; +import { + REJECT_REASON_MAX, + objectDisplayName, + rejectReasonError, +} from "@/pages/mall/staff/mall-actions.mixin"; /** * The Mall staff review workspace. @@ -249,6 +285,8 @@ import ObjectViewer from "@/components/mall/ObjectViewer.vue"; */ /** Which list a checker arrived from, and the object status that list shows. */ + + const LIST_STATUS: { [key: string]: number } = { pending: 2, warehouse: 3, @@ -293,10 +331,26 @@ export default Vue.extend({ loadError: "", inspection: null, rawSource: "", + rawSourceError: "", + isDownloading: false, + /** + * The object each in-flight fetch was issued for. + * + * Staff move through the queue faster than an inspection round-trips, and + * responses are not guaranteed to arrive in the order they were sent. A + * slow response for a previous object would otherwise be rendered under + * the current object's id -- the worst possible failure for a page whose + * whole job is deciding whether to accept or reject what is on screen. + */ + inspectionFor: null as number | null, + rawSourceFor: null as number | null, showRawSource: false, isProcessing: false, actionError: "", actionSuccess: "", + actionWarning: "", + rejectReason: "", + rejectReasonMax: REJECT_REASON_MAX, queue: { ids: [], consumed: [], @@ -374,6 +428,36 @@ export default Vue.extend({ nextId(): number { return this.neighbour(1); }, + /** + * Findings grouped by severity, most consequential first. + * + * "Needs staff review" leads because it means the rest of this page could + * not be established -- a checker who reads past it is trusting facts the + * inspection never actually proved. Empty groups are dropped rather than + * rendered as reassuring empty headings. + */ + findingGroups(): any[] { + const order = [ + { + severity: "needs_staff_review", + label: "Needs staff review", + className: "text-yellow-400", + }, + { severity: "warning", label: "Warnings", className: "text-orange-400" }, + { severity: "info", label: "Information", className: "opacity-70" }, + ]; + const findings = (this.inspection && this.inspection.findings) || []; + return order + .map(group => ({ + ...group, + // Findings from an older API build carry no severity; treating them as + // needing review matches the server's own fallback. + findings: findings.filter((finding: any) => + (finding.severity || "needs_staff_review") === group.severity), + })) + .filter(group => group.findings.length > 0); + }, + queueLabel(): string { if (this.currentIndex < 0 || !this.queue.total) { return ""; @@ -384,16 +468,18 @@ export default Vue.extend({ ownUrl(): string { return `/#${this.$route.fullPath}`; }, - downloadUrl(): string { - return `/api/mall/object/${this.objectId}/source?download=1`; - }, + }, watch: { objectId() { this.actionError = ""; this.actionSuccess = ""; + this.actionWarning = ""; + // The reason belongs to the object it was written about. + this.rejectReason = ""; this.showRawSource = false; this.rawSource = ""; + this.rawSourceError = ""; this.loadInspection(); }, }, @@ -421,10 +507,18 @@ export default Vue.extend({ this.loadError = "That is not a valid object id."; return; } + const requestedFor = this.objectId; + this.inspectionFor = requestedFor; try { - const response = await this.$http.get(`/mall/object/${this.objectId}/inspection`); + const response = await this.$http.get(`/mall/object/${requestedFor}/inspection`); + if (this.inspectionFor !== requestedFor) { + return; // staff have already moved to another object + } this.inspection = response.data.inspection; } catch (errorResponse: any) { + if (this.inspectionFor !== requestedFor) { + return; + } const status = errorResponse.response && errorResponse.response.status; this.loadError = status === 404 ? "That object no longer exists." @@ -516,9 +610,21 @@ export default Vue.extend({ return false; } + // Every consumed object has left the status list this queue pages through + // (only Accept and Reject mark one consumed, and both change its status), + // so the server's result set has shifted left by that many rows. Paging + // forward by the captured length would step past exactly that many + // objects: capture [1..10], reject 10, and a raw offset of 10 lands on the + // twelfth object, silently skipping 11. + // + // Backward extension needs no such adjustment: removing a row shifts only + // the rows after it, and everything consumed sits at or after this.offset. + const consumedInQueue = this.queue.ids + .filter((id: number) => this.queue.consumed.indexOf(id) !== -1).length; + const offset = direction < 0 ? Math.max(this.queue.offset - this.queue.limit, 0) - : this.queue.offset + this.queue.ids.length; + : Math.max(this.queue.offset + this.queue.ids.length - consumedInQueue, 0); const page = await this.fetchQueuePage(status, offset, this.queue.limit); if (!page.ids.length) { @@ -530,11 +636,27 @@ export default Vue.extend({ return false; } + // Another staff member acting on the same list concurrently can shift the + // result set further than our own consumption accounts for, which would + // hand back a row already captured. Duplicate ids would break navigation, + // which is indexOf-based, so they are dropped rather than appended. + const known = this.queue.ids; + const fresh = page.ids.filter((id: number) => known.indexOf(id) === -1); + + if (!fresh.length) { + if (direction < 0) { + this.queue.exhaustedBefore = true; + } else { + this.queue.exhaustedAfter = true; + } + return false; + } + if (direction < 0) { - this.queue.ids = page.ids.concat(this.queue.ids); + this.queue.ids = fresh.concat(this.queue.ids); this.queue.offset = offset; } else { - this.queue.ids = this.queue.ids.concat(page.ids); + this.queue.ids = this.queue.ids.concat(fresh); } this.queue.total = page.total; return true; @@ -545,11 +667,62 @@ export default Vue.extend({ if (!this.showRawSource || this.rawSource) { return; } + this.rawSourceError = ""; + const requestedFor = this.objectId; + this.rawSourceFor = requestedFor; try { - const response = await this.$http.get(`/mall/object/${this.objectId}/source`); + const response = await this.$http.get(`/mall/object/${requestedFor}/source`); + if (this.rawSourceFor !== requestedFor) { + return; + } this.rawSource = response.data; } catch (error) { - this.rawSource = "The source of this object could not be decoded."; + if (this.rawSourceFor !== requestedFor) { + return; + } + // Shown, but deliberately not stored in `rawSource`: caching the failure + // there makes the `this.rawSource` short-circuit above treat it as a + // successful fetch, so a transient error would never be retried. + this.rawSourceError = "The source of this object could not be decoded."; + } + }, + + /** + * Saves the decompressed source through the authenticated client. + * + * `/mall/object/:id/source` is behind `requireMallStaff`, which authorises + * from the `apitoken` request header alone. A plain `` is a browser + * navigation and cannot carry that header, so linking straight at the + * endpoint returns 400 even for a signed-in staff member; the bytes have to + * come back through the api client and be saved from memory instead. + */ + async downloadSource(): Promise { + if (this.isDownloading) { + return; + } + this.isDownloading = true; + this.rawSourceError = ""; + const requestedFor = this.objectId; + let url = ""; + try { + const response = await this.$http.get(`/mall/object/${requestedFor}/source`); + const blob = new Blob([response.data], { type: "model/vrml" }); + url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + // Matches the name the server sends for `?download=1`: never the + // member-supplied object name, never the stored filename. + link.download = `object-${requestedFor}.wrl`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } catch (error) { + this.rawSourceError = "The source of this object could not be downloaded."; + } finally { + if (url) { + window.URL.revokeObjectURL(url); + } + this.isDownloading = false; } }, @@ -561,29 +734,77 @@ export default Vue.extend({ }, confirmReject(): void { - if (!window.confirm(`Reject "${this.object.name}" (#${this.object.id})?`)) { + const reason = this.rejectReason.trim(); + const invalid = rejectReasonError(reason); + if (invalid) { + this.actionError = invalid; + this.actionSuccess = ""; + this.actionWarning = ""; + return; + } + if (!window.confirm( + `Reject "${this.object.name}" (#${this.object.id})?\n\n` + + `The uploader will be sent this reason:\n\n${reason}`, + )) { return; } - this.performAction("/mall/reject", { id: this.object.id }, "Object rejected."); + this.rejectObject(reason); }, - async performAction(endpoint: string, body: any, success: string): Promise { - if (this.isProcessing) { + /** + * Rejection has three outcomes, and they must not be conflated. + * + * A failure leaves the reason typed and the object in place so it can be + * retried. A success that could not notify is still a completed rejection -- + * the refund has already happened -- so it advances like any other success + * and warns rather than inviting a second Reject. + */ + async rejectObject(reason: string): Promise { + // Captured before the request, because a success advances the queue and + // `this.object` is then the next object rather than the rejected one. + const rejectedName = objectDisplayName(this.object); + + const data = await this.performAction( + "/mall/reject", + { id: this.object.id, reason }, + "Object rejected and the uploader notified.", + ); + + if (this.actionError) { return; } + + this.rejectReason = ""; + if (data && data.notified === false) { + // Set after the queue has advanced, so navigation does not clear it. + this.actionSuccess = "Object rejected."; + this.actionWarning = `${rejectedName} was rejected, but the uploader ` + + "could not be notified. Follow up manually."; + } + }, + + /** Returns the response body so a caller can act on what the server reported. */ + async performAction(endpoint: string, body: any, success: string): Promise { + if (this.isProcessing) { + return null; + } this.isProcessing = true; this.actionError = ""; this.actionSuccess = ""; + this.actionWarning = ""; + let data: any = null; try { - await this.$http.post(endpoint, body); + const response: any = await this.$http.post(endpoint, body); + data = response && response.data; this.actionSuccess = success; await this.advancePastCurrent(); } catch (errorResponse: any) { - const data = errorResponse.response && errorResponse.response.data; - this.actionError = (data && data.error) || "An unknown error occurred"; + const errorData = errorResponse.response && errorResponse.response.data; + this.actionError = (errorData && errorData.error) || "An unknown error occurred"; } finally { this.isProcessing = false; } + return data; }, /** @@ -648,9 +869,19 @@ export default Vue.extend({ ); }, + /** + * The buttons already bind `:disabled="isProcessing"`, but nothing here ever + * set it -- so an edit left them live and a second click sent a second + * mutation against the same object while the first was still in flight. + */ async performStaffEdit(endpoint: string, body: any, success: string): Promise { + if (this.isProcessing) { + return; + } + this.isProcessing = true; this.actionError = ""; this.actionSuccess = ""; + this.actionWarning = ""; try { await this.$http.post(endpoint, body); this.actionSuccess = success; @@ -658,6 +889,8 @@ export default Vue.extend({ } catch (errorResponse: any) { const data = errorResponse.response && errorResponse.response.data; this.actionError = (data && data.error) || "An unknown error occurred"; + } finally { + this.isProcessing = false; } }, diff --git a/spa/src/pages/mall/staff/StaffPage.vue b/spa/src/pages/mall/staff/StaffPage.vue index e395aa87..6bcfb709 100644 --- a/spa/src/pages/mall/staff/StaffPage.vue +++ b/spa/src/pages/mall/staff/StaffPage.vue @@ -21,7 +21,13 @@ Search

-
+ +

@@ -83,6 +89,18 @@ export default Vue.extend({ this.loaded = true; this.isMallStaff(); }, + computed: { + onPendingList(): boolean { + return this.$route.name === "MallPending"; + }, + }, + watch: { + onPendingList(pending: boolean) { + if (!pending) { + this.showExport = false; + } + }, + }, methods: { async isMallStaff() { try { @@ -134,7 +152,7 @@ export default Vue.extend({ return; } - this.saveExport(payload); + this.saveExport(payload, response.headers); this.showExport = false; } catch (error) { this.exportError = "The export could not be completed. Not saved."; @@ -143,25 +161,41 @@ export default Vue.extend({ } }, - saveExport(payload: any): void { - const blob = new Blob([JSON.stringify(payload, null, 1)], { + saveExport(payload: any, headers: any): void { + // Serialised compactly, matching what the server streamed. Re-indenting it + // built a third full copy of the largest string in the app -- the raw + // response, the parsed object, and then a pretty-printed rebuild of it -- + // for a file that is read by tooling rather than by eye. + const blob = new Blob([JSON.stringify(payload)], { type: "application/json", }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; - link.download = `ctr-mall-export-${this.today()}.json`; + link.download = this.exportFilename(headers); document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); }, - today(): string { - const now = new Date(); - const month = String(now.getMonth() + 1).padStart(2, "0"); - const day = String(now.getDate()).padStart(2, "0"); - return `${now.getFullYear()}-${month}-${day}`; + /** + * Prefers the name the server already chose. + * + * `exportMallData` sends a Content-Disposition whose stamp is precise to the + * second, so two exports taken minutes apart are distinct files. The local + * fallback matches that precision rather than the old date-only name, which + * collided for every export after the first on any given day. + */ + exportFilename(headers: any): string { + const disposition = headers && (headers["content-disposition"] + || headers["Content-Disposition"]); + const match = /filename="([^"]+)"/.exec(String(disposition || "")); + if (match && /^[\w.-]+$/.test(match[1])) { + return match[1]; + } + const stamp = new Date().toISOString().split(".")[0].replace(/:/g, ""); + return `ctr-mall-export-${stamp}Z.json`; }, }, }); diff --git a/spa/src/pages/mall/staff/mall-actions.mixin.ts b/spa/src/pages/mall/staff/mall-actions.mixin.ts index 0bb848f4..e4b4fb98 100644 --- a/spa/src/pages/mall/staff/mall-actions.mixin.ts +++ b/spa/src/pages/mall/staff/mall-actions.mixin.ts @@ -12,6 +12,45 @@ import Vue from "vue"; * 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 { diff --git a/spa/src/pages/mall/staff/pending.vue b/spa/src/pages/mall/staff/pending.vue index bad498d1..dd49e26a 100644 --- a/spa/src/pages/mall/staff/pending.vue +++ b/spa/src/pages/mall/staff/pending.vue @@ -62,7 +62,7 @@ @click="approve(object.id)">Accept + @click="openReject(object)">Reject
@@ -77,16 +77,48 @@
+ + + + + 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/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 b05a6ded..a4360fb5 100644 --- a/spa/src/pages/mall/staff/pending.vue +++ b/spa/src/pages/mall/staff/pending.vue @@ -115,6 +115,7 @@ import mallActions, { objectDisplayName, rejectReasonError, } from "./mall-actions.mixin"; +import mallStaffState from "./mall-staff-state"; export default mallActions.extend({ name: "MallPending", @@ -155,6 +156,12 @@ export default mallActions.extend({ this.isMallStaff(); this.getResults(); }, + destroyed(): void { + // Leaving the list makes its count meaningless; clearing it stops the + // export control from briefly reappearing on a later visit with the + // previous visit's number. + mallStaffState.pendingCount = null; + }, methods: { /** Reads page, limit and sort back out of the URL on load or browser Back. */ restoreListState(): void { @@ -204,6 +211,9 @@ export default mallActions.extend({ orderBy: this.orderBy, }); this.totalCount = response.data.objects.total[0].count; + // Published for the export control in the right-hand panel, which is + // offered only when this queue actually has something in it. + mallStaffState.pendingCount = this.totalCount; this.objects = response.data.objects.objects; this.showSuccess = true; const pages = Math.ceil(this.totalCount / this.limit); @@ -223,10 +233,18 @@ export default mallActions.extend({ try { this.error = ""; this.showError = false; - await this.$http.post("/mall/approve", { + const response: any = await this.$http.post("/mall/approve", { objectId: objectId, }); - this.success = "Object Approved"; + const data = response && response.data; + if (data && data.alreadyAccepted) { + this.success = "Object was already accepted."; + } else if (data && data.notified === false) { + this.success = "Object accepted, but the uploader could not be notified. " + + "Follow up manually."; + } else { + this.success = "Object accepted and the uploader notified."; + } this.showSuccess = true; this.getResults(); } catch (errorResponse: any) { diff --git a/spa/src/pages/mall/staff/warehouse.vue b/spa/src/pages/mall/staff/warehouse.vue index 670c8860..d244d02a 100644 --- a/spa/src/pages/mall/staff/warehouse.vue +++ b/spa/src/pages/mall/staff/warehouse.vue @@ -2,6 +2,12 @@
{{ error }}
+ +
{{ success }}
Mall Warehouse
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 3435be43..de553a17 100644 --- a/spa/src/routes.ts +++ b/spa/src/routes.ts @@ -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"; @@ -647,20 +648,31 @@ export default [ }, }, { + // 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, }, }, { @@ -669,6 +681,7 @@ export default [ name: "MallPending", meta: { title: "Mall Object Pending - Mall Staff Panel", + wrapper: true, }, }, { @@ -677,6 +690,7 @@ export default [ name: "MallStocked", meta: { title: "Mall Object Stocked - Mall Staff Panel", + wrapper: true, }, }, { @@ -685,6 +699,7 @@ export default [ name: "MallSoldOut", meta: { title: "Mall Object Sold Out - Mall Staff Panel", + wrapper: true, }, }, { @@ -693,17 +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 keeps the panel chrome and - // inherits its can_admin gate, rather than opening as a bare popup with - // no way back to the list. + // 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 index 74ac3cbb..f32fdab1 100644 --- a/spa/test/blob-download-revocation.test.js +++ b/spa/test/blob-download-revocation.test.js @@ -18,12 +18,12 @@ const assert = require("assert"); const { loadComponentOptions } = require("./support/load-vue-options"); const CHECKER_PATH = path.join(__dirname, "..", "src", "pages", "mall", "checker.vue"); -const STAFF_PAGE_PATH = path.join( - __dirname, "..", "src", "pages", "mall", "staff", "StaffPage.vue", +const STAFF_TOOLS_PATH = path.join( + __dirname, "..", "src", "pages", "mall", "staff", "StaffTools.vue", ); function checkerResolveImport(specifier) { - if (specifier.endsWith("ObjectViewer.vue")) { + if (specifier.endsWith("ObjectViewer.vue") || specifier.endsWith("CheckerModal.vue")) { return {}; } if (specifier.endsWith("mall-actions.mixin")) { @@ -134,9 +134,13 @@ async function testCheckerDownload() { console.log("PASS: checker.vue downloadSource defers blob revocation"); } -async function testStaffPageExportDownload() { +async function testStaffToolsExportDownload() { const dom = buildDomFakes(); - const options = loadComponentOptions(STAFF_PAGE_PATH, () => undefined, dom); + 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" } }; @@ -156,12 +160,12 @@ async function testStaffPageExportDownload() { assert.deepStrictEqual(dom.revokeCalls, [url], "revokeObjectURL must run exactly once, with the created url, once the deferred tick runs"); - console.log("PASS: StaffPage.vue saveExport defers blob revocation"); + console.log("PASS: StaffTools.vue saveExport defers blob revocation"); } async function run() { await testCheckerDownload(); - await testStaffPageExportDownload(); + await testStaffToolsExportDownload(); console.log("PASS: blob-download-revocation.test.js"); } diff --git a/spa/test/pending-reject-messaging.test.js b/spa/test/pending-reject-messaging.test.js index bc087941..d4c66671 100644 --- a/spa/test/pending-reject-messaging.test.js +++ b/spa/test/pending-reject-messaging.test.js @@ -16,6 +16,9 @@ function resolveImport(specifier, identityExtend) { if (specifier.endsWith("MallObjectRow.vue") || specifier.endsWith("Modal.vue")) { return {}; } + if (specifier.endsWith("mall-staff-state")) { + return { pendingCount: null }; + } if (specifier.endsWith("mall-actions.mixin")) { // `pending.vue` is `mallActions.extend({...})`: the default export needs // its own `.extend()`, matching the real mixin's shape -- reuse the same diff --git a/spa/test/staff-tools-export-visibility.test.js b/spa/test/staff-tools-export-visibility.test.js new file mode 100644 index 00000000..6039fd27 --- /dev/null +++ b/spa/test/staff-tools-export-visibility.test.js @@ -0,0 +1,65 @@ +/** + * Regression test for the Pending export control's visibility. + * + * Owner QA found "Export Mall Data" still offered while the Pending list said + * "No items to show" -- a download of an empty document presented as though + * there were something to export. The control now reads the Pending queue's + * published count and is offered only when that queue actually has rows. + * + * The `null` case matters separately from `0`: before the first count arrives + * the control must stay hidden rather than appear and then vanish. + * + * Run with: node test/staff-tools-export-visibility.test.js + */ + +const path = require("path"); +const assert = require("assert"); +const { loadComponentOptions } = require("./support/load-vue-options"); + +const STAFF_TOOLS_PATH = path.join( + __dirname, "..", "src", "pages", "mall", "staff", "StaffTools.vue", +); + +function run() { + const state = { pendingCount: null }; + const options = loadComponentOptions( + STAFF_TOOLS_PATH, + (specifier) => (specifier.endsWith("mall-staff-state") ? state : undefined), + ); + + const visible = (routeName, pendingCount) => { + state.pendingCount = pendingCount; + const self = { $route: { name: routeName } }; + self.onPendingList = options.computed.onPendingList.call(self); + return options.computed.showExportControl.call(self); + }; + + assert.strictEqual(visible("MallPending", 2), true, + "with pending rows, the export control must be offered"); + assert.strictEqual(visible("MallPending", 1), true, + "a single pending row is still something to export"); + assert.strictEqual(visible("MallPending", 0), false, + "an empty Pending queue must not offer an export of nothing"); + assert.strictEqual(visible("MallPending", null), false, + "before the queue has been counted the control must stay hidden"); + + // Pending-only: the export publishes the submission queue, so offering it + // from another list would imply it exports what that list shows. + assert.strictEqual(visible("MallStocked", 5), false, + "Stocked must not offer the Pending export"); + assert.strictEqual(visible("MallSoldOut", 5), false, + "Out of Stock must not offer the Pending export"); + assert.strictEqual(visible("MallObjectSearch", 5), false, + "Search must not offer the Pending export"); + assert.strictEqual(visible("mall-checker", 5), false, + "the checker must not offer the Pending export"); + + console.log("PASS: staff-tools-export-visibility.test.js"); +} + +try { + run(); +} catch (error) { + console.error("FAIL:", error.stack || error.message); + process.exitCode = 1; +} From 83ebac59a0bbc8bf1de7665348653e37bdf71cc3 Mon Sep 17 00:00:00 2001 From: Ryan Bundy Date: Mon, 24 Aug 2026 05:15:28 -0400 Subject: [PATCH 14/19] feat: rebuild the Mall checker review workspace The checker had all the right facts and no hierarchy, and one of its controls broke the page: SHOW RAW VRML expanded the source inline, and a real object's long lines pushed the document wider than the Cybertown frame so the whole page scrolled sideways. Layout, following what a checker actually does -- look at the object, then read what was found in it: - Left: the 3D preview, with Findings directly beneath it. - Right: the thumbnail first (it is what a buyer sees), then WorldInfo, the WorldInfo/CTR comparison, the file facts, the node counts, and a compact moderation panel. Every technical value is kept. Findings now lead with the plain-language sentence and carry the machine code beneath it as "Technical:", so the page reads for someone who knows VRML97 and for someone who only needs to know whether the object is alright. The comparison table gains explicit CTR RECORD / WORLDINFO / RESULT headers rather than three unlabelled columns. Raw source, the full-size thumbnail and the stored-file details each open in a bounded dialog that scrolls internally and cannot widen the document. The source viewer defaults to horizontal scrolling, because a VRML line is a meaningful unit and reflowing it by default would misrepresent the file; "Wrap lines" opts into a soft-wrapped view of the same bytes. The thumbnail is itself the control that opens full size, so the separate THUMBNAIL button is gone. The rejection field is a few lines wide instead of the width of the page. The 2000-character server limit is unchanged. Queue controls read as Previous Item / Next Item / Back to Pending rather than compact developer navigation. The id-based queue logic is untouched, and no global keyboard shortcut was added -- Escape closes a dialog and nothing else, so typing a rejection reason keeps every key. Two columns only above 1024px; below that the panes stack, which is what keeps a 768px portrait tablet inside the frame. No fixed pixel widths. --- spa/src/components/mall/CheckerModal.vue | 102 ++ spa/src/pages/mall/checker.vue | 872 ++++++++++++++---- spa/test/checker-navigation.test.js | 2 +- .../checker-raw-source-navigation.test.js | 11 +- spa/test/checker-reject-messaging.test.js | 2 +- 5 files changed, 792 insertions(+), 197 deletions(-) create mode 100644 spa/src/components/mall/CheckerModal.vue 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/pages/mall/checker.vue b/spa/src/pages/mall/checker.vue index 640389d6..f03ff526 100644 --- a/spa/src/pages/mall/checker.vue +++ b/spa/src/pages/mall/checker.vue @@ -1,5 +1,5 @@