Follow-up to #22827, addressing the review comments left around merge time and applying the endpoint convergence discussed afterwards. ## Review comments from #22827 - **Swallowed error in dev sync asset read** ([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571513265)): the swallow is intentional (a missing public asset must not fail the whole dev sync) but it now logs a warning with the asset path and error, and the registration keeps its previously stored file for that path instead of losing it. - **`isAbsoluteUrl` location** ([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571524234)): moved to `twenty-shared/utils/url`. The server, and now also `twenty-sdk`'s `normalize-application-assets`, use the shared util. - **Soft delete vs file cleanup** ([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571589558)): per review, deleting a registration is now a hard delete. Stored assets (bytes + rows) are deleted with it, dependent rows are removed by their existing FK cascades, and installed applications keep working with their registration link nulled. No soft-delete/cron mechanism. - **Asset cap too generous** ([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571595745)): lowered to 10MB per review and documented in the publishing and public-assets docs pages. - **One missing image retriggers a full asset sync** ([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571646243)): `storeRegistrationAssets` now takes `skipAlreadyStoredPaths`; the catalog sync passes it when the package version is unchanged, so only assets missing a stored file are fetched instead of re-downloading everything. - **`existing.logo` already contains the new logo** ([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571667847)): correct, `updateFromManifest` runs first, so the previous "keep fileId when the path did not change" guard compared the new logo against itself. The fileId preservation is now keyed on the stored server file for the exact path (files are unique per `(applicationRegistrationId, path)`): a changed logo path no longer inherits the old file's id, and a transient download failure on an unchanged path still keeps the working file. This also removed the fileId-preservation bookkeeping from `storeRegistrationAssets`. ## Endpoint convergence - **Path-addressed public route for registration assets**: `GET /file/server/application-registration/:fileId` is replaced by `GET /files/application-registrations/:registrationId/*path`, mirroring the manifest's public-folder paths and leaving room for a future `:version` segment. Assets stay addressable by stable ids server-side; the fileId now only marks a path as stored. No URL is ever persisted (all are built at query time), and the old route never shipped in a release, so there is nothing to migrate. - **`Application.logoUrl` resolved server-side**: new `ResolveField` on the `Application` type builds the `/public-assets/...` display URL (or passes absolute URLs through). `useApplicationChipData` now reads it from `currentWorkspace.installedApplications`, and the frontend `buildApplicationLogoUrl` util is deleted, so clients no longer construct file URLs themselves. ## Validation - Unit: `file.controller.spec` (route renamed, traversal case added), `server-file-storage.service.spec` (`findServerFile`, `deleteByApplicationRegistrationId`), `application-registration-asset-url.service.spec` (new URL shape, url-encoding), new `isAbsoluteUrl` test; all application/file suites pass. - Live against a local server: new route serves tarball and rehosted npm assets with `public, max-age=3600` (nested paths included), 404s on missing files, unknown registrations, traversal attempts, and the removed old route; `findManyApplicationRegistrations` returns path-addressed URLs for stored assets, CDN fallback for npm, absolute passthrough; `installedApplications.logoUrl` resolves the public-assets URL and stays null for logo-less apps. Registration hard delete verified against the DB: file rows cascade, application rows keep a nulled registration link. - Typecheck + lint on twenty-server, twenty-front, twenty-shared, twenty-sdk; metadata codegen and client-sdk regenerated.
This commit is contained in:
+63
-33
@@ -74,7 +74,7 @@ describe('FileController', () => {
|
||||
{
|
||||
provide: ServerFileStorageService,
|
||||
useValue: {
|
||||
readServerFileById: jest.fn(),
|
||||
readServerFile: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -322,28 +322,31 @@ describe('FileController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getApplicationRegistrationFileById', () => {
|
||||
describe('getApplicationRegistrationAsset', () => {
|
||||
const createAssetRequest = (path: string[] = ['images', 'logo.png']) =>
|
||||
({ params: { path } }) as any;
|
||||
|
||||
it('should stream the file with public cache headers', async () => {
|
||||
const mockStream = createMockStream();
|
||||
|
||||
jest
|
||||
.spyOn(serverFileStorageService, 'readServerFileById')
|
||||
.mockResolvedValue({
|
||||
stream: mockStream,
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
jest.spyOn(serverFileStorageService, 'readServerFile').mockResolvedValue({
|
||||
stream: mockStream,
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
|
||||
const mockResponse = createMockResponse() as any;
|
||||
|
||||
await controller.getApplicationRegistrationFileById(
|
||||
await controller.getApplicationRegistrationAsset(
|
||||
mockResponse,
|
||||
'file-123',
|
||||
createAssetRequest(),
|
||||
'registration-id',
|
||||
);
|
||||
|
||||
expect(serverFileStorageService.readServerFileById).toHaveBeenCalledWith(
|
||||
'file-123',
|
||||
ServerFileFolder.ApplicationRegistration,
|
||||
);
|
||||
expect(serverFileStorageService.readServerFile).toHaveBeenCalledWith({
|
||||
fileFolder: ServerFileFolder.ApplicationRegistration,
|
||||
applicationRegistrationId: 'registration-id',
|
||||
resourcePath: 'images/logo.png',
|
||||
});
|
||||
expect(mockResponse.setHeader).toHaveBeenCalledWith(
|
||||
'Content-Type',
|
||||
'image/png',
|
||||
@@ -357,10 +360,10 @@ describe('FileController', () => {
|
||||
|
||||
it('should throw FILE_NOT_FOUND when the file does not exist', async () => {
|
||||
jest
|
||||
.spyOn(serverFileStorageService, 'readServerFileById')
|
||||
.spyOn(serverFileStorageService, 'readServerFile')
|
||||
.mockRejectedValue(
|
||||
new FileStorageException(
|
||||
'Server file unknown-id not found',
|
||||
'Server file not found',
|
||||
FileStorageExceptionCode.FILE_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
@@ -368,9 +371,35 @@ describe('FileController', () => {
|
||||
const mockResponse = createMockResponse() as any;
|
||||
|
||||
await expect(
|
||||
controller.getApplicationRegistrationFileById(
|
||||
controller.getApplicationRegistrationAsset(
|
||||
mockResponse,
|
||||
'unknown-id',
|
||||
createAssetRequest(['missing.png']),
|
||||
'registration-id',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
new FileException('File not found', FileExceptionCode.FILE_NOT_FOUND),
|
||||
);
|
||||
|
||||
expect(mockPipeline).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw FILE_NOT_FOUND when the path is rejected by validation', async () => {
|
||||
jest
|
||||
.spyOn(serverFileStorageService, 'readServerFile')
|
||||
.mockRejectedValue(
|
||||
new FileStorageException(
|
||||
'Invalid file path',
|
||||
FileStorageExceptionCode.ACCESS_DENIED,
|
||||
),
|
||||
);
|
||||
|
||||
const mockResponse = createMockResponse() as any;
|
||||
|
||||
await expect(
|
||||
controller.getApplicationRegistrationAsset(
|
||||
mockResponse,
|
||||
createAssetRequest(['..', 'escape.png']),
|
||||
'registration-id',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
new FileException('File not found', FileExceptionCode.FILE_NOT_FOUND),
|
||||
@@ -380,19 +409,21 @@ describe('FileController', () => {
|
||||
});
|
||||
|
||||
it('should throw INTERNAL_SERVER_ERROR when the stream errors before headers are sent', async () => {
|
||||
jest
|
||||
.spyOn(serverFileStorageService, 'readServerFileById')
|
||||
.mockResolvedValue({
|
||||
stream: createMockStream(),
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
jest.spyOn(serverFileStorageService, 'readServerFile').mockResolvedValue({
|
||||
stream: createMockStream(),
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
|
||||
mockPipeline.mockRejectedValue(new Error('source backend exploded'));
|
||||
|
||||
const mockResponse = createMockResponse({ headersSent: false }) as any;
|
||||
|
||||
await expect(
|
||||
controller.getApplicationRegistrationFileById(mockResponse, 'file-123'),
|
||||
controller.getApplicationRegistrationAsset(
|
||||
mockResponse,
|
||||
createAssetRequest(),
|
||||
'registration-id',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
new FileException(
|
||||
'Error streaming file from storage',
|
||||
@@ -404,20 +435,19 @@ describe('FileController', () => {
|
||||
});
|
||||
|
||||
it('should destroy the response without throwing when the stream errors after headers are sent', async () => {
|
||||
jest
|
||||
.spyOn(serverFileStorageService, 'readServerFileById')
|
||||
.mockResolvedValue({
|
||||
stream: createMockStream(),
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
jest.spyOn(serverFileStorageService, 'readServerFile').mockResolvedValue({
|
||||
stream: createMockStream(),
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
|
||||
mockPipeline.mockRejectedValue(new Error('socket reset mid-flight'));
|
||||
|
||||
const mockResponse = createMockResponse({ headersSent: true }) as any;
|
||||
|
||||
await controller.getApplicationRegistrationFileById(
|
||||
await controller.getApplicationRegistrationAsset(
|
||||
mockResponse,
|
||||
'file-123',
|
||||
createAssetRequest(),
|
||||
'registration-id',
|
||||
);
|
||||
|
||||
expect(mockResponse.destroy).toHaveBeenCalledTimes(1);
|
||||
|
||||
+18
-14
@@ -16,7 +16,6 @@ import { type Readable } from 'stream';
|
||||
import { Request, Response } from 'express';
|
||||
import { FileFolder, ServerFileFolder } from 'twenty-shared/types';
|
||||
|
||||
import { SERVER_FILE_STORAGE_PREFIX } from 'src/engine/core-modules/file-storage/constants/server-file-storage-prefix.constant';
|
||||
import {
|
||||
FileStorageException,
|
||||
FileStorageExceptionCode,
|
||||
@@ -48,27 +47,32 @@ export class FileController {
|
||||
private readonly serverFileStorageService: ServerFileStorageService,
|
||||
) {}
|
||||
|
||||
// Serves application registration assets (logo, gallery images). These are
|
||||
// instance-global marketplace resources, also displayed on the public OAuth
|
||||
// authorize page, hence no auth token. The /server/ segment separates
|
||||
// instance-global server files from the workspace-scoped /file/:folder/:id.
|
||||
@Get(`file/${SERVER_FILE_STORAGE_PREFIX}/application-registration/:id`)
|
||||
// Serves application registration assets (logo, gallery images) by their
|
||||
// public folder path. These are instance-global marketplace resources, also
|
||||
// displayed on the public OAuth authorize page, hence no auth token, unlike
|
||||
// the workspace-scoped /file/:folder/:id.
|
||||
@Get('files/application-registrations/:applicationRegistrationId/*path')
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
async getApplicationRegistrationFileById(
|
||||
async getApplicationRegistrationAsset(
|
||||
@Res() res: Response,
|
||||
@Param('id') fileId: string,
|
||||
@Req() req: Request,
|
||||
@Param('applicationRegistrationId') applicationRegistrationId: string,
|
||||
) {
|
||||
const filepath = join(...req.params.path);
|
||||
|
||||
let fileResponse: { stream: Readable; mimeType: string };
|
||||
|
||||
try {
|
||||
fileResponse = await this.serverFileStorageService.readServerFileById(
|
||||
fileId,
|
||||
ServerFileFolder.ApplicationRegistration,
|
||||
);
|
||||
fileResponse = await this.serverFileStorageService.readServerFile({
|
||||
fileFolder: ServerFileFolder.ApplicationRegistration,
|
||||
applicationRegistrationId,
|
||||
resourcePath: filepath,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof FileStorageException &&
|
||||
error.code === FileStorageExceptionCode.FILE_NOT_FOUND
|
||||
(error.code === FileStorageExceptionCode.FILE_NOT_FOUND ||
|
||||
error.code === FileStorageExceptionCode.ACCESS_DENIED)
|
||||
) {
|
||||
throw new FileException(
|
||||
'File not found',
|
||||
@@ -76,7 +80,7 @@ export class FileController {
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.error('readServerFileById failed unexpectedly', { error });
|
||||
this.logger.error('readServerFile failed unexpectedly', { error });
|
||||
|
||||
throw new FileException(
|
||||
'Error retrieving file',
|
||||
|
||||
Reference in New Issue
Block a user