Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,10 @@ FILE_CLEANUP_ENABLED=true
FILE_ORPHAN_RETENTION_DAYS=7
FILE_EXPIRED_RETENTION_DAYS=1
FILE_DEFAULT_EXPIRY_HOURS=720
# Comma-separated allowlist of MIME types accepted at the upload boundary.
# Requests with any other MIME type are rejected with 400 before storage.
# Default covers images, documents, video, audio and archives.
FILE_ALLOWED_MIME_TYPES=image/jpeg,image/png,image/gif,image/webp,image/svg+xml,image/tiff,image/bmp,image/avif,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,text/plain,text/csv,application/json,application/xml,video/mp4,video/webm,video/ogg,video/quicktime,video/x-msvideo,video/x-matroska,audio/mpeg,audio/wav,audio/ogg,audio/webm,audio/aac,audio/flac,audio/mp4,application/zip,application/x-tar,application/gzip,application/x-rar-compressed,application/x-7z-compressed,application/x-bzip2

# S3 Configuration (when FILE_STORAGE_BACKEND=s3)
S3_BUCKET=alian-structure-files
Expand Down
9 changes: 8 additions & 1 deletion .github/workflows/build-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -159,5 +159,12 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: typescript-client
path: client/
path: sdks/typescript
retention-days: 30

- name: Upload Python client artifact
uses: actions/upload-artifact@v4
with:
name: python-client
path: sdks/python
retention-days: 30
115 changes: 109 additions & 6 deletions src/infrastructure/file-upload/file-upload.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,60 @@
FileSearchDto,
FileCleanupDto,
} from "./dto/file-upload.dto";
import { CurrentUser } from "src/core/auth/decorators/current-user.decorator";
import { AuthUser } from "src/core/auth/strategies/interfaces/auth-strategy.interface";
import { BadRequestException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";

/**
* Default MIME type allowlist used when FILE_ALLOWED_MIME_TYPES is not set.
* Mirrors the FileValidationService default categories.
*/
const DEFAULT_ALLOWED_MIME_TYPES = [
// Images
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/svg+xml",
"image/tiff",
"image/bmp",
"image/avif",
// Documents
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/plain",
"text/csv",
"application/json",
"application/xml",
// Video
"video/mp4",
"video/webm",
"video/ogg",
"video/quicktime",
"video/x-msvideo",
"video/x-matroska",
// Audio
"audio/mpeg",
"audio/wav",
"audio/ogg",
"audio/webm",
"audio/aac",
"audio/flac",
"audio/mp4",
// Archives
"application/zip",
"application/x-tar",
"application/gzip",
"application/x-rar-compressed",
"application/x-7z-compressed",
"application/x-bzip2",
];

@ApiTags("Files")
@ApiBearerAuth()
Expand All @@ -51,8 +105,25 @@
private readonly cleanupService: FileCleanupService,
private readonly scanService: FileScanService,
private readonly metadataService: FileMetadataService,
private readonly configService: ConfigService,
) {}

/**
* Resolves the MIME type allowlist from FILE_ALLOWED_MIME_TYPES env var.
* Falls back to the default list when unset or empty.
*/
private getAllowedMimeTypes(): Set<string> {
const raw = this.configService.get<string>("FILE_ALLOWED_MIME_TYPES");
if (!raw) {
return new Set(DEFAULT_ALLOWED_MIME_TYPES);
}
const parsed = raw
.split(",")
.map((t) => t.trim().toLowerCase())
.filter(Boolean);
return parsed.length > 0 ? new Set(parsed) : new Set(DEFAULT_ALLOWED_MIME_TYPES);
}

// ── Upload ────────────────────────────────────────────────────────

