100 lines
3.3 KiB
TypeScript
100 lines
3.3 KiB
TypeScript
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||
import { ConfigService } from '@nestjs/config';
|
||
import { mkdir, writeFile } from 'node:fs/promises';
|
||
import { dirname } from 'node:path';
|
||
import { PrismaService } from '../infra/prisma.service';
|
||
import {
|
||
collectOAuthSslHostnames,
|
||
parseHostnameFromPublicUrl,
|
||
resolveRegistrableDomain
|
||
} from '../infra/oauth-ssl-sans.util';
|
||
|
||
const OAUTH_SSL_SANS_SETTING_KEY = 'OAUTH_SSL_SANS';
|
||
|
||
@Injectable()
|
||
export class OAuthSslSansService implements OnModuleInit {
|
||
private readonly logger = new Logger(OAuthSslSansService.name);
|
||
|
||
constructor(
|
||
private readonly prisma: PrismaService,
|
||
private readonly config: ConfigService
|
||
) {}
|
||
|
||
async onModuleInit() {
|
||
try {
|
||
await this.syncFromDatabase();
|
||
} catch (error) {
|
||
this.logger.warn(
|
||
`Не удалось синхронизировать SAN из OAuth redirect URI: ${error instanceof Error ? error.message : 'ошибка'}`
|
||
);
|
||
}
|
||
}
|
||
|
||
async syncFromDatabase(): Promise<string[]> {
|
||
const clients = await this.prisma.oAuthClient.findMany({
|
||
where: { isActive: true },
|
||
select: { redirectUris: true }
|
||
});
|
||
const redirectUris = clients.flatMap((client) => client.redirectUris);
|
||
const hostnames = collectOAuthSslHostnames(redirectUris, this.buildExcludedHostnames());
|
||
|
||
await this.persistHostnames(hostnames);
|
||
return hostnames;
|
||
}
|
||
|
||
private buildExcludedHostnames(): Set<string> {
|
||
const excluded = new Set<string>();
|
||
|
||
for (const envKey of ['DOMAIN_API', 'DOMAIN_FRONTEND', 'DOMAIN_DOCS', 'DOMAIN_WS', 'DOMAIN_MINIO', 'DOMAIN_MINIO_CONSOLE']) {
|
||
const value = this.config.get<string>(envKey)?.trim().toLowerCase();
|
||
if (value) excluded.add(value);
|
||
}
|
||
|
||
for (const url of [
|
||
this.config.get<string>('PUBLIC_API_URL'),
|
||
this.config.get<string>('PUBLIC_FRONTEND_URL'),
|
||
this.config.get<string>('PUBLIC_DOCS_URL'),
|
||
this.config.get<string>('PUBLIC_WS_URL')
|
||
]) {
|
||
const host = parseHostnameFromPublicUrl(url);
|
||
if (host) excluded.add(host);
|
||
}
|
||
|
||
const idpHosts = [...excluded];
|
||
for (const host of idpHosts) {
|
||
excluded.add(resolveRegistrableDomain(host));
|
||
const parts = host.split('.');
|
||
if (parts.length >= 3) {
|
||
excluded.add(parts.slice(-2).join('.'));
|
||
}
|
||
}
|
||
|
||
return excluded;
|
||
}
|
||
|
||
private async persistHostnames(hostnames: string[]) {
|
||
const serialized = hostnames.join(',');
|
||
await this.prisma.systemSetting.upsert({
|
||
where: { key: OAUTH_SSL_SANS_SETTING_KEY },
|
||
create: {
|
||
key: OAUTH_SSL_SANS_SETTING_KEY,
|
||
value: serialized,
|
||
description: 'DNS-имена из redirect_uri OAuth-приложений для SAN сертификата (авто)',
|
||
isSecret: false
|
||
},
|
||
update: {
|
||
value: serialized,
|
||
description: 'DNS-имена из redirect_uri OAuth-приложений для SAN сертификата (авто)'
|
||
}
|
||
});
|
||
|
||
const targetFile =
|
||
this.config.get<string>('OAUTH_SSL_SANS_FILE')?.trim() ||
|
||
'/data/nginx-certs/oauth-redirect-sans.txt';
|
||
const body = `${hostnames.join('\n')}\n`;
|
||
await mkdir(dirname(targetFile), { recursive: true });
|
||
await writeFile(targetFile, body, 'utf8');
|
||
this.logger.log(`OAuth SSL SAN обновлён (${hostnames.length}): ${hostnames.join(', ') || '—'}`);
|
||
}
|
||
}
|