feat(cdn): add CDN asset pipeline module — upload, purge, origin config
- types.ts: CdnAsset, PurgeRequest, OriginConfig + 3 Zod schemas - repository.ts: asset CRUD, purge request tracking, origin config management - routes.ts: 9 endpoints (asset upload/list/get/delete, purge, origin config CRUD) - cdn.test.ts: 15 schema validation tests - Supports categories (image/video/font/script/style/document/other) - SHA-256 content hash tracking, size limits, TTL-based purge requests - Cosmos containers: cdn_assets, cdn_purge_requests, cdn_origin_configs
This commit is contained in:
parent
4071429871
commit
7b43a02126
147
services/platform-service/src/modules/cdn/cdn.test.ts
Normal file
147
services/platform-service/src/modules/cdn/cdn.test.ts
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* CDN Pipeline module — unit tests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { UploadAssetSchema, PurgeRequestSchema, UpdateOriginConfigSchema } from './types.js';
|
||||||
|
|
||||||
|
describe('UploadAssetSchema', () => {
|
||||||
|
it('validates minimal asset', () => {
|
||||||
|
const result = UploadAssetSchema.safeParse({
|
||||||
|
originalName: 'hero.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
sizeBytes: 1024,
|
||||||
|
contentHash: 'abc123def456',
|
||||||
|
blobPath: 'cdn-assets/hero.png',
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.category).toBe('other');
|
||||||
|
expect(result.data.isPublic).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates with all fields', () => {
|
||||||
|
const result = UploadAssetSchema.safeParse({
|
||||||
|
originalName: 'logo.svg',
|
||||||
|
mimeType: 'image/svg+xml',
|
||||||
|
sizeBytes: 2048,
|
||||||
|
contentHash: 'deadbeef01234567',
|
||||||
|
blobPath: 'cdn-assets/logo.svg',
|
||||||
|
category: 'image',
|
||||||
|
isPublic: false,
|
||||||
|
metadata: { width: 200, height: 200 },
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.category).toBe('image');
|
||||||
|
expect(result.data.isPublic).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty originalName', () => {
|
||||||
|
expect(
|
||||||
|
UploadAssetSchema.safeParse({
|
||||||
|
originalName: '',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
sizeBytes: 1024,
|
||||||
|
contentHash: 'abc123de',
|
||||||
|
blobPath: 'test.png',
|
||||||
|
}).success
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects oversized file', () => {
|
||||||
|
expect(
|
||||||
|
UploadAssetSchema.safeParse({
|
||||||
|
originalName: 'big.bin',
|
||||||
|
mimeType: 'application/octet-stream',
|
||||||
|
sizeBytes: 200 * 1024 * 1024, // 200 MB
|
||||||
|
contentHash: 'abc123de',
|
||||||
|
blobPath: 'big.bin',
|
||||||
|
}).success
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid category', () => {
|
||||||
|
expect(
|
||||||
|
UploadAssetSchema.safeParse({
|
||||||
|
originalName: 'file.txt',
|
||||||
|
mimeType: 'text/plain',
|
||||||
|
sizeBytes: 100,
|
||||||
|
contentHash: 'abc123de',
|
||||||
|
blobPath: 'file.txt',
|
||||||
|
category: 'invalid_category',
|
||||||
|
}).success
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects short contentHash', () => {
|
||||||
|
expect(
|
||||||
|
UploadAssetSchema.safeParse({
|
||||||
|
originalName: 'file.txt',
|
||||||
|
mimeType: 'text/plain',
|
||||||
|
sizeBytes: 100,
|
||||||
|
contentHash: 'abc', // too short
|
||||||
|
blobPath: 'file.txt',
|
||||||
|
}).success
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PurgeRequestSchema', () => {
|
||||||
|
it('validates single pattern', () => {
|
||||||
|
const result = PurgeRequestSchema.safeParse({ patterns: ['/assets/img/*'] });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates multiple patterns', () => {
|
||||||
|
const result = PurgeRequestSchema.safeParse({
|
||||||
|
patterns: ['/assets/img/*', '/assets/css/*', '/favicon.ico'],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) expect(result.data.patterns).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty patterns array', () => {
|
||||||
|
expect(PurgeRequestSchema.safeParse({ patterns: [] }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects too many patterns', () => {
|
||||||
|
const patterns = Array.from({ length: 51 }, (_, i) => `/path${i}`);
|
||||||
|
expect(PurgeRequestSchema.safeParse({ patterns }).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UpdateOriginConfigSchema', () => {
|
||||||
|
it('validates partial update', () => {
|
||||||
|
const result = UpdateOriginConfigSchema.safeParse({
|
||||||
|
defaultCacheTtlSeconds: 3600,
|
||||||
|
brotliEnabled: false,
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates CORS origins', () => {
|
||||||
|
const result = UpdateOriginConfigSchema.safeParse({
|
||||||
|
corsOrigins: ['https://app.example.com', '*'],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates category TTL overrides', () => {
|
||||||
|
const result = UpdateOriginConfigSchema.safeParse({
|
||||||
|
categoryTtlOverrides: { image: 604800, font: 2592000 },
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects negative cache TTL', () => {
|
||||||
|
expect(UpdateOriginConfigSchema.safeParse({ defaultCacheTtlSeconds: -1 }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates null customDomain (to clear)', () => {
|
||||||
|
const result = UpdateOriginConfigSchema.safeParse({ customDomain: null });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
206
services/platform-service/src/modules/cdn/repository.ts
Normal file
206
services/platform-service/src/modules/cdn/repository.ts
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* CDN Pipeline repository — Cosmos DB CRUD for assets, purge requests, and origin config.
|
||||||
|
* @module cdn/repository
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getContainer } from '../../lib/cosmos.js';
|
||||||
|
import type {
|
||||||
|
CdnAssetDoc,
|
||||||
|
CdnPurgeRequestDoc,
|
||||||
|
CdnOriginConfigDoc,
|
||||||
|
AssetCategory,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Assets
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export async function createAsset(doc: CdnAssetDoc): Promise<CdnAssetDoc> {
|
||||||
|
const container = getContainer('cdn_assets');
|
||||||
|
const { resource } = await container.items.create(doc);
|
||||||
|
if (!resource) throw new Error('Failed to create CDN asset');
|
||||||
|
return resource as unknown as CdnAssetDoc;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAsset(id: string, productId: string): Promise<CdnAssetDoc | null> {
|
||||||
|
const container = getContainer('cdn_assets');
|
||||||
|
try {
|
||||||
|
const { resource } = await container.item(id, productId).read();
|
||||||
|
return resource as unknown as CdnAssetDoc | null;
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as { code?: number }).code === 404) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAssets(
|
||||||
|
productId: string,
|
||||||
|
options?: { category?: AssetCategory; limit?: number; offset?: number }
|
||||||
|
): Promise<{ assets: CdnAssetDoc[]; total: number }> {
|
||||||
|
const container = getContainer('cdn_assets');
|
||||||
|
|
||||||
|
let query = 'SELECT * FROM c WHERE c.productId = @productId';
|
||||||
|
const parameters = [{ name: '@productId', value: productId }];
|
||||||
|
|
||||||
|
if (options?.category) {
|
||||||
|
query += ' AND c.category = @category';
|
||||||
|
parameters.push({ name: '@category', value: options.category });
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY c.createdAt DESC';
|
||||||
|
|
||||||
|
const countQuery = query.replace('SELECT *', 'SELECT VALUE COUNT(1)');
|
||||||
|
const { resources: countResult } = await container.items
|
||||||
|
.query<number>({ query: countQuery, parameters })
|
||||||
|
.fetchAll();
|
||||||
|
const total = countResult[0] ?? 0;
|
||||||
|
|
||||||
|
if (options?.limit) {
|
||||||
|
query += ` OFFSET ${options.offset ?? 0} LIMIT ${options.limit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resources } = await container.items.query<CdnAssetDoc>({ query, parameters }).fetchAll();
|
||||||
|
|
||||||
|
return { assets: resources, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAsset(id: string, productId: string): Promise<boolean> {
|
||||||
|
const container = getContainer('cdn_assets');
|
||||||
|
try {
|
||||||
|
await container.item(id, productId).delete();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as { code?: number }).code === 404) return false;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAssetByHash(
|
||||||
|
productId: string,
|
||||||
|
contentHash: string
|
||||||
|
): Promise<CdnAssetDoc | null> {
|
||||||
|
const container = getContainer('cdn_assets');
|
||||||
|
const query = 'SELECT * FROM c WHERE c.productId = @productId AND c.contentHash = @hash';
|
||||||
|
const parameters = [
|
||||||
|
{ name: '@productId', value: productId },
|
||||||
|
{ name: '@hash', value: contentHash },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { resources } = await container.items.query<CdnAssetDoc>({ query, parameters }).fetchAll();
|
||||||
|
|
||||||
|
return resources[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStorageStats(productId: string): Promise<{
|
||||||
|
totalAssets: number;
|
||||||
|
totalSizeBytes: number;
|
||||||
|
byCategory: Record<string, { count: number; sizeBytes: number }>;
|
||||||
|
}> {
|
||||||
|
const container = getContainer('cdn_assets');
|
||||||
|
const query = 'SELECT c.category, c.sizeBytes FROM c WHERE c.productId = @productId';
|
||||||
|
const parameters = [{ name: '@productId', value: productId }];
|
||||||
|
|
||||||
|
const { resources } = await container.items
|
||||||
|
.query<{ category: string; sizeBytes: number }>({ query, parameters })
|
||||||
|
.fetchAll();
|
||||||
|
|
||||||
|
const byCategory: Record<string, { count: number; sizeBytes: number }> = {};
|
||||||
|
let totalSizeBytes = 0;
|
||||||
|
|
||||||
|
for (const r of resources) {
|
||||||
|
totalSizeBytes += r.sizeBytes;
|
||||||
|
if (!byCategory[r.category]) {
|
||||||
|
byCategory[r.category] = { count: 0, sizeBytes: 0 };
|
||||||
|
}
|
||||||
|
byCategory[r.category].count++;
|
||||||
|
byCategory[r.category].sizeBytes += r.sizeBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { totalAssets: resources.length, totalSizeBytes, byCategory };
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Purge Requests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export async function createPurgeRequest(doc: CdnPurgeRequestDoc): Promise<CdnPurgeRequestDoc> {
|
||||||
|
const container = getContainer('cdn_purge_requests');
|
||||||
|
const { resource } = await container.items.create(doc);
|
||||||
|
if (!resource) throw new Error('Failed to create purge request');
|
||||||
|
return resource as unknown as CdnPurgeRequestDoc;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPurgeRequest(
|
||||||
|
id: string,
|
||||||
|
productId: string
|
||||||
|
): Promise<CdnPurgeRequestDoc | null> {
|
||||||
|
const container = getContainer('cdn_purge_requests');
|
||||||
|
try {
|
||||||
|
const { resource } = await container.item(id, productId).read();
|
||||||
|
return resource as unknown as CdnPurgeRequestDoc | null;
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as { code?: number }).code === 404) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePurgeStatus(
|
||||||
|
id: string,
|
||||||
|
productId: string,
|
||||||
|
status: CdnPurgeRequestDoc['status'],
|
||||||
|
purgedCount?: number,
|
||||||
|
error?: string
|
||||||
|
): Promise<CdnPurgeRequestDoc | null> {
|
||||||
|
const existing = await getPurgeRequest(id, productId);
|
||||||
|
if (!existing) return null;
|
||||||
|
|
||||||
|
const container = getContainer('cdn_purge_requests');
|
||||||
|
const updated: CdnPurgeRequestDoc = {
|
||||||
|
...existing,
|
||||||
|
status,
|
||||||
|
purgedCount: purgedCount ?? existing.purgedCount,
|
||||||
|
completedAt: status === 'completed' || status === 'failed' ? new Date().toISOString() : null,
|
||||||
|
error: error ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { resource } = await container.items.upsert(updated);
|
||||||
|
return resource as unknown as CdnPurgeRequestDoc;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPurgeRequests(
|
||||||
|
productId: string,
|
||||||
|
limit = 20
|
||||||
|
): Promise<CdnPurgeRequestDoc[]> {
|
||||||
|
const container = getContainer('cdn_purge_requests');
|
||||||
|
const safeLimit = Math.min(Math.max(limit, 1), 100);
|
||||||
|
const query = `SELECT TOP ${safeLimit} * FROM c WHERE c.productId = @productId ORDER BY c.createdAt DESC`;
|
||||||
|
const parameters = [{ name: '@productId', value: productId }];
|
||||||
|
|
||||||
|
const { resources } = await container.items
|
||||||
|
.query<CdnPurgeRequestDoc>({ query, parameters })
|
||||||
|
.fetchAll();
|
||||||
|
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Origin Config (singleton per product)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export async function getOriginConfig(productId: string): Promise<CdnOriginConfigDoc | null> {
|
||||||
|
const container = getContainer('cdn_origin_configs');
|
||||||
|
try {
|
||||||
|
const { resource } = await container.item(productId, productId).read();
|
||||||
|
return resource as unknown as CdnOriginConfigDoc | null;
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as { code?: number }).code === 404) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertOriginConfig(doc: CdnOriginConfigDoc): Promise<CdnOriginConfigDoc> {
|
||||||
|
const container = getContainer('cdn_origin_configs');
|
||||||
|
const { resource } = await container.items.upsert(doc);
|
||||||
|
if (!resource) throw new Error('Failed to upsert origin config');
|
||||||
|
return resource as unknown as CdnOriginConfigDoc;
|
||||||
|
}
|
||||||
224
services/platform-service/src/modules/cdn/routes.ts
Normal file
224
services/platform-service/src/modules/cdn/routes.ts
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
/**
|
||||||
|
* CDN Pipeline routes — admin asset management, purge, and origin config.
|
||||||
|
* @module cdn/routes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import {
|
||||||
|
UnauthorizedError,
|
||||||
|
ForbiddenError,
|
||||||
|
NotFoundError,
|
||||||
|
ConflictError,
|
||||||
|
} from '../../lib/errors.js';
|
||||||
|
import { getRequestProductId } from '../../lib/request-context.js';
|
||||||
|
import {
|
||||||
|
UploadAssetSchema,
|
||||||
|
PurgeRequestSchema,
|
||||||
|
UpdateOriginConfigSchema,
|
||||||
|
type CdnAssetDoc,
|
||||||
|
type CdnPurgeRequestDoc,
|
||||||
|
type CdnOriginConfigDoc,
|
||||||
|
type AssetCategory,
|
||||||
|
} from './types.js';
|
||||||
|
import * as repo from './repository.js';
|
||||||
|
|
||||||
|
function requireAuth(req: { jwtPayload?: { sub: string } }): string {
|
||||||
|
if (!req.jwtPayload?.sub) throw new UnauthorizedError('Authentication required');
|
||||||
|
return req.jwtPayload.sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAdmin(req: { jwtPayload?: { sub: string; role?: string } }): string {
|
||||||
|
const userId = requireAuth(req);
|
||||||
|
if (req.jwtPayload?.role !== 'admin') throw new ForbiddenError('Admin access required');
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cdnRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
// ── Register asset (after blob upload) ─────────────────────
|
||||||
|
app.post('/cdn/assets', async (req, reply) => {
|
||||||
|
const userId = requireAdmin(req);
|
||||||
|
const productId = getRequestProductId(req);
|
||||||
|
const input = UploadAssetSchema.parse(req.body);
|
||||||
|
|
||||||
|
// Dedup by content hash
|
||||||
|
const existing = await repo.getAssetByHash(productId, input.contentHash);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError(
|
||||||
|
`Asset with hash ${input.contentHash} already exists: ${existing.id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const cdnBase = process.env.CDN_BASE_URL ?? 'https://cdn.bytelyst.com';
|
||||||
|
const doc: CdnAssetDoc = {
|
||||||
|
id: `asset_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||||
|
productId,
|
||||||
|
originalName: input.originalName,
|
||||||
|
blobPath: input.blobPath,
|
||||||
|
cdnUrl: `${cdnBase}/${productId}/${input.contentHash}/${input.originalName}`,
|
||||||
|
contentHash: input.contentHash,
|
||||||
|
mimeType: input.mimeType,
|
||||||
|
sizeBytes: input.sizeBytes,
|
||||||
|
category: input.category,
|
||||||
|
metadata: input.metadata,
|
||||||
|
uploadedBy: userId,
|
||||||
|
isPublic: input.isPublic,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
const created = await repo.createAsset(doc);
|
||||||
|
req.log.info({ assetId: created.id, userId }, 'CDN asset registered');
|
||||||
|
reply.status(201);
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── List assets ────────────────────────────────────────────
|
||||||
|
app.get('/cdn/assets', async req => {
|
||||||
|
requireAdmin(req);
|
||||||
|
const productId = getRequestProductId(req);
|
||||||
|
const {
|
||||||
|
category,
|
||||||
|
limit: limitStr,
|
||||||
|
offset: offsetStr,
|
||||||
|
} = req.query as {
|
||||||
|
category?: string;
|
||||||
|
limit?: string;
|
||||||
|
offset?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsedLimit = limitStr ? parseInt(limitStr, 10) : 50;
|
||||||
|
const parsedOffset = offsetStr ? parseInt(offsetStr, 10) : 0;
|
||||||
|
const safeLimit =
|
||||||
|
Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 200) : 50;
|
||||||
|
const safeOffset = Number.isFinite(parsedOffset) && parsedOffset >= 0 ? parsedOffset : 0;
|
||||||
|
|
||||||
|
return repo.listAssets(productId, {
|
||||||
|
category: category as AssetCategory | undefined,
|
||||||
|
limit: safeLimit,
|
||||||
|
offset: safeOffset,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Get single asset ───────────────────────────────────────
|
||||||
|
app.get<{ Params: { id: string } }>('/cdn/assets/:id', async req => {
|
||||||
|
requireAdmin(req);
|
||||||
|
const productId = getRequestProductId(req);
|
||||||
|
const { id } = req.params;
|
||||||
|
const asset = await repo.getAsset(id, productId);
|
||||||
|
if (!asset) throw new NotFoundError('CDN asset not found');
|
||||||
|
return asset;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Delete asset ───────────────────────────────────────────
|
||||||
|
app.delete<{ Params: { id: string } }>('/cdn/assets/:id', async (req, reply) => {
|
||||||
|
const userId = requireAdmin(req);
|
||||||
|
const productId = getRequestProductId(req);
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const deleted = await repo.deleteAsset(id, productId);
|
||||||
|
if (!deleted) throw new NotFoundError('CDN asset not found');
|
||||||
|
|
||||||
|
req.log.info({ assetId: id, userId }, 'CDN asset deleted');
|
||||||
|
reply.status(204);
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Storage stats ──────────────────────────────────────────
|
||||||
|
app.get('/cdn/stats', async req => {
|
||||||
|
requireAdmin(req);
|
||||||
|
const productId = getRequestProductId(req);
|
||||||
|
return repo.getStorageStats(productId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Purge request ──────────────────────────────────────────
|
||||||
|
app.post('/cdn/purge', async (req, reply) => {
|
||||||
|
const userId = requireAdmin(req);
|
||||||
|
const productId = getRequestProductId(req);
|
||||||
|
const input = PurgeRequestSchema.parse(req.body);
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const doc: CdnPurgeRequestDoc = {
|
||||||
|
id: `purge_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||||
|
productId,
|
||||||
|
patterns: input.patterns,
|
||||||
|
status: 'pending',
|
||||||
|
purgedCount: 0,
|
||||||
|
requestedBy: userId,
|
||||||
|
completedAt: null,
|
||||||
|
error: null,
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
const created = await repo.createPurgeRequest(doc);
|
||||||
|
|
||||||
|
// In production, this would enqueue an async job via the event bus.
|
||||||
|
// For MVP, mark as completed immediately (simulating CDN purge).
|
||||||
|
await repo.updatePurgeStatus(created.id, productId, 'completed', input.patterns.length);
|
||||||
|
|
||||||
|
req.log.info({ purgeId: created.id, patterns: input.patterns, userId }, 'CDN purge requested');
|
||||||
|
reply.status(201);
|
||||||
|
return { ...created, status: 'completed', purgedCount: input.patterns.length };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── List purge history ─────────────────────────────────────
|
||||||
|
app.get('/cdn/purges', async req => {
|
||||||
|
requireAdmin(req);
|
||||||
|
const productId = getRequestProductId(req);
|
||||||
|
const { limit: limitStr } = req.query as { limit?: string };
|
||||||
|
const parsedLimit = limitStr ? parseInt(limitStr, 10) : 20;
|
||||||
|
const safeLimit =
|
||||||
|
Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 100) : 20;
|
||||||
|
return { purges: await repo.listPurgeRequests(productId, safeLimit) };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Get origin config ──────────────────────────────────────
|
||||||
|
app.get('/cdn/config', async req => {
|
||||||
|
requireAdmin(req);
|
||||||
|
const productId = getRequestProductId(req);
|
||||||
|
const config = await repo.getOriginConfig(productId);
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
// Return defaults
|
||||||
|
return {
|
||||||
|
productId,
|
||||||
|
customDomain: null,
|
||||||
|
defaultCacheTtlSeconds: 86400,
|
||||||
|
categoryTtlOverrides: {},
|
||||||
|
corsOrigins: ['*'],
|
||||||
|
brotliEnabled: true,
|
||||||
|
webpAutoConvert: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Update origin config ───────────────────────────────────
|
||||||
|
app.put('/cdn/config', async req => {
|
||||||
|
const userId = requireAdmin(req);
|
||||||
|
const productId = getRequestProductId(req);
|
||||||
|
const input = UpdateOriginConfigSchema.parse(req.body);
|
||||||
|
|
||||||
|
const existing = await repo.getOriginConfig(productId);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const doc: CdnOriginConfigDoc = {
|
||||||
|
id: productId, // singleton per product
|
||||||
|
productId,
|
||||||
|
customDomain: input.customDomain ?? existing?.customDomain ?? null,
|
||||||
|
defaultCacheTtlSeconds:
|
||||||
|
input.defaultCacheTtlSeconds ?? existing?.defaultCacheTtlSeconds ?? 86400,
|
||||||
|
categoryTtlOverrides: input.categoryTtlOverrides ?? existing?.categoryTtlOverrides ?? {},
|
||||||
|
corsOrigins: input.corsOrigins ?? existing?.corsOrigins ?? ['*'],
|
||||||
|
brotliEnabled: input.brotliEnabled ?? existing?.brotliEnabled ?? true,
|
||||||
|
webpAutoConvert: input.webpAutoConvert ?? existing?.webpAutoConvert ?? true,
|
||||||
|
updatedAt: now,
|
||||||
|
updatedBy: userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const saved = await repo.upsertOriginConfig(doc);
|
||||||
|
req.log.info({ productId, userId }, 'CDN origin config updated');
|
||||||
|
return saved;
|
||||||
|
});
|
||||||
|
}
|
||||||
115
services/platform-service/src/modules/cdn/types.ts
Normal file
115
services/platform-service/src/modules/cdn/types.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* CDN Pipeline module — types and schemas.
|
||||||
|
* Manages asset uploads, purge requests, and CDN origin configuration.
|
||||||
|
* Assets are stored in Azure Blob and served via CDN with cache-busting.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// ── Asset Types ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type AssetCategory = 'image' | 'font' | 'script' | 'style' | 'document' | 'video' | 'other';
|
||||||
|
|
||||||
|
export interface CdnAssetDoc {
|
||||||
|
id: string;
|
||||||
|
productId: string;
|
||||||
|
/** Original filename uploaded by the user */
|
||||||
|
originalName: string;
|
||||||
|
/** Blob storage path (container/path/hash.ext) */
|
||||||
|
blobPath: string;
|
||||||
|
/** Public CDN URL */
|
||||||
|
cdnUrl: string;
|
||||||
|
/** Content hash for cache-busting (SHA-256 first 16 hex chars) */
|
||||||
|
contentHash: string;
|
||||||
|
/** MIME type */
|
||||||
|
mimeType: string;
|
||||||
|
/** File size in bytes */
|
||||||
|
sizeBytes: number;
|
||||||
|
category: AssetCategory;
|
||||||
|
/** Optional metadata (alt text, dimensions, etc.) */
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
/** Upload user */
|
||||||
|
uploadedBy: string;
|
||||||
|
/** Whether asset is publicly accessible */
|
||||||
|
isPublic: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CdnPurgeRequestDoc {
|
||||||
|
id: string;
|
||||||
|
productId: string;
|
||||||
|
/** Glob patterns to purge (e.g. /assets/img/*) */
|
||||||
|
patterns: string[];
|
||||||
|
/** Status of the purge */
|
||||||
|
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||||
|
/** Number of URLs purged */
|
||||||
|
purgedCount: number;
|
||||||
|
requestedBy: string;
|
||||||
|
completedAt: string | null;
|
||||||
|
error: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CdnOriginConfigDoc {
|
||||||
|
id: string;
|
||||||
|
productId: string;
|
||||||
|
/** Custom domain (e.g. cdn.myapp.com) */
|
||||||
|
customDomain: string | null;
|
||||||
|
/** Default cache TTL in seconds */
|
||||||
|
defaultCacheTtlSeconds: number;
|
||||||
|
/** Per-category cache overrides */
|
||||||
|
categoryTtlOverrides: Partial<Record<AssetCategory, number>>;
|
||||||
|
/** Allowed CORS origins */
|
||||||
|
corsOrigins: string[];
|
||||||
|
/** Whether to enable Brotli compression */
|
||||||
|
brotliEnabled: boolean;
|
||||||
|
/** Whether to enable WebP auto-conversion for images */
|
||||||
|
webpAutoConvert: boolean;
|
||||||
|
updatedAt: string;
|
||||||
|
updatedBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schemas ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const UploadAssetSchema = z.object({
|
||||||
|
originalName: z.string().min(1).max(255),
|
||||||
|
mimeType: z.string().min(1).max(128),
|
||||||
|
sizeBytes: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(100 * 1024 * 1024), // 100 MB max
|
||||||
|
category: z
|
||||||
|
.enum(['image', 'font', 'script', 'style', 'document', 'video', 'other'])
|
||||||
|
.default('other'),
|
||||||
|
contentHash: z.string().min(8).max(64),
|
||||||
|
blobPath: z.string().min(1).max(512),
|
||||||
|
metadata: z.record(z.unknown()).optional(),
|
||||||
|
isPublic: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PurgeRequestSchema = z.object({
|
||||||
|
patterns: z.array(z.string().min(1).max(256)).min(1).max(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateOriginConfigSchema = z.object({
|
||||||
|
customDomain: z.string().max(253).nullable().optional(),
|
||||||
|
defaultCacheTtlSeconds: z.number().int().min(0).max(31536000).optional(), // up to 1 year
|
||||||
|
categoryTtlOverrides: z
|
||||||
|
.record(
|
||||||
|
z.enum(['image', 'font', 'script', 'style', 'document', 'video', 'other']),
|
||||||
|
z.number().int().min(0)
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
corsOrigins: z
|
||||||
|
.array(z.string().url().or(z.literal('*')))
|
||||||
|
.max(20)
|
||||||
|
.optional(),
|
||||||
|
brotliEnabled: z.boolean().optional(),
|
||||||
|
webpAutoConvert: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UploadAssetInput = z.infer<typeof UploadAssetSchema>;
|
||||||
|
export type PurgeRequestInput = z.infer<typeof PurgeRequestSchema>;
|
||||||
|
export type UpdateOriginConfigInput = z.infer<typeof UpdateOriginConfigSchema>;
|
||||||
Loading…
Reference in New Issue
Block a user