This commit is contained in:
2026-03-10 04:13:32 +01:00
parent 506a824401
commit df309d088c
13 changed files with 555 additions and 0 deletions

165
server/dist/index.js vendored
View File

@@ -43,6 +43,9 @@ const approvePendingUserParamsSchema = z.object({
const adminDeleteUserParamsSchema = z.object({
userId: z.string().min(1),
});
const webBundleFileParamsSchema = z.object({
'*': z.string().min(1),
});
const wsQuerySchema = z.object({
token: z.string().min(1),
});
@@ -240,6 +243,45 @@ else {
app.log.warn({ frontendDistPath }, 'Angular frontend build not found. Build the client before serving it from the backend.');
}
app.get('/api/health', async () => ({ ok: true }));
app.get('/api/web-app/manifest', async (request, reply) => {
const manifest = getFrontendBundleManifest();
if (!manifest) {
return reply.code(404).send({
message: 'Angular frontend build not found.',
frontendDistPath,
});
}
const etag = `"${manifest.bundleId}"`;
reply.header('Cache-Control', 'no-cache');
reply.header('ETag', etag);
if (requestMatchesEtag(request.headers['if-none-match'], etag)) {
return reply.code(304).send();
}
return manifest;
});
app.get('/api/web-app/files/*', async (request, reply) => {
const parsed = webBundleFileParamsSchema.safeParse(request.params);
if (!parsed.success) {
return reply.code(400).send({
message: 'Invalid web bundle asset path.',
issues: parsed.error.flatten(),
});
}
const asset = resolveFrontendBundleAsset(parsed.data['*']);
if (!asset) {
return reply.code(404).send({ message: 'Frontend asset not found.' });
}
const etag = `W/"${asset.etag}"`;
reply.header('Cache-Control', 'public, max-age=300');
reply.header('ETag', etag);
reply.header('Last-Modified', new Date(asset.lastModifiedMs).toUTCString());
if (requestMatchesEtag(request.headers['if-none-match'], etag)) {
return reply.code(304).send();
}
reply.header('Content-Length', String(asset.size));
reply.type(asset.contentType);
return reply.send(fs.createReadStream(asset.absolutePath));
});
app.post('/api/auth/register', async (request, reply) => {
const parsed = registerSchema.safeParse(request.body);
if (!parsed.success) {
@@ -1123,6 +1165,129 @@ function inferImageMimeType(imageBuffer) {
}
return 'application/octet-stream';
}
function getFrontendBundleManifest() {
if (!fs.existsSync(frontendIndexPath)) {
return null;
}
const files = listBundleFilePaths(frontendDistPath).map((absolutePath) => {
const relativePath = toBundleRelativePath(path.relative(frontendDistPath, absolutePath));
const stats = fs.statSync(absolutePath);
const sha256 = crypto.createHash('sha256').update(fs.readFileSync(absolutePath)).digest('hex');
return {
path: relativePath,
size: stats.size,
sha256,
lastModified: stats.mtime.toISOString(),
contentType: detectBundleContentType(relativePath),
href: bundleAssetHref(relativePath),
};
});
files.sort((left, right) => left.path.localeCompare(right.path));
const generatedAt = files.reduce((latest, file) => (file.lastModified > latest ? file.lastModified : latest), new Date(0).toISOString());
const bundleId = files.reduce((hash, file) => {
hash.update(file.path);
hash.update(file.sha256);
hash.update(String(file.size));
return hash;
}, crypto.createHash('sha256')).digest('hex');
return {
bundleId,
generatedAt,
indexPath: 'index.html',
files,
};
}
function resolveFrontendBundleAsset(relativeAssetPath) {
if (!fs.existsSync(frontendIndexPath) || !fs.existsSync(frontendDistPath)) {
return null;
}
const normalizedPath = toBundleRelativePath(relativeAssetPath);
if (normalizedPath.length === 0 ||
normalizedPath === '.' ||
normalizedPath.startsWith('../') ||
normalizedPath.startsWith('/')) {
return null;
}
const absolutePath = path.resolve(frontendDistPath, normalizedPath);
const relativeToRoot = path.relative(frontendDistPath, absolutePath);
if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot) || !fs.existsSync(absolutePath)) {
return null;
}
const stats = fs.statSync(absolutePath);
if (!stats.isFile()) {
return null;
}
return {
absolutePath,
contentType: detectBundleContentType(normalizedPath),
size: stats.size,
lastModifiedMs: stats.mtimeMs,
etag: `${stats.size}-${Math.floor(stats.mtimeMs)}`,
};
}
function listBundleFilePaths(rootPath) {
return fs.readdirSync(rootPath, { withFileTypes: true }).flatMap((entry) => {
const entryPath = path.join(rootPath, entry.name);
if (entry.isDirectory()) {
return listBundleFilePaths(entryPath);
}
if (!entry.isFile()) {
return [];
}
return [entryPath];
});
}
function bundleAssetHref(relativePath) {
return `/api/web-app/files/${relativePath.split('/').map((segment) => encodeURIComponent(segment)).join('/')}`;
}
function toBundleRelativePath(inputPath) {
return path.posix.normalize(inputPath.replaceAll('\\', '/'));
}
function detectBundleContentType(assetPath) {
const extension = path.extname(assetPath).toLowerCase();
switch (extension) {
case '.css':
return 'text/css; charset=utf-8';
case '.html':
return 'text/html; charset=utf-8';
case '.ico':
return 'image/x-icon';
case '.jpeg':
case '.jpg':
return 'image/jpeg';
case '.js':
return 'text/javascript; charset=utf-8';
case '.json':
return 'application/json; charset=utf-8';
case '.map':
return 'application/json; charset=utf-8';
case '.png':
return 'image/png';
case '.svg':
return 'image/svg+xml; charset=utf-8';
case '.txt':
return 'text/plain; charset=utf-8';
case '.webp':
return 'image/webp';
case '.webmanifest':
return 'application/manifest+json; charset=utf-8';
case '.woff':
return 'font/woff';
case '.woff2':
return 'font/woff2';
default:
return 'application/octet-stream';
}
}
function requestMatchesEtag(headerValue, etag) {
if (!headerValue) {
return false;
}
const incomingEtags = Array.isArray(headerValue)
? headerValue
: headerValue.split(',').map((value) => value.trim());
return incomingEtags.includes(etag) || incomingEtags.includes('*');
}
function normalizeSignal(signal) {
if (signal.type === 'sdp') {
return {

View File

@@ -171,6 +171,22 @@ type AccessKeySummary = {
createdAt: string;
};
type WebBundleFileEntry = {
path: string;
size: number;
sha256: string;
lastModified: string;
contentType: string;
href: string;
};
type WebBundleManifest = {
bundleId: string;
generatedAt: string;
indexPath: string;
files: WebBundleFileEntry[];
};
type PendingRegistration = {
challenge: string;
label: string;
@@ -233,6 +249,10 @@ const adminDeleteUserParamsSchema = z.object({
userId: z.string().min(1),
});
const webBundleFileParamsSchema = z.object({
'*': z.string().min(1),
});
const wsQuerySchema = z.object({
token: z.string().min(1),
});
@@ -461,6 +481,57 @@ if (hasFrontendBuild) {
app.get('/api/health', async () => ({ ok: true }));
app.get('/api/web-app/manifest', async (request, reply) => {
const manifest = getFrontendBundleManifest();
if (!manifest) {
return reply.code(404).send({
message: 'Angular frontend build not found.',
frontendDistPath,
});
}
const etag = `"${manifest.bundleId}"`;
reply.header('Cache-Control', 'no-cache');
reply.header('ETag', etag);
if (requestMatchesEtag(request.headers['if-none-match'], etag)) {
return reply.code(304).send();
}
return manifest;
});
app.get('/api/web-app/files/*', async (request, reply) => {
const parsed = webBundleFileParamsSchema.safeParse(request.params);
if (!parsed.success) {
return reply.code(400).send({
message: 'Invalid web bundle asset path.',
issues: parsed.error.flatten(),
});
}
const asset = resolveFrontendBundleAsset(parsed.data['*']);
if (!asset) {
return reply.code(404).send({ message: 'Frontend asset not found.' });
}
const etag = `W/"${asset.etag}"`;
reply.header('Cache-Control', 'public, max-age=300');
reply.header('ETag', etag);
reply.header('Last-Modified', new Date(asset.lastModifiedMs).toUTCString());
if (requestMatchesEtag(request.headers['if-none-match'], etag)) {
return reply.code(304).send();
}
reply.header('Content-Length', String(asset.size));
reply.type(asset.contentType);
return reply.send(fs.createReadStream(asset.absolutePath));
});
app.post('/api/auth/register', async (request, reply) => {
const parsed = registerSchema.safeParse(request.body);
@@ -1685,6 +1756,165 @@ function inferImageMimeType(imageBuffer: Buffer): string {
return 'application/octet-stream';
}
function getFrontendBundleManifest(): WebBundleManifest | null {
if (!fs.existsSync(frontendIndexPath)) {
return null;
}
const files = listBundleFilePaths(frontendDistPath).map((absolutePath) => {
const relativePath = toBundleRelativePath(path.relative(frontendDistPath, absolutePath));
const stats = fs.statSync(absolutePath);
const sha256 = crypto.createHash('sha256').update(fs.readFileSync(absolutePath)).digest('hex');
return {
path: relativePath,
size: stats.size,
sha256,
lastModified: stats.mtime.toISOString(),
contentType: detectBundleContentType(relativePath),
href: bundleAssetHref(relativePath),
} satisfies WebBundleFileEntry;
});
files.sort((left, right) => left.path.localeCompare(right.path));
const generatedAt = files.reduce(
(latest, file) => (file.lastModified > latest ? file.lastModified : latest),
new Date(0).toISOString(),
);
const bundleId = files.reduce((hash, file) => {
hash.update(file.path);
hash.update(file.sha256);
hash.update(String(file.size));
return hash;
}, crypto.createHash('sha256')).digest('hex');
return {
bundleId,
generatedAt,
indexPath: 'index.html',
files,
};
}
function resolveFrontendBundleAsset(relativeAssetPath: string): {
absolutePath: string;
contentType: string;
size: number;
lastModifiedMs: number;
etag: string;
} | null {
if (!fs.existsSync(frontendIndexPath) || !fs.existsSync(frontendDistPath)) {
return null;
}
const normalizedPath = toBundleRelativePath(relativeAssetPath);
if (
normalizedPath.length === 0 ||
normalizedPath === '.' ||
normalizedPath.startsWith('../') ||
normalizedPath.startsWith('/')
) {
return null;
}
const absolutePath = path.resolve(frontendDistPath, normalizedPath);
const relativeToRoot = path.relative(frontendDistPath, absolutePath);
if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot) || !fs.existsSync(absolutePath)) {
return null;
}
const stats = fs.statSync(absolutePath);
if (!stats.isFile()) {
return null;
}
return {
absolutePath,
contentType: detectBundleContentType(normalizedPath),
size: stats.size,
lastModifiedMs: stats.mtimeMs,
etag: `${stats.size}-${Math.floor(stats.mtimeMs)}`,
};
}
function listBundleFilePaths(rootPath: string): string[] {
return fs.readdirSync(rootPath, { withFileTypes: true }).flatMap((entry) => {
const entryPath = path.join(rootPath, entry.name);
if (entry.isDirectory()) {
return listBundleFilePaths(entryPath);
}
if (!entry.isFile()) {
return [];
}
return [entryPath];
});
}
function bundleAssetHref(relativePath: string): string {
return `/api/web-app/files/${relativePath.split('/').map((segment) => encodeURIComponent(segment)).join('/')}`;
}
function toBundleRelativePath(inputPath: string): string {
return path.posix.normalize(inputPath.replaceAll('\\', '/'));
}
function detectBundleContentType(assetPath: string): string {
const extension = path.extname(assetPath).toLowerCase();
switch (extension) {
case '.css':
return 'text/css; charset=utf-8';
case '.html':
return 'text/html; charset=utf-8';
case '.ico':
return 'image/x-icon';
case '.jpeg':
case '.jpg':
return 'image/jpeg';
case '.js':
return 'text/javascript; charset=utf-8';
case '.json':
return 'application/json; charset=utf-8';
case '.map':
return 'application/json; charset=utf-8';
case '.png':
return 'image/png';
case '.svg':
return 'image/svg+xml; charset=utf-8';
case '.txt':
return 'text/plain; charset=utf-8';
case '.webp':
return 'image/webp';
case '.webmanifest':
return 'application/manifest+json; charset=utf-8';
case '.woff':
return 'font/woff';
case '.woff2':
return 'font/woff2';
default:
return 'application/octet-stream';
}
}
function requestMatchesEtag(headerValue: string | string[] | undefined, etag: string): boolean {
if (!headerValue) {
return false;
}
const incomingEtags = Array.isArray(headerValue)
? headerValue
: headerValue.split(',').map((value) => value.trim());
return incomingEtags.includes(etag) || incomingEtags.includes('*');
}
function normalizeSignal(signal: Extract<ClientMessage, { type: 'signal' }>['signal']): SignalPayload {
if (signal.type === 'sdp') {
return {