mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
refactor(api): collapse single-impl interfaces and facades (#3382)
This commit is contained in:
1 parent
e7b13fd19f
commit
c16d414d93
426 files changed
+2085
-6445
No files matched your search
@@ -1,23 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {types} from 'cassandra-driver';
|
||||
|
||||
type CassandraValue =
|
||||
| string
|
||||
| number
|
||||
| bigint
|
||||
| boolean
|
||||
| Buffer
|
||||
| Date
|
||||
| types.LocalDate
|
||||
| Set<unknown>
|
||||
| Map<unknown, unknown>
|
||||
| Array<unknown>
|
||||
| Record<string, unknown>
|
||||
| null;
|
||||
export type CassandraParams = Record<string, CassandraValue>;
|
||||
|
||||
export interface PreparedQuery<P extends CassandraParams = CassandraParams> {
|
||||
cql: string;
|
||||
params: P;
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {CassandraParams, PreparedQuery} from '@pkgs/cassandra/src/CassandraTypes';
|
||||
import {type Logger, NoopLogger} from '@pkgs/cassandra/src/Logger';
|
||||
import cassandra from 'cassandra-driver';
|
||||
|
||||
const distance = cassandra.types.distance;
|
||||
@@ -22,45 +20,19 @@ interface CassandraConfig {
|
||||
readTimeoutMs?: number | undefined;
|
||||
}
|
||||
|
||||
interface CassandraClientOptions {
|
||||
logger?: Logger | undefined;
|
||||
}
|
||||
|
||||
interface CassandraExecuteOptions {
|
||||
prepare?: boolean | undefined;
|
||||
}
|
||||
|
||||
interface CassandraBatchOptions {
|
||||
prepare?: boolean | undefined;
|
||||
}
|
||||
|
||||
interface ICassandraClient {
|
||||
connect(): Promise<void>;
|
||||
shutdown(): Promise<void>;
|
||||
execute<P extends CassandraParams>(
|
||||
query: PreparedQuery<P>,
|
||||
options?: CassandraExecuteOptions,
|
||||
): Promise<cassandra.types.ResultSet>;
|
||||
batch(queries: Array<PreparedQuery>, options?: CassandraBatchOptions): Promise<void>;
|
||||
getNativeClient(): cassandra.Client;
|
||||
}
|
||||
|
||||
interface DefaultClientState {
|
||||
client: CassandraClient | null;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
const defaultClientState: DefaultClientState = {
|
||||
client: null,
|
||||
logger: NoopLogger,
|
||||
};
|
||||
|
||||
class CassandraClient implements ICassandraClient {
|
||||
class CassandraClient {
|
||||
private readonly config: CassandraConfig;
|
||||
private logger: Logger;
|
||||
private client: cassandra.Client | null;
|
||||
|
||||
public constructor(config: CassandraConfig, options: CassandraClientOptions = {}) {
|
||||
public constructor(config: CassandraConfig) {
|
||||
this.config = {
|
||||
hosts: [...config.hosts],
|
||||
port: config.port,
|
||||
@@ -70,7 +42,6 @@ class CassandraClient implements ICassandraClient {
|
||||
password: config.password,
|
||||
readTimeoutMs: config.readTimeoutMs,
|
||||
};
|
||||
this.logger = options.logger ?? NoopLogger;
|
||||
this.client = null;
|
||||
}
|
||||
|
||||
@@ -109,14 +80,6 @@ class CassandraClient implements ICassandraClient {
|
||||
});
|
||||
await client.connect();
|
||||
this.client = client;
|
||||
this.logger.info(
|
||||
{
|
||||
hosts: this.config.hosts,
|
||||
keyspace: this.config.keyspace,
|
||||
local_dc: this.config.localDc,
|
||||
},
|
||||
'Connected to Cassandra',
|
||||
);
|
||||
}
|
||||
|
||||
public async shutdown(): Promise<void> {
|
||||
@@ -126,26 +89,6 @@ class CassandraClient implements ICassandraClient {
|
||||
const activeClient = this.client;
|
||||
this.client = null;
|
||||
await activeClient.shutdown();
|
||||
this.logger.info({}, 'Cassandra connection closed');
|
||||
}
|
||||
|
||||
public async execute<P extends CassandraParams>(
|
||||
query: PreparedQuery<P>,
|
||||
options: CassandraExecuteOptions = {},
|
||||
): Promise<cassandra.types.ResultSet> {
|
||||
return this.getNativeClient().execute(query.cql, query.params, {
|
||||
prepare: options.prepare ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
public async batch(queries: Array<PreparedQuery>, options: CassandraBatchOptions = {}): Promise<void> {
|
||||
if (queries.length === 0) {
|
||||
return;
|
||||
}
|
||||
const batch = queries.map((query) => ({query: query.cql, params: query.params}));
|
||||
await this.getNativeClient().batch(batch, {
|
||||
prepare: options.prepare ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
public getNativeClient(): cassandra.Client {
|
||||
@@ -160,7 +103,7 @@ export async function initCassandra(config: CassandraConfig): Promise<void> {
|
||||
if (defaultClientState.client !== null) {
|
||||
await defaultClientState.client.shutdown();
|
||||
}
|
||||
const client = new CassandraClient(config, {logger: defaultClientState.logger});
|
||||
const client = new CassandraClient(config);
|
||||
await client.connect();
|
||||
defaultClientState.client = client;
|
||||
}
|
||||
@@ -173,7 +116,7 @@ export async function shutdownCassandra(): Promise<void> {
|
||||
defaultClientState.client = null;
|
||||
}
|
||||
|
||||
function getDefaultCassandraClient(): ICassandraClient {
|
||||
function getDefaultCassandraClient(): CassandraClient {
|
||||
if (defaultClientState.client === null) {
|
||||
throw new Error('Cassandra client is not initialized. Call initCassandra() first.');
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export interface Logger {
|
||||
info(context: Record<string, unknown>, message: string): void;
|
||||
debug(context: Record<string, unknown>, message: string): void;
|
||||
error(context: Record<string, unknown>, message: string): void;
|
||||
}
|
||||
|
||||
export const NoopLogger: Logger = {
|
||||
info(): void {},
|
||||
debug(): void {},
|
||||
error(): void {},
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {I18nResult} from '@fluxer/i18n/src/runtime/I18nTypes';
|
||||
import {getEmailTemplate} from '@pkgs/email/src/email_i18n/EmailI18n';
|
||||
import type {EmailTemplateVariables} from '@pkgs/email/src/email_i18n/EmailI18nTypes';
|
||||
import type {EmailTemplate, EmailTemplateKey} from '@pkgs/email/src/email_i18n/EmailI18nTypes.generated';
|
||||
|
||||
export interface IEmailI18nService {
|
||||
getTemplate<T extends EmailTemplateKey>(
|
||||
templateKey: T,
|
||||
locale: string | null,
|
||||
variables: EmailTemplateVariables[T],
|
||||
productName: string,
|
||||
): I18nResult<EmailTemplateKey, EmailTemplate>;
|
||||
}
|
||||
|
||||
export class EmailI18nService implements IEmailI18nService {
|
||||
getTemplate<T extends EmailTemplateKey>(
|
||||
templateKey: T,
|
||||
locale: string | null,
|
||||
variables: EmailTemplateVariables[T],
|
||||
productName: string,
|
||||
): I18nResult<EmailTemplateKey, EmailTemplate> {
|
||||
return getEmailTemplate(templateKey, locale, variables, productName);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createLogger} from '@fluxer/logger/src/Logger';
|
||||
import type {IEmailI18nService} from '@pkgs/email/src/EmailI18nService';
|
||||
import type {EmailConfig, IEmailProvider, UserBouncedEmailChecker} from '@pkgs/email/src/EmailProviderTypes';
|
||||
import {getEmailTemplate} from '@pkgs/email/src/email_i18n/EmailI18n';
|
||||
import type {
|
||||
EmailLegalLinks,
|
||||
EmailTemplateVariables,
|
||||
@@ -37,18 +37,15 @@ function legalLinkVariables(config: EmailConfig): {
|
||||
|
||||
export class EmailService implements IEmailService {
|
||||
private readonly config: EmailConfig;
|
||||
private readonly emailI18n: IEmailI18nService;
|
||||
private readonly provider: IEmailProvider | null;
|
||||
private readonly bouncedEmailChecker: UserBouncedEmailChecker | null;
|
||||
|
||||
constructor(
|
||||
config: EmailConfig,
|
||||
emailI18n: IEmailI18nService,
|
||||
provider: IEmailProvider | null = null,
|
||||
bouncedEmailChecker: UserBouncedEmailChecker | null = null,
|
||||
) {
|
||||
this.config = config;
|
||||
this.emailI18n = emailI18n;
|
||||
this.provider = provider;
|
||||
this.bouncedEmailChecker = bouncedEmailChecker;
|
||||
}
|
||||
@@ -394,7 +391,7 @@ export class EmailService implements IEmailService {
|
||||
locale: string | null,
|
||||
variables: EmailTemplateVariables[T],
|
||||
): Promise<boolean> {
|
||||
const result = this.emailI18n.getTemplate(templateKey, locale, variables, this.config.productName);
|
||||
const result = getEmailTemplate(templateKey, locale, variables, this.config.productName);
|
||||
if (!result.ok) {
|
||||
logger.error({key: templateKey, locale: result.locale, error: result.error}, 'Failed to resolve email template');
|
||||
return false;
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {IEmailService} from '@pkgs/email/src/IEmailService';
|
||||
|
||||
export interface SentEmailRecord {
|
||||
to: string;
|
||||
type: string;
|
||||
timestamp: Date;
|
||||
metadata: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ITestEmailService extends IEmailService {
|
||||
listSentEmails(): Array<SentEmailRecord>;
|
||||
clearSentEmails(): void;
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ReportReceivedTargetKind} from '@pkgs/email/src/email_i18n/EmailI18nTypes';
|
||||
import type {ITestEmailService, SentEmailRecord} from '@pkgs/email/src/ITestEmailService';
|
||||
import type {IEmailService} from '@pkgs/email/src/IEmailService';
|
||||
|
||||
export interface SentEmailRecord {
|
||||
to: string;
|
||||
type: string;
|
||||
timestamp: Date;
|
||||
metadata: Record<string, string>;
|
||||
}
|
||||
|
||||
function maskToken(token: string): string {
|
||||
if (token.length <= 8) {
|
||||
@@ -12,7 +19,7 @@ function maskToken(token: string): string {
|
||||
|
||||
const noopLogger = {info: (_message: string) => {}};
|
||||
|
||||
export class TestEmailService implements ITestEmailService {
|
||||
export class TestEmailService implements IEmailService {
|
||||
private readonly sentEmails: Array<SentEmailRecord> = [];
|
||||
private readonly logger: {
|
||||
info: (message: string) => void;
|
||||
|
||||
@@ -63,26 +63,6 @@ describe('KVClient cluster hash slots', () => {
|
||||
store.clear();
|
||||
});
|
||||
|
||||
it('reads several keys without a command spanning hash slots', async () => {
|
||||
expect(computeHashSlot('slot:alpha')).not.toBe(computeHashSlot('slot:beta'));
|
||||
const client = createClusteredClient();
|
||||
await client.set('slot:alpha', 'one');
|
||||
|
||||
await expect(client.mget('slot:alpha', 'slot:beta')).resolves.toEqual(['one', null]);
|
||||
expect(crossSlotCommands()).toEqual([]);
|
||||
});
|
||||
|
||||
it('writes several keys without a command spanning hash slots', async () => {
|
||||
expect(computeHashSlot('slot:alpha')).not.toBe(computeHashSlot('slot:beta'));
|
||||
const client = createClusteredClient();
|
||||
|
||||
await client.mset('slot:alpha', 'one', 'slot:beta', 'two');
|
||||
|
||||
expect(store.get('slot:alpha')).toBe('one');
|
||||
expect(store.get('slot:beta')).toBe('two');
|
||||
expect(crossSlotCommands()).toEqual([]);
|
||||
});
|
||||
|
||||
it('deletes several keys without a command spanning hash slots', async () => {
|
||||
expect(computeHashSlot('slot:alpha')).not.toBe(computeHashSlot('slot:beta'));
|
||||
const client = createClusteredClient();
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {LocaleCode} from '@fluxer/constants/src/Locales';
|
||||
import {resolveLocaleFromAcceptLanguageHeader} from '@pkgs/locale/src/resolution/AcceptLanguageNegotiation';
|
||||
|
||||
export function parseAcceptLanguage(acceptLanguageHeader: string | null | undefined): LocaleCode {
|
||||
return resolveLocaleFromAcceptLanguageHeader(acceptLanguageHeader);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ interface AcceptLanguagePreference {
|
||||
const TRADITIONAL_CHINESE_SUBTAGS: ReadonlySet<string> = new Set(['hant', 'tw', 'hk', 'mo']);
|
||||
const SIMPLIFIED_CHINESE_SUBTAGS: ReadonlySet<string> = new Set(['hans', 'cn', 'sg']);
|
||||
|
||||
export function resolveLocaleFromAcceptLanguageHeader(acceptLanguageHeader: string | null | undefined): LocaleCode {
|
||||
export function parseAcceptLanguage(acceptLanguageHeader: string | null | undefined): LocaleCode {
|
||||
if (!acceptLanguageHeader) {
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {type LocaleCode, Locales} from '@fluxer/constants/src/Locales';
|
||||
import {parseAcceptLanguage} from '@pkgs/locale/src/LocaleService';
|
||||
import {parseAcceptLanguage} from '@pkgs/locale/src/resolution/AcceptLanguageNegotiation';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
interface AcceptLanguageCase {
|
||||
|
||||
@@ -12,18 +12,10 @@
|
||||
"import": "./src/MediaProxyUtils.ts",
|
||||
"types": "./src/MediaProxyUtils.ts"
|
||||
},
|
||||
"./src/ExternalMediaProxyUrlBuilder": {
|
||||
"import": "./src/ExternalMediaProxyUrlBuilder.ts",
|
||||
"types": "./src/ExternalMediaProxyUrlBuilder.ts"
|
||||
},
|
||||
"./src/ExternalMediaProxyPathCodec": {
|
||||
"import": "./src/ExternalMediaProxyPathCodec.ts",
|
||||
"types": "./src/ExternalMediaProxyPathCodec.ts"
|
||||
},
|
||||
"./src/MediaProxySigner": {
|
||||
"import": "./src/MediaProxySigner.ts",
|
||||
"types": "./src/MediaProxySigner.ts"
|
||||
},
|
||||
"./src/AttachmentUrlSignature": {
|
||||
"import": "./src/AttachmentUrlSignature.ts",
|
||||
"types": "./src/AttachmentUrlSignature.ts"
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {buildExternalMediaProxyPath} from '@pkgs/media_proxy_utils/src/ExternalMediaProxyPathCodec';
|
||||
import {createMediaProxySigner, type IMediaProxySigner} from '@pkgs/media_proxy_utils/src/MediaProxySigner';
|
||||
|
||||
export interface ExternalMediaProxyUrlBuilderOptions {
|
||||
mediaProxyEndpoint: string;
|
||||
mediaProxySecretKey: string;
|
||||
}
|
||||
|
||||
export interface IExternalMediaProxyUrlBuilder {
|
||||
buildExternalMediaProxyUrl(inputUrl: string): string;
|
||||
}
|
||||
|
||||
interface InternalExternalMediaProxyUrlBuilderOptions {
|
||||
mediaProxyEndpoint: string;
|
||||
mediaProxySigner: IMediaProxySigner;
|
||||
}
|
||||
|
||||
function normalizeMediaProxyEndpoint(mediaProxyEndpoint: string): string {
|
||||
return mediaProxyEndpoint.replace(/\/+$/u, '');
|
||||
}
|
||||
|
||||
class ExternalMediaProxyUrlBuilder implements IExternalMediaProxyUrlBuilder {
|
||||
private readonly mediaProxyEndpoint: string;
|
||||
private readonly mediaProxySigner: IMediaProxySigner;
|
||||
|
||||
constructor(options: InternalExternalMediaProxyUrlBuilderOptions) {
|
||||
this.mediaProxyEndpoint = options.mediaProxyEndpoint;
|
||||
this.mediaProxySigner = options.mediaProxySigner;
|
||||
}
|
||||
|
||||
buildExternalMediaProxyUrl(inputUrl: string): string {
|
||||
const proxyUrlPath = buildExternalMediaProxyPath(inputUrl);
|
||||
const signature = this.mediaProxySigner.createSignature(proxyUrlPath);
|
||||
return `${this.mediaProxyEndpoint}/external/${signature}/${proxyUrlPath}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function createExternalMediaProxyUrlBuilder(
|
||||
options: ExternalMediaProxyUrlBuilderOptions,
|
||||
): IExternalMediaProxyUrlBuilder {
|
||||
const mediaProxySigner = createMediaProxySigner({
|
||||
mediaProxySecretKey: options.mediaProxySecretKey,
|
||||
});
|
||||
return new ExternalMediaProxyUrlBuilder({
|
||||
mediaProxyEndpoint: normalizeMediaProxyEndpoint(options.mediaProxyEndpoint),
|
||||
mediaProxySigner,
|
||||
});
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const BASE64_URL_PADDING_REGEX = /=*$/;
|
||||
|
||||
export interface MediaProxySignerOptions {
|
||||
mediaProxySecretKey: string;
|
||||
}
|
||||
|
||||
export interface IMediaProxySigner {
|
||||
createSignature(proxyUrlPath: string): string;
|
||||
verifySignature(proxyUrlPath: string, providedSignature: string): boolean;
|
||||
}
|
||||
|
||||
class MediaProxySigner implements IMediaProxySigner {
|
||||
private readonly mediaProxySecretKey: string;
|
||||
|
||||
constructor(options: MediaProxySignerOptions) {
|
||||
this.mediaProxySecretKey = options.mediaProxySecretKey;
|
||||
}
|
||||
|
||||
createSignature(proxyUrlPath: string): string {
|
||||
const hmac = crypto.createHmac('sha256', this.mediaProxySecretKey);
|
||||
hmac.update(proxyUrlPath);
|
||||
return hmac.digest('base64url').replace(BASE64_URL_PADDING_REGEX, '');
|
||||
}
|
||||
|
||||
verifySignature(proxyUrlPath: string, providedSignature: string): boolean {
|
||||
const expectedSignature = this.createSignature(proxyUrlPath);
|
||||
const expectedBuffer = Buffer.from(expectedSignature, 'utf8');
|
||||
const providedBuffer = Buffer.from(providedSignature, 'utf8');
|
||||
if (expectedBuffer.length !== providedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
return crypto.timingSafeEqual(expectedBuffer, providedBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
export function createMediaProxySigner(options: MediaProxySignerOptions): IMediaProxySigner {
|
||||
return new MediaProxySigner(options);
|
||||
}
|
||||
@@ -1,25 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import {createExternalMediaProxyUrlBuilder} from '@pkgs/media_proxy_utils/src/ExternalMediaProxyUrlBuilder';
|
||||
|
||||
const BASE64_URL_REGEX = /=*$/;
|
||||
|
||||
export function createSignature(inputString: string, mediaProxySecretKey: string): string {
|
||||
const hmac = crypto.createHmac('sha256', mediaProxySecretKey);
|
||||
hmac.update(inputString);
|
||||
return hmac.digest('base64url').replace(BASE64_URL_REGEX, '');
|
||||
}
|
||||
|
||||
export function verifySignature(proxyUrlPath: string, providedSignature: string, mediaProxySecretKey: string): boolean {
|
||||
const expectedSignature = createSignature(proxyUrlPath, mediaProxySecretKey);
|
||||
const expectedBuffer = Buffer.from(expectedSignature);
|
||||
const providedBuffer = Buffer.from(providedSignature);
|
||||
if (expectedBuffer.length !== providedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
return crypto.timingSafeEqual(expectedBuffer, providedBuffer);
|
||||
}
|
||||
import {buildExternalMediaProxyPath} from '@pkgs/media_proxy_utils/src/ExternalMediaProxyPathCodec';
|
||||
|
||||
export interface ExternalMediaProxyURLOptions {
|
||||
inputURL: string;
|
||||
@@ -28,9 +10,12 @@ export interface ExternalMediaProxyURLOptions {
|
||||
}
|
||||
|
||||
export function getExternalMediaProxyURL(options: ExternalMediaProxyURLOptions): string {
|
||||
const builder = createExternalMediaProxyUrlBuilder({
|
||||
mediaProxyEndpoint: options.mediaProxyEndpoint,
|
||||
mediaProxySecretKey: options.mediaProxySecretKey,
|
||||
});
|
||||
return builder.buildExternalMediaProxyUrl(options.inputURL);
|
||||
const endpoint = options.mediaProxyEndpoint.replace(/\/+$/u, '');
|
||||
const proxyUrlPath = buildExternalMediaProxyPath(options.inputURL);
|
||||
const signature = crypto
|
||||
.createHmac('sha256', options.mediaProxySecretKey)
|
||||
.update(proxyUrlPath)
|
||||
.digest('base64url')
|
||||
.replace(/=*$/, '');
|
||||
return `${endpoint}/external/${signature}/${proxyUrlPath}`;
|
||||
}
|
||||
@@ -9,10 +9,6 @@
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluxer/logger": "workspace:*",
|
||||
"itty-time": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"typescript": "catalog:ts7"
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {VirusScanProviderResult} from '@pkgs/virus_scan/src/VirusScanProviderResult';
|
||||
|
||||
export interface IVirusScanProvider {
|
||||
scanFile(filePath: string): Promise<VirusScanProviderResult>;
|
||||
scanBuffer(buffer: Buffer): Promise<VirusScanProviderResult>;
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import {createReadStream} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
|
||||
import type {IVirusHashCache} from '@pkgs/virus_scan/src/cache/IVirusHashCache';
|
||||
import type {IVirusScanFailureReporter} from '@pkgs/virus_scan/src/failures/IVirusScanFailureReporter';
|
||||
import {NoopVirusScanFailureReporter} from '@pkgs/virus_scan/src/failures/NoopVirusScanFailureReporter';
|
||||
import type {IVirusScanProvider} from '@pkgs/virus_scan/src/IVirusScanProvider';
|
||||
import type {IVirusScanService} from '@pkgs/virus_scan/src/IVirusScanService';
|
||||
import type {VirusScanResult} from '@pkgs/virus_scan/src/VirusScanResult';
|
||||
|
||||
interface VirusScanConfig {
|
||||
failOpen: boolean;
|
||||
cachedThreatLabel?: string;
|
||||
}
|
||||
|
||||
interface VirusScanServiceDependencies {
|
||||
provider: IVirusScanProvider;
|
||||
virusHashCache: IVirusHashCache;
|
||||
logger: LoggerInterface;
|
||||
config: VirusScanConfig;
|
||||
failureReporter?: IVirusScanFailureReporter;
|
||||
}
|
||||
|
||||
export class VirusScanService implements IVirusScanService {
|
||||
readonly enabled = true;
|
||||
private readonly cachedThreatLabel: string;
|
||||
private readonly failureReporter: IVirusScanFailureReporter;
|
||||
|
||||
constructor(private dependencies: VirusScanServiceDependencies) {
|
||||
this.cachedThreatLabel = dependencies.config.cachedThreatLabel ?? 'Cached virus signature';
|
||||
this.failureReporter = dependencies.failureReporter ?? new NoopVirusScanFailureReporter();
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await this.failureReporter.initialize();
|
||||
}
|
||||
|
||||
async scanFile(filePath: string): Promise<VirusScanResult> {
|
||||
const filename = path.basename(filePath);
|
||||
const fileHash = await this.hashFile(filePath);
|
||||
const isCachedVirus = await this.dependencies.virusHashCache.isKnownVirusHash(fileHash);
|
||||
if (isCachedVirus) {
|
||||
return {
|
||||
isClean: false,
|
||||
threat: this.cachedThreatLabel,
|
||||
fileHash,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const scanResult = await this.dependencies.provider.scanFile(filePath);
|
||||
if (scanResult.isClean) {
|
||||
return {
|
||||
isClean: true,
|
||||
fileHash,
|
||||
};
|
||||
}
|
||||
if (!scanResult.threat) {
|
||||
throw new Error('Virus scan provider returned infected status without threat name');
|
||||
}
|
||||
await this.dependencies.virusHashCache.cacheVirusHash(fileHash);
|
||||
return {
|
||||
isClean: false,
|
||||
threat: scanResult.threat,
|
||||
fileHash,
|
||||
};
|
||||
} catch (error) {
|
||||
this.dependencies.logger.error(
|
||||
{
|
||||
error: this.describeError(error),
|
||||
filename,
|
||||
fileHash,
|
||||
},
|
||||
'Virus scan failed',
|
||||
);
|
||||
await this.reportScanFailure(error, filename, fileHash);
|
||||
if (this.dependencies.config.failOpen) {
|
||||
return {
|
||||
isClean: true,
|
||||
fileHash,
|
||||
};
|
||||
}
|
||||
throw new Error(`Virus scan failed: ${this.describeError(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async scanBuffer(buffer: Buffer, filename: string): Promise<VirusScanResult> {
|
||||
const fileHash = this.createFileHashFromBuffer(buffer);
|
||||
const isCachedVirus = await this.dependencies.virusHashCache.isKnownVirusHash(fileHash);
|
||||
if (isCachedVirus) {
|
||||
return {
|
||||
isClean: false,
|
||||
threat: this.cachedThreatLabel,
|
||||
fileHash,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const scanResult = await this.dependencies.provider.scanBuffer(buffer);
|
||||
if (scanResult.isClean) {
|
||||
return {
|
||||
isClean: true,
|
||||
fileHash,
|
||||
};
|
||||
}
|
||||
if (!scanResult.threat) {
|
||||
throw new Error('Virus scan provider returned infected status without threat name');
|
||||
}
|
||||
await this.dependencies.virusHashCache.cacheVirusHash(fileHash);
|
||||
return {
|
||||
isClean: false,
|
||||
threat: scanResult.threat,
|
||||
fileHash,
|
||||
};
|
||||
} catch (error) {
|
||||
this.dependencies.logger.error(
|
||||
{
|
||||
error: this.describeError(error),
|
||||
filename,
|
||||
fileHash,
|
||||
},
|
||||
'Virus scan failed',
|
||||
);
|
||||
await this.reportScanFailure(error, filename, fileHash);
|
||||
if (this.dependencies.config.failOpen) {
|
||||
return {
|
||||
isClean: true,
|
||||
fileHash,
|
||||
};
|
||||
}
|
||||
throw new Error(`Virus scan failed: ${this.describeError(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private createFileHashFromBuffer(buffer: Buffer): string {
|
||||
return crypto.createHash('sha256').update(buffer).digest('hex');
|
||||
}
|
||||
|
||||
private async hashFile(filePath: string): Promise<string> {
|
||||
const hash = crypto.createHash('sha256');
|
||||
const stream = createReadStream(filePath, {highWaterMark: 1024 * 1024});
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
hash.update(chunk as Buffer);
|
||||
}
|
||||
} catch (error) {
|
||||
stream.destroy();
|
||||
throw error;
|
||||
}
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
private describeError(error: unknown): string {
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return 'Unknown error';
|
||||
}
|
||||
|
||||
private async reportScanFailure(error: unknown, filename: string, fileHash: string): Promise<void> {
|
||||
try {
|
||||
await this.failureReporter.reportFailure({
|
||||
error,
|
||||
filename,
|
||||
fileHash,
|
||||
failOpen: this.dependencies.config.failOpen,
|
||||
});
|
||||
} catch (reportError) {
|
||||
this.dependencies.logger.warn(
|
||||
{
|
||||
error: this.describeError(reportError),
|
||||
filename,
|
||||
fileHash,
|
||||
},
|
||||
'Failed to report virus scan failure',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export interface IVirusHashCache {
|
||||
isKnownVirusHash(fileHash: string): Promise<boolean>;
|
||||
cacheVirusHash(fileHash: string): Promise<void>;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export interface IVirusScanCacheStore {
|
||||
get<T>(key: string): Promise<T | null>;
|
||||
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {IVirusHashCache} from '@pkgs/virus_scan/src/cache/IVirusHashCache';
|
||||
import type {IVirusScanCacheStore} from '@pkgs/virus_scan/src/cache/IVirusScanCacheStore';
|
||||
import {seconds} from 'itty-time';
|
||||
|
||||
interface VirusHashCacheConfig {
|
||||
keyPrefix?: string;
|
||||
ttlSeconds?: number;
|
||||
}
|
||||
|
||||
export class VirusHashCache implements IVirusHashCache {
|
||||
private readonly keyPrefix: string;
|
||||
private readonly ttlSeconds: number;
|
||||
|
||||
constructor(
|
||||
private cacheStore: IVirusScanCacheStore,
|
||||
config: VirusHashCacheConfig = {},
|
||||
) {
|
||||
this.keyPrefix = config.keyPrefix ?? 'virus';
|
||||
this.ttlSeconds = config.ttlSeconds ?? seconds('7 days');
|
||||
}
|
||||
|
||||
async isKnownVirusHash(fileHash: string): Promise<boolean> {
|
||||
const cachedValue = await this.cacheStore.get(this.buildCacheKey(fileHash));
|
||||
return cachedValue != null;
|
||||
}
|
||||
|
||||
async cacheVirusHash(fileHash: string): Promise<void> {
|
||||
await this.cacheStore.set(this.buildCacheKey(fileHash), 'true', this.ttlSeconds);
|
||||
}
|
||||
|
||||
private buildCacheKey(fileHash: string): string {
|
||||
return `${this.keyPrefix}:${fileHash}`;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {VirusScanFailureContext} from '@pkgs/virus_scan/src/failures/VirusScanFailureContext';
|
||||
|
||||
export interface IVirusScanFailureReporter {
|
||||
initialize(): Promise<void>;
|
||||
reportFailure(context: VirusScanFailureContext): Promise<void>;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {IVirusScanFailureReporter} from '@pkgs/virus_scan/src/failures/IVirusScanFailureReporter';
|
||||
import type {VirusScanFailureContext} from '@pkgs/virus_scan/src/failures/VirusScanFailureContext';
|
||||
|
||||
export class NoopVirusScanFailureReporter implements IVirusScanFailureReporter {
|
||||
async initialize(): Promise<void> {}
|
||||
|
||||
async reportFailure(_context: VirusScanFailureContext): Promise<void> {}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export interface VirusScanFailureContext {
|
||||
error: unknown;
|
||||
filename: string;
|
||||
fileHash: string;
|
||||
failOpen: boolean;
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
import {createReadStream} from 'node:fs';
|
||||
import {createConnection} from 'node:net';
|
||||
import {Readable} from 'node:stream';
|
||||
import type {IVirusScanProvider} from '@pkgs/virus_scan/src/IVirusScanProvider';
|
||||
import type {VirusScanProviderResult} from '@pkgs/virus_scan/src/VirusScanProviderResult';
|
||||
|
||||
interface ClamAVConfig {
|
||||
@@ -12,7 +11,7 @@ interface ClamAVConfig {
|
||||
streamChunkBytes?: number;
|
||||
}
|
||||
|
||||
export class ClamAVProvider implements IVirusScanProvider {
|
||||
export class ClamAVProvider {
|
||||
private readonly streamChunkBytes: number;
|
||||
|
||||
constructor(private config: ClamAVConfig) {
|
||||
|
||||
@@ -4,10 +4,6 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./WorkerContext": {
|
||||
"import": "./src/context/WorkerContext.ts",
|
||||
"types": "./src/context/WorkerContext.ts"
|
||||
},
|
||||
"./WorkerTask": {
|
||||
"import": "./src/contracts/WorkerTask.ts",
|
||||
"types": "./src/contracts/WorkerTask.ts"
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
let workerDependencies: unknown | null = null;
|
||||
|
||||
export function setWorkerDependencies<T>(dependencies: T): void {
|
||||
workerDependencies = dependencies;
|
||||
}
|
||||
|
||||
export function getWorkerDependencies<T>(): T {
|
||||
if (!workerDependencies) {
|
||||
throw new Error('Worker dependencies have not been initialized. Call setWorkerDependencies() first.');
|
||||
}
|
||||
return workerDependencies as T;
|
||||
}
|
||||
|
||||
export function clearWorkerDependencies(): void {
|
||||
workerDependencies = null;
|
||||
}
|
||||
Reference in new issue
Block a user