PWA
This commit is contained in:
165
server/dist/index.js
vendored
165
server/dist/index.js
vendored
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user