refactor(api): collapse single-impl interfaces and facades (#3382)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-10 23:04:42 +02:00
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;
}
+4 -61
View File
@@ -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.');
}
-13
View File
@@ -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);
}
}
+2 -5
View File
@@ -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}`;
}
-4
View File
@@ -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>;
}
-36
View File
@@ -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
View File
@@ -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;
}
+2 -2
View File
@@ -6,7 +6,7 @@ import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {IMediaService} from '@app/api/infrastructure/IMediaService';
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
import type {BotMfaMirrorService} from '@app/api/oauth/BotMfaMirrorService';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
import type {UserActivityBuffer} from '@app/api/user/services/UserActivityBuffer';
import type {UserContactChangeLogService} from '@app/api/user/services/UserContactChangeLogService';
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
@@ -17,7 +17,7 @@ import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService';
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
export interface ApiServices {
users: IUserRepository;
users: UserRepository;
cache: ICacheService;
gateway: IGatewayService;
kv: IKVProvider;
+23 -3
View File
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {AdminAuditLog, BannedIpEntry, BannedIpKind, IAdminRepository} from '@app/api/admin/IAdminRepository';
import {createUserID} from '@app/api/BrandedTypes';
import {createUserID, type UserID} from '@app/api/BrandedTypes';
import {isIpBanExempt} from '@app/api/ban/IpBanExemptions';
import {ContentBlocklistCategory} from '@app/api/constants/ContentModeration';
import {
@@ -35,6 +34,27 @@ import {parseIpBanEntry, tryParseSingleIp} from '@app/api/utils/IpRangeUtils';
import {canonicalizeStoredPhrase} from '@app/api/utils/PhraseBlocklistNormalization';
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
export interface AdminAuditLog {
logId: bigint;
adminUserId: UserID;
targetType: string;
targetId: bigint;
action: string;
auditLogReason: string | null;
metadata: Map<string, string>;
createdAt: Date;
}
export type BannedIpKind = 'permanent' | 'temporary_24h';
export interface BannedIpEntry {
ip: string;
kind: BannedIpKind;
reason: string | null;
expiresAt: Date | null;
createdAt: Date | null;
}
const FETCH_AUDIT_LOG_BY_ID_QUERY = AdminAuditLogs.select({
where: AdminAuditLogs.where.eq('log_id'),
});
@@ -84,7 +104,7 @@ function canonicalizeBannedIpEntry(value: string): string {
return parseIpBanEntry(value)?.canonical ?? value;
}
export class AdminRepository implements IAdminRepository {
export class AdminRepository {
async createAuditLog(log: AdminAuditLogRow): Promise<AdminAuditLog> {
await upsertOne(AdminAuditLogs.insert(log));
return this.mapRowToAuditLog(log);
+11 -11
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ApiContext} from '@app/api/ApiContext';
import type {IAdminRepository} from '@app/api/admin/IAdminRepository';
import type {AdminRepository} from '@app/api/admin/AdminRepository';
import {AdminApplicationService} from '@app/api/admin/services/AdminApplicationService';
import {AdminAssetPurgeService} from '@app/api/admin/services/AdminAssetPurgeService';
import {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
@@ -18,9 +18,9 @@ import {AdminUserService} from '@app/api/admin/services/AdminUserService';
import {AdminVoiceService} from '@app/api/admin/services/AdminVoiceService';
import {ReporterResolutionNotifier} from '@app/api/admin/services/ReporterResolutionNotifier';
import type {UserID} from '@app/api/BrandedTypes';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
import type {ChannelService} from '@app/api/channel/services/ChannelService';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import type {GuildService} from '@app/api/guild/services/GuildService';
import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
import type {EntityAssetService} from '@app/api/infrastructure/EntityAssetService';
@@ -29,10 +29,10 @@ import type {IStorageService} from '@app/api/infrastructure/IStorageService';
import type {KVBulkMessageDeletionQueueService} from '@app/api/infrastructure/KVBulkMessageDeletionQueueService';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import type {InviteRepository} from '@app/api/invite/InviteRepository';
import type {IJobLedgerRepository} from '@app/api/jobs/IJobLedgerRepository';
import {JobAdminService} from '@app/api/jobs/JobAdminService';
import type {JobLedgerRepository} from '@app/api/jobs/JobLedgerRepository';
import {getGuildDiscoveryRepository, getKVAccountDeletionQueue} from '@app/api/middleware/ServiceSingletons';
import type {IApplicationRepository} from '@app/api/oauth/repositories/IApplicationRepository';
import type {ApplicationRepository} from '@app/api/oauth/repositories/ApplicationRepository';
import type {ReportService} from '@app/api/report/ReportService';
import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService';
import type {UserService} from '@app/api/user/services/UserService';
@@ -59,9 +59,9 @@ export class AdminService {
constructor(
private readonly apiContext: ApiContext,
private readonly guildRepository: IGuildRepositoryAggregate,
private readonly channelRepository: IChannelRepository,
private readonly adminRepository: IAdminRepository,
private readonly guildRepository: GuildRepository,
private readonly channelRepository: ChannelRepository,
private readonly adminRepository: AdminRepository,
private readonly inviteRepository: InviteRepository,
private readonly discriminatorService: IDiscriminatorService,
private readonly guildService: GuildService,
@@ -74,16 +74,16 @@ export class AdminService {
private readonly reportService: ReportService,
private readonly voiceRepository: VoiceRepository,
private readonly bulkMessageDeletionQueue: KVBulkMessageDeletionQueueService,
private readonly applicationRepository: IApplicationRepository,
private readonly applicationRepository: ApplicationRepository,
private readonly stripe: Stripe | null = null,
private readonly jobLedger: IJobLedgerRepository,
private readonly jobLedger: JobLedgerRepository,
private readonly storeEntitlementService: StoreEntitlementService,
) {
const {users, gateway, worker, snowflake} = this.apiContext.services;
this.auditService = new AdminAuditService(this.adminRepository, snowflake, {
userRepository: users,
guildRepository: this.guildRepository,
channelRepository: this.channelRepository,
channelRepository: this.channelRepository.channelData,
});
this.banManagementService = new AdminBanManagementService({
apiContext: this.apiContext,
@@ -1,101 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {UserID} from '@app/api/BrandedTypes';
import type {
AdminAuditLogRow,
BannedAvatarHashRow,
BannedFileShaRow,
BannedProfileSubstringRow,
BannedProfileSubstringScope,
BannedUrlDomainRow,
BannedUrlRow,
} from '@app/api/database/types/AdminArchiveTypes';
export interface AdminAuditLog {
logId: bigint;
adminUserId: UserID;
targetType: string;
targetId: bigint;
action: string;
auditLogReason: string | null;
metadata: Map<string, string>;
createdAt: Date;
}
export type BannedIpKind = 'permanent' | 'temporary_24h';
export interface BannedIpEntry {
ip: string;
kind: BannedIpKind;
reason: string | null;
expiresAt: Date | null;
createdAt: Date | null;
}
export abstract class IAdminRepository {
abstract createAuditLog(log: AdminAuditLogRow): Promise<AdminAuditLog>;
abstract getAuditLog(logId: bigint): Promise<AdminAuditLog | null>;
abstract listAuditLogsByIds(logIds: Array<bigint>): Promise<Array<AdminAuditLog>>;
abstract listAllAuditLogsPaginated(limit: number, lastLogId?: bigint): Promise<Array<AdminAuditLog>>;
abstract isIpBanned(ip: string): Promise<boolean>;
abstract banIp(ip: string, ttlSeconds?: number | null): Promise<void>;
abstract banIpTemp(ip: string, ttlSeconds: number): Promise<void>;
abstract unbanIp(ip: string): Promise<void>;
abstract loadAllBannedIpEntries(): Promise<Array<BannedIpEntry>>;
abstract isEmailBanned(email: string): Promise<boolean>;
abstract banEmail(email: string): Promise<void>;
abstract unbanEmail(email: string): Promise<void>;
abstract loadAllBannedEmails(): Promise<Array<string>>;
abstract banPhrase(phrase: string): Promise<void>;
abstract unbanPhrase(phrase: string): Promise<void>;
abstract loadAllBannedPhrases(): Promise<Array<string>>;
abstract loadAllBannedIps(): Promise<Set<string>>;
abstract banUrl(row: BannedUrlRow): Promise<void>;
abstract unbanUrl(url: string): Promise<void>;
abstract loadAllBannedUrls(): Promise<Array<BannedUrlRow>>;
abstract banUrlDomain(row: BannedUrlDomainRow): Promise<void>;
abstract unbanUrlDomain(domain: string): Promise<void>;
abstract loadAllBannedUrlDomains(): Promise<Array<BannedUrlDomainRow>>;
abstract banFileSha(row: BannedFileShaRow): Promise<void>;
abstract unbanFileSha(sha256Hex: string): Promise<void>;
abstract unbanFeedFileSha(sha256Hex: string): Promise<boolean>;
abstract loadAllBannedFileShas(): Promise<Array<BannedFileShaRow>>;
abstract banAvatarHash(row: BannedAvatarHashRow): Promise<void>;
abstract unbanAvatarHash(hashShort: string): Promise<void>;
abstract loadAllBannedAvatarHashes(): Promise<Array<BannedAvatarHashRow>>;
abstract banProfileSubstring(row: BannedProfileSubstringRow): Promise<void>;
abstract unbanProfileSubstring(scope: BannedProfileSubstringScope, substring: string): Promise<void>;
abstract loadAllBannedProfileSubstrings(): Promise<Array<BannedProfileSubstringRow>>;
}
@@ -13,7 +13,7 @@ import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import type {User} from '@app/api/models/User';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp} from '@app/api/types/HonoEnv';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
import {Validator} from '@app/api/Validator';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {DiscoveryApplicationStatus, DiscoveryCategoryLabels} from '@fluxer/constants/src/DiscoveryConstants';
@@ -65,13 +65,13 @@ interface GuildEnrichment {
async function enrichGuilds(
rows: ReadonlyArray<GuildDiscoveryRow>,
guildService: GuildService,
userRepository: IUserRepository,
userRepository: UserRepository,
): Promise<Map<string, GuildEnrichment>> {
const map = new Map<string, GuildEnrichment>();
const guilds = await Promise.all(
rows.map(async (row) => {
try {
return await guildService.data.getGuildSystem(row.guild_id);
return await guildService.data.operations.getGuildSystem(row.guild_id);
} catch {
return null;
}
@@ -62,7 +62,7 @@ export function ThreadAdminController(app: HonoApp) {
}),
async (ctx) => {
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const thread = await ctx.get('channelRepository').findUnique(channelId);
const thread = await ctx.get('channelRepository').channelData.findUnique(channelId);
if (!thread) throw new UnknownChannelError();
if (!thread.isThread()) throw new InvalidChannelTypeError();
const adminUserId = ctx.get('adminUserId');
@@ -1,10 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {
CreateAdminApiKeyData,
IAdminApiKeyRepository,
UpdateAdminApiKeyData,
} from '@app/api/admin/repositories/IAdminApiKeyRepository';
import type {UserID} from '@app/api/BrandedTypes';
import {
BatchBuilder,
@@ -19,12 +14,23 @@ import {AdminApiKey} from '@app/api/models/AdminApiKey';
import {AdminApiKeys, AdminApiKeysByCreator} from '@app/api/Tables';
import {hashPassword} from '@app/api/utils/PasswordUtils';
export interface CreateAdminApiKeyData {
name: string;
expiresAt: Date | null;
acls: Set<string>;
}
export interface UpdateAdminApiKeyData {
name?: string;
acls?: Set<string>;
}
function computeTtlSeconds(expiresAt: Date): number {
const diffSeconds = Math.floor((expiresAt.getTime() - Date.now()) / 1000);
return Math.max(diffSeconds, 1);
}
export class AdminApiKeyRepository implements IAdminApiKeyRepository {
export class AdminApiKeyRepository {
async create(data: CreateAdminApiKeyData, createdBy: UserID, keyId: bigint, rawKey: string): Promise<AdminApiKey> {
const keyHash = await hashPassword(rawKey);
const createdAt = new Date();
@@ -1,24 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {UserID} from '@app/api/BrandedTypes';
import type {AdminApiKey} from '@app/api/models/AdminApiKey';
export interface CreateAdminApiKeyData {
name: string;
expiresAt: Date | null;
acls: Set<string>;
}
export interface UpdateAdminApiKeyData {
name?: string;
acls?: Set<string>;
}
export interface IAdminApiKeyRepository {
create(data: CreateAdminApiKeyData, createdBy: UserID, keyId: bigint, rawKey: string): Promise<AdminApiKey>;
findById(keyId: bigint): Promise<AdminApiKey | null>;
update(apiKey: AdminApiKey, data: UpdateAdminApiKeyData): Promise<AdminApiKey>;
listByCreator(createdBy: UserID): Promise<Array<AdminApiKey>>;
updateLastUsed(keyId: bigint, expiresAt: Date | null): Promise<void>;
revoke(keyId: bigint, createdBy: UserID): Promise<void>;
}
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {randomInt} from 'node:crypto';
import type {IAdminApiKeyRepository} from '@app/api/admin/repositories/IAdminApiKeyRepository';
import type {AdminApiKeyRepository} from '@app/api/admin/repositories/AdminApiKeyRepository';
import type {UserID} from '@app/api/BrandedTypes';
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
import type {AdminApiKey} from '@app/api/models/AdminApiKey';
@@ -39,7 +39,7 @@ export interface AdminApiKeyView {
export class AdminApiKeyService {
constructor(
private readonly adminApiKeyRepository: IAdminApiKeyRepository,
private readonly adminApiKeyRepository: AdminApiKeyRepository,
private readonly snowflakeService: ISnowflakeService,
) {}
@@ -3,9 +3,9 @@
import type {ApiContext} from '@app/api/ApiContext';
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
import {type ApplicationID, createApplicationID, createUserID, type GuildID, type UserID} from '@app/api/BrandedTypes';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import type {Application} from '@app/api/models/Application';
import type {IApplicationRepository} from '@app/api/oauth/repositories/IApplicationRepository';
import type {ApplicationRepository} from '@app/api/oauth/repositories/ApplicationRepository';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {UnknownApplicationError} from '@fluxer/errors/src/domains/oauth/UnknownApplicationError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
@@ -18,9 +18,9 @@ import type {
interface AdminApplicationServiceDeps {
apiContext: ApiContext;
applicationRepository: IApplicationRepository;
applicationRepository: ApplicationRepository;
auditService: AdminAuditService;
guildRepository: IGuildRepositoryAggregate;
guildRepository: GuildRepository;
}
interface UserDisplay {
@@ -4,10 +4,10 @@ import {AdminArchive} from '@app/api/admin/models/AdminArchiveModel';
import type {AdminArchiveRepository} from '@app/api/admin/repositories/AdminArchiveRepository';
import type {GuildID, UserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
@@ -35,8 +35,8 @@ interface ListArchivesParams {
export class AdminArchiveService {
constructor(
private readonly adminArchiveRepository: AdminArchiveRepository,
private readonly userRepository: IUserRepository,
private readonly guildRepository: IGuildRepositoryAggregate,
private readonly userRepository: UserRepository,
private readonly guildRepository: GuildRepository,
private readonly storageService: IStorageService,
private readonly snowflakeService: ISnowflakeService,
private readonly workerService: IWorkerService<WorkerTaskName>,
@@ -3,7 +3,7 @@
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
import {createEmojiID, createStickerID, type GuildID, type UserID} from '@app/api/BrandedTypes';
import {mapGuildEmojiToResponse, mapGuildStickerToResponse} from '@app/api/guild/GuildModel';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import {ExpressionAssetPurger} from '@app/api/guild/services/content/ExpressionAssetPurger';
import type {IAssetDeletionQueue} from '@app/api/infrastructure/IAssetDeletionQueue';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
@@ -15,7 +15,7 @@ import type {
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
interface AdminAssetPurgeServiceDeps {
guildRepository: IGuildRepositoryAggregate;
guildRepository: GuildRepository;
gatewayService: IGatewayService;
assetDeletionQueue: IAssetDeletionQueue;
auditService: AdminAuditService;
@@ -1,18 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ADMIN_AUDIT_READ_ACTIONS, getAdminAuditAccess} from '@app/api/admin/AdminAuditActions';
import type {AdminAuditLog, IAdminRepository} from '@app/api/admin/IAdminRepository';
import type {AdminAuditLog, AdminRepository} from '@app/api/admin/AdminRepository';
import type {UserID} from '@app/api/BrandedTypes';
import {createChannelID, createGuildID, createUserID} from '@app/api/BrandedTypes';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {ChannelDataRepository} from '@app/api/channel/repositories/ChannelDataRepository';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
import {Logger} from '@app/api/Logger';
import type {Channel} from '@app/api/models/Channel';
import type {Guild} from '@app/api/models/Guild';
import type {User} from '@app/api/models/User';
import {getAuditLogSearchService} from '@app/api/SearchFactory';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
import type {AuditLogSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
import type {
AdminAuditAccess,
@@ -34,7 +34,7 @@ interface CreateAdminAuditLogParams {
export class AdminAuditService {
constructor(
private readonly adminRepository: IAdminRepository,
private readonly adminRepository: AdminRepository,
private readonly snowflakeService: ISnowflakeService,
private readonly enrichmentDeps: AuditLogEnrichmentDeps = {},
) {}
@@ -341,9 +341,9 @@ function accessFilters(access: AdminAuditAccess | undefined): AuditLogSearchFilt
}
interface AuditLogEnrichmentDeps {
userRepository?: Pick<IUserRepository, 'findUnique'>;
guildRepository?: Pick<IGuildRepositoryAggregate, 'findUnique'>;
channelRepository?: Pick<IChannelRepository, 'findUnique'>;
userRepository?: Pick<UserRepository, 'findUnique'>;
guildRepository?: Pick<GuildRepository, 'findUnique'>;
channelRepository?: Pick<ChannelDataRepository, 'findUnique'>;
}
interface AuditLogEnrichment {
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ApiContext} from '@app/api/ApiContext';
import type {IAdminRepository} from '@app/api/admin/IAdminRepository';
import type {AdminRepository} from '@app/api/admin/AdminRepository';
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
import {createUserID, type UserID} from '@app/api/BrandedTypes';
import {isIpBanExempt} from '@app/api/ban/IpBanExemptions';
@@ -36,7 +36,7 @@ import type {AdminBlocklistListType} from '@fluxer/schema/src/domains/admin/Admi
interface AdminBanManagementServiceDeps {
apiContext: ApiContext;
adminRepository: IAdminRepository;
adminRepository: AdminRepository;
auditService: AdminAuditService;
}
@@ -3,7 +3,7 @@
import type {UserID} from '@app/api/BrandedTypes';
import type {GiftCodeDurationType, GiftCodeRow} from '@app/api/database/types/PaymentTypes';
import {mapGiftCodeDurationToMonths} from '@app/api/models/GiftCode';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
import * as RandomUtils from '@app/api/utils/RandomUtils';
const CODE_LENGTH = 32;
@@ -19,7 +19,7 @@ interface GenerateGiftCodesOptions {
}
export class AdminCodeGenerationService {
constructor(private readonly userRepository: IUserRepository) {}
constructor(private readonly userRepository: UserRepository) {}
async generateGiftCodes(options: GenerateGiftCodesOptions): Promise<Array<string>> {
const {count, durationType, durationQuantity, createdByUserId} = this.validateOptions(options);
@@ -8,27 +8,27 @@ import {AdminGuildUpdatePropagator} from '@app/api/admin/services/guild/AdminGui
import {AdminGuildUpdateService} from '@app/api/admin/services/guild/AdminGuildUpdateService';
import {AdminGuildVanityService} from '@app/api/admin/services/guild/AdminGuildVanityService';
import {createGuildID, createUserID} from '@app/api/BrandedTypes';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
import type {IGuildDiscoveryRepository} from '@app/api/guild/repositories/GuildDiscoveryRepository';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
import type {GuildDiscoveryRepository} from '@app/api/guild/repositories/GuildDiscoveryRepository';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import type {GuildService} from '@app/api/guild/services/GuildService';
import type {EntityAssetService} from '@app/api/infrastructure/EntityAssetService';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {InviteRepository} from '@app/api/invite/InviteRepository';
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
import type {ListGuildAuditLogsRequest} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
interface AdminGuildServiceDeps {
guildRepository: IGuildRepositoryAggregate;
userRepository: IUserRepository;
channelRepository: IChannelRepository;
guildRepository: GuildRepository;
userRepository: UserRepository;
channelRepository: ChannelRepository;
inviteRepository: InviteRepository;
guildService: GuildService;
gatewayService: IGatewayService;
entityAssetService: EntityAssetService;
auditService: AdminAuditService;
discoveryRepository: IGuildDiscoveryRepository;
discoveryRepository: GuildDiscoveryRepository;
}
export class AdminGuildService {
@@ -4,7 +4,7 @@ import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService'
import type {AdminMessageShredService} from '@app/api/admin/services/AdminMessageShredService';
import type {ChannelID, MessageID, UserID} from '@app/api/BrandedTypes';
import {createUserID} from '@app/api/BrandedTypes';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
import {Logger} from '@app/api/Logger';
import type {
DeleteAllUserMessagesRequest,
@@ -12,7 +12,7 @@ import type {
} from '@fluxer/schema/src/domains/admin/AdminMessageSchemas';
interface AdminMessageDeletionServiceDeps {
channelRepository: IChannelRepository;
channelRepository: ChannelRepository;
messageShredService: AdminMessageShredService;
auditService: AdminAuditService;
}
@@ -78,7 +78,7 @@ export class AdminMessageDeletionService {
let messageCount = 0;
let channelCount = 0;
while (true) {
const messageRefs = await this.deps.channelRepository.listMessagesByAuthor(
const messageRefs = await this.deps.channelRepository.messages.listMessagesByAuthor(
authorId,
FETCH_CHUNK_SIZE,
lastMessageId,
@@ -11,7 +11,7 @@ import {
type MessageID,
type UserID,
} from '@app/api/BrandedTypes';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
import {withThreadContext} from '@app/api/channel/services/ChannelGatewayDispatch';
import {
enqueueCrosspostFamilyPurgeFromCopies,
@@ -25,7 +25,7 @@ import {
messageResponseAccessForGuild,
} from '@app/api/channel/services/message/MessageResponseDataService';
import {resolveNsfwScopeChannel} from '@app/api/channel/utils/ThreadNsfwScope';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import {getPurgeQueue, getStorageService} from '@app/api/middleware/ServiceSingletons';
import {getMessageSearchService} from '@app/api/SearchFactory';
import {deleteMessageSearchDocuments} from '@app/api/search/MessageSearchIndexCleanup';
@@ -44,8 +44,8 @@ import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageRe
interface AdminMessageServiceDeps {
apiContext: ApiContext;
channelRepository: IChannelRepository;
guildRepository: IGuildRepositoryAggregate;
channelRepository: ChannelRepository;
guildRepository: GuildRepository;
auditService: AdminAuditService;
}
@@ -84,7 +84,7 @@ export class AdminMessageService {
async lookupMessageByAttachment(data: LookupMessageByAttachmentRequest) {
const channelId = createChannelID(data.channel_id);
const attachmentId = createAttachmentID(data.attachment_id);
const messageId = await this.deps.channelRepository.lookupAttachmentByChannelAndFilename(
const messageId = await this.deps.channelRepository.messages.lookupAttachmentByChannelAndFilename(
channelId,
attachmentId,
data.filename,
@@ -113,11 +113,11 @@ export class AdminMessageService {
const {gateway: gatewayService, worker: workerService} = this.deps.apiContext.services;
const channelId = createChannelID(data.channel_id);
const messageId = createMessageID(data.message_id);
const channel = await channelRepository.findUnique(channelId);
const message = await channelRepository.getMessage(channelId, messageId);
const channel = await channelRepository.channelData.findUnique(channelId);
const message = await channelRepository.messages.getMessage(channelId, messageId);
if (message) {
await purgeMessageAttachments(message, getStorageService(), getPurgeQueue());
await channelRepository.deleteMessage(
await channelRepository.messages.deleteMessage(
channelId,
messageId,
message.authorId || createUserID(0n),
@@ -227,7 +227,7 @@ export class AdminMessageService {
}
private async getMessageResponseAccessForAdmin(channelId: ChannelID): Promise<MessageResponseAccessContext> {
const channel = await this.deps.channelRepository.findUnique(channelId);
const channel = await this.deps.channelRepository.channelData.findUnique(channelId);
const access = channel ? messageResponseAccessForChannel(channel) : messageResponseAccessForGuild(null);
return {...access, includeHidden: true};
}
@@ -283,7 +283,7 @@ export class AdminMessageService {
private async getChannelNsfwContext(channelId: ChannelID): Promise<ChannelNsfwContext> {
const {channelRepository, guildRepository} = this.deps;
const channel = await channelRepository.findUnique(channelId);
const channel = await channelRepository.channelData.findUnique(channelId);
if (!channel) {
return {
channelNsfw: null,
@@ -304,7 +304,7 @@ export class AdminMessageService {
}
const [guild, scope] = await Promise.all([
guildRepository.findUnique(channel.guildId),
resolveNsfwScopeChannel(channel, (id) => channelRepository.findUnique(id)),
resolveNsfwScopeChannel(channel, (id) => channelRepository.channelData.findUnique(id)),
]);
return {
channelNsfw: scope.isNsfw,
@@ -15,7 +15,7 @@ import {
type UserID,
} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
import {makeAttachmentCdnKey} from '@app/api/channel/services/message/MessageHelpers';
import {
createMessageResponseDataService,
@@ -25,7 +25,7 @@ import {
} from '@app/api/channel/services/message/MessageResponseDataService';
import {resolveNsfwScopeChannel} from '@app/api/channel/utils/ThreadNsfwScope';
import type {MessageAttachment} from '@app/api/database/types/MessageTypes';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import {Logger} from '@app/api/Logger';
@@ -33,7 +33,7 @@ import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import {describeReportFlowAnswers} from '@app/api/report/flows/ReportFlowRegistry';
import {findReportReason, listReportReasons} from '@app/api/report/flows/ReportReasonCatalog';
import type {IARMessageContext, IARSubmission} from '@app/api/report/IReportRepository';
import type {IARMessageContext, IARSubmission} from '@app/api/report/ReportModels';
import type {ReportService} from '@app/api/report/ReportService';
import {getReportSearchService} from '@app/api/SearchFactory';
import {isHiddenPartial} from '@app/api/user/ProfileVisibility';
@@ -57,8 +57,8 @@ import {seconds} from 'itty-time';
interface AdminReportServiceDeps {
apiContext: ApiContext;
reportService: ReportService;
guildRepository: IGuildRepositoryAggregate;
channelRepository: IChannelRepository;
guildRepository: GuildRepository;
channelRepository: ChannelRepository;
storageService: IStorageService;
auditService: AdminAuditService;
userCacheService: UserCacheService;
@@ -541,7 +541,7 @@ export class AdminReportService {
}
private async getMessageResponseAccessForAdmin(channelId: ChannelID): Promise<MessageResponseAccessContext> {
const channel = await this.deps.channelRepository.findUnique(channelId);
const channel = await this.deps.channelRepository.channelData.findUnique(channelId);
const access = channel ? messageResponseAccessForChannel(channel) : messageResponseAccessForGuild(null);
return {...access, includeHidden: true};
}
@@ -628,9 +628,9 @@ export class AdminReportService {
if (reportLookupCache.channelNsfwByChannelId.has(channelIdString)) {
return reportLookupCache.channelNsfwByChannelId.get(channelIdString) ?? null;
}
const channel = await this.deps.channelRepository.findUnique(channelId);
const channel = await this.deps.channelRepository.channelData.findUnique(channelId);
const scope = channel
? await resolveNsfwScopeChannel(channel, (id) => this.deps.channelRepository.findUnique(id))
? await resolveNsfwScopeChannel(channel, (id) => this.deps.channelRepository.channelData.findUnique(id))
: null;
const channelNsfw = scope?.isNsfw ?? null;
reportLookupCache.channelNsfwByChannelId.set(channelIdString, channelNsfw);
@@ -665,7 +665,7 @@ export class AdminReportService {
if (!channelId) {
return null;
}
const channel = await this.deps.channelRepository.findUnique(channelId);
const channel = await this.deps.channelRepository.channelData.findUnique(channelId);
if (!channel?.guildId) {
return null;
}
@@ -7,7 +7,7 @@ import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService'
import {createGuildID, createUserID, type UserID} from '@app/api/BrandedTypes';
import {isSyntheticUserId} from '@app/api/constants/Core';
import {channelThreadsEnabled} from '@app/api/experiment/ChannelThreadsGate';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import {Logger} from '@app/api/Logger';
import {getGuildSearchService, getUserSearchService} from '@app/api/SearchFactory';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
@@ -36,7 +36,7 @@ interface RefreshSearchIndexJobPayload extends WorkerJobPayload {
interface AdminSearchServiceDeps {
apiContext: ApiContext;
guildRepository: IGuildRepositoryAggregate;
guildRepository: GuildRepository;
auditService: AdminAuditService;
}
@@ -15,7 +15,7 @@ import type {KVAccountDeletionQueueService} from '@app/api/infrastructure/KVAcco
import {Logger} from '@app/api/Logger';
import type {User} from '@app/api/models/User';
import type {OAuth2TokenRepository} from '@app/api/oauth/repositories/OAuth2TokenRepository';
import {type IARSubmission, ReportStatus} from '@app/api/report/IReportRepository';
import {type IARSubmission, ReportStatus} from '@app/api/report/ReportModels';
import type {ReportService} from '@app/api/report/ReportService';
import {getReportSearchService} from '@app/api/SearchFactory';
import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService';
@@ -5,7 +5,7 @@ import {mapUserToAdminResponse} from '@app/api/admin/models/UserTypes';
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
import type {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserUpdatePropagator';
import {createUserID, type UserID} from '@app/api/BrandedTypes';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import {GuildMemberSearchIndexService} from '@app/api/guild/services/member/GuildMemberSearchIndexService';
import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
import type {EntityAssetService, PreparedAssetUpload} from '@app/api/infrastructure/EntityAssetService';
@@ -37,7 +37,7 @@ interface AdminUserProfileServiceDeps {
entityAssetService: EntityAssetService;
auditService: AdminAuditService;
updatePropagator: AdminUserUpdatePropagator;
guildRepository: IGuildRepositoryAggregate;
guildRepository: GuildRepository;
}
export class AdminUserProfileService {
@@ -12,8 +12,8 @@ import {AdminUserSecurityService} from '@app/api/admin/services/AdminUserSecurit
import {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserUpdatePropagator';
import {ReporterResolutionNotifier} from '@app/api/admin/services/ReporterResolutionNotifier';
import {createChannelID, createUserID, type UserID} from '@app/api/BrandedTypes';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
import type {EntityAssetService} from '@app/api/infrastructure/EntityAssetService';
import type {KVAccountDeletionQueueService} from '@app/api/infrastructure/KVAccountDeletionQueueService';
@@ -23,7 +23,7 @@ import {getBillingRepository} from '@app/api/middleware/ServiceRegistry';
import {OAuth2TokenRepository} from '@app/api/oauth/repositories/OAuth2TokenRepository';
import type {ReportService} from '@app/api/report/ReportService';
import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
import type {UserContactChangeLogService} from '@app/api/user/services/UserContactChangeLogService';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
@@ -38,8 +38,8 @@ import type Stripe from 'stripe';
interface AdminUserServiceDeps {
apiContext: ApiContext;
guildRepository: IGuildRepositoryAggregate;
channelRepository: IChannelRepository;
guildRepository: GuildRepository;
channelRepository: ChannelRepository;
discriminatorService: IDiscriminatorService;
entityAssetService: EntityAssetService;
auditService: AdminAuditService;
@@ -62,9 +62,9 @@ export class AdminUserService {
private readonly updatePropagator: AdminUserUpdatePropagator;
private readonly contactChangeLogService: UserContactChangeLogService;
private readonly auditService: AdminAuditService;
private readonly userRepository: IUserRepository;
private readonly guildRepository: IGuildRepositoryAggregate;
private readonly channelRepository: IChannelRepository;
private readonly userRepository: UserRepository;
private readonly guildRepository: GuildRepository;
private readonly channelRepository: ChannelRepository;
private readonly bulkMessageDeletionQueue: KVBulkMessageDeletionQueueService;
private readonly cacheService: ICacheService;
@@ -132,7 +132,9 @@ export class AdminUserService {
beforeChannelId: data.before ? createChannelID(data.before) : undefined,
afterChannelId: data.after ? createChannelID(data.after) : undefined,
});
const channelModels = await this.channelRepository.listChannels(channels.map((channel) => channel.channelId));
const channelModels = await this.channelRepository.channelData.listChannels(
channels.map((channel) => channel.channelId),
);
const channelModelById = new Map(
channelModels.filter((c): c is NonNullable<typeof c> => c != null).map((c) => [c.id, c]),
);
@@ -234,7 +236,9 @@ export class AdminUserService {
if (groupDmSummaries.length === 0) {
return {channels: []};
}
const channelModels = await this.channelRepository.listChannels(groupDmSummaries.map((s) => s.channelId));
const channelModels = await this.channelRepository.channelData.listChannels(
groupDmSummaries.map((s) => s.channelId),
);
const allUserIds = new Set<UserID>();
for (const model of channelModels) {
if (model) {
@@ -1,19 +1,19 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {UserID} from '@app/api/BrandedTypes';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import type {User} from '@app/api/models/User';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
import {BaseUserUpdatePropagator} from '@app/api/user/services/BaseUserUpdatePropagator';
import {propagatePartialUserChange} from '@app/api/user/services/PartialUserChangePropagation';
import {hasPartialUserFieldsChanged} from '@app/api/user/UserMappers';
interface AdminUserUpdatePropagatorDeps {
userCacheService: UserCacheService;
userRepository: IUserRepository;
guildRepository: IGuildRepositoryAggregate;
userRepository: UserRepository;
guildRepository: GuildRepository;
gatewayService: IGatewayService;
}
@@ -12,7 +12,7 @@ import {getInstanceProductName} from '@app/api/instance/ProductName';
import {Logger} from '@app/api/Logger';
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {User} from '@app/api/models/User';
import type {IARSubmission} from '@app/api/report/IReportRepository';
import type {IARSubmission} from '@app/api/report/ReportModels';
import type {UserChannelService} from '@app/api/user/services/UserChannelService';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {getEmailTemplate} from '@pkgs/email/src/email_i18n/EmailI18n';
@@ -4,11 +4,11 @@ import {mapGuildsToAdminResponse} from '@app/api/admin/models/GuildTypes';
import type {GuildID} from '@app/api/BrandedTypes';
import {createGuildID, createUserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
import {mapGuildFeatures} from '@app/api/guild/GuildFeatureUtils';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
import {MEDIA_PROXY_ICON_SIZE_DEFAULT} from '@fluxer/constants/src/MediaProxyAssetSizes';
import type {MediaProxyImageSize} from '@fluxer/constants/src/MediaProxyImageSizes';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
@@ -22,9 +22,9 @@ import type {ListGuildEmojisResponse, ListGuildStickersResponse} from '@fluxer/s
const ADMIN_STICKER_MEDIA_RUNG: MediaProxyImageSize = 320;
interface AdminGuildLookupServiceDeps {
guildRepository: IGuildRepositoryAggregate;
userRepository: IUserRepository;
channelRepository: IChannelRepository;
guildRepository: GuildRepository;
userRepository: UserRepository;
channelRepository: ChannelRepository;
gatewayService: IGatewayService;
}
@@ -39,7 +39,7 @@ export class AdminGuildLookupService {
return {guild: null};
}
const [channels, roles, ownerUser] = await Promise.all([
channelRepository.listGuildChannels(guildId, 'maintenance'),
channelRepository.channelData.listGuildChannels(guildId, 'maintenance'),
guildRepository.listRoles(guildId),
userRepository.findUnique(guild.ownerId),
]);
@@ -2,14 +2,14 @@
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
import {createGuildID, type GuildID, type UserID} from '@app/api/BrandedTypes';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import type {GuildService} from '@app/api/guild/services/GuildService';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import type {SuccessResponse} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
interface AdminGuildManagementServiceDeps {
guildRepository: IGuildRepositoryAggregate;
guildRepository: GuildRepository;
gatewayService: IGatewayService;
guildService: GuildService;
auditService: AdminAuditService;
@@ -63,7 +63,7 @@ export class AdminGuildManagementService {
async deleteGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null): Promise<SuccessResponse> {
const {guildService, auditService} = this.deps;
const guildId = createGuildID(guildIdRaw);
await guildService.data.deleteGuildForAdmin(guildId, auditLogReason);
await guildService.data.operations.deleteGuildById(guildId);
await auditService.createAuditLog({
adminUserId,
targetType: 'guild',
@@ -4,7 +4,7 @@ import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService'
import {createGuildID, createUserID, type UserID} from '@app/api/BrandedTypes';
import type {GuildService} from '@app/api/guild/services/GuildService';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
import {JoinSourceTypes} from '@fluxer/constants/src/GuildConstants';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {
@@ -15,7 +15,7 @@ import type {
import type {SuccessResponse} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
interface AdminGuildMembershipServiceDeps {
userRepository: IUserRepository;
userRepository: UserRepository;
guildService: GuildService;
auditService: AdminAuditService;
}
@@ -2,7 +2,7 @@
import type {GuildID, UserID} from '@app/api/BrandedTypes';
import {mapGuildToGuildResponse} from '@app/api/guild/GuildModel';
import type {IGuildDiscoveryRepository} from '@app/api/guild/repositories/GuildDiscoveryRepository';
import type {GuildDiscoveryRepository} from '@app/api/guild/repositories/GuildDiscoveryRepository';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import {Logger} from '@app/api/Logger';
import type {Guild} from '@app/api/models/Guild';
@@ -13,7 +13,7 @@ import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
interface AdminGuildUpdatePropagatorDeps {
gatewayService: IGatewayService;
discoveryRepository: IGuildDiscoveryRepository;
discoveryRepository: GuildDiscoveryRepository;
}
interface AdminGuildUpdateDispatchOptions {
@@ -5,7 +5,7 @@ import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService'
import type {AdminGuildUpdatePropagator} from '@app/api/admin/services/guild/AdminGuildUpdatePropagator';
import {createGuildID, createUserID, type GuildID, type UserID} from '@app/api/BrandedTypes';
import type {GuildRow} from '@app/api/database/types/GuildTypes';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import type {EntityAssetService, PreparedAssetUpload} from '@app/api/infrastructure/EntityAssetService';
import {Logger} from '@app/api/Logger';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
@@ -17,7 +17,7 @@ import type {
} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
interface AdminGuildUpdateServiceDeps {
guildRepository: IGuildRepositoryAggregate;
guildRepository: GuildRepository;
entityAssetService: EntityAssetService;
auditService: AdminAuditService;
updatePropagator: AdminGuildUpdatePropagator;
@@ -10,7 +10,7 @@ import {
type UserID,
vanityCodeToInviteCode,
} from '@app/api/BrandedTypes';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import type {InviteRepository} from '@app/api/invite/InviteRepository';
import {InviteTypes} from '@fluxer/constants/src/ChannelConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
@@ -19,7 +19,7 @@ import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildEr
import type {UpdateGuildVanityRequest} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
interface AdminGuildVanityServiceDeps {
guildRepository: IGuildRepositoryAggregate;
guildRepository: GuildRepository;
inviteRepository: InviteRepository;
auditService: AdminAuditService;
updatePropagator: AdminGuildUpdatePropagator;
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {AdminAuditLog} from '@app/api/admin/IAdminRepository';
import type {AdminAuditLog} from '@app/api/admin/AdminRepository';
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {decodeHeaderUtf8} from '@app/api/middleware/AuditLogMiddleware';
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {AdminRepository} from '@app/api/admin/AdminRepository';
import type {AdminAuditLog} from '@app/api/admin/IAdminRepository';
import {type AdminAuditLog, AdminRepository} from '@app/api/admin/AdminRepository';
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {AdminAuditLog} from '@app/api/admin/IAdminRepository';
import type {AdminAuditLog} from '@app/api/admin/AdminRepository';
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {
createAttachmentID,
@@ -1,10 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createGuildID, createUserID, type UserID} from '@app/api/BrandedTypes';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
import type {CrosspostWorkerService} from '@app/api/channel/services/message/CrosspostPropagation';
import {UserMessageDeletionService} from '@app/api/channel/services/message/UserMessageDeletionService';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import {GuildMemberOperationsService} from '@app/api/guild/services/member/GuildMemberOperationsService';
import type {IPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
@@ -34,7 +34,7 @@ function unusableDependency(): object {
function createMessageDeletionService(): UserMessageDeletionService {
const unusable = unusableDependency();
return new UserMessageDeletionService({
channelRepository: unusable as IChannelRepository,
channelRepository: unusable as ChannelRepository,
gatewayService: unusable as IGatewayService,
storageService: unusable as IStorageService,
purgeQueue: unusable as IPurgeQueue,
@@ -50,7 +50,7 @@ function createGuildMemberOperationsService(): GuildMemberOperationsService {
async getMember() {
return null;
},
} as unknown as IGuildRepositoryAggregate;
} as unknown as GuildRepository;
const unusable = unusableDependency();
return new GuildMemberOperationsService(
guildRepository,
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {AdminAuditLog} from '@app/api/admin/IAdminRepository';
import type {AdminAuditLog} from '@app/api/admin/AdminRepository';
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getAdminAuditAccess} from '@app/api/admin/AdminAuditActions';
import type {AdminAuditLog} from '@app/api/admin/IAdminRepository';
import type {AdminAuditLog} from '@app/api/admin/AdminRepository';
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
+6 -22
View File
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {requireSudoMode} from '@app/api/auth/services/SudoVerificationService';
import {Config} from '@app/api/Config';
import {
onUsernameInstance,
RequireEmailAccountIdentity,
@@ -16,8 +15,8 @@ import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {SudoModeMiddleware} from '@app/api/middleware/SudoModeMiddleware';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {requireRequestClientIp} from '@app/api/utils/RequestClientIp';
import {Validator} from '@app/api/Validator';
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
import {
AuthLoginResponse,
AuthorizeIpRequest,
@@ -646,10 +645,7 @@ export function AuthController(app: HonoApp) {
'Retrieve device and location information about a pending handoff request. Non-destructive – the code remains valid after this call.',
}),
async (ctx) => {
const clientIp = requireClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
const clientIp = requireRequestClientIp(ctx);
const response = await ctx.get('authRequestService').getHandoffInfo({
code: ctx.req.valid('param').code,
clientIp,
@@ -672,10 +668,7 @@ export function AuthController(app: HonoApp) {
'Complete the handoff process and authenticate on the target device using the handoff code. With the deep_link return method, responds with the deep link that hands the one-time grant back to the initiating app. Otherwise responds with no content.',
}),
async (ctx) => {
const clientIp = requireClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
const clientIp = requireRequestClientIp(ctx);
const response = await ctx.get('authRequestService').completeHandoff({
data: ctx.req.valid('json'),
clientIp,
@@ -700,10 +693,7 @@ export function AuthController(app: HonoApp) {
'Decline a handoff request after looking it up. The initiating device sees the denied status and the code can no longer be approved.',
}),
async (ctx) => {
const clientIp = requireClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
const clientIp = requireRequestClientIp(ctx);
await ctx.get('authRequestService').denyHandoff({code: ctx.req.valid('param').code, clientIp});
return ctx.body(null, 204);
},
@@ -723,10 +713,7 @@ export function AuthController(app: HonoApp) {
'Check the status of a handoff session. Returns whether the handoff has been completed or is still pending.',
}),
async (ctx) => {
const clientIp = requireClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
const clientIp = requireRequestClientIp(ctx);
const response = await ctx.get('authRequestService').getHandoffStatus({
code: ctx.req.valid('param').code,
clientIp,
@@ -750,10 +737,7 @@ export function AuthController(app: HonoApp) {
'Check the status of a handoff session using the poll secret from initiation. Returns the authentication token once the handoff is complete and the presented secret matches.',
}),
async (ctx) => {
const clientIp = requireClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
const clientIp = requireRequestClientIp(ctx);
const response = await ctx.get('authRequestService').getHandoffStatus({
code: ctx.req.valid('param').code,
clientIp,
+2 -8
View File
@@ -8,9 +8,9 @@ import {createEmailRevertToken} from '@app/api/BrandedTypes';
import type {User} from '@app/api/models/User';
import {enqueueStripeCustomerEmailSync} from '@app/api/stripe/StripeCustomer';
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
import {requireConfiguredClientIp} from '@app/api/utils/RequestClientIp';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
interface IssueEmailRevertTokenParams {
user: User;
@@ -80,13 +80,7 @@ export async function revertEmailChange(
await users.deleteAllWebAuthnCredentials(user.id);
await users.deleteAllAuthorizedIps(user.id);
await AuthSession.terminateAllUserSessions(ctx, user.id);
await users.createAuthorizedIp(
user.id,
requireClientIp(request, {
trustClientIpHeader: config.proxy.trust_client_ip_header,
clientIpHeaderName: config.proxy.client_ip_header,
}),
);
await users.createAuthorizedIp(user.id, requireConfiguredClientIp(request, config.proxy));
await gateway.dispatchPresence({
userId: updatedUser.id,
event: 'USER_UPDATE',
+2 -5
View File
@@ -31,6 +31,7 @@ import type {User} from '@app/api/models/User';
import {findPersonByLoginHandle, type ParsedLoginHandle, parseLoginHandle} from '@app/api/user/UniqueUsernames';
import {lookupGeoip} from '@app/api/utils/IpUtils';
import {createRateLimitError} from '@app/api/utils/RateLimitUtils';
import {requireConfiguredClientIp} from '@app/api/utils/RequestClientIp';
import {AccountIdentityModes} from '@fluxer/constants/src/AccountIdentityConstants';
import {UserAuthenticatorTypes, UserFlags} from '@fluxer/constants/src/UserConstants';
import {type ValidationErrorCode, ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
@@ -42,7 +43,6 @@ import {RegistrationPendingApprovalError} from '@fluxer/errors/src/domains/auth/
import {RegistrationRejectedError} from '@fluxer/errors/src/domains/auth/RegistrationRejectedError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
import type {LoginRequest} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {EmailType} from '@fluxer/schema/src/primitives/UserValidators';
@@ -240,10 +240,7 @@ export async function login(
}
};
await enforceRateLimits(identifier.rateLimits);
const clientIp = requireClientIp(request, {
trustClientIpHeader: config.proxy.trust_client_ip_header,
clientIpHeaderName: config.proxy.client_ip_header,
});
const clientIp = requireConfiguredClientIp(request, config.proxy);
const sourceKey = getSameIpDecisionKey(clientIp) ?? clientIp;
await enforceRateLimits([
...identifier.sourceRateLimits(sourceKey),
+2 -5
View File
@@ -17,12 +17,12 @@ import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits';
import * as FetchUtils from '@app/api/utils/FetchUtils';
import {hashPassword as hashPasswordUtil, verifyPassword as verifyPasswordUtil} from '@app/api/utils/PasswordUtils';
import {createRateLimitError} from '@app/api/utils/RateLimitUtils';
import {requireConfiguredClientIp} from '@app/api/utils/RequestClientIp';
import {AccountIdentityModes} from '@fluxer/constants/src/AccountIdentityConstants';
import {FLUXER_USER_AGENT} from '@fluxer/constants/src/Core';
import {UserAuthenticatorTypes, UserFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
import type {ForgotPasswordRequest, ResetPasswordRequest} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {ms} from 'itty-time';
@@ -197,10 +197,7 @@ export async function isPasswordPwned(_ctx: ApiContext, password: string): Promi
export async function forgotPassword(ctx: ApiContext, {data, request}: ForgotPasswordParams): Promise<void> {
const {users, email, rateLimit, emailDnsValidation, config} = ctx.services;
const clientIp = requireClientIp(request, {
trustClientIpHeader: config.proxy.trust_client_ip_header,
clientIpHeaderName: config.proxy.client_ip_header,
});
const clientIp = requireConfiguredClientIp(request, config.proxy);
const ipRateLimit = await rateLimit.checkLimit({
identifier: `password_reset:ip:${getSameIpDecisionKey(clientIp) ?? clientIp}`,
maxAttempts: 20,
+2 -5
View File
@@ -19,6 +19,7 @@ import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons
import type {User} from '@app/api/models/User';
import {findPersonByLoginHandle, parseLoginHandle} from '@app/api/user/UniqueUsernames';
import {createRateLimitError} from '@app/api/utils/RateLimitUtils';
import {requireConfiguredClientIp} from '@app/api/utils/RequestClientIp';
import {AccountIdentityModes} from '@fluxer/constants/src/AccountIdentityConstants';
import {
generateRecoveryKey,
@@ -30,7 +31,6 @@ import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {RegistrationPendingApprovalError} from '@fluxer/errors/src/domains/auth/RegistrationPendingApprovalError';
import {RegistrationRejectedError} from '@fluxer/errors/src/domains/auth/RegistrationRejectedError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
import type {RecoverAccountRequest} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import type {
@@ -101,10 +101,7 @@ function invalidRecoveryKeyError(): InputValidationError {
async function checkRecoverRateLimits(ctx: ApiContext, username: string | null, request: Request): Promise<void> {
const {rateLimit, config} = ctx.services;
const clientIp = requireClientIp(request, {
trustClientIpHeader: config.proxy.trust_client_ip_header,
clientIpHeaderName: config.proxy.client_ip_header,
});
const clientIp = requireConfiguredClientIp(request, config.proxy);
const sourceKey = getSameIpDecisionKey(clientIp) ?? clientIp;
const ipRateLimit = await rateLimit.checkLimit({
identifier: `recover:ip:${sourceKey}`,
+3 -6
View File
@@ -42,6 +42,7 @@ import * as AgeUtils from '@app/api/utils/AgeUtils';
import {extractEmailDomain} from '@app/api/utils/EmailDomainUtils';
import {lookupGeoip} from '@app/api/utils/IpUtils';
import {createRateLimitError} from '@app/api/utils/RateLimitUtils';
import {requireConfiguredClientIp} from '@app/api/utils/RequestClientIp';
import {generateRandomUsername} from '@app/api/utils/UsernameGenerator';
import {deriveUsernameFromDisplayName} from '@app/api/utils/UsernameSuggestionUtils';
import {inputValidationErrorFromZodIssues} from '@app/api/Validator';
@@ -54,11 +55,10 @@ import {RegistrationClosedError} from '@fluxer/errors/src/domains/auth/Registrat
import {RegistrationUrlInvalidError} from '@fluxer/errors/src/domains/auth/RegistrationUrlInvalidError';
import {ContentBlockedError} from '@fluxer/errors/src/domains/content/ContentBlockedError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
import {getSameIpDecisionKey, getSubnet} from '@fluxer/ip_utils/src/IpAddress';
import type {RegisterRequest} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {UsernameType} from '@fluxer/schema/src/primitives/UserValidators';
import {parseAcceptLanguage} from '@pkgs/locale/src/LocaleService';
import {parseAcceptLanguage} from '@pkgs/locale/src/resolution/AcceptLanguageNegotiation';
import {types} from 'cassandra-driver';
import {ms} from 'itty-time';
@@ -143,10 +143,7 @@ async function registerAccount(
}
const now = new Date();
const registrationAccess = await resolveRegistrationAccess(instanceConfigRepository, data.registration_url_code);
const clientIp = requireClientIp(request, {
trustClientIpHeader: config.proxy.trust_client_ip_header,
clientIpHeaderName: config.proxy.client_ip_header,
});
const clientIp = requireConfiguredClientIp(request, config.proxy);
const geoipResult = await lookupGeoip(clientIp);
const countryCode = geoipResult.countryCode;
const collectDateOfBirth = appPublicConfig.registration.collect_date_of_birth;
+2 -5
View File
@@ -14,6 +14,7 @@ import {getKVAccountDeletionQueue} from '@app/api/middleware/ServiceSingletons';
import type {AuthSession} from '@app/api/models/AuthSession';
import type {User} from '@app/api/models/User';
import {lookupGeoip} from '@app/api/utils/IpUtils';
import {requireConfiguredClientIp} from '@app/api/utils/RequestClientIp';
import {isFluxerNativeUserAgent, parseReportedClientOs} from '@app/api/utils/SessionClientIdentity';
import {BotUserAuthSessionCreationDeniedError} from '@fluxer/errors/src/domains/auth/BotUserAuthSessionCreationDeniedError';
import {RegistrationPendingApprovalError} from '@fluxer/errors/src/domains/auth/RegistrationPendingApprovalError';
@@ -21,7 +22,6 @@ import {RegistrationRejectedError} from '@fluxer/errors/src/domains/auth/Registr
import {SessionTokenMismatchError} from '@fluxer/errors/src/domains/auth/SessionTokenMismatchError';
import {InvalidTokenError} from '@fluxer/errors/src/domains/core/InvalidTokenError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
export interface SessionOrigin {
@@ -67,10 +67,7 @@ interface CreateAdditionalAuthSessionFromTokenParams {
export function resolveSessionOrigin(ctx: ApiContext, request: Request): SessionOrigin {
const {config} = ctx.services;
const ip = requireClientIp(request, {
trustClientIpHeader: config.proxy.trust_client_ip_header,
clientIpHeaderName: config.proxy.client_ip_header,
});
const ip = requireConfiguredClientIp(request, config.proxy);
const userAgent = request.headers.get('user-agent')?.trim() || null;
const clientOs = isFluxerNativeUserAgent(userAgent)
? parseReportedClientOs(request.headers.get('x-fluxer-client-properties'))
@@ -1,10 +1,10 @@
import type {UserID} from '@app/api/BrandedTypes';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {IUserRepositoryAggregate} from '@app/api/user/repositories/IUserRepositoryAggregate';
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
import {runAllInOrder} from '@app/api/utils/ConcurrencyUtils';
interface SessionRevocationDependencies {
users: IUserRepositoryAggregate;
users: UserRepository;
gateway: IGatewayService;
}
@@ -1,10 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {randomBytes} from 'node:crypto';
import {createAuthHarness, createTestAccount, loginAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {
createAuthHarness,
createTestAccount,
createTotpSecret,
loginAccount,
loginWithTotp,
type TestAccount,
totpCodeNow,
} from '@app/api/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {TotpGenerator} from '@app/api/utils/TotpGenerator';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
@@ -25,65 +31,6 @@ interface OAuth2ApplicationResponse {
};
}
function generateTotpSecret(): string {
const buffer = randomBytes(20);
const base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let result = '';
for (let i = 0; i < buffer.length; i += 5) {
const bytes = [buffer[i]!, buffer[i + 1]!, buffer[i + 2]!, buffer[i + 3]!, buffer[i + 4]!];
const n = (bytes[0]! << 24) | (bytes[1]! << 16) | (bytes[2]! << 8) | bytes[3]!;
const indices = [
(n >> 3) & 0x1f,
((n >> 11) | ((bytes[4]! << 4) & 0xf)) & 0x1f,
((n >> 19) | ((bytes[4]! << 2) & 0x3c)) & 0x1f,
(bytes[4]! >> 1) & 0x1f,
];
result += base32Chars[indices[0]!];
result += base32Chars[indices[1]!];
result += base32Chars[indices[2]!];
result += base32Chars[indices[3]!];
}
return result;
}
async function generateTotpCode(secret: string): Promise<string> {
const totp = new TotpGenerator(secret);
const codes = await totp.generateTotp();
return codes[0]!;
}
async function loginWithTotp(harness: ApiTestHarness, account: TestAccount, secret: string): Promise<TestAccount> {
const loginResp = await createBuilderWithoutAuth<
| {
mfa: true;
ticket: string;
}
| {
mfa: false;
token: string;
}
>(harness)
.post('/auth/login')
.body({
email: account.email,
password: account.password,
})
.execute();
if (!loginResp.mfa) {
throw new Error('Expected MFA login');
}
const mfaLoginResp = await createBuilderWithoutAuth<{
token: string;
}>(harness)
.post('/auth/login/mfa/totp')
.body({
ticket: loginResp.ticket,
code: await generateTotpCode(secret),
})
.execute();
return {...account, token: mfaLoginResp.token};
}
async function createOAuth2BotApplication(
harness: ApiTestHarness,
account: TestAccount,
@@ -177,8 +124,8 @@ describe('Auth sudo required operations', () => {
});
it('requires sudo for disable TOTP MFA', async () => {
let account = await createTestAccount(harness);
const secret = generateTotpSecret();
const code = await generateTotpCode(secret);
const secret = createTotpSecret();
const code = totpCodeNow(secret);
const enableResp = await createBuilder<BackupCodesResponse>(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
@@ -202,15 +149,15 @@ describe('Auth sudo required operations', () => {
.body({
code: backupCode,
mfa_method: 'totp',
mfa_code: await generateTotpCode(secret),
mfa_code: totpCodeNow(secret),
})
.expect(204)
.execute();
});
it('requires sudo for enable TOTP MFA', async () => {
const account = await createTestAccount(harness);
const secret = generateTotpSecret();
const code = await generateTotpCode(secret);
const secret = createTotpSecret();
const code = totpCodeNow(secret);
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
@@ -4,11 +4,11 @@ import {
createAuthHarness,
createTestAccount,
createTotpSecret,
type TestAccount,
loginWithTotp,
totpCodeNow,
} from '@app/api/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
@@ -21,38 +21,6 @@ interface BackupCodesResponse {
const SUDO_MODE_HEADER = 'X-Fluxer-Sudo-Mode-JWT';
async function loginWithTotp(harness: ApiTestHarness, account: TestAccount, secret: string): Promise<TestAccount> {
const loginResp = await createBuilderWithoutAuth<
| {
mfa: true;
ticket: string;
}
| {
mfa: false;
token: string;
}
>(harness)
.post('/auth/login')
.body({
email: account.email,
password: account.password,
})
.execute();
if (!loginResp.mfa) {
throw new Error('Expected MFA login');
}
const mfaLoginResp = await createBuilderWithoutAuth<{
token: string;
}>(harness)
.post('/auth/login/mfa/totp')
.body({
ticket: loginResp.ticket,
code: totpCodeNow(secret),
})
.execute();
return {...account, token: mfaLoginResp.token};
}
describe('Auth sudo TOTP verification', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
@@ -260,6 +260,22 @@ export function totpCodeNow(secret: string): string {
return generateTotpCode(secret, Date.now());
}
export async function loginWithTotp(
harness: ApiTestHarness,
account: TestAccount,
secret: string,
): Promise<TestAccount> {
const login = await loginUser(harness, {email: account.email, password: account.password});
if (!('mfa' in login)) {
throw new Error('Expected MFA login');
}
const mfaLogin = await createBuilder<{token: string}>(harness, '')
.post('/auth/login/mfa/totp')
.body({ticket: login.ticket, code: totpCodeNow(secret)})
.execute();
return {...account, token: mfaLogin.token};
}
export async function seedMfaTicket(
harness: ApiTestHarness,
ticket: string,
@@ -4,6 +4,7 @@ import {
createTestAccount,
createTotpSecret,
generateTotpCode,
loginWithTotp,
type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import {
@@ -51,27 +52,6 @@ interface SudoModeRequiredResponse {
};
}
async function loginWithTotp(harness: ApiTestHarness, account: TestAccount, secret: string): Promise<TestAccount> {
const login = await createBuilderWithoutAuth<LoginMfaResponse>(harness)
.post('/auth/login')
.body({
email: account.email,
password: account.password,
})
.execute();
expect(login.mfa).toBe(true);
const mfaLogin = await createBuilderWithoutAuth<{
token: string;
}>(harness)
.post('/auth/login/mfa/totp')
.body({
code: generateTotpCode(secret),
ticket: login.ticket,
})
.execute();
return {...account, token: mfaLogin.token};
}
async function setupWebAuthnOnlyUser(
harness: ApiTestHarness,
account: TestAccount,
@@ -1,11 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {randomBytes} from 'node:crypto';
import {createAuthHarness, createTestAccount, loginUser, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {
createAuthHarness,
createTestAccount,
createTotpSecret,
loginUser,
loginWithTotp,
type TestAccount,
totpCodeNow,
} from '@app/api/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {TotpGenerator} from '@app/api/utils/TotpGenerator';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const SUDO_MODE_HEADER = 'X-Fluxer-Sudo-Mode-JWT';
@@ -22,35 +28,8 @@ interface BackupCodesResponse {
}>;
}
function generateTotpSecret(): string {
const buffer = randomBytes(20);
const base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let result = '';
for (let i = 0; i < buffer.length; i += 5) {
const bytes = [buffer[i]!, buffer[i + 1]!, buffer[i + 2]!, buffer[i + 3]!, buffer[i + 4]!];
const n = (bytes[0]! << 24) | (bytes[1]! << 16) | (bytes[2]! << 8) | bytes[3]!;
const indices = [
(n >> 3) & 0x1f,
((n >> 11) | ((bytes[4]! << 4) & 0xf)) & 0x1f,
((n >> 19) | ((bytes[4]! << 2) & 0x3c)) & 0x1f,
(bytes[4]! >> 1) & 0x1f,
];
result += base32Chars[indices[0]!];
result += base32Chars[indices[1]!];
result += base32Chars[indices[2]!];
result += base32Chars[indices[3]!];
}
return result;
}
async function generateTotpCode(secret: string): Promise<string> {
const totp = new TotpGenerator(secret);
const codes = await totp.generateTotp();
return codes[0]!;
}
async function enableTotpForAccount(harness: ApiTestHarness, account: TestAccount, secret: string): Promise<void> {
const code = await generateTotpCode(secret);
const code = totpCodeNow(secret);
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
@@ -61,29 +40,12 @@ async function enableTotpForAccount(harness: ApiTestHarness, account: TestAccoun
.execute();
}
async function loginWithTotp(harness: ApiTestHarness, account: TestAccount, secret: string): Promise<TestAccount> {
const login = await loginUser(harness, {email: account.email, password: account.password});
if (!('mfa' in login)) {
throw new Error('Expected MFA login');
}
const mfaLoginResp = await createBuilderWithoutAuth<{
token: string;
}>(harness)
.post('/auth/login/mfa/totp')
.body({
ticket: login.ticket,
code: await generateTotpCode(secret),
})
.execute();
return {...account, token: mfaLoginResp.token};
}
async function getSudoTokenViaMfa(harness: ApiTestHarness, token: string, secret: string): Promise<string> {
const {response} = await createBuilder<BackupCodesResponse>(harness, token)
.post('/users/@me/mfa/backup-codes')
.body({
mfa_method: 'totp',
mfa_code: await generateTotpCode(secret),
mfa_code: totpCodeNow(secret),
regenerate: false,
})
.executeWithResponse();
@@ -108,7 +70,7 @@ describe('Sudo mode negative cases', () => {
describe('invalid sudo token rejected', () => {
it('rejects malformed sudo token', async () => {
const account = await createTestAccount(harness);
const secret = generateTotpSecret();
const secret = createTotpSecret();
await enableTotpForAccount(harness, account, secret);
const loggedIn = await loginWithTotp(harness, account, secret);
const invalidTokens = [
@@ -128,7 +90,7 @@ describe('Sudo mode negative cases', () => {
});
it('rejects empty sudo token header', async () => {
const account = await createTestAccount(harness);
const secret = generateTotpSecret();
const secret = createTotpSecret();
await enableTotpForAccount(harness, account, secret);
const loggedIn = await loginWithTotp(harness, account, secret);
const {json: errResp} = await createBuilder<ErrorResponse>(harness, loggedIn.token)
@@ -166,7 +128,7 @@ describe('Sudo mode negative cases', () => {
describe('wrong MFA code rejected', () => {
it('rejects incorrect TOTP codes', async () => {
const account = await createTestAccount(harness);
const secret = generateTotpSecret();
const secret = createTotpSecret();
await enableTotpForAccount(harness, account, secret);
const loggedIn = await loginWithTotp(harness, account, secret);
const wrongCodes = ['000000', '123456', '999999', '12345', '1234567', 'abcdef'];
@@ -185,12 +147,12 @@ describe('Sudo mode negative cases', () => {
describe('sudo token for wrong user rejected', () => {
it('rejects sudo token from different user', async () => {
const account1 = await createTestAccount(harness);
const secret1 = generateTotpSecret();
const secret1 = createTotpSecret();
await enableTotpForAccount(harness, account1, secret1);
const loggedIn1 = await loginWithTotp(harness, account1, secret1);
const user1SudoToken = await getSudoTokenViaMfa(harness, loggedIn1.token, secret1);
const account2 = await createTestAccount(harness);
const secret2 = generateTotpSecret();
const secret2 = createTotpSecret();
await enableTotpForAccount(harness, account2, secret2);
const loggedIn2 = await loginWithTotp(harness, account2, secret2);
const {json: errResp} = await createBuilder<ErrorResponse>(harness, loggedIn2.token)
@@ -243,7 +205,7 @@ describe('Sudo mode negative cases', () => {
describe('MFA registration without token fails', () => {
it('WebAuthn registration options do not issue sudo token', async () => {
const account = await createTestAccount(harness);
const secret = generateTotpSecret();
const secret = createTotpSecret();
await enableTotpForAccount(harness, account, secret);
const loggedIn = await loginWithTotp(harness, account, secret);
const sudoToken = await getSudoTokenViaMfa(harness, loggedIn.token, secret);
@@ -259,7 +221,7 @@ describe('Sudo mode negative cases', () => {
describe('existing MFA token allows skipping MFA', () => {
it('sudo token from MFA allows skipping MFA verification on sensitive endpoint', async () => {
const account = await createTestAccount(harness);
const secret = generateTotpSecret();
const secret = createTotpSecret();
await enableTotpForAccount(harness, account, secret);
const loggedIn = await loginWithTotp(harness, account, secret);
const sudoToken = await getSudoTokenViaMfa(harness, loggedIn.token, secret);
@@ -1,17 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
createHash,
createHmac,
createPrivateKey,
createPublicKey,
createSign,
generateKeyPairSync,
randomBytes,
} from 'node:crypto';
import {createHash, createPrivateKey, createPublicKey, createSign, generateKeyPairSync, randomBytes} from 'node:crypto';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {decode as base32Decode, encode as base32Encode} from 'hi-base32';
export {createTotpSecret, generateTotpCode} from '@app/api/auth/tests/AuthTestUtils';
export interface WebAuthnDevice {
privateKey: string;
@@ -103,30 +96,6 @@ interface WebAuthnAuthenticationResponse {
response: AuthenticatorAssertionResponse;
}
export function createTotpSecret(): string {
const buf = randomBytes(20);
return base32Encode(buf).replace(/=/g, '');
}
export function generateTotpCode(secret: string, time = Date.now()): string {
const key = Buffer.from(base32Decode.asBytes(secret.toUpperCase()));
const epoch = Math.floor(time / 1000);
const counter = Math.floor(epoch / 30);
const counterBuf = Buffer.alloc(8);
counterBuf.writeBigUInt64BE(BigInt(counter));
const hmac = createHmac('sha1', key);
hmac.update(counterBuf);
const hash = hmac.digest();
const offset = hash[hash.length - 1] & 0x0f;
const binary =
((hash[offset] & 0x7f) << 24) |
((hash[offset + 1] & 0xff) << 16) |
((hash[offset + 2] & 0xff) << 8) |
(hash[offset + 3] & 0xff);
const otp = binary % 1000000;
return otp.toString().padStart(6, '0');
}
function resolveWebAuthnOrigin(): {
rpId: string;
origin: string;
+16 -116
View File
@@ -1,124 +1,24 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {AttachmentID, ChannelID, GuildID, MessageID, UserID} from '@app/api/BrandedTypes';
import {IChannelRepository} from '@app/api/channel/IChannelRepository';
import {ChannelRepository as NewChannelRepository} from '@app/api/channel/repositories/ChannelRepository';
import type {GuildChannelListMode} from '@app/api/channel/repositories/IChannelDataRepository';
import type {UpsertMessageOptions} from '@app/api/channel/repositories/IMessageRepository';
import type {ChannelRow} from '@app/api/database/types/ChannelTypes';
import type {MessageRow} from '@app/api/database/types/MessageTypes';
import {ChannelDataRepository} from '@app/api/channel/repositories/ChannelDataRepository';
import {CrosspostedMessageRepository} from '@app/api/channel/repositories/CrosspostedMessageRepository';
import {MessageInteractionRepository} from '@app/api/channel/repositories/MessageInteractionRepository';
import {MessageRepository} from '@app/api/channel/repositories/MessageRepository';
import {ThreadRepository} from '@app/api/channel/repositories/ThreadRepository';
import {enqueueRepairThreadIndexes} from '@app/api/channel/threads/ThreadJobs';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {Channel} from '@app/api/models/Channel';
import type {Message} from '@app/api/models/Message';
export class ChannelRepository extends IChannelRepository {
private repository: NewChannelRepository;
export class ChannelRepository {
readonly channelData: ChannelDataRepository;
readonly messages: MessageRepository;
readonly messageInteractions: MessageInteractionRepository;
readonly threads: ThreadRepository;
readonly crossposts = new CrosspostedMessageRepository();
constructor(requestCache?: RequestCache) {
super();
this.repository = new NewChannelRepository(requestCache);
}
get channelData() {
return this.repository.channelData;
}
get messages() {
return this.repository.messages;
}
get messageInteractions() {
return this.repository.messageInteractions;
}
get threads() {
return this.repository.threads;
}
get crossposts() {
return this.repository.crossposts;
}
async findUnique(channelId: ChannelID): Promise<Channel | null> {
return this.repository.channelData.findUnique(channelId);
}
async upsert(data: ChannelRow): Promise<Channel> {
return this.repository.channelData.upsert(data);
}
async delete(channelId: ChannelID, guildId?: GuildID, type?: number): Promise<void> {
return this.repository.channelData.delete(channelId, guildId, type);
}
async listMessages(
channelId: ChannelID,
beforeMessageId?: MessageID,
limit?: number,
afterMessageId?: MessageID,
): Promise<Array<Message>> {
return this.repository.messages.listMessages(channelId, beforeMessageId, limit, afterMessageId);
}
async getMessage(channelId: ChannelID, messageId: MessageID): Promise<Message | null> {
return this.repository.messages.getMessage(channelId, messageId);
}
async upsertMessage(data: MessageRow, oldData?: MessageRow | null, opts?: UpsertMessageOptions): Promise<Message> {
return this.repository.messages.upsertMessage(data, oldData, opts);
}
async deleteMessage(
channelId: ChannelID,
messageId: MessageID,
authorId: UserID,
pinnedTimestamp?: Date,
): Promise<void> {
return this.repository.messages.deleteMessage(channelId, messageId, authorId, pinnedTimestamp);
}
async bulkDeleteMessages(channelId: ChannelID, messageIds: Array<MessageID>): Promise<void> {
return this.repository.messages.bulkDeleteMessages(channelId, messageIds);
}
async listGuildChannels(guildId: GuildID, mode: GuildChannelListMode): Promise<Array<Channel>> {
return this.repository.channelData.listGuildChannels(guildId, mode);
}
async listChannels(channelIds: Array<ChannelID>): Promise<Array<Channel>> {
return this.repository.channelData.listChannels(channelIds);
}
async lookupAttachmentByChannelAndFilename(
channelId: ChannelID,
attachmentId: AttachmentID,
filename: string,
): Promise<MessageID | null> {
return this.repository.messages.lookupAttachmentByChannelAndFilename(channelId, attachmentId, filename);
}
async listMessagesByAuthor(
authorId: UserID,
limit?: number,
lastMessageId?: MessageID,
): Promise<
Array<{
channelId: ChannelID;
messageId: MessageID;
}>
> {
return this.repository.messages.listMessagesByAuthor(authorId, limit, lastMessageId);
}
async anonymizeMessage(channelId: ChannelID, messageId: MessageID, newAuthorId: UserID): Promise<void> {
return this.repository.messages.anonymizeMessage(channelId, messageId, newAuthorId);
}
async deleteAllChannelMessages(channelId: ChannelID): Promise<void> {
return this.repository.messages.deleteAllChannelMessages(channelId);
}
async updateEmbeds(message: Message): Promise<void> {
return this.repository.messages.updateEmbeds(message);
this.channelData = new ChannelDataRepository(requestCache);
this.messages = new MessageRepository(this.channelData);
this.messageInteractions = new MessageInteractionRepository(this.messages);
this.threads = new ThreadRepository(this.channelData, this.messages, enqueueRepairThreadIndexes);
}
}
@@ -1,65 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {AttachmentID, ChannelID, GuildID, MessageID, UserID} from '@app/api/BrandedTypes';
import type {GuildChannelListMode} from '@app/api/channel/repositories/IChannelDataRepository';
import {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate';
import type {UpsertMessageOptions} from '@app/api/channel/repositories/IMessageRepository';
import type {ChannelRow} from '@app/api/database/types/ChannelTypes';
import type {MessageRow} from '@app/api/database/types/MessageTypes';
import type {Channel} from '@app/api/models/Channel';
import type {Message} from '@app/api/models/Message';
export abstract class IChannelRepository extends IChannelRepositoryAggregate {
abstract findUnique(channelId: ChannelID): Promise<Channel | null>;
abstract upsert(data: ChannelRow): Promise<Channel>;
abstract delete(channelId: ChannelID, guildId?: GuildID, type?: number): Promise<void>;
abstract listGuildChannels(guildId: GuildID, mode: GuildChannelListMode): Promise<Array<Channel>>;
abstract listChannels(channelIds: Array<ChannelID>): Promise<Array<Channel>>;
abstract listMessages(
channelId: ChannelID,
beforeMessageId?: MessageID,
limit?: number,
afterMessageId?: MessageID,
): Promise<Array<Message>>;
abstract getMessage(channelId: ChannelID, messageId: MessageID): Promise<Message | null>;
abstract upsertMessage(data: MessageRow, oldData?: MessageRow | null, opts?: UpsertMessageOptions): Promise<Message>;
abstract deleteMessage(
channelId: ChannelID,
messageId: MessageID,
authorId: UserID,
pinnedTimestamp?: Date,
): Promise<void>;
abstract bulkDeleteMessages(channelId: ChannelID, messageIds: Array<MessageID>): Promise<void>;
abstract lookupAttachmentByChannelAndFilename(
channelId: ChannelID,
attachmentId: AttachmentID,
filename: string,
): Promise<MessageID | null>;
abstract listMessagesByAuthor(
authorId: UserID,
limit?: number,
lastMessageId?: MessageID,
): Promise<
Array<{
channelId: ChannelID;
messageId: MessageID;
}>
>;
abstract anonymizeMessage(channelId: ChannelID, messageId: MessageID, newAuthorId: UserID): Promise<void>;
abstract deleteAllChannelMessages(channelId: ChannelID): Promise<void>;
abstract updateEmbeds(message: Message): Promise<void>;
}
@@ -2,7 +2,6 @@
import {requireSudoMode} from '@app/api/auth/services/SudoVerificationService';
import {createAttachmentID, createChannelID, createMessageID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {MessageRequest, MessageUpdateRequest} from '@app/api/channel/MessageTypes';
import {normalizeMessageRequestPayload} from '@app/api/channel/services/message/MessageRequestCompatibility';
import {parseMultipartMessageData} from '@app/api/channel/services/message/MessageRequestParser';
@@ -16,10 +15,10 @@ import {readStateCapable} from '@app/api/read_state/ReadStateChannelMeta';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {assertAccountNotLimited} from '@app/api/user/AccountLimit';
import {parseJsonPreservingLargeIntegers} from '@app/api/utils/LosslessJsonParser';
import {requireRequestClientIp} from '@app/api/utils/RequestClientIp';
import {Validator} from '@app/api/Validator';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
import {SudoVerificationSchema} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {
ChannelIdMessageIdAttachmentIdParam,
@@ -227,10 +226,7 @@ export function MessageController(app: HonoApp) {
'Returns presigned upload URLs for message attachments in the target channel. Small files (<=10MB) return a singlepart PUT URL; larger files return a multipart plan (upload_id + per-part URLs) that the client should complete via the matching /attachments/complete endpoint. Requires message send and attachment permissions in guild channels.',
}),
async (ctx) => {
const clientIp = requireClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
const clientIp = requireRequestClientIp(ctx);
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const {attachments} = ctx.req.valid('json');
return ctx.json({
@@ -261,10 +257,7 @@ export function MessageController(app: HonoApp) {
'Finalizes one or more multipart attachment uploads. Called after all chunks have been PUT to their presigned URLs. The server lists the uploaded parts and issues S3 CompleteMultipartUpload. Returns the finalized upload keys, which can be referenced in a subsequent message create request.',
}),
async (ctx) => {
const clientIp = requireClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
const clientIp = requireRequestClientIp(ctx);
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const {uploads} = ctx.req.valid('json');
return ctx.json({
@@ -1,15 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createChannelID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate';
import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {requireRequestClientIp} from '@app/api/utils/RequestClientIp';
import {Validator} from '@app/api/Validator';
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
import {
StreamPreviewResponse,
StreamPreviewUploadBodySchema,
@@ -102,10 +101,7 @@ export function StreamController(app: HonoApp) {
const user = ctx.get('user');
const {channel_id, content_type} = ctx.req.valid('json');
const streamKey = ctx.req.valid('param').stream_key;
const clientIp = requireClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
const clientIp = requireRequestClientIp(ctx);
const response = await ctx.get('streamService').createPreviewUploadUrl({
viewer: viewerFromCtx(ctx),
streamKey,
@@ -6,7 +6,6 @@ import {
privateChannelLastMessageIdPatch,
privateChannelMetadataPatch,
} from '@app/api/channel/PrivateChannelSnapshot';
import {type GuildChannelListMode, IChannelDataRepository} from '@app/api/channel/repositories/IChannelDataRepository';
import {
BatchBuilder,
executeConditional,
@@ -27,6 +26,8 @@ import {Channel} from '@app/api/models/Channel';
import {Channels, ChannelsByGuild, PrivateChannels, ThreadOnlyChannelsByGuild, ThreadStats} from '@app/api/Tables';
import {THREAD_CHANNEL_TYPES, THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants';
export type GuildChannelListMode = 'enrolled' | 'maintenance' | 'complete';
const FETCH_CHANNEL_BY_ID = Channels.select({
where: [Channels.where.eq('channel_id'), Channels.where.eq('soft_deleted')],
limit: 1,
@@ -48,10 +49,8 @@ const FETCH_OPEN_PRIVATE_CHANNEL_TARGET = PrivateChannels.selectCql({
limit: 1,
});
export class ChannelDataRepository extends IChannelDataRepository {
constructor(private readonly requestCache?: RequestCache) {
super();
}
export class ChannelDataRepository {
constructor(private readonly requestCache?: RequestCache) {}
async findUnique(channelId: ChannelID): Promise<Channel | null> {
const prefetched = this.requestCache?.takeChannel(channelId);
@@ -1,27 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ChannelDataRepository} from '@app/api/channel/repositories/ChannelDataRepository';
import {CrosspostedMessageRepository} from '@app/api/channel/repositories/CrosspostedMessageRepository';
import {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate';
import {MessageInteractionRepository} from '@app/api/channel/repositories/MessageInteractionRepository';
import {MessageRepository} from '@app/api/channel/repositories/MessageRepository';
import {ThreadRepository} from '@app/api/channel/repositories/ThreadRepository';
import {enqueueRepairThreadIndexes} from '@app/api/channel/threads/ThreadJobs';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
export class ChannelRepository extends IChannelRepositoryAggregate {
readonly channelData: ChannelDataRepository;
readonly messages: MessageRepository;
readonly messageInteractions: MessageInteractionRepository;
readonly threads: ThreadRepository;
readonly crossposts: CrosspostedMessageRepository;
constructor(requestCache?: RequestCache) {
super();
this.channelData = new ChannelDataRepository(requestCache);
this.messages = new MessageRepository(this.channelData);
this.messageInteractions = new MessageInteractionRepository(this.messages);
this.threads = new ThreadRepository(this.channelData, this.messages, enqueueRepairThreadIndexes);
this.crossposts = new CrosspostedMessageRepository();
}
}
@@ -1,12 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ChannelID, MessageID, WebhookID} from '@app/api/BrandedTypes';
import {
type CrosspostedMessageKey,
type CrosspostSource,
type CrosspostSyncState,
ICrosspostedMessageRepository,
} from '@app/api/channel/repositories/ICrosspostedMessageRepository';
import {
deleteOneOrMany,
executeConditional,
@@ -18,6 +12,21 @@ import {Db} from '@app/api/database/CassandraTypes';
import type {CrosspostedMessageRow, CrosspostSourceByChannelRow} from '@app/api/database/types/ChannelTypes';
import {CrosspostedMessages, CrosspostSourcesByChannel} from '@app/api/Tables';
export interface CrosspostedMessageKey {
sourceMessageId: MessageID;
webhookId: WebhookID;
}
export interface CrosspostSyncState {
targetMessageId: MessageID;
sourceFingerprint: string | null;
}
export interface CrosspostSource {
sourceChannelId: ChannelID;
sourceMessageId: MessageID;
}
const FETCH_CROSSPOSTED_MESSAGE_CQL = CrosspostedMessages.selectCql({
where: [CrosspostedMessages.where.eq('source_message_id'), CrosspostedMessages.where.eq('webhook_id')],
limit: 1,
@@ -64,7 +73,7 @@ function toPk(key: CrosspostedMessageKey): Pick<CrosspostedMessageRow, 'source_m
return {source_message_id: key.sourceMessageId, webhook_id: key.webhookId};
}
export class CrosspostedMessageRepository extends ICrosspostedMessageRepository {
export class CrosspostedMessageRepository {
async get(sourceMessageId: MessageID, webhookId: WebhookID): Promise<CrosspostedMessageRow | null> {
return fetchOne<CrosspostedMessageRow>(FETCH_CROSSPOSTED_MESSAGE_CQL, {
source_message_id: sourceMessageId,
@@ -1,25 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ChannelID, GuildID, MessageID} from '@app/api/BrandedTypes';
import type {ChannelRow} from '@app/api/database/types/ChannelTypes';
import type {Channel} from '@app/api/models/Channel';
export type GuildChannelListMode = 'enrolled' | 'maintenance' | 'complete';
export abstract class IChannelDataRepository {
abstract findUnique(channelId: ChannelID): Promise<Channel | null>;
abstract upsert(data: ChannelRow, oldData?: ChannelRow | null): Promise<Channel>;
abstract updateLastMessageId(channelId: ChannelID, messageId: MessageID, opts?: {isInsert?: boolean}): Promise<void>;
abstract delete(channelId: ChannelID, guildId?: GuildID, type?: number): Promise<void>;
abstract listGuildChannels(guildId: GuildID, mode: GuildChannelListMode): Promise<Array<Channel>>;
abstract listChannels(channelIds: Array<ChannelID>): Promise<Array<Channel>>;
abstract countGuildChannels(guildId: GuildID): Promise<number>;
abstract patchIndexedAt(channelId: ChannelID, indexedAt: Date): Promise<void>;
}
@@ -1,15 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {IChannelDataRepository} from '@app/api/channel/repositories/IChannelDataRepository';
import type {ICrosspostedMessageRepository} from '@app/api/channel/repositories/ICrosspostedMessageRepository';
import type {IMessageInteractionRepository} from '@app/api/channel/repositories/IMessageInteractionRepository';
import type {IMessageRepository} from '@app/api/channel/repositories/IMessageRepository';
import type {IThreadRepository} from '@app/api/channel/repositories/IThreadRepository';
export abstract class IChannelRepositoryAggregate {
abstract readonly channelData: IChannelDataRepository;
abstract readonly messages: IMessageRepository;
abstract readonly messageInteractions: IMessageInteractionRepository;
abstract readonly threads: IThreadRepository;
abstract readonly crossposts: ICrosspostedMessageRepository;
}
@@ -1,57 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ChannelID, MessageID, WebhookID} from '@app/api/BrandedTypes';
import type {CrosspostedMessageRow} from '@app/api/database/types/ChannelTypes';
export interface CrosspostedMessageKey {
sourceMessageId: MessageID;
webhookId: WebhookID;
}
export interface CrosspostSyncState {
targetMessageId: MessageID;
sourceFingerprint: string | null;
}
export interface CrosspostSource {
sourceChannelId: ChannelID;
sourceMessageId: MessageID;
}
export abstract class ICrosspostedMessageRepository {
abstract get(sourceMessageId: MessageID, webhookId: WebhookID): Promise<CrosspostedMessageRow | null>;
abstract insertPending(row: CrosspostedMessageRow): Promise<boolean>;
abstract reclaimPending(
key: CrosspostedMessageKey,
data: {
fromTargetMessageId: MessageID;
toTargetMessageId: MessageID;
reservedAt: Date;
},
): Promise<boolean>;
abstract markDelivered(key: CrosspostedMessageKey, data: CrosspostSyncState): Promise<boolean>;
abstract updateSynced(key: CrosspostedMessageKey, data: CrosspostSyncState): Promise<boolean>;
abstract listBySourceMessage(
sourceMessageId: MessageID,
options: {afterWebhookId?: WebhookID; limit: number},
): Promise<Array<CrosspostedMessageRow>>;
abstract delete(
key: CrosspostedMessageKey,
expected?: Partial<Pick<CrosspostedMessageRow, 'state' | 'target_message_id'>>,
): Promise<boolean>;
abstract addSource(source: CrosspostSource): Promise<void>;
abstract listSourcesByChannel(
sourceChannelId: ChannelID,
options: {afterMessageId?: MessageID; limit: number},
): Promise<Array<MessageID>>;
abstract deleteSource(source: CrosspostSource): Promise<void>;
}
@@ -1,69 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ChannelID, EmojiID, MessageID, UserID} from '@app/api/BrandedTypes';
import type {Message} from '@app/api/models/Message';
import type {MessageReaction} from '@app/api/models/MessageReaction';
export abstract class IMessageInteractionRepository {
abstract listChannelPins(channelId: ChannelID, beforePinnedTimestamp: Date, limit?: number): Promise<Array<Message>>;
abstract addChannelPin(channelId: ChannelID, messageId: MessageID, pinnedTimestamp: Date): Promise<void>;
abstract removeChannelPin(channelId: ChannelID, messageId: MessageID): Promise<void>;
abstract listMessageReactions(channelId: ChannelID, messageId: MessageID): Promise<Array<MessageReaction>>;
abstract listReactionUsers(
channelId: ChannelID,
messageId: MessageID,
emojiName: string,
limit?: number,
after?: UserID,
emojiId?: EmojiID,
): Promise<Array<MessageReaction>>;
abstract addReaction(
channelId: ChannelID,
messageId: MessageID,
userId: UserID,
emojiName: string,
emojiId?: EmojiID,
emojiAnimated?: boolean,
): Promise<MessageReaction>;
abstract removeReaction(
channelId: ChannelID,
messageId: MessageID,
userId: UserID,
emojiName: string,
emojiId?: EmojiID,
): Promise<void>;
abstract removeAllReactions(channelId: ChannelID, messageId: MessageID): Promise<void>;
abstract removeAllReactionsForEmoji(
channelId: ChannelID,
messageId: MessageID,
emojiName: string,
emojiId?: EmojiID,
): Promise<void>;
abstract countReactionUsers(
channelId: ChannelID,
messageId: MessageID,
emojiName: string,
emojiId?: EmojiID,
): Promise<number>;
abstract countUniqueReactions(channelId: ChannelID, messageId: MessageID): Promise<number>;
abstract checkUserReactionExists(
channelId: ChannelID,
messageId: MessageID,
userId: UserID,
emojiName: string,
emojiId?: EmojiID,
): Promise<boolean>;
abstract setHasReaction(channelId: ChannelID, messageId: MessageID, hasReaction: boolean): Promise<void>;
}
@@ -1,61 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {AttachmentID, ChannelID, MessageID, UserID} from '@app/api/BrandedTypes';
import type {MessageRow} from '@app/api/database/types/MessageTypes';
import type {Message} from '@app/api/models/Message';
export interface ListMessagesOptions {
restrictToBeforeBucket?: boolean;
immediateAfter?: boolean;
}
export interface UpsertMessageOptions {
isInsert?: boolean;
skipParentLastMessageId?: boolean;
}
export abstract class IMessageRepository {
abstract listMessages(
channelId: ChannelID,
beforeMessageId?: MessageID,
limit?: number,
afterMessageId?: MessageID,
options?: ListMessagesOptions,
): Promise<Array<Message>>;
abstract getMessage(channelId: ChannelID, messageId: MessageID): Promise<Message | null>;
abstract upsertMessage(data: MessageRow, oldData?: MessageRow | null, opts?: UpsertMessageOptions): Promise<Message>;
abstract updateEmbeds(message: Message): Promise<void>;
abstract deleteMessage(
channelId: ChannelID,
messageId: MessageID,
authorId: UserID,
pinnedTimestamp?: Date,
): Promise<void>;
abstract bulkDeleteMessages(channelId: ChannelID, messageIds: Array<MessageID>): Promise<void>;
abstract deleteAllChannelMessages(channelId: ChannelID): Promise<void>;
abstract listMessagesByAuthor(
authorId: UserID,
limit?: number,
lastMessageId?: MessageID,
): Promise<
Array<{
channelId: ChannelID;
messageId: MessageID;
}>
>;
abstract anonymizeMessage(channelId: ChannelID, messageId: MessageID, newAuthorId: UserID): Promise<void>;
abstract lookupAttachmentByChannelAndFilename(
channelId: ChannelID,
attachmentId: AttachmentID,
filename: string,
): Promise<MessageID | null>;
}
@@ -1,172 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ChannelID, GuildID, MessageID, UserID} from '@app/api/BrandedTypes';
import type {ChannelRow} from '@app/api/database/types/ChannelTypes';
import type {GuildThreadStateRow, ThreadParentConfigRow, ThreadStateRow} from '@app/api/database/types/ThreadTypes';
import type {MuteConfig} from '@app/api/database/types/UserTypes';
import type {ThreadMember} from '@app/api/models/ThreadMember';
import type {ThreadParentConfig} from '@app/api/models/ThreadParentConfig';
import type {ThreadState} from '@app/api/models/ThreadState';
import type {ThreadStats} from '@app/api/models/ThreadStats';
export interface CreateThreadMember {
userId: UserID;
flags: number;
}
export interface CreateThreadParams {
channel: ChannelRow;
parentType: number;
autoArchiveDuration: number;
invitable: boolean | null;
flags: number;
appliedTags: Array<bigint>;
hasStarter: boolean;
createdAt: Date;
members: Array<CreateThreadMember>;
}
export type ThreadStatePatch = Partial<
Pick<
ThreadStateRow,
'archived' | 'locked' | 'invitable' | 'auto_archive_duration' | 'archive_timestamp' | 'flags' | 'applied_tags'
>
>;
export interface ThreadStateTransition {
previous: ThreadState;
state: ThreadState;
}
export interface ArchivedThreadPage {
threads: Array<ThreadState>;
hasMore: boolean;
}
export interface ThreadMemberSettingsPatch {
flags?: number;
muted?: boolean;
muteConfig?: MuteConfig | null;
}
export interface ThreadMemberAddResult {
added: Array<ThreadMember>;
state: ThreadState;
}
export interface ThreadMemberRemoveResult {
removed: Array<ThreadMember>;
state: ThreadState | null;
}
export type ThreadParentConfigPatch = Partial<Omit<ThreadParentConfigRow, 'guild_id' | 'channel_id'>>;
export abstract class IThreadRepository {
abstract getState(threadId: ChannelID): Promise<ThreadState | null>;
abstract getStates(threadIds: Array<ChannelID>): Promise<Array<ThreadState>>;
abstract getStats(threadId: ChannelID): Promise<ThreadStats>;
abstract getStatsMany(threadIds: Array<ChannelID>): Promise<Map<ChannelID, ThreadStats>>;
abstract adjustMessageCount(threadId: ChannelID, delta: number): Promise<void>;
abstract create(params: CreateThreadParams): Promise<ThreadState>;
abstract updateState(
threadId: ChannelID,
mutate: (current: ThreadState) => ThreadStatePatch | null,
): Promise<ThreadStateTransition | null>;
abstract claimForumPin(parentId: ChannelID, threadId: ChannelID): Promise<boolean>;
abstract releaseForumPin(parentId: ChannelID, threadId: ChannelID): Promise<void>;
abstract getForumPin(parentId: ChannelID): Promise<ChannelID | null>;
abstract countActiveThreads(guildId: GuildID): Promise<number>;
abstract listActiveThreads(guildId: GuildID): Promise<Array<ThreadState>>;
abstract listArchivedThreads(
parentId: ChannelID,
isPrivate: boolean,
opts: {before?: Date; limit: number},
): Promise<ArchivedThreadPage>;
abstract listJoinedPrivateArchivedThreads(
userId: UserID,
guildId: GuildID,
parentId: ChannelID,
opts: {before?: ChannelID; limit: number},
): Promise<ArchivedThreadPage>;
abstract listJoinedThreadIds(userId: UserID, guildId: GuildID): Promise<Array<ChannelID>>;
abstract listJoinedPrivateThreadIds(
userId: UserID,
guildId: GuildID,
parentId: ChannelID,
limit: number,
): Promise<Array<ChannelID>>;
abstract repairThreadIndexes(threadIds: Array<ChannelID>): Promise<void>;
abstract getMember(threadId: ChannelID, userId: UserID): Promise<ThreadMember | null>;
abstract getMembers(threadId: ChannelID, userIds: Array<UserID>): Promise<Array<ThreadMember>>;
abstract listMembers(threadId: ChannelID, opts: {after?: UserID; limit: number}): Promise<Array<ThreadMember>>;
abstract addMembers(
threadId: ChannelID,
members: Array<CreateThreadMember>,
opts?: {joinTimestamp?: Date},
): Promise<ThreadMemberAddResult | null>;
abstract removeMembers(threadId: ChannelID, userIds: Array<UserID>): Promise<ThreadMemberRemoveResult>;
abstract updateMemberSettings(expected: ThreadMember, patch: ThreadMemberSettingsPatch): Promise<ThreadMember | null>;
abstract listThreadIdsByParent(
parentId: ChannelID,
opts: {after?: ChannelID; limit: number},
): Promise<Array<ChannelID>>;
abstract listParentThreads(parentId: ChannelID): Promise<Array<{threadId: ChannelID; type: number}>>;
abstract setThreadType(threadId: ChannelID, type: number): Promise<ThreadState | null>;
abstract listGuildThreadIds(
guildId: GuildID,
opts?: {activeSince?: Date; parents?: ReadonlyArray<{id: ChannelID; type: number}>},
): Promise<Array<ChannelID>>;
abstract purgeThread(threadId: ChannelID): Promise<void>;
abstract revertParentLastMessageId(
parentId: ChannelID,
threadId: ChannelID,
previous: MessageID | null,
): Promise<void>;
abstract purgeGuild(guildId: GuildID): Promise<void>;
abstract getGuildMarker(guildId: GuildID): Promise<GuildThreadStateRow | null>;
abstract ensureGuildMarker(guildId: GuildID, opts?: {permsSeededAt?: Date}): Promise<void>;
abstract markGuildPermsSeeded(guildId: GuildID, at: Date): Promise<void>;
abstract markGuildSearchBackfilled(guildId: GuildID, at: Date): Promise<void>;
abstract clearGuildSearchBackfilled(guildId: GuildID): Promise<void>;
abstract getParentConfig(guildId: GuildID, channelId: ChannelID): Promise<ThreadParentConfig | null>;
abstract listParentConfigs(guildId: GuildID): Promise<Array<ThreadParentConfig>>;
abstract patchParentConfig(guildId: GuildID, channelId: ChannelID, patch: ThreadParentConfigPatch): Promise<void>;
abstract deleteParentConfig(guildId: GuildID, channelId: ChannelID): Promise<void>;
}
@@ -2,7 +2,6 @@
import type {ChannelID, EmojiID, MessageID, UserID} from '@app/api/BrandedTypes';
import {createEmojiID} from '@app/api/BrandedTypes';
import {IMessageInteractionRepository} from '@app/api/channel/repositories/IMessageInteractionRepository';
import type {MessageRepository} from '@app/api/channel/repositories/MessageRepository';
import {deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
import {Db} from '@app/api/database/CassandraTypes';
@@ -66,11 +65,10 @@ const CHECK_USER_REACTION_EXISTS_QUERY = MessageReactions.selectCql({
limit: 1,
});
export class MessageInteractionRepository extends IMessageInteractionRepository {
export class MessageInteractionRepository {
private messageRepository: MessageRepository;
constructor(messageRepository: MessageRepository) {
super();
this.messageRepository = messageRepository;
}
@@ -2,11 +2,6 @@
import type {AttachmentID, ChannelID, MessageID, UserID} from '@app/api/BrandedTypes';
import type {ChannelDataRepository} from '@app/api/channel/repositories/ChannelDataRepository';
import {
IMessageRepository,
type ListMessagesOptions,
type UpsertMessageOptions,
} from '@app/api/channel/repositories/IMessageRepository';
import {MessageAttachmentRepository} from '@app/api/channel/repositories/message/MessageAttachmentRepository';
import {MessageAuthorRepository} from '@app/api/channel/repositories/message/MessageAuthorRepository';
import {MessageDataRepository} from '@app/api/channel/repositories/message/MessageDataRepository';
@@ -14,7 +9,17 @@ import {MessageDeletionRepository} from '@app/api/channel/repositories/message/M
import type {MessageRow} from '@app/api/database/types/MessageTypes';
import type {Message} from '@app/api/models/Message';
export class MessageRepository extends IMessageRepository {
export interface ListMessagesOptions {
restrictToBeforeBucket?: boolean;
immediateAfter?: boolean;
}
export interface UpsertMessageOptions {
isInsert?: boolean;
skipParentLastMessageId?: boolean;
}
export class MessageRepository {
private dataRepo: MessageDataRepository;
private deletionRepo: MessageDeletionRepository;
private attachmentRepo: MessageAttachmentRepository;
@@ -22,7 +27,6 @@ export class MessageRepository extends IMessageRepository {
private channelDataRepo: ChannelDataRepository;
constructor(channelDataRepo: ChannelDataRepository) {
super();
this.dataRepo = new MessageDataRepository();
this.deletionRepo = new MessageDeletionRepository(this.dataRepo);
this.attachmentRepo = new MessageAttachmentRepository();
@@ -13,9 +13,8 @@ import {
createUserID,
type GuildID,
} from '@app/api/BrandedTypes';
import {ChannelRepository} from '@app/api/channel/repositories/ChannelRepository';
import type {CreateThreadParams} from '@app/api/channel/repositories/IThreadRepository';
import {ThreadRepository} from '@app/api/channel/repositories/ThreadRepository';
import {ChannelRepository} from '@app/api/channel/ChannelRepository';
import {type CreateThreadParams, ThreadRepository} from '@app/api/channel/repositories/ThreadRepository';
import {
type CassandraQueryExecutorForTesting,
deleteOneOrMany,
@@ -2,19 +2,7 @@
import {type ChannelID, channelIdToMessageId, type GuildID, type MessageID, type UserID} from '@app/api/BrandedTypes';
import type {ChannelDataRepository} from '@app/api/channel/repositories/ChannelDataRepository';
import type {IMessageRepository} from '@app/api/channel/repositories/IMessageRepository';
import {
type ArchivedThreadPage,
type CreateThreadMember,
type CreateThreadParams,
IThreadRepository,
type ThreadMemberAddResult,
type ThreadMemberRemoveResult,
type ThreadMemberSettingsPatch,
type ThreadParentConfigPatch,
type ThreadStatePatch,
type ThreadStateTransition,
} from '@app/api/channel/repositories/IThreadRepository';
import type {MessageRepository} from '@app/api/channel/repositories/MessageRepository';
import {
BatchBuilder,
deleteOneOrMany,
@@ -37,6 +25,7 @@ import type {
ThreadStatsRow,
ThreadsByParentRow,
} from '@app/api/database/types/ThreadTypes';
import type {MuteConfig} from '@app/api/database/types/UserTypes';
import {insertGuildThreadMarker, isTainted} from '@app/api/experiment/ChannelThreadsGate';
import {Logger} from '@app/api/Logger';
import {ThreadMember} from '@app/api/models/ThreadMember';
@@ -70,6 +59,58 @@ import {ThreadAlreadyCreatedForMessageError} from '@fluxer/errors/src/domains/ch
import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError';
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
export interface CreateThreadMember {
userId: UserID;
flags: number;
}
export interface CreateThreadParams {
channel: ChannelRow;
parentType: number;
autoArchiveDuration: number;
invitable: boolean | null;
flags: number;
appliedTags: Array<bigint>;
hasStarter: boolean;
createdAt: Date;
members: Array<CreateThreadMember>;
}
export type ThreadStatePatch = Partial<
Pick<
ThreadStateRow,
'archived' | 'locked' | 'invitable' | 'auto_archive_duration' | 'archive_timestamp' | 'flags' | 'applied_tags'
>
>;
export interface ThreadStateTransition {
previous: ThreadState;
state: ThreadState;
}
export interface ArchivedThreadPage {
threads: Array<ThreadState>;
hasMore: boolean;
}
export interface ThreadMemberSettingsPatch {
flags?: number;
muted?: boolean;
muteConfig?: MuteConfig | null;
}
export interface ThreadMemberAddResult {
added: Array<ThreadMember>;
state: ThreadState;
}
export interface ThreadMemberRemoveResult {
removed: Array<ThreadMember>;
state: ThreadState | null;
}
export type ThreadParentConfigPatch = Partial<Omit<ThreadParentConfigRow, 'guild_id' | 'channel_id'>>;
const STATE_CAS_ATTEMPTS = 3;
const DANGLING_CREATE_REPAIR_MS = 30_000;
const ENUMERATION_PAGE_SIZE = 1000;
@@ -197,14 +238,12 @@ function toStateOps(patch: ThreadStatePatch): StatePatchOps {
return ops;
}
export class ThreadRepository extends IThreadRepository {
export class ThreadRepository {
constructor(
private readonly channelData: ChannelDataRepository,
private readonly messages: IMessageRepository,
private readonly messages: MessageRepository,
private readonly onIndexDrift?: (threadIds: Array<ChannelID>) => void,
) {
super();
}
) {}
async getState(threadId: ChannelID): Promise<ThreadState | null> {
const row = await fetchOne<ThreadStateRow>(FETCH_STATE.bind({thread_id: threadId}));
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ChannelID, MessageID} from '@app/api/BrandedTypes';
import type {ListMessagesOptions} from '@app/api/channel/repositories/IMessageRepository';
import type {ListMessagesOptions} from '@app/api/channel/repositories/MessageRepository';
import {BucketScanDirection, scanBucketsWithIndex} from '@app/api/channel/repositories/message/BucketScanEngine';
import {BatchBuilder, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
import {Db, type QueryTemplate} from '@app/api/database/CassandraTypes';
@@ -3,7 +3,7 @@
import type {AttachmentID, ChannelID, MessageID, UserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {UploadedAttachment} from '@app/api/channel/AttachmentDTOs';
import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate';
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
import type {
AttachmentUploadMode,
AttachmentUploadTraceRepository,
@@ -33,7 +33,7 @@ import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {Attachment} from '@app/api/models/Attachment';
import type {Channel} from '@app/api/models/Channel';
import type {Message} from '@app/api/models/Message';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
import {mapWithConcurrency} from '@app/api/utils/ConcurrencyUtils';
import {assertGuildMemberCanCommunicate} from '@app/api/utils/GuildCommunicationUtils';
import {Permissions, TEXT_BASED_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants';
@@ -111,8 +111,8 @@ const FORM_DATA_UPLOAD_CONCURRENCY = 2;
export class AttachmentUploadService {
constructor(
private channelRepository: IChannelRepositoryAggregate,
private userRepository: IUserRepository,
private channelRepository: ChannelRepository,
private userRepository: UserRepository,
private storageService: IStorageService,
private attachmentUploadTraceRepository: AttachmentUploadTraceRepository,
private purgeQueue: IPurgeQueue,
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes';
import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate';
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
import type {AuthenticatedChannel, AuthenticatedThread} from '@app/api/channel/services/AuthenticatedChannel';
import {DMPermissionValidator} from '@app/api/channel/services/DMPermissionValidator';
import {
@@ -26,13 +26,13 @@ import {
viewerActive,
} from '@app/api/experiment/ChannelThreadsGate';
import {isGuildMemberTimedOut} from '@app/api/guild/GuildModel';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import {createGuildMfaEnforcer} from '@app/api/guild/services/GuildMfaEnforcement';
import type {GuildChannelAuthContext, IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {Channel} from '@app/api/models/Channel';
import type {GuildMember} from '@app/api/models/GuildMember';
import type {User} from '@app/api/models/User';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
import {canUserAccessNsfwContent} from '@app/api/utils/AgeUtils';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {
@@ -80,9 +80,9 @@ export abstract class BaseChannelAuthService {
protected dmPermissionValidator: DMPermissionValidator;
constructor(
protected channelRepository: IChannelRepositoryAggregate,
protected userRepository: IUserRepository,
protected guildRepository: IGuildRepositoryAggregate,
protected channelRepository: ChannelRepository,
protected userRepository: UserRepository,
protected guildRepository: GuildRepository,
protected gatewayService: IGatewayService,
) {
this.dmPermissionValidator = new DMPermissionValidator({
@@ -2,11 +2,11 @@
import {type ChannelID, createMessageID, type UserID} from '@app/api/BrandedTypes';
import {mapChannelToResponse} from '@app/api/channel/ChannelMappers';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
import {DMPermissionValidator} from '@app/api/channel/services/DMPermissionValidator';
import {buildBroadcastMessageData} from '@app/api/channel/services/message/MessageGatewayDispatch';
import {incrementDmMentionCounts} from '@app/api/channel/services/message/ReadStateHelpers';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import type {CallData, IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
import type {IVoiceRoomStore} from '@app/api/infrastructure/IVoiceRoomStore';
@@ -14,8 +14,8 @@ import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {Channel} from '@app/api/models/Channel';
import type {ReadStateService} from '@app/api/read_state/ReadStateService';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {assertMayStartConversation} from '@app/api/user/NewConversationLimit';
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
import type {VoiceAccessContext, VoiceAvailabilityService} from '@app/api/voice/VoiceAvailabilityService';
import {AUTOMATIC_VOICE_REGION_ID, ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
import {IncomingCallFlags, RelationshipTypes} from '@fluxer/constants/src/UserConstants';
@@ -31,9 +31,9 @@ export class CallService {
private dmPermissionValidator: DMPermissionValidator;
constructor(
private channelRepository: IChannelRepository,
private userRepository: IUserRepository,
private guildRepository: IGuildRepositoryAggregate,
private channelRepository: ChannelRepository,
private userRepository: UserRepository,
private guildRepository: GuildRepository,
private gatewayService: IGatewayService,
private userCacheService: UserCacheService,
private snowflakeService: ISnowflakeService,
@@ -51,7 +51,7 @@ export class CallService {
ringable: boolean;
silent?: boolean;
}> {
const channel = await this.channelRepository.findUnique(channelId);
const channel = await this.channelRepository.channelData.findUnique(channelId);
if (!channel) throw new UnknownChannelError();
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
throw new InvalidChannelTypeForCallError();
@@ -148,7 +148,7 @@ export class CallService {
latitude?: string;
longitude?: string;
}): Promise<CallData> {
const channel = await this.channelRepository.findUnique(channelId);
const channel = await this.channelRepository.channelData.findUnique(channelId);
if (!channel) throw new UnknownChannelError();
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
throw new InvalidChannelTypeForCallError();
@@ -175,7 +175,7 @@ export class CallService {
user: caller,
targetId: dmRecipientIds[0]!,
users: this.userRepository,
messages: this.channelRepository,
messages: this.channelRepository.messages,
channel,
});
}
@@ -191,7 +191,7 @@ export class CallService {
const selectedRegion = region && region !== AUTOMATIC_VOICE_REGION_ID ? region : AUTOMATIC_VOICE_REGION_ID;
const allRecipients = Array.from(new Set([userId, ...Array.from(channel.recipientIds)]));
const messageId = createMessageID(await this.snowflakeService.generateForChannel(channelId));
await this.channelRepository.upsertMessage({
await this.channelRepository.messages.upsertMessage({
channel_id: channelId,
bucket: BucketUtils.makeBucket(messageId),
message_id: messageId,
@@ -258,7 +258,7 @@ export class CallService {
emitGateway: false,
});
}
const message = await this.channelRepository.getMessage(channelId, messageId);
const message = await this.channelRepository.messages.getMessage(channelId, messageId);
if (message) {
const messageResponse = await buildBroadcastMessageData({
channel,
@@ -286,7 +286,7 @@ export class CallService {
latitude?: string;
longitude?: string;
}): Promise<void> {
const channel = await this.channelRepository.findUnique(channelId);
const channel = await this.channelRepository.channelData.findUnique(channelId);
if (!channel) throw new UnknownChannelError();
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
throw new InvalidChannelTypeForCallError();
@@ -326,7 +326,7 @@ export class CallService {
latitude?: string;
longitude?: string;
}): Promise<void> {
const channel = await this.channelRepository.findUnique(channelId);
const channel = await this.channelRepository.channelData.findUnique(channelId);
if (!channel) throw new UnknownChannelError();
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
throw new InvalidChannelTypeForCallError();
@@ -353,7 +353,7 @@ export class CallService {
user: caller,
targetId: dmRecipientIds[0]!,
users: this.userRepository,
messages: this.channelRepository,
messages: this.channelRepository.messages,
channel,
});
}
@@ -472,7 +472,7 @@ export class CallService {
channelId: ChannelID;
recipients?: Array<UserID>;
}): Promise<void> {
const channel = await this.channelRepository.findUnique(channelId);
const channel = await this.channelRepository.channelData.findUnique(channelId);
if (!channel) throw new UnknownChannelError();
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
throw new InvalidChannelTypeForCallError();
@@ -2,7 +2,7 @@
import type {ChannelID, UserID} from '@app/api/BrandedTypes';
import {createUserID} from '@app/api/BrandedTypes';
import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate';
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
import {ChannelAuthService} from '@app/api/channel/services/channel_data/ChannelAuthService';
import type {
ChannelTypeConversion,
@@ -16,7 +16,7 @@ import {ThreadModifyService} from '@app/api/channel/services/thread/ThreadModify
import {pickThreadParentSettings} from '@app/api/channel/services/thread/ThreadParentSettings';
import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate';
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import type {AvatarService} from '@app/api/infrastructure/AvatarService';
import type {IPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
@@ -25,13 +25,13 @@ import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService'
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
import type {IVoiceRoomStore} from '@app/api/infrastructure/IVoiceRoomStore';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import type {IInviteRepository} from '@app/api/invite/IInviteRepository';
import type {InviteRepository} from '@app/api/invite/InviteRepository';
import type {LimitConfigService} from '@app/api/limits/LimitConfigService';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {Channel} from '@app/api/models/Channel';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
import type {VoiceAvailabilityService} from '@app/api/voice/VoiceAvailabilityService';
import type {IWebhookRepository} from '@app/api/webhook/IWebhookRepository';
import type {WebhookRepository} from '@app/api/webhook/WebhookRepository';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import type {
ChannelUpdateGatedRequest,
@@ -57,9 +57,9 @@ export class ChannelDataService {
public readonly threadModify: ThreadModifyService;
constructor(
channelRepository: IChannelRepositoryAggregate,
userRepository: IUserRepository,
guildRepository: IGuildRepositoryAggregate,
channelRepository: ChannelRepository,
userRepository: UserRepository,
guildRepository: GuildRepository,
userCacheService: UserCacheService,
storageService: IStorageService,
gatewayService: IGatewayService,
@@ -71,8 +71,8 @@ export class ChannelDataService {
voiceAvailabilityService: VoiceAvailabilityService | null,
messagePersistenceService: MessagePersistenceService,
guildAuditLogService: GuildAuditLogService,
inviteRepository: IInviteRepository,
webhookRepository: IWebhookRepository,
inviteRepository: InviteRepository,
webhookRepository: WebhookRepository,
limitConfigService: LimitConfigService,
rateLimitService: IRateLimitService,
cacheService: ICacheService,
Loaded 100 of 426 files, more files were not shown because too many files have changed in this diff. Show more