Carries the still-applicable findings from the PR #116 review into PR #120, since #120 includes the mobile-bootstrap commit. CORS hardening (removing the unconditional localhost/capacitor origins) is intentionally left out of scope. Service worker routing (latent bug fix + testability): - vite.config.ts: anchor Workbox path matching to a segment boundary (^/<seg>(/|$)) instead of startsWith, so siblings like /apidocs, /collaborators, /socket.iox are no longer mis-routed as API/realtime and forced NetworkOnly; align navigateFallbackDenylist with the same anchors. - new apps/client/src/pwa/sw-strategy.ts holds the canonical predicates (isApiPath, isCollabOrSocketPath) + unit tests; the vite.config regexes mirror it inline (Workbox generateSW serializes urlPattern fns standalone, so they cannot import the module). Server CORS (R1 extraction + coverage): - extract buildCorsAllowlist / isOriginAllowed into cors.util.ts with unit tests (evil-origin rejected, WebView/no-Origin allowed); main.ts rewired to use them with byte-for-byte identical behavior. Privacy — clear offline cache on logout: - new clear-offline-cache.ts purges the persisted query cache (idb-keyval gitmost-rq-cache), the Yjs page.* IndexedDB databases, and the service-worker api-get-cache; wired into handleLogout (best-effort, before the redirect) so a previous user's private data does not linger locally. Conventions & docs: - prettier fixes on main.ts and login.dto.ts. - CHANGELOG: document offline reading, returnToken opt-in, optional Swagger, new env vars, logout cache-clear, and the CORS open->allowlist breaking change. - docs/mobile-app-plan.md: correct the now-false §2.4 claims and update the §12 checklist (native cap add ios left unchecked — generated locally, gitignored). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
216 lines
6.6 KiB
TypeScript
216 lines
6.6 KiB
TypeScript
import { NestFactory, Reflector } from '@nestjs/core';
|
|
import { AppModule } from './app.module';
|
|
import {
|
|
FastifyAdapter,
|
|
NestFastifyApplication,
|
|
} from '@nestjs/platform-fastify';
|
|
import { Logger, NotFoundException, ValidationPipe } from '@nestjs/common';
|
|
import { Logger as PinoLogger } from 'nestjs-pino';
|
|
import { TransformHttpResponseInterceptor } from './common/interceptors/http-response.interceptor';
|
|
import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter';
|
|
import fastifyMultipart from '@fastify/multipart';
|
|
import fastifyCookie from '@fastify/cookie';
|
|
import fastifyIp from 'fastify-ip';
|
|
import { InternalLogFilter } from './common/logger/internal-log-filter';
|
|
import { EnvironmentService } from './integrations/environment/environment.service';
|
|
import { resolveFrameHeader } from './common/helpers';
|
|
import { resolveTrustProxy } from './integrations/environment/trust-proxy.util';
|
|
import {
|
|
buildCorsAllowlist,
|
|
isOriginAllowed,
|
|
} from './integrations/environment/cors.util';
|
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
|
|
|
async function bootstrap() {
|
|
const app = await NestFactory.create<NestFastifyApplication>(
|
|
AppModule,
|
|
new FastifyAdapter({
|
|
trustProxy: resolveTrustProxy(process.env.TRUST_PROXY),
|
|
routerOptions: {
|
|
maxParamLength: 1000,
|
|
ignoreTrailingSlash: true,
|
|
ignoreDuplicateSlashes: true,
|
|
},
|
|
}),
|
|
{
|
|
rawBody: true,
|
|
// captures NestJS internal errors
|
|
logger: new InternalLogFilter(),
|
|
// bufferLogs must be false else pino will fail
|
|
// to log OnApplicationBootstrap logs
|
|
bufferLogs: false,
|
|
},
|
|
);
|
|
|
|
app.useLogger(app.get(PinoLogger));
|
|
|
|
app.setGlobalPrefix('api', {
|
|
exclude: [
|
|
'robots.txt',
|
|
'share/:shareId/p/:pageSlug',
|
|
// Vanity link resolver lives outside /api so /l/<alias> is a clean
|
|
// public URL that 302s to the canonical share page.
|
|
'l/:alias',
|
|
'mcp',
|
|
],
|
|
});
|
|
|
|
const reflector = app.get(Reflector);
|
|
const redisIoAdapter = new WsRedisIoAdapter(app);
|
|
await redisIoAdapter.connectToRedis();
|
|
|
|
app.useWebSocketAdapter(redisIoAdapter);
|
|
|
|
await app.register(fastifyIp);
|
|
await app.register(fastifyMultipart);
|
|
await app.register(fastifyCookie);
|
|
|
|
const environmentService = app.get(EnvironmentService);
|
|
const frameHeader = resolveFrameHeader(
|
|
environmentService.isIframeEmbedAllowed(),
|
|
environmentService.getIframeAllowedOrigins(),
|
|
);
|
|
if (frameHeader) {
|
|
// Skipped routes:
|
|
// /api/files/ - attachment controller sets its own CSP we'd overwrite
|
|
// /share/ 0 public share pages are safe to embed
|
|
const frameHeaderSkippedPrefixes = ['/api/files/', '/share/'];
|
|
app
|
|
.getHttpAdapter()
|
|
.getInstance()
|
|
.addHook('onSend', (req, reply, payload, done) => {
|
|
if (frameHeaderSkippedPrefixes.some((p) => req.url.startsWith(p))) {
|
|
return done(null, payload);
|
|
}
|
|
reply.header(frameHeader.name, frameHeader.value);
|
|
done(null, payload);
|
|
});
|
|
}
|
|
|
|
app
|
|
.getHttpAdapter()
|
|
.getInstance()
|
|
.addHook('onRequest', (request, _reply, done) => {
|
|
(request.raw as any).ip = request.ip;
|
|
done();
|
|
});
|
|
|
|
app
|
|
.getHttpAdapter()
|
|
.getInstance()
|
|
.addContentTypeParser(
|
|
'application/scim+json',
|
|
{ parseAs: 'string' },
|
|
(_, body, done) => {
|
|
try {
|
|
const json = JSON.parse(body.toString());
|
|
done(null, json);
|
|
} catch (err: any) {
|
|
done(err);
|
|
}
|
|
},
|
|
);
|
|
|
|
app
|
|
.getHttpAdapter()
|
|
.getInstance()
|
|
.decorateReply('setHeader', function (name: string, value: unknown) {
|
|
this.header(name, value);
|
|
})
|
|
.decorateReply('end', function () {
|
|
this.send('');
|
|
})
|
|
.addHook('preHandler', function (req, reply, done) {
|
|
// don't require workspaceId for the following paths
|
|
const excludedPaths = [
|
|
'/api/auth/setup',
|
|
'/api/health',
|
|
'/api/billing/stripe/webhook',
|
|
'/api/workspace/check-hostname',
|
|
'/api/sso/google',
|
|
'/api/workspace/create',
|
|
'/api/workspace/joined',
|
|
'/api/workspace/find-by-email',
|
|
];
|
|
|
|
if (
|
|
req.originalUrl.startsWith('/api') &&
|
|
!excludedPaths.some((path) => req.originalUrl.startsWith(path))
|
|
) {
|
|
if (!req.raw?.['workspaceId'] && req.originalUrl !== '/api') {
|
|
throw new NotFoundException('Workspace not found');
|
|
}
|
|
done();
|
|
} else {
|
|
done();
|
|
}
|
|
});
|
|
|
|
app.useGlobalPipes(
|
|
new ValidationPipe({
|
|
whitelist: true,
|
|
stopAtFirstError: true,
|
|
transform: true,
|
|
}),
|
|
);
|
|
|
|
// Configure CORS explicitly (replaces the previous unconfigured enableCors()).
|
|
// The web client is same-origin in production; an explicit allowlist lets
|
|
// native/mobile WebView origins (Capacitor) and any configured cross-origin
|
|
// clients call the API, while everything else is rejected.
|
|
const corsAllowedOrigins = buildCorsAllowlist({
|
|
appUrl: environmentService.getAppUrl(),
|
|
configuredOrigins: environmentService.getCorsAllowedOrigins(),
|
|
});
|
|
|
|
app.enableCors({
|
|
// Allow requests with no Origin header (curl, server-to-server, some native
|
|
// WebView requests) and any origin in the allowlist; reject the rest.
|
|
origin: (
|
|
origin: string | undefined,
|
|
callback: (err: Error | null, allow?: boolean) => void,
|
|
) => {
|
|
callback(null, isOriginAllowed(origin, corsAllowedOrigins));
|
|
},
|
|
credentials: true,
|
|
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'],
|
|
allowedHeaders: ['Content-Type', 'Authorization'],
|
|
});
|
|
|
|
app.useGlobalInterceptors(new TransformHttpResponseInterceptor(reflector));
|
|
|
|
if (environmentService.isSwaggerEnabled()) {
|
|
// Optional OpenAPI docs to speed up typed mobile-client generation.
|
|
const swaggerConfig = new DocumentBuilder()
|
|
.setTitle('Gitmost API')
|
|
.setDescription('Gitmost REST API (RPC-style POST endpoints).')
|
|
.setVersion(process.env.APP_VERSION || '0.0.0')
|
|
.addBearerAuth()
|
|
.build();
|
|
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
|
SwaggerModule.setup('api/docs', app, document);
|
|
}
|
|
|
|
app.enableShutdownHooks();
|
|
|
|
const logger = new Logger('NestApplication');
|
|
|
|
process.on('unhandledRejection', (reason, promise) => {
|
|
logger.error(`UnhandledRejection, reason: ${reason}`, promise);
|
|
});
|
|
|
|
process.on('uncaughtException', (error) => {
|
|
logger.error('UncaughtException:', error);
|
|
});
|
|
|
|
const port = process.env.PORT || 3000;
|
|
const host = process.env.HOST || '0.0.0.0';
|
|
await app.listen(port, host, () => {
|
|
logger.log(
|
|
`Listening on http://127.0.0.1:${port} / ${process.env.APP_URL}`,
|
|
);
|
|
});
|
|
}
|
|
|
|
bootstrap();
|