Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions modules/storage/README.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ Documents may have complex structures and subcollections and can be stored local
- AWS S3 (or any other S3-compatible provider)
- Alibaba Cloud (Aliyun)

## Public files

Public files expose a stable Conduit-relative `uri` in the form `/storage/getFileUrl/:id`.
If both the container and file are public, `url` contains the direct provider/CDN URL.
If the container is private and the file is public, `url` is left empty and Conduit signs a provider URL on demand through `uri`.
Private files are not allowed in public containers.

## Requirements ⚡

- [Database](../database) module
Expand Down
6 changes: 6 additions & 0 deletions modules/storage/src/Storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { status } from '@grpc/grpc-js';
import { isEmpty, isNil } from 'lodash-es';
import { runMigrations } from './migrations/index.js';
import { migratePublicContainers } from './migrations/publicContainerMigration.js';
import { migrateFileUriReferences } from './migrations/fileUriMigration.js';
import {
CdnConfiguration,
cdnConfigsAreEqual,
Expand Down Expand Up @@ -82,6 +83,7 @@ export default class Storage extends ManagedModule<Config> {
private enableAuthRoutes: boolean = false;
private storageParamAdapter: StorageParamAdapter;
private publicContainerMigrationRan: boolean = false;
private fileUriMigrationRan: boolean = false;
private previousCdnConfig: CdnConfiguration = {};
private _storageAuthzResourceDispose: (() => void) | null = null;
private refreshAppRoutesTimeout: NodeJS.Timeout | null = null;
Expand Down Expand Up @@ -253,6 +255,10 @@ export default class Storage extends ManagedModule<Config> {
this.publicContainerMigrationRan = true;
await migratePublicContainers(this.grpcSdk, this.storageProvider);
}
if (!this.fileUriMigrationRan) {
this.fileUriMigrationRan = true;
await migrateFileUriReferences();
}
// Detect CDN config changes and run migration if needed (order-independent comparison)
const currentCdnConfig = (ConfigController.getInstance().config.cdnConfiguration ??
{}) as CdnConfiguration;
Expand Down
16 changes: 10 additions & 6 deletions modules/storage/src/adapter/StorageParamAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,22 @@ export class StorageParamAdapter {
constructor() {}

getFileResponse(response: UnparsedRouterResponse): FileResponse {
const file = response as Indexable;
return {
id: (response as Indexable)._id,
url: (response as Indexable).url,
name: (response as Indexable).name,
id: file._id,
url: file.url ?? '',
uri: file.uri ?? '',
name: file.name,
};
}

getFileByUrlResponse(response: UnparsedRouterResponse): FileByUrlResponse {
const file = (response as Indexable).file as Indexable;
return {
id: (response as Indexable).file._id,
fileUrl: (response as Indexable).file.url,
name: (response as Indexable).file.name,
id: file._id,
fileUrl: file.url ?? file.uri ?? '',
uri: file.uri ?? '',
name: file.name,
uploadUrl: (response as Indexable).url,
};
}
Expand Down
6 changes: 4 additions & 2 deletions modules/storage/src/admin/adminFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
applyCdnHost,
deepPathHandler,
normalizeFolderPath,
resolvePublicFileAccessUrl,
storeNewFile,
validateName,
} from '../utils/index.js';
Expand Down Expand Up @@ -204,10 +205,11 @@ export class AdminFileHandlers {
throw new GrpcError(status.NOT_FOUND, 'File does not exist');
}
if (found.isPublic) {
const url = await resolvePublicFileAccessUrl(this.storageProvider, found);
if (!call.request.params.redirect) {
return { result: found.url };
return { result: url };
}
return { redirect: found.url };
return { redirect: url };
}
const options: UrlOptions = {
download: call.request.params.download ?? false,
Expand Down
4 changes: 4 additions & 0 deletions modules/storage/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export default {
format: 'String',
default: '',
},
serviceAccountKeyJson: {
format: 'String',
default: '',
},
},
azure: {
connectionString: { format: 'String', default: '' },
Expand Down
6 changes: 4 additions & 2 deletions modules/storage/src/handlers/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
applyCdnHost,
deepPathHandler,
normalizeFolderPath,
resolvePublicFileAccessUrl,
storeNewFile,
validateName,
} from '../utils/index.js';
Expand Down Expand Up @@ -270,10 +271,11 @@ export class FileHandlers {
throw new GrpcError(status.NOT_FOUND, 'File does not exist');
}
if (found.isPublic) {
const url = await resolvePublicFileAccessUrl(this.storageProvider, found);
if (!call.request.params.redirect) {
return { result: found.url };
return { result: url };
}
return { redirect: found.url };
return { redirect: url };
}
await this.fileAccessCheck('read', call.request, found);
const options: UrlOptions = {
Expand Down
6 changes: 0 additions & 6 deletions modules/storage/src/interfaces/IStorageProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,6 @@ export interface IStorageProvider {

getSignedUrl(fileName: string, options?: UrlOptions): Promise<any | Error>;

/**
* Gets a publicly accessible URL for a file.
* @param fileName The file name/path
* @param containerIsPublic Whether the container is publicly accessible.
* If true, returns a plain URL. If false, returns a long-lived signed URL.
*/
getPublicUrl(fileName: string, containerIsPublic?: boolean): Promise<string | Error>;

getUploadUrl(fileName: string): Promise<string | Error>;
Expand Down
1 change: 1 addition & 0 deletions modules/storage/src/interfaces/StorageConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface StorageConfig {
*/
google: {
serviceAccountKeyPath: string;
serviceAccountKeyJson: string;
};
aws: {
accessKeyId: string;
Expand Down
40 changes: 40 additions & 0 deletions modules/storage/src/migrations/fileUriMigration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { ConduitGrpcSdk } from '@conduitplatform/grpc-sdk';
import { buildFileUri } from '../utils/index.js';
import { _StorageContainer, File } from '../models/index.js';

export async function migrateFileUriReferences(): Promise<void> {
const logger = ConduitGrpcSdk.Logger;

try {
const publicFiles = await File.getInstance().findMany({ isPublic: true });
if (publicFiles.length === 0) {
logger.log('No public files found for URI migration');
return;
}

const containers = await _StorageContainer.getInstance().findMany({});
const containerIsPublic = new Map(
containers.map(container => [container.name, container.isPublic ?? false]),
);

let updated = 0;
for (const file of publicFiles) {
const isContainerPublic = containerIsPublic.get(file.container) ?? false;
const update: Record<string, string> = {
uri: buildFileUri(file._id),
};

if (!isContainerPublic) {
update.url = '';
update.sourceUrl = '';
}

await File.getInstance().findByIdAndUpdate(file._id, update);
updated++;
}

logger.log(`File URI migration completed for ${updated} public file(s)`);
} catch (error) {
logger.error(`File URI migration failed: ${(error as Error).message}`);
}
}
4 changes: 3 additions & 1 deletion modules/storage/src/models/File.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const schema: ConduitModel = {
default: false,
},
url: TYPE.String,
uri: TYPE.String,
sourceUrl: TYPE.String,
mimeType: TYPE.String,
createdAt: TYPE.Date,
Expand Down Expand Up @@ -56,7 +57,8 @@ export class File extends ConduitActiveSchema<File> {
container!: string;
size!: number;
isPublic?: boolean;
url!: string;
url?: string;
uri?: string;
sourceUrl?: string;
mimeType!: string;
createdAt!: Date;
Expand Down
16 changes: 7 additions & 9 deletions modules/storage/src/providers/aliyun/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,15 +157,13 @@ export class AliyunStorage implements IStorageProvider {
});
}

async getPublicUrl(fileName: string, containerIsPublic?: boolean): Promise<string> {
if (containerIsPublic) {
return this._ossClient.getObjectUrl(fileName);
async getPublicUrl(
fileName: string,
containerIsPublic?: boolean,
): Promise<string | Error> {
if (!containerIsPublic) {
return new Error('Public URL is only available for files in public containers');
}

// For private containers with public files, generate long-lived signed URL (100 years)
return this._ossClient.signatureUrl(fileName, {
expires: 3600 * 24 * 365 * 100,
method: 'GET',
});
return this._ossClient.getObjectUrl(fileName);
}
}
6 changes: 4 additions & 2 deletions modules/storage/src/providers/aws/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,10 @@ export class AWSS3Storage implements IStorageProvider {
});
}

async getPublicUrl(fileName: string, _containerIsPublic?: boolean) {
// AWS uses bucket-level ACLs, so public URL format is the same regardless
async getPublicUrl(fileName: string, containerIsPublic?: boolean) {
if (!containerIsPublic) {
return new Error('Public URL is only available for files in public containers');
}
const config: Config['aws'] = ConfigController.getInstance().config.aws;
if (config.endpoint !== '') {
// check if endpoint contains http/https or not
Expand Down
26 changes: 8 additions & 18 deletions modules/storage/src/providers/azure/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,27 +135,17 @@ export class AzureStorage implements IStorageProvider {
return this.blobClient(fileName).generateSasUrl(sasOptions);
}

async getPublicUrl(fileName: string, containerIsPublic?: boolean): Promise<string> {
if (containerIsPublic) {
// Return direct URL without SAS token for public containers
return `${this._storage.url}${this._activeContainer}/${fileName}`;
async getPublicUrl(
fileName: string,
containerIsPublic?: boolean,
): Promise<string | Error> {
if (!containerIsPublic) {
return new Error('Public URL is only available for files in public containers');
}
// For private containers with public files, generate long-lived signed URL (99 years)
const containerClient = this._storage.getContainerClient(this._activeContainer);
const sasOptions: BlobSASSignatureValues = {
containerName: containerClient.containerName,
blobName: fileName,
expiresOn: new Date(new Date().setFullYear(new Date().getFullYear() + 99)),
permissions: BlobSASPermissions.parse('r'),
};
return this.blobClient(fileName).generateSasUrl(sasOptions);
return `${this._storage.url}${this._activeContainer}/${fileName}`;
}

async store(
fileName: string,
data: any,
isPublic: boolean = false,
): Promise<boolean | Error> {
async store(fileName: string, data: any): Promise<boolean | Error> {
await this._storage
.getContainerClient(this._activeContainer)
.getBlockBlobClient(fileName)
Expand Down
Loading