mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
8c0280e287
Every trip-scoped controller carried its own three-line requireTrip, and a requireEdit next to it: eighteen copies of a security check, each of which had to remember that no access is a 404 'Trip not found' and never a 403 — the distinction that stops a stranger learning which trip ids exist. One of them getting it wrong would have looked like nothing. TripAccessGuard resolves :tripId once, refuses what the user cannot reach and hands the row to the handler through a @Trip() param decorator (a param decorator rather than a req.trip cast, because the request type that used to be widened for that is gone). @RequirePermission carries the same action string the domain services pass to checkPermission, so the HTTP and MCP paths cannot drift apart on which right they demand. Status codes and bodies are unchanged. Piloted on days, day-notes, todo, packing and files. Two things worth knowing: Files applies the guard PER HANDLER and leaves the upload route on its own check. Guards run before interceptors, so a class-level guard would answer 404 before multer had read the multipart body, and a response sent while the client is still streaming gets the socket destroyed — the caller sees ECONNRESET instead of the 404. Refusing after the body has been read costs the bandwidth and keeps the answer readable. The services keep verifyTripAccess and canEdit: the large majority of their callers are *.mcp.ts tools, which never pass through an HTTP guard. canEdit lost its incidental controller-test coverage in the move, so it now has direct tests asserting the action and the shared flag — the same pair the guard reproduces. Also fixes the backup zip-slip test that failed only on CI: it asserted a drive-letter path, which is absolute on win32 and an ordinary relative segment on Linux, so the branch it was guarding never fired there.
298 lines
19 KiB
TypeScript
298 lines
19 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
|
import { HttpException } from '@nestjs/common';
|
|
import type { Request, Response } from 'express';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
|
|
vi.mock('../../../src/nest/common/demo', () => ({ isDemoEmail: vi.fn(() => false) }));
|
|
|
|
import { FilesController } from '../../../src/nest/files/files.controller';
|
|
import { FilesDownloadController } from '../../../src/nest/files/files-download.controller';
|
|
import { PhotosController } from '../../../src/nest/photos/photos.controller';
|
|
import type { FilesService } from '../../../src/nest/files/files.service';
|
|
import type { PhotosService } from '../../../src/nest/photos/photos.service';
|
|
import { isDemoEmail } from '../../../src/nest/common/demo';
|
|
import type { User } from '../../../src/types';
|
|
|
|
const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User;
|
|
|
|
function fsvc(o: Partial<FilesService> = {}): FilesService {
|
|
return {
|
|
verifyTripAccess: vi.fn().mockReturnValue({ user_id: 1 }),
|
|
can: vi.fn().mockReturnValue(true),
|
|
findForeignLinkTarget: vi.fn().mockReturnValue(null),
|
|
broadcast: vi.fn(),
|
|
...o,
|
|
} as unknown as FilesService;
|
|
}
|
|
|
|
function thrown(fn: () => unknown): { status: number; body: unknown } {
|
|
try { fn(); } catch (err) {
|
|
expect(err).toBeInstanceOf(HttpException);
|
|
const e = err as HttpException;
|
|
return { status: e.getStatus(), body: e.getResponse() };
|
|
}
|
|
throw new Error('expected throw');
|
|
}
|
|
|
|
beforeEach(() => vi.clearAllMocks());
|
|
afterEach(() => { delete process.env.DEMO_MODE; });
|
|
|
|
/** The trip row TripAccessGuard resolves and hands to every handler via @Trip(). */
|
|
const trip = { id: 5, user_id: 42 } as never;
|
|
|
|
// The 404 "Trip not found" cases moved to trip-access.guard.test.ts with the check.
|
|
describe('FilesController (parity with the legacy /api/trips/:tripId/files route)', () => {
|
|
it('GET / lists with the trash flag', () => {
|
|
const listFiles = vi.fn().mockReturnValue([{ id: 1 }]);
|
|
expect(new FilesController(fsvc({ listFiles } as Partial<FilesService>), new RuntimeEnvService()).list(user, trip, '5', 'true')).toEqual({ files: [{ id: 1 }] });
|
|
expect(listFiles).toHaveBeenCalledWith('5', true);
|
|
});
|
|
|
|
describe('POST / (upload)', () => {
|
|
it('404 without trip access — upload keeps its own check so the body is read first', () => {
|
|
// The guard would answer before multer, and a response sent mid-upload destroys
|
|
// the socket: the caller would see ECONNRESET rather than this 404.
|
|
const svc = fsvc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) });
|
|
expect(thrown(() => new FilesController(svc, new RuntimeEnvService()).upload(user, '5', undefined, {}))).toEqual({
|
|
status: 404,
|
|
body: { error: 'Trip not found' },
|
|
});
|
|
});
|
|
|
|
const file = { filename: 'a.pdf' } as Express.Multer.File;
|
|
it('403 in demo mode for a demo email', () => {
|
|
process.env.DEMO_MODE = 'true';
|
|
vi.mocked(isDemoEmail).mockReturnValue(true);
|
|
expect(thrown(() => new FilesController(fsvc(), new RuntimeEnvService()).upload(user, '5', file, {}))).toEqual({ status: 403, body: { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' } });
|
|
});
|
|
it('403 without file_upload, 400 without a file, else creates + broadcasts', () => {
|
|
expect(thrown(() => new FilesController(fsvc({ can: vi.fn().mockReturnValue(false) }), new RuntimeEnvService()).upload(user, '5', file, {}))).toEqual({ status: 403, body: { error: 'No permission to upload files' } });
|
|
expect(thrown(() => new FilesController(fsvc(), new RuntimeEnvService()).upload(user, '5', undefined, {}))).toEqual({ status: 400, body: { error: 'No file uploaded' } });
|
|
const createFile = vi.fn().mockReturnValue({ id: 9 });
|
|
const broadcast = vi.fn();
|
|
const s = fsvc({ createFile, broadcast } as Partial<FilesService>);
|
|
expect(new FilesController(s, new RuntimeEnvService()).upload(user, '5', file, { description: 'd' }, 'sock')).toEqual({ file: { id: 9 } });
|
|
expect(createFile).toHaveBeenCalledWith('5', file, 1, { place_id: undefined, description: 'd', reservation_id: undefined });
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'file:created', { file: { id: 9 } }, 'sock');
|
|
});
|
|
|
|
it('caps non-video by extension and accepts large video; cleans up on rejection (#823)', () => {
|
|
const big = { filename: 'big.pdf', originalname: 'big.pdf', size: 60 * 1024 * 1024, path: '' } as Express.Multer.File;
|
|
expect(thrown(() => new FilesController(fsvc(), new RuntimeEnvService()).upload(user, '5', big, {}))).toEqual({ status: 400, body: { error: 'File is too large' } });
|
|
|
|
const createFile = vi.fn().mockReturnValue({ id: 9 });
|
|
const vid = { filename: 'v.mp4', originalname: 'clip.mp4', size: 200 * 1024 * 1024, path: '' } as Express.Multer.File;
|
|
expect(new FilesController(fsvc({ createFile, broadcast: vi.fn() } as Partial<FilesService>), new RuntimeEnvService()).upload(user, '5', vid, {})).toEqual({ file: { id: 9 } });
|
|
|
|
// A rejected upload with a real path triggers the unlink cleanup branch
|
|
// (the file doesn't exist, so the inner best-effort catch swallows it).
|
|
const withPath = { filename: 'a.pdf', path: '/nonexistent/zzz.pdf' } as Express.Multer.File;
|
|
expect(thrown(() => new FilesController(fsvc({ can: vi.fn().mockReturnValue(false) }), new RuntimeEnvService()).upload(user, '5', withPath, {}))).toEqual({ status: 403, body: { error: 'No permission to upload files' } });
|
|
});
|
|
});
|
|
|
|
it('PUT /:id 403 without file_edit, 404 unknown, else updates + broadcasts', () => {
|
|
expect(thrown(() => new FilesController(fsvc({ can: vi.fn().mockReturnValue(false) }), new RuntimeEnvService()).update(user, trip, '5', '9', {}))).toEqual({ status: 403, body: { error: 'No permission to edit files' } });
|
|
expect(thrown(() => new FilesController(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>), new RuntimeEnvService()).update(user, trip, '5', '9', {}))).toEqual({ status: 404, body: { error: 'File not found' } });
|
|
const updateFile = vi.fn().mockReturnValue({ id: 9 });
|
|
const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9, description: 'x' }), updateFile, broadcast: vi.fn() } as Partial<FilesService>);
|
|
expect(new FilesController(s, new RuntimeEnvService()).update(user, trip, '5', '9', { description: 'new' })).toEqual({ file: { id: 9 } });
|
|
});
|
|
|
|
it('PATCH /:id/star 403/404, else toggles', () => {
|
|
expect(thrown(() => new FilesController(fsvc({ can: vi.fn().mockReturnValue(false) }), new RuntimeEnvService()).star(user, trip, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission' } });
|
|
expect(thrown(() => new FilesController(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>), new RuntimeEnvService()).star(user, trip, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
|
const toggleStarred = vi.fn().mockReturnValue({ id: 9, starred: 1 });
|
|
const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9, starred: 0 }), toggleStarred, broadcast: vi.fn() } as Partial<FilesService>);
|
|
expect(new FilesController(s, new RuntimeEnvService()).star(user, trip, '5', '9')).toEqual({ file: { id: 9, starred: 1 } });
|
|
expect(toggleStarred).toHaveBeenCalledWith('9', 0);
|
|
});
|
|
|
|
it('DELETE /:id soft-delete 403/404, else success', () => {
|
|
expect(thrown(() => new FilesController(fsvc({ can: vi.fn().mockReturnValue(false) }), new RuntimeEnvService()).remove(user, trip, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission to delete files' } });
|
|
expect(thrown(() => new FilesController(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>), new RuntimeEnvService()).remove(user, trip, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
|
const softDeleteFile = vi.fn();
|
|
const broadcast = vi.fn();
|
|
const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9 }), softDeleteFile, broadcast } as Partial<FilesService>);
|
|
expect(new FilesController(s, new RuntimeEnvService()).remove(user, trip, '5', '9', 'sock')).toEqual({ success: true });
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'file:deleted', { fileId: 9 }, 'sock');
|
|
});
|
|
|
|
it('POST /:id/restore 404 not in trash, else restores', () => {
|
|
expect(thrown(() => new FilesController(fsvc({ getDeletedFile: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>), new RuntimeEnvService()).restore(user, trip, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found in trash' } });
|
|
const restoreFile = vi.fn().mockReturnValue({ id: 9 });
|
|
const s = fsvc({ getDeletedFile: vi.fn().mockReturnValue({ id: 9 }), restoreFile, broadcast: vi.fn() } as Partial<FilesService>);
|
|
expect(new FilesController(s, new RuntimeEnvService()).restore(user, trip, '5', '9')).toEqual({ file: { id: 9 } });
|
|
});
|
|
|
|
it('DELETE /:id/permanent 404 not in trash, else deletes', async () => {
|
|
await expect(new FilesController(fsvc({ getDeletedFile: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>), new RuntimeEnvService()).permanent(user, '5', '9')).rejects.toBeInstanceOf(HttpException);
|
|
const permanentDeleteFile = vi.fn().mockResolvedValue(undefined);
|
|
const s = fsvc({ getDeletedFile: vi.fn().mockReturnValue({ id: 9 }), permanentDeleteFile, broadcast: vi.fn() } as Partial<FilesService>);
|
|
expect(await new FilesController(s, new RuntimeEnvService()).permanent(user, '5', '9')).toEqual({ success: true });
|
|
});
|
|
|
|
it('DELETE /trash/empty 403, else returns the count', async () => {
|
|
await expect(new FilesController(fsvc({ can: vi.fn().mockReturnValue(false) }), new RuntimeEnvService()).emptyTrash(user, '5')).rejects.toBeInstanceOf(HttpException);
|
|
const s = fsvc({ emptyTrash: vi.fn().mockResolvedValue(3) } as Partial<FilesService>);
|
|
expect(await new FilesController(s, new RuntimeEnvService()).emptyTrash(user, '5')).toEqual({ success: true, deleted: 3 });
|
|
});
|
|
|
|
it('POST /:id/link 404 unknown file, else links', () => {
|
|
expect(thrown(() => new FilesController(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>), new RuntimeEnvService()).link(user, trip, '5', '9', {}))).toEqual({ status: 404, body: { error: 'File not found' } });
|
|
const createFileLink = vi.fn().mockReturnValue([{ id: 1 }]);
|
|
const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9 }), createFileLink } as Partial<FilesService>);
|
|
expect(new FilesController(s, new RuntimeEnvService()).link(user, trip, '5', '9', { reservation_id: 2 })).toEqual({ success: true, links: [{ id: 1 }] });
|
|
});
|
|
|
|
it('DELETE /:id/link/:linkId removes the link; GET /:id/links lists', () => {
|
|
const deleteFileLink = vi.fn();
|
|
expect(new FilesController(fsvc({ deleteFileLink } as Partial<FilesService>), new RuntimeEnvService()).unlink(user, trip, '5', '9', '3')).toEqual({ success: true });
|
|
expect(deleteFileLink).toHaveBeenCalledWith('3', '9');
|
|
const s = fsvc({ getFileLinks: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<FilesService>);
|
|
expect(new FilesController(s, new RuntimeEnvService()).links(user, trip, '5', '9')).toEqual({ links: [{ id: 1 }] });
|
|
});
|
|
|
|
it('the trash + link routes all reject without file_delete / file_edit', async () => {
|
|
const denied = () => fsvc({ can: vi.fn().mockReturnValue(false) });
|
|
await expect(new FilesController(denied(), new RuntimeEnvService()).permanent(user, '5', '9')).rejects.toMatchObject({ status: 403 });
|
|
expect(thrown(() => new FilesController(denied(), new RuntimeEnvService()).restore(user, trip, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission' } });
|
|
expect(thrown(() => new FilesController(denied(), new RuntimeEnvService()).link(user, trip, '5', '9', {}))).toEqual({ status: 403, body: { error: 'No permission' } });
|
|
expect(thrown(() => new FilesController(denied(), new RuntimeEnvService()).unlink(user, trip, '5', '9', '3'))).toEqual({ status: 403, body: { error: 'No permission' } });
|
|
});
|
|
|
|
});
|
|
|
|
describe('FilesDownloadController', () => {
|
|
function dsvc(o: Partial<FilesService> = {}): FilesService {
|
|
return {
|
|
authenticateDownload: vi.fn().mockReturnValue({ userId: 1 }),
|
|
verifyTripAccess: vi.fn().mockReturnValue({ user_id: 1 }),
|
|
getFileById: vi.fn().mockReturnValue({ filename: 'x.pdf', original_name: 'x.pdf' }),
|
|
resolveFilePath: vi.fn().mockReturnValue({ resolved: 'C:/nope/x.pdf', safe: true }),
|
|
...o,
|
|
} as unknown as FilesService;
|
|
}
|
|
const req = { headers: {}, query: {} } as Request;
|
|
const res = { setHeader: vi.fn(), sendFile: vi.fn() } as unknown as Response;
|
|
|
|
it('maps the auth error from authenticateDownload', () => {
|
|
const s = dsvc({ authenticateDownload: vi.fn().mockReturnValue({ error: 'Authentication required', status: 401 }) });
|
|
expect(thrown(() => new FilesDownloadController(s).download(req, res, '5', '9'))).toEqual({ status: 401, body: { error: 'Authentication required' } });
|
|
});
|
|
it('404 without trip access, 404 unknown file, 403 on an unsafe path', () => {
|
|
expect(thrown(() => new FilesDownloadController(dsvc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).download(req, res, '5', '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
|
expect(thrown(() => new FilesDownloadController(dsvc({ getFileById: vi.fn().mockReturnValue(undefined) })).download(req, res, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
|
expect(thrown(() => new FilesDownloadController(dsvc({ resolveFilePath: vi.fn().mockReturnValue({ resolved: '/x', safe: false }) })).download(req, res, '5', '9'))).toEqual({ status: 403, body: { error: 'Forbidden' } });
|
|
});
|
|
|
|
it('404 when the safe path is gone from disk', () => {
|
|
const missing = path.join(os.tmpdir(), `trek-no-such-${Date.now()}.pdf`);
|
|
const s = dsvc({ resolveFilePath: vi.fn().mockReturnValue({ resolved: missing, safe: true }) });
|
|
expect(thrown(() => new FilesDownloadController(s).download(req, res, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
|
});
|
|
|
|
it('streams a regular file via sendFile with an explicit root', () => {
|
|
const real = path.join(os.tmpdir(), `trek-dl-${Date.now()}.pdf`);
|
|
fs.writeFileSync(real, 'x');
|
|
try {
|
|
const sendFile = vi.fn();
|
|
const localRes = { setHeader: vi.fn(), sendFile } as unknown as Response;
|
|
const s = dsvc({ resolveFilePath: vi.fn().mockReturnValue({ resolved: real, safe: true }) });
|
|
new FilesDownloadController(s).download(req, localRes, '5', '9');
|
|
expect(sendFile).toHaveBeenCalledWith(path.basename(real), { root: path.dirname(real) });
|
|
expect(localRes.setHeader).not.toHaveBeenCalled();
|
|
} finally {
|
|
fs.unlinkSync(real);
|
|
}
|
|
});
|
|
|
|
it('serves a .pkpass inline with the Wallet MIME type and the original name', () => {
|
|
const real = path.join(os.tmpdir(), `trek-pass-${Date.now()}.pkpass`);
|
|
fs.writeFileSync(real, 'x');
|
|
try {
|
|
const setHeader = vi.fn();
|
|
const localRes = { setHeader, sendFile: vi.fn() } as unknown as Response;
|
|
const s = dsvc({
|
|
getFileById: vi.fn().mockReturnValue({ filename: 'pass.pkpass', original_name: 'BoardingPass.pkpass' }),
|
|
resolveFilePath: vi.fn().mockReturnValue({ resolved: real, safe: true }),
|
|
});
|
|
new FilesDownloadController(s).download(req, localRes, '5', '9');
|
|
expect(setHeader).toHaveBeenCalledWith('Content-Type', 'application/vnd.apple.pkpass');
|
|
expect(setHeader).toHaveBeenCalledWith('Content-Disposition', 'inline; filename="BoardingPass.pkpass"');
|
|
} finally {
|
|
fs.unlinkSync(real);
|
|
}
|
|
});
|
|
|
|
it('serves a .pkpasses bundle inline with its own Wallet MIME type', () => {
|
|
const real = path.join(os.tmpdir(), `trek-pass-${Date.now()}.pkpasses`);
|
|
fs.writeFileSync(real, 'x');
|
|
try {
|
|
const setHeader = vi.fn();
|
|
const localRes = { setHeader, sendFile: vi.fn() } as unknown as Response;
|
|
const s = dsvc({
|
|
getFileById: vi.fn().mockReturnValue({ filename: 'passes.pkpasses', original_name: 'BoardingPasses.pkpasses' }),
|
|
resolveFilePath: vi.fn().mockReturnValue({ resolved: real, safe: true }),
|
|
});
|
|
new FilesDownloadController(s).download(req, localRes, '5', '9');
|
|
expect(setHeader).toHaveBeenCalledWith('Content-Type', 'application/vnd.apple.pkpasses');
|
|
expect(setHeader).toHaveBeenCalledWith('Content-Disposition', 'inline; filename="BoardingPasses.pkpasses"');
|
|
} finally {
|
|
fs.unlinkSync(real);
|
|
}
|
|
});
|
|
|
|
it('falls back to the resolved basename when a .pkpass has no original name', () => {
|
|
const real = path.join(os.tmpdir(), `trek-pass-${Date.now()}.pkpass`);
|
|
fs.writeFileSync(real, 'x');
|
|
try {
|
|
const setHeader = vi.fn();
|
|
const localRes = { setHeader, sendFile: vi.fn() } as unknown as Response;
|
|
const s = dsvc({
|
|
getFileById: vi.fn().mockReturnValue({ filename: 'pass.pkpass', original_name: null }),
|
|
resolveFilePath: vi.fn().mockReturnValue({ resolved: real, safe: true }),
|
|
});
|
|
new FilesDownloadController(s).download(req, localRes, '5', '9');
|
|
expect(setHeader).toHaveBeenCalledWith('Content-Disposition', `inline; filename="${path.basename(real)}"`);
|
|
} finally {
|
|
fs.unlinkSync(real);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('PhotosController', () => {
|
|
const user2 = { id: 1 } as User;
|
|
function psvc(o: Partial<PhotosService> = {}): PhotosService {
|
|
return { canAccess: vi.fn().mockReturnValue(true), stream: vi.fn().mockResolvedValue(undefined), info: vi.fn(), ...o } as unknown as PhotosService;
|
|
}
|
|
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() } as unknown as Response;
|
|
|
|
it('400 on a non-finite id, 403 without access', async () => {
|
|
await expect(new PhotosController(psvc()).thumbnail(user2, 'abc', res)).rejects.toMatchObject({ status: 400 });
|
|
await expect(new PhotosController(psvc({ canAccess: vi.fn().mockReturnValue(false) })).original(user2, '5', res)).rejects.toMatchObject({ status: 403 });
|
|
});
|
|
|
|
it('streams thumbnail/original', async () => {
|
|
const stream = vi.fn().mockResolvedValue(undefined);
|
|
const c = new PhotosController(psvc({ stream }));
|
|
await c.thumbnail(user2, '5', res);
|
|
expect(stream).toHaveBeenCalledWith(res, 1, 5, 'thumbnail');
|
|
await c.original(user2, '5', res);
|
|
expect(stream).toHaveBeenCalledWith(res, 1, 5, 'original', undefined);
|
|
});
|
|
|
|
it('info writes the data, maps a service error', async () => {
|
|
const okRes = { status: vi.fn().mockReturnThis(), json: vi.fn() } as unknown as Response;
|
|
await new PhotosController(psvc({ info: vi.fn().mockResolvedValue({ data: { id: '5' } }) })).info(user2, '5', okRes);
|
|
expect(okRes.json).toHaveBeenCalledWith({ id: '5' });
|
|
const errRes = { status: vi.fn().mockReturnThis(), json: vi.fn() } as unknown as Response;
|
|
await new PhotosController(psvc({ info: vi.fn().mockResolvedValue({ error: { status: 404, message: 'Photo not found' } }) })).info(user2, '5', errRes);
|
|
expect(errRes.status).toHaveBeenCalledWith(404);
|
|
expect(errRes.json).toHaveBeenCalledWith({ error: 'Photo not found' });
|
|
});
|
|
});
|