From 0cefe010641e8df69688b99e60bec26c0e2e6cce Mon Sep 17 00:00:00 2001 From: Chetan Date: Mon, 6 Jul 2026 12:28:11 +0530 Subject: [PATCH 01/16] Concurrent Login Feature --- src/guards/access-token.guard.ts | 27 +++++++++++++-- src/index.ts | 1 + src/interfaces/active-user-data.interface.ts | 6 ++++ .../active-session-storage.service.ts | 24 ++++++++++++++ src/services/authentication.service.ts | 33 +++++++++++++++++-- .../refresh-token-ids-storage.service.ts | 17 +++++----- .../default-settings-provider.service.ts | 11 ++++++- src/solid-core.module.ts | 2 ++ 8 files changed, 106 insertions(+), 15 deletions(-) create mode 100644 src/services/active-session-storage.service.ts diff --git a/src/guards/access-token.guard.ts b/src/guards/access-token.guard.ts index 9e7949a1..542d3ce9 100755 --- a/src/guards/access-token.guard.ts +++ b/src/guards/access-token.guard.ts @@ -2,16 +2,17 @@ import type { SolidCoreSetting } from "src/services/settings/default-settings-pr import { CanActivate, ExecutionContext, - Inject, Injectable, UnauthorizedException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Request } from 'express'; +import { ERROR_MESSAGES } from "src/constants/error-messages"; import { ActiveUserData } from '../interfaces/active-user-data.interface'; import { REQUEST_USER_KEY } from "../constants"; import { PermissionMetadataService } from '../services/permission-metadata.service'; import { ClsService } from 'nestjs-cls'; +import { ActiveSessionStorageService } from "../services/active-session-storage.service"; import { SettingService } from '../services/setting.service'; @Injectable() @@ -19,6 +20,7 @@ export class AccessTokenGuard implements CanActivate { constructor( private readonly jwtService: JwtService, private readonly permissionsService: PermissionMetadataService, + private readonly activeSessionStorage: ActiveSessionStorageService, private readonly settingService: SettingService, private readonly cls: ClsService ) { } @@ -44,6 +46,23 @@ export class AccessTokenGuard implements CanActivate { jwtConfiguration ); + if ( + this.settingService.getConfigValue( + "preventConcurrentLogins", + ) + ) { + const activeSessionId = await this.activeSessionStorage.getActiveSession( + payload.sub, + ); + if ( + !payload.sessionId || + !activeSessionId || + payload.sessionId !== activeSessionId + ) { + throw new UnauthorizedException(ERROR_MESSAGES.SESSION_INVALID); + } + } + // Load permissions given the user. const permissions = await this.permissionsService.findAllUsingRoles(payload.roles); payload.permissions = permissions.map((permission) => permission.name); @@ -52,8 +71,10 @@ export class AccessTokenGuard implements CanActivate { this.cls.set(REQUEST_USER_KEY, payload); // console.log(`About to set payload in the request user key:`); // console.log(payload); - } catch { - throw new UnauthorizedException(); + } catch (err) { + throw err instanceof UnauthorizedException + ? err + : new UnauthorizedException(); } return true; } diff --git a/src/index.ts b/src/index.ts index b010b2b2..3d7adb18 100755 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ export * from './commands/run-tests.command' export * from './commands/test.command' export * from './config/cache.options' +export * from './services/active-session-storage.service' export * from './decorators/active-user.decorator' export * from './decorators/solid-request-context.decorator' diff --git a/src/interfaces/active-user-data.interface.ts b/src/interfaces/active-user-data.interface.ts index 7882f980..34bb8d1f 100755 --- a/src/interfaces/active-user-data.interface.ts +++ b/src/interfaces/active-user-data.interface.ts @@ -16,6 +16,12 @@ export interface ActiveUserData { */ email: string; + /** + * Logical login session identifier used to invalidate older sessions when + * concurrent logins are disabled. + */ + sessionId?: string; + /** * The subject's (user) roles. * These are part of the JWT token, we simply decode them. diff --git a/src/services/active-session-storage.service.ts b/src/services/active-session-storage.service.ts new file mode 100644 index 00000000..929e8e11 --- /dev/null +++ b/src/services/active-session-storage.service.ts @@ -0,0 +1,24 @@ +import { CACHE_MANAGER } from "@nestjs/cache-manager"; +import { Inject, Injectable } from "@nestjs/common"; +import { Cache } from "cache-manager"; + +@Injectable() +export class ActiveSessionStorageService { + constructor(@Inject(CACHE_MANAGER) private readonly cacheManager: Cache) {} + + async setActiveSession(userId: number, sessionId: string): Promise { + await this.cacheManager.set(this.getKey(userId), sessionId); + } + + async getActiveSession(userId: number): Promise { + return this.cacheManager.get(this.getKey(userId)); + } + + async clearActiveSession(userId: number): Promise { + await this.cacheManager.del(this.getKey(userId)); + } + + private getKey(userId: number): string { + return `active-session-${userId}`; + } +} diff --git a/src/services/authentication.service.ts b/src/services/authentication.service.ts index c1a4e8f9..cb46b28a 100755 --- a/src/services/authentication.service.ts +++ b/src/services/authentication.service.ts @@ -39,6 +39,7 @@ import { SignUpDto } from "../dtos/sign-up.dto"; import { User } from "../entities/user.entity"; import { EventDetails, EventType } from "../interfaces"; import { ActiveUserData } from "../interfaces/active-user-data.interface"; +import { ActiveSessionStorageService } from "./active-session-storage.service"; import { HashingService } from "./hashing.service"; import { InvalidatedRefreshTokenError, @@ -74,6 +75,7 @@ export class AuthenticationService { private readonly userRepository: UserRepository, private readonly hashingService: HashingService, private readonly jwtService: JwtService, + private readonly activeSessionStorage: ActiveSessionStorageService, private readonly refreshTokenIdsStorage: RefreshTokenIdsStorageService, private readonly httpService: HttpService, // private readonly mailService: SMTPEMailService, @@ -1677,8 +1679,16 @@ export class AuthenticationService { } async generateTokens(user: User) { + const sessionId = this.shouldPreventConcurrentLogins() + ? randomUUID() + : undefined; + if (sessionId) { + await this.activeSessionStorage.setActiveSession(user.id, sessionId); + } else { + await this.activeSessionStorage.clearActiveSession(user.id); + } const [accessToken, refreshToken] = await Promise.all([ - await this.generateAccessToken(user), + await this.generateAccessToken(user, sessionId), await this.generateRefreshToken(user), ]); @@ -1688,16 +1698,24 @@ export class AuthenticationService { }; } - async generateAccessToken(user: User) { + async generateAccessToken(user: User, sessionId?: string) { // const userRoleNames = user.roles.map((role) => role.name).join(';') const userRoleNames = user.roles.map((role) => role.name); + const resolvedSessionId = this.shouldPreventConcurrentLogins() + ? sessionId ?? (await this.activeSessionStorage.getActiveSession(user.id)) + : undefined; const accessTokenTtl = this.settingService.getConfigValue("accessTokenTtl"); const accessToken = await this.signToken>( user.id, accessTokenTtl, - { username: user.username, email: user.email, roles: userRoleNames }, + { + username: user.username, + email: user.email, + roles: userRoleNames, + ...(resolvedSessionId ? { sessionId: resolvedSessionId } : {}), + }, ); return accessToken; @@ -2116,6 +2134,14 @@ export class AuthenticationService { ); } + private shouldPreventConcurrentLogins(): boolean { + return ( + this.settingService.getConfigValue( + "preventConcurrentLogins", + ) === true + ); + } + private checkAccountBlocked(user: User): void { const maxFailedAttempts = this.settingService.getConfigValue( @@ -2172,6 +2198,7 @@ export class AuthenticationService { const userId = payload.sub; await this.refreshTokenIdsStorage.invalidate(userId); + await this.activeSessionStorage.clearActiveSession(userId); const user = await this.userRepository.findOne({ where: { id: userId, diff --git a/src/services/refresh-token-ids-storage.service.ts b/src/services/refresh-token-ids-storage.service.ts index 141244d6..95b5b601 100755 --- a/src/services/refresh-token-ids-storage.service.ts +++ b/src/services/refresh-token-ids-storage.service.ts @@ -6,6 +6,11 @@ import { AuthenticationService } from './authentication.service'; // TODO: Ideally this should be in a separate file - putting this here for brevity export class InvalidatedRefreshTokenError extends Error { } +type RefreshTokenState = { + currentRefreshToken: string; + previousRefreshToken: string; +}; + @Injectable() // export class RefreshTokenIdsStorageService implements OnApplicationBootstrap, OnApplicationShutdown { export class RefreshTokenIdsStorageService { @@ -29,10 +34,7 @@ export class RefreshTokenIdsStorageService { ) { } async insert(userId: number, refreshToken: string, previousRefreshToken?: string): Promise { - // TODO: save a refresh token object with this shape {"currentRefreshToken": "", "previousRefreshToken": ""} - // Save a refresh token object with the shape: { currentRefreshToken: string, previousRefreshToken: string } - const existing = (await this.cacheManager.get(this.getKey(userId))) as { currentRefreshToken?: string, previousRefreshToken?: string } | undefined; - const refreshTokenState = { + const refreshTokenState: RefreshTokenState = { currentRefreshToken: refreshToken, previousRefreshToken: previousRefreshToken ?? "", }; @@ -58,8 +60,7 @@ export class RefreshTokenIdsStorageService { // TODO: Assume you get this shape out of the cache {"currentRefreshToken": "", "previousRefreshToken": ""} // Then you will compare against the currentRefreshToken. - const refreshTokenState = await this.cacheManager.get(this.getKey(user.id)); - console.log("refreshTokenState", refreshTokenState); + const refreshTokenState = await this.cacheManager.get(this.getKey(user.id)) as RefreshTokenState | undefined; // Use the authentication service to generate a new refresh token, set it in the currentRefreshToken in scenario 1 and return. @@ -108,7 +109,7 @@ export class RefreshTokenIdsStorageService { valid = true; // Do not modify cache // Generate new refresh token based on currentRefreshToken - const existingRefreshTokenState = (await this.cacheManager.get(this.getKey(user.id))) as { currentRefreshToken?: string, previousRefreshToken?: string } | undefined; + const existingRefreshTokenState = (await this.cacheManager.get(this.getKey(user.id))) as RefreshTokenState | undefined; newRefreshToken = existingRefreshTokenState?.currentRefreshToken; } } @@ -126,7 +127,7 @@ export class RefreshTokenIdsStorageService { return `user-${userId}`; } - getCurrentRefreshTokenState(userId: number): Promise { + getCurrentRefreshTokenState(userId: number): Promise { return this.cacheManager.get(this.getKey(userId)); } } diff --git a/src/services/settings/default-settings-provider.service.ts b/src/services/settings/default-settings-provider.service.ts index 9fd6e466..6db0fc41 100644 --- a/src/services/settings/default-settings-provider.service.ts +++ b/src/services/settings/default-settings-provider.service.ts @@ -1201,7 +1201,16 @@ const getSolidCoreSettings = (isProd: boolean) => value: parseInt(process.env.IAM_JWT_REFRESH_TOKEN_TTL ?? "604800", 10), level: SettingLevel.SystemEnv, }, - + { + moduleName: "solid-core", + key: "preventConcurrentLogins", + value: false, + level: SettingLevel.SystemAdminEditable, + label: "Prevent Concurrent Logins", + group: "authentication-settings", + sortOrder: 200, + controlType: "boolean", + }, // queues-settings-provider.service.ts { moduleName: "solid-core", diff --git a/src/solid-core.module.ts b/src/solid-core.module.ts index f89b3bf8..861b0883 100644 --- a/src/solid-core.module.ts +++ b/src/solid-core.module.ts @@ -153,6 +153,7 @@ import { TwilioSmsQueueSubscriberRedis } from "./jobs/redis/twilio-sms-subscribe import { UserRegistrationListener } from "./listeners/user-registration.listener"; import { GoogleOauthStrategy } from "./passport-strategies/google-oauth.strategy"; import { ApiKeyService } from "./services/api-key.service"; +import { ActiveSessionStorageService } from "./services/active-session-storage.service"; import { AuthenticationService } from "./services/authentication.service"; import { BcryptService } from "./services/bcrypt.service"; import { UuidExternalIdEntityComputedFieldProvider } from "./services/computed-fields/entity/uuid-externalid-entity-computed-field-provider.service"; @@ -647,6 +648,7 @@ import { PostgresDatasourceIntrospectionProviderService } from "./services/datas AccessTokenGuard, ApiKeyGuard, ApiKeyService, + ActiveSessionStorageService, AuthenticationService, GoogleAuthenticationController, RefreshTokenIdsStorageService, From e0e8d432f0641841069304263fe519e3973764ab Mon Sep 17 00:00:00 2001 From: Chetan Date: Mon, 6 Jul 2026 16:45:55 +0530 Subject: [PATCH 02/16] class serializer interceptor added for impact of Exclude and Expose --- src/helpers/bootstrap.helper.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/helpers/bootstrap.helper.ts b/src/helpers/bootstrap.helper.ts index cd06ba7e..3b78b568 100644 --- a/src/helpers/bootstrap.helper.ts +++ b/src/helpers/bootstrap.helper.ts @@ -1,5 +1,5 @@ -import { ValidationPipe } from '@nestjs/common'; -import { NestFactory } from '@nestjs/core'; +import { ClassSerializerInterceptor, ValidationPipe } from '@nestjs/common'; +import { NestFactory, Reflector } from '@nestjs/core'; import { WsAdapter } from '@nestjs/platform-ws'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { NextFunction, Request, Response } from 'express'; @@ -169,7 +169,13 @@ export async function bootstrapSolidApp( } // Global interceptor - app.useGlobalInterceptors(new WrapResponseInterceptor()); + app.useGlobalInterceptors( + new ClassSerializerInterceptor(app.get(Reflector), { + enableImplicitConversion: true, // optional but handy + // groups: ['client'], // if you use groups + }), + new WrapResponseInterceptor() + ); // CORS app.enableCors(buildDefaultCorsOptions()); From 5dad052ffc9c5fbfa36b6e7f6597f9ba6043ab38 Mon Sep 17 00:00:00 2001 From: Oswald Rodrigues Date: Thu, 9 Jul 2026 11:47:34 +0530 Subject: [PATCH 03/16] chatter message endpoint made public --- src/controllers/chatter-message.controller.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/controllers/chatter-message.controller.ts b/src/controllers/chatter-message.controller.ts index 361b1feb..0643ce14 100644 --- a/src/controllers/chatter-message.controller.ts +++ b/src/controllers/chatter-message.controller.ts @@ -61,7 +61,6 @@ export class ChatterMessageController { // return this.service.recover(id); // } - @Public() @Get('/getChatterMessages/:entityId/:entityName') @ApiQuery({ name: 'showSoftDeleted', required: false, enum: ShowSoftDeleted }) @ApiQuery({ name: 'limit', required: false, type: Number }) From a731fa6c67c9c658e82bb0dfa20c253fc2241850 Mon Sep 17 00:00:00 2001 From: SaibazKhan Date: Thu, 9 Jul 2026 14:30:44 +0530 Subject: [PATCH 04/16] add security against chatter --- src/controllers/chatter-message.controller.ts | 13 ++++---- .../seed-data/solid-core-metadata.json | 1 - src/services/chatter-message.service.ts | 32 +++++++++++++++++-- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/controllers/chatter-message.controller.ts b/src/controllers/chatter-message.controller.ts index 0643ce14..7b4951fa 100644 --- a/src/controllers/chatter-message.controller.ts +++ b/src/controllers/chatter-message.controller.ts @@ -20,12 +20,12 @@ enum ShowSoftDeleted { export class ChatterMessageController { constructor(private readonly service: ChatterMessageService) {} - @ApiBearerAuth("jwt") - @Post() - @UseInterceptors(AnyFilesInterceptor()) - create(@Body() createDto: CreateChatterMessageDto, @UploadedFiles() files: Array) { - return this.service.create(createDto, files); - } + // @ApiBearerAuth("jwt") + // @Post() + // @UseInterceptors(AnyFilesInterceptor()) + // create(@Body() createDto: CreateChatterMessageDto, @UploadedFiles() files: Array) { + // return this.service.create(createDto, files); + // } // @ApiBearerAuth("jwt") // @Post('/bulk') @@ -61,6 +61,7 @@ export class ChatterMessageController { // return this.service.recover(id); // } + @ApiBearerAuth("jwt") @Get('/getChatterMessages/:entityId/:entityName') @ApiQuery({ name: 'showSoftDeleted', required: false, enum: ShowSoftDeleted }) @ApiQuery({ name: 'limit', required: false, type: Number }) diff --git a/src/seeders/seed-data/solid-core-metadata.json b/src/seeders/seed-data/solid-core-metadata.json index eea5f383..02fd75e2 100755 --- a/src/seeders/seed-data/solid-core-metadata.json +++ b/src/seeders/seed-data/solid-core-metadata.json @@ -5304,7 +5304,6 @@ "SavedFiltersController.update", "SavedFiltersController.insertMany", "SavedFiltersController.create", - "ChatterMessageController.create", "ChatterMessageController.getChatterMessages", "ChatterMessageController.postMessage", "ChatterMessageController.findMany", diff --git a/src/services/chatter-message.service.ts b/src/services/chatter-message.service.ts index 790c0184..d6e198f8 100644 --- a/src/services/chatter-message.service.ts +++ b/src/services/chatter-message.service.ts @@ -23,6 +23,7 @@ import { ChatterMessage } from '../entities/chatter-message.entity'; import { getMediaStorageProvider } from './mediaStorageProviders'; import { RequestContextService } from './request-context.service'; import { Logger } from '@nestjs/common'; +import { SolidIntrospectService } from './solid-introspect.service'; @Injectable() export class ChatterMessageService extends CRUDService { @@ -50,6 +51,26 @@ export class ChatterMessageService extends CRUDService { super(entityManager, repo, 'chatterMessage', 'solid-core', moduleRef); } + private getCoModelService(coModelName: string): CRUDService { + const introspectService = this.moduleRef.get(SolidIntrospectService, { strict: false }); + const modelSingularName = lowerFirst(coModelName); + const coModelService = introspectService?.getCRUDService(modelSingularName); + if (!coModelService) { + throw new BadRequestException(ERROR_MESSAGES.MODEL_SERVICE_NOT_FOUND(modelSingularName)); + } + return coModelService; + } + + private async assertRecordAccess(coModelName: string, coModelEntityId: number) { + const activeUser = this.requestContextService.getActiveUser(); + if (!activeUser) { + throw new ForbiddenException(ERROR_MESSAGES.FORBIDDEN); + } + + const coModelService = this.getCoModelService(coModelName); + await coModelService.findOne(coModelEntityId, {}, { activeUser }); + } + private resolveMessageUserId(userId?: number | null): number | null { if (userId) { return userId; @@ -96,6 +117,8 @@ export class ChatterMessageService extends CRUDService { throw new NotFoundException(`Entity [solid-core.chatterMessage] with id ${id} not found`); } + await this.assertRecordAccess(message.coModelName, message.coModelEntityId); + message.status = CHATTER_MESSAGE_STATUS.COMPLETED; return this.repo.save(message); } @@ -111,6 +134,8 @@ export class ChatterMessageService extends CRUDService { throw new NotFoundException(`Entity [solid-core.chatterMessage] with id ${id} not found`); } + await this.assertRecordAccess(message.coModelName, message.coModelEntityId); + if (!this.isEditableCustomNoteMessage(message)) { throw new BadRequestException('Only custom note messages can be edited.'); } @@ -193,17 +218,20 @@ export class ChatterMessageService extends CRUDService { } async postMessage(postDto: PostChatterMessageDto, files: Express.Multer.File[] = []) { + const coModelName = lowerFirst(postDto.coModelName); + await this.assertRecordAccess(coModelName, postDto.coModelEntityId); + const chatterMessage = new ChatterMessage(); chatterMessage.messageType = CHATTER_MESSAGE_TYPE.CUSTOM; chatterMessage.messageSubType = postDto.messageSubType || CHATTER_MESSAGE_SUBTYPE.CUSTOM; chatterMessage.status = postDto.status ?? CHATTER_MESSAGE_STATUS.PENDING; chatterMessage.messageBody = postDto.messageBody; chatterMessage.coModelEntityId = postDto.coModelEntityId; - chatterMessage.coModelName = postDto.coModelName; + chatterMessage.coModelName = coModelName; chatterMessage.modelUserKey = postDto.modelUserKey ?? null; const model = await this.modelMetadataRepo.findOne({ - where: { singularName: lowerFirst(postDto.coModelName) }, + where: { singularName: coModelName }, relations: { userKeyField: true } }); chatterMessage.modelDisplayName = model?.displayName ?? null; From 56f72b4192a7b2233ab60c7b4d5b9f66d94ef4ec Mon Sep 17 00:00:00 2001 From: Oswald Rodrigues Date: Thu, 9 Jul 2026 15:23:04 +0530 Subject: [PATCH 05/16] fix to allow linking a userKey belonging to the parent model while seeding a child model --- src/seeders/module-metadata-seeder.service.ts | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/seeders/module-metadata-seeder.service.ts b/src/seeders/module-metadata-seeder.service.ts index 669ba9df..3fe1aa96 100755 --- a/src/seeders/module-metadata-seeder.service.ts +++ b/src/seeders/module-metadata-seeder.service.ts @@ -1536,8 +1536,9 @@ export class ModuleMetadataSeederService { const { fields: fieldsMetadata, ...modelMetaDataWithoutFields } = modelMetadata; // Load and set the parent model if it exists. + let parentModel: ModelMetadata | null = null; if (modelMetadata.isChild && modelMetadata.parentModelUserKey) { - const parentModel = await this.getModelByUserKeyCached(modelMetadata.parentModelUserKey, { + parentModel = await this.getModelByUserKeyCached(modelMetadata.parentModelUserKey, { moduleName: moduleMetadata.name, component: 'module-model-fields', details: `parentModel=${modelMetadata.parentModelUserKey}`, @@ -1605,7 +1606,27 @@ export class ModuleMetadataSeederService { } } - // Now that we have created fields & model update the model to stamp the userKeyField. + // If userKeyField wasn't found among this model's own fields (e.g. STI "extension" models + // whose userKeyField is a column inherited from the parent entity), look it up on the + // parent model by the same name. + if (!userKeyField && modelMetadata.isChild && parentModel && userKeyFieldName) { + const inheritedUserKeyField = await this.timeOperation('field-find-inherited-user-key', () => fieldMetadataRepo.findOne({ + where: { + model: { id: parentModel.id }, + name: userKeyFieldName, + }, + }), { + moduleName: moduleMetadata.name, + component: 'module-model-fields', + serviceCall: 'fieldMetadataRepo.findOne', + details: `model=${modelMetadata.singularName} parentModel=${modelMetadata.parentModelUserKey} field=${userKeyFieldName}`, + }); + if (inheritedUserKeyField) { + userKeyField = inheritedUserKeyField; + } + } + + // Now that we have created fields & model update the model to stamp the userKeyField. if (userKeyField) { modelMetaDataWithoutFields['userKeyField'] = userKeyField; await this.timeOperation('model-user-key-field-upsert', () => this.modelMetadataService.upsert(modelMetaDataWithoutFields), { From a5e40a89366afe92fe341966e20c45f87c69052d Mon Sep 17 00:00:00 2001 From: Chetan Date: Thu, 9 Jul 2026 16:14:32 +0530 Subject: [PATCH 06/16] updatedAt value is updating now --- src/services/crud.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/services/crud.service.ts b/src/services/crud.service.ts index 33d2cf84..92a2cec6 100755 --- a/src/services/crud.service.ts +++ b/src/services/crud.service.ts @@ -232,6 +232,7 @@ export class CRUDService { // Add two generic value i.e // 5. Save the entity // For media, we need to use a storage provider and save the media, then save the associated uri against the entity or media table const mergedEntity = this.repo.merge(entity, updateDto); + delete mergedEntity.updatedAt; const savedEntity = await this.repo.save(mergedEntity) as T; //FIXME: Skip the many-to-many, and instead fire differential updates and avoid loading the entire association graph for the ids From 2e3d38de9ee83df7828e5b3869818e15b043f1f8 Mon Sep 17 00:00:00 2001 From: SaibazKhan Date: Thu, 9 Jul 2026 19:15:28 +0530 Subject: [PATCH 07/16] add validation on get chatter message --- src/services/chatter-message.service.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/services/chatter-message.service.ts b/src/services/chatter-message.service.ts index d6e198f8..34cc597e 100644 --- a/src/services/chatter-message.service.ts +++ b/src/services/chatter-message.service.ts @@ -711,6 +711,8 @@ export class ChatterMessageService extends CRUDService { const { limit = 25, offset = 0, populate = [], populateMedia = [], filters } = query; this.logHeapUsed('getChatterMessages-start'); + await this.assertRecordAccess(lowerFirst(entityName), entityId); + const model = await this.modelMetadataRepo.findOne({ where: { singularName: entityName From 75260f75dbac74a006ea68e630bb788963e2af7b Mon Sep 17 00:00:00 2001 From: Chetan Date: Fri, 10 Jul 2026 10:35:20 +0530 Subject: [PATCH 08/16] solidx session invalid changes --- src/guards/access-token.guard.ts | 10 ++++---- src/guards/authentication.guard.ts | 23 ++++++++++++++++++- ...solid-core-error-codes-provider.service.ts | 21 ++++++++++++++++- 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/src/guards/access-token.guard.ts b/src/guards/access-token.guard.ts index 542d3ce9..4afaf0a7 100755 --- a/src/guards/access-token.guard.ts +++ b/src/guards/access-token.guard.ts @@ -54,11 +54,11 @@ export class AccessTokenGuard implements CanActivate { const activeSessionId = await this.activeSessionStorage.getActiveSession( payload.sub, ); - if ( - !payload.sessionId || - !activeSessionId || - payload.sessionId !== activeSessionId - ) { + + // Tokens issued before concurrent-login prevention was enabled do not + // carry a sessionId. Keep them valid until a newer login establishes + // an active session to compare against. + if (activeSessionId && payload.sessionId !== activeSessionId) { throw new UnauthorizedException(ERROR_MESSAGES.SESSION_INVALID); } } diff --git a/src/guards/authentication.guard.ts b/src/guards/authentication.guard.ts index d88c1fed..20d11b3d 100755 --- a/src/guards/authentication.guard.ts +++ b/src/guards/authentication.guard.ts @@ -32,6 +32,22 @@ export class AuthenticationGuard implements CanActivate { private readonly cls: ClsService, ) { } + private isGenericUnauthorizedError(error: unknown): boolean { + if (!(error instanceof UnauthorizedException)) { + return false; + } + + const response = error.getResponse(); + const message = + typeof response === 'string' + ? response + : Array.isArray((response as any)?.message) + ? (response as any).message[0] + : (response as any)?.message; + + return !message || message === 'Unauthorized'; + } + async canActivate(context: ExecutionContext): Promise { // If method marked as public, then we return with true, else go ahead and apply the access token guard. const contextLog = context.getHandler(); @@ -72,7 +88,12 @@ export class AuthenticationGuard implements CanActivate { const canActivate = await Promise.resolve( instance.canActivate(context), ).catch((err) => { - error = err; + if ( + this.isGenericUnauthorizedError(error) || + !this.isGenericUnauthorizedError(err) + ) { + error = err; + } }); if (canActivate) { diff --git a/src/helpers/solid-core-error-codes-provider.service.ts b/src/helpers/solid-core-error-codes-provider.service.ts index 84f6a24a..cea81375 100644 --- a/src/helpers/solid-core-error-codes-provider.service.ts +++ b/src/helpers/solid-core-error-codes-provider.service.ts @@ -1,5 +1,6 @@ // src/common/errors/providers/solidcore-error-code.provider.ts import { Injectable } from '@nestjs/common'; +import { ERROR_MESSAGES } from 'src/constants/error-messages'; import { ErrorCodeProvider } from 'src/decorators/error-codes-provider.decorator'; import { ErrorMeta, ErrorRule, IErrorCodeProvider } from 'src/interfaces'; @@ -13,6 +14,24 @@ export class SolidCoreErrorCodesProvider implements IErrorCodeProvider { rules(): ReadonlyArray { return [ + { + code: 'solidx-session-invalid', + priority: 110, + match: (txt) => txt.includes(ERROR_MESSAGES.SESSION_INVALID.toLowerCase()), + meta: { + message: ERROR_MESSAGES.SESSION_INVALID, + httpStatus: 401, + }, + }, + { + code: 'solidx-session-expired', + priority: 110, + match: (txt) => txt.includes(ERROR_MESSAGES.SESSION_EXPIRED.toLowerCase()), + meta: { + message: ERROR_MESSAGES.SESSION_EXPIRED, + httpStatus: 401, + }, + }, { code: 'solidx-mcp-server-unavailable', priority: 100, @@ -60,4 +79,4 @@ export class SolidCoreErrorCodesProvider implements IErrorCodeProvider { const rule = this.rules().find((r) => r.code === code); return rule?.meta; } -} \ No newline at end of file +} From 4690d1f4ca4e1dd5322dd57c3201b139ea2c0720 Mon Sep 17 00:00:00 2001 From: Chetan Date: Fri, 10 Jul 2026 18:25:04 +0530 Subject: [PATCH 09/16] some fixes --- src/guards/access-token.guard.ts | 51 ++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/src/guards/access-token.guard.ts b/src/guards/access-token.guard.ts index 4afaf0a7..b478a60a 100755 --- a/src/guards/access-token.guard.ts +++ b/src/guards/access-token.guard.ts @@ -14,6 +14,7 @@ import { PermissionMetadataService } from '../services/permission-metadata.servi import { ClsService } from 'nestjs-cls'; import { ActiveSessionStorageService } from "../services/active-session-storage.service"; import { SettingService } from '../services/setting.service'; +import { createHash } from "crypto"; @Injectable() export class AccessTokenGuard implements CanActivate { @@ -46,22 +47,8 @@ export class AccessTokenGuard implements CanActivate { jwtConfiguration ); - if ( - this.settingService.getConfigValue( - "preventConcurrentLogins", - ) - ) { - const activeSessionId = await this.activeSessionStorage.getActiveSession( - payload.sub, - ); - - // Tokens issued before concurrent-login prevention was enabled do not - // carry a sessionId. Keep them valid until a newer login establishes - // an active session to compare against. - if (activeSessionId && payload.sessionId !== activeSessionId) { - throw new UnauthorizedException(ERROR_MESSAGES.SESSION_INVALID); - } - } + // Prevent Concurrent Login Feature + await this.validateConcurrentLoginSession(payload, token); // Load permissions given the user. const permissions = await this.permissionsService.findAllUsingRoles(payload.roles); @@ -85,4 +72,36 @@ export class AccessTokenGuard implements CanActivate { const [_, token] = request.headers.authorization?.split(' ') ?? []; return token; } + + private resolveSessionId(payload: ActiveUserData, token: string): string { + if (payload.sessionId) { + return payload.sessionId; + } + + // Legacy tokens (issued before preventConcurrentLogins was enabled) + // have no sessionId claim, so derive a stable fallback from the token. + return createHash('sha256').update(token).digest('hex'); + } + + private async validateConcurrentLoginSession(payload: ActiveUserData, token: string,): Promise { + if (!this.settingService.getConfigValue("preventConcurrentLogins",)) { + return; + } + + const activeSessionId = await this.activeSessionStorage.getActiveSession(payload.sub); + const currentSessionId = this.resolveSessionId(payload, token); + + if (!activeSessionId) { + // No active session recorded yet for this user — this is the first + // request we've seen since the feature was enabled (or storage was + // cleared). Adopt this request's session as the active one so this + // browser stays logged in going forward. + await this.activeSessionStorage.setActiveSession(payload.sub, currentSessionId,); + return; + } + + if (currentSessionId !== activeSessionId) { + throw new UnauthorizedException(ERROR_MESSAGES.SESSION_INVALID); + } + } } From a0f59568e23885bd651d4aaadde4cd7729926dff Mon Sep 17 00:00:00 2001 From: SaibazKhan Date: Mon, 13 Jul 2026 14:58:23 +0530 Subject: [PATCH 10/16] add private media support --- src/controllers/media.controller.ts | 66 ++-- src/dtos/create-media.dto.ts | 9 +- src/dtos/update-media.dto.ts | 9 +- src/entities/media.entity.ts | 5 +- src/repository/media.repository.ts | 3 + .../seed-data/solid-core-metadata.json | 87 +++++- src/services/media.service.ts | 285 ++++++++++++++---- .../file-s3-storage-provider.ts | 34 ++- .../file-storage-provider.ts | 65 +++- 9 files changed, 459 insertions(+), 104 deletions(-) diff --git a/src/controllers/media.controller.ts b/src/controllers/media.controller.ts index a5ca6c47..6752be94 100644 --- a/src/controllers/media.controller.ts +++ b/src/controllers/media.controller.ts @@ -1,10 +1,12 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, UploadedFiles, UseInterceptors } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, Res, UploadedFiles, UseInterceptors } from '@nestjs/common'; import { AnyFilesInterceptor } from "@nestjs/platform-express"; import { ApiBearerAuth, ApiQuery, ApiTags } from '@nestjs/swagger'; -import { Public } from 'src/decorators/public.decorator'; import { CreateMediaDto } from 'src/dtos/create-media.dto'; +import { SolidRequestContextDecorator } from 'src/decorators/solid-request-context.decorator'; +import { SolidRequestContextDto } from 'src/dtos/solid-request-context.dto'; import { UpdateMediaDto } from 'src/dtos/update-media.dto'; import { MediaService } from 'src/services/media.service'; +import { Response } from 'express'; enum ShowSoftDeleted { INCLUSIVE = "inclusive", @@ -21,30 +23,30 @@ export class MediaController { @ApiBearerAuth("jwt") @Post() @UseInterceptors(AnyFilesInterceptor()) - create(@Body() createDto: CreateMediaDto, @UploadedFiles() files: Array) { - return this.service.create(createDto, files); + create(@Body() createDto: CreateMediaDto, @UploadedFiles() files: Array, @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { + return this.service.create(createDto, files, solidRequestContext); } @ApiBearerAuth("jwt") @Post('/bulk') @UseInterceptors(AnyFilesInterceptor()) - insertMany(@Body() createDtos: CreateMediaDto[], @UploadedFiles() filesArray: Express.Multer.File[][] = []) { - return this.service.insertMany(createDtos, filesArray); + insertMany(@Body() createDtos: CreateMediaDto[], @UploadedFiles() filesArray: Express.Multer.File[][] = [], @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { + return this.service.insertMany(createDtos, filesArray, solidRequestContext); } @ApiBearerAuth("jwt") @Put(':id') @UseInterceptors(AnyFilesInterceptor()) - update(@Param('id') id: number, @Body() updateDto: UpdateMediaDto, @UploadedFiles() files: Array) { - return this.service.update(id, updateDto, files); + update(@Param('id') id: number, @Body() updateDto: UpdateMediaDto, @UploadedFiles() files: Array, @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { + return this.service.update(id, updateDto, files, false, solidRequestContext); } @ApiBearerAuth("jwt") @Patch(':id') @UseInterceptors(AnyFilesInterceptor()) - partialUpdate(@Param('id') id: number, @Body() updateDto: UpdateMediaDto, @UploadedFiles() files: Array) { - return this.service.update(id, updateDto, files, true); + partialUpdate(@Param('id') id: number, @Body() updateDto: UpdateMediaDto, @UploadedFiles() files: Array, @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { + return this.service.update(id, updateDto, files, true, solidRequestContext); } // @Public() @@ -52,22 +54,22 @@ export class MediaController { @ApiBearerAuth("jwt") @Post('/upload') @UseInterceptors(AnyFilesInterceptor()) - upload(@UploadedFiles() files: Array, @Body() createDto: CreateMediaDto, ) { + upload(@UploadedFiles() files: Array, @Body() createDto: CreateMediaDto, @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { // this.logger.log(`Creating a new model: ${JSON.stringify(createDto)}`); - return this.service.upload(createDto, files); + return this.service.upload(createDto, files, solidRequestContext); } @ApiBearerAuth("jwt") @Post('/bulk-recover') - async recoverMany(@Body() ids: number[]) { - return this.service.recoverMany(ids); + async recoverMany(@Body() ids: number[], @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { + return this.service.recoverMany(ids, solidRequestContext); } @ApiBearerAuth("jwt") @Get('/recover/:id') - async recover(@Param('id') id: number) { - return this.service.recover(id); + async recover(@Param('id') id: number, @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { + return this.service.recover(id, solidRequestContext); } @ApiBearerAuth("jwt") @@ -81,26 +83,42 @@ export class MediaController { @ApiQuery({ name: 'populateMedia', required: false, type: Array }) @ApiQuery({ name: 'filters', required: false, type: Array }) @Get() - async findMany(@Query() query: any) { - return this.service.find(query); + async findMany(@Query() query: any, @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { + return this.service.find(query, solidRequestContext); + } + + @ApiBearerAuth("jwt") + @Get(':id/download') + async download(@Param('id') id: string, @Res() res: Response, @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { + const media = await this.service.findOne(+id, {}, solidRequestContext); + const { stream, fileName, mimeType, redirectUrl } = await this.service.getDownloadStream(media); + + if (redirectUrl) { + return res.redirect(302, redirectUrl); + } + + res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); + res.setHeader('Content-Type', mimeType); + res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition, Content-Type'); + stream.pipe(res); } @ApiBearerAuth("jwt") @Get(':id') - async findOne(@Param('id') id: string, @Query() query: any) { - return this.service.findOne(+id, query); + async findOne(@Param('id') id: string, @Query() query: any, @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { + return this.service.findOne(+id, query, solidRequestContext); } @ApiBearerAuth("jwt") @Delete('/bulk') - async deleteMany(@Body() ids: number[]) { - return this.service.deleteMany(ids); + async deleteMany(@Body() ids: number[], @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { + return this.service.deleteMany(ids, solidRequestContext); } @ApiBearerAuth("jwt") @Delete(':id') - async delete(@Param('id') id: number) { - return this.service.delete(id); + async delete(@Param('id') id: number, @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { + return this.service.delete(id, solidRequestContext); } diff --git a/src/dtos/create-media.dto.ts b/src/dtos/create-media.dto.ts index 47d8a1be..05019c08 100644 --- a/src/dtos/create-media.dto.ts +++ b/src/dtos/create-media.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsInt } from 'class-validator'; +import { IsBoolean, IsInt } from 'class-validator'; import { IsNotEmpty, IsString, IsOptional } from 'class-validator'; export class CreateMediaDto { @IsNotEmpty() @@ -7,6 +7,11 @@ export class CreateMediaDto { @ApiProperty() entityId: number; +@IsOptional() +@IsBoolean() +@ApiProperty() +isPublic: boolean; + @IsOptional() @IsString() @ApiProperty() @@ -56,4 +61,4 @@ fieldMetadataId: number; @IsOptional() @ApiProperty() fieldMetadataUserKey: string; -} \ No newline at end of file +} diff --git a/src/dtos/update-media.dto.ts b/src/dtos/update-media.dto.ts index 25e75cdf..5879ba31 100644 --- a/src/dtos/update-media.dto.ts +++ b/src/dtos/update-media.dto.ts @@ -1,4 +1,4 @@ -import { IsInt,IsOptional, IsNotEmpty, IsString } from 'class-validator'; +import { IsBoolean, IsInt,IsOptional, IsNotEmpty, IsString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; export class UpdateMediaDto { @IsOptional() @@ -11,6 +11,11 @@ export class UpdateMediaDto { @ApiProperty() entityId: number; +@IsOptional() +@IsBoolean() +@ApiProperty() +isPublic: boolean; + @IsOptional() @IsString() @ApiProperty() @@ -60,4 +65,4 @@ fieldMetadataId: number; @IsOptional() @ApiProperty() fieldMetadataUserKey: string; -} \ No newline at end of file +} diff --git a/src/entities/media.entity.ts b/src/entities/media.entity.ts index a0f73b0a..8e982af0 100644 --- a/src/entities/media.entity.ts +++ b/src/entities/media.entity.ts @@ -9,6 +9,9 @@ export class Media extends CommonEntity { @Column({ type: "integer" }) entityId: number; + @Column({ name: "is_public", nullable: true }) + isPublic: boolean; + @Column({ type: "varchar", nullable: true }) relativeUri: string; @@ -36,4 +39,4 @@ export class Media extends CommonEntity { @ManyToOne(() => FieldMetadata, { nullable: false }) @JoinColumn() fieldMetadata: FieldMetadata; -} \ No newline at end of file +} diff --git a/src/repository/media.repository.ts b/src/repository/media.repository.ts index 3808848d..6a993f89 100644 --- a/src/repository/media.repository.ts +++ b/src/repository/media.repository.ts @@ -44,6 +44,9 @@ export class MediaRepository extends SolidBaseRepository { id: createDto['mediaStorageProviderMetadataId'] }, }); + if (createDto['isPublic'] === undefined) { + createDto['isPublic'] = createDto['mediaStorageProviderMetadata']?.isPublic !== false; + } const media = this.create(createDto); return this.save(media); } diff --git a/src/seeders/seed-data/solid-core-metadata.json b/src/seeders/seed-data/solid-core-metadata.json index eea5f383..4d7ae062 100755 --- a/src/seeders/seed-data/solid-core-metadata.json +++ b/src/seeders/seed-data/solid-core-metadata.json @@ -1704,6 +1704,18 @@ "columnName": "original_file_name", "isSystem": true }, + { + "name": "isPublic", + "displayName": "Is Public", + "type": "boolean", + "required": false, + "unique": false, + "index": false, + "private": false, + "encrypt": false, + "columnName": "is_public", + "isSystem": true + }, { "name": "modelMetadata", "displayName": "Model Metadata", @@ -5294,6 +5306,9 @@ "FieldMetadataController.getSelectionDynamicValues", "FieldMetadataController.getSelectionDynamicValue", "FieldMetadataController.findFieldDefaultMetaData", + "MediaController.findMany", + "MediaController.findOne", + "MediaController.download", "SavedFiltersController.delete", "SavedFiltersController.deleteMany", "SavedFiltersController.findOne", @@ -8747,6 +8762,13 @@ "sortable": false } }, + { + "type": "field", + "attrs": { + "name": "isPublic", + "sortable": true + } + }, { "type": "field", "attrs": { @@ -8842,6 +8864,14 @@ "isSearchable": true } }, + { + "type": "field", + "attrs": { + "name": "isPublic", + "label": "isPublic", + "isSearchable": true + } + }, { "type": "field", "attrs": { @@ -8910,6 +8940,12 @@ "name": "mediaStorageProviderMetadata" } }, + { + "type": "field", + "attrs": { + "name": "isPublic" + } + }, { "type": "field", "attrs": { @@ -14505,7 +14541,15 @@ "mediaStorageProviders": [ { "name": "default-filesystem", - "type": "filesystem" + "type": "filesystem", + "isPublic": true, + "localPath": "media-files-storage" + }, + { + "name": "private-filesystem", + "type": "filesystem", + "isPublic": false, + "localPath": "media-private-files-storage" }, { "name": "default-aws-s3", @@ -14541,6 +14585,47 @@ } } } + }, + { + "name": "model:Media-role:Internal User-own", + "description": "Show Media records owned by the current user", + "roleUserKey": "Internal User", + "modelMetadataUserKey": "media", + "securityRuleConfig": { + "filters": { + "createdBy": { + "$eq": "$activeUserId" + } + } + } + }, + { + "name": "model:Media-role:Internal User-public", + "description": "Show public Media records", + "roleUserKey": "Internal User", + "modelMetadataUserKey": "media", + "securityRuleConfig": { + "filters": { + "mediaStorageProviderMetadata": { + "isPublic": { + "$eq": true + } + } + } + } + }, + { + "name": "model:Media-role:Admin", + "description": "Show All Media records", + "roleUserKey": "Admin", + "modelMetadataUserKey": "media", + "securityRuleConfig": { + "filters": { + "id": { + "$ne": "0" + } + } + } } ], "modelSequences": [], diff --git a/src/services/media.service.ts b/src/services/media.service.ts index 0b760200..8149fd4d 100644 --- a/src/services/media.service.ts +++ b/src/services/media.service.ts @@ -1,8 +1,9 @@ -import { forwardRef, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { forwardRef, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { ModuleRef } from "@nestjs/core"; import { InjectEntityManager } from '@nestjs/typeorm'; import { EntityManager, In } from 'typeorm'; import * as path from 'path'; +import { Readable } from 'stream'; import { DEFAULT_MEDIA_FILE_STORAGE_DIR } from "src/services/settings/default-settings-provider.service"; import type { SolidCoreSetting } from "src/services/settings/default-settings-provider.service"; @@ -15,15 +16,19 @@ import { ERROR_MESSAGES } from 'src/constants/error-messages'; import { BasicFilterDto } from 'src/dtos/basic-filters.dto'; import { MediaStorageProviderType } from 'src/dtos/create-media-storage-provider-metadata.dto'; import { Media } from 'src/entities/media.entity'; +import { MediaStorageProviderMetadata } from 'src/entities/media-storage-provider-metadata.entity'; import { FieldMetadataRepository } from 'src/repository/field-metadata.repository'; import { MediaStorageProviderMetadataRepository } from 'src/repository/media-storage-provider-metadata.repository'; import { MediaRepository } from 'src/repository/media.repository'; import { ModelMetadataRepository } from 'src/repository/model-metadata.repository'; import { getMediaStorageProvider } from "./mediaStorageProviders"; +const DEFAULT_PRIVATE_MEDIA_STORAGE_DIR = "media-private-files-storage"; @Injectable() export class MediaService extends CRUDService { + private readonly logger = new Logger(MediaService.name); + constructor( readonly configService: ConfigService, readonly diskFileService: DiskFileService, @@ -51,75 +56,73 @@ export class MediaService extends CRUDService { async find(basicFilterDto: BasicFilterDto, solidRequestContext: any = {}) { const data = await super.find(basicFilterDto, solidRequestContext); if (data.records) { - - for (const media of data.records) { - const mediaStorageProvider = media.mediaStorageProviderMetadata; - - if (mediaStorageProvider?.type === MediaStorageProviderType.Filesystem) { - media.relativeUri = await this.diskFileService.getUrl(this.getFullFilePathForDisk(media.relativeUri)); - } else if (mediaStorageProvider?.type === MediaStorageProviderType.AwsS3) { - media.relativeUri = await this.s3FileService.getUrl(`${mediaStorageProvider.bucketName}:${media.relativeUri}`, { region: mediaStorageProvider.region }); - } - } + await this.decorateMediaRecords(data.records); } if (data.groupRecords) { - for (const group of data.groupRecords) { - for (const media of group.groupData.records) { - const mediaStorageProvider = media.mediaStorageProviderMetadata; - - if (mediaStorageProvider?.type === MediaStorageProviderType.Filesystem) { - media.relativeUri = await this.diskFileService.getUrl(this.getFullFilePathForDisk(media.relativeUri)); - } - else if (mediaStorageProvider?.type === MediaStorageProviderType.AwsS3) { - media.relativeUri = await this.s3FileService.getUrl(`${mediaStorageProvider.bucketName}:${media.relativeUri}`, { region: mediaStorageProvider.region }); - } - } + await this.decorateMediaRecords(group.groupData.records); } } return data } - async upload(createDto: any, files: Array) { + async findOne(id: number, query: any = {}, solidRequestContext: any = {}) { + const media = await super.findOne(id, query, solidRequestContext); + if (media) { + await this.decorateMediaRecord(media); + } + return media; + } + + async upload(createDto: any, files: Array, _solidRequestContext: any = {}) { - if (!files) { + if (!files || files.length === 0) { throw new NotFoundException(ERROR_MESSAGES.FILE_NOT_FOUND); } - const savedMedias = []; - for (let i = 0; i < files.length; i++) { - createDto['fieldMetadata'] = await this.fieldMetadataRepo.findOne({ - where: { - id: createDto['fieldMetadataId'] - }, - }); - createDto['modelMetadata'] = await this.modelMetadataRepo.findOne({ - where: { - id: createDto['modelMetadataId'] - }, - }); - createDto['mediaStorageProviderMetadata'] = await this.mediaStorageProviderMetadataRepo.findOne({ - where: { - id: createDto['mediaStorageProviderMetadataId'] - }, - }); + createDto['fieldMetadata'] = await this.fieldMetadataRepo.findOne({ + where: { + id: createDto['fieldMetadataId'] + }, + relations: ['mediaStorageProvider', 'model'], + }); + createDto['modelMetadata'] = await this.modelMetadataRepo.findOne({ + where: { + id: createDto['modelMetadataId'] + }, + }); + createDto['mediaStorageProviderMetadata'] = await this.mediaStorageProviderMetadataRepo.findOne({ + where: { + id: createDto['mediaStorageProviderMetadataId'] + }, + }); + createDto['mediaStorageProviderMetadata'] = createDto['mediaStorageProviderMetadata'] || createDto['fieldMetadata']?.mediaStorageProvider; + createDto['modelMetadata'] = createDto['modelMetadata'] || createDto['fieldMetadata']?.model; + + if (!createDto['mediaStorageProviderMetadata']) { + throw new NotFoundException('Media storage provider metadata not found'); + } + const savedMedias = []; + for (let i = 0; i < files.length; i++) { const file = files[i]; + const storageProvider = createDto.mediaStorageProviderMetadata as MediaStorageProviderMetadata; - switch (createDto.mediaStorageProviderMetadata.type) { + switch (storageProvider.type) { case MediaStorageProviderType.Filesystem: - const fileStoragePath = this.getFullFilePathForDisk(this.getFileName(file)); + const fileStoragePath = this.getFullFilePathForDisk(this.getFileName(file), storageProvider); await this.diskFileService.copy(file.path, fileStoragePath); createDto['relativeUri'] = this.getFileName(file); break; case MediaStorageProviderType.AwsS3: const fileName = this.getFileName(file); - const bucketName = createDto.mediaStorageProviderMetadata.bucketName; + const bucketName = storageProvider.bucketName; + const region = this.getEffectiveRegion(storageProvider.region); // Read file from disk and upload to S3 const fileData = await this.diskFileService.read(file.path); - await this.s3FileService.write(`${bucketName}:${fileName}`, fileData, { contentType: file.mimetype }); + await this.s3FileService.write(`${bucketName}:${fileName}`, fileData, { contentType: file.mimetype, region }); createDto['relativeUri'] = fileName; break; @@ -129,8 +132,10 @@ export class MediaService extends CRUDService { // Delete temp file from disk await this.diskFileService.delete(file.path); - const media = this.repo.create(createDto); + createDto['isPublic'] = this.resolveStoredIsPublic(createDto['isPublic'], storageProvider); + const media = this.repo.create(createDto as Partial) as Media; const savedMedia = await this.repo.save(media); + savedMedia.isPublic = this.resolveStoredIsPublic(savedMedia.isPublic, storageProvider); savedMedias.push(savedMedia) } return savedMedias @@ -157,17 +162,195 @@ export class MediaService extends CRUDService { return this.repo.remove(media); } - private getFullFilePathForDisk(fileName: string): string { - const base = this.settingService.getConfigValue("fileStorageDir") - || DEFAULT_MEDIA_FILE_STORAGE_DIR; - if (path.isAbsolute(fileName) || fileName.startsWith(`${base}/`)) { + async getDownloadStream(media: Media): Promise<{ stream: Readable | null, fileName: string, mimeType: string, redirectUrl?: string }> { + const loadedMedia = await this.repo.findOne({ + where: { id: media.id }, + relations: ['mediaStorageProviderMetadata'], + }); + + if (!loadedMedia || !loadedMedia.mediaStorageProviderMetadata) { + throw new NotFoundException(`Media with id ${media.id} not found`); + } + + const fileName = loadedMedia.originalFileName || path.basename(loadedMedia.relativeUri || `${loadedMedia.id}`); + const mimeType = loadedMedia.mimeType || 'application/octet-stream'; + + switch (loadedMedia.mediaStorageProviderMetadata.type as MediaStorageProviderType) { + case MediaStorageProviderType.Filesystem: + return { + stream: await this.diskFileService.readStream( + this.getFullFilePathForDisk(loadedMedia.relativeUri, loadedMedia.mediaStorageProviderMetadata), + ), + fileName, + mimeType, + }; + case MediaStorageProviderType.AwsS3: + const expiresIn = loadedMedia.mediaStorageProviderMetadata.isPublic === false + ? (loadedMedia.mediaStorageProviderMetadata.signedUrlExpiry ?? 60) * 60 + : 0; + return { + stream: null, + fileName, + mimeType, + redirectUrl: await this.s3FileService.getUrl( + `${loadedMedia.mediaStorageProviderMetadata.bucketName}:${loadedMedia.relativeUri}`, + { + region: this.getEffectiveRegion(loadedMedia.mediaStorageProviderMetadata.region), + expiresIn, + }, + ), + }; + default: + throw new Error(`Unsupported media storage provider type ${loadedMedia.mediaStorageProviderMetadata.type}`); + } + } + + async delete(id: number, solidRequestContext: any = {}) { + const media = await this.repo.findOne({ + where: { id }, + relations: ['mediaStorageProviderMetadata'], + }); + + const result = await super.delete(id, solidRequestContext); + + if (media) { + await this.deletePhysicalFile(media); + } + + return result; + } + + async deleteMany(ids: number[], solidRequestContext: any = {}) { + const mediaRecords = await this.repo.find({ + where: { + id: In(ids), + }, + relations: ['mediaStorageProviderMetadata'], + }); + + const result = await super.deleteMany(ids, solidRequestContext); + + for (const media of mediaRecords) { + await this.deletePhysicalFile(media); + } + + return result; + } + + private async decorateMediaRecords(medias: Media[]): Promise { + for (const media of medias) { + await this.decorateMediaRecord(media); + } + } + + private async decorateMediaRecord(media: Media): Promise { + const mediaStorageProvider = await this.resolveMediaStorageProvider(media); + media.isPublic = this.resolveStoredIsPublic(media.isPublic, mediaStorageProvider); + media.relativeUri = await this.resolveMediaUrl(media, mediaStorageProvider); + } + + private async resolveMediaUrl(media: Media, mediaStorageProvider?: MediaStorageProviderMetadata): Promise { + const resolvedMediaStorageProvider = mediaStorageProvider || await this.resolveMediaStorageProvider(media); + const isPublic = this.resolveStoredIsPublic(media.isPublic, resolvedMediaStorageProvider); + + if (isPublic === false) { + return this.getDownloadPath(media.id); + } + + if (resolvedMediaStorageProvider?.type === MediaStorageProviderType.Filesystem) { + return this.diskFileService.getUrl(this.getFullFilePathForDisk(media.relativeUri, resolvedMediaStorageProvider)); + } + + if (resolvedMediaStorageProvider?.type === MediaStorageProviderType.AwsS3) { + return this.s3FileService.getUrl( + `${resolvedMediaStorageProvider.bucketName}:${media.relativeUri}`, + { region: this.getEffectiveRegion(resolvedMediaStorageProvider.region), expiresIn: 0 }, + ); + } + + return media.relativeUri; + } + + private async deletePhysicalFile(media: Media): Promise { + if (!media?.relativeUri) { + return; + } + + try { + const mediaStorageProvider = await this.resolveMediaStorageProvider(media); + if (!mediaStorageProvider) { + return; + } + + switch (mediaStorageProvider.type as MediaStorageProviderType) { + case MediaStorageProviderType.Filesystem: + await this.diskFileService.delete(this.getFullFilePathForDisk(media.relativeUri, mediaStorageProvider)); + return; + case MediaStorageProviderType.AwsS3: + await this.s3FileService.delete( + `${mediaStorageProvider.bucketName}:${media.relativeUri}`, + { region: this.getEffectiveRegion(mediaStorageProvider.region) }, + ); + return; + default: + this.logger.warn(`Skipping physical delete for unsupported media storage provider type ${mediaStorageProvider.type}`); + } + } catch (error: any) { + const message = error?.message ?? String(error); + this.logger.warn(`Failed to delete physical media file for media id ${media.id}: ${message}`); + } + } + + private getFullFilePathForDisk(fileName: string, mediaStorageProvider?: MediaStorageProviderMetadata): string { + const publicBase = this.settingService.getConfigValue("fileStorageDir") || DEFAULT_MEDIA_FILE_STORAGE_DIR; + const privateBase = DEFAULT_PRIVATE_MEDIA_STORAGE_DIR; + const providerBase = mediaStorageProvider?.localPath || (mediaStorageProvider?.isPublic === false ? privateBase : publicBase); + if ( path.isAbsolute(fileName) || fileName.startsWith(`${publicBase}/`) || fileName.startsWith(`${privateBase}/`) || (!!mediaStorageProvider?.localPath && fileName.startsWith(`${mediaStorageProvider.localPath}/`))) { return fileName; } - return `${base}/${fileName}`; + return `${providerBase}/${fileName}`; + } + + private getDownloadPath(id: number): string { + return `/media/${id}/download`; } private getFileName(file: Express.Multer.File): string { return `${file.filename}-${file.originalname}`; } + private getEffectiveRegion(providerRegion?: string): string | undefined { + return providerRegion || this.configService.get('S3_AWS_REGION_NAME'); + } + + private async resolveMediaStorageProvider(media: Media): Promise { + if (media.mediaStorageProviderMetadata) { + return media.mediaStorageProviderMetadata; + } + + if (!media.id) { + return undefined; + } + + const loadedMedia = await this.repo.findOne({ + where: { id: media.id }, + relations: ['mediaStorageProviderMetadata'], + }); + + return loadedMedia?.mediaStorageProviderMetadata; + } + + private resolveIsPublic(mediaStorageProvider?: MediaStorageProviderMetadata): boolean | undefined { + if (!mediaStorageProvider) { + return undefined; + } + return mediaStorageProvider.isPublic !== false; + } + + private resolveStoredIsPublic(currentValue: boolean | undefined, mediaStorageProvider?: MediaStorageProviderMetadata): boolean | undefined { + if (typeof currentValue === 'boolean') { + return currentValue; + } + return this.resolveIsPublic(mediaStorageProvider); + } } diff --git a/src/services/mediaStorageProviders/file-s3-storage-provider.ts b/src/services/mediaStorageProviders/file-s3-storage-provider.ts index ca1d094a..271d2581 100755 --- a/src/services/mediaStorageProviders/file-s3-storage-provider.ts +++ b/src/services/mediaStorageProviders/file-s3-storage-provider.ts @@ -54,6 +54,7 @@ export class FileS3StorageProvider implements MediaStorageProvider { mediaStorageProviderMetadataId: mediaFieldMetadata.mediaStorageProvider.id, fieldMetadataId: mediaFieldMetadata.id }) as unknown as Media; + mediaEntity.isPublic = this.resolveStoredIsPublic(mediaEntity.isPublic, storageProvider); result.push(mediaEntity); this.logger.debug(`Stored media with`, mediaEntity); } @@ -75,15 +76,11 @@ export class FileS3StorageProvider implements MediaStorageProvider { // Add the full URL to the media for (const m of media) { const storageMeta = m.mediaStorageProviderMetadata; - const region = this.getEffectiveRegion(storageMeta.region); - if (storageMeta.isPublic === false) { - // Generate signed URL - const expiryInSeconds = (storageMeta.signedUrlExpiry ?? 60) * 60; - m['_full_url'] = await this.s3FileService.getUrl(`${storageMeta?.bucketName}:${m.relativeUri}`, { expiresIn: expiryInSeconds, region }); - } else { - // Public S3 or local filesystem: use normal URL - m['_full_url'] = this.getFullFilePath(m); - } + const isPublic = this.resolveStoredIsPublic(m.isPublic, storageMeta); + m.isPublic = isPublic; + m['_full_url'] = isPublic === false + ? this.getDownloadPath(m.id) + : this.getFullFilePath(m); } return media; @@ -129,6 +126,7 @@ export class FileS3StorageProvider implements MediaStorageProvider { mediaStorageProviderMetadataId: mediaFieldMetadata.mediaStorageProvider.id, fieldMetadataId: mediaFieldMetadata.id }) as unknown as Media; + mediaEntity.isPublic = this.resolveStoredIsPublic(mediaEntity.isPublic, storageProvider); result.push(mediaEntity); this.logger.debug(`Stored media with`, mediaEntity); }; @@ -182,7 +180,25 @@ export class FileS3StorageProvider implements MediaStorageProvider { return `https://${media.mediaStorageProviderMetadata.bucketName}.s3.${region}.amazonaws.com/${media.relativeUri}`; } + private getDownloadPath(id: number): string { + return `/media/${id}/download`; + } + private getFileName(file: Express.Multer.File): string { return `${file.filename}-${file.originalname}`; } + + private resolveIsPublic(storageProvider?: Media['mediaStorageProviderMetadata']): boolean | undefined { + if (!storageProvider) { + return undefined; + } + return storageProvider.isPublic !== false; + } + + private resolveStoredIsPublic(currentValue: boolean | undefined, storageProvider?: Media['mediaStorageProviderMetadata']): boolean | undefined { + if (typeof currentValue === 'boolean') { + return currentValue; + } + return this.resolveIsPublic(storageProvider); + } } diff --git a/src/services/mediaStorageProviders/file-storage-provider.ts b/src/services/mediaStorageProviders/file-storage-provider.ts index 53f9b053..c93b8141 100755 --- a/src/services/mediaStorageProviders/file-storage-provider.ts +++ b/src/services/mediaStorageProviders/file-storage-provider.ts @@ -3,6 +3,7 @@ import { ConfigService } from "@nestjs/config"; import { CommonEntity } from "src/entities/common.entity"; import { FieldMetadata } from "src/entities/field-metadata.entity"; import { Media } from "src/entities/media.entity"; +import { MediaStorageProviderMetadata } from "src/entities/media-storage-provider-metadata.entity"; import { MediaStorageProvider } from "src/interfaces"; import { MediaRepository } from "src/repository/media.repository"; import { DiskFileService } from "src/services/file"; @@ -10,9 +11,11 @@ import { Readable } from "stream"; import * as path from "path"; import * as fs from "fs"; import { SettingService } from "../setting.service"; -import { DEFAULT_MEDIA_FILE_STORAGE_DIR } from "src/services/settings/default-settings-provider.service"; +import { DEFAULT_MEDIA_FILE_STORAGE_DIR} from "src/services/settings/default-settings-provider.service"; import type { SolidCoreSetting } from "src/services/settings/default-settings-provider.service"; +const DEFAULT_PRIVATE_MEDIA_STORAGE_DIR = "media-private-files-storage"; + @Injectable() export class FileStorageProvider implements MediaStorageProvider { private logger = new Logger(FileStorageProvider.name); @@ -37,7 +40,12 @@ export class FileStorageProvider implements MediaStorageProvider { // media.forEach(m => { // }); for (const m of media) { - m['_full_url'] = `${this.settingService.getConfigValue("baseUrl")}/${this.getFullFilePath(m.relativeUri)}`; + const storageProvider = this.resolveMediaStorageProvider(m, mediaFieldMetadata); + const isPublic = this.resolveStoredIsPublic(m.isPublic, storageProvider); + m.isPublic = isPublic; + m['_full_url'] = isPublic === false + ? this.getDownloadPath(m.id) + : await this.fileService.getUrl(this.getFullFilePath(m.relativeUri, storageProvider)); } @@ -49,9 +57,10 @@ export class FileStorageProvider implements MediaStorageProvider { // throw new Error("Entity must be an instance of CommonEntity"); //FIXME This needs to be handled through generics. e.g T extends CommonEntity // } const result: Media[] = []; + const storageProvider = mediaFieldMetadata.mediaStorageProvider; for (const file of files) { // Store the file in the configured file storage directory - const fileStoragePath = this.getFullFilePath(this.getFileName(file)); + const fileStoragePath = this.getFullFilePath(this.getFileName(file), storageProvider); await this.fileService.copy(file.path, fileStoragePath); await this.fileService.delete(file.path); @@ -64,9 +73,10 @@ export class FileStorageProvider implements MediaStorageProvider { mimeType: file.mimetype, fileSize: file.size, originalFileName: file.originalname, - mediaStorageProviderMetadataId: mediaFieldMetadata.mediaStorageProvider.id, + mediaStorageProviderMetadataId: storageProvider.id, fieldMetadataId: mediaFieldMetadata.id }) as unknown as Media; + mediaEntity.isPublic = this.resolveStoredIsPublic(mediaEntity.isPublic, storageProvider); result.push(mediaEntity); this.logger.debug(`Stored media with`, mediaEntity); }; @@ -78,10 +88,11 @@ export class FileStorageProvider implements MediaStorageProvider { // throw new Error("Entity must be an instance of CommonEntity"); //FIXME This needs to be handled through generics. e.g T extends CommonEntity // } const result: Media[] = []; + const storageProvider = mediaFieldMetadata.mediaStorageProvider; for (const pair of streamPairs) { const stream = pair[0]; const fileName = pair[1]; - const fullPath = this.getFullFilePath(fileName); + const fullPath = this.getFullFilePath(fileName, storageProvider); await this.fileService.writeStream(fullPath, stream); const { size: fileSize } = await fs.promises.stat(fullPath); const mediaEntity = await this.mediaRepository.createMedia({ @@ -90,9 +101,11 @@ export class FileStorageProvider implements MediaStorageProvider { modelMetadataId: mediaFieldMetadata.model.id, relativeUri: fileName, fileSize, - mediaStorageProviderMetadataId: mediaFieldMetadata.mediaStorageProvider.id, + mediaStorageProviderMetadataId: storageProvider.id, fieldMetadataId: mediaFieldMetadata.id }) as unknown as Media; + mediaEntity.isPublic = this.resolveStoredIsPublic(mediaEntity.isPublic, storageProvider); + result.push(mediaEntity); this.logger.debug(`Stored media with`, mediaEntity); }; return result; @@ -105,10 +118,11 @@ export class FileStorageProvider implements MediaStorageProvider { //@ts-ignore const existingMedia = await this.mediaRepository.findByEntityIdAndFieldIdAndModelMetadataId(entity.id, mediaFieldMetadata.id, mediaFieldMetadata.model.id, ['mediaStorageProviderMetadata']); //@ts-ignore - this.mediaRepository.deleteByEntityIdAndFieldIdAndModelMetadataId(entity.id, mediaFieldMetadata.id, mediaFieldMetadata.model.id); + await this.mediaRepository.deleteByEntityIdAndFieldIdAndModelMetadataId(entity.id, mediaFieldMetadata.id, mediaFieldMetadata.model.id); for (const media of existingMedia) { - await this.fileService.delete(this.getFullFilePath(media.relativeUri)); + const storageProvider = this.resolveMediaStorageProvider(media, mediaFieldMetadata); + await this.fileService.delete(this.getFullFilePath(media.relativeUri, storageProvider)); } // existingMedia.forEach(media => { // }); @@ -118,19 +132,42 @@ export class FileStorageProvider implements MediaStorageProvider { if (!media?.relativeUri) { return; } - await this.fileService.delete(this.getFullFilePath(media.relativeUri)); + await this.fileService.delete(this.getFullFilePath(media.relativeUri, media.mediaStorageProviderMetadata)); } - private getFullFilePath(fileName: string): string { - const base = this.settingService.getConfigValue("fileStorageDir") - || DEFAULT_MEDIA_FILE_STORAGE_DIR; - if (path.isAbsolute(fileName) || fileName.startsWith(`${base}/`)) { + private getFullFilePath(fileName: string, storageProvider?: MediaStorageProviderMetadata): string { + const publicBase = this.settingService.getConfigValue("fileStorageDir") || DEFAULT_MEDIA_FILE_STORAGE_DIR; + const privateBase = DEFAULT_PRIVATE_MEDIA_STORAGE_DIR; + const providerBase = storageProvider?.localPath || (storageProvider?.isPublic === false ? privateBase : publicBase); + if (path.isAbsolute(fileName) || fileName.startsWith(`${publicBase}/`) || fileName.startsWith(`${privateBase}/`) || (!!storageProvider?.localPath && fileName.startsWith(`${storageProvider.localPath}/`))) { return fileName; } - return `${base}/${fileName}`; + return `${providerBase}/${fileName}`; + } + + private getDownloadPath(id: number): string { + return `/media/${id}/download`; } private getFileName(file: Express.Multer.File): string { return `${file.filename}-${file.originalname}`; } + + private resolveMediaStorageProvider(media: Media, mediaFieldMetadata?: FieldMetadata): MediaStorageProviderMetadata | undefined { + return media.mediaStorageProviderMetadata || mediaFieldMetadata?.mediaStorageProvider; + } + + private resolveIsPublic(storageProvider?: MediaStorageProviderMetadata): boolean | undefined { + if (!storageProvider) { + return undefined; + } + return storageProvider.isPublic !== false; + } + + private resolveStoredIsPublic(currentValue: boolean | undefined, storageProvider?: MediaStorageProviderMetadata): boolean | undefined { + if (typeof currentValue === 'boolean') { + return currentValue; + } + return this.resolveIsPublic(storageProvider); + } } From 844876c7240ecf601087156bc0b2c0738a516b6f Mon Sep 17 00:00:00 2001 From: SaibazKhan Date: Mon, 13 Jul 2026 17:10:41 +0530 Subject: [PATCH 11/16] rename function name --- src/controllers/media.controller.ts | 2 +- src/services/media.service.ts | 18 +----------------- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/src/controllers/media.controller.ts b/src/controllers/media.controller.ts index 6752be94..c918f00d 100644 --- a/src/controllers/media.controller.ts +++ b/src/controllers/media.controller.ts @@ -91,7 +91,7 @@ export class MediaController { @Get(':id/download') async download(@Param('id') id: string, @Res() res: Response, @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { const media = await this.service.findOne(+id, {}, solidRequestContext); - const { stream, fileName, mimeType, redirectUrl } = await this.service.getDownloadStream(media); + const { stream, fileName, mimeType, redirectUrl } = await this.service.fileDownloadStream(media); if (redirectUrl) { return res.redirect(302, redirectUrl); diff --git a/src/services/media.service.ts b/src/services/media.service.ts index 8149fd4d..051eb8c1 100644 --- a/src/services/media.service.ts +++ b/src/services/media.service.ts @@ -162,7 +162,7 @@ export class MediaService extends CRUDService { return this.repo.remove(media); } - async getDownloadStream(media: Media): Promise<{ stream: Readable | null, fileName: string, mimeType: string, redirectUrl?: string }> { + async fileDownloadStream(media: Media): Promise<{ stream: Readable | null, fileName: string, mimeType: string, redirectUrl?: string }> { const loadedMedia = await this.repo.findOne({ where: { id: media.id }, relations: ['mediaStorageProviderMetadata'], @@ -184,22 +184,6 @@ export class MediaService extends CRUDService { fileName, mimeType, }; - case MediaStorageProviderType.AwsS3: - const expiresIn = loadedMedia.mediaStorageProviderMetadata.isPublic === false - ? (loadedMedia.mediaStorageProviderMetadata.signedUrlExpiry ?? 60) * 60 - : 0; - return { - stream: null, - fileName, - mimeType, - redirectUrl: await this.s3FileService.getUrl( - `${loadedMedia.mediaStorageProviderMetadata.bucketName}:${loadedMedia.relativeUri}`, - { - region: this.getEffectiveRegion(loadedMedia.mediaStorageProviderMetadata.region), - expiresIn, - }, - ), - }; default: throw new Error(`Unsupported media storage provider type ${loadedMedia.mediaStorageProviderMetadata.type}`); } From 5c86cd654b342577724880e52433a2f7c17699b8 Mon Sep 17 00:00:00 2001 From: Oswald Rodrigues Date: Mon, 13 Jul 2026 18:10:19 +0530 Subject: [PATCH 12/16] Revert "rename function name" This reverts commit 844876c7240ecf601087156bc0b2c0738a516b6f. --- src/controllers/media.controller.ts | 2 +- src/services/media.service.ts | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/controllers/media.controller.ts b/src/controllers/media.controller.ts index c918f00d..6752be94 100644 --- a/src/controllers/media.controller.ts +++ b/src/controllers/media.controller.ts @@ -91,7 +91,7 @@ export class MediaController { @Get(':id/download') async download(@Param('id') id: string, @Res() res: Response, @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { const media = await this.service.findOne(+id, {}, solidRequestContext); - const { stream, fileName, mimeType, redirectUrl } = await this.service.fileDownloadStream(media); + const { stream, fileName, mimeType, redirectUrl } = await this.service.getDownloadStream(media); if (redirectUrl) { return res.redirect(302, redirectUrl); diff --git a/src/services/media.service.ts b/src/services/media.service.ts index 051eb8c1..8149fd4d 100644 --- a/src/services/media.service.ts +++ b/src/services/media.service.ts @@ -162,7 +162,7 @@ export class MediaService extends CRUDService { return this.repo.remove(media); } - async fileDownloadStream(media: Media): Promise<{ stream: Readable | null, fileName: string, mimeType: string, redirectUrl?: string }> { + async getDownloadStream(media: Media): Promise<{ stream: Readable | null, fileName: string, mimeType: string, redirectUrl?: string }> { const loadedMedia = await this.repo.findOne({ where: { id: media.id }, relations: ['mediaStorageProviderMetadata'], @@ -184,6 +184,22 @@ export class MediaService extends CRUDService { fileName, mimeType, }; + case MediaStorageProviderType.AwsS3: + const expiresIn = loadedMedia.mediaStorageProviderMetadata.isPublic === false + ? (loadedMedia.mediaStorageProviderMetadata.signedUrlExpiry ?? 60) * 60 + : 0; + return { + stream: null, + fileName, + mimeType, + redirectUrl: await this.s3FileService.getUrl( + `${loadedMedia.mediaStorageProviderMetadata.bucketName}:${loadedMedia.relativeUri}`, + { + region: this.getEffectiveRegion(loadedMedia.mediaStorageProviderMetadata.region), + expiresIn, + }, + ), + }; default: throw new Error(`Unsupported media storage provider type ${loadedMedia.mediaStorageProviderMetadata.type}`); } From 4bf574d6b1584085303466c69489555c3f5f1ca5 Mon Sep 17 00:00:00 2001 From: Oswald Rodrigues Date: Mon, 13 Jul 2026 18:10:41 +0530 Subject: [PATCH 13/16] Revert "add private media support" This reverts commit a0f59568e23885bd651d4aaadde4cd7729926dff. --- src/controllers/media.controller.ts | 66 ++-- src/dtos/create-media.dto.ts | 9 +- src/dtos/update-media.dto.ts | 9 +- src/entities/media.entity.ts | 5 +- src/repository/media.repository.ts | 3 - .../seed-data/solid-core-metadata.json | 87 +----- src/services/media.service.ts | 285 ++++-------------- .../file-s3-storage-provider.ts | 34 +-- .../file-storage-provider.ts | 65 +--- 9 files changed, 104 insertions(+), 459 deletions(-) diff --git a/src/controllers/media.controller.ts b/src/controllers/media.controller.ts index 6752be94..a5ca6c47 100644 --- a/src/controllers/media.controller.ts +++ b/src/controllers/media.controller.ts @@ -1,12 +1,10 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, Res, UploadedFiles, UseInterceptors } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, UploadedFiles, UseInterceptors } from '@nestjs/common'; import { AnyFilesInterceptor } from "@nestjs/platform-express"; import { ApiBearerAuth, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { Public } from 'src/decorators/public.decorator'; import { CreateMediaDto } from 'src/dtos/create-media.dto'; -import { SolidRequestContextDecorator } from 'src/decorators/solid-request-context.decorator'; -import { SolidRequestContextDto } from 'src/dtos/solid-request-context.dto'; import { UpdateMediaDto } from 'src/dtos/update-media.dto'; import { MediaService } from 'src/services/media.service'; -import { Response } from 'express'; enum ShowSoftDeleted { INCLUSIVE = "inclusive", @@ -23,30 +21,30 @@ export class MediaController { @ApiBearerAuth("jwt") @Post() @UseInterceptors(AnyFilesInterceptor()) - create(@Body() createDto: CreateMediaDto, @UploadedFiles() files: Array, @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { - return this.service.create(createDto, files, solidRequestContext); + create(@Body() createDto: CreateMediaDto, @UploadedFiles() files: Array) { + return this.service.create(createDto, files); } @ApiBearerAuth("jwt") @Post('/bulk') @UseInterceptors(AnyFilesInterceptor()) - insertMany(@Body() createDtos: CreateMediaDto[], @UploadedFiles() filesArray: Express.Multer.File[][] = [], @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { - return this.service.insertMany(createDtos, filesArray, solidRequestContext); + insertMany(@Body() createDtos: CreateMediaDto[], @UploadedFiles() filesArray: Express.Multer.File[][] = []) { + return this.service.insertMany(createDtos, filesArray); } @ApiBearerAuth("jwt") @Put(':id') @UseInterceptors(AnyFilesInterceptor()) - update(@Param('id') id: number, @Body() updateDto: UpdateMediaDto, @UploadedFiles() files: Array, @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { - return this.service.update(id, updateDto, files, false, solidRequestContext); + update(@Param('id') id: number, @Body() updateDto: UpdateMediaDto, @UploadedFiles() files: Array) { + return this.service.update(id, updateDto, files); } @ApiBearerAuth("jwt") @Patch(':id') @UseInterceptors(AnyFilesInterceptor()) - partialUpdate(@Param('id') id: number, @Body() updateDto: UpdateMediaDto, @UploadedFiles() files: Array, @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { - return this.service.update(id, updateDto, files, true, solidRequestContext); + partialUpdate(@Param('id') id: number, @Body() updateDto: UpdateMediaDto, @UploadedFiles() files: Array) { + return this.service.update(id, updateDto, files, true); } // @Public() @@ -54,22 +52,22 @@ export class MediaController { @ApiBearerAuth("jwt") @Post('/upload') @UseInterceptors(AnyFilesInterceptor()) - upload(@UploadedFiles() files: Array, @Body() createDto: CreateMediaDto, @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { + upload(@UploadedFiles() files: Array, @Body() createDto: CreateMediaDto, ) { // this.logger.log(`Creating a new model: ${JSON.stringify(createDto)}`); - return this.service.upload(createDto, files, solidRequestContext); + return this.service.upload(createDto, files); } @ApiBearerAuth("jwt") @Post('/bulk-recover') - async recoverMany(@Body() ids: number[], @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { - return this.service.recoverMany(ids, solidRequestContext); + async recoverMany(@Body() ids: number[]) { + return this.service.recoverMany(ids); } @ApiBearerAuth("jwt") @Get('/recover/:id') - async recover(@Param('id') id: number, @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { - return this.service.recover(id, solidRequestContext); + async recover(@Param('id') id: number) { + return this.service.recover(id); } @ApiBearerAuth("jwt") @@ -83,42 +81,26 @@ export class MediaController { @ApiQuery({ name: 'populateMedia', required: false, type: Array }) @ApiQuery({ name: 'filters', required: false, type: Array }) @Get() - async findMany(@Query() query: any, @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { - return this.service.find(query, solidRequestContext); - } - - @ApiBearerAuth("jwt") - @Get(':id/download') - async download(@Param('id') id: string, @Res() res: Response, @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { - const media = await this.service.findOne(+id, {}, solidRequestContext); - const { stream, fileName, mimeType, redirectUrl } = await this.service.getDownloadStream(media); - - if (redirectUrl) { - return res.redirect(302, redirectUrl); - } - - res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); - res.setHeader('Content-Type', mimeType); - res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition, Content-Type'); - stream.pipe(res); + async findMany(@Query() query: any) { + return this.service.find(query); } @ApiBearerAuth("jwt") @Get(':id') - async findOne(@Param('id') id: string, @Query() query: any, @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { - return this.service.findOne(+id, query, solidRequestContext); + async findOne(@Param('id') id: string, @Query() query: any) { + return this.service.findOne(+id, query); } @ApiBearerAuth("jwt") @Delete('/bulk') - async deleteMany(@Body() ids: number[], @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { - return this.service.deleteMany(ids, solidRequestContext); + async deleteMany(@Body() ids: number[]) { + return this.service.deleteMany(ids); } @ApiBearerAuth("jwt") @Delete(':id') - async delete(@Param('id') id: number, @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { - return this.service.delete(id, solidRequestContext); + async delete(@Param('id') id: number) { + return this.service.delete(id); } diff --git a/src/dtos/create-media.dto.ts b/src/dtos/create-media.dto.ts index 05019c08..47d8a1be 100644 --- a/src/dtos/create-media.dto.ts +++ b/src/dtos/create-media.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsBoolean, IsInt } from 'class-validator'; +import { IsInt } from 'class-validator'; import { IsNotEmpty, IsString, IsOptional } from 'class-validator'; export class CreateMediaDto { @IsNotEmpty() @@ -7,11 +7,6 @@ export class CreateMediaDto { @ApiProperty() entityId: number; -@IsOptional() -@IsBoolean() -@ApiProperty() -isPublic: boolean; - @IsOptional() @IsString() @ApiProperty() @@ -61,4 +56,4 @@ fieldMetadataId: number; @IsOptional() @ApiProperty() fieldMetadataUserKey: string; -} +} \ No newline at end of file diff --git a/src/dtos/update-media.dto.ts b/src/dtos/update-media.dto.ts index 5879ba31..25e75cdf 100644 --- a/src/dtos/update-media.dto.ts +++ b/src/dtos/update-media.dto.ts @@ -1,4 +1,4 @@ -import { IsBoolean, IsInt,IsOptional, IsNotEmpty, IsString } from 'class-validator'; +import { IsInt,IsOptional, IsNotEmpty, IsString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; export class UpdateMediaDto { @IsOptional() @@ -11,11 +11,6 @@ export class UpdateMediaDto { @ApiProperty() entityId: number; -@IsOptional() -@IsBoolean() -@ApiProperty() -isPublic: boolean; - @IsOptional() @IsString() @ApiProperty() @@ -65,4 +60,4 @@ fieldMetadataId: number; @IsOptional() @ApiProperty() fieldMetadataUserKey: string; -} +} \ No newline at end of file diff --git a/src/entities/media.entity.ts b/src/entities/media.entity.ts index 8e982af0..a0f73b0a 100644 --- a/src/entities/media.entity.ts +++ b/src/entities/media.entity.ts @@ -9,9 +9,6 @@ export class Media extends CommonEntity { @Column({ type: "integer" }) entityId: number; - @Column({ name: "is_public", nullable: true }) - isPublic: boolean; - @Column({ type: "varchar", nullable: true }) relativeUri: string; @@ -39,4 +36,4 @@ export class Media extends CommonEntity { @ManyToOne(() => FieldMetadata, { nullable: false }) @JoinColumn() fieldMetadata: FieldMetadata; -} +} \ No newline at end of file diff --git a/src/repository/media.repository.ts b/src/repository/media.repository.ts index 6a993f89..3808848d 100644 --- a/src/repository/media.repository.ts +++ b/src/repository/media.repository.ts @@ -44,9 +44,6 @@ export class MediaRepository extends SolidBaseRepository { id: createDto['mediaStorageProviderMetadataId'] }, }); - if (createDto['isPublic'] === undefined) { - createDto['isPublic'] = createDto['mediaStorageProviderMetadata']?.isPublic !== false; - } const media = this.create(createDto); return this.save(media); } diff --git a/src/seeders/seed-data/solid-core-metadata.json b/src/seeders/seed-data/solid-core-metadata.json index 4df7503f..02fd75e2 100755 --- a/src/seeders/seed-data/solid-core-metadata.json +++ b/src/seeders/seed-data/solid-core-metadata.json @@ -1704,18 +1704,6 @@ "columnName": "original_file_name", "isSystem": true }, - { - "name": "isPublic", - "displayName": "Is Public", - "type": "boolean", - "required": false, - "unique": false, - "index": false, - "private": false, - "encrypt": false, - "columnName": "is_public", - "isSystem": true - }, { "name": "modelMetadata", "displayName": "Model Metadata", @@ -5306,9 +5294,6 @@ "FieldMetadataController.getSelectionDynamicValues", "FieldMetadataController.getSelectionDynamicValue", "FieldMetadataController.findFieldDefaultMetaData", - "MediaController.findMany", - "MediaController.findOne", - "MediaController.download", "SavedFiltersController.delete", "SavedFiltersController.deleteMany", "SavedFiltersController.findOne", @@ -8761,13 +8746,6 @@ "sortable": false } }, - { - "type": "field", - "attrs": { - "name": "isPublic", - "sortable": true - } - }, { "type": "field", "attrs": { @@ -8863,14 +8841,6 @@ "isSearchable": true } }, - { - "type": "field", - "attrs": { - "name": "isPublic", - "label": "isPublic", - "isSearchable": true - } - }, { "type": "field", "attrs": { @@ -8939,12 +8909,6 @@ "name": "mediaStorageProviderMetadata" } }, - { - "type": "field", - "attrs": { - "name": "isPublic" - } - }, { "type": "field", "attrs": { @@ -14540,15 +14504,7 @@ "mediaStorageProviders": [ { "name": "default-filesystem", - "type": "filesystem", - "isPublic": true, - "localPath": "media-files-storage" - }, - { - "name": "private-filesystem", - "type": "filesystem", - "isPublic": false, - "localPath": "media-private-files-storage" + "type": "filesystem" }, { "name": "default-aws-s3", @@ -14584,47 +14540,6 @@ } } } - }, - { - "name": "model:Media-role:Internal User-own", - "description": "Show Media records owned by the current user", - "roleUserKey": "Internal User", - "modelMetadataUserKey": "media", - "securityRuleConfig": { - "filters": { - "createdBy": { - "$eq": "$activeUserId" - } - } - } - }, - { - "name": "model:Media-role:Internal User-public", - "description": "Show public Media records", - "roleUserKey": "Internal User", - "modelMetadataUserKey": "media", - "securityRuleConfig": { - "filters": { - "mediaStorageProviderMetadata": { - "isPublic": { - "$eq": true - } - } - } - } - }, - { - "name": "model:Media-role:Admin", - "description": "Show All Media records", - "roleUserKey": "Admin", - "modelMetadataUserKey": "media", - "securityRuleConfig": { - "filters": { - "id": { - "$ne": "0" - } - } - } } ], "modelSequences": [], diff --git a/src/services/media.service.ts b/src/services/media.service.ts index 8149fd4d..0b760200 100644 --- a/src/services/media.service.ts +++ b/src/services/media.service.ts @@ -1,9 +1,8 @@ -import { forwardRef, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { forwardRef, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { ModuleRef } from "@nestjs/core"; import { InjectEntityManager } from '@nestjs/typeorm'; import { EntityManager, In } from 'typeorm'; import * as path from 'path'; -import { Readable } from 'stream'; import { DEFAULT_MEDIA_FILE_STORAGE_DIR } from "src/services/settings/default-settings-provider.service"; import type { SolidCoreSetting } from "src/services/settings/default-settings-provider.service"; @@ -16,19 +15,15 @@ import { ERROR_MESSAGES } from 'src/constants/error-messages'; import { BasicFilterDto } from 'src/dtos/basic-filters.dto'; import { MediaStorageProviderType } from 'src/dtos/create-media-storage-provider-metadata.dto'; import { Media } from 'src/entities/media.entity'; -import { MediaStorageProviderMetadata } from 'src/entities/media-storage-provider-metadata.entity'; import { FieldMetadataRepository } from 'src/repository/field-metadata.repository'; import { MediaStorageProviderMetadataRepository } from 'src/repository/media-storage-provider-metadata.repository'; import { MediaRepository } from 'src/repository/media.repository'; import { ModelMetadataRepository } from 'src/repository/model-metadata.repository'; import { getMediaStorageProvider } from "./mediaStorageProviders"; -const DEFAULT_PRIVATE_MEDIA_STORAGE_DIR = "media-private-files-storage"; @Injectable() export class MediaService extends CRUDService { - private readonly logger = new Logger(MediaService.name); - constructor( readonly configService: ConfigService, readonly diskFileService: DiskFileService, @@ -56,73 +51,75 @@ export class MediaService extends CRUDService { async find(basicFilterDto: BasicFilterDto, solidRequestContext: any = {}) { const data = await super.find(basicFilterDto, solidRequestContext); if (data.records) { - await this.decorateMediaRecords(data.records); + + for (const media of data.records) { + const mediaStorageProvider = media.mediaStorageProviderMetadata; + + if (mediaStorageProvider?.type === MediaStorageProviderType.Filesystem) { + media.relativeUri = await this.diskFileService.getUrl(this.getFullFilePathForDisk(media.relativeUri)); + } else if (mediaStorageProvider?.type === MediaStorageProviderType.AwsS3) { + media.relativeUri = await this.s3FileService.getUrl(`${mediaStorageProvider.bucketName}:${media.relativeUri}`, { region: mediaStorageProvider.region }); + } + } } if (data.groupRecords) { + for (const group of data.groupRecords) { - await this.decorateMediaRecords(group.groupData.records); + for (const media of group.groupData.records) { + const mediaStorageProvider = media.mediaStorageProviderMetadata; + + if (mediaStorageProvider?.type === MediaStorageProviderType.Filesystem) { + media.relativeUri = await this.diskFileService.getUrl(this.getFullFilePathForDisk(media.relativeUri)); + } + else if (mediaStorageProvider?.type === MediaStorageProviderType.AwsS3) { + media.relativeUri = await this.s3FileService.getUrl(`${mediaStorageProvider.bucketName}:${media.relativeUri}`, { region: mediaStorageProvider.region }); + } + } } } return data } - async findOne(id: number, query: any = {}, solidRequestContext: any = {}) { - const media = await super.findOne(id, query, solidRequestContext); - if (media) { - await this.decorateMediaRecord(media); - } - return media; - } - - async upload(createDto: any, files: Array, _solidRequestContext: any = {}) { + async upload(createDto: any, files: Array) { - if (!files || files.length === 0) { + if (!files) { throw new NotFoundException(ERROR_MESSAGES.FILE_NOT_FOUND); } - - createDto['fieldMetadata'] = await this.fieldMetadataRepo.findOne({ - where: { - id: createDto['fieldMetadataId'] - }, - relations: ['mediaStorageProvider', 'model'], - }); - createDto['modelMetadata'] = await this.modelMetadataRepo.findOne({ - where: { - id: createDto['modelMetadataId'] - }, - }); - createDto['mediaStorageProviderMetadata'] = await this.mediaStorageProviderMetadataRepo.findOne({ - where: { - id: createDto['mediaStorageProviderMetadataId'] - }, - }); - createDto['mediaStorageProviderMetadata'] = createDto['mediaStorageProviderMetadata'] || createDto['fieldMetadata']?.mediaStorageProvider; - createDto['modelMetadata'] = createDto['modelMetadata'] || createDto['fieldMetadata']?.model; - - if (!createDto['mediaStorageProviderMetadata']) { - throw new NotFoundException('Media storage provider metadata not found'); - } - const savedMedias = []; for (let i = 0; i < files.length; i++) { + + createDto['fieldMetadata'] = await this.fieldMetadataRepo.findOne({ + where: { + id: createDto['fieldMetadataId'] + }, + }); + createDto['modelMetadata'] = await this.modelMetadataRepo.findOne({ + where: { + id: createDto['modelMetadataId'] + }, + }); + createDto['mediaStorageProviderMetadata'] = await this.mediaStorageProviderMetadataRepo.findOne({ + where: { + id: createDto['mediaStorageProviderMetadataId'] + }, + }); + const file = files[i]; - const storageProvider = createDto.mediaStorageProviderMetadata as MediaStorageProviderMetadata; - switch (storageProvider.type) { + switch (createDto.mediaStorageProviderMetadata.type) { case MediaStorageProviderType.Filesystem: - const fileStoragePath = this.getFullFilePathForDisk(this.getFileName(file), storageProvider); + const fileStoragePath = this.getFullFilePathForDisk(this.getFileName(file)); await this.diskFileService.copy(file.path, fileStoragePath); createDto['relativeUri'] = this.getFileName(file); break; case MediaStorageProviderType.AwsS3: const fileName = this.getFileName(file); - const bucketName = storageProvider.bucketName; - const region = this.getEffectiveRegion(storageProvider.region); + const bucketName = createDto.mediaStorageProviderMetadata.bucketName; // Read file from disk and upload to S3 const fileData = await this.diskFileService.read(file.path); - await this.s3FileService.write(`${bucketName}:${fileName}`, fileData, { contentType: file.mimetype, region }); + await this.s3FileService.write(`${bucketName}:${fileName}`, fileData, { contentType: file.mimetype }); createDto['relativeUri'] = fileName; break; @@ -132,10 +129,8 @@ export class MediaService extends CRUDService { // Delete temp file from disk await this.diskFileService.delete(file.path); - createDto['isPublic'] = this.resolveStoredIsPublic(createDto['isPublic'], storageProvider); - const media = this.repo.create(createDto as Partial) as Media; + const media = this.repo.create(createDto); const savedMedia = await this.repo.save(media); - savedMedia.isPublic = this.resolveStoredIsPublic(savedMedia.isPublic, storageProvider); savedMedias.push(savedMedia) } return savedMedias @@ -162,195 +157,17 @@ export class MediaService extends CRUDService { return this.repo.remove(media); } - async getDownloadStream(media: Media): Promise<{ stream: Readable | null, fileName: string, mimeType: string, redirectUrl?: string }> { - const loadedMedia = await this.repo.findOne({ - where: { id: media.id }, - relations: ['mediaStorageProviderMetadata'], - }); - - if (!loadedMedia || !loadedMedia.mediaStorageProviderMetadata) { - throw new NotFoundException(`Media with id ${media.id} not found`); - } - - const fileName = loadedMedia.originalFileName || path.basename(loadedMedia.relativeUri || `${loadedMedia.id}`); - const mimeType = loadedMedia.mimeType || 'application/octet-stream'; - - switch (loadedMedia.mediaStorageProviderMetadata.type as MediaStorageProviderType) { - case MediaStorageProviderType.Filesystem: - return { - stream: await this.diskFileService.readStream( - this.getFullFilePathForDisk(loadedMedia.relativeUri, loadedMedia.mediaStorageProviderMetadata), - ), - fileName, - mimeType, - }; - case MediaStorageProviderType.AwsS3: - const expiresIn = loadedMedia.mediaStorageProviderMetadata.isPublic === false - ? (loadedMedia.mediaStorageProviderMetadata.signedUrlExpiry ?? 60) * 60 - : 0; - return { - stream: null, - fileName, - mimeType, - redirectUrl: await this.s3FileService.getUrl( - `${loadedMedia.mediaStorageProviderMetadata.bucketName}:${loadedMedia.relativeUri}`, - { - region: this.getEffectiveRegion(loadedMedia.mediaStorageProviderMetadata.region), - expiresIn, - }, - ), - }; - default: - throw new Error(`Unsupported media storage provider type ${loadedMedia.mediaStorageProviderMetadata.type}`); - } - } - - async delete(id: number, solidRequestContext: any = {}) { - const media = await this.repo.findOne({ - where: { id }, - relations: ['mediaStorageProviderMetadata'], - }); - - const result = await super.delete(id, solidRequestContext); - - if (media) { - await this.deletePhysicalFile(media); - } - - return result; - } - - async deleteMany(ids: number[], solidRequestContext: any = {}) { - const mediaRecords = await this.repo.find({ - where: { - id: In(ids), - }, - relations: ['mediaStorageProviderMetadata'], - }); - - const result = await super.deleteMany(ids, solidRequestContext); - - for (const media of mediaRecords) { - await this.deletePhysicalFile(media); - } - - return result; - } - - private async decorateMediaRecords(medias: Media[]): Promise { - for (const media of medias) { - await this.decorateMediaRecord(media); - } - } - - private async decorateMediaRecord(media: Media): Promise { - const mediaStorageProvider = await this.resolveMediaStorageProvider(media); - media.isPublic = this.resolveStoredIsPublic(media.isPublic, mediaStorageProvider); - media.relativeUri = await this.resolveMediaUrl(media, mediaStorageProvider); - } - - private async resolveMediaUrl(media: Media, mediaStorageProvider?: MediaStorageProviderMetadata): Promise { - const resolvedMediaStorageProvider = mediaStorageProvider || await this.resolveMediaStorageProvider(media); - const isPublic = this.resolveStoredIsPublic(media.isPublic, resolvedMediaStorageProvider); - - if (isPublic === false) { - return this.getDownloadPath(media.id); - } - - if (resolvedMediaStorageProvider?.type === MediaStorageProviderType.Filesystem) { - return this.diskFileService.getUrl(this.getFullFilePathForDisk(media.relativeUri, resolvedMediaStorageProvider)); - } - - if (resolvedMediaStorageProvider?.type === MediaStorageProviderType.AwsS3) { - return this.s3FileService.getUrl( - `${resolvedMediaStorageProvider.bucketName}:${media.relativeUri}`, - { region: this.getEffectiveRegion(resolvedMediaStorageProvider.region), expiresIn: 0 }, - ); - } - - return media.relativeUri; - } - - private async deletePhysicalFile(media: Media): Promise { - if (!media?.relativeUri) { - return; - } - - try { - const mediaStorageProvider = await this.resolveMediaStorageProvider(media); - if (!mediaStorageProvider) { - return; - } - - switch (mediaStorageProvider.type as MediaStorageProviderType) { - case MediaStorageProviderType.Filesystem: - await this.diskFileService.delete(this.getFullFilePathForDisk(media.relativeUri, mediaStorageProvider)); - return; - case MediaStorageProviderType.AwsS3: - await this.s3FileService.delete( - `${mediaStorageProvider.bucketName}:${media.relativeUri}`, - { region: this.getEffectiveRegion(mediaStorageProvider.region) }, - ); - return; - default: - this.logger.warn(`Skipping physical delete for unsupported media storage provider type ${mediaStorageProvider.type}`); - } - } catch (error: any) { - const message = error?.message ?? String(error); - this.logger.warn(`Failed to delete physical media file for media id ${media.id}: ${message}`); - } - } - - private getFullFilePathForDisk(fileName: string, mediaStorageProvider?: MediaStorageProviderMetadata): string { - const publicBase = this.settingService.getConfigValue("fileStorageDir") || DEFAULT_MEDIA_FILE_STORAGE_DIR; - const privateBase = DEFAULT_PRIVATE_MEDIA_STORAGE_DIR; - const providerBase = mediaStorageProvider?.localPath || (mediaStorageProvider?.isPublic === false ? privateBase : publicBase); - if ( path.isAbsolute(fileName) || fileName.startsWith(`${publicBase}/`) || fileName.startsWith(`${privateBase}/`) || (!!mediaStorageProvider?.localPath && fileName.startsWith(`${mediaStorageProvider.localPath}/`))) { + private getFullFilePathForDisk(fileName: string): string { + const base = this.settingService.getConfigValue("fileStorageDir") + || DEFAULT_MEDIA_FILE_STORAGE_DIR; + if (path.isAbsolute(fileName) || fileName.startsWith(`${base}/`)) { return fileName; } - return `${providerBase}/${fileName}`; - } - - private getDownloadPath(id: number): string { - return `/media/${id}/download`; + return `${base}/${fileName}`; } private getFileName(file: Express.Multer.File): string { return `${file.filename}-${file.originalname}`; } - private getEffectiveRegion(providerRegion?: string): string | undefined { - return providerRegion || this.configService.get('S3_AWS_REGION_NAME'); - } - - private async resolveMediaStorageProvider(media: Media): Promise { - if (media.mediaStorageProviderMetadata) { - return media.mediaStorageProviderMetadata; - } - - if (!media.id) { - return undefined; - } - - const loadedMedia = await this.repo.findOne({ - where: { id: media.id }, - relations: ['mediaStorageProviderMetadata'], - }); - - return loadedMedia?.mediaStorageProviderMetadata; - } - - private resolveIsPublic(mediaStorageProvider?: MediaStorageProviderMetadata): boolean | undefined { - if (!mediaStorageProvider) { - return undefined; - } - return mediaStorageProvider.isPublic !== false; - } - - private resolveStoredIsPublic(currentValue: boolean | undefined, mediaStorageProvider?: MediaStorageProviderMetadata): boolean | undefined { - if (typeof currentValue === 'boolean') { - return currentValue; - } - return this.resolveIsPublic(mediaStorageProvider); - } } diff --git a/src/services/mediaStorageProviders/file-s3-storage-provider.ts b/src/services/mediaStorageProviders/file-s3-storage-provider.ts index 271d2581..ca1d094a 100755 --- a/src/services/mediaStorageProviders/file-s3-storage-provider.ts +++ b/src/services/mediaStorageProviders/file-s3-storage-provider.ts @@ -54,7 +54,6 @@ export class FileS3StorageProvider implements MediaStorageProvider { mediaStorageProviderMetadataId: mediaFieldMetadata.mediaStorageProvider.id, fieldMetadataId: mediaFieldMetadata.id }) as unknown as Media; - mediaEntity.isPublic = this.resolveStoredIsPublic(mediaEntity.isPublic, storageProvider); result.push(mediaEntity); this.logger.debug(`Stored media with`, mediaEntity); } @@ -76,11 +75,15 @@ export class FileS3StorageProvider implements MediaStorageProvider { // Add the full URL to the media for (const m of media) { const storageMeta = m.mediaStorageProviderMetadata; - const isPublic = this.resolveStoredIsPublic(m.isPublic, storageMeta); - m.isPublic = isPublic; - m['_full_url'] = isPublic === false - ? this.getDownloadPath(m.id) - : this.getFullFilePath(m); + const region = this.getEffectiveRegion(storageMeta.region); + if (storageMeta.isPublic === false) { + // Generate signed URL + const expiryInSeconds = (storageMeta.signedUrlExpiry ?? 60) * 60; + m['_full_url'] = await this.s3FileService.getUrl(`${storageMeta?.bucketName}:${m.relativeUri}`, { expiresIn: expiryInSeconds, region }); + } else { + // Public S3 or local filesystem: use normal URL + m['_full_url'] = this.getFullFilePath(m); + } } return media; @@ -126,7 +129,6 @@ export class FileS3StorageProvider implements MediaStorageProvider { mediaStorageProviderMetadataId: mediaFieldMetadata.mediaStorageProvider.id, fieldMetadataId: mediaFieldMetadata.id }) as unknown as Media; - mediaEntity.isPublic = this.resolveStoredIsPublic(mediaEntity.isPublic, storageProvider); result.push(mediaEntity); this.logger.debug(`Stored media with`, mediaEntity); }; @@ -180,25 +182,7 @@ export class FileS3StorageProvider implements MediaStorageProvider { return `https://${media.mediaStorageProviderMetadata.bucketName}.s3.${region}.amazonaws.com/${media.relativeUri}`; } - private getDownloadPath(id: number): string { - return `/media/${id}/download`; - } - private getFileName(file: Express.Multer.File): string { return `${file.filename}-${file.originalname}`; } - - private resolveIsPublic(storageProvider?: Media['mediaStorageProviderMetadata']): boolean | undefined { - if (!storageProvider) { - return undefined; - } - return storageProvider.isPublic !== false; - } - - private resolveStoredIsPublic(currentValue: boolean | undefined, storageProvider?: Media['mediaStorageProviderMetadata']): boolean | undefined { - if (typeof currentValue === 'boolean') { - return currentValue; - } - return this.resolveIsPublic(storageProvider); - } } diff --git a/src/services/mediaStorageProviders/file-storage-provider.ts b/src/services/mediaStorageProviders/file-storage-provider.ts index c93b8141..53f9b053 100755 --- a/src/services/mediaStorageProviders/file-storage-provider.ts +++ b/src/services/mediaStorageProviders/file-storage-provider.ts @@ -3,7 +3,6 @@ import { ConfigService } from "@nestjs/config"; import { CommonEntity } from "src/entities/common.entity"; import { FieldMetadata } from "src/entities/field-metadata.entity"; import { Media } from "src/entities/media.entity"; -import { MediaStorageProviderMetadata } from "src/entities/media-storage-provider-metadata.entity"; import { MediaStorageProvider } from "src/interfaces"; import { MediaRepository } from "src/repository/media.repository"; import { DiskFileService } from "src/services/file"; @@ -11,11 +10,9 @@ import { Readable } from "stream"; import * as path from "path"; import * as fs from "fs"; import { SettingService } from "../setting.service"; -import { DEFAULT_MEDIA_FILE_STORAGE_DIR} from "src/services/settings/default-settings-provider.service"; +import { DEFAULT_MEDIA_FILE_STORAGE_DIR } from "src/services/settings/default-settings-provider.service"; import type { SolidCoreSetting } from "src/services/settings/default-settings-provider.service"; -const DEFAULT_PRIVATE_MEDIA_STORAGE_DIR = "media-private-files-storage"; - @Injectable() export class FileStorageProvider implements MediaStorageProvider { private logger = new Logger(FileStorageProvider.name); @@ -40,12 +37,7 @@ export class FileStorageProvider implements MediaStorageProvider { // media.forEach(m => { // }); for (const m of media) { - const storageProvider = this.resolveMediaStorageProvider(m, mediaFieldMetadata); - const isPublic = this.resolveStoredIsPublic(m.isPublic, storageProvider); - m.isPublic = isPublic; - m['_full_url'] = isPublic === false - ? this.getDownloadPath(m.id) - : await this.fileService.getUrl(this.getFullFilePath(m.relativeUri, storageProvider)); + m['_full_url'] = `${this.settingService.getConfigValue("baseUrl")}/${this.getFullFilePath(m.relativeUri)}`; } @@ -57,10 +49,9 @@ export class FileStorageProvider implements MediaStorageProvider { // throw new Error("Entity must be an instance of CommonEntity"); //FIXME This needs to be handled through generics. e.g T extends CommonEntity // } const result: Media[] = []; - const storageProvider = mediaFieldMetadata.mediaStorageProvider; for (const file of files) { // Store the file in the configured file storage directory - const fileStoragePath = this.getFullFilePath(this.getFileName(file), storageProvider); + const fileStoragePath = this.getFullFilePath(this.getFileName(file)); await this.fileService.copy(file.path, fileStoragePath); await this.fileService.delete(file.path); @@ -73,10 +64,9 @@ export class FileStorageProvider implements MediaStorageProvider { mimeType: file.mimetype, fileSize: file.size, originalFileName: file.originalname, - mediaStorageProviderMetadataId: storageProvider.id, + mediaStorageProviderMetadataId: mediaFieldMetadata.mediaStorageProvider.id, fieldMetadataId: mediaFieldMetadata.id }) as unknown as Media; - mediaEntity.isPublic = this.resolveStoredIsPublic(mediaEntity.isPublic, storageProvider); result.push(mediaEntity); this.logger.debug(`Stored media with`, mediaEntity); }; @@ -88,11 +78,10 @@ export class FileStorageProvider implements MediaStorageProvider { // throw new Error("Entity must be an instance of CommonEntity"); //FIXME This needs to be handled through generics. e.g T extends CommonEntity // } const result: Media[] = []; - const storageProvider = mediaFieldMetadata.mediaStorageProvider; for (const pair of streamPairs) { const stream = pair[0]; const fileName = pair[1]; - const fullPath = this.getFullFilePath(fileName, storageProvider); + const fullPath = this.getFullFilePath(fileName); await this.fileService.writeStream(fullPath, stream); const { size: fileSize } = await fs.promises.stat(fullPath); const mediaEntity = await this.mediaRepository.createMedia({ @@ -101,11 +90,9 @@ export class FileStorageProvider implements MediaStorageProvider { modelMetadataId: mediaFieldMetadata.model.id, relativeUri: fileName, fileSize, - mediaStorageProviderMetadataId: storageProvider.id, + mediaStorageProviderMetadataId: mediaFieldMetadata.mediaStorageProvider.id, fieldMetadataId: mediaFieldMetadata.id }) as unknown as Media; - mediaEntity.isPublic = this.resolveStoredIsPublic(mediaEntity.isPublic, storageProvider); - result.push(mediaEntity); this.logger.debug(`Stored media with`, mediaEntity); }; return result; @@ -118,11 +105,10 @@ export class FileStorageProvider implements MediaStorageProvider { //@ts-ignore const existingMedia = await this.mediaRepository.findByEntityIdAndFieldIdAndModelMetadataId(entity.id, mediaFieldMetadata.id, mediaFieldMetadata.model.id, ['mediaStorageProviderMetadata']); //@ts-ignore - await this.mediaRepository.deleteByEntityIdAndFieldIdAndModelMetadataId(entity.id, mediaFieldMetadata.id, mediaFieldMetadata.model.id); + this.mediaRepository.deleteByEntityIdAndFieldIdAndModelMetadataId(entity.id, mediaFieldMetadata.id, mediaFieldMetadata.model.id); for (const media of existingMedia) { - const storageProvider = this.resolveMediaStorageProvider(media, mediaFieldMetadata); - await this.fileService.delete(this.getFullFilePath(media.relativeUri, storageProvider)); + await this.fileService.delete(this.getFullFilePath(media.relativeUri)); } // existingMedia.forEach(media => { // }); @@ -132,42 +118,19 @@ export class FileStorageProvider implements MediaStorageProvider { if (!media?.relativeUri) { return; } - await this.fileService.delete(this.getFullFilePath(media.relativeUri, media.mediaStorageProviderMetadata)); + await this.fileService.delete(this.getFullFilePath(media.relativeUri)); } - private getFullFilePath(fileName: string, storageProvider?: MediaStorageProviderMetadata): string { - const publicBase = this.settingService.getConfigValue("fileStorageDir") || DEFAULT_MEDIA_FILE_STORAGE_DIR; - const privateBase = DEFAULT_PRIVATE_MEDIA_STORAGE_DIR; - const providerBase = storageProvider?.localPath || (storageProvider?.isPublic === false ? privateBase : publicBase); - if (path.isAbsolute(fileName) || fileName.startsWith(`${publicBase}/`) || fileName.startsWith(`${privateBase}/`) || (!!storageProvider?.localPath && fileName.startsWith(`${storageProvider.localPath}/`))) { + private getFullFilePath(fileName: string): string { + const base = this.settingService.getConfigValue("fileStorageDir") + || DEFAULT_MEDIA_FILE_STORAGE_DIR; + if (path.isAbsolute(fileName) || fileName.startsWith(`${base}/`)) { return fileName; } - return `${providerBase}/${fileName}`; - } - - private getDownloadPath(id: number): string { - return `/media/${id}/download`; + return `${base}/${fileName}`; } private getFileName(file: Express.Multer.File): string { return `${file.filename}-${file.originalname}`; } - - private resolveMediaStorageProvider(media: Media, mediaFieldMetadata?: FieldMetadata): MediaStorageProviderMetadata | undefined { - return media.mediaStorageProviderMetadata || mediaFieldMetadata?.mediaStorageProvider; - } - - private resolveIsPublic(storageProvider?: MediaStorageProviderMetadata): boolean | undefined { - if (!storageProvider) { - return undefined; - } - return storageProvider.isPublic !== false; - } - - private resolveStoredIsPublic(currentValue: boolean | undefined, storageProvider?: MediaStorageProviderMetadata): boolean | undefined { - if (typeof currentValue === 'boolean') { - return currentValue; - } - return this.resolveIsPublic(storageProvider); - } } From 95cb70b730cea284d00552820589180a7760e943 Mon Sep 17 00:00:00 2001 From: Oswald Rodrigues Date: Mon, 13 Jul 2026 18:32:38 +0530 Subject: [PATCH 14/16] 0.1.12-beta.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index b0edd72b..50d80028 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@solidxai/core", - "version": "0.1.11", + "version": "0.1.12-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@solidxai/core", - "version": "0.1.11", + "version": "0.1.12-beta.0", "license": "BUSL-1.1", "dependencies": { "@aws-sdk/client-s3": "^3.637.0", diff --git a/package.json b/package.json index 56e58396..e9899f8f 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@solidxai/core", - "version": "0.1.11", + "version": "0.1.12-beta.0", "description": "This module is a NestJS module containing all the required core providers required by a Solid application", "main": "dist/index.js", "types": "dist/index.d.ts", From 297bbfd7d72c1078ad0f8dd61cbf0b214673d2df Mon Sep 17 00:00:00 2001 From: Oswald Rodrigues Date: Mon, 13 Jul 2026 18:45:20 +0530 Subject: [PATCH 15/16] handle technical error messages due to missing resource in static resources folder --- src/constants/error-messages.ts | 1 + src/helpers/solid-core-error-codes-provider.service.ts | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/src/constants/error-messages.ts b/src/constants/error-messages.ts index 8f888d0e..43a309f8 100644 --- a/src/constants/error-messages.ts +++ b/src/constants/error-messages.ts @@ -56,6 +56,7 @@ export const ERROR_MESSAGES = { // general errors FORBIDDEN: 'Forbidden', + RESOURCE_NOT_FOUND: 'The requested resource was not found.', // database errors diff --git a/src/helpers/solid-core-error-codes-provider.service.ts b/src/helpers/solid-core-error-codes-provider.service.ts index cea81375..18fc14c3 100644 --- a/src/helpers/solid-core-error-codes-provider.service.ts +++ b/src/helpers/solid-core-error-codes-provider.service.ts @@ -43,6 +43,15 @@ export class SolidCoreErrorCodesProvider implements IErrorCodeProvider { httpStatus: 503, }, }, + { + code: 'solidx-resource-not-found', + priority: 95, + match: (txt) => txt.includes('enoent') && txt.includes('no such file or directory'), + meta: { + message: ERROR_MESSAGES.RESOURCE_NOT_FOUND, + httpStatus: 404, + }, + }, { code: 'solidx-db-duplicate-key', priority: 90, From 002a6108c90e33ca19e1c09d4ec106a90fe20bfe Mon Sep 17 00:00:00 2001 From: Oswald Rodrigues Date: Mon, 13 Jul 2026 18:50:19 +0530 Subject: [PATCH 16/16] 0.1.12-beta.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 50d80028..3bf3ee7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@solidxai/core", - "version": "0.1.12-beta.0", + "version": "0.1.12-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@solidxai/core", - "version": "0.1.12-beta.0", + "version": "0.1.12-beta.1", "license": "BUSL-1.1", "dependencies": { "@aws-sdk/client-s3": "^3.637.0", diff --git a/package.json b/package.json index e9899f8f..75c1af36 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@solidxai/core", - "version": "0.1.12-beta.0", + "version": "0.1.12-beta.1", "description": "This module is a NestJS module containing all the required core providers required by a Solid application", "main": "dist/index.js", "types": "dist/index.d.ts",