@Post("upload")
Expand All @@ -62,6 +133,24 @@
limits: {
fileSize: 50 * 1024 * 1024, // 50MB
},
fileFilter: (req, file, cb) => {
const f = file;
if (!f) {
cb(new BadRequestException("No file provided"), false);
return;
}
const allowed = this.getAllowedMimeTypes();

Check failure on line 142 in src/infrastructure/file-upload/file-upload.controller.ts

View workflow job for this annotation

GitHub Actions / build

Object is possibly 'undefined'.
if (allowed.has(f.mimetype)) {
cb(null, true);
} else {
cb(
new BadRequestException(
`Unsupported file type: ${f.mimetype}`,
),
false,
);
}
},
}),
)
@ApiOperation({ summary: "Upload a file" })
Expand All @@ -85,8 +174,12 @@
async uploadFile(
@UploadedFile() file: Express.Multer.File,
@Body() dto: UploadFileDto,
@CurrentUser() user: AuthUser,
) {
const userId = "system";
if (!user?.id) {
throw new BadRequestException("User identity not found in request");
}
const userId = user.id;
const result = await this.storageService.upload(file, userId, dto);

return {
Expand Down Expand Up @@ -114,12 +207,16 @@
@ApiQuery({ name: "limit", required: false })
@ApiQuery({ name: "offset", required: false })
async listFiles(
@CurrentUser() user: AuthUser,
@Query("name") name?: string,
@Query("category") category?: string,
@Query("limit") limit?: number,
@Query("offset") offset?: number,
) {
const userId = "system";
if (!user?.id) {
throw new BadRequestException("User identity not found in request");
}
const userId = user.id;
const { files, total } = await this.storageService.search(
{
name,
Expand Down Expand Up @@ -148,8 +245,11 @@

@Get("search")
@ApiOperation({ summary: "Search files with filters" })
async searchFiles(@Query() dto: FileSearchDto) {
const userId = "system";
async searchFiles(@Query() dto: FileSearchDto, @CurrentUser() user: AuthUser) {
if (!user?.id) {
throw new BadRequestException("User identity not found in request");
}
const userId = user.id;
const { files, total } = await this.storageService.search(dto, userId);

return {
Expand Down Expand Up @@ -262,8 +362,11 @@

@Get("stats/overview")
@ApiOperation({ summary: "Get file storage statistics" })
async getStats() {
const userId = "system";
async getStats(@CurrentUser() user: AuthUser) {
if (!user?.id) {
throw new BadRequestException("User identity not found in request");
}
const userId = user.id;
const stats = await this.storageService.getStorageStats(userId);
return { success: true, stats };
}
Expand Down
56 changes: 43 additions & 13 deletions src/infrastructure/webhooks/webhook.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ import {
UpdateWebhookSubscriptionDto,
PublishWebhookEventDto,
} from "./dto/webhook.dto";
import { CurrentUser } from "src/core/auth/decorators/current-user.decorator";
import { AuthUser } from "src/core/auth/strategies/interfaces/auth-strategy.interface";
import { BadRequestException } from "@nestjs/common";

@ApiTags("Webhooks")
@ApiBearerAuth()
Expand All @@ -48,9 +51,12 @@ export class WebhookController {
@ApiResponse({ status: 201, description: "Subscription created with signing key" })
async createSubscription(
@Body() dto: CreateWebhookSubscriptionDto,
@CurrentUser() user: AuthUser,
) {
// TODO: extract userId from auth guard
const userId = "system";
if (!user?.id) {
throw new BadRequestException("User identity not found in request");
}
const userId = user.id;
const sub = await this.subscriptionService.create(userId, dto);
return {
success: true,
Expand All @@ -61,8 +67,11 @@ export class WebhookController {

@Get("subscriptions")
@ApiOperation({ summary: "List all webhook subscriptions" })
async listSubscriptions() {
const userId = "system";
async listSubscriptions(@CurrentUser() user: AuthUser) {
if (!user?.id) {
throw new BadRequestException("User identity not found in request");
}
const userId = user.id;
const subscriptions = await this.subscriptionService.findAll(userId);
// Strip signing keys from response
const safe = subscriptions.map(({ signingKey, ...rest }) => rest);
Expand All @@ -72,8 +81,11 @@ export class WebhookController {
@Get("subscriptions/:id")
@ApiOperation({ summary: "Get a webhook subscription by ID" })
@ApiParam({ name: "id" })
async getSubscription(@Param("id") id: string) {
const userId = "system";
async getSubscription(@Param("id") id: string, @CurrentUser() user: AuthUser) {
if (!user?.id) {
throw new BadRequestException("User identity not found in request");
}
const userId = user.id;
const sub = await this.subscriptionService.findOne(id, userId);
const { signingKey, ...rest } = sub;
return { success: true, subscription: rest };
Expand All @@ -85,8 +97,12 @@ export class WebhookController {
async updateSubscription(
@Param("id") id: string,
@Body() dto: UpdateWebhookSubscriptionDto,
@CurrentUser() user: AuthUser,
) {
const userId = "system";
if (!user?.id) {
throw new BadRequestException("User identity not found in request");
}
const userId = user.id;
const sub = await this.subscriptionService.update(id, userId, dto);
const { signingKey, ...rest } = sub;
return { success: true, subscription: rest };
Expand All @@ -96,8 +112,11 @@ export class WebhookController {
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Disable a webhook subscription" })
@ApiParam({ name: "id" })
async deleteSubscription(@Param("id") id: string) {
const userId = "system";
async deleteSubscription(@Param("id") id: string, @CurrentUser() user: AuthUser) {
if (!user?.id) {
throw new BadRequestException("User identity not found in request");
}
const userId = user.id;
await this.subscriptionService.remove(id, userId);
return { success: true, message: "Subscription disabled" };
}
Expand All @@ -106,8 +125,11 @@ export class WebhookController {
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Rotate the signing key for a subscription" })
@ApiParam({ name: "id" })
async rotateKey(@Param("id") id: string) {
const userId = "system";
async rotateKey(@Param("id") id: string, @CurrentUser() user: AuthUser) {
if (!user?.id) {
throw new BadRequestException("User identity not found in request");
}
const userId = user.id;
const result = await this.subscriptionService.rotateKey(id, userId);
return {
success: true,
Expand All @@ -123,8 +145,12 @@ export class WebhookController {
async toggleStatus(
@Param("id") id: string,
@Query("status") status: "active" | "paused" | "disabled",
@CurrentUser() user: AuthUser,
) {
const userId = "system";
if (!user?.id) {
throw new BadRequestException("User identity not found in request");
}
const userId = user.id;
const sub = await this.subscriptionService.toggleStatus(id, userId, status as any);
const { signingKey, ...rest } = sub;
return { success: true, subscription: rest };
Expand Down Expand Up @@ -190,10 +216,14 @@ export class WebhookController {
@ApiQuery({ name: "limit", required: false })
@ApiQuery({ name: "offset", required: false })
async listDeadLetters(
@CurrentUser() user: AuthUser,
@Query("limit") limit?: number,
@Query("offset") offset?: number,
) {
const userId = "system";
if (!user?.id) {
throw new BadRequestException("User identity not found in request");
}
const userId = user.id;
const deadLetters = await this.deliveryService.getDeadLetters(
userId,
limit || 50,
Expand Down
Loading