refactor progress
This commit is contained in:
400
packages/api/src/guild/GuildAuditLogService.tsx
Normal file
400
packages/api/src/guild/GuildAuditLogService.tsx
Normal file
@@ -0,0 +1,400 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {GuildAuditLogRow} from '@fluxer/api/src/database/types/GuildTypes';
|
||||
import type {AuditLogChange, GuildAuditLogChange} from '@fluxer/api/src/guild/GuildAuditLogTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {GuildAuditLog} from '@fluxer/api/src/models/GuildAuditLog';
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import type {IWorkerService} from '@fluxer/worker/src/contracts/IWorkerService';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
interface MessageDeleteBatchGroup {
|
||||
logs: Array<GuildAuditLog>;
|
||||
userId: UserID;
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
interface BatchResult {
|
||||
processedLogs: Array<GuildAuditLog>;
|
||||
deletedLogIds: Array<bigint>;
|
||||
createdLogs: Array<GuildAuditLog>;
|
||||
}
|
||||
|
||||
interface CreateGuildAuditLogParams {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
actionType: AuditLogActionType;
|
||||
targetId?: string | null;
|
||||
auditLogReason?: string | null;
|
||||
metadata?: Map<string, string> | Record<string, string> | Array<[string, string]>;
|
||||
changes?: GuildAuditLogChange | null;
|
||||
createdAt?: Date;
|
||||
}
|
||||
|
||||
function normalizeAuditLogMetadata(metadata?: CreateGuildAuditLogParams['metadata']): Map<string, string> {
|
||||
if (!metadata) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
if (metadata instanceof Map) {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
if (Array.isArray(metadata)) {
|
||||
return new Map(metadata);
|
||||
}
|
||||
|
||||
return new Map(Object.entries(metadata));
|
||||
}
|
||||
|
||||
export class GuildAuditLogService {
|
||||
constructor(
|
||||
private readonly guildRepository: IGuildRepositoryAggregate,
|
||||
private readonly snowflakeService: SnowflakeService,
|
||||
private readonly workerService?: IWorkerService,
|
||||
) {}
|
||||
|
||||
async createLog(params: CreateGuildAuditLogParams): Promise<GuildAuditLog> {
|
||||
const logId = await this.snowflakeService.generate();
|
||||
const metadataMap = normalizeAuditLogMetadata(params.metadata);
|
||||
|
||||
const row: GuildAuditLogRow = {
|
||||
guild_id: params.guildId,
|
||||
log_id: logId,
|
||||
user_id: params.userId,
|
||||
target_id: params.targetId ?? null,
|
||||
action_type: params.actionType,
|
||||
reason: params.auditLogReason ?? null,
|
||||
options: metadataMap.size > 0 ? metadataMap : null,
|
||||
changes: params.changes ? JSON.stringify(params.changes) : null,
|
||||
};
|
||||
|
||||
const log = await this.guildRepository.createAuditLog(row);
|
||||
|
||||
if (params.actionType === AuditLogActionType.MESSAGE_DELETE && this.workerService) {
|
||||
await this.scheduleMessageDeleteBatchJob(params.guildId);
|
||||
}
|
||||
|
||||
return log;
|
||||
}
|
||||
|
||||
async scheduleMessageDeleteBatchJob(guildId: GuildID): Promise<void> {
|
||||
if (!this.workerService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const runAt = new Date(Date.now() + ms('30 seconds'));
|
||||
await this.workerService.addJob(
|
||||
'batchGuildAuditLogMessageDeletes',
|
||||
{guildId: guildId.toString()},
|
||||
{
|
||||
jobKey: `batch-audit-log-message-deletes:${guildId}`,
|
||||
runAt,
|
||||
maxAttempts: 3,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async batchConsecutiveMessageDeleteLogs(guildId: GuildID, logs: Array<GuildAuditLog>): Promise<BatchResult> {
|
||||
const groups = this.findConsecutiveMessageDeleteGroups(logs);
|
||||
const deletedLogIds: Array<bigint> = [];
|
||||
const createdLogs: Array<GuildAuditLog> = [];
|
||||
const processedLogs: Array<GuildAuditLog> = [];
|
||||
|
||||
for (const log of logs) {
|
||||
const group = groups.find((g) => g.logs.includes(log));
|
||||
|
||||
if (group && group.logs.length >= 2) {
|
||||
if (log === group.logs[0]) {
|
||||
const newestLogId = group.logs.reduce((max, l) => (l.logId > max ? l.logId : max), group.logs[0].logId);
|
||||
|
||||
for (const groupLog of group.logs) {
|
||||
deletedLogIds.push(groupLog.logId);
|
||||
}
|
||||
|
||||
const batchedLog = await this.createBatchedMessageDeleteLog(guildId, group, newestLogId);
|
||||
createdLogs.push(batchedLog);
|
||||
processedLogs.push(batchedLog);
|
||||
}
|
||||
} else {
|
||||
processedLogs.push(log);
|
||||
}
|
||||
}
|
||||
|
||||
return {processedLogs, deletedLogIds, createdLogs};
|
||||
}
|
||||
|
||||
async batchRecentMessageDeleteLogs(guildId: GuildID, limit: number = 250): Promise<BatchResult> {
|
||||
const logs = await this.guildRepository.listAuditLogs({
|
||||
guildId,
|
||||
limit,
|
||||
actionType: AuditLogActionType.MESSAGE_DELETE,
|
||||
});
|
||||
|
||||
if (logs.length < 2) {
|
||||
return {processedLogs: logs, deletedLogIds: [], createdLogs: []};
|
||||
}
|
||||
|
||||
const allLogs = await this.guildRepository.listAuditLogs({
|
||||
guildId,
|
||||
limit,
|
||||
});
|
||||
|
||||
return this.batchConsecutiveMessageDeleteLogs(guildId, allLogs);
|
||||
}
|
||||
|
||||
private findConsecutiveMessageDeleteGroups(logs: Array<GuildAuditLog>): Array<MessageDeleteBatchGroup> {
|
||||
const groups: Array<MessageDeleteBatchGroup> = [];
|
||||
let currentGroup: MessageDeleteBatchGroup | null = null;
|
||||
|
||||
for (const log of logs) {
|
||||
if (log.actionType !== AuditLogActionType.MESSAGE_DELETE) {
|
||||
if (currentGroup && currentGroup.logs.length >= 2) {
|
||||
groups.push(currentGroup);
|
||||
}
|
||||
currentGroup = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const channelId = log.options.get('channel_id');
|
||||
if (!channelId) {
|
||||
if (currentGroup && currentGroup.logs.length >= 2) {
|
||||
groups.push(currentGroup);
|
||||
}
|
||||
currentGroup = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentGroup && currentGroup.userId === log.userId && currentGroup.channelId === channelId) {
|
||||
currentGroup.logs.push(log);
|
||||
} else {
|
||||
if (currentGroup && currentGroup.logs.length >= 2) {
|
||||
groups.push(currentGroup);
|
||||
}
|
||||
currentGroup = {
|
||||
logs: [log],
|
||||
userId: log.userId,
|
||||
channelId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (currentGroup && currentGroup.logs.length >= 2) {
|
||||
groups.push(currentGroup);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
private async createBatchedMessageDeleteLog(
|
||||
guildId: GuildID,
|
||||
group: MessageDeleteBatchGroup,
|
||||
logId: bigint,
|
||||
): Promise<GuildAuditLog> {
|
||||
const row: GuildAuditLogRow = {
|
||||
guild_id: guildId,
|
||||
log_id: logId,
|
||||
user_id: group.userId,
|
||||
target_id: null,
|
||||
action_type: AuditLogActionType.MESSAGE_BULK_DELETE,
|
||||
reason: null,
|
||||
options: new Map([
|
||||
['channel_id', group.channelId],
|
||||
['count', group.logs.length.toString()],
|
||||
]),
|
||||
changes: null,
|
||||
};
|
||||
|
||||
return this.guildRepository.batchDeleteAndCreateAuditLogs(guildId, group.logs, row);
|
||||
}
|
||||
|
||||
createBuilder(guildId: GuildID, userId: UserID): GuildAuditLogBuilder {
|
||||
return new GuildAuditLogBuilder(this, guildId, userId);
|
||||
}
|
||||
|
||||
computeChanges(
|
||||
previous: Record<string, unknown> | null | undefined,
|
||||
next: Record<string, unknown> | null | undefined,
|
||||
): GuildAuditLogChange {
|
||||
const changes: Array<AuditLogChange> = [];
|
||||
|
||||
if (!previous && next) {
|
||||
for (const [key, value] of Object.entries(next)) {
|
||||
changes.push({key, new_value: value});
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
if (previous && !next) {
|
||||
for (const [key, value] of Object.entries(previous)) {
|
||||
changes.push({key, old_value: value});
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
if (previous && next) {
|
||||
const allKeys = new Set([...Object.keys(previous), ...Object.keys(next)]);
|
||||
|
||||
for (const key of allKeys) {
|
||||
const oldValue = previous[key];
|
||||
const newValue = next[key];
|
||||
|
||||
if (this.areValuesEqual(oldValue, newValue)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const change: AuditLogChange = {key};
|
||||
|
||||
if (oldValue === undefined && newValue !== undefined) {
|
||||
change.new_value = newValue;
|
||||
} else if (oldValue !== undefined && newValue === undefined) {
|
||||
change.old_value = oldValue;
|
||||
} else {
|
||||
change.old_value = oldValue;
|
||||
change.new_value = newValue;
|
||||
}
|
||||
|
||||
changes.push(change);
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
computeArrayChange<T>(
|
||||
previous: Array<T> | null | undefined,
|
||||
next: Array<T> | null | undefined,
|
||||
key: string,
|
||||
): AuditLogChange | null {
|
||||
if (!previous?.length && !next?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.areArraysEqual(previous, next)) {
|
||||
const change: AuditLogChange = {key};
|
||||
|
||||
if (previous !== undefined && previous !== null) {
|
||||
change.old_value = previous;
|
||||
}
|
||||
if (next !== undefined && next !== null) {
|
||||
change.new_value = next;
|
||||
}
|
||||
|
||||
return change;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private areValuesEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
|
||||
if (a == null || b == null) return false;
|
||||
|
||||
if (typeof a !== typeof b) return false;
|
||||
|
||||
if (typeof a === 'object' && typeof b === 'object') {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private areArraysEqual(a: Array<unknown> | null | undefined, b: Array<unknown> | null | undefined): boolean {
|
||||
if (a === b) return true;
|
||||
if (!a || !b) return false;
|
||||
if (a.length !== b.length) return false;
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
}
|
||||
|
||||
export class GuildAuditLogBuilder {
|
||||
private readonly params: Partial<CreateGuildAuditLogParams>;
|
||||
private metadataMap: Map<string, string> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly service: GuildAuditLogService,
|
||||
guildId: GuildID,
|
||||
userId: UserID,
|
||||
) {
|
||||
this.params = {guildId, userId};
|
||||
}
|
||||
|
||||
withAction(actionType: AuditLogActionType, targetId?: string | null): this {
|
||||
this.params.actionType = actionType;
|
||||
this.params.targetId = targetId ?? null;
|
||||
return this;
|
||||
}
|
||||
|
||||
withReason(reason?: string | null): this {
|
||||
this.params.auditLogReason = reason ?? null;
|
||||
return this;
|
||||
}
|
||||
|
||||
withMetadata(metadata?: CreateGuildAuditLogParams['metadata']): this {
|
||||
this.metadataMap = normalizeAuditLogMetadata(metadata);
|
||||
return this;
|
||||
}
|
||||
|
||||
withMetadataEntry(key: string, value: string): this {
|
||||
if (!this.metadataMap) {
|
||||
this.metadataMap = new Map();
|
||||
}
|
||||
this.metadataMap.set(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
withChanges(changes: GuildAuditLogChange | null): this {
|
||||
this.params.changes = changes;
|
||||
return this;
|
||||
}
|
||||
|
||||
withComputedChanges(
|
||||
previous: Record<string, unknown> | null | undefined,
|
||||
next: Record<string, unknown> | null | undefined,
|
||||
): this {
|
||||
this.params.changes = this.service.computeChanges(previous, next);
|
||||
return this;
|
||||
}
|
||||
|
||||
withCreatedAt(createdAt?: Date): this {
|
||||
this.params.createdAt = createdAt;
|
||||
return this;
|
||||
}
|
||||
|
||||
async commit(): Promise<GuildAuditLog> {
|
||||
if (this.params.actionType === undefined) {
|
||||
throw new Error('Audit log action type must be set before committing');
|
||||
}
|
||||
|
||||
return this.service.createLog({
|
||||
guildId: this.params.guildId!,
|
||||
userId: this.params.userId!,
|
||||
actionType: this.params.actionType,
|
||||
targetId: this.params.targetId ?? null,
|
||||
auditLogReason: this.params.auditLogReason ?? null,
|
||||
metadata: this.metadataMap ?? undefined,
|
||||
changes: this.params.changes ?? null,
|
||||
createdAt: this.params.createdAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
26
packages/api/src/guild/GuildAuditLogTypes.tsx
Normal file
26
packages/api/src/guild/GuildAuditLogTypes.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface AuditLogChange<K extends string = string, D = unknown> {
|
||||
key: K;
|
||||
old_value?: D;
|
||||
new_value?: D;
|
||||
}
|
||||
|
||||
export type GuildAuditLogChange = Array<AuditLogChange>;
|
||||
25
packages/api/src/guild/GuildController.tsx
Normal file
25
packages/api/src/guild/GuildController.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {registerGuildControllers} from '@fluxer/api/src/guild/controllers';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
|
||||
export function GuildController(app: HonoApp) {
|
||||
registerGuildControllers(app);
|
||||
}
|
||||
24
packages/api/src/guild/GuildFeatureUtils.tsx
Normal file
24
packages/api/src/guild/GuildFeatureUtils.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildFeature} from '@fluxer/constants/src/GuildConstants';
|
||||
|
||||
export function mapGuildFeatures(features: ReadonlySet<string>): Array<GuildFeature> {
|
||||
return Array.from(features) as Array<GuildFeature>;
|
||||
}
|
||||
71
packages/api/src/guild/GuildMemberLimitUtils.tsx
Normal file
71
packages/api/src/guild/GuildMemberLimitUtils.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {MAX_GUILD_MEMBERS, MAX_GUILD_MEMBERS_VERY_LARGE_GUILD} from '@fluxer/constants/src/LimitConstants';
|
||||
import {DEFAULT_FREE_LIMITS} from '@fluxer/limits/src/LimitDefaults';
|
||||
import {resolveLimit} from '@fluxer/limits/src/LimitResolver';
|
||||
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
|
||||
|
||||
function toGuildFeatureSet(guildFeatures: Iterable<string> | null | undefined): Set<string> {
|
||||
const featureSet = new Set<string>();
|
||||
if (!guildFeatures) {
|
||||
return featureSet;
|
||||
}
|
||||
for (const feature of guildFeatures) {
|
||||
if (feature) {
|
||||
featureSet.add(feature);
|
||||
}
|
||||
}
|
||||
return featureSet;
|
||||
}
|
||||
|
||||
export function resolveDefaultMaxGuildMembers(guildFeatures: Iterable<string> | null | undefined): number {
|
||||
const featureSet = toGuildFeatureSet(guildFeatures);
|
||||
if (featureSet.has(GuildFeatures.VERY_LARGE_GUILD)) {
|
||||
return MAX_GUILD_MEMBERS_VERY_LARGE_GUILD;
|
||||
}
|
||||
return MAX_GUILD_MEMBERS;
|
||||
}
|
||||
|
||||
export function resolveMaxGuildMembersLimit(params: {
|
||||
guildFeatures: Iterable<string> | null | undefined;
|
||||
snapshot: LimitConfigSnapshot | null | undefined;
|
||||
}): number {
|
||||
const featureSet = toGuildFeatureSet(params.guildFeatures);
|
||||
const defaultLimit = resolveDefaultMaxGuildMembers(featureSet);
|
||||
|
||||
if (!params.snapshot) {
|
||||
return defaultLimit;
|
||||
}
|
||||
|
||||
const ctx = createLimitMatchContext({guildFeatures: featureSet});
|
||||
const resolved = resolveLimit(params.snapshot, ctx, 'max_guild_members', {
|
||||
evaluationContext: 'guild',
|
||||
baseLimits: {
|
||||
...DEFAULT_FREE_LIMITS,
|
||||
max_guild_members: defaultLimit,
|
||||
},
|
||||
});
|
||||
if (!Number.isFinite(resolved) || resolved < 0) {
|
||||
return defaultLimit;
|
||||
}
|
||||
return Math.floor(resolved);
|
||||
}
|
||||
240
packages/api/src/guild/GuildModel.tsx
Normal file
240
packages/api/src/guild/GuildModel.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {mapGuildFeatures} from '@fluxer/api/src/guild/GuildFeatureUtils';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Guild} from '@fluxer/api/src/models/Guild';
|
||||
import type {GuildBan} from '@fluxer/api/src/models/GuildBan';
|
||||
import type {GuildEmoji} from '@fluxer/api/src/models/GuildEmoji';
|
||||
import type {GuildMember} from '@fluxer/api/src/models/GuildMember';
|
||||
import type {GuildRole} from '@fluxer/api/src/models/GuildRole';
|
||||
import type {GuildSticker} from '@fluxer/api/src/models/GuildSticker';
|
||||
import {getCachedUserPartialResponse, getCachedUserPartialResponses} from '@fluxer/api/src/user/UserCacheHelpers';
|
||||
import type {
|
||||
GuildEmojiResponse,
|
||||
GuildEmojiWithUserResponse,
|
||||
GuildStickerResponse,
|
||||
GuildStickerWithUserResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
|
||||
import type {GuildBanResponse, GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import type {GuildPartialResponse, GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {GuildRoleResponse} from '@fluxer/schema/src/domains/guild/GuildRoleSchemas';
|
||||
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import type {z} from 'zod';
|
||||
|
||||
export function mapGuildToPartialResponse(guild: Guild): z.infer<typeof GuildPartialResponse> {
|
||||
return {
|
||||
id: guild.id.toString(),
|
||||
name: guild.name,
|
||||
icon: guild.iconHash,
|
||||
banner: guild.bannerHash,
|
||||
banner_width: guild.bannerWidth,
|
||||
banner_height: guild.bannerHeight,
|
||||
splash: guild.splashHash,
|
||||
splash_width: guild.splashWidth,
|
||||
splash_height: guild.splashHeight,
|
||||
embed_splash: guild.embedSplashHash,
|
||||
embed_splash_width: guild.embedSplashWidth,
|
||||
embed_splash_height: guild.embedSplashHeight,
|
||||
splash_card_alignment: guild.splashCardAlignment,
|
||||
features: mapGuildFeatures(guild.features),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapGuildToGuildResponse(
|
||||
guild: Guild,
|
||||
options?: {permissions?: bigint | null},
|
||||
): z.infer<typeof GuildResponse> {
|
||||
return {
|
||||
id: guild.id.toString(),
|
||||
name: guild.name,
|
||||
icon: guild.iconHash,
|
||||
banner: guild.bannerHash,
|
||||
banner_width: guild.bannerWidth,
|
||||
banner_height: guild.bannerHeight,
|
||||
splash: guild.splashHash,
|
||||
splash_width: guild.splashWidth,
|
||||
splash_height: guild.splashHeight,
|
||||
embed_splash: guild.embedSplashHash,
|
||||
embed_splash_width: guild.embedSplashWidth,
|
||||
embed_splash_height: guild.embedSplashHeight,
|
||||
splash_card_alignment: guild.splashCardAlignment,
|
||||
vanity_url_code: guild.vanityUrlCode,
|
||||
owner_id: guild.ownerId.toString(),
|
||||
system_channel_id: guild.systemChannelId ? guild.systemChannelId.toString() : null,
|
||||
system_channel_flags: guild.systemChannelFlags,
|
||||
rules_channel_id: guild.rulesChannelId ? guild.rulesChannelId.toString() : null,
|
||||
afk_channel_id: guild.afkChannelId ? guild.afkChannelId.toString() : null,
|
||||
afk_timeout: guild.afkTimeout,
|
||||
features: mapGuildFeatures(guild.features),
|
||||
verification_level: guild.verificationLevel,
|
||||
mfa_level: guild.mfaLevel,
|
||||
nsfw_level: guild.nsfwLevel,
|
||||
explicit_content_filter: guild.explicitContentFilter,
|
||||
default_message_notifications: guild.defaultMessageNotifications,
|
||||
disabled_operations: guild.disabledOperations,
|
||||
message_history_cutoff: guild.messageHistoryCutoff ? guild.messageHistoryCutoff.toISOString() : null,
|
||||
permissions: options?.permissions != null ? options.permissions.toString() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapGuildRoleToResponse(role: GuildRole): z.infer<typeof GuildRoleResponse> {
|
||||
return {
|
||||
id: role.id.toString(),
|
||||
name: role.name,
|
||||
color: role.color,
|
||||
position: role.position,
|
||||
hoist_position: role.hoistPosition,
|
||||
permissions: role.permissions.toString(),
|
||||
hoist: role.isHoisted,
|
||||
mentionable: role.isMentionable,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapGuildEmojiToResponse(emoji: GuildEmoji): z.infer<typeof GuildEmojiResponse> {
|
||||
return {
|
||||
id: emoji.id.toString(),
|
||||
name: emoji.name,
|
||||
animated: emoji.isAnimated,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapGuildStickerToResponse(sticker: GuildSticker): z.infer<typeof GuildStickerResponse> {
|
||||
return {
|
||||
id: sticker.id.toString(),
|
||||
name: sticker.name,
|
||||
description: sticker.description ?? '',
|
||||
tags: sticker.tags,
|
||||
animated: sticker.animated,
|
||||
};
|
||||
}
|
||||
|
||||
function mapMemberWithUser(
|
||||
member: GuildMember,
|
||||
userPartial: z.infer<typeof UserPartialResponse>,
|
||||
): z.infer<typeof GuildMemberResponse> {
|
||||
const now = Date.now();
|
||||
const isTimedOut = member.communicationDisabledUntil != null && member.communicationDisabledUntil.getTime() > now;
|
||||
return {
|
||||
user: userPartial,
|
||||
nick: member.nickname,
|
||||
avatar: member.isPremiumSanitized ? null : member.avatarHash,
|
||||
banner: member.isPremiumSanitized ? null : member.bannerHash,
|
||||
accent_color: member.accentColor,
|
||||
roles: Array.from(member.roleIds).map((id) => id.toString()),
|
||||
joined_at: member.joinedAt.toISOString(),
|
||||
mute: isTimedOut ? true : member.isMute,
|
||||
deaf: member.isDeaf,
|
||||
communication_disabled_until: member.communicationDisabledUntil?.toISOString() ?? null,
|
||||
profile_flags: member.profileFlags || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function isGuildMemberTimedOut(member?: z.infer<typeof GuildMemberResponse> | null): boolean {
|
||||
if (!member?.communication_disabled_until) {
|
||||
return false;
|
||||
}
|
||||
const timestamp = Date.parse(member.communication_disabled_until);
|
||||
return !Number.isNaN(timestamp) && timestamp > Date.now();
|
||||
}
|
||||
|
||||
export async function mapGuildMemberToResponse(
|
||||
member: GuildMember,
|
||||
userCacheService: UserCacheService,
|
||||
requestCache: RequestCache,
|
||||
): Promise<z.infer<typeof GuildMemberResponse>> {
|
||||
const userPartial = await getCachedUserPartialResponse({userId: member.userId, userCacheService, requestCache});
|
||||
return mapMemberWithUser(member, userPartial);
|
||||
}
|
||||
|
||||
function mapEmojiWithUser(
|
||||
emoji: GuildEmoji,
|
||||
userPartial: z.infer<typeof UserPartialResponse>,
|
||||
): z.infer<typeof GuildEmojiWithUserResponse> {
|
||||
return {
|
||||
id: emoji.id.toString(),
|
||||
name: emoji.name,
|
||||
animated: emoji.isAnimated,
|
||||
user: userPartial,
|
||||
};
|
||||
}
|
||||
|
||||
export async function mapGuildEmojisWithUsersToResponse(
|
||||
emojis: Array<GuildEmoji>,
|
||||
userCacheService: UserCacheService,
|
||||
requestCache: RequestCache,
|
||||
): Promise<Array<z.infer<typeof GuildEmojiWithUserResponse>>> {
|
||||
const userIds = [...new Set(emojis.map((emoji) => emoji.creatorId))];
|
||||
const userPartials = await getCachedUserPartialResponses({userIds, userCacheService, requestCache});
|
||||
return emojis
|
||||
.filter((emoji) => userPartials.has(emoji.creatorId))
|
||||
.map((emoji) => mapEmojiWithUser(emoji, userPartials.get(emoji.creatorId)!));
|
||||
}
|
||||
|
||||
function mapStickerWithUser(
|
||||
sticker: GuildSticker,
|
||||
userPartial: z.infer<typeof UserPartialResponse>,
|
||||
): z.infer<typeof GuildStickerWithUserResponse> {
|
||||
return {
|
||||
id: sticker.id.toString(),
|
||||
name: sticker.name,
|
||||
description: sticker.description ?? '',
|
||||
tags: sticker.tags,
|
||||
animated: sticker.animated,
|
||||
user: userPartial,
|
||||
};
|
||||
}
|
||||
|
||||
export async function mapGuildStickersWithUsersToResponse(
|
||||
stickers: Array<GuildSticker>,
|
||||
userCacheService: UserCacheService,
|
||||
requestCache: RequestCache,
|
||||
): Promise<Array<z.infer<typeof GuildStickerWithUserResponse>>> {
|
||||
const userIds = [...new Set(stickers.map((sticker) => sticker.creatorId))];
|
||||
const userPartials = await getCachedUserPartialResponses({userIds, userCacheService, requestCache});
|
||||
return stickers
|
||||
.filter((sticker) => userPartials.has(sticker.creatorId))
|
||||
.map((sticker) => mapStickerWithUser(sticker, userPartials.get(sticker.creatorId)!));
|
||||
}
|
||||
|
||||
function mapBanWithUser(
|
||||
ban: GuildBan,
|
||||
userPartial: z.infer<typeof UserPartialResponse>,
|
||||
): z.infer<typeof GuildBanResponse> {
|
||||
return {
|
||||
user: userPartial,
|
||||
reason: ban.reason,
|
||||
moderator_id: ban.moderatorId.toString(),
|
||||
banned_at: ban.bannedAt.toISOString(),
|
||||
expires_at: ban.expiresAt ? ban.expiresAt.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function mapGuildBansToResponse(
|
||||
bans: Array<GuildBan>,
|
||||
userCacheService: UserCacheService,
|
||||
requestCache: RequestCache,
|
||||
): Promise<Array<z.infer<typeof GuildBanResponse>>> {
|
||||
const userIds = [...new Set(bans.map((ban) => ban.userId))];
|
||||
const userPartials = await getCachedUserPartialResponses({userIds, userCacheService, requestCache});
|
||||
return bans
|
||||
.filter((ban) => userPartials.has(ban.userId))
|
||||
.map((ban) => mapBanWithUser(ban, userPartials.get(ban.userId)!));
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createGuildID, createUserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {DefaultUserOnly, LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {GuildAuditLogListQuery, GuildAuditLogListResponse} from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas';
|
||||
|
||||
export function GuildAuditLogController(app: HonoApp) {
|
||||
app.get(
|
||||
'/guilds/:guild_id/audit-logs',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_AUDIT_LOGS),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('query', GuildAuditLogListQuery),
|
||||
OpenAPI({
|
||||
operationId: 'list_guild_audit_logs',
|
||||
summary: 'List guild audit logs',
|
||||
responseSchema: GuildAuditLogListResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description:
|
||||
'List guild audit logs. Only default users can access. Requires view_audit_logs permission. Returns guild activity history with pagination and action filtering.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const query = ctx.req.valid('query');
|
||||
|
||||
const requestCache = ctx.get('requestCache');
|
||||
const response = await ctx.get('guildService').listGuildAuditLogs({
|
||||
userId,
|
||||
guildId,
|
||||
requestCache,
|
||||
limit: query.limit ?? undefined,
|
||||
beforeLogId: query.before ?? undefined,
|
||||
afterLogId: query.after ?? undefined,
|
||||
filterUserId: query.user_id ? createUserID(query.user_id) : undefined,
|
||||
actionType: query.action_type,
|
||||
});
|
||||
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
}
|
||||
305
packages/api/src/guild/controllers/GuildBaseController.tsx
Normal file
305
packages/api/src/guild/controllers/GuildBaseController.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {requireSudoMode} from '@fluxer/api/src/auth/services/SudoVerificationService';
|
||||
import {createGuildID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {DefaultUserOnly, LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
|
||||
import {requireOAuth2ScopeForBearer} from '@fluxer/api/src/middleware/OAuth2ScopeMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {SudoModeMiddleware} from '@fluxer/api/src/middleware/SudoModeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {EnabledToggleRequest, GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {
|
||||
GuildCreateRequest,
|
||||
GuildDeleteRequest,
|
||||
GuildListQuery,
|
||||
GuildUpdateRequest,
|
||||
GuildVanityURLUpdateRequest,
|
||||
GuildVanityURLUpdateResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
|
||||
import {GuildResponse, GuildVanityURLResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {z} from 'zod';
|
||||
|
||||
export function GuildBaseController(app: HonoApp) {
|
||||
app.post(
|
||||
'/guilds',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_CREATE),
|
||||
Validator('json', GuildCreateRequest),
|
||||
LoginRequired,
|
||||
OpenAPI({
|
||||
operationId: 'create_guild',
|
||||
summary: 'Create guild',
|
||||
description: 'Only authenticated users can create guilds.',
|
||||
responseSchema: GuildResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const data = ctx.req.valid('json');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
return ctx.json(await ctx.get('guildService').createGuild({user, data}, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/guilds',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_LIST),
|
||||
requireOAuth2ScopeForBearer('guilds'),
|
||||
LoginRequired,
|
||||
Validator('query', GuildListQuery),
|
||||
OpenAPI({
|
||||
operationId: 'list_guilds',
|
||||
summary: 'List current user guilds',
|
||||
description: 'Requires guilds OAuth scope if using bearer token. Returns all guilds the user is a member of.',
|
||||
responseSchema: z.array(GuildResponse),
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const {before, after, limit, with_counts} = ctx.req.valid('query');
|
||||
return ctx.json(
|
||||
await ctx.get('guildService').getUserGuilds(userId, {
|
||||
before: before != null ? createGuildID(before) : undefined,
|
||||
after: after != null ? createGuildID(after) : undefined,
|
||||
limit,
|
||||
withCounts: with_counts,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/guilds/:guild_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_LEAVE),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'leave_guild',
|
||||
summary: 'Leave guild',
|
||||
description: 'Removes the current user from the specified guild membership.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
await ctx.get('guildService').leaveGuild({userId, guildId}, auditLogReason);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/guilds/:guild_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_GET),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_guild',
|
||||
summary: 'Get guild information',
|
||||
description: 'User must be a member of the guild to access this endpoint.',
|
||||
responseSchema: GuildResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
return ctx.json(await ctx.get('guildService').getGuild({userId, guildId}));
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/guilds/:guild_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_UPDATE),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
SudoModeMiddleware,
|
||||
Validator('json', GuildUpdateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_guild',
|
||||
summary: 'Update guild settings',
|
||||
description:
|
||||
'Requires manage_guild permission. Updates guild name, description, icon, banner, and other configuration options.',
|
||||
responseSchema: GuildResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const data = ctx.req.valid('json');
|
||||
if (data.mfa_level !== undefined) {
|
||||
const user = ctx.get('user');
|
||||
await requireSudoMode(ctx, user, data, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
}
|
||||
const requestCache = ctx.get('requestCache');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
return ctx.json(await ctx.get('guildService').updateGuild({userId, guildId, data, requestCache}, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/guilds/:guild_id/delete',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_DELETE),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
SudoModeMiddleware,
|
||||
Validator('json', GuildDeleteRequest),
|
||||
OpenAPI({
|
||||
operationId: 'delete_guild',
|
||||
summary: 'Delete guild',
|
||||
description:
|
||||
'Only guild owner can delete. Requires sudo mode verification (MFA). Permanently deletes the guild and all associated data.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const body = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
await ctx.get('guildService').deleteGuild({user, guildId}, auditLogReason);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/guilds/:guild_id/vanity-url',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_VANITY_URL_GET),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_guild_vanity_url',
|
||||
summary: 'Get guild vanity URL',
|
||||
description: 'Returns the custom invite code for the guild if configured.',
|
||||
responseSchema: GuildVanityURLResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
return ctx.json(await ctx.get('guildService').getVanityURL({userId, guildId}));
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/guilds/:guild_id/vanity-url',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_VANITY_URL_PATCH),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('json', GuildVanityURLUpdateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_guild_vanity_url',
|
||||
summary: 'Update guild vanity URL',
|
||||
description:
|
||||
'Only default users can set vanity URLs. Requires manage_guild permission. Sets or removes a custom invite code.',
|
||||
responseSchema: GuildVanityURLUpdateResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const {code} = ctx.req.valid('json');
|
||||
const requestCache = ctx.get('requestCache');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
const {code: newCode} = await ctx
|
||||
.get('guildService')
|
||||
.updateVanityURL({userId, guildId, code: code ?? null, requestCache}, auditLogReason);
|
||||
return ctx.json({code: newCode});
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/guilds/:guild_id/text-channel-flexible-names',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_UPDATE),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('json', EnabledToggleRequest),
|
||||
OpenAPI({
|
||||
operationId: 'toggle_text_channel_flexible_names',
|
||||
summary: 'Toggle text channel flexible names',
|
||||
description: 'Requires manage_guild permission. Allows or disables flexible naming for text channels.',
|
||||
responseSchema: GuildResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const {enabled} = ctx.req.valid('json');
|
||||
const requestCache = ctx.get('requestCache');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
return ctx.json(
|
||||
await ctx
|
||||
.get('guildService')
|
||||
.updateTextChannelFlexibleNamesFeature({userId, guildId, enabled, requestCache}, auditLogReason),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/guilds/:guild_id/detached-banner',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_UPDATE),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('json', EnabledToggleRequest),
|
||||
OpenAPI({
|
||||
operationId: 'toggle_detached_banner',
|
||||
summary: 'Toggle detached banner',
|
||||
description: 'Requires manage_guild permission. Enables or disables independent banner display configuration.',
|
||||
responseSchema: GuildResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const {enabled} = ctx.req.valid('json');
|
||||
const requestCache = ctx.get('requestCache');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
return ctx.json(
|
||||
await ctx
|
||||
.get('guildService')
|
||||
.updateDetachedBannerFeature({userId, guildId, enabled, requestCache}, auditLogReason),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
126
packages/api/src/guild/controllers/GuildChannelController.tsx
Normal file
126
packages/api/src/guild/controllers/GuildChannelController.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createChannelID, createGuildID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {
|
||||
ChannelCreateRequest,
|
||||
ChannelPositionUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas';
|
||||
import {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import {GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {z} from 'zod';
|
||||
|
||||
export function GuildChannelController(app: HonoApp) {
|
||||
app.get(
|
||||
'/guilds/:guild_id/channels',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_CHANNELS_LIST),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'list_guild_channels',
|
||||
summary: 'List guild channels',
|
||||
responseSchema: z.array(ChannelResponse),
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description: 'List guild channels. Returns all channels in the guild that the user has access to view.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const requestCache = ctx.get('requestCache');
|
||||
return ctx.json(await ctx.get('guildService').getChannels({userId, guildId, requestCache}));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/guilds/:guild_id/channels',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_CHANNEL_CREATE),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('json', ChannelCreateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'create_guild_channel',
|
||||
summary: 'Create guild channel',
|
||||
responseSchema: ChannelResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description:
|
||||
'Create guild channel. Requires manage_channels permission. Creates a new text, voice, or category channel in the guild.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const data = ctx.req.valid('json');
|
||||
const requestCache = ctx.get('requestCache');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
return ctx.json(
|
||||
await ctx.get('guildService').createChannel({userId, guildId, data, requestCache}, auditLogReason),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/guilds/:guild_id/channels',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_CHANNEL_POSITIONS),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('json', ChannelPositionUpdateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_guild_channel_positions',
|
||||
summary: 'Update channel positions',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description:
|
||||
'Update channel positions. Requires manage_channels permission. Reorders channels and optionally changes parent categories and permission locks.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const payload = ctx.req.valid('json');
|
||||
const requestCache = ctx.get('requestCache');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
|
||||
await ctx.get('guildService').updateChannelPositions(
|
||||
{
|
||||
userId,
|
||||
guildId,
|
||||
updates: payload.map((item) => ({
|
||||
channelId: createChannelID(item.id),
|
||||
position: item.position,
|
||||
parentId: item.parent_id == null ? item.parent_id : createChannelID(item.parent_id),
|
||||
lockPermissions: item.lock_permissions ?? false,
|
||||
})),
|
||||
requestCache,
|
||||
},
|
||||
auditLogReason,
|
||||
);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
}
|
||||
297
packages/api/src/guild/controllers/GuildDiscoveryController.tsx
Normal file
297
packages/api/src/guild/controllers/GuildDiscoveryController.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createGuildID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {GuildDiscoveryRow} from '@fluxer/api/src/database/types/GuildDiscoveryTypes';
|
||||
import {LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {DiscoveryApplicationStatus, DiscoveryCategoryLabels} from '@fluxer/constants/src/DiscoveryConstants';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {DiscoveryApplicationNotFoundError} from '@fluxer/errors/src/domains/discovery/DiscoveryApplicationNotFoundError';
|
||||
import {DiscoveryNotDiscoverableError} from '@fluxer/errors/src/domains/discovery/DiscoveryNotDiscoverableError';
|
||||
import {GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {
|
||||
DiscoveryApplicationPatchRequest,
|
||||
DiscoveryApplicationRequest,
|
||||
DiscoveryApplicationResponse,
|
||||
DiscoveryCategoryListResponse,
|
||||
DiscoveryGuildListResponse,
|
||||
DiscoverySearchQuery,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
|
||||
|
||||
function mapDiscoveryRowToResponse(row: GuildDiscoveryRow) {
|
||||
return {
|
||||
guild_id: row.guild_id.toString(),
|
||||
status: row.status,
|
||||
description: row.description,
|
||||
category_id: row.category_id,
|
||||
applied_at: row.applied_at.toISOString(),
|
||||
reviewed_at: row.reviewed_at?.toISOString() ?? null,
|
||||
review_reason: row.review_reason ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function GuildDiscoveryController(app: HonoApp) {
|
||||
app.get(
|
||||
'/discovery/guilds',
|
||||
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_SEARCH),
|
||||
LoginRequired,
|
||||
Validator('query', DiscoverySearchQuery),
|
||||
OpenAPI({
|
||||
operationId: 'search_discovery_guilds',
|
||||
summary: 'Search discoverable guilds',
|
||||
description: 'Search for guilds listed in the discovery directory.',
|
||||
responseSchema: DiscoveryGuildListResponse,
|
||||
statusCode: 200,
|
||||
security: ['sessionToken', 'bearerToken'],
|
||||
tags: ['Discovery'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const query = ctx.req.valid('query');
|
||||
const discoveryService = ctx.get('discoveryService');
|
||||
|
||||
const results = await discoveryService.searchDiscoverable({
|
||||
query: query.query,
|
||||
categoryId: query.category,
|
||||
sortBy: query.sort_by,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
});
|
||||
|
||||
return ctx.json(results);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/discovery/categories',
|
||||
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_CATEGORIES),
|
||||
LoginRequired,
|
||||
OpenAPI({
|
||||
operationId: 'list_discovery_categories',
|
||||
summary: 'List discovery categories',
|
||||
description: 'Returns the list of available discovery categories.',
|
||||
responseSchema: DiscoveryCategoryListResponse,
|
||||
statusCode: 200,
|
||||
security: ['sessionToken', 'bearerToken'],
|
||||
tags: ['Discovery'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const categories = Object.entries(DiscoveryCategoryLabels).map(([id, name]) => ({
|
||||
id: Number(id),
|
||||
name,
|
||||
}));
|
||||
return ctx.json(categories);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/discovery/guilds/:guild_id/join',
|
||||
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_JOIN),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'join_discovery_guild',
|
||||
summary: 'Join a discoverable guild',
|
||||
description: 'Join a guild that is listed in discovery without needing an invite.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['sessionToken', 'bearerToken'],
|
||||
tags: ['Discovery'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const {guild_id} = ctx.req.valid('param');
|
||||
const guildId = createGuildID(guild_id);
|
||||
|
||||
const status = await ctx.get('discoveryService').getStatus(guildId);
|
||||
if (!status || status.status !== DiscoveryApplicationStatus.APPROVED) {
|
||||
throw new DiscoveryNotDiscoverableError();
|
||||
}
|
||||
|
||||
await ctx.get('guildService').addUserToGuild({
|
||||
userId: user.id,
|
||||
guildId,
|
||||
sendJoinMessage: true,
|
||||
joinSourceType: 6,
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/guilds/:guild_id/discovery',
|
||||
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_APPLY),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('json', DiscoveryApplicationRequest),
|
||||
OpenAPI({
|
||||
operationId: 'apply_for_discovery',
|
||||
summary: 'Apply for guild discovery',
|
||||
description: 'Submit a discovery application for a guild. Requires MANAGE_GUILD permission.',
|
||||
responseSchema: DiscoveryApplicationResponse,
|
||||
statusCode: 200,
|
||||
security: ['sessionToken', 'bearerToken', 'botToken'],
|
||||
tags: ['Discovery'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const {guild_id} = ctx.req.valid('param');
|
||||
const guildId = createGuildID(guild_id);
|
||||
const data = ctx.req.valid('json');
|
||||
|
||||
const hasPermission = await ctx.get('gatewayService').checkPermission({
|
||||
guildId,
|
||||
userId: user.id,
|
||||
permission: Permissions.MANAGE_GUILD,
|
||||
});
|
||||
if (!hasPermission) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
const row = await ctx.get('discoveryService').apply({
|
||||
guildId,
|
||||
userId: user.id,
|
||||
description: data.description,
|
||||
categoryId: data.category_id,
|
||||
});
|
||||
|
||||
return ctx.json(mapDiscoveryRowToResponse(row));
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/guilds/:guild_id/discovery',
|
||||
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_APPLY),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('json', DiscoveryApplicationPatchRequest),
|
||||
OpenAPI({
|
||||
operationId: 'edit_discovery_application',
|
||||
summary: 'Edit discovery application',
|
||||
description:
|
||||
'Update the description or category of an existing discovery application. Requires MANAGE_GUILD permission.',
|
||||
responseSchema: DiscoveryApplicationResponse,
|
||||
statusCode: 200,
|
||||
security: ['sessionToken', 'bearerToken', 'botToken'],
|
||||
tags: ['Discovery'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const {guild_id} = ctx.req.valid('param');
|
||||
const guildId = createGuildID(guild_id);
|
||||
const data = ctx.req.valid('json');
|
||||
|
||||
const hasPermission = await ctx.get('gatewayService').checkPermission({
|
||||
guildId,
|
||||
userId: user.id,
|
||||
permission: Permissions.MANAGE_GUILD,
|
||||
});
|
||||
if (!hasPermission) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
const row = await ctx.get('discoveryService').editApplication({
|
||||
guildId,
|
||||
userId: user.id,
|
||||
data,
|
||||
});
|
||||
|
||||
return ctx.json(mapDiscoveryRowToResponse(row));
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/guilds/:guild_id/discovery',
|
||||
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_APPLY),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'withdraw_discovery_application',
|
||||
summary: 'Withdraw discovery application',
|
||||
description:
|
||||
'Withdraw a discovery application or remove a guild from discovery. Requires MANAGE_GUILD permission.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['sessionToken', 'bearerToken', 'botToken'],
|
||||
tags: ['Discovery'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const {guild_id} = ctx.req.valid('param');
|
||||
const guildId = createGuildID(guild_id);
|
||||
|
||||
const hasPermission = await ctx.get('gatewayService').checkPermission({
|
||||
guildId,
|
||||
userId: user.id,
|
||||
permission: Permissions.MANAGE_GUILD,
|
||||
});
|
||||
if (!hasPermission) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
await ctx.get('discoveryService').withdraw({guildId, userId: user.id});
|
||||
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/guilds/:guild_id/discovery',
|
||||
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_STATUS),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_discovery_status',
|
||||
summary: 'Get discovery status',
|
||||
description: 'Get the current discovery status of a guild. Requires MANAGE_GUILD permission.',
|
||||
responseSchema: DiscoveryApplicationResponse,
|
||||
statusCode: 200,
|
||||
security: ['sessionToken', 'bearerToken', 'botToken'],
|
||||
tags: ['Discovery'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const {guild_id} = ctx.req.valid('param');
|
||||
const guildId = createGuildID(guild_id);
|
||||
|
||||
const hasPermission = await ctx.get('gatewayService').checkPermission({
|
||||
guildId,
|
||||
userId: user.id,
|
||||
permission: Permissions.MANAGE_GUILD,
|
||||
});
|
||||
if (!hasPermission) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
const row = await ctx.get('discoveryService').getStatus(guildId);
|
||||
if (!row) {
|
||||
throw new DiscoveryApplicationNotFoundError();
|
||||
}
|
||||
|
||||
return ctx.json(mapDiscoveryRowToResponse(row));
|
||||
},
|
||||
);
|
||||
}
|
||||
175
packages/api/src/guild/controllers/GuildEmojiController.tsx
Normal file
175
packages/api/src/guild/controllers/GuildEmojiController.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createEmojiID, createGuildID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {GuildIdEmojiIdParam, GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {PurgeQuery} from '@fluxer/schema/src/domains/common/CommonQuerySchemas';
|
||||
import {
|
||||
GuildEmojiBulkCreateResponse,
|
||||
GuildEmojiResponse,
|
||||
GuildEmojiWithUserListResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
|
||||
import {
|
||||
GuildEmojiBulkCreateRequest,
|
||||
GuildEmojiCreateRequest,
|
||||
GuildEmojiUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
|
||||
|
||||
export function GuildEmojiController(app: HonoApp) {
|
||||
app.post(
|
||||
'/guilds/:guild_id/emojis',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_EMOJI_CREATE),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('json', GuildEmojiCreateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'create_guild_emoji',
|
||||
summary: 'Create guild emoji',
|
||||
responseSchema: GuildEmojiResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description:
|
||||
'Create guild emoji. Requires manage_emojis permission. Uploads and registers a new custom emoji for the guild.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const {name, image} = ctx.req.valid('json');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
const emoji = await ctx.get('guildService').createEmoji({user, guildId, name, image}, auditLogReason);
|
||||
return ctx.json(emoji);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/guilds/:guild_id/emojis/bulk',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_EMOJI_BULK_CREATE),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('json', GuildEmojiBulkCreateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'bulk_create_guild_emojis',
|
||||
summary: 'Bulk create guild emojis',
|
||||
responseSchema: GuildEmojiBulkCreateResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description:
|
||||
'Bulk create guild emojis. Requires manage_emojis permission. Creates multiple emojis in a single request for efficiency.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const {emojis} = ctx.req.valid('json');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
const result = await ctx.get('guildService').bulkCreateEmojis({user, guildId, emojis}, auditLogReason);
|
||||
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/guilds/:guild_id/emojis',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_EMOJIS_LIST),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'list_guild_emojis',
|
||||
summary: 'List guild emojis',
|
||||
responseSchema: GuildEmojiWithUserListResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description:
|
||||
'List guild emojis. Returns all custom emojis for the guild including metadata about creators and timestamps.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {guild_id} = ctx.req.valid('param');
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(guild_id);
|
||||
const requestCache = ctx.get('requestCache');
|
||||
return ctx.json(await ctx.get('guildService').getEmojis({userId, guildId, requestCache}));
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/guilds/:guild_id/emojis/:emoji_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_EMOJI_UPDATE),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdEmojiIdParam),
|
||||
Validator('json', GuildEmojiUpdateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_guild_emoji',
|
||||
summary: 'Update guild emoji',
|
||||
responseSchema: GuildEmojiResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description:
|
||||
'Update guild emoji. Requires manage_emojis permission. Renames or updates properties of an existing emoji.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {guild_id, emoji_id} = ctx.req.valid('param');
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(guild_id);
|
||||
const emojiId = createEmojiID(emoji_id);
|
||||
const {name} = ctx.req.valid('json');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
const emoji = await ctx.get('guildService').updateEmoji({userId, guildId, emojiId, name}, auditLogReason);
|
||||
return ctx.json(emoji);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/guilds/:guild_id/emojis/:emoji_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_EMOJI_DELETE),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdEmojiIdParam),
|
||||
Validator('query', PurgeQuery),
|
||||
OpenAPI({
|
||||
operationId: 'delete_guild_emoji',
|
||||
summary: 'Delete guild emoji',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description:
|
||||
'Delete guild emoji. Requires manage_emojis permission. Removes a custom emoji from the guild; optionally purges all references.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {guild_id, emoji_id} = ctx.req.valid('param');
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(guild_id);
|
||||
const emojiId = createEmojiID(emoji_id);
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
const {purge = false} = ctx.req.valid('query');
|
||||
|
||||
await ctx.get('guildService').deleteEmoji({userId, guildId, emojiId, purge}, auditLogReason);
|
||||
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
}
|
||||
389
packages/api/src/guild/controllers/GuildMemberController.tsx
Normal file
389
packages/api/src/guild/controllers/GuildMemberController.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {requireSudoMode} from '@fluxer/api/src/auth/services/SudoVerificationService';
|
||||
import {createGuildID, createRoleID, createUserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {SudoModeMiddleware} from '@fluxer/api/src/middleware/SudoModeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {SudoVerificationSchema} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
import {
|
||||
GuildIdParam,
|
||||
GuildIdUserIdParam,
|
||||
GuildIdUserIdRoleIdParam,
|
||||
} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {GuildBanResponse, GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import {
|
||||
GuildBanCreateRequest,
|
||||
GuildMemberListQuery,
|
||||
GuildMemberUpdateRequest,
|
||||
GuildTransferOwnershipRequest,
|
||||
MyGuildMemberUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
|
||||
import {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {z} from 'zod';
|
||||
|
||||
export function GuildMemberController(app: HonoApp) {
|
||||
app.get(
|
||||
'/guilds/:guild_id/members',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_MEMBERS),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('query', GuildMemberListQuery),
|
||||
OpenAPI({
|
||||
operationId: 'list_guild_members',
|
||||
summary: 'List guild members',
|
||||
responseSchema: z.array(GuildMemberResponse),
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description:
|
||||
'List guild members. Supports pagination with limit and after cursor. Returns member information for the specified guild.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const {limit, after} = ctx.req.valid('query');
|
||||
const requestCache = ctx.get('requestCache');
|
||||
return ctx.json(
|
||||
await ctx.get('guildService').getMembers({
|
||||
userId,
|
||||
guildId,
|
||||
limit,
|
||||
after: after != null ? createUserID(after) : undefined,
|
||||
requestCache,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/guilds/:guild_id/members/@me',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_MEMBERS),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_current_guild_member',
|
||||
summary: 'Get current user guild member',
|
||||
responseSchema: GuildMemberResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description:
|
||||
'Get current user guild member. Returns the member information for the authenticated user in the specified guild.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const requestCache = ctx.get('requestCache');
|
||||
return ctx.json(await ctx.get('guildService').getMember({userId, targetId: userId, guildId, requestCache}));
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/guilds/:guild_id/members/:user_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_MEMBERS),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdUserIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_guild_member',
|
||||
summary: 'Get guild member by user ID',
|
||||
responseSchema: GuildMemberResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description:
|
||||
'Get guild member by user ID. Returns member information including roles, nickname, and join date for the specified user in the guild.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {guild_id, user_id} = ctx.req.valid('param');
|
||||
const userId = ctx.get('user').id;
|
||||
const targetId = createUserID(user_id);
|
||||
const guildId = createGuildID(guild_id);
|
||||
const requestCache = ctx.get('requestCache');
|
||||
return ctx.json(await ctx.get('guildService').getMember({userId, targetId, guildId, requestCache}));
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/guilds/:guild_id/members/@me',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_MEMBER_UPDATE),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('json', MyGuildMemberUpdateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_current_guild_member',
|
||||
summary: 'Update current user guild member',
|
||||
responseSchema: GuildMemberResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description: 'Update current user guild member. User can modify their own nickname within the guild.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const requestCache = ctx.get('requestCache');
|
||||
const data = ctx.req.valid('json');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
const result = await ctx
|
||||
.get('guildService')
|
||||
.updateMember({userId, targetId: userId, guildId, data, requestCache}, auditLogReason);
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/guilds/:guild_id/members/:user_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_MEMBER_UPDATE),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdUserIdParam),
|
||||
Validator('json', GuildMemberUpdateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_guild_member',
|
||||
summary: 'Update guild member',
|
||||
responseSchema: GuildMemberResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description:
|
||||
'Update guild member. Requires manage_members permission. Can modify member nickname, voice state, and other member properties.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {guild_id, user_id} = ctx.req.valid('param');
|
||||
const userId = ctx.get('user').id;
|
||||
const targetId = createUserID(user_id);
|
||||
const guildId = createGuildID(guild_id);
|
||||
const data = ctx.req.valid('json');
|
||||
const requestCache = ctx.get('requestCache');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
|
||||
const result = await ctx
|
||||
.get('guildService')
|
||||
.updateMember({userId, targetId, guildId, data, requestCache}, auditLogReason);
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/guilds/:guild_id/members/:user_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_MEMBER_REMOVE),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdUserIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'remove_guild_member',
|
||||
summary: 'Remove guild member',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description: 'Remove guild member. Requires kick_members permission. Removes the specified user from the guild.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {guild_id, user_id} = ctx.req.valid('param');
|
||||
const userId = ctx.get('user').id;
|
||||
const targetId = createUserID(user_id);
|
||||
const guildId = createGuildID(guild_id);
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
await ctx.get('guildService').removeMember({userId, targetId, guildId}, auditLogReason);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/guilds/:guild_id/transfer-ownership',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_UPDATE),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
SudoModeMiddleware,
|
||||
Validator('json', GuildTransferOwnershipRequest.merge(SudoVerificationSchema)),
|
||||
OpenAPI({
|
||||
operationId: 'transfer_guild_ownership',
|
||||
summary: 'Transfer guild ownership',
|
||||
responseSchema: GuildResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description:
|
||||
'Transfer guild ownership. Only current owner can transfer. Requires sudo mode verification (MFA). Transfers all guild permissions to a new owner.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const userId = user.id;
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const body = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
const {new_owner_id} = body;
|
||||
const newOwnerId = createUserID(new_owner_id);
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
return ctx.json(await ctx.get('guildService').transferOwnership({userId, guildId, newOwnerId}, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/guilds/:guild_id/bans',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_MEMBERS),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'list_guild_bans',
|
||||
summary: 'List guild bans',
|
||||
responseSchema: z.array(GuildBanResponse),
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description:
|
||||
'List guild bans. Requires ban_members permission. Returns all banned users for the guild including ban reasons and expiry times.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const requestCache = ctx.get('requestCache');
|
||||
return ctx.json(await ctx.get('guildService').listBans({userId, guildId, requestCache}));
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
'/guilds/:guild_id/bans/:user_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_MEMBER_REMOVE),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdUserIdParam),
|
||||
Validator('json', GuildBanCreateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'ban_guild_member',
|
||||
summary: 'Ban guild member',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description:
|
||||
'Ban guild member. Requires ban_members permission. Prevents user from joining; optionally deletes recent messages and sets ban expiry duration.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {guild_id, user_id} = ctx.req.valid('param');
|
||||
const userId = ctx.get('user').id;
|
||||
const targetId = createUserID(user_id);
|
||||
const guildId = createGuildID(guild_id);
|
||||
const {delete_message_days, reason, ban_duration_seconds} = ctx.req.valid('json');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
|
||||
await ctx.get('guildService').banMember(
|
||||
{
|
||||
userId,
|
||||
guildId,
|
||||
targetId,
|
||||
deleteMessageDays: delete_message_days,
|
||||
reason: reason ?? undefined,
|
||||
banDurationSeconds: ban_duration_seconds,
|
||||
},
|
||||
auditLogReason,
|
||||
);
|
||||
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/guilds/:guild_id/bans/:user_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_MEMBER_REMOVE),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdUserIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'unban_guild_member',
|
||||
summary: 'Unban guild member',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description:
|
||||
'Unban guild member. Requires ban_members permission. Removes ban and allows user to rejoin the guild.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {guild_id, user_id} = ctx.req.valid('param');
|
||||
const userId = ctx.get('user').id;
|
||||
const targetId = createUserID(user_id);
|
||||
const guildId = createGuildID(guild_id);
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
|
||||
await ctx.get('guildService').unbanMember({userId, guildId, targetId}, auditLogReason);
|
||||
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
'/guilds/:guild_id/members/:user_id/roles/:role_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_MEMBER_ROLE_ADD),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdUserIdRoleIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'add_guild_member_role',
|
||||
summary: 'Add role to guild member',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description:
|
||||
'Add role to guild member. Requires manage_roles permission. Grants the specified role to the user in the guild.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {guild_id, user_id, role_id} = ctx.req.valid('param');
|
||||
const userId = ctx.get('user').id;
|
||||
const targetId = createUserID(user_id);
|
||||
const guildId = createGuildID(guild_id);
|
||||
const roleId = createRoleID(role_id);
|
||||
const requestCache = ctx.get('requestCache');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
await ctx.get('guildService').addMemberRole({userId, targetId, guildId, roleId, requestCache}, auditLogReason);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/guilds/:guild_id/members/:user_id/roles/:role_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_MEMBER_ROLE_REMOVE),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdUserIdRoleIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'remove_guild_member_role',
|
||||
summary: 'Remove role from guild member',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description:
|
||||
'Remove role from guild member. Requires manage_roles permission. Revokes the specified role from the user in the guild.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {guild_id, user_id, role_id} = ctx.req.valid('param');
|
||||
const userId = ctx.get('user').id;
|
||||
const targetId = createUserID(user_id);
|
||||
const guildId = createGuildID(guild_id);
|
||||
const roleId = createRoleID(role_id);
|
||||
const requestCache = ctx.get('requestCache');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
await ctx.get('guildService').removeMemberRole({userId, targetId, guildId, roleId, requestCache}, auditLogReason);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createGuildID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {GuildRepository} from '@fluxer/api/src/guild/repositories/GuildRepository';
|
||||
import {LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import {getGuildMemberSearchService} from '@fluxer/api/src/SearchFactory';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {SearchableGuildMember} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
import {GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {
|
||||
GuildMemberSearchRequest,
|
||||
GuildMemberSearchResponse,
|
||||
type GuildMemberSearchResponse as GuildMemberSearchResponseBody,
|
||||
type GuildMemberSearchResult,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildMemberSearchSchemas';
|
||||
|
||||
const guildRepository = new GuildRepository();
|
||||
|
||||
function createEmptySearchResponse(guildId: string, indexing: boolean): GuildMemberSearchResponseBody {
|
||||
return {
|
||||
guild_id: guildId,
|
||||
members: [],
|
||||
page_result_count: 0,
|
||||
total_result_count: 0,
|
||||
indexing,
|
||||
};
|
||||
}
|
||||
|
||||
function mapSearchableMember(hit: SearchableGuildMember): GuildMemberSearchResult {
|
||||
return {
|
||||
id: hit.id,
|
||||
guild_id: hit.guildId,
|
||||
user_id: hit.userId,
|
||||
username: hit.username,
|
||||
discriminator: hit.discriminator,
|
||||
global_name: hit.globalName,
|
||||
nickname: hit.nickname,
|
||||
role_ids: hit.roleIds,
|
||||
joined_at: hit.joinedAt,
|
||||
supplemental: {
|
||||
join_source_type: hit.joinSourceType,
|
||||
source_invite_code: hit.sourceInviteCode,
|
||||
inviter_id: hit.inviterId,
|
||||
},
|
||||
is_bot: hit.isBot,
|
||||
};
|
||||
}
|
||||
|
||||
export function GuildMemberSearchController(app: HonoApp) {
|
||||
app.post(
|
||||
'/guilds/:guild_id/members-search',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_MEMBERS),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('json', GuildMemberSearchRequest),
|
||||
OpenAPI({
|
||||
operationId: 'search_guild_members',
|
||||
summary: 'Search guild members',
|
||||
responseSchema: GuildMemberSearchResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description: 'Search and filter guild members with pagination support.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const guildIdString = guildId.toString();
|
||||
const body = ctx.req.valid('json');
|
||||
|
||||
const {checkPermission} = await ctx.get('guildService').getGuildAuthenticated({userId, guildId});
|
||||
await checkPermission(Permissions.MANAGE_GUILD);
|
||||
|
||||
const searchService = getGuildMemberSearchService();
|
||||
if (!searchService || !searchService.isAvailable()) {
|
||||
return ctx.json(createEmptySearchResponse(guildIdString, false));
|
||||
}
|
||||
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
return ctx.json(createEmptySearchResponse(guildIdString, false));
|
||||
}
|
||||
|
||||
if (!guild.membersIndexedAt) {
|
||||
const workerService = ctx.get('workerService');
|
||||
await workerService.addJob(
|
||||
'indexGuildMembers',
|
||||
{guildId: guildIdString},
|
||||
{jobKey: `index-guild-members-${guildId}-lazy`, maxAttempts: 3},
|
||||
);
|
||||
return ctx.json(createEmptySearchResponse(guildIdString, true));
|
||||
}
|
||||
|
||||
const query = body.query?.trim() ?? '';
|
||||
const limit = body.limit ?? 25;
|
||||
const offset = body.offset ?? 0;
|
||||
|
||||
const results = await searchService.searchMembers(
|
||||
query,
|
||||
{
|
||||
guildId: guildIdString,
|
||||
roleIds: body.role_ids,
|
||||
joinedAtGte: body.joined_at_gte,
|
||||
joinedAtLte: body.joined_at_lte,
|
||||
joinSourceType: body.join_source_type,
|
||||
sourceInviteCode: body.source_invite_code,
|
||||
userCreatedAtGte: body.user_created_at_gte,
|
||||
userCreatedAtLte: body.user_created_at_lte,
|
||||
isBot: body.is_bot,
|
||||
sortBy: body.sort_by,
|
||||
sortOrder: body.sort_order,
|
||||
},
|
||||
{limit, offset},
|
||||
);
|
||||
|
||||
const members = results.hits.map(mapSearchableMember);
|
||||
|
||||
return ctx.json({
|
||||
guild_id: guildIdString,
|
||||
members,
|
||||
page_result_count: members.length,
|
||||
total_result_count: results.total,
|
||||
indexing: false,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
225
packages/api/src/guild/controllers/GuildRoleController.tsx
Normal file
225
packages/api/src/guild/controllers/GuildRoleController.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createGuildID, createRoleID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {GuildIdParam, GuildIdRoleIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {
|
||||
GuildRoleCreateRequest,
|
||||
GuildRoleHoistPositionsRequest,
|
||||
GuildRolePositionsRequest,
|
||||
GuildRoleUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
|
||||
import {GuildRoleResponse} from '@fluxer/schema/src/domains/guild/GuildRoleSchemas';
|
||||
import {z} from 'zod';
|
||||
|
||||
export function GuildRoleController(app: HonoApp) {
|
||||
app.get(
|
||||
'/guilds/:guild_id/roles',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_ROLE_LIST),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'list_guild_roles',
|
||||
summary: 'List guild roles',
|
||||
responseSchema: z.array(GuildRoleResponse),
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description: 'List guild roles. Returns all roles defined in the guild including their permissions and settings.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
return ctx.json(await ctx.get('guildService').listRoles({userId, guildId}));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/guilds/:guild_id/roles',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_ROLE_CREATE),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('json', GuildRoleCreateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'create_guild_role',
|
||||
summary: 'Create guild role',
|
||||
responseSchema: GuildRoleResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description:
|
||||
'Create guild role. Requires manage_roles permission. Creates a new role with specified name, permissions, and color.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const data = ctx.req.valid('json');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
return ctx.json(await ctx.get('guildService').createRole({userId, guildId, data}, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/guilds/:guild_id/roles/hoist-positions',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_ROLE_HOIST_POSITIONS),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('json', GuildRoleHoistPositionsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_role_hoist_positions',
|
||||
summary: 'Update role hoist positions',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description:
|
||||
'Update role hoist positions. Requires manage_roles permission. Sets the display priority for hoisted (separated) roles in the member list.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const payload = ctx.req.valid('json');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
await ctx.get('guildService').updateHoistPositions(
|
||||
{
|
||||
userId,
|
||||
guildId,
|
||||
updates: payload.map((item) => ({roleId: createRoleID(item.id), hoistPosition: item.hoist_position})),
|
||||
},
|
||||
auditLogReason,
|
||||
);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/guilds/:guild_id/roles/hoist-positions',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_ROLE_HOIST_POSITIONS_RESET),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'reset_role_hoist_positions',
|
||||
summary: 'Reset role hoist positions',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description:
|
||||
'Reset role hoist positions. Requires manage_roles permission. Clears all hoist position assignments for roles in the guild.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
await ctx.get('guildService').resetHoistPositions({userId, guildId}, auditLogReason);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/guilds/:guild_id/roles/:role_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_ROLE_UPDATE),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdRoleIdParam),
|
||||
Validator('json', GuildRoleUpdateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_guild_role',
|
||||
summary: 'Update guild role',
|
||||
responseSchema: GuildRoleResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description:
|
||||
'Update guild role. Requires manage_roles permission. Modifies role name, permissions, color, and other settings.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {guild_id, role_id} = ctx.req.valid('param');
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(guild_id);
|
||||
const roleId = createRoleID(role_id);
|
||||
const data = ctx.req.valid('json');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
return ctx.json(await ctx.get('guildService').updateRole({userId, guildId, roleId, data}, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/guilds/:guild_id/roles',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_ROLE_POSITIONS),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('json', GuildRolePositionsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_guild_role_positions',
|
||||
summary: 'Update role positions',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description:
|
||||
'Update role positions. Requires manage_roles permission. Reorders roles to change their hierarchy and permission precedence.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const payload = ctx.req.valid('json');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
await ctx.get('guildService').updateRolePositions(
|
||||
{
|
||||
userId,
|
||||
guildId,
|
||||
updates: payload.map((item) => ({roleId: createRoleID(item.id), position: item.position})),
|
||||
},
|
||||
auditLogReason,
|
||||
);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/guilds/:guild_id/roles/:role_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_ROLE_DELETE),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdRoleIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'delete_guild_role',
|
||||
summary: 'Delete guild role',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description: 'Delete guild role. Requires manage_roles permission. Permanently removes the role from the guild.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {guild_id, role_id} = ctx.req.valid('param');
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(guild_id);
|
||||
const roleId = createRoleID(role_id);
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
await ctx.get('guildService').deleteRole({userId, guildId, roleId}, auditLogReason);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
}
|
||||
180
packages/api/src/guild/controllers/GuildStickerController.tsx
Normal file
180
packages/api/src/guild/controllers/GuildStickerController.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createGuildID, createStickerID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {GuildIdParam, GuildIdStickerIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {PurgeQuery} from '@fluxer/schema/src/domains/common/CommonQuerySchemas';
|
||||
import {
|
||||
GuildStickerBulkCreateResponse,
|
||||
GuildStickerResponse,
|
||||
GuildStickerWithUserListResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
|
||||
import {
|
||||
GuildStickerBulkCreateRequest,
|
||||
GuildStickerCreateRequest,
|
||||
GuildStickerUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
|
||||
|
||||
export function GuildStickerController(app: HonoApp) {
|
||||
app.post(
|
||||
'/guilds/:guild_id/stickers',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_STICKER_CREATE),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('json', GuildStickerCreateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'create_guild_sticker',
|
||||
summary: 'Create guild sticker',
|
||||
responseSchema: GuildStickerResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description:
|
||||
'Create guild sticker. Requires manage_emojis permission. Uploads a new sticker with name, description, and tags.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const {name, description, tags, image} = ctx.req.valid('json');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
const sticker = await ctx
|
||||
.get('guildService')
|
||||
.createSticker({user, guildId, name, description, tags, image}, auditLogReason);
|
||||
return ctx.json(sticker);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/guilds/:guild_id/stickers/bulk',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_STICKER_BULK_CREATE),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('json', GuildStickerBulkCreateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'bulk_create_guild_stickers',
|
||||
summary: 'Bulk create guild stickers',
|
||||
responseSchema: GuildStickerBulkCreateResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description:
|
||||
'Bulk create guild stickers. Requires manage_emojis permission. Creates multiple stickers in a single request for efficiency.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const {stickers} = ctx.req.valid('json');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
const result = await ctx.get('guildService').bulkCreateStickers({user, guildId, stickers}, auditLogReason);
|
||||
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/guilds/:guild_id/stickers',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_STICKERS_LIST),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'list_guild_stickers',
|
||||
summary: 'List guild stickers',
|
||||
responseSchema: GuildStickerWithUserListResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description:
|
||||
'List guild stickers. Returns all custom stickers for the guild including metadata about creators, descriptions, and tags.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {guild_id} = ctx.req.valid('param');
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(guild_id);
|
||||
const requestCache = ctx.get('requestCache');
|
||||
return ctx.json(await ctx.get('guildService').getStickers({userId, guildId, requestCache}));
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/guilds/:guild_id/stickers/:sticker_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_STICKER_UPDATE),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdStickerIdParam),
|
||||
Validator('json', GuildStickerUpdateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_guild_sticker',
|
||||
summary: 'Update guild sticker',
|
||||
responseSchema: GuildStickerResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description:
|
||||
'Update guild sticker. Requires manage_emojis permission. Updates sticker name, description, or tags.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {guild_id, sticker_id} = ctx.req.valid('param');
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(guild_id);
|
||||
const stickerId = createStickerID(sticker_id);
|
||||
const {name, description, tags} = ctx.req.valid('json');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
return ctx.json(
|
||||
await ctx
|
||||
.get('guildService')
|
||||
.updateSticker({userId, guildId, stickerId, name, description, tags}, auditLogReason),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/guilds/:guild_id/stickers/:sticker_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_STICKER_DELETE),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdStickerIdParam),
|
||||
Validator('query', PurgeQuery),
|
||||
OpenAPI({
|
||||
operationId: 'delete_guild_sticker',
|
||||
summary: 'Delete guild sticker',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
description:
|
||||
'Delete guild sticker. Requires manage_emojis permission. Removes a sticker from the guild; optionally purges all references.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {guild_id, sticker_id} = ctx.req.valid('param');
|
||||
const userId = ctx.get('user').id;
|
||||
const guildId = createGuildID(guild_id);
|
||||
const stickerId = createStickerID(sticker_id);
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
const {purge = false} = ctx.req.valid('query');
|
||||
|
||||
await ctx.get('guildService').deleteSticker({userId, guildId, stickerId, purge}, auditLogReason);
|
||||
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
}
|
||||
41
packages/api/src/guild/controllers/index.tsx
Normal file
41
packages/api/src/guild/controllers/index.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {GuildAuditLogController} from '@fluxer/api/src/guild/controllers/GuildAuditLogController';
|
||||
import {GuildBaseController} from '@fluxer/api/src/guild/controllers/GuildBaseController';
|
||||
import {GuildChannelController} from '@fluxer/api/src/guild/controllers/GuildChannelController';
|
||||
import {GuildDiscoveryController} from '@fluxer/api/src/guild/controllers/GuildDiscoveryController';
|
||||
import {GuildEmojiController} from '@fluxer/api/src/guild/controllers/GuildEmojiController';
|
||||
import {GuildMemberController} from '@fluxer/api/src/guild/controllers/GuildMemberController';
|
||||
import {GuildMemberSearchController} from '@fluxer/api/src/guild/controllers/GuildMemberSearchController';
|
||||
import {GuildRoleController} from '@fluxer/api/src/guild/controllers/GuildRoleController';
|
||||
import {GuildStickerController} from '@fluxer/api/src/guild/controllers/GuildStickerController';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
|
||||
export function registerGuildControllers(app: HonoApp) {
|
||||
GuildBaseController(app);
|
||||
GuildMemberController(app);
|
||||
GuildMemberSearchController(app);
|
||||
GuildRoleController(app);
|
||||
GuildChannelController(app);
|
||||
GuildEmojiController(app);
|
||||
GuildStickerController(app);
|
||||
GuildAuditLogController(app);
|
||||
GuildDiscoveryController(app);
|
||||
}
|
||||
197
packages/api/src/guild/repositories/GuildContentRepository.tsx
Normal file
197
packages/api/src/guild/repositories/GuildContentRepository.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {EmojiID, GuildID, StickerID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {
|
||||
BatchBuilder,
|
||||
buildPatchFromData,
|
||||
executeVersionedUpdate,
|
||||
fetchMany,
|
||||
fetchOne,
|
||||
} from '@fluxer/api/src/database/Cassandra';
|
||||
import {
|
||||
GUILD_EMOJI_COLUMNS,
|
||||
GUILD_STICKER_COLUMNS,
|
||||
type GuildEmojiRow,
|
||||
type GuildStickerRow,
|
||||
} from '@fluxer/api/src/database/types/GuildTypes';
|
||||
import {IGuildContentRepository} from '@fluxer/api/src/guild/repositories/IGuildContentRepository';
|
||||
import {GuildEmoji} from '@fluxer/api/src/models/GuildEmoji';
|
||||
import {GuildSticker} from '@fluxer/api/src/models/GuildSticker';
|
||||
import {GuildEmojis, GuildEmojisByEmojiId, GuildStickers, GuildStickersByStickerId} from '@fluxer/api/src/Tables';
|
||||
|
||||
const FETCH_GUILD_EMOJIS_BY_GUILD_ID_QUERY = GuildEmojis.selectCql({
|
||||
where: GuildEmojis.where.eq('guild_id'),
|
||||
});
|
||||
|
||||
const FETCH_GUILD_EMOJI_BY_ID_QUERY = GuildEmojis.selectCql({
|
||||
where: [GuildEmojis.where.eq('guild_id'), GuildEmojis.where.eq('emoji_id')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_GUILD_EMOJI_BY_EMOJI_ID_ONLY_QUERY = GuildEmojisByEmojiId.selectCql({
|
||||
where: GuildEmojisByEmojiId.where.eq('emoji_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_GUILD_STICKERS_BY_GUILD_ID_QUERY = GuildStickers.selectCql({
|
||||
where: GuildStickers.where.eq('guild_id'),
|
||||
});
|
||||
|
||||
const FETCH_GUILD_STICKER_BY_ID_QUERY = GuildStickers.selectCql({
|
||||
where: [GuildStickers.where.eq('guild_id'), GuildStickers.where.eq('sticker_id')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_GUILD_STICKER_BY_STICKER_ID_ONLY_QUERY = GuildStickersByStickerId.selectCql({
|
||||
where: GuildStickersByStickerId.where.eq('sticker_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
export class GuildContentRepository extends IGuildContentRepository {
|
||||
async getEmoji(emojiId: EmojiID, guildId: GuildID): Promise<GuildEmoji | null> {
|
||||
const emoji = await fetchOne<GuildEmojiRow>(FETCH_GUILD_EMOJI_BY_ID_QUERY, {
|
||||
guild_id: guildId,
|
||||
emoji_id: emojiId,
|
||||
});
|
||||
return emoji ? new GuildEmoji(emoji) : null;
|
||||
}
|
||||
|
||||
async getEmojiById(emojiId: EmojiID): Promise<GuildEmoji | null> {
|
||||
const emoji = await fetchOne<GuildEmojiRow>(FETCH_GUILD_EMOJI_BY_EMOJI_ID_ONLY_QUERY, {
|
||||
emoji_id: emojiId,
|
||||
});
|
||||
return emoji ? new GuildEmoji(emoji) : null;
|
||||
}
|
||||
|
||||
async listEmojis(guildId: GuildID): Promise<Array<GuildEmoji>> {
|
||||
const emojis = await fetchMany<GuildEmojiRow>(FETCH_GUILD_EMOJIS_BY_GUILD_ID_QUERY, {
|
||||
guild_id: guildId,
|
||||
});
|
||||
return emojis.map((emoji) => new GuildEmoji(emoji));
|
||||
}
|
||||
|
||||
async countEmojis(guildId: GuildID): Promise<number> {
|
||||
const emojis = await fetchMany<GuildEmojiRow>(FETCH_GUILD_EMOJIS_BY_GUILD_ID_QUERY, {
|
||||
guild_id: guildId,
|
||||
});
|
||||
return emojis.length;
|
||||
}
|
||||
|
||||
async upsertEmoji(data: GuildEmojiRow, oldData?: GuildEmojiRow | null): Promise<GuildEmoji> {
|
||||
const guildId = data.guild_id;
|
||||
const emojiId = data.emoji_id;
|
||||
|
||||
const result = await executeVersionedUpdate<GuildEmojiRow, 'guild_id' | 'emoji_id'>(
|
||||
async () => {
|
||||
if (oldData !== undefined) return oldData;
|
||||
return await fetchOne<GuildEmojiRow>(FETCH_GUILD_EMOJI_BY_ID_QUERY, {
|
||||
guild_id: guildId,
|
||||
emoji_id: emojiId,
|
||||
});
|
||||
},
|
||||
(current) => ({
|
||||
pk: {guild_id: guildId, emoji_id: emojiId},
|
||||
patch: buildPatchFromData(data, current, GUILD_EMOJI_COLUMNS, ['guild_id', 'emoji_id']),
|
||||
}),
|
||||
GuildEmojis,
|
||||
);
|
||||
|
||||
await fetchOne(GuildEmojisByEmojiId.insert(data));
|
||||
|
||||
return new GuildEmoji({...data, version: result.finalVersion ?? 1});
|
||||
}
|
||||
|
||||
async deleteEmoji(guildId: GuildID, emojiId: EmojiID): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(
|
||||
GuildEmojis.deleteByPk({
|
||||
guild_id: guildId,
|
||||
emoji_id: emojiId,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(GuildEmojisByEmojiId.deleteByPk({emoji_id: emojiId}));
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async getSticker(stickerId: StickerID, guildId: GuildID): Promise<GuildSticker | null> {
|
||||
const sticker = await fetchOne<GuildStickerRow>(FETCH_GUILD_STICKER_BY_ID_QUERY, {
|
||||
guild_id: guildId,
|
||||
sticker_id: stickerId,
|
||||
});
|
||||
return sticker ? new GuildSticker(sticker) : null;
|
||||
}
|
||||
|
||||
async getStickerById(stickerId: StickerID): Promise<GuildSticker | null> {
|
||||
const sticker = await fetchOne<GuildStickerRow>(FETCH_GUILD_STICKER_BY_STICKER_ID_ONLY_QUERY, {
|
||||
sticker_id: stickerId,
|
||||
});
|
||||
return sticker ? new GuildSticker(sticker) : null;
|
||||
}
|
||||
|
||||
async listStickers(guildId: GuildID): Promise<Array<GuildSticker>> {
|
||||
const stickers = await fetchMany<GuildStickerRow>(FETCH_GUILD_STICKERS_BY_GUILD_ID_QUERY, {
|
||||
guild_id: guildId,
|
||||
});
|
||||
return stickers.map((sticker) => new GuildSticker(sticker));
|
||||
}
|
||||
|
||||
async countStickers(guildId: GuildID): Promise<number> {
|
||||
const stickers = await fetchMany<GuildStickerRow>(FETCH_GUILD_STICKERS_BY_GUILD_ID_QUERY, {
|
||||
guild_id: guildId,
|
||||
});
|
||||
return stickers.length;
|
||||
}
|
||||
|
||||
async upsertSticker(data: GuildStickerRow, oldData?: GuildStickerRow | null): Promise<GuildSticker> {
|
||||
const guildId = data.guild_id;
|
||||
const stickerId = data.sticker_id;
|
||||
|
||||
const result = await executeVersionedUpdate<GuildStickerRow, 'guild_id' | 'sticker_id'>(
|
||||
async () => {
|
||||
if (oldData !== undefined) return oldData;
|
||||
return await fetchOne<GuildStickerRow>(FETCH_GUILD_STICKER_BY_ID_QUERY, {
|
||||
guild_id: guildId,
|
||||
sticker_id: stickerId,
|
||||
});
|
||||
},
|
||||
(current) => ({
|
||||
pk: {guild_id: guildId, sticker_id: stickerId},
|
||||
patch: buildPatchFromData(data, current, GUILD_STICKER_COLUMNS, ['guild_id', 'sticker_id']),
|
||||
}),
|
||||
GuildStickers,
|
||||
);
|
||||
|
||||
await fetchOne(GuildStickersByStickerId.insert(data));
|
||||
|
||||
return new GuildSticker({...data, version: result.finalVersion ?? 1});
|
||||
}
|
||||
|
||||
async deleteSticker(guildId: GuildID, stickerId: StickerID): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(
|
||||
GuildStickers.deleteByPk({
|
||||
guild_id: guildId,
|
||||
sticker_id: stickerId,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(GuildStickersByStickerId.deleteByPk({sticker_id: stickerId}));
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
145
packages/api/src/guild/repositories/GuildDataRepository.tsx
Normal file
145
packages/api/src/guild/repositories/GuildDataRepository.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {
|
||||
BatchBuilder,
|
||||
buildPatchFromData,
|
||||
executeVersionedUpdate,
|
||||
fetchMany,
|
||||
fetchOne,
|
||||
} from '@fluxer/api/src/database/Cassandra';
|
||||
import {GUILD_COLUMNS, type GuildMemberByUserIdRow, type GuildRow} from '@fluxer/api/src/database/types/GuildTypes';
|
||||
import {IGuildDataRepository} from '@fluxer/api/src/guild/repositories/IGuildDataRepository';
|
||||
import {Guild} from '@fluxer/api/src/models/Guild';
|
||||
import {GuildMembersByUserId, Guilds} from '@fluxer/api/src/Tables';
|
||||
|
||||
const FETCH_GUILD_BY_ID_QUERY = Guilds.selectCql({
|
||||
where: Guilds.where.eq('guild_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_GUILDS_BY_IDS_QUERY = Guilds.selectCql({
|
||||
where: Guilds.where.in('guild_id', 'guild_ids'),
|
||||
});
|
||||
|
||||
const createFetchAllGuildsPaginatedQuery = (limit: number) =>
|
||||
Guilds.selectCql({
|
||||
where: Guilds.where.tokenGt('guild_id', 'last_guild_id'),
|
||||
limit,
|
||||
});
|
||||
|
||||
function createFetchAllGuildsFirstPageQuery(limit: number) {
|
||||
return Guilds.selectCql({limit});
|
||||
}
|
||||
|
||||
export class GuildDataRepository extends IGuildDataRepository {
|
||||
async findUnique(guildId: GuildID): Promise<Guild | null> {
|
||||
const guild = await fetchOne<GuildRow>(FETCH_GUILD_BY_ID_QUERY, {
|
||||
guild_id: guildId,
|
||||
});
|
||||
return guild ? new Guild(guild) : null;
|
||||
}
|
||||
|
||||
async listGuilds(guildIds: Array<GuildID>): Promise<Array<Guild>> {
|
||||
if (guildIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const guilds = await fetchMany<GuildRow>(FETCH_GUILDS_BY_IDS_QUERY, {guild_ids: guildIds});
|
||||
return guilds.map((guild) => new Guild(guild));
|
||||
}
|
||||
|
||||
async listAllGuildsPaginated(limit: number, lastGuildId?: GuildID): Promise<Array<Guild>> {
|
||||
let guilds: Array<GuildRow>;
|
||||
|
||||
if (lastGuildId) {
|
||||
const query = createFetchAllGuildsPaginatedQuery(limit);
|
||||
guilds = await fetchMany<GuildRow>(query, {
|
||||
last_guild_id: lastGuildId,
|
||||
});
|
||||
} else {
|
||||
const query = createFetchAllGuildsFirstPageQuery(limit);
|
||||
guilds = await fetchMany<GuildRow>(query, {});
|
||||
}
|
||||
|
||||
return guilds.map((guild) => new Guild(guild));
|
||||
}
|
||||
|
||||
async listUserGuilds(userId: UserID): Promise<Array<Guild>> {
|
||||
const query = GuildMembersByUserId.select({
|
||||
columns: ['guild_id'],
|
||||
where: GuildMembersByUserId.where.eq('user_id'),
|
||||
});
|
||||
|
||||
const guildMemberships = await fetchMany<Pick<GuildMemberByUserIdRow, 'guild_id'>>(query.bind({user_id: userId}));
|
||||
|
||||
if (guildMemberships.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const guildIds = guildMemberships.map((m) => m.guild_id);
|
||||
const guilds = await fetchMany<GuildRow>(FETCH_GUILDS_BY_IDS_QUERY, {guild_ids: guildIds});
|
||||
return guilds.map((guild) => new Guild(guild));
|
||||
}
|
||||
|
||||
async countUserGuilds(userId: UserID): Promise<number> {
|
||||
const query = GuildMembersByUserId.select({
|
||||
columns: ['guild_id'],
|
||||
where: GuildMembersByUserId.where.eq('user_id'),
|
||||
});
|
||||
|
||||
const guildMemberships = await fetchMany<Pick<GuildMemberByUserIdRow, 'guild_id'>>(query.bind({user_id: userId}));
|
||||
return guildMemberships.length;
|
||||
}
|
||||
|
||||
async listOwnedGuildIds(userId: UserID): Promise<Array<GuildID>> {
|
||||
const userGuilds = await this.listUserGuilds(userId);
|
||||
return userGuilds.filter((guild) => guild.ownerId === userId).map((guild) => guild.id);
|
||||
}
|
||||
|
||||
async upsert(data: GuildRow, oldData?: GuildRow | null, _previousOwnerId?: UserID): Promise<Guild> {
|
||||
const guildId = data.guild_id;
|
||||
|
||||
const result = await executeVersionedUpdate<GuildRow, 'guild_id'>(
|
||||
async () => {
|
||||
if (oldData !== undefined) return oldData;
|
||||
return await fetchOne<GuildRow>(FETCH_GUILD_BY_ID_QUERY, {guild_id: guildId});
|
||||
},
|
||||
(current) => ({
|
||||
pk: {guild_id: guildId},
|
||||
patch: buildPatchFromData(data, current, GUILD_COLUMNS, ['guild_id']),
|
||||
}),
|
||||
Guilds,
|
||||
);
|
||||
|
||||
return new Guild({...data, version: result.finalVersion ?? 0});
|
||||
}
|
||||
|
||||
async delete(guildId: GuildID, _ownerId?: UserID): Promise<void> {
|
||||
const guild = await this.findUnique(guildId);
|
||||
if (!guild) {
|
||||
return;
|
||||
}
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(Guilds.deleteByPk({guild_id: guildId}));
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
139
packages/api/src/guild/repositories/GuildDiscoveryRepository.tsx
Normal file
139
packages/api/src/guild/repositories/GuildDiscoveryRepository.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {BatchBuilder, fetchMany, fetchOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {GuildDiscoveryByStatusRow, GuildDiscoveryRow} from '@fluxer/api/src/database/types/GuildDiscoveryTypes';
|
||||
import {GuildDiscovery, GuildDiscoveryByStatus} from '@fluxer/api/src/Tables';
|
||||
|
||||
const FETCH_DISCOVERY_BY_GUILD_ID = GuildDiscovery.selectCql({
|
||||
where: GuildDiscovery.where.eq('guild_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_DISCOVERY_BY_STATUS = GuildDiscoveryByStatus.selectCql({
|
||||
where: GuildDiscoveryByStatus.where.eq('status'),
|
||||
});
|
||||
|
||||
export abstract class IGuildDiscoveryRepository {
|
||||
abstract findByGuildId(guildId: GuildID): Promise<GuildDiscoveryRow | null>;
|
||||
abstract listByStatus(status: string, limit: number): Promise<Array<GuildDiscoveryByStatusRow>>;
|
||||
abstract upsert(row: GuildDiscoveryRow): Promise<void>;
|
||||
abstract deleteByGuildId(guildId: GuildID, status: string, appliedAt: Date): Promise<void>;
|
||||
abstract updateStatus(
|
||||
guildId: GuildID,
|
||||
oldStatus: string,
|
||||
oldAppliedAt: Date,
|
||||
updatedRow: GuildDiscoveryRow,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export class GuildDiscoveryRepository extends IGuildDiscoveryRepository {
|
||||
async findByGuildId(guildId: GuildID): Promise<GuildDiscoveryRow | null> {
|
||||
return fetchOne<GuildDiscoveryRow>(FETCH_DISCOVERY_BY_GUILD_ID, {
|
||||
guild_id: guildId,
|
||||
});
|
||||
}
|
||||
|
||||
async listByStatus(status: string, limit: number): Promise<Array<GuildDiscoveryByStatusRow>> {
|
||||
const rows = await fetchMany<GuildDiscoveryByStatusRow>(FETCH_DISCOVERY_BY_STATUS, {
|
||||
status,
|
||||
});
|
||||
return rows.slice(0, limit);
|
||||
}
|
||||
|
||||
async upsert(row: GuildDiscoveryRow): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(
|
||||
GuildDiscovery.insert({
|
||||
guild_id: row.guild_id,
|
||||
status: row.status,
|
||||
category_id: row.category_id,
|
||||
description: row.description,
|
||||
applied_at: row.applied_at,
|
||||
reviewed_at: row.reviewed_at,
|
||||
reviewed_by: row.reviewed_by,
|
||||
review_reason: row.review_reason,
|
||||
removed_at: row.removed_at,
|
||||
removed_by: row.removed_by,
|
||||
removal_reason: row.removal_reason,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(
|
||||
GuildDiscoveryByStatus.insert({
|
||||
status: row.status,
|
||||
applied_at: row.applied_at,
|
||||
guild_id: row.guild_id,
|
||||
}),
|
||||
);
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async deleteByGuildId(guildId: GuildID, status: string, appliedAt: Date): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(GuildDiscovery.deleteByPk({guild_id: guildId}));
|
||||
batch.addPrepared(
|
||||
GuildDiscoveryByStatus.deleteByPk({
|
||||
status,
|
||||
applied_at: appliedAt,
|
||||
guild_id: guildId,
|
||||
}),
|
||||
);
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
guildId: GuildID,
|
||||
oldStatus: string,
|
||||
oldAppliedAt: Date,
|
||||
updatedRow: GuildDiscoveryRow,
|
||||
): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(
|
||||
GuildDiscoveryByStatus.deleteByPk({
|
||||
status: oldStatus,
|
||||
applied_at: oldAppliedAt,
|
||||
guild_id: guildId,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(
|
||||
GuildDiscovery.insert({
|
||||
guild_id: updatedRow.guild_id,
|
||||
status: updatedRow.status,
|
||||
category_id: updatedRow.category_id,
|
||||
description: updatedRow.description,
|
||||
applied_at: updatedRow.applied_at,
|
||||
reviewed_at: updatedRow.reviewed_at,
|
||||
reviewed_by: updatedRow.reviewed_by,
|
||||
review_reason: updatedRow.review_reason,
|
||||
removed_at: updatedRow.removed_at,
|
||||
removed_by: updatedRow.removed_by,
|
||||
removal_reason: updatedRow.removal_reason,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(
|
||||
GuildDiscoveryByStatus.insert({
|
||||
status: updatedRow.status,
|
||||
applied_at: updatedRow.applied_at,
|
||||
guild_id: updatedRow.guild_id,
|
||||
}),
|
||||
);
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
133
packages/api/src/guild/repositories/GuildMemberRepository.tsx
Normal file
133
packages/api/src/guild/repositories/GuildMemberRepository.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {
|
||||
BatchBuilder,
|
||||
buildPatchFromData,
|
||||
executeVersionedUpdate,
|
||||
fetchMany,
|
||||
fetchOne,
|
||||
} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {GuildMemberRow} from '@fluxer/api/src/database/types/GuildTypes';
|
||||
import {GUILD_MEMBER_COLUMNS} from '@fluxer/api/src/database/types/GuildTypes';
|
||||
import {IGuildMemberRepository} from '@fluxer/api/src/guild/repositories/IGuildMemberRepository';
|
||||
import {GuildMember} from '@fluxer/api/src/models/GuildMember';
|
||||
import {GuildMembers, GuildMembersByUserId} from '@fluxer/api/src/Tables';
|
||||
|
||||
const FETCH_GUILD_MEMBER_BY_GUILD_AND_USER_ID_QUERY = GuildMembers.selectCql({
|
||||
where: [GuildMembers.where.eq('guild_id'), GuildMembers.where.eq('user_id')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_GUILD_MEMBERS_BY_GUILD_ID_QUERY = GuildMembers.selectCql({
|
||||
where: GuildMembers.where.eq('guild_id'),
|
||||
});
|
||||
|
||||
function createPaginatedFirstPageQuery(limit: number) {
|
||||
return GuildMembers.selectCql({
|
||||
where: GuildMembers.where.eq('guild_id'),
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
function createPaginatedQuery(limit: number) {
|
||||
return GuildMembers.selectCql({
|
||||
where: [GuildMembers.where.eq('guild_id'), GuildMembers.where.gt('user_id')],
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
export class GuildMemberRepository extends IGuildMemberRepository {
|
||||
async getMember(guildId: GuildID, userId: UserID): Promise<GuildMember | null> {
|
||||
const member = await fetchOne<GuildMemberRow>(FETCH_GUILD_MEMBER_BY_GUILD_AND_USER_ID_QUERY, {
|
||||
guild_id: guildId,
|
||||
user_id: userId,
|
||||
});
|
||||
return member ? new GuildMember(member) : null;
|
||||
}
|
||||
|
||||
async listMembers(guildId: GuildID): Promise<Array<GuildMember>> {
|
||||
const members = await fetchMany<GuildMemberRow>(FETCH_GUILD_MEMBERS_BY_GUILD_ID_QUERY, {
|
||||
guild_id: guildId,
|
||||
});
|
||||
return members.map((member) => new GuildMember(member));
|
||||
}
|
||||
|
||||
async upsertMember(data: GuildMemberRow, oldData?: GuildMemberRow | null): Promise<GuildMember> {
|
||||
const guildId = data.guild_id;
|
||||
const userId = data.user_id;
|
||||
|
||||
const result = await executeVersionedUpdate<GuildMemberRow, 'guild_id' | 'user_id'>(
|
||||
async () => {
|
||||
if (oldData !== undefined) return oldData;
|
||||
return await fetchOne<GuildMemberRow>(FETCH_GUILD_MEMBER_BY_GUILD_AND_USER_ID_QUERY, {
|
||||
guild_id: guildId,
|
||||
user_id: userId,
|
||||
});
|
||||
},
|
||||
(current) => ({
|
||||
pk: {guild_id: guildId, user_id: userId},
|
||||
patch: buildPatchFromData(data, current, GUILD_MEMBER_COLUMNS, ['guild_id', 'user_id']),
|
||||
}),
|
||||
GuildMembers,
|
||||
);
|
||||
|
||||
await fetchOne(
|
||||
GuildMembersByUserId.insert({
|
||||
user_id: userId,
|
||||
guild_id: guildId,
|
||||
}),
|
||||
);
|
||||
|
||||
return new GuildMember({...data, version: result.finalVersion ?? 1});
|
||||
}
|
||||
|
||||
async listMembersPaginated(guildId: GuildID, limit: number, afterUserId?: UserID): Promise<Array<GuildMember>> {
|
||||
let rows: Array<GuildMemberRow>;
|
||||
if (afterUserId) {
|
||||
rows = await fetchMany<GuildMemberRow>(createPaginatedQuery(limit), {
|
||||
guild_id: guildId,
|
||||
user_id: afterUserId,
|
||||
});
|
||||
} else {
|
||||
rows = await fetchMany<GuildMemberRow>(createPaginatedFirstPageQuery(limit), {
|
||||
guild_id: guildId,
|
||||
});
|
||||
}
|
||||
return rows.map((row) => new GuildMember(row));
|
||||
}
|
||||
|
||||
async deleteMember(guildId: GuildID, userId: UserID): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(
|
||||
GuildMembers.deleteByPk({
|
||||
guild_id: guildId,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(
|
||||
GuildMembersByUserId.deleteByPk({
|
||||
user_id: userId,
|
||||
guild_id: guildId,
|
||||
}),
|
||||
);
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {
|
||||
BatchBuilder,
|
||||
Db,
|
||||
deleteOneOrMany,
|
||||
executeVersionedUpdate,
|
||||
fetchMany,
|
||||
fetchOne,
|
||||
upsertOne,
|
||||
} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {GuildAuditLogRow, GuildBanRow, GuildRow} from '@fluxer/api/src/database/types/GuildTypes';
|
||||
import {IGuildModerationRepository} from '@fluxer/api/src/guild/repositories/IGuildModerationRepository';
|
||||
import {GuildAuditLog} from '@fluxer/api/src/models/GuildAuditLog';
|
||||
import {GuildBan} from '@fluxer/api/src/models/GuildBan';
|
||||
import {
|
||||
GuildAuditLogs,
|
||||
GuildAuditLogsByAction,
|
||||
GuildAuditLogsByUser,
|
||||
GuildAuditLogsByUserAction,
|
||||
GuildBans,
|
||||
Guilds,
|
||||
} from '@fluxer/api/src/Tables';
|
||||
import type {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {seconds} from 'itty-time';
|
||||
|
||||
const FETCH_GUILD_BAN_BY_GUILD_AND_USER_ID_QUERY = GuildBans.selectCql({
|
||||
where: [GuildBans.where.eq('guild_id'), GuildBans.where.eq('user_id')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_GUILD_BANS_BY_GUILD_ID_QUERY = GuildBans.selectCql({
|
||||
where: GuildBans.where.eq('guild_id'),
|
||||
});
|
||||
|
||||
const AUDIT_LOG_TTL_SECONDS = seconds('45 days');
|
||||
|
||||
const FETCH_GUILD_BY_ID_QUERY = Guilds.selectCql({
|
||||
where: Guilds.where.eq('guild_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_GUILD_AUDIT_LOG_QUERY = GuildAuditLogs.selectCql({
|
||||
where: [GuildAuditLogs.where.eq('guild_id'), GuildAuditLogs.where.eq('log_id')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_GUILD_AUDIT_LOGS_BY_IDS_QUERY = GuildAuditLogs.selectCql({
|
||||
where: [GuildAuditLogs.where.eq('guild_id'), GuildAuditLogs.where.in('log_id', 'log_ids')],
|
||||
});
|
||||
|
||||
export class GuildModerationRepository extends IGuildModerationRepository {
|
||||
async getBan(guildId: GuildID, userId: UserID): Promise<GuildBan | null> {
|
||||
const ban = await fetchOne<GuildBanRow>(FETCH_GUILD_BAN_BY_GUILD_AND_USER_ID_QUERY, {
|
||||
guild_id: guildId,
|
||||
user_id: userId,
|
||||
});
|
||||
return ban ? new GuildBan(ban) : null;
|
||||
}
|
||||
|
||||
async listBans(guildId: GuildID): Promise<Array<GuildBan>> {
|
||||
const bans = await fetchMany<GuildBanRow>(FETCH_GUILD_BANS_BY_GUILD_ID_QUERY, {
|
||||
guild_id: guildId,
|
||||
});
|
||||
return bans.map((ban) => new GuildBan(ban));
|
||||
}
|
||||
|
||||
async upsertBan(data: GuildBanRow): Promise<GuildBan> {
|
||||
if (data.expires_at) {
|
||||
const now = Date.now();
|
||||
const expiryTime = data.expires_at.getTime();
|
||||
const ttl = Math.max(0, Math.ceil((expiryTime - now) / 1000));
|
||||
await upsertOne(GuildBans.insertWithTtl(data, ttl));
|
||||
} else {
|
||||
await upsertOne(GuildBans.insert(data));
|
||||
}
|
||||
return new GuildBan(data);
|
||||
}
|
||||
|
||||
async deleteBan(guildId: GuildID, userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
GuildBans.deleteByPk({
|
||||
guild_id: guildId,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async createAuditLog(data: GuildAuditLogRow): Promise<GuildAuditLog> {
|
||||
const payload = {
|
||||
...data,
|
||||
options: data.options ?? null,
|
||||
changes: data.changes ?? null,
|
||||
};
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(GuildAuditLogs.insertWithTtl(payload, AUDIT_LOG_TTL_SECONDS));
|
||||
batch.addPrepared(GuildAuditLogsByUser.insertWithTtl(payload, AUDIT_LOG_TTL_SECONDS));
|
||||
batch.addPrepared(GuildAuditLogsByAction.insertWithTtl(payload, AUDIT_LOG_TTL_SECONDS));
|
||||
batch.addPrepared(GuildAuditLogsByUserAction.insertWithTtl(payload, AUDIT_LOG_TTL_SECONDS));
|
||||
await batch.execute();
|
||||
|
||||
return this.mapRowToGuildAuditLog(data);
|
||||
}
|
||||
|
||||
async getAuditLog(guildId: GuildID, logId: bigint): Promise<GuildAuditLog | null> {
|
||||
const row = await fetchOne<GuildAuditLogRow>(FETCH_GUILD_AUDIT_LOG_QUERY, {
|
||||
guild_id: guildId,
|
||||
log_id: logId,
|
||||
});
|
||||
return row ? this.mapRowToGuildAuditLog(row) : null;
|
||||
}
|
||||
|
||||
async listAuditLogs(params: {
|
||||
guildId: GuildID;
|
||||
limit: number;
|
||||
afterLogId?: bigint;
|
||||
beforeLogId?: bigint;
|
||||
userId?: UserID;
|
||||
actionType?: AuditLogActionType;
|
||||
}): Promise<Array<GuildAuditLog>> {
|
||||
const {guildId, limit, afterLogId, beforeLogId, userId, actionType} = params;
|
||||
const table = this.selectAuditLogTable(userId, actionType);
|
||||
const query = this.buildAuditLogSelectQuery(table, limit, beforeLogId, afterLogId, userId, actionType);
|
||||
|
||||
const values: {
|
||||
guild_id: GuildID;
|
||||
user_id?: UserID;
|
||||
action_type?: AuditLogActionType;
|
||||
before_log_id?: bigint;
|
||||
after_log_id?: bigint;
|
||||
} = {guild_id: guildId};
|
||||
|
||||
if (userId) {
|
||||
values.user_id = userId;
|
||||
}
|
||||
if (actionType !== undefined) {
|
||||
values.action_type = actionType;
|
||||
}
|
||||
if (beforeLogId) {
|
||||
values.before_log_id = beforeLogId;
|
||||
} else if (afterLogId) {
|
||||
values.after_log_id = afterLogId;
|
||||
}
|
||||
|
||||
const rows = await fetchMany<GuildAuditLogRow>(query, values);
|
||||
return rows.map((row) => this.mapRowToGuildAuditLog(row));
|
||||
}
|
||||
|
||||
async listAuditLogsByIds(guildId: GuildID, logIds: Array<bigint>): Promise<Array<GuildAuditLog>> {
|
||||
if (logIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await fetchMany<GuildAuditLogRow>(FETCH_GUILD_AUDIT_LOGS_BY_IDS_QUERY, {
|
||||
guild_id: guildId,
|
||||
log_ids: logIds,
|
||||
});
|
||||
return rows.map((row) => this.mapRowToGuildAuditLog(row));
|
||||
}
|
||||
|
||||
async deleteAuditLogs(guildId: GuildID, logs: Array<GuildAuditLog>): Promise<void> {
|
||||
if (logs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (const log of logs) {
|
||||
batch.addPrepared(
|
||||
GuildAuditLogs.deleteByPk({
|
||||
guild_id: guildId,
|
||||
log_id: log.logId,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(
|
||||
GuildAuditLogsByUser.deleteByPk({
|
||||
guild_id: guildId,
|
||||
user_id: log.userId,
|
||||
log_id: log.logId,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(
|
||||
GuildAuditLogsByAction.deleteByPk({
|
||||
guild_id: guildId,
|
||||
action_type: log.actionType,
|
||||
log_id: log.logId,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(
|
||||
GuildAuditLogsByUserAction.deleteByPk({
|
||||
guild_id: guildId,
|
||||
user_id: log.userId,
|
||||
action_type: log.actionType,
|
||||
log_id: log.logId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async batchDeleteAndCreateAuditLogs(
|
||||
guildId: GuildID,
|
||||
logsToDelete: Array<GuildAuditLog>,
|
||||
logToCreate: GuildAuditLogRow,
|
||||
): Promise<GuildAuditLog> {
|
||||
const payload = {
|
||||
...logToCreate,
|
||||
options: logToCreate.options ?? null,
|
||||
changes: logToCreate.changes ?? null,
|
||||
};
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
for (const log of logsToDelete) {
|
||||
batch.addPrepared(
|
||||
GuildAuditLogs.deleteByPk({
|
||||
guild_id: guildId,
|
||||
log_id: log.logId,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(
|
||||
GuildAuditLogsByUser.deleteByPk({
|
||||
guild_id: guildId,
|
||||
user_id: log.userId,
|
||||
log_id: log.logId,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(
|
||||
GuildAuditLogsByAction.deleteByPk({
|
||||
guild_id: guildId,
|
||||
action_type: log.actionType,
|
||||
log_id: log.logId,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(
|
||||
GuildAuditLogsByUserAction.deleteByPk({
|
||||
guild_id: guildId,
|
||||
user_id: log.userId,
|
||||
action_type: log.actionType,
|
||||
log_id: log.logId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
batch.addPrepared(GuildAuditLogs.insertWithTtl(payload, AUDIT_LOG_TTL_SECONDS));
|
||||
batch.addPrepared(GuildAuditLogsByUser.insertWithTtl(payload, AUDIT_LOG_TTL_SECONDS));
|
||||
batch.addPrepared(GuildAuditLogsByAction.insertWithTtl(payload, AUDIT_LOG_TTL_SECONDS));
|
||||
batch.addPrepared(GuildAuditLogsByUserAction.insertWithTtl(payload, AUDIT_LOG_TTL_SECONDS));
|
||||
|
||||
await batch.execute();
|
||||
|
||||
return this.mapRowToGuildAuditLog(payload);
|
||||
}
|
||||
|
||||
async updateAuditLogsIndexedAt(guildId: GuildID, indexedAt: Date | null): Promise<void> {
|
||||
await executeVersionedUpdate<GuildRow, 'guild_id'>(
|
||||
() => fetchOne<GuildRow>(FETCH_GUILD_BY_ID_QUERY, {guild_id: guildId}),
|
||||
(current) => {
|
||||
const patch: Record<string, ReturnType<typeof Db.set> | ReturnType<typeof Db.clear>> = {};
|
||||
|
||||
if (indexedAt !== null) {
|
||||
patch['audit_logs_indexed_at'] = Db.set(indexedAt);
|
||||
} else if (current?.audit_logs_indexed_at !== null && current?.audit_logs_indexed_at !== undefined) {
|
||||
patch['audit_logs_indexed_at'] = Db.clear();
|
||||
}
|
||||
|
||||
return {
|
||||
pk: {guild_id: guildId},
|
||||
patch,
|
||||
};
|
||||
},
|
||||
Guilds,
|
||||
);
|
||||
}
|
||||
|
||||
private mapRowToGuildAuditLog(row: GuildAuditLogRow): GuildAuditLog {
|
||||
return new GuildAuditLog(row);
|
||||
}
|
||||
|
||||
private buildAuditLogSelectQuery(
|
||||
table:
|
||||
| typeof GuildAuditLogs
|
||||
| typeof GuildAuditLogsByUser
|
||||
| typeof GuildAuditLogsByAction
|
||||
| typeof GuildAuditLogsByUserAction,
|
||||
limit: number,
|
||||
beforeLogId?: bigint,
|
||||
afterLogId?: bigint,
|
||||
userId?: UserID,
|
||||
actionType?: AuditLogActionType,
|
||||
): string {
|
||||
const where: Array<ReturnType<typeof table.where.eq | typeof table.where.lt | typeof table.where.gt>> = [
|
||||
table.where.eq('guild_id'),
|
||||
];
|
||||
|
||||
if (userId) {
|
||||
where.push(table.where.eq('user_id'));
|
||||
}
|
||||
if (actionType !== undefined) {
|
||||
where.push(table.where.eq('action_type'));
|
||||
}
|
||||
if (beforeLogId) {
|
||||
where.push(table.where.lt('log_id', 'before_log_id'));
|
||||
} else if (afterLogId) {
|
||||
where.push(table.where.gt('log_id', 'after_log_id'));
|
||||
}
|
||||
|
||||
return table.selectCql({
|
||||
where,
|
||||
limit,
|
||||
orderBy: {col: 'log_id', direction: 'DESC'},
|
||||
});
|
||||
}
|
||||
|
||||
private selectAuditLogTable(
|
||||
userId?: UserID,
|
||||
actionType?: AuditLogActionType,
|
||||
):
|
||||
| typeof GuildAuditLogs
|
||||
| typeof GuildAuditLogsByUser
|
||||
| typeof GuildAuditLogsByAction
|
||||
| typeof GuildAuditLogsByUserAction {
|
||||
if (userId && actionType !== undefined) {
|
||||
return GuildAuditLogsByUserAction;
|
||||
}
|
||||
if (userId) {
|
||||
return GuildAuditLogsByUser;
|
||||
}
|
||||
if (actionType !== undefined) {
|
||||
return GuildAuditLogsByAction;
|
||||
}
|
||||
return GuildAuditLogs;
|
||||
}
|
||||
}
|
||||
267
packages/api/src/guild/repositories/GuildRepository.tsx
Normal file
267
packages/api/src/guild/repositories/GuildRepository.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {EmojiID, GuildID, RoleID, StickerID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {
|
||||
GuildAuditLogRow,
|
||||
GuildBanRow,
|
||||
GuildEmojiRow,
|
||||
GuildMemberRow,
|
||||
GuildRoleRow,
|
||||
GuildRow,
|
||||
GuildStickerRow,
|
||||
} from '@fluxer/api/src/database/types/GuildTypes';
|
||||
import {GuildContentRepository} from '@fluxer/api/src/guild/repositories/GuildContentRepository';
|
||||
import {GuildDataRepository} from '@fluxer/api/src/guild/repositories/GuildDataRepository';
|
||||
import {GuildMemberRepository} from '@fluxer/api/src/guild/repositories/GuildMemberRepository';
|
||||
import {GuildModerationRepository} from '@fluxer/api/src/guild/repositories/GuildModerationRepository';
|
||||
import {GuildRoleRepository} from '@fluxer/api/src/guild/repositories/GuildRoleRepository';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {Guild} from '@fluxer/api/src/models/Guild';
|
||||
import type {GuildAuditLog} from '@fluxer/api/src/models/GuildAuditLog';
|
||||
import type {GuildBan} from '@fluxer/api/src/models/GuildBan';
|
||||
import type {GuildEmoji} from '@fluxer/api/src/models/GuildEmoji';
|
||||
import type {GuildMember} from '@fluxer/api/src/models/GuildMember';
|
||||
import type {GuildRole} from '@fluxer/api/src/models/GuildRole';
|
||||
import type {GuildSticker} from '@fluxer/api/src/models/GuildSticker';
|
||||
import type {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
|
||||
export class GuildRepository implements IGuildRepositoryAggregate {
|
||||
private dataRepo: GuildDataRepository;
|
||||
private memberRepo: GuildMemberRepository;
|
||||
private roleRepo: GuildRoleRepository;
|
||||
private moderationRepo: GuildModerationRepository;
|
||||
private contentRepo: GuildContentRepository;
|
||||
|
||||
constructor() {
|
||||
this.dataRepo = new GuildDataRepository();
|
||||
this.memberRepo = new GuildMemberRepository();
|
||||
this.roleRepo = new GuildRoleRepository();
|
||||
this.moderationRepo = new GuildModerationRepository();
|
||||
this.contentRepo = new GuildContentRepository();
|
||||
}
|
||||
|
||||
async findUnique(guildId: GuildID): Promise<Guild | null> {
|
||||
return await this.dataRepo.findUnique(guildId);
|
||||
}
|
||||
|
||||
async listGuilds(guildIds: Array<GuildID>): Promise<Array<Guild>> {
|
||||
return await this.dataRepo.listGuilds(guildIds);
|
||||
}
|
||||
|
||||
async listAllGuildsPaginated(limit: number, lastGuildId?: GuildID): Promise<Array<Guild>> {
|
||||
return await this.dataRepo.listAllGuildsPaginated(limit, lastGuildId);
|
||||
}
|
||||
|
||||
async listUserGuilds(userId: UserID): Promise<Array<Guild>> {
|
||||
return await this.dataRepo.listUserGuilds(userId);
|
||||
}
|
||||
|
||||
async countUserGuilds(userId: UserID): Promise<number> {
|
||||
return await this.dataRepo.countUserGuilds(userId);
|
||||
}
|
||||
|
||||
async listOwnedGuildIds(userId: UserID): Promise<Array<GuildID>> {
|
||||
return await this.dataRepo.listOwnedGuildIds(userId);
|
||||
}
|
||||
|
||||
async upsert(data: GuildRow, oldData?: GuildRow | null, previousOwnerId?: UserID): Promise<Guild> {
|
||||
return await this.dataRepo.upsert(data, oldData, previousOwnerId);
|
||||
}
|
||||
|
||||
async delete(guildId: GuildID, ownerId?: UserID): Promise<void> {
|
||||
const guild = await this.findUnique(guildId);
|
||||
if (!guild) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actualOwnerId = ownerId ?? guild.ownerId;
|
||||
|
||||
const [members, roles, emojis] = await Promise.all([
|
||||
this.memberRepo.listMembers(guildId),
|
||||
this.roleRepo.listRoles(guildId),
|
||||
this.contentRepo.listEmojis(guildId),
|
||||
]);
|
||||
|
||||
const BATCH_SIZE = 50;
|
||||
for (let i = 0; i < members.length; i += BATCH_SIZE) {
|
||||
const memberBatch = members.slice(i, i + BATCH_SIZE);
|
||||
await Promise.all(memberBatch.map((member) => this.memberRepo.deleteMember(guildId, member.userId)));
|
||||
}
|
||||
|
||||
for (let i = 0; i < roles.length; i += BATCH_SIZE) {
|
||||
const roleBatch = roles.slice(i, i + BATCH_SIZE);
|
||||
await Promise.all(roleBatch.map((role) => this.roleRepo.deleteRole(guildId, role.id)));
|
||||
}
|
||||
|
||||
for (let i = 0; i < emojis.length; i += BATCH_SIZE) {
|
||||
const emojiBatch = emojis.slice(i, i + BATCH_SIZE);
|
||||
await Promise.all(emojiBatch.map((emoji) => this.contentRepo.deleteEmoji(guildId, emoji.id)));
|
||||
}
|
||||
|
||||
await this.dataRepo.delete(guildId, actualOwnerId);
|
||||
}
|
||||
|
||||
async getMember(guildId: GuildID, userId: UserID): Promise<GuildMember | null> {
|
||||
return await this.memberRepo.getMember(guildId, userId);
|
||||
}
|
||||
|
||||
async listMembers(guildId: GuildID): Promise<Array<GuildMember>> {
|
||||
return await this.memberRepo.listMembers(guildId);
|
||||
}
|
||||
|
||||
async upsertMember(data: GuildMemberRow): Promise<GuildMember> {
|
||||
return await this.memberRepo.upsertMember(data);
|
||||
}
|
||||
|
||||
async listMembersPaginated(guildId: GuildID, limit: number, afterUserId?: UserID): Promise<Array<GuildMember>> {
|
||||
return await this.memberRepo.listMembersPaginated(guildId, limit, afterUserId);
|
||||
}
|
||||
|
||||
async deleteMember(guildId: GuildID, userId: UserID): Promise<void> {
|
||||
return await this.memberRepo.deleteMember(guildId, userId);
|
||||
}
|
||||
|
||||
async getRole(roleId: RoleID, guildId: GuildID): Promise<GuildRole | null> {
|
||||
return await this.roleRepo.getRole(roleId, guildId);
|
||||
}
|
||||
|
||||
async listRoles(guildId: GuildID): Promise<Array<GuildRole>> {
|
||||
return await this.roleRepo.listRoles(guildId);
|
||||
}
|
||||
|
||||
async listRolesByIds(roleIds: Array<RoleID>, guildId: GuildID): Promise<Array<GuildRole>> {
|
||||
return await this.roleRepo.listRolesByIds(roleIds, guildId);
|
||||
}
|
||||
|
||||
async countRoles(guildId: GuildID): Promise<number> {
|
||||
return await this.roleRepo.countRoles(guildId);
|
||||
}
|
||||
|
||||
async upsertRole(data: GuildRoleRow): Promise<GuildRole> {
|
||||
return await this.roleRepo.upsertRole(data);
|
||||
}
|
||||
|
||||
async deleteRole(guildId: GuildID, roleId: RoleID): Promise<void> {
|
||||
return await this.roleRepo.deleteRole(guildId, roleId);
|
||||
}
|
||||
|
||||
async getBan(guildId: GuildID, userId: UserID): Promise<GuildBan | null> {
|
||||
return await this.moderationRepo.getBan(guildId, userId);
|
||||
}
|
||||
|
||||
async listBans(guildId: GuildID): Promise<Array<GuildBan>> {
|
||||
return await this.moderationRepo.listBans(guildId);
|
||||
}
|
||||
|
||||
async upsertBan(data: GuildBanRow): Promise<GuildBan> {
|
||||
return await this.moderationRepo.upsertBan(data);
|
||||
}
|
||||
|
||||
async deleteBan(guildId: GuildID, userId: UserID): Promise<void> {
|
||||
return await this.moderationRepo.deleteBan(guildId, userId);
|
||||
}
|
||||
|
||||
async createAuditLog(data: GuildAuditLogRow): Promise<GuildAuditLog> {
|
||||
return await this.moderationRepo.createAuditLog(data);
|
||||
}
|
||||
|
||||
async getAuditLog(guildId: GuildID, logId: bigint): Promise<GuildAuditLog | null> {
|
||||
return await this.moderationRepo.getAuditLog(guildId, logId);
|
||||
}
|
||||
|
||||
async listAuditLogs(params: {
|
||||
guildId: GuildID;
|
||||
limit: number;
|
||||
afterLogId?: bigint;
|
||||
beforeLogId?: bigint;
|
||||
userId?: UserID;
|
||||
actionType?: AuditLogActionType;
|
||||
}): Promise<Array<GuildAuditLog>> {
|
||||
return await this.moderationRepo.listAuditLogs(params);
|
||||
}
|
||||
|
||||
async listAuditLogsByIds(guildId: GuildID, logIds: Array<bigint>): Promise<Array<GuildAuditLog>> {
|
||||
return await this.moderationRepo.listAuditLogsByIds(guildId, logIds);
|
||||
}
|
||||
|
||||
async deleteAuditLogs(guildId: GuildID, logs: Array<GuildAuditLog>): Promise<void> {
|
||||
return await this.moderationRepo.deleteAuditLogs(guildId, logs);
|
||||
}
|
||||
|
||||
async batchDeleteAndCreateAuditLogs(
|
||||
guildId: GuildID,
|
||||
logsToDelete: Array<GuildAuditLog>,
|
||||
logToCreate: GuildAuditLogRow,
|
||||
): Promise<GuildAuditLog> {
|
||||
return await this.moderationRepo.batchDeleteAndCreateAuditLogs(guildId, logsToDelete, logToCreate);
|
||||
}
|
||||
|
||||
async updateAuditLogsIndexedAt(guildId: GuildID, indexedAt: Date | null): Promise<void> {
|
||||
return await this.moderationRepo.updateAuditLogsIndexedAt(guildId, indexedAt);
|
||||
}
|
||||
|
||||
async getEmoji(emojiId: EmojiID, guildId: GuildID): Promise<GuildEmoji | null> {
|
||||
return await this.contentRepo.getEmoji(emojiId, guildId);
|
||||
}
|
||||
|
||||
async getEmojiById(emojiId: EmojiID): Promise<GuildEmoji | null> {
|
||||
return await this.contentRepo.getEmojiById(emojiId);
|
||||
}
|
||||
|
||||
async listEmojis(guildId: GuildID): Promise<Array<GuildEmoji>> {
|
||||
return await this.contentRepo.listEmojis(guildId);
|
||||
}
|
||||
|
||||
async countEmojis(guildId: GuildID): Promise<number> {
|
||||
return await this.contentRepo.countEmojis(guildId);
|
||||
}
|
||||
|
||||
async upsertEmoji(data: GuildEmojiRow): Promise<GuildEmoji> {
|
||||
return await this.contentRepo.upsertEmoji(data);
|
||||
}
|
||||
|
||||
async deleteEmoji(guildId: GuildID, emojiId: EmojiID): Promise<void> {
|
||||
return await this.contentRepo.deleteEmoji(guildId, emojiId);
|
||||
}
|
||||
|
||||
async getSticker(stickerId: StickerID, guildId: GuildID): Promise<GuildSticker | null> {
|
||||
return await this.contentRepo.getSticker(stickerId, guildId);
|
||||
}
|
||||
|
||||
async getStickerById(stickerId: StickerID): Promise<GuildSticker | null> {
|
||||
return await this.contentRepo.getStickerById(stickerId);
|
||||
}
|
||||
|
||||
async listStickers(guildId: GuildID): Promise<Array<GuildSticker>> {
|
||||
return await this.contentRepo.listStickers(guildId);
|
||||
}
|
||||
|
||||
async countStickers(guildId: GuildID): Promise<number> {
|
||||
return await this.contentRepo.countStickers(guildId);
|
||||
}
|
||||
|
||||
async upsertSticker(data: GuildStickerRow): Promise<GuildSticker> {
|
||||
return await this.contentRepo.upsertSticker(data);
|
||||
}
|
||||
|
||||
async deleteSticker(guildId: GuildID, stickerId: StickerID): Promise<void> {
|
||||
return await this.contentRepo.deleteSticker(guildId, stickerId);
|
||||
}
|
||||
}
|
||||
110
packages/api/src/guild/repositories/GuildRoleRepository.tsx
Normal file
110
packages/api/src/guild/repositories/GuildRoleRepository.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, RoleID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {
|
||||
buildPatchFromData,
|
||||
deleteOneOrMany,
|
||||
executeVersionedUpdate,
|
||||
fetchMany,
|
||||
fetchOne,
|
||||
} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {GuildRoleRow} from '@fluxer/api/src/database/types/GuildTypes';
|
||||
import {GUILD_ROLE_COLUMNS} from '@fluxer/api/src/database/types/GuildTypes';
|
||||
import {IGuildRoleRepository} from '@fluxer/api/src/guild/repositories/IGuildRoleRepository';
|
||||
import {GuildRole} from '@fluxer/api/src/models/GuildRole';
|
||||
import {GuildRoles} from '@fluxer/api/src/Tables';
|
||||
|
||||
const FETCH_GUILD_ROLE_BY_ID_QUERY = GuildRoles.selectCql({
|
||||
where: [GuildRoles.where.eq('guild_id'), GuildRoles.where.eq('role_id')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_GUILD_ROLES_BY_GUILD_ID_QUERY = GuildRoles.selectCql({
|
||||
where: GuildRoles.where.eq('guild_id'),
|
||||
});
|
||||
|
||||
const FETCH_ROLES_BY_IDS_QUERY = GuildRoles.selectCql({
|
||||
where: [GuildRoles.where.eq('guild_id'), GuildRoles.where.in('role_id', 'role_ids')],
|
||||
});
|
||||
|
||||
export class GuildRoleRepository extends IGuildRoleRepository {
|
||||
async getRole(roleId: RoleID, guildId: GuildID): Promise<GuildRole | null> {
|
||||
const role = await fetchOne<GuildRoleRow>(FETCH_GUILD_ROLE_BY_ID_QUERY, {
|
||||
guild_id: guildId,
|
||||
role_id: roleId,
|
||||
});
|
||||
return role ? new GuildRole(role) : null;
|
||||
}
|
||||
|
||||
async listRoles(guildId: GuildID): Promise<Array<GuildRole>> {
|
||||
const roles = await fetchMany<GuildRoleRow>(FETCH_GUILD_ROLES_BY_GUILD_ID_QUERY, {
|
||||
guild_id: guildId,
|
||||
});
|
||||
return roles.map((role) => new GuildRole(role));
|
||||
}
|
||||
|
||||
async listRolesByIds(roleIds: Array<RoleID>, guildId: GuildID): Promise<Array<GuildRole>> {
|
||||
if (roleIds.length === 0) return [];
|
||||
|
||||
const roles = await fetchMany<GuildRoleRow>(FETCH_ROLES_BY_IDS_QUERY, {
|
||||
guild_id: guildId,
|
||||
role_ids: roleIds,
|
||||
});
|
||||
return roles.map((role) => new GuildRole(role));
|
||||
}
|
||||
|
||||
async countRoles(guildId: GuildID): Promise<number> {
|
||||
const roles = await fetchMany<GuildRoleRow>(FETCH_GUILD_ROLES_BY_GUILD_ID_QUERY, {
|
||||
guild_id: guildId,
|
||||
});
|
||||
return roles.length;
|
||||
}
|
||||
|
||||
async upsertRole(data: GuildRoleRow, oldData?: GuildRoleRow | null): Promise<GuildRole> {
|
||||
const guildId = data.guild_id;
|
||||
const roleId = data.role_id;
|
||||
|
||||
const result = await executeVersionedUpdate<GuildRoleRow, 'guild_id' | 'role_id'>(
|
||||
async () => {
|
||||
if (oldData !== undefined) return oldData;
|
||||
return await fetchOne<GuildRoleRow>(FETCH_GUILD_ROLE_BY_ID_QUERY, {
|
||||
guild_id: guildId,
|
||||
role_id: roleId,
|
||||
});
|
||||
},
|
||||
(current) => ({
|
||||
pk: {guild_id: guildId, role_id: roleId},
|
||||
patch: buildPatchFromData(data, current, GUILD_ROLE_COLUMNS, ['guild_id', 'role_id']),
|
||||
}),
|
||||
GuildRoles,
|
||||
);
|
||||
|
||||
return new GuildRole({...data, version: result.finalVersion ?? 1});
|
||||
}
|
||||
|
||||
async deleteRole(guildId: GuildID, roleId: RoleID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
GuildRoles.deleteByPk({
|
||||
guild_id: guildId,
|
||||
role_id: roleId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {EmojiID, GuildID, StickerID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {GuildEmojiRow, GuildStickerRow} from '@fluxer/api/src/database/types/GuildTypes';
|
||||
import type {GuildEmoji} from '@fluxer/api/src/models/GuildEmoji';
|
||||
import type {GuildSticker} from '@fluxer/api/src/models/GuildSticker';
|
||||
|
||||
export abstract class IGuildContentRepository {
|
||||
abstract getEmoji(emojiId: EmojiID, guildId: GuildID): Promise<GuildEmoji | null>;
|
||||
abstract getEmojiById(emojiId: EmojiID): Promise<GuildEmoji | null>;
|
||||
abstract listEmojis(guildId: GuildID): Promise<Array<GuildEmoji>>;
|
||||
abstract countEmojis(guildId: GuildID): Promise<number>;
|
||||
abstract upsertEmoji(data: GuildEmojiRow): Promise<GuildEmoji>;
|
||||
abstract deleteEmoji(guildId: GuildID, emojiId: EmojiID): Promise<void>;
|
||||
abstract getSticker(stickerId: StickerID, guildId: GuildID): Promise<GuildSticker | null>;
|
||||
abstract getStickerById(stickerId: StickerID): Promise<GuildSticker | null>;
|
||||
abstract listStickers(guildId: GuildID): Promise<Array<GuildSticker>>;
|
||||
abstract countStickers(guildId: GuildID): Promise<number>;
|
||||
abstract upsertSticker(data: GuildStickerRow): Promise<GuildSticker>;
|
||||
abstract deleteSticker(guildId: GuildID, stickerId: StickerID): Promise<void>;
|
||||
}
|
||||
33
packages/api/src/guild/repositories/IGuildDataRepository.tsx
Normal file
33
packages/api/src/guild/repositories/IGuildDataRepository.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {GuildRow} from '@fluxer/api/src/database/types/GuildTypes';
|
||||
import type {Guild} from '@fluxer/api/src/models/Guild';
|
||||
|
||||
export abstract class IGuildDataRepository {
|
||||
abstract findUnique(guildId: GuildID): Promise<Guild | null>;
|
||||
abstract listGuilds(guildIds: Array<GuildID>): Promise<Array<Guild>>;
|
||||
abstract listAllGuildsPaginated(limit: number, lastGuildId?: GuildID): Promise<Array<Guild>>;
|
||||
abstract listUserGuilds(userId: UserID): Promise<Array<Guild>>;
|
||||
abstract countUserGuilds(userId: UserID): Promise<number>;
|
||||
abstract listOwnedGuildIds(userId: UserID): Promise<Array<GuildID>>;
|
||||
abstract upsert(data: GuildRow, oldData?: GuildRow | null, previousOwnerId?: UserID): Promise<Guild>;
|
||||
abstract delete(guildId: GuildID, ownerId?: UserID): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {GuildMemberRow} from '@fluxer/api/src/database/types/GuildTypes';
|
||||
import type {GuildMember} from '@fluxer/api/src/models/GuildMember';
|
||||
|
||||
export abstract class IGuildMemberRepository {
|
||||
abstract getMember(guildId: GuildID, userId: UserID): Promise<GuildMember | null>;
|
||||
abstract listMembers(guildId: GuildID): Promise<Array<GuildMember>>;
|
||||
abstract upsertMember(data: GuildMemberRow): Promise<GuildMember>;
|
||||
abstract deleteMember(guildId: GuildID, userId: UserID): Promise<void>;
|
||||
abstract listMembersPaginated(guildId: GuildID, limit: number, afterUserId?: UserID): Promise<Array<GuildMember>>;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {GuildAuditLogRow, GuildBanRow} from '@fluxer/api/src/database/types/GuildTypes';
|
||||
import type {GuildAuditLog} from '@fluxer/api/src/models/GuildAuditLog';
|
||||
import type {GuildBan} from '@fluxer/api/src/models/GuildBan';
|
||||
import type {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
|
||||
export abstract class IGuildModerationRepository {
|
||||
abstract getBan(guildId: GuildID, userId: UserID): Promise<GuildBan | null>;
|
||||
abstract listBans(guildId: GuildID): Promise<Array<GuildBan>>;
|
||||
abstract upsertBan(data: GuildBanRow): Promise<GuildBan>;
|
||||
abstract deleteBan(guildId: GuildID, userId: UserID): Promise<void>;
|
||||
abstract createAuditLog(data: GuildAuditLogRow): Promise<GuildAuditLog>;
|
||||
abstract batchDeleteAndCreateAuditLogs(
|
||||
guildId: GuildID,
|
||||
logsToDelete: Array<GuildAuditLog>,
|
||||
logToCreate: GuildAuditLogRow,
|
||||
): Promise<GuildAuditLog>;
|
||||
abstract getAuditLog(guildId: GuildID, logId: bigint): Promise<GuildAuditLog | null>;
|
||||
abstract listAuditLogs(params: {
|
||||
guildId: GuildID;
|
||||
limit: number;
|
||||
afterLogId?: bigint;
|
||||
beforeLogId?: bigint;
|
||||
userId?: UserID;
|
||||
actionType?: AuditLogActionType;
|
||||
}): Promise<Array<GuildAuditLog>>;
|
||||
abstract listAuditLogsByIds(guildId: GuildID, logIds: Array<bigint>): Promise<Array<GuildAuditLog>>;
|
||||
abstract deleteAuditLogs(guildId: GuildID, logs: Array<GuildAuditLog>): Promise<void>;
|
||||
abstract updateAuditLogsIndexedAt(guildId: GuildID, indexedAt: Date | null): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {IGuildContentRepository} from '@fluxer/api/src/guild/repositories/IGuildContentRepository';
|
||||
import type {IGuildDataRepository} from '@fluxer/api/src/guild/repositories/IGuildDataRepository';
|
||||
import type {IGuildMemberRepository} from '@fluxer/api/src/guild/repositories/IGuildMemberRepository';
|
||||
import type {IGuildModerationRepository} from '@fluxer/api/src/guild/repositories/IGuildModerationRepository';
|
||||
import type {IGuildRoleRepository} from '@fluxer/api/src/guild/repositories/IGuildRoleRepository';
|
||||
|
||||
export interface IGuildRepositoryAggregate
|
||||
extends IGuildDataRepository,
|
||||
IGuildMemberRepository,
|
||||
IGuildRoleRepository,
|
||||
IGuildModerationRepository,
|
||||
IGuildContentRepository {}
|
||||
31
packages/api/src/guild/repositories/IGuildRoleRepository.tsx
Normal file
31
packages/api/src/guild/repositories/IGuildRoleRepository.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, RoleID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {GuildRoleRow} from '@fluxer/api/src/database/types/GuildTypes';
|
||||
import type {GuildRole} from '@fluxer/api/src/models/GuildRole';
|
||||
|
||||
export abstract class IGuildRoleRepository {
|
||||
abstract getRole(roleId: RoleID, guildId: GuildID): Promise<GuildRole | null>;
|
||||
abstract listRoles(guildId: GuildID): Promise<Array<GuildRole>>;
|
||||
abstract listRolesByIds(roleIds: Array<RoleID>, guildId: GuildID): Promise<Array<GuildRole>>;
|
||||
abstract countRoles(guildId: GuildID): Promise<number>;
|
||||
abstract upsertRole(data: GuildRoleRow): Promise<GuildRole>;
|
||||
abstract deleteRole(guildId: GuildID, roleId: RoleID): Promise<void>;
|
||||
}
|
||||
147
packages/api/src/guild/services/GuildChannelService.tsx
Normal file
147
packages/api/src/guild/services/GuildChannelService.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {mapChannelToResponse} from '@fluxer/api/src/channel/ChannelMappers';
|
||||
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
|
||||
import type {GuildAuditLogService} from '@fluxer/api/src/guild/GuildAuditLogService';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import {ChannelOperationsService} from '@fluxer/api/src/guild/services/channel/ChannelOperationsService';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import type {ChannelCreateRequest} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
|
||||
export class GuildChannelService {
|
||||
private readonly channelOps: ChannelOperationsService;
|
||||
|
||||
constructor(
|
||||
private readonly channelRepository: IChannelRepository,
|
||||
guildRepository: IGuildRepositoryAggregate,
|
||||
private readonly userCacheService: UserCacheService,
|
||||
private readonly gatewayService: IGatewayService,
|
||||
cacheService: ICacheService,
|
||||
snowflakeService: SnowflakeService,
|
||||
guildAuditLogService: GuildAuditLogService,
|
||||
limitConfigService: LimitConfigService,
|
||||
) {
|
||||
this.channelOps = new ChannelOperationsService(
|
||||
channelRepository,
|
||||
guildRepository,
|
||||
userCacheService,
|
||||
gatewayService,
|
||||
cacheService,
|
||||
snowflakeService,
|
||||
guildAuditLogService,
|
||||
limitConfigService,
|
||||
);
|
||||
}
|
||||
|
||||
async getChannels(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Array<ChannelResponse>> {
|
||||
try {
|
||||
await this.gatewayService.getGuildData({guildId: params.guildId, userId: params.userId});
|
||||
} catch (error) {
|
||||
if (error instanceof UnknownGuildError) {
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const viewableChannelIds = await this.gatewayService.getViewableChannels({
|
||||
guildId: params.guildId,
|
||||
userId: params.userId,
|
||||
});
|
||||
const channels = await this.channelRepository.listGuildChannels(params.guildId);
|
||||
const viewableChannels = channels.filter((channel) => viewableChannelIds.includes(channel.id));
|
||||
|
||||
return Promise.all(
|
||||
viewableChannels.map((channel) => {
|
||||
return mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: null,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache: params.requestCache,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async createChannel(
|
||||
params: {userId: UserID; guildId: GuildID; data: ChannelCreateRequest; requestCache: RequestCache},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<ChannelResponse> {
|
||||
await this.checkPermission({
|
||||
userId: params.userId,
|
||||
guildId: params.guildId,
|
||||
permission: Permissions.MANAGE_CHANNELS,
|
||||
});
|
||||
return this.channelOps.createChannel(params, auditLogReason);
|
||||
}
|
||||
|
||||
async updateChannelPositions(
|
||||
params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
updates: Array<{
|
||||
channelId: ChannelID;
|
||||
position?: number;
|
||||
parentId: ChannelID | null | undefined;
|
||||
lockPermissions: boolean;
|
||||
}>;
|
||||
requestCache: RequestCache;
|
||||
},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
await this.checkPermission({
|
||||
userId: params.userId,
|
||||
guildId: params.guildId,
|
||||
permission: Permissions.MANAGE_CHANNELS,
|
||||
});
|
||||
await this.channelOps.updateChannelPositionsByList({
|
||||
userId: params.userId,
|
||||
guildId: params.guildId,
|
||||
updates: params.updates,
|
||||
requestCache: params.requestCache,
|
||||
auditLogReason: auditLogReason ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async sanitizeTextChannelNames(params: {guildId: GuildID; requestCache: RequestCache}): Promise<void> {
|
||||
await this.channelOps.sanitizeTextChannelNames(params);
|
||||
}
|
||||
|
||||
private async checkPermission(params: {userId: UserID; guildId: GuildID; permission: bigint}): Promise<void> {
|
||||
const hasPermission = await this.gatewayService.checkPermission({
|
||||
guildId: params.guildId,
|
||||
userId: params.userId,
|
||||
permission: params.permission,
|
||||
});
|
||||
if (!hasPermission) throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
195
packages/api/src/guild/services/GuildContentService.tsx
Normal file
195
packages/api/src/guild/services/GuildContentService.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {EmojiID, GuildID, StickerID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {GuildAuditLogService} from '@fluxer/api/src/guild/GuildAuditLogService';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import {ContentHelpers} from '@fluxer/api/src/guild/services/content/ContentHelpers';
|
||||
import {EmojiService} from '@fluxer/api/src/guild/services/content/EmojiService';
|
||||
import {ExpressionAssetPurger} from '@fluxer/api/src/guild/services/content/ExpressionAssetPurger';
|
||||
import {StickerService} from '@fluxer/api/src/guild/services/content/StickerService';
|
||||
import type {AvatarService} from '@fluxer/api/src/infrastructure/AvatarService';
|
||||
import type {IAssetDeletionQueue} from '@fluxer/api/src/infrastructure/IAssetDeletionQueue';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {
|
||||
GuildEmojiResponse,
|
||||
GuildEmojiWithUserResponse,
|
||||
GuildStickerResponse,
|
||||
GuildStickerWithUserResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
|
||||
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
|
||||
export class GuildContentService {
|
||||
private readonly contentHelpers: ContentHelpers;
|
||||
private readonly emojiService: EmojiService;
|
||||
private readonly stickerService: StickerService;
|
||||
|
||||
constructor(
|
||||
guildRepository: IGuildRepositoryAggregate,
|
||||
userCacheService: UserCacheService,
|
||||
gatewayService: IGatewayService,
|
||||
avatarService: AvatarService,
|
||||
snowflakeService: SnowflakeService,
|
||||
guildAuditLogService: GuildAuditLogService,
|
||||
assetDeletionQueue: IAssetDeletionQueue,
|
||||
limitConfigService: LimitConfigService,
|
||||
) {
|
||||
this.contentHelpers = new ContentHelpers(gatewayService, guildAuditLogService);
|
||||
const expressionAssetPurger = new ExpressionAssetPurger(assetDeletionQueue);
|
||||
this.emojiService = new EmojiService(
|
||||
guildRepository,
|
||||
userCacheService,
|
||||
gatewayService,
|
||||
avatarService,
|
||||
snowflakeService,
|
||||
this.contentHelpers,
|
||||
expressionAssetPurger,
|
||||
limitConfigService,
|
||||
);
|
||||
this.stickerService = new StickerService(
|
||||
guildRepository,
|
||||
userCacheService,
|
||||
gatewayService,
|
||||
avatarService,
|
||||
snowflakeService,
|
||||
this.contentHelpers,
|
||||
expressionAssetPurger,
|
||||
limitConfigService,
|
||||
);
|
||||
}
|
||||
|
||||
async getEmojis(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Array<GuildEmojiWithUserResponse>> {
|
||||
return this.emojiService.getEmojis(params);
|
||||
}
|
||||
|
||||
async getEmojiUser(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
emojiId: EmojiID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<UserPartialResponse> {
|
||||
return this.emojiService.getEmojiUser(params);
|
||||
}
|
||||
|
||||
async createEmoji(
|
||||
params: {user: User; guildId: GuildID; name: string; image: string},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<GuildEmojiResponse> {
|
||||
return this.emojiService.createEmoji(params, auditLogReason);
|
||||
}
|
||||
|
||||
async bulkCreateEmojis(
|
||||
params: {user: User; guildId: GuildID; emojis: Array<{name: string; image: string}>},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<{
|
||||
success: Array<GuildEmojiResponse>;
|
||||
failed: Array<{name: string; error: string}>;
|
||||
}> {
|
||||
return this.emojiService.bulkCreateEmojis(params, auditLogReason);
|
||||
}
|
||||
|
||||
async updateEmoji(
|
||||
params: {userId: UserID; guildId: GuildID; emojiId: EmojiID; name: string},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<GuildEmojiResponse> {
|
||||
return this.emojiService.updateEmoji(params, auditLogReason);
|
||||
}
|
||||
|
||||
async deleteEmoji(
|
||||
params: {userId: UserID; guildId: GuildID; emojiId: EmojiID; purge?: boolean},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
return this.emojiService.deleteEmoji(params, auditLogReason);
|
||||
}
|
||||
|
||||
async getStickers(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Array<GuildStickerWithUserResponse>> {
|
||||
return this.stickerService.getStickers(params);
|
||||
}
|
||||
|
||||
async getStickerUser(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
stickerId: StickerID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<UserPartialResponse> {
|
||||
return this.stickerService.getStickerUser(params);
|
||||
}
|
||||
|
||||
async createSticker(
|
||||
params: {
|
||||
user: User;
|
||||
guildId: GuildID;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
tags: Array<string>;
|
||||
image: string;
|
||||
},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<GuildStickerResponse> {
|
||||
return this.stickerService.createSticker(params, auditLogReason);
|
||||
}
|
||||
|
||||
async bulkCreateStickers(
|
||||
params: {
|
||||
user: User;
|
||||
guildId: GuildID;
|
||||
stickers: Array<{name: string; description?: string | null; tags: Array<string>; image: string}>;
|
||||
},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<{
|
||||
success: Array<GuildStickerResponse>;
|
||||
failed: Array<{name: string; error: string}>;
|
||||
}> {
|
||||
return this.stickerService.bulkCreateStickers(params, auditLogReason);
|
||||
}
|
||||
|
||||
async updateSticker(
|
||||
params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
stickerId: StickerID;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
tags: Array<string>;
|
||||
},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<GuildStickerResponse> {
|
||||
return this.stickerService.updateSticker(params, auditLogReason);
|
||||
}
|
||||
|
||||
async deleteSticker(
|
||||
params: {userId: UserID; guildId: GuildID; stickerId: StickerID; purge?: boolean},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
return this.stickerService.deleteSticker(params, auditLogReason);
|
||||
}
|
||||
}
|
||||
157
packages/api/src/guild/services/GuildDataService.tsx
Normal file
157
packages/api/src/guild/services/GuildDataService.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
|
||||
import type {ChannelService} from '@fluxer/api/src/channel/services/ChannelService';
|
||||
import type {GuildAuditLogService} from '@fluxer/api/src/guild/GuildAuditLogService';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import {GuildDataHelpers} from '@fluxer/api/src/guild/services/data/GuildDataHelpers';
|
||||
import {GuildOperationsService} from '@fluxer/api/src/guild/services/data/GuildOperationsService';
|
||||
import {GuildOwnershipService} from '@fluxer/api/src/guild/services/data/GuildOwnershipService';
|
||||
import {GuildVanityService} from '@fluxer/api/src/guild/services/data/GuildVanityService';
|
||||
import type {EntityAssetService} from '@fluxer/api/src/infrastructure/EntityAssetService';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {InviteRepository} from '@fluxer/api/src/invite/InviteRepository';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Guild} from '@fluxer/api/src/models/Guild';
|
||||
import type {GuildMember} from '@fluxer/api/src/models/GuildMember';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {GuildManagedTraitService} from '@fluxer/api/src/traits/GuildManagedTraitService';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import type {IWebhookRepository} from '@fluxer/api/src/webhook/IWebhookRepository';
|
||||
import type {GuildCreateRequest, GuildUpdateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
|
||||
import type {
|
||||
GuildPartialResponse,
|
||||
GuildResponse,
|
||||
GuildVanityURLResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
|
||||
export class GuildDataService {
|
||||
private readonly helpers: GuildDataHelpers;
|
||||
private readonly operationsService: GuildOperationsService;
|
||||
private readonly vanityService: GuildVanityService;
|
||||
private readonly ownershipService: GuildOwnershipService;
|
||||
|
||||
constructor(
|
||||
private readonly guildRepository: IGuildRepositoryAggregate,
|
||||
private readonly channelRepository: IChannelRepository,
|
||||
private readonly inviteRepository: InviteRepository,
|
||||
private readonly channelService: ChannelService,
|
||||
private readonly gatewayService: IGatewayService,
|
||||
private readonly entityAssetService: EntityAssetService,
|
||||
private readonly userRepository: IUserRepository,
|
||||
private readonly snowflakeService: SnowflakeService,
|
||||
private readonly webhookRepository: IWebhookRepository,
|
||||
private readonly guildAuditLogService: GuildAuditLogService,
|
||||
private readonly limitConfigService: LimitConfigService,
|
||||
private readonly guildManagedTraitService?: GuildManagedTraitService,
|
||||
) {
|
||||
this.helpers = new GuildDataHelpers(this.gatewayService, this.guildAuditLogService);
|
||||
|
||||
this.operationsService = new GuildOperationsService(
|
||||
this.guildRepository,
|
||||
this.channelRepository,
|
||||
this.inviteRepository,
|
||||
this.channelService,
|
||||
this.gatewayService,
|
||||
this.entityAssetService,
|
||||
this.userRepository,
|
||||
this.snowflakeService,
|
||||
this.webhookRepository,
|
||||
this.helpers,
|
||||
this.limitConfigService,
|
||||
this.guildManagedTraitService,
|
||||
);
|
||||
|
||||
this.vanityService = new GuildVanityService(this.guildRepository, this.inviteRepository, this.helpers);
|
||||
|
||||
this.ownershipService = new GuildOwnershipService(this.guildRepository, this.userRepository, this.helpers);
|
||||
}
|
||||
|
||||
async getGuild({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildResponse> {
|
||||
return this.operationsService.getGuild({userId, guildId});
|
||||
}
|
||||
|
||||
async getUserGuilds(
|
||||
userId: UserID,
|
||||
options?: {
|
||||
before?: GuildID;
|
||||
after?: GuildID;
|
||||
limit?: number;
|
||||
withCounts?: boolean;
|
||||
},
|
||||
): Promise<Array<GuildResponse>> {
|
||||
return this.operationsService.getUserGuilds(userId, options);
|
||||
}
|
||||
|
||||
async getPublicGuildData(guildId: GuildID): Promise<GuildPartialResponse> {
|
||||
return this.operationsService.getPublicGuildData(guildId);
|
||||
}
|
||||
|
||||
async getGuildSystem(guildId: GuildID): Promise<Guild> {
|
||||
return this.operationsService.getGuildSystem(guildId);
|
||||
}
|
||||
|
||||
async createGuild(
|
||||
params: {user: User; data: GuildCreateRequest},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<GuildResponse> {
|
||||
return this.operationsService.createGuild(params, auditLogReason);
|
||||
}
|
||||
|
||||
async updateGuild(
|
||||
params: {userId: UserID; guildId: GuildID; data: GuildUpdateRequest; requestCache: RequestCache},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<GuildResponse> {
|
||||
return this.operationsService.updateGuild(params, auditLogReason);
|
||||
}
|
||||
|
||||
async getVanityURL(params: {userId: UserID; guildId: GuildID}): Promise<GuildVanityURLResponse> {
|
||||
return this.vanityService.getVanityURL(params);
|
||||
}
|
||||
|
||||
async updateVanityURL(
|
||||
params: {userId: UserID; guildId: GuildID; code: string | null; requestCache: RequestCache},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<{code: string}> {
|
||||
return this.vanityService.updateVanityURL(params, auditLogReason);
|
||||
}
|
||||
|
||||
async deleteGuild(params: {user: User; guildId: GuildID}, auditLogReason?: string | null): Promise<void> {
|
||||
return this.operationsService.deleteGuild(params, auditLogReason);
|
||||
}
|
||||
|
||||
async deleteGuildForAdmin(guildId: GuildID, _auditLogReason?: string | null): Promise<void> {
|
||||
return this.operationsService.deleteGuildById(guildId);
|
||||
}
|
||||
|
||||
async transferOwnership(
|
||||
params: {userId: UserID; guildId: GuildID; newOwnerId: UserID},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<GuildResponse> {
|
||||
return this.ownershipService.transferOwnership(params, auditLogReason);
|
||||
}
|
||||
|
||||
async checkGuildVerification(params: {user: User; guild: Guild; member: GuildMember}): Promise<void> {
|
||||
return this.ownershipService.checkGuildVerification(params);
|
||||
}
|
||||
}
|
||||
439
packages/api/src/guild/services/GuildDiscoveryService.tsx
Normal file
439
packages/api/src/guild/services/GuildDiscoveryService.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import type {GuildDiscoveryRow} from '@fluxer/api/src/database/types/GuildDiscoveryTypes';
|
||||
import {mapGuildToGuildResponse} from '@fluxer/api/src/guild/GuildModel';
|
||||
import type {IGuildDiscoveryRepository} from '@fluxer/api/src/guild/repositories/GuildDiscoveryRepository';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {IGuildSearchService} from '@fluxer/api/src/search/IGuildSearchService';
|
||||
import {
|
||||
DISCOVERY_MIN_MEMBER_COUNT,
|
||||
DISCOVERY_MIN_MEMBER_COUNT_DEV,
|
||||
DiscoveryApplicationStatus,
|
||||
DiscoveryCategories,
|
||||
type DiscoveryCategory,
|
||||
} from '@fluxer/constants/src/DiscoveryConstants';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {DiscoveryAlreadyAppliedError} from '@fluxer/errors/src/domains/discovery/DiscoveryAlreadyAppliedError';
|
||||
import {DiscoveryApplicationAlreadyReviewedError} from '@fluxer/errors/src/domains/discovery/DiscoveryApplicationAlreadyReviewedError';
|
||||
import {DiscoveryApplicationNotFoundError} from '@fluxer/errors/src/domains/discovery/DiscoveryApplicationNotFoundError';
|
||||
import {DiscoveryInsufficientMembersError} from '@fluxer/errors/src/domains/discovery/DiscoveryInsufficientMembersError';
|
||||
import {DiscoveryInvalidCategoryError} from '@fluxer/errors/src/domains/discovery/DiscoveryInvalidCategoryError';
|
||||
import {DiscoveryNotDiscoverableError} from '@fluxer/errors/src/domains/discovery/DiscoveryNotDiscoverableError';
|
||||
import type {GuildSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
import type {DiscoveryApplicationPatchRequest} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
|
||||
|
||||
const VALID_CATEGORY_IDS = new Set<number>(Object.values(DiscoveryCategories));
|
||||
|
||||
export abstract class IGuildDiscoveryService {
|
||||
abstract apply(params: {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
description: string;
|
||||
categoryId: number;
|
||||
}): Promise<GuildDiscoveryRow>;
|
||||
|
||||
abstract editApplication(params: {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
data: DiscoveryApplicationPatchRequest;
|
||||
}): Promise<GuildDiscoveryRow>;
|
||||
|
||||
abstract withdraw(params: {guildId: GuildID; userId: UserID}): Promise<void>;
|
||||
|
||||
abstract getStatus(guildId: GuildID): Promise<GuildDiscoveryRow | null>;
|
||||
|
||||
abstract approve(params: {guildId: GuildID; adminUserId: UserID; reason?: string}): Promise<GuildDiscoveryRow>;
|
||||
|
||||
abstract reject(params: {guildId: GuildID; adminUserId: UserID; reason: string}): Promise<GuildDiscoveryRow>;
|
||||
|
||||
abstract remove(params: {guildId: GuildID; adminUserId: UserID; reason: string}): Promise<GuildDiscoveryRow>;
|
||||
|
||||
abstract listByStatus(params: {status: string; limit: number}): Promise<Array<GuildDiscoveryRow>>;
|
||||
|
||||
abstract searchDiscoverable(params: {
|
||||
query?: string;
|
||||
categoryId?: number;
|
||||
sortBy?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}): Promise<{guilds: Array<DiscoveryGuildResult>; total: number}>;
|
||||
}
|
||||
|
||||
export interface DiscoveryGuildResult {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
description: string | null;
|
||||
category_id: number;
|
||||
member_count: number;
|
||||
online_count: number;
|
||||
features: Array<string>;
|
||||
verification_level: number;
|
||||
}
|
||||
|
||||
export class GuildDiscoveryService extends IGuildDiscoveryService {
|
||||
constructor(
|
||||
private readonly discoveryRepository: IGuildDiscoveryRepository,
|
||||
private readonly guildRepository: IGuildRepositoryAggregate,
|
||||
private readonly gatewayService: IGatewayService,
|
||||
private readonly guildSearchService: IGuildSearchService | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async apply(params: {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
description: string;
|
||||
categoryId: number;
|
||||
}): Promise<GuildDiscoveryRow> {
|
||||
const {guildId, description, categoryId} = params;
|
||||
|
||||
if (!VALID_CATEGORY_IDS.has(categoryId)) {
|
||||
throw new DiscoveryInvalidCategoryError();
|
||||
}
|
||||
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new DiscoveryApplicationNotFoundError();
|
||||
}
|
||||
|
||||
const minMembers = Config.dev.testModeEnabled ? DISCOVERY_MIN_MEMBER_COUNT_DEV : DISCOVERY_MIN_MEMBER_COUNT;
|
||||
if (guild.memberCount < minMembers) {
|
||||
throw new DiscoveryInsufficientMembersError();
|
||||
}
|
||||
|
||||
const existing = await this.discoveryRepository.findByGuildId(guildId);
|
||||
if (existing) {
|
||||
if (
|
||||
existing.status === DiscoveryApplicationStatus.PENDING ||
|
||||
existing.status === DiscoveryApplicationStatus.APPROVED
|
||||
) {
|
||||
throw new DiscoveryAlreadyAppliedError();
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const row: GuildDiscoveryRow = {
|
||||
guild_id: guildId,
|
||||
status: DiscoveryApplicationStatus.PENDING,
|
||||
category_id: categoryId as DiscoveryCategory,
|
||||
description,
|
||||
applied_at: now,
|
||||
reviewed_at: null,
|
||||
reviewed_by: null,
|
||||
review_reason: null,
|
||||
removed_at: null,
|
||||
removed_by: null,
|
||||
removal_reason: null,
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
await this.discoveryRepository.deleteByGuildId(guildId, existing.status, existing.applied_at);
|
||||
}
|
||||
await this.discoveryRepository.upsert(row);
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
async editApplication(params: {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
data: DiscoveryApplicationPatchRequest;
|
||||
}): Promise<GuildDiscoveryRow> {
|
||||
const {guildId, data} = params;
|
||||
|
||||
const existing = await this.discoveryRepository.findByGuildId(guildId);
|
||||
if (!existing) {
|
||||
throw new DiscoveryApplicationNotFoundError();
|
||||
}
|
||||
|
||||
if (
|
||||
existing.status !== DiscoveryApplicationStatus.PENDING &&
|
||||
existing.status !== DiscoveryApplicationStatus.APPROVED
|
||||
) {
|
||||
throw new DiscoveryApplicationAlreadyReviewedError();
|
||||
}
|
||||
|
||||
if (data.category_id !== undefined && !VALID_CATEGORY_IDS.has(data.category_id)) {
|
||||
throw new DiscoveryInvalidCategoryError();
|
||||
}
|
||||
|
||||
const updatedRow: GuildDiscoveryRow = {
|
||||
...existing,
|
||||
description: data.description ?? existing.description,
|
||||
category_id: data.category_id !== undefined ? (data.category_id as DiscoveryCategory) : existing.category_id,
|
||||
};
|
||||
|
||||
await this.discoveryRepository.updateStatus(guildId, existing.status, existing.applied_at, updatedRow);
|
||||
|
||||
if (existing.status === DiscoveryApplicationStatus.APPROVED && this.guildSearchService) {
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
if (guild) {
|
||||
await this.guildSearchService.updateGuild(guild, {
|
||||
description: updatedRow.description,
|
||||
categoryId: updatedRow.category_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return updatedRow;
|
||||
}
|
||||
|
||||
async withdraw(params: {guildId: GuildID; userId: UserID}): Promise<void> {
|
||||
const {guildId} = params;
|
||||
|
||||
const existing = await this.discoveryRepository.findByGuildId(guildId);
|
||||
if (!existing) {
|
||||
throw new DiscoveryApplicationNotFoundError();
|
||||
}
|
||||
|
||||
await this.discoveryRepository.deleteByGuildId(guildId, existing.status, existing.applied_at);
|
||||
|
||||
if (existing.status === DiscoveryApplicationStatus.APPROVED) {
|
||||
await this.removeDiscoverableFeature(guildId);
|
||||
if (this.guildSearchService) {
|
||||
await this.guildSearchService.deleteGuild(guildId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getStatus(guildId: GuildID): Promise<GuildDiscoveryRow | null> {
|
||||
return this.discoveryRepository.findByGuildId(guildId);
|
||||
}
|
||||
|
||||
async approve(params: {guildId: GuildID; adminUserId: UserID; reason?: string}): Promise<GuildDiscoveryRow> {
|
||||
const {guildId, adminUserId, reason} = params;
|
||||
|
||||
const existing = await this.discoveryRepository.findByGuildId(guildId);
|
||||
if (!existing) {
|
||||
throw new DiscoveryApplicationNotFoundError();
|
||||
}
|
||||
|
||||
if (existing.status !== DiscoveryApplicationStatus.PENDING) {
|
||||
throw new DiscoveryApplicationAlreadyReviewedError();
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updatedRow: GuildDiscoveryRow = {
|
||||
...existing,
|
||||
status: DiscoveryApplicationStatus.APPROVED,
|
||||
reviewed_at: now,
|
||||
reviewed_by: adminUserId,
|
||||
review_reason: reason ?? null,
|
||||
};
|
||||
|
||||
await this.discoveryRepository.updateStatus(guildId, existing.status, existing.applied_at, updatedRow);
|
||||
await this.addDiscoverableFeature(guildId);
|
||||
|
||||
if (this.guildSearchService) {
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
if (guild) {
|
||||
await this.guildSearchService.updateGuild(guild, {
|
||||
description: updatedRow.description,
|
||||
categoryId: updatedRow.category_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return updatedRow;
|
||||
}
|
||||
|
||||
async reject(params: {guildId: GuildID; adminUserId: UserID; reason: string}): Promise<GuildDiscoveryRow> {
|
||||
const {guildId, adminUserId, reason} = params;
|
||||
|
||||
const existing = await this.discoveryRepository.findByGuildId(guildId);
|
||||
if (!existing) {
|
||||
throw new DiscoveryApplicationNotFoundError();
|
||||
}
|
||||
|
||||
if (existing.status !== DiscoveryApplicationStatus.PENDING) {
|
||||
throw new DiscoveryApplicationAlreadyReviewedError();
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updatedRow: GuildDiscoveryRow = {
|
||||
...existing,
|
||||
status: DiscoveryApplicationStatus.REJECTED,
|
||||
reviewed_at: now,
|
||||
reviewed_by: adminUserId,
|
||||
review_reason: reason,
|
||||
};
|
||||
|
||||
await this.discoveryRepository.updateStatus(guildId, existing.status, existing.applied_at, updatedRow);
|
||||
|
||||
return updatedRow;
|
||||
}
|
||||
|
||||
async remove(params: {guildId: GuildID; adminUserId: UserID; reason: string}): Promise<GuildDiscoveryRow> {
|
||||
const {guildId, adminUserId, reason} = params;
|
||||
|
||||
const existing = await this.discoveryRepository.findByGuildId(guildId);
|
||||
if (!existing) {
|
||||
throw new DiscoveryApplicationNotFoundError();
|
||||
}
|
||||
|
||||
if (existing.status !== DiscoveryApplicationStatus.APPROVED) {
|
||||
throw new DiscoveryNotDiscoverableError();
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updatedRow: GuildDiscoveryRow = {
|
||||
...existing,
|
||||
status: DiscoveryApplicationStatus.REMOVED,
|
||||
removed_at: now,
|
||||
removed_by: adminUserId,
|
||||
removal_reason: reason,
|
||||
};
|
||||
|
||||
await this.discoveryRepository.updateStatus(guildId, existing.status, existing.applied_at, updatedRow);
|
||||
await this.removeDiscoverableFeature(guildId);
|
||||
|
||||
if (this.guildSearchService) {
|
||||
await this.guildSearchService.deleteGuild(guildId);
|
||||
}
|
||||
|
||||
return updatedRow;
|
||||
}
|
||||
|
||||
async listByStatus(params: {status: string; limit: number}): Promise<Array<GuildDiscoveryRow>> {
|
||||
const statusRows = await this.discoveryRepository.listByStatus(params.status, params.limit);
|
||||
const fullRows = await Promise.all(statusRows.map((row) => this.discoveryRepository.findByGuildId(row.guild_id)));
|
||||
return fullRows.filter((row): row is GuildDiscoveryRow => row !== null);
|
||||
}
|
||||
|
||||
async searchDiscoverable(params: {
|
||||
query?: string;
|
||||
categoryId?: number;
|
||||
sortBy?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}): Promise<{guilds: Array<DiscoveryGuildResult>; total: number}> {
|
||||
if (this.guildSearchService) {
|
||||
const sortByMap: Record<string, GuildSearchFilters['sortBy']> = {
|
||||
member_count: 'memberCount',
|
||||
online_count: 'onlineCount',
|
||||
};
|
||||
|
||||
const filters: GuildSearchFilters = {
|
||||
isDiscoverable: true,
|
||||
discoveryCategory: params.categoryId,
|
||||
sortBy: sortByMap[params.sortBy ?? ''] ?? 'relevance',
|
||||
sortOrder: 'desc',
|
||||
};
|
||||
|
||||
const results = await this.guildSearchService.searchGuilds(params.query ?? '', filters, {
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
});
|
||||
|
||||
const guilds: Array<DiscoveryGuildResult> = results.hits.map((hit) => ({
|
||||
id: hit.id,
|
||||
name: hit.name,
|
||||
icon: hit.iconHash,
|
||||
description: hit.discoveryDescription,
|
||||
category_id: hit.discoveryCategory ?? 0,
|
||||
member_count: hit.memberCount,
|
||||
online_count: hit.onlineCount,
|
||||
features: hit.features,
|
||||
verification_level: hit.verificationLevel,
|
||||
}));
|
||||
|
||||
return {guilds, total: results.total};
|
||||
}
|
||||
|
||||
const statusRows = await this.discoveryRepository.listByStatus(
|
||||
DiscoveryApplicationStatus.APPROVED,
|
||||
params.limit + params.offset,
|
||||
);
|
||||
|
||||
const paginatedRows = statusRows.slice(params.offset, params.offset + params.limit);
|
||||
const guilds: Array<DiscoveryGuildResult> = [];
|
||||
|
||||
for (const statusRow of paginatedRows) {
|
||||
const discoveryRow = await this.discoveryRepository.findByGuildId(statusRow.guild_id);
|
||||
if (!discoveryRow) continue;
|
||||
|
||||
if (params.categoryId !== undefined && discoveryRow.category_id !== params.categoryId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const guild = await this.guildRepository.findUnique(statusRow.guild_id);
|
||||
if (!guild) continue;
|
||||
|
||||
guilds.push({
|
||||
id: statusRow.guild_id.toString(),
|
||||
name: guild.name,
|
||||
icon: guild.iconHash,
|
||||
description: discoveryRow.description,
|
||||
category_id: discoveryRow.category_id,
|
||||
member_count: guild.memberCount,
|
||||
online_count: 0,
|
||||
features: Array.from(guild.features),
|
||||
verification_level: guild.verificationLevel,
|
||||
});
|
||||
}
|
||||
|
||||
return {guilds, total: statusRows.length};
|
||||
}
|
||||
|
||||
private async addDiscoverableFeature(guildId: GuildID): Promise<void> {
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
if (!guild) return;
|
||||
|
||||
const newFeatures = new Set(guild.features);
|
||||
newFeatures.add(GuildFeatures.DISCOVERABLE);
|
||||
|
||||
const guildRow = guild.toRow();
|
||||
const updatedGuild = await this.guildRepository.upsert({
|
||||
...guildRow,
|
||||
features: newFeatures,
|
||||
});
|
||||
|
||||
await this.gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_UPDATE',
|
||||
data: mapGuildToGuildResponse(updatedGuild),
|
||||
});
|
||||
}
|
||||
|
||||
private async removeDiscoverableFeature(guildId: GuildID): Promise<void> {
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
if (!guild) return;
|
||||
|
||||
const newFeatures = new Set(guild.features);
|
||||
newFeatures.delete(GuildFeatures.DISCOVERABLE);
|
||||
|
||||
const guildRow = guild.toRow();
|
||||
const updatedGuild = await this.guildRepository.upsert({
|
||||
...guildRow,
|
||||
features: newFeatures,
|
||||
});
|
||||
|
||||
await this.gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_UPDATE',
|
||||
data: mapGuildToGuildResponse(updatedGuild),
|
||||
});
|
||||
}
|
||||
}
|
||||
362
packages/api/src/guild/services/GuildMemberService.tsx
Normal file
362
packages/api/src/guild/services/GuildMemberService.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, InviteCode, RoleID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {ChannelService} from '@fluxer/api/src/channel/services/ChannelService';
|
||||
import type {GuildAuditLogService} from '@fluxer/api/src/guild/GuildAuditLogService';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import {GuildMemberAuditService} from '@fluxer/api/src/guild/services/member/GuildMemberAuditService';
|
||||
import {GuildMemberAuthService} from '@fluxer/api/src/guild/services/member/GuildMemberAuthService';
|
||||
import {GuildMemberEventService} from '@fluxer/api/src/guild/services/member/GuildMemberEventService';
|
||||
import {GuildMemberOperationsService} from '@fluxer/api/src/guild/services/member/GuildMemberOperationsService';
|
||||
import {GuildMemberRoleService} from '@fluxer/api/src/guild/services/member/GuildMemberRoleService';
|
||||
import {GuildMemberSearchIndexService} from '@fluxer/api/src/guild/services/member/GuildMemberSearchIndexService';
|
||||
import {GuildMemberValidationService} from '@fluxer/api/src/guild/services/member/GuildMemberValidationService';
|
||||
import type {EntityAssetService} from '@fluxer/api/src/infrastructure/EntityAssetService';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {GuildMember} from '@fluxer/api/src/models/GuildMember';
|
||||
import type {GuildManagedTraitService} from '@fluxer/api/src/traits/GuildManagedTraitService';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {UnknownGuildMemberError} from '@fluxer/errors/src/domains/guild/UnknownGuildMemberError';
|
||||
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import type {GuildMemberUpdateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
|
||||
|
||||
export class GuildMemberService {
|
||||
private readonly authService: GuildMemberAuthService;
|
||||
private readonly validationService: GuildMemberValidationService;
|
||||
private readonly auditService: GuildMemberAuditService;
|
||||
private readonly eventService: GuildMemberEventService;
|
||||
private readonly operationsService: GuildMemberOperationsService;
|
||||
private readonly roleService: GuildMemberRoleService;
|
||||
private readonly searchIndexService: GuildMemberSearchIndexService;
|
||||
private readonly userRepository: IUserRepository;
|
||||
|
||||
constructor(
|
||||
private readonly guildRepository: IGuildRepositoryAggregate,
|
||||
channelService: ChannelService,
|
||||
userCacheService: UserCacheService,
|
||||
gatewayService: IGatewayService,
|
||||
entityAssetService: EntityAssetService,
|
||||
userRepository: IUserRepository,
|
||||
rateLimitService: IRateLimitService,
|
||||
private readonly guildAuditLogService: GuildAuditLogService,
|
||||
limitConfigService: LimitConfigService,
|
||||
guildManagedTraitService?: GuildManagedTraitService,
|
||||
) {
|
||||
this.userRepository = userRepository;
|
||||
this.authService = new GuildMemberAuthService(gatewayService);
|
||||
this.validationService = new GuildMemberValidationService(guildRepository, userRepository);
|
||||
this.auditService = new GuildMemberAuditService(guildAuditLogService);
|
||||
this.eventService = new GuildMemberEventService(gatewayService, userCacheService);
|
||||
this.searchIndexService = new GuildMemberSearchIndexService();
|
||||
this.operationsService = new GuildMemberOperationsService(
|
||||
guildRepository,
|
||||
channelService,
|
||||
userCacheService,
|
||||
gatewayService,
|
||||
entityAssetService,
|
||||
userRepository,
|
||||
rateLimitService,
|
||||
this.authService,
|
||||
this.validationService,
|
||||
this.guildAuditLogService,
|
||||
limitConfigService,
|
||||
guildManagedTraitService,
|
||||
this.searchIndexService,
|
||||
);
|
||||
this.roleService = new GuildMemberRoleService(
|
||||
guildRepository,
|
||||
gatewayService,
|
||||
this.authService,
|
||||
this.validationService,
|
||||
);
|
||||
}
|
||||
|
||||
async getMembers(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
limit?: number;
|
||||
after?: UserID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Array<GuildMemberResponse>> {
|
||||
return this.operationsService.getMembers(params);
|
||||
}
|
||||
|
||||
async getMember(params: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
guildId: GuildID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<GuildMemberResponse> {
|
||||
return this.operationsService.getMember(params);
|
||||
}
|
||||
|
||||
async updateMember(
|
||||
params: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
guildId: GuildID;
|
||||
data: GuildMemberUpdateRequest | Omit<GuildMemberUpdateRequest, 'roles'>;
|
||||
requestCache: RequestCache;
|
||||
},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<GuildMemberResponse> {
|
||||
const {userId, targetId, guildId, data, requestCache} = params;
|
||||
|
||||
const targetMember = await this.guildRepository.getMember(guildId, targetId);
|
||||
if (!targetMember) throw new UnknownGuildMemberError();
|
||||
|
||||
const previousSnapshot = this.auditService.serializeMemberForAudit(targetMember);
|
||||
|
||||
const result = await this.operationsService.updateMember({
|
||||
userId,
|
||||
targetId,
|
||||
guildId,
|
||||
data,
|
||||
requestCache,
|
||||
auditLogReason,
|
||||
});
|
||||
|
||||
const updatedMember = await this.guildRepository.getMember(guildId, targetId);
|
||||
if (!updatedMember) throw new UnknownGuildMemberError();
|
||||
|
||||
await this.eventService.dispatchGuildMemberUpdate({guildId, member: updatedMember, requestCache});
|
||||
|
||||
const targetUser = await this.userRepository.findUnique(targetId);
|
||||
if (targetUser) {
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
if (guild?.membersIndexedAt) {
|
||||
void this.searchIndexService.updateMember(updatedMember, targetUser);
|
||||
}
|
||||
}
|
||||
const timeoutMetadata = (() => {
|
||||
if (data.communication_disabled_until === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const metadata: Record<string, string> = {};
|
||||
if (data.communication_disabled_until !== null) {
|
||||
metadata['communication_disabled_until'] = data.communication_disabled_until;
|
||||
}
|
||||
const trimmedReason = data.timeout_reason?.trim();
|
||||
if (trimmedReason) {
|
||||
metadata['timeout_reason'] = trimmedReason;
|
||||
}
|
||||
return Object.keys(metadata).length > 0 ? metadata : undefined;
|
||||
})();
|
||||
await this.auditService.recordAuditLog({
|
||||
guildId,
|
||||
userId,
|
||||
action: AuditLogActionType.MEMBER_UPDATE,
|
||||
targetId: targetId,
|
||||
auditLogReason: auditLogReason ?? null,
|
||||
metadata: timeoutMetadata,
|
||||
changes: this.guildAuditLogService.computeChanges(
|
||||
previousSnapshot,
|
||||
this.auditService.serializeMemberForAudit(updatedMember),
|
||||
),
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async addMemberRole(
|
||||
params: {userId: UserID; targetId: UserID; guildId: GuildID; roleId: RoleID; requestCache: RequestCache},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
const {userId, targetId, guildId, roleId, requestCache} = params;
|
||||
|
||||
const targetMember = await this.guildRepository.getMember(guildId, targetId);
|
||||
if (!targetMember) throw new UnknownGuildMemberError();
|
||||
|
||||
const previousSnapshot = this.auditService.serializeMemberForAudit(targetMember);
|
||||
|
||||
await this.roleService.addMemberRole(params);
|
||||
|
||||
const updatedMember = await this.guildRepository.getMember(guildId, targetId);
|
||||
if (updatedMember) {
|
||||
await this.eventService.dispatchGuildMemberUpdate({guildId, member: updatedMember, requestCache});
|
||||
|
||||
const roleTargetUser = await this.userRepository.findUnique(targetId);
|
||||
if (roleTargetUser) {
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
if (guild?.membersIndexedAt) {
|
||||
void this.searchIndexService.updateMember(updatedMember, roleTargetUser);
|
||||
}
|
||||
}
|
||||
|
||||
await this.auditService.recordAuditLog({
|
||||
guildId,
|
||||
userId,
|
||||
action: AuditLogActionType.MEMBER_ROLE_UPDATE,
|
||||
targetId: targetId,
|
||||
auditLogReason: auditLogReason ?? null,
|
||||
metadata: {role_id: roleId.toString(), action: 'add'},
|
||||
changes: this.guildAuditLogService.computeChanges(
|
||||
previousSnapshot,
|
||||
this.auditService.serializeMemberForAudit(updatedMember),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async systemAddMemberRole(params: {
|
||||
targetId: UserID;
|
||||
guildId: GuildID;
|
||||
roleId: RoleID;
|
||||
initiatorId: UserID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const {targetId, guildId, roleId, initiatorId, requestCache} = params;
|
||||
|
||||
const targetMember = await this.guildRepository.getMember(guildId, targetId);
|
||||
if (!targetMember) throw new UnknownGuildMemberError();
|
||||
|
||||
const previousSnapshot = this.auditService.serializeMemberForAudit(targetMember);
|
||||
|
||||
await this.roleService.systemAddMemberRole({targetId, guildId, roleId});
|
||||
|
||||
const updatedMember = await this.guildRepository.getMember(guildId, targetId);
|
||||
if (updatedMember) {
|
||||
await this.eventService.dispatchGuildMemberUpdate({guildId, member: updatedMember, requestCache});
|
||||
|
||||
const roleTargetUser = await this.userRepository.findUnique(targetId);
|
||||
if (roleTargetUser) {
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
if (guild?.membersIndexedAt) {
|
||||
void this.searchIndexService.updateMember(updatedMember, roleTargetUser);
|
||||
}
|
||||
}
|
||||
|
||||
await this.auditService.recordAuditLog({
|
||||
guildId,
|
||||
userId: initiatorId,
|
||||
action: AuditLogActionType.MEMBER_ROLE_UPDATE,
|
||||
targetId: targetId,
|
||||
auditLogReason: null,
|
||||
metadata: {role_id: roleId.toString(), action: 'add'},
|
||||
changes: this.guildAuditLogService.computeChanges(
|
||||
previousSnapshot,
|
||||
this.auditService.serializeMemberForAudit(updatedMember),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async removeMemberRole(
|
||||
params: {userId: UserID; targetId: UserID; guildId: GuildID; roleId: RoleID; requestCache: RequestCache},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
const {userId, targetId, guildId, roleId, requestCache} = params;
|
||||
|
||||
const targetMember = await this.guildRepository.getMember(guildId, targetId);
|
||||
if (!targetMember) throw new UnknownGuildMemberError();
|
||||
|
||||
const previousSnapshot = this.auditService.serializeMemberForAudit(targetMember);
|
||||
|
||||
await this.roleService.removeMemberRole(params);
|
||||
|
||||
const updatedMember = await this.guildRepository.getMember(guildId, targetId);
|
||||
if (updatedMember) {
|
||||
await this.eventService.dispatchGuildMemberUpdate({guildId, member: updatedMember, requestCache});
|
||||
|
||||
const roleTargetUser = await this.userRepository.findUnique(targetId);
|
||||
if (roleTargetUser) {
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
if (guild?.membersIndexedAt) {
|
||||
void this.searchIndexService.updateMember(updatedMember, roleTargetUser);
|
||||
}
|
||||
}
|
||||
|
||||
await this.auditService.recordAuditLog({
|
||||
guildId,
|
||||
userId,
|
||||
action: AuditLogActionType.MEMBER_ROLE_UPDATE,
|
||||
targetId: targetId,
|
||||
auditLogReason: auditLogReason ?? null,
|
||||
metadata: {role_id: roleId.toString(), action: 'remove'},
|
||||
changes: this.guildAuditLogService.computeChanges(
|
||||
previousSnapshot,
|
||||
this.auditService.serializeMemberForAudit(updatedMember),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async removeMember(
|
||||
params: {userId: UserID; targetId: UserID; guildId: GuildID},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
const {userId, targetId, guildId} = params;
|
||||
|
||||
const targetMember = await this.guildRepository.getMember(guildId, targetId);
|
||||
if (!targetMember) throw new UnknownGuildMemberError();
|
||||
|
||||
await this.operationsService.removeMember(params);
|
||||
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
if (guild?.membersIndexedAt) {
|
||||
void this.searchIndexService.deleteMember(guildId, targetId);
|
||||
}
|
||||
|
||||
await this.eventService.dispatchGuildMemberRemove({guildId, userId: targetId});
|
||||
await this.auditService.recordAuditLog({
|
||||
guildId,
|
||||
userId,
|
||||
action: AuditLogActionType.MEMBER_KICK,
|
||||
targetId: targetId,
|
||||
auditLogReason: auditLogReason ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async addUserToGuild(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
sendJoinMessage?: boolean;
|
||||
skipGuildLimitCheck?: boolean;
|
||||
skipBanCheck?: boolean;
|
||||
isTemporary?: boolean;
|
||||
joinSourceType?: number;
|
||||
sourceInviteCode?: InviteCode;
|
||||
inviterId?: UserID;
|
||||
requestCache: RequestCache;
|
||||
initiatorId?: UserID;
|
||||
}): Promise<GuildMember> {
|
||||
return this.operationsService.addUserToGuild(params, this.eventService);
|
||||
}
|
||||
|
||||
async leaveGuild(params: {userId: UserID; guildId: GuildID}, _auditLogReason?: string | null): Promise<void> {
|
||||
const {userId, guildId} = params;
|
||||
const member = await this.guildRepository.getMember(guildId, userId);
|
||||
if (!member) throw new UnknownGuildMemberError();
|
||||
|
||||
await this.operationsService.leaveGuild(params);
|
||||
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
if (guild?.membersIndexedAt) {
|
||||
void this.searchIndexService.deleteMember(guildId, userId);
|
||||
}
|
||||
|
||||
await this.eventService.dispatchGuildMemberRemove({guildId, userId});
|
||||
}
|
||||
}
|
||||
294
packages/api/src/guild/services/GuildModerationService.tsx
Normal file
294
packages/api/src/guild/services/GuildModerationService.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {GuildAuditLogService} from '@fluxer/api/src/guild/GuildAuditLogService';
|
||||
import type {GuildAuditLogChange} from '@fluxer/api/src/guild/GuildAuditLogTypes';
|
||||
import {mapGuildBansToResponse} from '@fluxer/api/src/guild/GuildModel';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import {GuildMemberSearchIndexService} from '@fluxer/api/src/guild/services/member/GuildMemberSearchIndexService';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {GuildBan} from '@fluxer/api/src/models/GuildBan';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {BannedFromGuildError} from '@fluxer/errors/src/domains/guild/BannedFromGuildError';
|
||||
import {IpBannedFromGuildError} from '@fluxer/errors/src/domains/guild/IpBannedFromGuildError';
|
||||
import {UnknownGuildMemberError} from '@fluxer/errors/src/domains/guild/UnknownGuildMemberError';
|
||||
import type {GuildBanResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import type {IWorkerService} from '@fluxer/worker/src/contracts/IWorkerService';
|
||||
|
||||
export class GuildModerationService {
|
||||
private readonly searchIndexService: GuildMemberSearchIndexService;
|
||||
|
||||
constructor(
|
||||
private readonly guildRepository: IGuildRepositoryAggregate,
|
||||
private readonly userRepository: IUserRepository,
|
||||
private readonly gatewayService: IGatewayService,
|
||||
private readonly userCacheService: UserCacheService,
|
||||
private readonly workerService: IWorkerService,
|
||||
private readonly guildAuditLogService: GuildAuditLogService,
|
||||
) {
|
||||
this.searchIndexService = new GuildMemberSearchIndexService();
|
||||
}
|
||||
|
||||
async banMember(
|
||||
params: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
guildId: GuildID;
|
||||
deleteMessageDays?: number;
|
||||
reason?: string | null;
|
||||
banDurationSeconds?: number;
|
||||
},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
const {userId, guildId, targetId, deleteMessageDays, reason, banDurationSeconds} = params;
|
||||
|
||||
const hasPermission = await this.gatewayService.checkPermission({
|
||||
guildId,
|
||||
userId,
|
||||
permission: Permissions.BAN_MEMBERS,
|
||||
});
|
||||
if (!hasPermission) throw new MissingPermissionsError();
|
||||
if (userId === targetId) throw new UnknownGuildMemberError();
|
||||
|
||||
const targetMember = await this.guildRepository.getMember(guildId, targetId);
|
||||
|
||||
if (targetMember) {
|
||||
const canManage = await this.gatewayService.checkTargetMember({guildId, userId, targetUserId: targetId});
|
||||
if (!canManage) throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
if (deleteMessageDays && deleteMessageDays > 0) {
|
||||
await this.workerService.addJob('deleteUserMessagesInGuildByTime', {
|
||||
guildId: guildId.toString(),
|
||||
userId: targetId.toString(),
|
||||
days: deleteMessageDays,
|
||||
});
|
||||
}
|
||||
|
||||
const targetUser = await this.userRepository.findUnique(targetId);
|
||||
const targetIp = targetUser?.lastActiveIp || null;
|
||||
|
||||
let expiresAt: Date | null = null;
|
||||
if (banDurationSeconds && banDurationSeconds > 0) {
|
||||
expiresAt = new Date(Date.now() + banDurationSeconds * 1000);
|
||||
}
|
||||
|
||||
const ban = await this.guildRepository.upsertBan({
|
||||
guild_id: guildId,
|
||||
user_id: targetId,
|
||||
moderator_id: userId,
|
||||
banned_at: new Date(),
|
||||
expires_at: expiresAt,
|
||||
reason: reason || null,
|
||||
ip: targetIp,
|
||||
});
|
||||
|
||||
const metadata: Record<string, string> | undefined =
|
||||
deleteMessageDays !== undefined ? {delete_member_days: deleteMessageDays.toString()} : undefined;
|
||||
|
||||
await this.recordAuditLog({
|
||||
guildId,
|
||||
userId,
|
||||
action: AuditLogActionType.MEMBER_BAN_ADD,
|
||||
targetId: targetId,
|
||||
auditLogReason: auditLogReason ?? null,
|
||||
metadata,
|
||||
changes: this.guildAuditLogService.computeChanges(null, this.serializeBanForAudit(ban)),
|
||||
});
|
||||
|
||||
await this.gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_BAN_ADD',
|
||||
data: {
|
||||
guild_id: guildId.toString(),
|
||||
user: {id: targetId.toString()},
|
||||
},
|
||||
});
|
||||
|
||||
if (targetMember) {
|
||||
await this.guildRepository.deleteMember(guildId, targetId);
|
||||
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
if (guild) {
|
||||
const guildRow = guild.toRow();
|
||||
await this.guildRepository.upsert({
|
||||
...guildRow,
|
||||
member_count: Math.max(0, guild.memberCount - 1),
|
||||
});
|
||||
}
|
||||
|
||||
await this.dispatchGuildMemberRemove({guildId, userId: targetId});
|
||||
await this.gatewayService.leaveGuild({userId: targetId, guildId});
|
||||
|
||||
const guildForSearch = await this.guildRepository.findUnique(guildId);
|
||||
if (guildForSearch?.membersIndexedAt) {
|
||||
void this.searchIndexService.deleteMember(guildId, targetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async listBans(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Array<GuildBanResponse>> {
|
||||
const {userId, guildId, requestCache} = params;
|
||||
|
||||
const hasPermission = await this.gatewayService.checkPermission({
|
||||
guildId,
|
||||
userId,
|
||||
permission: Permissions.BAN_MEMBERS,
|
||||
});
|
||||
if (!hasPermission) throw new MissingPermissionsError();
|
||||
|
||||
const bans = await this.guildRepository.listBans(guildId);
|
||||
return await mapGuildBansToResponse(bans, this.userCacheService, requestCache);
|
||||
}
|
||||
|
||||
async unbanMember(
|
||||
params: {userId: UserID; targetId: UserID; guildId: GuildID},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
const {userId, guildId, targetId} = params;
|
||||
|
||||
const hasPermission = await this.gatewayService.checkPermission({
|
||||
guildId,
|
||||
userId,
|
||||
permission: Permissions.BAN_MEMBERS,
|
||||
});
|
||||
if (!hasPermission) throw new MissingPermissionsError();
|
||||
|
||||
const ban = await this.guildRepository.getBan(guildId, targetId);
|
||||
if (!ban) {
|
||||
throw InputValidationError.fromCode('user_id', ValidationErrorCodes.USER_IS_NOT_BANNED);
|
||||
}
|
||||
|
||||
await this.guildRepository.deleteBan(guildId, targetId);
|
||||
|
||||
await this.recordAuditLog({
|
||||
guildId,
|
||||
userId,
|
||||
action: AuditLogActionType.MEMBER_BAN_REMOVE,
|
||||
targetId: targetId,
|
||||
auditLogReason: auditLogReason ?? null,
|
||||
changes: this.guildAuditLogService.computeChanges(this.serializeBanForAudit(ban), null),
|
||||
});
|
||||
|
||||
await this.gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_BAN_REMOVE',
|
||||
data: {
|
||||
guild_id: guildId.toString(),
|
||||
user: {id: targetId.toString()},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async checkUserBanStatus(params: {userId: UserID; guildId: GuildID}): Promise<void> {
|
||||
const {userId, guildId} = params;
|
||||
|
||||
const bans = await this.guildRepository.listBans(guildId);
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
const userIp = user?.lastActiveIp;
|
||||
|
||||
for (const ban of bans) {
|
||||
if (ban.userId === userId) {
|
||||
throw new BannedFromGuildError();
|
||||
}
|
||||
if (userIp && ban.ipAddress && ban.ipAddress === userIp) {
|
||||
throw new IpBannedFromGuildError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private serializeBanForAudit(ban: GuildBan): Record<string, unknown> {
|
||||
return {
|
||||
user_id: ban.userId.toString(),
|
||||
moderator_id: ban.moderatorId.toString(),
|
||||
banned_at: ban.bannedAt.toISOString(),
|
||||
expires_at: ban.expiresAt ? ban.expiresAt.toISOString() : null,
|
||||
reason: ban.reason ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private async dispatchGuildMemberRemove({guildId, userId}: {guildId: GuildID; userId: UserID}): Promise<void> {
|
||||
await this.gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_MEMBER_REMOVE',
|
||||
data: {user: {id: userId.toString()}},
|
||||
});
|
||||
}
|
||||
|
||||
private async recordAuditLog(params: {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
action: AuditLogActionType;
|
||||
targetId?: UserID | string | null;
|
||||
auditLogReason?: string | null;
|
||||
metadata?: Map<string, string> | Record<string, string>;
|
||||
changes?: GuildAuditLogChange | null;
|
||||
createdAt?: Date;
|
||||
}): Promise<void> {
|
||||
const targetId =
|
||||
params.targetId === undefined || params.targetId === null
|
||||
? null
|
||||
: typeof params.targetId === 'string'
|
||||
? params.targetId
|
||||
: params.targetId.toString();
|
||||
|
||||
try {
|
||||
const builder = this.guildAuditLogService
|
||||
.createBuilder(params.guildId, params.userId)
|
||||
.withAction(params.action, targetId)
|
||||
.withReason(params.auditLogReason ?? null);
|
||||
|
||||
if (params.metadata) {
|
||||
builder.withMetadata(params.metadata);
|
||||
}
|
||||
if (params.changes) {
|
||||
builder.withChanges(params.changes);
|
||||
}
|
||||
if (params.createdAt) {
|
||||
builder.withCreatedAt(params.createdAt);
|
||||
}
|
||||
|
||||
await builder.commit();
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{
|
||||
error,
|
||||
guildId: params.guildId.toString(),
|
||||
userId: params.userId.toString(),
|
||||
action: params.action,
|
||||
targetId,
|
||||
},
|
||||
'Failed to record guild audit log',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
934
packages/api/src/guild/services/GuildRoleService.tsx
Normal file
934
packages/api/src/guild/services/GuildRoleService.tsx
Normal file
@@ -0,0 +1,934 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, RoleID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {createRoleID, guildIdToRoleId} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {GuildAuditLogService} from '@fluxer/api/src/guild/GuildAuditLogService';
|
||||
import type {GuildAuditLogChange} from '@fluxer/api/src/guild/GuildAuditLogTypes';
|
||||
import {mapGuildRoleToResponse} from '@fluxer/api/src/guild/GuildModel';
|
||||
import type {IGuildMemberRepository} from '@fluxer/api/src/guild/repositories/IGuildMemberRepository';
|
||||
import type {IGuildRoleRepository} from '@fluxer/api/src/guild/repositories/IGuildRoleRepository';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
|
||||
import {GuildRole} from '@fluxer/api/src/models/GuildRole';
|
||||
import {computePermissionsDiff} from '@fluxer/api/src/utils/PermissionUtils';
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {ALL_PERMISSIONS, DEFAULT_PERMISSIONS, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
|
||||
import {MAX_GUILD_ROLES} from '@fluxer/constants/src/LimitConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {ResourceLockedError} from '@fluxer/errors/src/domains/core/ResourceLockedError';
|
||||
import {MaxGuildRolesError} from '@fluxer/errors/src/domains/guild/MaxGuildRolesError';
|
||||
import {UnknownGuildRoleError} from '@fluxer/errors/src/domains/guild/UnknownGuildRoleError';
|
||||
import type {
|
||||
GuildRoleCreateRequest,
|
||||
GuildRoleUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {GuildRoleResponse} from '@fluxer/schema/src/domains/guild/GuildRoleSchemas';
|
||||
|
||||
interface RoleReorderOperation {
|
||||
roleId: RoleID;
|
||||
precedingRoleId: RoleID | null;
|
||||
}
|
||||
|
||||
interface GuildAuth {
|
||||
guildData: GuildResponse;
|
||||
checkPermission: (permission: bigint) => Promise<void>;
|
||||
getMyPermissions: () => Promise<bigint>;
|
||||
}
|
||||
|
||||
type RoleUpdateData = Partial<{
|
||||
name: string;
|
||||
color: number;
|
||||
position: number;
|
||||
hoistPosition: number | null;
|
||||
permissions: bigint;
|
||||
iconHash: string | null;
|
||||
unicodeEmoji: string | null;
|
||||
hoist: boolean;
|
||||
mentionable: boolean;
|
||||
}>;
|
||||
|
||||
export class GuildRoleService {
|
||||
constructor(
|
||||
private readonly guildRoleRepository: IGuildRoleRepository,
|
||||
private readonly guildMemberRepository: IGuildMemberRepository,
|
||||
private readonly snowflakeService: SnowflakeService,
|
||||
private readonly cacheService: ICacheService,
|
||||
private readonly gatewayService: IGatewayService,
|
||||
private readonly guildAuditLogService: GuildAuditLogService,
|
||||
private readonly limitConfigService: LimitConfigService,
|
||||
) {}
|
||||
|
||||
async systemCreateRole(params: {
|
||||
initiatorId: UserID;
|
||||
guildId: GuildID;
|
||||
data: GuildRoleCreateRequest;
|
||||
}): Promise<GuildRoleResponse> {
|
||||
const {initiatorId, guildId, data} = params;
|
||||
const guildData = await this.gatewayService.getGuildData({guildId, userId: initiatorId, skipMembershipCheck: true});
|
||||
|
||||
const currentRoleCount = await this.guildRoleRepository.countRoles(guildId);
|
||||
const roleLimit = this.resolveGuildLimit(guildData.features, 'max_guild_roles', MAX_GUILD_ROLES);
|
||||
if (currentRoleCount >= roleLimit) throw new MaxGuildRolesError(roleLimit);
|
||||
|
||||
const permissions = data.permissions !== undefined ? data.permissions & ALL_PERMISSIONS : DEFAULT_PERMISSIONS;
|
||||
|
||||
const roleId = createRoleID(await this.snowflakeService.generate());
|
||||
const position = 1;
|
||||
const role = await this.guildRoleRepository.upsertRole({
|
||||
guild_id: guildId,
|
||||
role_id: roleId,
|
||||
name: data.name,
|
||||
permissions,
|
||||
position,
|
||||
hoist_position: null,
|
||||
color: data.color || 0,
|
||||
icon_hash: null,
|
||||
unicode_emoji: null,
|
||||
hoist: false,
|
||||
mentionable: false,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
await this.dispatchGuildRoleCreate({guildId, role});
|
||||
|
||||
await this.recordAuditLog({
|
||||
guildId,
|
||||
userId: initiatorId,
|
||||
action: AuditLogActionType.ROLE_CREATE,
|
||||
targetId: role.id,
|
||||
auditLogReason: null,
|
||||
changes: this.guildAuditLogService.computeChanges(null, this.serializeRoleForAudit(role)),
|
||||
});
|
||||
|
||||
getMetricsService().counter({
|
||||
name: 'fluxer.roles.created',
|
||||
dimensions: {
|
||||
guild_id: guildId.toString(),
|
||||
position: role.position.toString(),
|
||||
permissions_count: role.permissions.toString().length.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
return mapGuildRoleToResponse(role);
|
||||
}
|
||||
|
||||
async createRole(
|
||||
params: {userId: UserID; guildId: GuildID; data: GuildRoleCreateRequest},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<GuildRoleResponse> {
|
||||
const {userId, guildId, data} = params;
|
||||
const {checkPermission, getMyPermissions, guildData} = await this.getGuildAuthenticated({userId, guildId});
|
||||
await checkPermission(Permissions.MANAGE_ROLES);
|
||||
|
||||
const currentRoleCount = await this.guildRoleRepository.countRoles(guildId);
|
||||
const roleLimit = this.resolveGuildLimit(guildData.features, 'max_guild_roles', MAX_GUILD_ROLES);
|
||||
if (currentRoleCount >= roleLimit) throw new MaxGuildRolesError(roleLimit);
|
||||
|
||||
const permissions =
|
||||
data.permissions !== undefined
|
||||
? await this.resolveRequestedPermissions({
|
||||
requestedPermissions: data.permissions,
|
||||
guildData,
|
||||
userId,
|
||||
getMyPermissions,
|
||||
})
|
||||
: DEFAULT_PERMISSIONS;
|
||||
|
||||
const roleId = createRoleID(await this.snowflakeService.generate());
|
||||
const position = 1;
|
||||
const role = await this.guildRoleRepository.upsertRole({
|
||||
guild_id: guildId,
|
||||
role_id: roleId,
|
||||
name: data.name,
|
||||
permissions,
|
||||
position,
|
||||
hoist_position: null,
|
||||
color: data.color || 0,
|
||||
icon_hash: null,
|
||||
unicode_emoji: null,
|
||||
hoist: false,
|
||||
mentionable: false,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
await this.dispatchGuildRoleCreate({guildId, role});
|
||||
|
||||
await this.recordAuditLog({
|
||||
guildId,
|
||||
userId,
|
||||
action: AuditLogActionType.ROLE_CREATE,
|
||||
targetId: role.id,
|
||||
auditLogReason: auditLogReason ?? null,
|
||||
changes: this.guildAuditLogService.computeChanges(null, this.serializeRoleForAudit(role)),
|
||||
});
|
||||
|
||||
getMetricsService().counter({
|
||||
name: 'fluxer.roles.created',
|
||||
dimensions: {
|
||||
guild_id: guildId.toString(),
|
||||
position: role.position.toString(),
|
||||
permissions_count: role.permissions.toString().length.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
return mapGuildRoleToResponse(role);
|
||||
}
|
||||
|
||||
private async resolveRequestedPermissions(params: {
|
||||
requestedPermissions: bigint;
|
||||
guildData: {owner_id: string};
|
||||
userId: UserID;
|
||||
getMyPermissions: () => Promise<bigint>;
|
||||
}): Promise<bigint> {
|
||||
const {requestedPermissions, guildData, userId, getMyPermissions} = params;
|
||||
const sanitizedPermissions = requestedPermissions & ALL_PERMISSIONS;
|
||||
const isOwner = guildData && guildData.owner_id === userId.toString();
|
||||
if (!isOwner) {
|
||||
const myPermissions = await getMyPermissions();
|
||||
if ((sanitizedPermissions & ~myPermissions) !== 0n) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
return sanitizedPermissions;
|
||||
}
|
||||
|
||||
async updateRole(
|
||||
params: {userId: UserID; guildId: GuildID; roleId: RoleID; data: GuildRoleUpdateRequest},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<GuildRoleResponse> {
|
||||
const {userId, guildId, roleId, data} = params;
|
||||
const {guildData, checkPermission, getMyPermissions} = await this.getGuildAuthenticated({userId, guildId});
|
||||
await checkPermission(Permissions.MANAGE_ROLES);
|
||||
|
||||
const role = await this.guildRoleRepository.getRole(roleId, guildId);
|
||||
if (!role || (role.id === guildIdToRoleId(guildId) && roleId !== guildIdToRoleId(guildId))) {
|
||||
throw new UnknownGuildRoleError();
|
||||
}
|
||||
const isOwner = guildData && guildData.owner_id === userId.toString();
|
||||
if (!isOwner) {
|
||||
const canManageRole = await this.checkCanManageRole({guildId, userId, targetRole: role});
|
||||
if (!canManageRole) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
const previousSnapshot = this.serializeRoleForAudit(role);
|
||||
|
||||
const updateData = await this.buildRoleUpdateData({
|
||||
role,
|
||||
guildId,
|
||||
guildData,
|
||||
userId,
|
||||
data,
|
||||
getMyPermissions,
|
||||
});
|
||||
|
||||
const updatedRoleData = {
|
||||
...role.toRow(),
|
||||
name: updateData.name ?? role.name,
|
||||
color: updateData.color ?? role.color,
|
||||
position: updateData.position ?? role.position,
|
||||
hoist_position: updateData.hoistPosition !== undefined ? updateData.hoistPosition : role.hoistPosition,
|
||||
permissions: updateData.permissions ?? role.permissions,
|
||||
icon_hash: updateData.iconHash ?? role.iconHash,
|
||||
unicode_emoji: updateData.unicodeEmoji ?? role.unicodeEmoji,
|
||||
hoist: updateData.hoist ?? role.isHoisted,
|
||||
mentionable: updateData.mentionable ?? role.isMentionable,
|
||||
};
|
||||
|
||||
const updatedRole = await this.guildRoleRepository.upsertRole(updatedRoleData);
|
||||
|
||||
await this.dispatchGuildRoleUpdate({guildId, role: updatedRole});
|
||||
|
||||
const changes = this.guildAuditLogService.computeChanges(previousSnapshot, this.serializeRoleForAudit(updatedRole));
|
||||
if (role.permissions !== updatedRole.permissions) {
|
||||
const permissionsDiff = computePermissionsDiff(role.permissions, updatedRole.permissions);
|
||||
changes.push({key: 'permissions_diff', new_value: permissionsDiff});
|
||||
}
|
||||
|
||||
await this.recordAuditLog({
|
||||
guildId,
|
||||
userId,
|
||||
action: AuditLogActionType.ROLE_UPDATE,
|
||||
targetId: roleId,
|
||||
auditLogReason: auditLogReason ?? null,
|
||||
changes,
|
||||
});
|
||||
return mapGuildRoleToResponse(updatedRole);
|
||||
}
|
||||
|
||||
async deleteRole(
|
||||
params: {userId: UserID; guildId: GuildID; roleId: RoleID},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
const {userId, guildId, roleId} = params;
|
||||
const {checkPermission, guildData} = await this.getGuildAuthenticated({userId, guildId});
|
||||
await checkPermission(Permissions.MANAGE_ROLES);
|
||||
|
||||
const role = await this.guildRoleRepository.getRole(roleId, guildId);
|
||||
if (!role || role.id === guildIdToRoleId(guildId)) {
|
||||
throw new UnknownGuildRoleError();
|
||||
}
|
||||
const isOwner = guildData && guildData.owner_id === userId.toString();
|
||||
if (!isOwner) {
|
||||
const canManageRole = await this.checkCanManageRole({guildId, userId, targetRole: role});
|
||||
if (!canManageRole) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
const previousSnapshot = this.serializeRoleForAudit(role);
|
||||
|
||||
const memberIds = await this.gatewayService.getMembersWithRole({guildId, roleId});
|
||||
await Promise.all(
|
||||
memberIds.map(async (memberId) => {
|
||||
const member = await this.guildMemberRepository.getMember(guildId, memberId);
|
||||
if (member?.roleIds.has(roleId)) {
|
||||
const updatedRoleIds = new Set(member.roleIds);
|
||||
updatedRoleIds.delete(roleId);
|
||||
await this.guildMemberRepository.upsertMember({
|
||||
...member.toRow(),
|
||||
role_ids: updatedRoleIds.size > 0 ? updatedRoleIds : null,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
await this.guildRoleRepository.deleteRole(guildId, role.id);
|
||||
await this.dispatchGuildRoleDelete({guildId, roleId: role.id});
|
||||
|
||||
await this.recordAuditLog({
|
||||
guildId,
|
||||
userId,
|
||||
action: AuditLogActionType.ROLE_DELETE,
|
||||
targetId: roleId,
|
||||
auditLogReason: auditLogReason ?? null,
|
||||
changes: this.guildAuditLogService.computeChanges(previousSnapshot, null),
|
||||
});
|
||||
|
||||
getMetricsService().counter({
|
||||
name: 'fluxer.roles.deleted',
|
||||
dimensions: {
|
||||
guild_id: guildId.toString(),
|
||||
position: role.position.toString(),
|
||||
permissions_count: role.permissions.toString().length.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateRolePositions(
|
||||
params: {userId: UserID; guildId: GuildID; updates: Array<{roleId: RoleID; position?: number}>},
|
||||
_auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
const {userId, guildId, updates} = params;
|
||||
const {checkPermission} = await this.getGuildAuthenticated({userId, guildId});
|
||||
await checkPermission(Permissions.MANAGE_ROLES);
|
||||
|
||||
const lockKey = `guild:${guildId}:role-positions`;
|
||||
const lockToken = await this.cacheService.acquireLock(lockKey, 30);
|
||||
if (!lockToken) {
|
||||
throw new ResourceLockedError();
|
||||
}
|
||||
|
||||
try {
|
||||
await this.updateRolePositionsByList({userId, guildId, updates});
|
||||
} finally {
|
||||
await this.cacheService.releaseLock(lockKey, lockToken);
|
||||
}
|
||||
}
|
||||
|
||||
async listRoles(params: {userId: UserID; guildId: GuildID}): Promise<Array<GuildRoleResponse>> {
|
||||
const {userId, guildId} = params;
|
||||
const {checkPermission} = await this.getGuildAuthenticated({userId, guildId});
|
||||
await checkPermission(Permissions.MANAGE_ROLES);
|
||||
const roles = await this.guildRoleRepository.listRoles(guildId);
|
||||
const sortedRoles = [...roles].sort((a, b) => {
|
||||
if (b.position !== a.position) return b.position - a.position;
|
||||
return String(a.id).localeCompare(String(b.id));
|
||||
});
|
||||
return sortedRoles.map(mapGuildRoleToResponse);
|
||||
}
|
||||
|
||||
async updateHoistPositions(
|
||||
params: {userId: UserID; guildId: GuildID; updates: Array<{roleId: RoleID; hoistPosition: number}>},
|
||||
_auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
const {userId, guildId, updates} = params;
|
||||
const {checkPermission, guildData} = await this.getGuildAuthenticated({userId, guildId});
|
||||
await checkPermission(Permissions.MANAGE_ROLES);
|
||||
|
||||
const lockKey = `guild:${guildId}:role-hoist-positions`;
|
||||
const lockToken = await this.cacheService.acquireLock(lockKey, 30);
|
||||
if (!lockToken) {
|
||||
throw new ResourceLockedError();
|
||||
}
|
||||
|
||||
try {
|
||||
const allRoles = await this.guildRoleRepository.listRoles(guildId);
|
||||
const roleMap = new Map(allRoles.map((r) => [r.id, r]));
|
||||
const everyoneRoleId = guildIdToRoleId(guildId);
|
||||
const isOwner = guildData && guildData.owner_id === userId.toString();
|
||||
|
||||
let myHighestRole: GuildRole | null = null;
|
||||
if (!isOwner) {
|
||||
const member = await this.guildMemberRepository.getMember(guildId, userId);
|
||||
if (member) {
|
||||
myHighestRole = this.getUserHighestRole(member, allRoles);
|
||||
}
|
||||
}
|
||||
|
||||
const canManageRole = (role: GuildRole): boolean => {
|
||||
if (isOwner) return true;
|
||||
if (role.id === everyoneRoleId) return false;
|
||||
if (!myHighestRole) return false;
|
||||
return this.isRoleHigherThan(myHighestRole, role);
|
||||
};
|
||||
|
||||
for (const update of updates) {
|
||||
if (update.roleId === everyoneRoleId) {
|
||||
throw InputValidationError.fromCode('id', ValidationErrorCodes.CANNOT_SET_HOIST_FOR_EVERYONE_ROLE);
|
||||
}
|
||||
const role = roleMap.get(update.roleId);
|
||||
if (!role) {
|
||||
throw InputValidationError.fromCode('id', ValidationErrorCodes.INVALID_ROLE_ID, {
|
||||
roleId: update.roleId.toString(),
|
||||
});
|
||||
}
|
||||
if (!canManageRole(role)) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
|
||||
const changedRoles: Array<GuildRole> = [];
|
||||
for (const update of updates) {
|
||||
const role = roleMap.get(update.roleId)!;
|
||||
if (role.hoistPosition === update.hoistPosition) continue;
|
||||
|
||||
const updatedRole = await this.guildRoleRepository.upsertRole({
|
||||
...role.toRow(),
|
||||
hoist_position: update.hoistPosition,
|
||||
});
|
||||
changedRoles.push(updatedRole);
|
||||
}
|
||||
|
||||
if (changedRoles.length > 0) {
|
||||
await this.dispatchGuildRoleUpdateBulk({guildId, roles: changedRoles});
|
||||
}
|
||||
} finally {
|
||||
await this.cacheService.releaseLock(lockKey, lockToken);
|
||||
}
|
||||
}
|
||||
|
||||
async resetHoistPositions(
|
||||
params: {userId: UserID; guildId: GuildID},
|
||||
_auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
const {userId, guildId} = params;
|
||||
const {checkPermission} = await this.getGuildAuthenticated({userId, guildId});
|
||||
await checkPermission(Permissions.MANAGE_ROLES);
|
||||
|
||||
const lockKey = `guild:${guildId}:role-hoist-positions`;
|
||||
const lockToken = await this.cacheService.acquireLock(lockKey, 30);
|
||||
if (!lockToken) {
|
||||
throw new ResourceLockedError();
|
||||
}
|
||||
|
||||
try {
|
||||
const allRoles = await this.guildRoleRepository.listRoles(guildId);
|
||||
const changedRoles: Array<GuildRole> = [];
|
||||
|
||||
for (const role of allRoles) {
|
||||
if (role.hoistPosition === null) continue;
|
||||
|
||||
const updatedRole = await this.guildRoleRepository.upsertRole({
|
||||
...role.toRow(),
|
||||
hoist_position: null,
|
||||
});
|
||||
changedRoles.push(updatedRole);
|
||||
}
|
||||
|
||||
if (changedRoles.length > 0) {
|
||||
await this.dispatchGuildRoleUpdateBulk({guildId, roles: changedRoles});
|
||||
}
|
||||
} finally {
|
||||
await this.cacheService.releaseLock(lockKey, lockToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
|
||||
const guildData = await this.gatewayService.getGuildData({guildId, userId});
|
||||
|
||||
const checkPermission = async (permission: bigint) => {
|
||||
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
|
||||
if (!hasPermission) throw new MissingPermissionsError();
|
||||
};
|
||||
|
||||
const getMyPermissions = async () => this.gatewayService.getUserPermissions({guildId, userId});
|
||||
|
||||
return {
|
||||
guildData,
|
||||
checkPermission,
|
||||
getMyPermissions,
|
||||
};
|
||||
}
|
||||
|
||||
private async checkCanManageRole(params: {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
targetRole: GuildRole;
|
||||
}): Promise<boolean> {
|
||||
const {guildId, userId, targetRole} = params;
|
||||
return this.gatewayService.canManageRole({guildId, userId, roleId: targetRole.id});
|
||||
}
|
||||
|
||||
private getUserHighestRole(member: {roleIds: Set<RoleID>}, allRoles: Array<GuildRole>): GuildRole | null {
|
||||
const roleMap = new Map(allRoles.map((r) => [r.id, r]));
|
||||
|
||||
let highestRole: GuildRole | null = null;
|
||||
for (const roleId of member.roleIds) {
|
||||
const role = roleMap.get(roleId);
|
||||
if (!role) continue;
|
||||
|
||||
if (!highestRole) {
|
||||
highestRole = role;
|
||||
} else {
|
||||
if (this.isRoleHigherThan(role, highestRole)) {
|
||||
highestRole = role;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return highestRole;
|
||||
}
|
||||
|
||||
private isRoleHigherThan(roleA: GuildRole, roleB: GuildRole): boolean {
|
||||
if (roleA.position > roleB.position) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (roleA.position === roleB.position) {
|
||||
return String(roleA.id) < String(roleB.id);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async buildRoleUpdateData(params: {
|
||||
role: GuildRole;
|
||||
guildId: GuildID;
|
||||
guildData: {owner_id: string};
|
||||
userId: UserID;
|
||||
data: GuildRoleUpdateRequest;
|
||||
getMyPermissions: () => Promise<bigint>;
|
||||
}): Promise<RoleUpdateData> {
|
||||
const {role, guildId, guildData, userId, data, getMyPermissions} = params;
|
||||
const updateData: RoleUpdateData = {};
|
||||
const isEveryoneRole = role.id === guildIdToRoleId(guildId);
|
||||
|
||||
if (data.name !== undefined && !isEveryoneRole) {
|
||||
updateData.name = data.name;
|
||||
}
|
||||
if (data.color !== undefined) {
|
||||
updateData.color = data.color;
|
||||
}
|
||||
if (data.hoist !== undefined && !isEveryoneRole) {
|
||||
updateData.hoist = data.hoist;
|
||||
}
|
||||
if (data.hoist_position !== undefined && !isEveryoneRole) {
|
||||
updateData.hoistPosition = data.hoist_position;
|
||||
}
|
||||
if (data.mentionable !== undefined && !isEveryoneRole) {
|
||||
updateData.mentionable = data.mentionable;
|
||||
}
|
||||
if (data.permissions !== undefined) {
|
||||
updateData.permissions = await this.resolveRequestedPermissions({
|
||||
requestedPermissions: data.permissions,
|
||||
guildData,
|
||||
userId,
|
||||
getMyPermissions,
|
||||
});
|
||||
}
|
||||
return updateData;
|
||||
}
|
||||
|
||||
private async updateRolePositionsByList(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
updates: Array<{roleId: RoleID; position?: number}>;
|
||||
auditLogReason?: string | null;
|
||||
}): Promise<void> {
|
||||
const {userId, guildId, updates} = params;
|
||||
const {guildData} = await this.getGuildAuthenticated({userId, guildId});
|
||||
const allRoles = await this.guildRoleRepository.listRoles(guildId);
|
||||
const roleMap = new Map(allRoles.map((r) => [r.id, r]));
|
||||
|
||||
for (const update of updates) {
|
||||
if (update.roleId === guildIdToRoleId(guildId)) {
|
||||
throw InputValidationError.fromCode('id', ValidationErrorCodes.CANNOT_REORDER_EVERYONE_ROLE);
|
||||
}
|
||||
if (!roleMap.has(update.roleId)) {
|
||||
throw InputValidationError.fromCode('id', ValidationErrorCodes.INVALID_ROLE_ID, {
|
||||
roleId: update.roleId.toString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const everyoneRoleId = guildIdToRoleId(guildId);
|
||||
const isOwner = guildData && guildData.owner_id === userId.toString();
|
||||
|
||||
let myHighestRole: GuildRole | null = null;
|
||||
if (!isOwner) {
|
||||
const member = await this.guildMemberRepository.getMember(guildId, userId);
|
||||
if (member) {
|
||||
myHighestRole = this.getUserHighestRole(member, allRoles);
|
||||
}
|
||||
}
|
||||
|
||||
const canManageRole = (role: GuildRole): boolean => {
|
||||
if (isOwner) return true;
|
||||
if (role.id === everyoneRoleId) return false;
|
||||
if (!myHighestRole) return false;
|
||||
return this.isRoleHigherThan(myHighestRole, role);
|
||||
};
|
||||
|
||||
for (const update of updates) {
|
||||
const role = roleMap.get(update.roleId)!;
|
||||
if (!canManageRole(role)) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
|
||||
const explicitPositions = new Map<RoleID, number>();
|
||||
for (const update of updates) {
|
||||
if (update.position !== undefined) {
|
||||
explicitPositions.set(update.roleId, update.position);
|
||||
}
|
||||
}
|
||||
|
||||
const nonEveryoneRoles = allRoles.filter((r) => r.id !== everyoneRoleId);
|
||||
const currentOrder = [...nonEveryoneRoles].sort(
|
||||
(a, b) => b.position - a.position || String(a.id).localeCompare(String(b.id)),
|
||||
);
|
||||
|
||||
const targetOrder = [...currentOrder].sort((a, b) => {
|
||||
const posA = explicitPositions.has(a.id) ? explicitPositions.get(a.id)! : a.position;
|
||||
const posB = explicitPositions.has(b.id) ? explicitPositions.get(b.id)! : b.position;
|
||||
if (posA !== posB) return posB - posA;
|
||||
return 0;
|
||||
});
|
||||
|
||||
for (const role of currentOrder) {
|
||||
if (!canManageRole(role)) {
|
||||
const originalIndex = currentOrder.findIndex((r) => r.id === role.id);
|
||||
const newIndex = targetOrder.findIndex((r) => r.id === role.id);
|
||||
if (originalIndex !== newIndex) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reorderedIds = targetOrder.map((r) => r.id);
|
||||
await this.updateRolePositionsLocked({
|
||||
userId,
|
||||
guildId,
|
||||
operation: {roleId: reorderedIds[0]!, precedingRoleId: null},
|
||||
customOrder: reorderedIds,
|
||||
});
|
||||
}
|
||||
|
||||
private async updateRolePositionsLocked(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
operation: RoleReorderOperation;
|
||||
customOrder?: Array<RoleID>;
|
||||
}): Promise<void> {
|
||||
const {userId, guildId, operation, customOrder} = params;
|
||||
const {guildData} = await this.getGuildAuthenticated({userId, guildId});
|
||||
|
||||
const allRoles = await this.guildRoleRepository.listRoles(guildId);
|
||||
const roleMap = new Map(allRoles.map((r) => [r.id, r]));
|
||||
|
||||
const targetRole = roleMap.get(operation.roleId);
|
||||
if (!targetRole) {
|
||||
throw InputValidationError.fromCode('role_id', ValidationErrorCodes.INVALID_ROLE_ID, {
|
||||
roleId: operation.roleId.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
const everyoneRoleId = guildIdToRoleId(guildId);
|
||||
if (targetRole.id === everyoneRoleId) {
|
||||
throw InputValidationError.fromCode('role_id', ValidationErrorCodes.CANNOT_REORDER_EVERYONE_ROLE);
|
||||
}
|
||||
|
||||
let precedingRole: GuildRole | null = null;
|
||||
if (!customOrder) {
|
||||
if (operation.precedingRoleId) {
|
||||
if (operation.precedingRoleId === targetRole.id) {
|
||||
throw InputValidationError.fromCode(
|
||||
'preceding_role_id',
|
||||
ValidationErrorCodes.CANNOT_USE_SAME_ROLE_AS_PRECEDING,
|
||||
);
|
||||
}
|
||||
precedingRole = roleMap.get(operation.precedingRoleId) ?? null;
|
||||
if (!precedingRole) {
|
||||
throw InputValidationError.fromCode('preceding_role_id', ValidationErrorCodes.INVALID_ROLE_ID, {
|
||||
roleId: operation.precedingRoleId.toString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sortedRoles = [...allRoles].sort((a, b) => {
|
||||
if (b.position !== a.position) {
|
||||
return b.position - a.position;
|
||||
}
|
||||
return String(a.id).localeCompare(String(b.id));
|
||||
});
|
||||
|
||||
const originalIndex = sortedRoles.findIndex((role) => role.id === targetRole.id);
|
||||
if (originalIndex === -1) {
|
||||
throw new Error('Role ordering inconsistency detected');
|
||||
}
|
||||
|
||||
const baseList = sortedRoles.filter((role) => role.id !== targetRole.id);
|
||||
|
||||
let insertIndex = 0;
|
||||
if (!customOrder) {
|
||||
if (precedingRole) {
|
||||
const precedingIndex = baseList.findIndex((role) => role.id === precedingRole!.id);
|
||||
if (precedingIndex === -1) {
|
||||
throw InputValidationError.fromCode('preceding_role_id', ValidationErrorCodes.PRECEDING_ROLE_NOT_IN_GUILD);
|
||||
}
|
||||
insertIndex = precedingIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const isOwner = guildData && guildData.owner_id === userId.toString();
|
||||
|
||||
let myHighestRole: GuildRole | null = null;
|
||||
if (!isOwner) {
|
||||
const member = await this.guildMemberRepository.getMember(guildId, userId);
|
||||
if (member) {
|
||||
myHighestRole = this.getUserHighestRole(member, allRoles);
|
||||
}
|
||||
}
|
||||
|
||||
const canManageRole = (role: GuildRole): boolean => {
|
||||
if (isOwner) return true;
|
||||
if (role.id === everyoneRoleId) return false;
|
||||
if (!myHighestRole) return false;
|
||||
return this.isRoleHigherThan(myHighestRole, role);
|
||||
};
|
||||
|
||||
if (!canManageRole(targetRole)) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
if (insertIndex < originalIndex) {
|
||||
const rolesToCross = sortedRoles.slice(insertIndex, originalIndex);
|
||||
for (const role of rolesToCross) {
|
||||
if (!canManageRole(role)) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (customOrder) {
|
||||
baseList.splice(0, baseList.length, ...sortedRoles.filter((role) => role.id !== everyoneRoleId));
|
||||
} else {
|
||||
baseList.splice(insertIndex, 0, targetRole);
|
||||
}
|
||||
|
||||
const finalOrder = customOrder
|
||||
? customOrder.filter((roleId) => roleId !== everyoneRoleId)
|
||||
: baseList.map((role) => role.id).filter((roleId) => roleId !== everyoneRoleId);
|
||||
|
||||
const reorderedRoles = this.reorderRolePositions({
|
||||
allRoles,
|
||||
reorderedIds: finalOrder,
|
||||
guildId,
|
||||
});
|
||||
|
||||
const updatePromises = reorderedRoles.map((role) => this.guildRoleRepository.upsertRole(role.toRow()));
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
const updatedRoles = await this.guildRoleRepository.listRoles(guildId);
|
||||
const changedRoles = updatedRoles.filter((role) => {
|
||||
const oldRole = roleMap.get(role.id);
|
||||
return oldRole && oldRole.position !== role.position;
|
||||
});
|
||||
|
||||
if (changedRoles.length > 0) {
|
||||
await this.dispatchGuildRoleUpdateBulk({guildId, roles: changedRoles});
|
||||
}
|
||||
}
|
||||
|
||||
private reorderRolePositions({
|
||||
allRoles,
|
||||
reorderedIds,
|
||||
guildId,
|
||||
}: {
|
||||
allRoles: Array<GuildRole>;
|
||||
reorderedIds: Array<RoleID>;
|
||||
guildId: GuildID;
|
||||
}): Array<GuildRole> {
|
||||
const roleMap = new Map(allRoles.map((r) => [r.id, r]));
|
||||
const everyoneRole = roleMap.get(guildIdToRoleId(guildId));
|
||||
const reorderedRoleSet = new Set(reorderedIds);
|
||||
|
||||
const nonReorderedRoles = allRoles
|
||||
.filter((role) => role.id !== guildIdToRoleId(guildId) && !reorderedRoleSet.has(role.id))
|
||||
.sort((a, b) => a.position - b.position);
|
||||
|
||||
const newRoles: Array<GuildRole> = [];
|
||||
|
||||
if (everyoneRole) {
|
||||
newRoles.push(new GuildRole({...everyoneRole.toRow(), position: 0}));
|
||||
}
|
||||
|
||||
let currentPosition = reorderedIds.length + nonReorderedRoles.length;
|
||||
for (const roleId of reorderedIds) {
|
||||
const role = roleMap.get(roleId);
|
||||
if (role && roleId !== guildIdToRoleId(guildId)) {
|
||||
newRoles.push(new GuildRole({...role.toRow(), position: currentPosition}));
|
||||
currentPosition--;
|
||||
}
|
||||
}
|
||||
|
||||
for (const role of nonReorderedRoles) {
|
||||
newRoles.push(new GuildRole({...role.toRow(), position: currentPosition}));
|
||||
currentPosition--;
|
||||
}
|
||||
|
||||
return newRoles;
|
||||
}
|
||||
|
||||
private serializeRoleForAudit(role: GuildRole): Record<string, unknown> {
|
||||
return {
|
||||
role_id: role.id.toString(),
|
||||
name: role.name,
|
||||
permissions: role.permissions.toString(),
|
||||
position: role.position,
|
||||
hoist_position: role.hoistPosition,
|
||||
color: role.color,
|
||||
icon_hash: role.iconHash ?? null,
|
||||
unicode_emoji: role.unicodeEmoji ?? null,
|
||||
hoist: role.isHoisted,
|
||||
mentionable: role.isMentionable,
|
||||
};
|
||||
}
|
||||
|
||||
private async dispatchGuildRoleCreate({guildId, role}: {guildId: GuildID; role: GuildRole}): Promise<void> {
|
||||
await this.gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_ROLE_CREATE',
|
||||
data: {role: mapGuildRoleToResponse(role)},
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatchGuildRoleUpdate({guildId, role}: {guildId: GuildID; role: GuildRole}): Promise<void> {
|
||||
await this.gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_ROLE_UPDATE',
|
||||
data: {role: mapGuildRoleToResponse(role)},
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatchGuildRoleDelete({guildId, roleId}: {guildId: GuildID; roleId: RoleID}): Promise<void> {
|
||||
await this.gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_ROLE_DELETE',
|
||||
data: {role_id: roleId.toString()},
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatchGuildRoleUpdateBulk({
|
||||
guildId,
|
||||
roles,
|
||||
}: {
|
||||
guildId: GuildID;
|
||||
roles: Array<GuildRole>;
|
||||
}): Promise<void> {
|
||||
const roleResponses = roles.map((role) => mapGuildRoleToResponse(role));
|
||||
|
||||
await this.gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_ROLE_UPDATE_BULK',
|
||||
data: {roles: roleResponses},
|
||||
});
|
||||
}
|
||||
|
||||
private async recordAuditLog(params: {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
action: AuditLogActionType;
|
||||
targetId?: RoleID | string | null;
|
||||
auditLogReason?: string | null;
|
||||
metadata?: Map<string, string> | Record<string, string>;
|
||||
changes?: GuildAuditLogChange | null;
|
||||
}): Promise<void> {
|
||||
const targetId =
|
||||
params.targetId === undefined || params.targetId === null
|
||||
? null
|
||||
: typeof params.targetId === 'string'
|
||||
? params.targetId
|
||||
: params.targetId.toString();
|
||||
|
||||
try {
|
||||
const builder = this.guildAuditLogService
|
||||
.createBuilder(params.guildId, params.userId)
|
||||
.withAction(params.action, targetId)
|
||||
.withReason(params.auditLogReason ?? null);
|
||||
|
||||
if (params.metadata) {
|
||||
builder.withMetadata(params.metadata);
|
||||
}
|
||||
if (params.changes) {
|
||||
builder.withChanges(params.changes);
|
||||
}
|
||||
|
||||
await builder.commit();
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{
|
||||
error,
|
||||
guildId: params.guildId.toString(),
|
||||
userId: params.userId.toString(),
|
||||
action: params.action,
|
||||
targetId,
|
||||
},
|
||||
'Failed to record guild audit log',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveGuildLimit(guildFeatures: Iterable<string> | null, key: LimitKey, fallback: number): number {
|
||||
const ctx = createLimitMatchContext({guildFeatures});
|
||||
return resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, key, fallback);
|
||||
}
|
||||
}
|
||||
374
packages/api/src/guild/services/GuildSearchService.tsx
Normal file
374
packages/api/src/guild/services/GuildSearchService.tsx
Normal file
@@ -0,0 +1,374 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
|
||||
import type {ChannelService} from '@fluxer/api/src/channel/services/ChannelService';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import {getMessageSearchService} from '@fluxer/api/src/SearchFactory';
|
||||
import {buildMessageSearchFilters} from '@fluxer/api/src/search/BuildMessageSearchFilters';
|
||||
import {channelNeedsReindexing} from '@fluxer/api/src/search/ChannelIndexingUtils';
|
||||
import {MessageSearchResponseMapper} from '@fluxer/api/src/search/MessageSearchResponseMapper';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {isUserAdult} from '@fluxer/api/src/utils/AgeUtils';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildNSFWLevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {NsfwContentRequiresAgeVerificationError} from '@fluxer/errors/src/domains/moderation/NsfwContentRequiresAgeVerificationError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {MessageSearchRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
|
||||
import type {MessageSearchResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {IWorkerService} from '@fluxer/worker/src/contracts/IWorkerService';
|
||||
|
||||
export class GuildSearchService {
|
||||
private readonly responseMapper: MessageSearchResponseMapper;
|
||||
|
||||
constructor(
|
||||
private readonly channelRepository: IChannelRepository,
|
||||
private readonly channelService: ChannelService,
|
||||
private readonly userCacheService: UserCacheService,
|
||||
private readonly gatewayService: IGatewayService,
|
||||
private readonly userRepository: IUserRepository,
|
||||
private readonly mediaService: IMediaService,
|
||||
private readonly workerService: IWorkerService,
|
||||
) {
|
||||
this.responseMapper = new MessageSearchResponseMapper(
|
||||
this.channelRepository,
|
||||
this.channelService,
|
||||
this.userCacheService,
|
||||
this.mediaService,
|
||||
);
|
||||
}
|
||||
|
||||
async searchMessages(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
channelIds: Array<ChannelID>;
|
||||
searchParams: MessageSearchRequest;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<MessageSearchResponse> {
|
||||
const {userId, guildId, searchParams, requestCache} = params;
|
||||
let {channelIds} = params;
|
||||
|
||||
const guildData = await this.gatewayService.getGuildData({guildId, userId});
|
||||
const guildIsAgeRestricted = guildData?.nsfw_level === GuildNSFWLevel.AGE_RESTRICTED;
|
||||
|
||||
const searchService = getMessageSearchService();
|
||||
if (!searchService) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
|
||||
const explicitChannelIds = channelIds.length > 0;
|
||||
|
||||
if (!explicitChannelIds) {
|
||||
const channels = await this.channelRepository.listGuildChannels(guildId);
|
||||
channelIds = channels.map((c) => c.id);
|
||||
}
|
||||
|
||||
const includeNsfwRequested = searchParams.include_nsfw ?? false;
|
||||
let userIsAdultResult: boolean | null = null;
|
||||
const getIsUserAdult = async (): Promise<boolean> => {
|
||||
if (userIsAdultResult !== null) {
|
||||
return userIsAdultResult;
|
||||
}
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
userIsAdultResult = isUserAdult(user.dateOfBirth);
|
||||
return userIsAdultResult;
|
||||
};
|
||||
|
||||
if (guildIsAgeRestricted) {
|
||||
if (!(await getIsUserAdult())) {
|
||||
throw new NsfwContentRequiresAgeVerificationError();
|
||||
}
|
||||
|
||||
// Searching an age-restricted community requires an explicit NSFW opt-in.
|
||||
if (!includeNsfwRequested) {
|
||||
const hitsPerPage = searchParams.hits_per_page ?? 25;
|
||||
const page = searchParams.page ?? 1;
|
||||
return {
|
||||
messages: [],
|
||||
total: 0,
|
||||
hits_per_page: hitsPerPage,
|
||||
page,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const canIncludeNsfw = includeNsfwRequested ? await getIsUserAdult() : false;
|
||||
const validChannelIds: Array<ChannelID> = [];
|
||||
for (const channelId of channelIds) {
|
||||
const channel = await this.channelRepository.findUnique(channelId);
|
||||
if (!channel || channel.guildId !== guildId) {
|
||||
throw InputValidationError.fromCode('channel_ids', ValidationErrorCodes.ALL_CHANNELS_MUST_BELONG_TO_GUILD);
|
||||
}
|
||||
|
||||
if (channel.isNsfw && !canIncludeNsfw) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const canSearch = await this.gatewayService.checkPermission({
|
||||
guildId,
|
||||
userId,
|
||||
channelId,
|
||||
permission: Permissions.VIEW_CHANNEL | Permissions.READ_MESSAGE_HISTORY,
|
||||
});
|
||||
|
||||
if (!canSearch) {
|
||||
if (explicitChannelIds) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
validChannelIds.push(channelId);
|
||||
}
|
||||
|
||||
if (validChannelIds.length === 0) {
|
||||
const hitsPerPage = searchParams.hits_per_page ?? 25;
|
||||
const page = searchParams.page ?? 1;
|
||||
return {
|
||||
messages: [],
|
||||
total: 0,
|
||||
hits_per_page: hitsPerPage,
|
||||
page,
|
||||
};
|
||||
}
|
||||
|
||||
const channelsNeedingIndex = await Promise.all(
|
||||
validChannelIds.map(async (channelId) => {
|
||||
const channel = await this.channelRepository.findUnique(channelId);
|
||||
return channel && channelNeedsReindexing(channel.indexedAt) ? channelId : null;
|
||||
}),
|
||||
);
|
||||
|
||||
const unindexedChannels = channelsNeedingIndex.filter((id): id is ChannelID => id !== null);
|
||||
|
||||
if (unindexedChannels.length > 0) {
|
||||
await Promise.all(
|
||||
unindexedChannels.map((channelId) =>
|
||||
this.workerService.addJob(
|
||||
'indexChannelMessages',
|
||||
{channelId: channelId.toString()},
|
||||
{
|
||||
jobKey: `indexChannelMessages-${channelId}`,
|
||||
maxAttempts: 3,
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
return {indexing: true};
|
||||
}
|
||||
|
||||
const filters = buildMessageSearchFilters(
|
||||
searchParams,
|
||||
validChannelIds.map((id) => id.toString()),
|
||||
);
|
||||
|
||||
const hitsPerPage = searchParams.hits_per_page ?? 25;
|
||||
const page = searchParams.page ?? 1;
|
||||
const result = await searchService.searchMessages(searchParams.content ?? '', filters, {
|
||||
hitsPerPage,
|
||||
page,
|
||||
});
|
||||
|
||||
const messageResponses = await this.responseMapper.mapSearchResultToResponses(result, userId, requestCache);
|
||||
|
||||
return {
|
||||
messages: messageResponses,
|
||||
total: result.total,
|
||||
hits_per_page: hitsPerPage,
|
||||
page,
|
||||
};
|
||||
}
|
||||
|
||||
async searchAllGuilds(params: {
|
||||
userId: UserID;
|
||||
channelIds: Array<ChannelID>;
|
||||
searchParams: MessageSearchRequest;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<MessageSearchResponse> {
|
||||
const {userId, channelIds, searchParams, requestCache} = params;
|
||||
|
||||
const searchService = getMessageSearchService();
|
||||
if (!searchService) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
|
||||
const {accessibleChannels, unindexedChannelIds, guildNsfwLevels} =
|
||||
await this.collectAccessibleGuildChannels(userId);
|
||||
|
||||
if (unindexedChannelIds.size > 0) {
|
||||
await this.queueIndexingChannels(unindexedChannelIds);
|
||||
return {indexing: true};
|
||||
}
|
||||
|
||||
let searchChannelIds = Array.from(accessibleChannels.keys());
|
||||
|
||||
if (channelIds.length > 0) {
|
||||
const requestedChannelStrings = channelIds.map((id) => id.toString());
|
||||
for (const requested of requestedChannelStrings) {
|
||||
if (!accessibleChannels.has(requested)) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
searchChannelIds = requestedChannelStrings;
|
||||
}
|
||||
|
||||
const includeNsfwRequested = searchParams.include_nsfw ?? false;
|
||||
let canIncludeNsfw = false;
|
||||
if (includeNsfwRequested) {
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
canIncludeNsfw = isUserAdult(user.dateOfBirth);
|
||||
}
|
||||
|
||||
searchChannelIds = searchChannelIds.filter((channelIdStr) => {
|
||||
const channel = accessibleChannels.get(channelIdStr);
|
||||
if (!channel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const guildId = channel.guildId?.toString();
|
||||
const guildIsAgeRestricted = guildId != null && guildNsfwLevels.get(guildId) === GuildNSFWLevel.AGE_RESTRICTED;
|
||||
|
||||
if (guildIsAgeRestricted) {
|
||||
return canIncludeNsfw;
|
||||
}
|
||||
|
||||
if (channel.isNsfw) {
|
||||
return canIncludeNsfw;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (searchChannelIds.length === 0) {
|
||||
const hitsPerPage = searchParams.hits_per_page ?? 25;
|
||||
const page = searchParams.page ?? 1;
|
||||
return {
|
||||
messages: [],
|
||||
total: 0,
|
||||
hits_per_page: hitsPerPage,
|
||||
page,
|
||||
};
|
||||
}
|
||||
|
||||
const filters = buildMessageSearchFilters(searchParams, searchChannelIds);
|
||||
|
||||
const hitsPerPage = searchParams.hits_per_page ?? 25;
|
||||
const page = searchParams.page ?? 1;
|
||||
const result = await searchService.searchMessages(searchParams.content ?? '', filters, {
|
||||
hitsPerPage,
|
||||
page,
|
||||
});
|
||||
|
||||
const messageResponses = await this.responseMapper.mapSearchResultToResponses(result, userId, requestCache);
|
||||
|
||||
return {
|
||||
messages: messageResponses,
|
||||
total: result.total,
|
||||
hits_per_page: hitsPerPage,
|
||||
page,
|
||||
};
|
||||
}
|
||||
|
||||
private async queueIndexingChannels(channelIds: Iterable<string>): Promise<void> {
|
||||
await Promise.all(
|
||||
Array.from(channelIds).map((channelId) =>
|
||||
this.workerService.addJob(
|
||||
'indexChannelMessages',
|
||||
{channelId},
|
||||
{
|
||||
jobKey: `indexChannelMessages-${channelId}`,
|
||||
maxAttempts: 3,
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async collectAccessibleGuildChannels(userId: UserID): Promise<{
|
||||
accessibleChannels: Map<string, Channel>;
|
||||
unindexedChannelIds: Set<string>;
|
||||
guildNsfwLevels: Map<string, number>;
|
||||
}> {
|
||||
const guildIds = await this.userRepository.getUserGuildIds(userId);
|
||||
const accessibleChannels = new Map<string, Channel>();
|
||||
const unindexedChannelIds = new Set<string>();
|
||||
const guildNsfwLevels = new Map<string, number>();
|
||||
|
||||
for (const guildId of guildIds) {
|
||||
const guildData = await this.gatewayService.getGuildData({guildId, userId});
|
||||
if (guildData) {
|
||||
guildNsfwLevels.set(guildId.toString(), guildData.nsfw_level);
|
||||
}
|
||||
|
||||
const guildChannels = await this.channelRepository.listGuildChannels(guildId);
|
||||
if (guildChannels.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const viewableChannelIds = new Set(
|
||||
(
|
||||
await this.gatewayService.getViewableChannels({
|
||||
guildId,
|
||||
userId,
|
||||
})
|
||||
).map((channelId) => channelId.toString()),
|
||||
);
|
||||
|
||||
for (const channel of guildChannels) {
|
||||
const channelIdStr = channel.id.toString();
|
||||
if (!viewableChannelIds.has(channelIdStr)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasPermission = await this.gatewayService.checkPermission({
|
||||
guildId,
|
||||
userId,
|
||||
channelId: channel.id,
|
||||
permission: Permissions.VIEW_CHANNEL | Permissions.READ_MESSAGE_HISTORY,
|
||||
});
|
||||
|
||||
if (!hasPermission) {
|
||||
continue;
|
||||
}
|
||||
|
||||
accessibleChannels.set(channelIdStr, channel);
|
||||
if (channelNeedsReindexing(channel.indexedAt)) {
|
||||
unindexedChannelIds.add(channelIdStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {accessibleChannels, unindexedChannelIds, guildNsfwLevels};
|
||||
}
|
||||
}
|
||||
1171
packages/api/src/guild/services/GuildService.tsx
Normal file
1171
packages/api/src/guild/services/GuildService.tsx
Normal file
File diff suppressed because it is too large
Load Diff
136
packages/api/src/guild/services/channel/ChannelHelpers.tsx
Normal file
136
packages/api/src/guild/services/channel/ChannelHelpers.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import {serializeChannelForAudit as serializeChannelForAuditUtil} from '@fluxer/api/src/utils/AuditSerializationUtils';
|
||||
import {toIdString} from '@fluxer/api/src/utils/IdUtils';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
|
||||
export interface ChannelReorderOperation {
|
||||
channelId: ChannelID;
|
||||
parentId: ChannelID | null | undefined;
|
||||
precedingSiblingId: ChannelID | null;
|
||||
}
|
||||
|
||||
// biome-ignore lint/complexity/noStaticOnlyClass: Using class for namespace organization
|
||||
export class ChannelHelpers {
|
||||
static getNextGlobalChannelPosition(
|
||||
channelType: number,
|
||||
parentId: ChannelID | null | undefined,
|
||||
existingChannels: Array<Channel>,
|
||||
): number {
|
||||
if (channelType === ChannelTypes.GUILD_CATEGORY) {
|
||||
return existingChannels.reduce((max, c) => Math.max(max, c.position || 0), 0) + 1;
|
||||
}
|
||||
|
||||
if (parentId == null || parentId === undefined) {
|
||||
return existingChannels.reduce((max, c) => Math.max(max, c.position || 0), 0) + 1;
|
||||
}
|
||||
|
||||
const parentCategory = existingChannels.find((c) => c.id === parentId);
|
||||
if (!parentCategory) {
|
||||
return existingChannels.reduce((max, c) => Math.max(max, c.position || 0), 0) + 1;
|
||||
}
|
||||
|
||||
const channelsInCategory = existingChannels.filter((c) => c.parentId === parentId);
|
||||
const textChannels = channelsInCategory.filter(
|
||||
(c) => c.type === ChannelTypes.GUILD_TEXT || c.type === ChannelTypes.GUILD_LINK,
|
||||
);
|
||||
const voiceChannels = channelsInCategory.filter((c) => c.type === ChannelTypes.GUILD_VOICE);
|
||||
|
||||
if (channelType === ChannelTypes.GUILD_VOICE) {
|
||||
if (voiceChannels.length > 0) {
|
||||
return voiceChannels.reduce((max, c) => Math.max(max, c.position || 0), 0) + 1;
|
||||
} else if (textChannels.length > 0) {
|
||||
return textChannels.reduce((max, c) => Math.max(max, c.position || 0), 0) + 1;
|
||||
} else {
|
||||
return parentCategory.position + 1;
|
||||
}
|
||||
} else {
|
||||
if (textChannels.length > 0) {
|
||||
const maxTextPosition = textChannels.reduce((max, c) => Math.max(max, c.position || 0), 0);
|
||||
if (voiceChannels.length > 0) {
|
||||
return maxTextPosition + 1;
|
||||
} else {
|
||||
return maxTextPosition + 1;
|
||||
}
|
||||
} else {
|
||||
return parentCategory.position + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static validateChannelVoicePlacement(
|
||||
finalChannels: Array<Channel>,
|
||||
parentMap: Map<ChannelID, ChannelID | null>,
|
||||
): void {
|
||||
const orderedByParent = new Map<ChannelID | null, Array<Channel>>();
|
||||
|
||||
for (const channel of finalChannels) {
|
||||
const parentId = parentMap.get(channel.id) ?? null;
|
||||
if (!orderedByParent.has(parentId)) {
|
||||
orderedByParent.set(parentId, []);
|
||||
}
|
||||
orderedByParent.get(parentId)!.push(channel);
|
||||
}
|
||||
|
||||
for (const [parentId, siblings] of orderedByParent.entries()) {
|
||||
if (parentId === null) continue;
|
||||
let encounteredVoice = false;
|
||||
for (const sibling of siblings) {
|
||||
if (sibling.type === ChannelTypes.GUILD_VOICE) {
|
||||
encounteredVoice = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const isText = sibling.type === ChannelTypes.GUILD_TEXT || sibling.type === ChannelTypes.GUILD_LINK;
|
||||
if (encounteredVoice && isText) {
|
||||
throw InputValidationError.fromCode(
|
||||
'preceding_sibling_id',
|
||||
ValidationErrorCodes.VOICE_CHANNELS_CANNOT_BE_ABOVE_TEXT_CHANNELS,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static serializeChannelForAudit(channel: Channel): Record<string, unknown> {
|
||||
return serializeChannelForAuditUtil(channel);
|
||||
}
|
||||
|
||||
static serializeChannelOrdering(
|
||||
channels: Array<Channel>,
|
||||
): Array<{channel_id: string; parent_id: string | null; position: number}> {
|
||||
return [...channels]
|
||||
.sort((a, b) => {
|
||||
if (a.position !== b.position) {
|
||||
return a.position - b.position;
|
||||
}
|
||||
return a.id.toString().localeCompare(b.id.toString());
|
||||
})
|
||||
.map((channel) => ({
|
||||
channel_id: channel.id.toString(),
|
||||
parent_id: toIdString(channel.parentId),
|
||||
position: channel.position,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,774 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, EmojiID, GuildID, RoleID, StickerID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {createChannelID, createRoleID, createUserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {mapChannelToResponse} from '@fluxer/api/src/channel/ChannelMappers';
|
||||
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
|
||||
import type {PermissionOverwrite} from '@fluxer/api/src/database/types/ChannelTypes';
|
||||
import type {GuildAuditLogService} from '@fluxer/api/src/guild/GuildAuditLogService';
|
||||
import type {AuditLogChange, GuildAuditLogChange} from '@fluxer/api/src/guild/GuildAuditLogTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import {ChannelHelpers, type ChannelReorderOperation} from '@fluxer/api/src/guild/services/channel/ChannelHelpers';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import {ChannelPermissionOverwrite} from '@fluxer/api/src/models/ChannelPermissionOverwrite';
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {MAX_CHANNELS_PER_CATEGORY, MAX_GUILD_CHANNELS} from '@fluxer/constants/src/LimitConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {MaxCategoryChannelsError} from '@fluxer/errors/src/domains/channel/MaxCategoryChannelsError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {ResourceLockedError} from '@fluxer/errors/src/domains/core/ResourceLockedError';
|
||||
import {MaxGuildChannelsError} from '@fluxer/errors/src/domains/guild/MaxGuildChannelsError';
|
||||
import type {ChannelCreateRequest} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import {
|
||||
computeChannelMoveBlockIds,
|
||||
computeGuildChannelReorderPlan,
|
||||
type GuildChannelReorderErrorCode,
|
||||
sortChannelsForOrdering,
|
||||
} from '@fluxer/schema/src/domains/channel/GuildChannelOrdering';
|
||||
import {ChannelNameType} from '@fluxer/schema/src/primitives/ChannelValidators';
|
||||
|
||||
export class ChannelOperationsService {
|
||||
constructor(
|
||||
private readonly channelRepository: IChannelRepository,
|
||||
private readonly guildRepository: IGuildRepositoryAggregate,
|
||||
private readonly userCacheService: UserCacheService,
|
||||
private readonly gatewayService: IGatewayService,
|
||||
private readonly cacheService: ICacheService,
|
||||
private readonly snowflakeService: SnowflakeService,
|
||||
private readonly guildAuditLogService: GuildAuditLogService,
|
||||
private readonly limitConfigService: LimitConfigService,
|
||||
) {}
|
||||
|
||||
async createChannel(
|
||||
params: {userId: UserID; guildId: GuildID; data: ChannelCreateRequest; requestCache: RequestCache},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<ChannelResponse> {
|
||||
await this.ensureGuildHasCapacity(params.guildId);
|
||||
const channels = await this.channelRepository.listGuildChannels(params.guildId);
|
||||
|
||||
const parentId = params.data.parent_id ? createChannelID(params.data.parent_id) : null;
|
||||
|
||||
if (parentId) {
|
||||
await this.ensureCategoryHasCapacity({guildId: params.guildId, categoryId: parentId});
|
||||
}
|
||||
|
||||
const newPosition = ChannelHelpers.getNextGlobalChannelPosition(params.data.type, parentId, channels);
|
||||
let permissionOverwrites: Map<RoleID | UserID, PermissionOverwrite> | null = null;
|
||||
|
||||
const requestedOverwrites = params.data.permission_overwrites ?? null;
|
||||
if (requestedOverwrites) {
|
||||
const basePermissions = await this.gatewayService.getUserPermissions({
|
||||
guildId: params.guildId,
|
||||
userId: params.userId,
|
||||
channelId: parentId ?? undefined,
|
||||
});
|
||||
|
||||
for (const overwrite of requestedOverwrites) {
|
||||
const allowPerms = overwrite.allow ? BigInt(overwrite.allow) : 0n;
|
||||
const denyPerms = overwrite.deny ? BigInt(overwrite.deny) : 0n;
|
||||
const combined = allowPerms | denyPerms;
|
||||
if ((combined & ~basePermissions) !== 0n) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
|
||||
permissionOverwrites = new Map(
|
||||
requestedOverwrites.map((overwrite) => {
|
||||
const targetId = overwrite.type === 0 ? createRoleID(overwrite.id) : createUserID(overwrite.id);
|
||||
return [
|
||||
targetId,
|
||||
new ChannelPermissionOverwrite({
|
||||
type: overwrite.type,
|
||||
allow_: overwrite.allow ? BigInt(overwrite.allow) : 0n,
|
||||
deny_: overwrite.deny ? BigInt(overwrite.deny) : 0n,
|
||||
}).toPermissionOverwrite(),
|
||||
];
|
||||
}),
|
||||
);
|
||||
} else if (parentId) {
|
||||
const parentChannel = await this.channelRepository.findUnique(parentId);
|
||||
if (parentChannel?.permissionOverwrites) {
|
||||
permissionOverwrites = new Map(
|
||||
Array.from(parentChannel.permissionOverwrites.entries()).map(([targetId, overwrite]) => [
|
||||
targetId,
|
||||
overwrite.toPermissionOverwrite(),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let channelName = params.data.name;
|
||||
if (params.data.type === ChannelTypes.GUILD_TEXT) {
|
||||
const guildData = await this.gatewayService.getGuildData({
|
||||
guildId: params.guildId,
|
||||
userId: params.userId,
|
||||
skipMembershipCheck: true,
|
||||
});
|
||||
const hasFlexibleNamesEnabled = guildData.features.includes(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES);
|
||||
if (!hasFlexibleNamesEnabled) {
|
||||
channelName = ChannelNameType.parse(channelName);
|
||||
}
|
||||
}
|
||||
|
||||
const channelId = createChannelID(await this.snowflakeService.generate());
|
||||
const channel = await this.channelRepository.upsert({
|
||||
channel_id: channelId,
|
||||
guild_id: params.guildId,
|
||||
type: params.data.type,
|
||||
name: channelName,
|
||||
topic: params.data.topic ?? null,
|
||||
icon_hash: null,
|
||||
url: params.data.url ?? null,
|
||||
parent_id: parentId,
|
||||
position: newPosition,
|
||||
owner_id: null,
|
||||
recipient_ids: null,
|
||||
nsfw: false,
|
||||
rate_limit_per_user: 0,
|
||||
bitrate: params.data.type === ChannelTypes.GUILD_VOICE ? (params.data.bitrate ?? 64000) : null,
|
||||
user_limit: params.data.type === ChannelTypes.GUILD_VOICE ? (params.data.user_limit ?? 0) : null,
|
||||
rtc_region: null,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: permissionOverwrites,
|
||||
nicks: null,
|
||||
soft_deleted: false,
|
||||
indexed_at: null,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
await this.dispatchChannelCreate({guildId: params.guildId, channel, requestCache: params.requestCache});
|
||||
|
||||
await this.recordAuditLog({
|
||||
guildId: params.guildId,
|
||||
userId: params.userId,
|
||||
action: AuditLogActionType.CHANNEL_CREATE,
|
||||
targetId: channel.id,
|
||||
auditLogReason: auditLogReason ?? null,
|
||||
metadata: {name: channel.name ?? '', type: channel.type.toString()},
|
||||
changes: this.guildAuditLogService.computeChanges(null, ChannelHelpers.serializeChannelForAudit(channel)),
|
||||
});
|
||||
|
||||
getMetricsService().counter({
|
||||
name: 'fluxer.channels.created',
|
||||
dimensions: {
|
||||
guild_id: params.guildId.toString(),
|
||||
channel_type: channel.type.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
return await mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: null,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache: params.requestCache,
|
||||
});
|
||||
}
|
||||
|
||||
async updateChannelPositionsLocked(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
operation: ChannelReorderOperation;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const lockKey = `guild:${params.guildId}:channel-positions`;
|
||||
const lockToken = await this.cacheService.acquireLock(lockKey, 30);
|
||||
if (!lockToken) {
|
||||
throw new ResourceLockedError();
|
||||
}
|
||||
|
||||
try {
|
||||
await this.executeChannelReorder(params);
|
||||
} finally {
|
||||
await this.cacheService.releaseLock(lockKey, lockToken);
|
||||
}
|
||||
}
|
||||
|
||||
async sanitizeTextChannelNames(params: {guildId: GuildID; requestCache: RequestCache}): Promise<void> {
|
||||
const {guildId, requestCache} = params;
|
||||
const channels = await this.channelRepository.listGuildChannels(guildId);
|
||||
|
||||
let hasChanges = false;
|
||||
const updatedChannels: Array<Channel> = [];
|
||||
|
||||
for (const channel of channels) {
|
||||
if (channel.type !== ChannelTypes.GUILD_TEXT || channel.name == null) {
|
||||
updatedChannels.push(channel);
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = ChannelNameType.parse(channel.name);
|
||||
if (normalized === channel.name) {
|
||||
updatedChannels.push(channel);
|
||||
continue;
|
||||
}
|
||||
|
||||
const updated = await this.channelRepository.upsert({
|
||||
...channel.toRow(),
|
||||
name: normalized,
|
||||
});
|
||||
updatedChannels.push(updated);
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
await this.dispatchChannelUpdateBulk({guildId, channels: updatedChannels, requestCache});
|
||||
}
|
||||
}
|
||||
|
||||
async setChannelPermissionOverwrite(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
overwriteId: bigint;
|
||||
overwrite: {type: number; allow_: bigint; deny_: bigint};
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const channel = await this.channelRepository.findUnique(params.channelId);
|
||||
if (!channel || !channel.guildId) {
|
||||
throw InputValidationError.fromCode('channel_id', ValidationErrorCodes.INVALID_CHANNEL);
|
||||
}
|
||||
const guildId = channel.guildId;
|
||||
const hasManageRoles = await this.gatewayService.checkPermission({
|
||||
guildId,
|
||||
userId: params.userId,
|
||||
permission: Permissions.MANAGE_ROLES,
|
||||
});
|
||||
if (!hasManageRoles) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
const userPermissions = await this.gatewayService.getUserPermissions({
|
||||
guildId,
|
||||
userId: params.userId,
|
||||
channelId: channel.id,
|
||||
});
|
||||
const combined = params.overwrite.allow_ | params.overwrite.deny_;
|
||||
if ((combined & ~userPermissions) !== 0n) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
const overwrites = new Map(channel.permissionOverwrites ?? []);
|
||||
const targetId = params.overwrite.type === 0 ? createRoleID(params.overwriteId) : createUserID(params.overwriteId);
|
||||
const previousOverwrite = overwrites.get(targetId);
|
||||
overwrites.set(targetId, new ChannelPermissionOverwrite(params.overwrite));
|
||||
|
||||
const updated = await this.channelRepository.upsert({
|
||||
...channel.toRow(),
|
||||
permission_overwrites: new Map(
|
||||
Array.from(overwrites.entries()).map(([id, ow]) => [id, ow.toPermissionOverwrite()]),
|
||||
),
|
||||
});
|
||||
await this.dispatchChannelUpdateBulk({guildId, channels: [updated], requestCache: params.requestCache});
|
||||
|
||||
const changeSet = this.buildOverwriteChanges(previousOverwrite, new ChannelPermissionOverwrite(params.overwrite));
|
||||
if (changeSet.length > 0) {
|
||||
await this.recordAuditLog({
|
||||
guildId,
|
||||
userId: params.userId,
|
||||
action: previousOverwrite
|
||||
? AuditLogActionType.CHANNEL_OVERWRITE_UPDATE
|
||||
: AuditLogActionType.CHANNEL_OVERWRITE_CREATE,
|
||||
targetId,
|
||||
auditLogReason: null,
|
||||
metadata: {
|
||||
type: params.overwrite.type.toString(),
|
||||
channel_id: channel.id.toString(),
|
||||
},
|
||||
changes: changeSet,
|
||||
});
|
||||
|
||||
getMetricsService().counter({
|
||||
name: 'fluxer.channels.permissions_updated',
|
||||
dimensions: {
|
||||
guild_id: guildId.toString(),
|
||||
channel_id: channel.id.toString(),
|
||||
action: previousOverwrite ? 'update' : 'create',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async deleteChannelPermissionOverwrite(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
overwriteId: bigint;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const channel = await this.channelRepository.findUnique(params.channelId);
|
||||
if (!channel || !channel.guildId) {
|
||||
throw InputValidationError.fromCode('channel_id', ValidationErrorCodes.INVALID_CHANNEL);
|
||||
}
|
||||
const guildId = channel.guildId;
|
||||
const hasManageRoles = await this.gatewayService.checkPermission({
|
||||
guildId,
|
||||
userId: params.userId,
|
||||
permission: Permissions.MANAGE_ROLES,
|
||||
});
|
||||
if (!hasManageRoles) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
const overwrites = new Map(channel.permissionOverwrites ?? []);
|
||||
const roleKey = createRoleID(params.overwriteId);
|
||||
const userKey = createUserID(params.overwriteId);
|
||||
let targetId: (RoleID | UserID) | null = null;
|
||||
let targetType: number | null = null;
|
||||
|
||||
if (overwrites.has(roleKey)) {
|
||||
targetId = roleKey;
|
||||
targetType = 0;
|
||||
} else if (overwrites.has(userKey)) {
|
||||
targetId = userKey;
|
||||
targetType = 1;
|
||||
}
|
||||
|
||||
const previousOverwrite = targetId ? overwrites.get(targetId) : undefined;
|
||||
|
||||
overwrites.delete(roleKey);
|
||||
overwrites.delete(userKey);
|
||||
|
||||
const updated = await this.channelRepository.upsert({
|
||||
...channel.toRow(),
|
||||
permission_overwrites: new Map(
|
||||
Array.from(overwrites.entries()).map(([id, ow]) => [id, ow.toPermissionOverwrite()]),
|
||||
),
|
||||
});
|
||||
await this.dispatchChannelUpdateBulk({guildId, channels: [updated], requestCache: params.requestCache});
|
||||
|
||||
if (targetId && previousOverwrite) {
|
||||
const changeSet = this.buildOverwriteChanges(previousOverwrite, undefined);
|
||||
if (changeSet.length > 0) {
|
||||
await this.recordAuditLog({
|
||||
guildId,
|
||||
userId: params.userId,
|
||||
action: AuditLogActionType.CHANNEL_OVERWRITE_DELETE,
|
||||
targetId,
|
||||
auditLogReason: null,
|
||||
metadata: {
|
||||
type: targetType !== null ? targetType.toString() : '0',
|
||||
channel_id: channel.id.toString(),
|
||||
},
|
||||
changes: changeSet,
|
||||
});
|
||||
|
||||
getMetricsService().counter({
|
||||
name: 'fluxer.channels.permissions_updated',
|
||||
dimensions: {
|
||||
guild_id: guildId.toString(),
|
||||
channel_id: channel.id.toString(),
|
||||
action: 'delete',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async updateChannelPositionsByList(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
updates: Array<{
|
||||
channelId: ChannelID;
|
||||
position?: number;
|
||||
parentId: ChannelID | null | undefined;
|
||||
lockPermissions: boolean;
|
||||
}>;
|
||||
requestCache: RequestCache;
|
||||
auditLogReason: string | null;
|
||||
}): Promise<void> {
|
||||
const {guildId, userId, updates, requestCache} = params;
|
||||
const lockKey = `guild:${guildId}:channel-positions`;
|
||||
const lockToken = await this.cacheService.acquireLock(lockKey, 30);
|
||||
if (!lockToken) {
|
||||
throw new ResourceLockedError();
|
||||
}
|
||||
|
||||
try {
|
||||
const allChannels = await this.channelRepository.listGuildChannels(guildId);
|
||||
const channelMap = new Map(allChannels.map((ch) => [ch.id, ch]));
|
||||
|
||||
for (const update of updates) {
|
||||
if (!channelMap.has(update.channelId)) {
|
||||
throw InputValidationError.fromCode('id', ValidationErrorCodes.CHANNEL_NOT_FOUND);
|
||||
}
|
||||
if (update.parentId && !channelMap.has(update.parentId)) {
|
||||
throw InputValidationError.fromCode('parent_id', ValidationErrorCodes.INVALID_PARENT_CHANNEL);
|
||||
}
|
||||
}
|
||||
|
||||
const viewable = new Set(await this.gatewayService.getViewableChannels({guildId, userId}));
|
||||
|
||||
for (const update of updates) {
|
||||
if (!viewable.has(update.channelId)) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
if (update.parentId && !viewable.has(update.parentId)) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
|
||||
for (const update of updates) {
|
||||
await this.applySinglePositionUpdate({
|
||||
guildId,
|
||||
userId,
|
||||
update,
|
||||
requestCache,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await this.cacheService.releaseLock(lockKey, lockToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async applySinglePositionUpdate(params: {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
update: {channelId: ChannelID; position?: number; parentId: ChannelID | null | undefined; lockPermissions: boolean};
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const {guildId, update, requestCache} = params;
|
||||
const allChannels = await this.channelRepository.listGuildChannels(guildId);
|
||||
const channelMap = new Map(allChannels.map((ch) => [ch.id, ch]));
|
||||
const target = channelMap.get(update.channelId);
|
||||
if (!target) {
|
||||
throw InputValidationError.fromCode('id', ValidationErrorCodes.CHANNEL_NOT_FOUND);
|
||||
}
|
||||
|
||||
const desiredParent = update.parentId === undefined ? (target.parentId ?? null) : update.parentId;
|
||||
if (desiredParent && !channelMap.has(desiredParent)) {
|
||||
throw InputValidationError.fromCode('parent_id', ValidationErrorCodes.INVALID_PARENT_CHANNEL);
|
||||
}
|
||||
if (desiredParent) {
|
||||
const parentChannel = channelMap.get(desiredParent)!;
|
||||
if (parentChannel.type !== ChannelTypes.GUILD_CATEGORY) {
|
||||
throw InputValidationError.fromCode('parent_id', ValidationErrorCodes.PARENT_MUST_BE_CATEGORY);
|
||||
}
|
||||
}
|
||||
if (target.type === ChannelTypes.GUILD_CATEGORY && desiredParent) {
|
||||
throw InputValidationError.fromCode('parent_id', ValidationErrorCodes.CATEGORIES_CANNOT_HAVE_PARENTS);
|
||||
}
|
||||
|
||||
const orderedChannels = sortChannelsForOrdering(allChannels);
|
||||
const siblings = orderedChannels.filter((ch) => (ch.parentId ?? null) === desiredParent);
|
||||
const blockIds = computeChannelMoveBlockIds({channels: orderedChannels, targetId: target.id});
|
||||
const siblingsWithoutBlock = siblings.filter((ch) => !blockIds.has(ch.id));
|
||||
|
||||
let insertIndex = siblingsWithoutBlock.length;
|
||||
if (update.position !== undefined) {
|
||||
const adjustedPosition = Math.max(update.position, 0);
|
||||
insertIndex = Math.min(adjustedPosition, siblingsWithoutBlock.length);
|
||||
} else {
|
||||
const isVoice = target.type === ChannelTypes.GUILD_VOICE;
|
||||
if (isVoice) {
|
||||
insertIndex = siblingsWithoutBlock.length;
|
||||
} else {
|
||||
const firstVoice = siblingsWithoutBlock.findIndex((ch) => ch.type === ChannelTypes.GUILD_VOICE);
|
||||
insertIndex = firstVoice === -1 ? siblingsWithoutBlock.length : firstVoice;
|
||||
}
|
||||
}
|
||||
|
||||
const precedingSibling = insertIndex === 0 ? null : siblingsWithoutBlock[insertIndex - 1].id;
|
||||
|
||||
await this.executeChannelReorder({
|
||||
guildId,
|
||||
operation: {
|
||||
channelId: target.id,
|
||||
parentId: desiredParent,
|
||||
precedingSiblingId: precedingSibling,
|
||||
},
|
||||
requestCache,
|
||||
});
|
||||
|
||||
if (update.lockPermissions && desiredParent && desiredParent !== (target.parentId ?? null)) {
|
||||
await this.syncPermissionsWithParent({guildId, channelId: target.id, parentId: desiredParent});
|
||||
}
|
||||
}
|
||||
|
||||
private async syncPermissionsWithParent(params: {
|
||||
guildId: GuildID;
|
||||
channelId: ChannelID;
|
||||
parentId: ChannelID;
|
||||
}): Promise<void> {
|
||||
const parent = await this.channelRepository.findUnique(params.parentId);
|
||||
if (!parent || !parent.permissionOverwrites) return;
|
||||
const child = await this.channelRepository.findUnique(params.channelId);
|
||||
if (!child) return;
|
||||
|
||||
await this.channelRepository.upsert({
|
||||
...child.toRow(),
|
||||
permission_overwrites: new Map(
|
||||
Array.from(parent.permissionOverwrites.entries()).map(([targetId, overwrite]) => [
|
||||
targetId,
|
||||
overwrite.toPermissionOverwrite(),
|
||||
]),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
private async executeChannelReorder(params: {
|
||||
guildId: GuildID;
|
||||
operation: ChannelReorderOperation;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const {guildId, operation, requestCache} = params;
|
||||
const allChannels = await this.channelRepository.listGuildChannels(guildId);
|
||||
|
||||
const planResult = computeGuildChannelReorderPlan({channels: allChannels, operation});
|
||||
if (!planResult.ok) {
|
||||
this.throwReorderPlanError(planResult.code, operation);
|
||||
}
|
||||
|
||||
const {plan} = planResult;
|
||||
const desiredParentId = plan.desiredParentById.get(operation.channelId) ?? null;
|
||||
const targetChannel = allChannels.find((ch) => ch.id === operation.channelId);
|
||||
const currentParentId = targetChannel?.parentId ?? null;
|
||||
if (desiredParentId && desiredParentId !== currentParentId) {
|
||||
await this.ensureCategoryHasCapacity({guildId, categoryId: desiredParentId});
|
||||
}
|
||||
|
||||
ChannelHelpers.validateChannelVoicePlacement(plan.finalChannels, plan.desiredParentById);
|
||||
|
||||
if (plan.orderUnchanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatePromises: Array<Promise<void>> = [];
|
||||
for (let index = 0; index < plan.finalChannels.length; index++) {
|
||||
const channel = plan.finalChannels[index];
|
||||
const desiredPosition = index + 1;
|
||||
const desiredParent = plan.desiredParentById.get(channel.id) ?? null;
|
||||
const currentParent = channel.parentId ?? null;
|
||||
|
||||
if (channel.position !== desiredPosition || currentParent !== desiredParent) {
|
||||
updatePromises.push(
|
||||
this.channelRepository
|
||||
.upsert({...channel.toRow(), position: desiredPosition, parent_id: desiredParent})
|
||||
.then(() => {}),
|
||||
);
|
||||
}
|
||||
}
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
const updatedChannels = await this.channelRepository.listGuildChannels(guildId);
|
||||
await this.dispatchChannelUpdateBulk({guildId, channels: updatedChannels, requestCache});
|
||||
}
|
||||
|
||||
private throwReorderPlanError(code: GuildChannelReorderErrorCode, operation: ChannelReorderOperation): never {
|
||||
switch (code) {
|
||||
case 'TARGET_CHANNEL_NOT_FOUND':
|
||||
throw InputValidationError.fromCode('channel_id', ValidationErrorCodes.INVALID_CHANNEL_ID, {
|
||||
channelId: operation.channelId.toString(),
|
||||
});
|
||||
case 'CATEGORIES_CANNOT_HAVE_PARENTS':
|
||||
throw InputValidationError.fromCode('parent_id', ValidationErrorCodes.CATEGORIES_CANNOT_HAVE_PARENT_CHANNEL);
|
||||
case 'PARENT_NOT_FOUND':
|
||||
case 'PARENT_NOT_CATEGORY':
|
||||
throw InputValidationError.fromCode('parent_id', ValidationErrorCodes.INVALID_PARENT_CHANNEL);
|
||||
case 'PRECEDING_CHANNEL_NOT_FOUND':
|
||||
throw InputValidationError.fromCode('preceding_sibling_id', ValidationErrorCodes.INVALID_CHANNEL_ID, {
|
||||
channelId: String(operation.precedingSiblingId),
|
||||
});
|
||||
case 'CANNOT_POSITION_RELATIVE_TO_SELF_BLOCK':
|
||||
throw InputValidationError.fromCode(
|
||||
'preceding_sibling_id',
|
||||
ValidationErrorCodes.CANNOT_POSITION_CHANNEL_RELATIVE_TO_ITSELF,
|
||||
);
|
||||
case 'PRECEDING_PARENT_MISMATCH':
|
||||
throw InputValidationError.fromCode(
|
||||
'preceding_sibling_id',
|
||||
ValidationErrorCodes.PRECEDING_CHANNEL_MUST_SHARE_PARENT,
|
||||
);
|
||||
case 'PRECEDING_NOT_IN_GUILD_LIST':
|
||||
throw InputValidationError.fromCode(
|
||||
'preceding_sibling_id',
|
||||
ValidationErrorCodes.PRECEDING_CHANNEL_NOT_IN_GUILD,
|
||||
);
|
||||
case 'PARENT_NOT_IN_GUILD_LIST':
|
||||
throw InputValidationError.fromCode('parent_id', ValidationErrorCodes.PARENT_CHANNEL_NOT_IN_GUILD);
|
||||
}
|
||||
}
|
||||
|
||||
private async recordAuditLog(params: {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
action: AuditLogActionType;
|
||||
targetId?: GuildID | ChannelID | RoleID | UserID | EmojiID | StickerID | string | null;
|
||||
auditLogReason?: string | null;
|
||||
metadata?: Map<string, string> | Record<string, string>;
|
||||
changes?: GuildAuditLogChange | null;
|
||||
createdAt?: Date;
|
||||
}): Promise<void> {
|
||||
const targetId =
|
||||
params.targetId === undefined || params.targetId === null
|
||||
? null
|
||||
: typeof params.targetId === 'string'
|
||||
? params.targetId
|
||||
: params.targetId.toString();
|
||||
|
||||
try {
|
||||
const builder = this.guildAuditLogService
|
||||
.createBuilder(params.guildId, params.userId)
|
||||
.withAction(params.action, targetId)
|
||||
.withReason(params.auditLogReason ?? null);
|
||||
|
||||
if (params.metadata) {
|
||||
builder.withMetadata(params.metadata);
|
||||
}
|
||||
if (params.changes) {
|
||||
builder.withChanges(params.changes);
|
||||
}
|
||||
if (params.createdAt) {
|
||||
builder.withCreatedAt(params.createdAt);
|
||||
}
|
||||
|
||||
await builder.commit();
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{
|
||||
error,
|
||||
guildId: params.guildId.toString(),
|
||||
userId: params.userId.toString(),
|
||||
action: params.action,
|
||||
targetId,
|
||||
},
|
||||
'Failed to record guild audit log',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private buildOverwriteChanges(
|
||||
previous: ChannelPermissionOverwrite | undefined,
|
||||
current: ChannelPermissionOverwrite | undefined,
|
||||
): GuildAuditLogChange {
|
||||
const changes: GuildAuditLogChange = [];
|
||||
|
||||
const pushChange = (key: string, before?: bigint, after?: bigint) => {
|
||||
if (before === after) {
|
||||
return;
|
||||
}
|
||||
|
||||
const change: AuditLogChange = {key};
|
||||
if (before !== undefined) {
|
||||
change.old_value = before;
|
||||
}
|
||||
if (after !== undefined) {
|
||||
change.new_value = after;
|
||||
}
|
||||
changes.push(change);
|
||||
};
|
||||
|
||||
pushChange('allow', previous?.allow, current?.allow);
|
||||
pushChange('deny', previous?.deny, current?.deny);
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
private async dispatchChannelCreate({
|
||||
guildId,
|
||||
channel,
|
||||
requestCache,
|
||||
}: {
|
||||
guildId: GuildID;
|
||||
channel: Channel;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
await this.gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'CHANNEL_CREATE',
|
||||
data: await mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: null,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatchChannelUpdateBulk({
|
||||
guildId,
|
||||
channels,
|
||||
requestCache,
|
||||
}: {
|
||||
guildId: GuildID;
|
||||
channels: Array<Channel>;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const channelResponses = await Promise.all(
|
||||
channels.map((channel) =>
|
||||
mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: null,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await this.gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'CHANNEL_UPDATE_BULK',
|
||||
data: {channels: channelResponses},
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureCategoryHasCapacity(params: {guildId: GuildID; categoryId: ChannelID}): Promise<void> {
|
||||
const count = await this.gatewayService.getCategoryChannelCount(params);
|
||||
|
||||
let maxChannels = MAX_CHANNELS_PER_CATEGORY;
|
||||
const guild = await this.guildRepository.findUnique(params.guildId);
|
||||
const ctx = createLimitMatchContext({user: null, guildFeatures: guild?.features ?? null});
|
||||
maxChannels = resolveLimitSafe(
|
||||
this.limitConfigService.getConfigSnapshot(),
|
||||
ctx,
|
||||
'max_channels_per_category',
|
||||
maxChannels,
|
||||
);
|
||||
|
||||
if (count >= maxChannels) {
|
||||
throw new MaxCategoryChannelsError(maxChannels);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureGuildHasCapacity(guildId: GuildID): Promise<void> {
|
||||
const count = await this.gatewayService.getChannelCount({guildId});
|
||||
|
||||
let maxChannels = MAX_GUILD_CHANNELS;
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
const ctx = createLimitMatchContext({user: null, guildFeatures: guild?.features ?? null});
|
||||
maxChannels = resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, 'max_guild_channels', maxChannels);
|
||||
|
||||
if (count >= maxChannels) {
|
||||
throw new MaxGuildChannelsError(maxChannels);
|
||||
}
|
||||
}
|
||||
}
|
||||
125
packages/api/src/guild/services/content/ContentHelpers.tsx
Normal file
125
packages/api/src/guild/services/content/ContentHelpers.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {EmojiID, GuildID, StickerID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {GuildAuditLogService} from '@fluxer/api/src/guild/GuildAuditLogService';
|
||||
import type {GuildAuditLogChange} from '@fluxer/api/src/guild/GuildAuditLogTypes';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import type {GuildEmoji} from '@fluxer/api/src/models/GuildEmoji';
|
||||
import type {GuildSticker} from '@fluxer/api/src/models/GuildSticker';
|
||||
import {serializeEmojiForAudit, serializeStickerForAudit} from '@fluxer/api/src/utils/AuditSerializationUtils';
|
||||
import {hasPermission, requirePermission} from '@fluxer/api/src/utils/PermissionUtils';
|
||||
import type {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
|
||||
export class ContentHelpers {
|
||||
constructor(
|
||||
private readonly gatewayService: IGatewayService,
|
||||
public readonly guildAuditLogService: GuildAuditLogService,
|
||||
) {}
|
||||
|
||||
async getGuildData(params: {userId: UserID; guildId: GuildID}) {
|
||||
const {userId, guildId} = params;
|
||||
const guildData = await this.gatewayService.getGuildData({guildId, userId});
|
||||
return guildData;
|
||||
}
|
||||
|
||||
async checkPermission(params: {userId: UserID; guildId: GuildID; permission: bigint}) {
|
||||
const {userId, guildId, permission} = params;
|
||||
await requirePermission(this.gatewayService, {guildId, userId, permission});
|
||||
}
|
||||
|
||||
async checkManageExpressionsPermission(params: {userId: UserID; guildId: GuildID}) {
|
||||
return this.checkPermission({...params, permission: Permissions.MANAGE_EXPRESSIONS});
|
||||
}
|
||||
|
||||
async checkCreateExpressionsPermission(params: {userId: UserID; guildId: GuildID}) {
|
||||
return this.checkPermission({...params, permission: Permissions.CREATE_EXPRESSIONS});
|
||||
}
|
||||
|
||||
async checkModifyExpressionPermission(params: {userId: UserID; guildId: GuildID; creatorId: UserID}) {
|
||||
const {userId, guildId, creatorId} = params;
|
||||
if (userId === creatorId) {
|
||||
return this.checkCreateExpressionsPermission({userId, guildId});
|
||||
}
|
||||
return this.checkManageExpressionsPermission({userId, guildId});
|
||||
}
|
||||
|
||||
async hasManageExpressionsPermission(params: {userId: UserID; guildId: GuildID}): Promise<boolean> {
|
||||
const {userId, guildId} = params;
|
||||
return hasPermission(this.gatewayService, {guildId, userId, permission: Permissions.MANAGE_EXPRESSIONS});
|
||||
}
|
||||
|
||||
serializeEmojiForAudit(emoji: GuildEmoji): Record<string, unknown> {
|
||||
return serializeEmojiForAudit(emoji);
|
||||
}
|
||||
|
||||
serializeStickerForAudit(sticker: GuildSticker): Record<string, unknown> {
|
||||
return serializeStickerForAudit(sticker);
|
||||
}
|
||||
|
||||
async recordAuditLog(params: {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
action: AuditLogActionType;
|
||||
targetId?: GuildID | EmojiID | StickerID | string | null;
|
||||
auditLogReason?: string | null;
|
||||
metadata?: Map<string, string> | Record<string, string>;
|
||||
changes?: GuildAuditLogChange | null;
|
||||
createdAt?: Date;
|
||||
}): Promise<void> {
|
||||
const targetId =
|
||||
params.targetId === undefined || params.targetId === null
|
||||
? null
|
||||
: typeof params.targetId === 'string'
|
||||
? params.targetId
|
||||
: params.targetId.toString();
|
||||
|
||||
try {
|
||||
const builder = this.guildAuditLogService
|
||||
.createBuilder(params.guildId, params.userId)
|
||||
.withAction(params.action, targetId)
|
||||
.withReason(params.auditLogReason ?? null);
|
||||
|
||||
if (params.metadata) {
|
||||
builder.withMetadata(params.metadata);
|
||||
}
|
||||
if (params.changes) {
|
||||
builder.withChanges(params.changes);
|
||||
}
|
||||
if (params.createdAt) {
|
||||
builder.withCreatedAt(params.createdAt);
|
||||
}
|
||||
|
||||
await builder.commit();
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{
|
||||
error,
|
||||
guildId: params.guildId.toString(),
|
||||
userId: params.userId.toString(),
|
||||
action: params.action,
|
||||
targetId,
|
||||
},
|
||||
'Failed to record guild audit log',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
376
packages/api/src/guild/services/content/EmojiService.tsx
Normal file
376
packages/api/src/guild/services/content/EmojiService.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createEmojiID, type EmojiID, type GuildID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {mapGuildEmojisWithUsersToResponse, mapGuildEmojiToResponse} from '@fluxer/api/src/guild/GuildModel';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {ContentHelpers} from '@fluxer/api/src/guild/services/content/ContentHelpers';
|
||||
import type {ExpressionAssetPurger} from '@fluxer/api/src/guild/services/content/ExpressionAssetPurger';
|
||||
import type {AvatarService} from '@fluxer/api/src/infrastructure/AvatarService';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {GuildEmoji} from '@fluxer/api/src/models/GuildEmoji';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import {getCachedUserPartialResponse} from '@fluxer/api/src/user/UserCacheHelpers';
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
|
||||
import {
|
||||
MAX_GUILD_EMOJIS_ANIMATED,
|
||||
MAX_GUILD_EMOJIS_ANIMATED_MORE_EMOJI,
|
||||
MAX_GUILD_EMOJIS_STATIC,
|
||||
MAX_GUILD_EMOJIS_STATIC_MORE_EMOJI,
|
||||
MAX_GUILD_EXPRESSION_SLOTS_UNLIMITED,
|
||||
} from '@fluxer/constants/src/LimitConstants';
|
||||
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
|
||||
import {MaxGuildEmojisAnimatedError} from '@fluxer/errors/src/domains/guild/MaxGuildEmojisAnimatedError';
|
||||
import {MaxGuildEmojisStaticError} from '@fluxer/errors/src/domains/guild/MaxGuildEmojisStaticError';
|
||||
import {UnknownGuildEmojiError} from '@fluxer/errors/src/domains/guild/UnknownGuildEmojiError';
|
||||
import {resolveLimit} from '@fluxer/limits/src/LimitResolver';
|
||||
import type {GuildEmojiResponse, GuildEmojiWithUserResponse} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
|
||||
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
|
||||
export class EmojiService {
|
||||
constructor(
|
||||
private readonly guildRepository: IGuildRepositoryAggregate,
|
||||
private readonly userCacheService: UserCacheService,
|
||||
private readonly gatewayService: IGatewayService,
|
||||
private readonly avatarService: AvatarService,
|
||||
private readonly snowflakeService: SnowflakeService,
|
||||
private readonly contentHelpers: ContentHelpers,
|
||||
private readonly assetPurger: ExpressionAssetPurger,
|
||||
private readonly limitConfigService: LimitConfigService,
|
||||
) {}
|
||||
|
||||
private resolveGuildLimit(key: LimitKey, fallback: number, guildFeatures: Iterable<string> | null): number {
|
||||
const ctx = createLimitMatchContext({user: null, guildFeatures});
|
||||
const resolved = resolveLimit(this.limitConfigService.getConfigSnapshot(), ctx, key, {
|
||||
evaluationContext: 'guild',
|
||||
});
|
||||
if (!Number.isFinite(resolved) || resolved < 0) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.floor(resolved);
|
||||
}
|
||||
|
||||
async getEmojis(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Array<GuildEmojiWithUserResponse>> {
|
||||
const {userId, guildId, requestCache} = params;
|
||||
await this.contentHelpers.getGuildData({userId, guildId});
|
||||
|
||||
const emojis = await this.guildRepository.listEmojis(guildId);
|
||||
return await mapGuildEmojisWithUsersToResponse(emojis, this.userCacheService, requestCache);
|
||||
}
|
||||
|
||||
async getEmojiUser(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
emojiId: EmojiID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<UserPartialResponse> {
|
||||
const {userId, guildId, emojiId, requestCache} = params;
|
||||
await this.contentHelpers.getGuildData({userId, guildId});
|
||||
|
||||
const emoji = await this.guildRepository.getEmoji(emojiId, guildId);
|
||||
if (!emoji) throw new UnknownGuildEmojiError();
|
||||
|
||||
const userPartial = await getCachedUserPartialResponse({
|
||||
userId: emoji.creatorId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
return userPartial;
|
||||
}
|
||||
|
||||
async createEmoji(
|
||||
params: {user: User; guildId: GuildID; name: string; image: string},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<GuildEmojiResponse> {
|
||||
const {user, guildId, name, image} = params;
|
||||
const guildData = await this.contentHelpers.getGuildData({userId: user.id, guildId});
|
||||
await this.contentHelpers.checkCreateExpressionsPermission({userId: user.id, guildId});
|
||||
|
||||
const allEmojis = await this.guildRepository.listEmojis(guildId);
|
||||
const staticCount = allEmojis.filter((e) => !e.isAnimated).length;
|
||||
const animatedCount = allEmojis.filter((e) => e.isAnimated).length;
|
||||
|
||||
const {animated, imageBuffer, contentType} = await this.avatarService.processEmoji({
|
||||
errorPath: 'image',
|
||||
base64Image: image,
|
||||
});
|
||||
|
||||
const guildFeatures = guildData.features;
|
||||
const hasMoreEmoji = guildFeatures.includes(GuildFeatures.MORE_EMOJI);
|
||||
const hasUnlimitedEmoji = guildFeatures.includes(GuildFeatures.UNLIMITED_EMOJI);
|
||||
const useElevatedEmojiLimits = hasMoreEmoji || hasUnlimitedEmoji;
|
||||
const staticKey: LimitKey = useElevatedEmojiLimits ? 'max_guild_emojis_static_more' : 'max_guild_emojis_static';
|
||||
const animatedKey: LimitKey = useElevatedEmojiLimits
|
||||
? 'max_guild_emojis_animated_more'
|
||||
: 'max_guild_emojis_animated';
|
||||
const fallbackStatic = hasUnlimitedEmoji
|
||||
? MAX_GUILD_EXPRESSION_SLOTS_UNLIMITED
|
||||
: useElevatedEmojiLimits
|
||||
? MAX_GUILD_EMOJIS_STATIC_MORE_EMOJI
|
||||
: MAX_GUILD_EMOJIS_STATIC;
|
||||
const fallbackAnimated = hasUnlimitedEmoji
|
||||
? MAX_GUILD_EXPRESSION_SLOTS_UNLIMITED
|
||||
: useElevatedEmojiLimits
|
||||
? MAX_GUILD_EMOJIS_ANIMATED_MORE_EMOJI
|
||||
: MAX_GUILD_EMOJIS_ANIMATED;
|
||||
const maxStatic = this.resolveGuildLimit(staticKey, fallbackStatic, guildFeatures);
|
||||
const maxAnimated = this.resolveGuildLimit(animatedKey, fallbackAnimated, guildFeatures);
|
||||
|
||||
if (!animated && staticCount >= maxStatic) {
|
||||
throw new MaxGuildEmojisStaticError(maxStatic);
|
||||
}
|
||||
if (animated && animatedCount >= maxAnimated) {
|
||||
throw new MaxGuildEmojisAnimatedError(maxAnimated);
|
||||
}
|
||||
|
||||
const emojiId = createEmojiID(await this.snowflakeService.generate());
|
||||
await this.avatarService.uploadEmoji({
|
||||
prefix: 'emojis',
|
||||
emojiId,
|
||||
imageBuffer,
|
||||
contentType,
|
||||
});
|
||||
|
||||
const emoji = await this.guildRepository.upsertEmoji({
|
||||
guild_id: guildId,
|
||||
emoji_id: emojiId,
|
||||
name,
|
||||
creator_id: user.id,
|
||||
animated,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
const updatedEmojis = [...allEmojis, emoji];
|
||||
await this.dispatchGuildEmojisUpdate({guildId, emojis: updatedEmojis});
|
||||
|
||||
await this.contentHelpers.recordAuditLog({
|
||||
guildId,
|
||||
userId: user.id,
|
||||
action: AuditLogActionType.EMOJI_CREATE,
|
||||
targetId: emoji.id,
|
||||
auditLogReason: auditLogReason ?? null,
|
||||
changes: this.contentHelpers.guildAuditLogService.computeChanges(
|
||||
null,
|
||||
this.contentHelpers.serializeEmojiForAudit(emoji),
|
||||
),
|
||||
});
|
||||
return mapGuildEmojiToResponse(emoji);
|
||||
}
|
||||
|
||||
async bulkCreateEmojis(
|
||||
params: {user: User; guildId: GuildID; emojis: Array<{name: string; image: string}>},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<{
|
||||
success: Array<GuildEmojiResponse>;
|
||||
failed: Array<{name: string; error: string}>;
|
||||
}> {
|
||||
const {user, guildId, emojis} = params;
|
||||
const guildData = await this.contentHelpers.getGuildData({userId: user.id, guildId});
|
||||
await this.contentHelpers.checkCreateExpressionsPermission({userId: user.id, guildId});
|
||||
|
||||
const allEmojis = await this.guildRepository.listEmojis(guildId);
|
||||
|
||||
const guildFeatures = guildData.features;
|
||||
const hasMoreEmoji = guildFeatures.includes(GuildFeatures.MORE_EMOJI);
|
||||
const hasUnlimitedEmoji = guildFeatures.includes(GuildFeatures.UNLIMITED_EMOJI);
|
||||
const useElevatedEmojiLimits = hasMoreEmoji || hasUnlimitedEmoji;
|
||||
const staticKey: LimitKey = useElevatedEmojiLimits ? 'max_guild_emojis_static_more' : 'max_guild_emojis_static';
|
||||
const animatedKey: LimitKey = useElevatedEmojiLimits
|
||||
? 'max_guild_emojis_animated_more'
|
||||
: 'max_guild_emojis_animated';
|
||||
const fallbackStatic = hasUnlimitedEmoji
|
||||
? MAX_GUILD_EXPRESSION_SLOTS_UNLIMITED
|
||||
: useElevatedEmojiLimits
|
||||
? MAX_GUILD_EMOJIS_STATIC_MORE_EMOJI
|
||||
: MAX_GUILD_EMOJIS_STATIC;
|
||||
const fallbackAnimated = hasUnlimitedEmoji
|
||||
? MAX_GUILD_EXPRESSION_SLOTS_UNLIMITED
|
||||
: useElevatedEmojiLimits
|
||||
? MAX_GUILD_EMOJIS_ANIMATED_MORE_EMOJI
|
||||
: MAX_GUILD_EMOJIS_ANIMATED;
|
||||
const maxStatic = this.resolveGuildLimit(staticKey, fallbackStatic, guildFeatures);
|
||||
const maxAnimated = this.resolveGuildLimit(animatedKey, fallbackAnimated, guildFeatures);
|
||||
|
||||
let staticCount = allEmojis.filter((e) => !e.isAnimated).length;
|
||||
let animatedCount = allEmojis.filter((e) => e.isAnimated).length;
|
||||
|
||||
const success: Array<GuildEmojiResponse> = [];
|
||||
const failed: Array<{name: string; error: string}> = [];
|
||||
const newEmojis: Array<GuildEmoji> = [];
|
||||
|
||||
for (const emojiData of emojis) {
|
||||
try {
|
||||
const {animated, imageBuffer, contentType} = await this.avatarService.processEmoji({
|
||||
errorPath: `emojis[${success.length + failed.length}].image`,
|
||||
base64Image: emojiData.image,
|
||||
});
|
||||
|
||||
if (!animated && staticCount >= maxStatic) {
|
||||
const limitLabel = `${Math.floor(maxStatic)}`;
|
||||
failed.push({name: emojiData.name, error: `Maximum static emojis reached (${limitLabel})`});
|
||||
continue;
|
||||
}
|
||||
if (animated && animatedCount >= maxAnimated) {
|
||||
const limitLabel = `${Math.floor(maxAnimated)}`;
|
||||
failed.push({name: emojiData.name, error: `Maximum animated emojis reached (${limitLabel})`});
|
||||
continue;
|
||||
}
|
||||
|
||||
const emojiId = createEmojiID(await this.snowflakeService.generate());
|
||||
await this.avatarService.uploadEmoji({
|
||||
prefix: 'emojis',
|
||||
emojiId,
|
||||
imageBuffer,
|
||||
contentType,
|
||||
});
|
||||
|
||||
const emoji = await this.guildRepository.upsertEmoji({
|
||||
guild_id: guildId,
|
||||
emoji_id: emojiId,
|
||||
name: emojiData.name,
|
||||
animated,
|
||||
creator_id: user.id,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
if (animated) {
|
||||
animatedCount++;
|
||||
} else {
|
||||
staticCount++;
|
||||
}
|
||||
|
||||
newEmojis.push(emoji);
|
||||
success.push(mapGuildEmojiToResponse(emoji));
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
failed.push({name: emojiData.name, error: errorMessage});
|
||||
}
|
||||
}
|
||||
|
||||
if (newEmojis.length > 0) {
|
||||
const updatedEmojis = [...allEmojis, ...newEmojis];
|
||||
await this.dispatchGuildEmojisUpdate({guildId, emojis: updatedEmojis});
|
||||
|
||||
await Promise.all(
|
||||
newEmojis.map((emoji) =>
|
||||
this.contentHelpers.recordAuditLog({
|
||||
guildId,
|
||||
userId: user.id,
|
||||
action: AuditLogActionType.EMOJI_CREATE,
|
||||
targetId: emoji.id,
|
||||
auditLogReason: auditLogReason ?? null,
|
||||
changes: this.contentHelpers.guildAuditLogService.computeChanges(
|
||||
null,
|
||||
this.contentHelpers.serializeEmojiForAudit(emoji),
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {success, failed};
|
||||
}
|
||||
|
||||
async updateEmoji(
|
||||
params: {userId: UserID; guildId: GuildID; emojiId: EmojiID; name: string},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<GuildEmojiResponse> {
|
||||
const {userId, guildId, emojiId, name} = params;
|
||||
|
||||
const allEmojis = await this.guildRepository.listEmojis(guildId);
|
||||
const emoji = allEmojis.find((e) => e.id === emojiId);
|
||||
if (!emoji) throw new UnknownGuildEmojiError();
|
||||
|
||||
await this.contentHelpers.checkModifyExpressionPermission({userId, guildId, creatorId: emoji.creatorId});
|
||||
const previousSnapshot = this.contentHelpers.serializeEmojiForAudit(emoji);
|
||||
|
||||
const updatedEmoji = await this.guildRepository.upsertEmoji({...emoji.toRow(), name});
|
||||
const updatedEmojis = allEmojis.map((e) => (e.id === emojiId ? updatedEmoji : e));
|
||||
await this.dispatchGuildEmojisUpdate({guildId, emojis: updatedEmojis});
|
||||
|
||||
await this.contentHelpers.recordAuditLog({
|
||||
guildId,
|
||||
userId,
|
||||
action: AuditLogActionType.EMOJI_UPDATE,
|
||||
targetId: emojiId,
|
||||
auditLogReason: auditLogReason ?? null,
|
||||
changes: this.contentHelpers.guildAuditLogService.computeChanges(
|
||||
previousSnapshot,
|
||||
this.contentHelpers.serializeEmojiForAudit(updatedEmoji),
|
||||
),
|
||||
});
|
||||
return mapGuildEmojiToResponse(updatedEmoji);
|
||||
}
|
||||
|
||||
async deleteEmoji(
|
||||
params: {userId: UserID; guildId: GuildID; emojiId: EmojiID; purge?: boolean},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
const {userId, guildId, emojiId, purge = false} = params;
|
||||
const guildData = await this.contentHelpers.getGuildData({userId, guildId});
|
||||
|
||||
if (purge && !guildData.features.includes(GuildFeatures.EXPRESSION_PURGE_ALLOWED)) {
|
||||
throw new MissingAccessError();
|
||||
}
|
||||
|
||||
const allEmojis = await this.guildRepository.listEmojis(guildId);
|
||||
const emoji = allEmojis.find((e) => e.id === emojiId);
|
||||
if (!emoji) throw new UnknownGuildEmojiError();
|
||||
|
||||
await this.contentHelpers.checkModifyExpressionPermission({userId, guildId, creatorId: emoji.creatorId});
|
||||
const previousSnapshot = this.contentHelpers.serializeEmojiForAudit(emoji);
|
||||
|
||||
await this.guildRepository.deleteEmoji(guildId, emojiId);
|
||||
const updatedEmojis = allEmojis.filter((e) => e.id !== emojiId);
|
||||
await this.dispatchGuildEmojisUpdate({guildId, emojis: updatedEmojis});
|
||||
|
||||
if (purge) {
|
||||
await this.assetPurger.purgeEmoji(emoji.id.toString());
|
||||
}
|
||||
|
||||
await this.contentHelpers.recordAuditLog({
|
||||
guildId,
|
||||
userId,
|
||||
action: AuditLogActionType.EMOJI_DELETE,
|
||||
targetId: emojiId,
|
||||
auditLogReason: auditLogReason ?? null,
|
||||
changes: this.contentHelpers.guildAuditLogService.computeChanges(previousSnapshot, null),
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatchGuildEmojisUpdate(params: {guildId: GuildID; emojis: Array<GuildEmoji>}): Promise<void> {
|
||||
const {guildId, emojis} = params;
|
||||
await this.gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_EMOJIS_UPDATE',
|
||||
data: {emojis: emojis.map(mapGuildEmojiToResponse)},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import type {IAssetDeletionQueue} from '@fluxer/api/src/infrastructure/IAssetDeletionQueue';
|
||||
|
||||
const STICKER_EXTENSIONS = ['png', 'apng', 'gif', 'webp', 'avif'];
|
||||
|
||||
export class ExpressionAssetPurger {
|
||||
constructor(private readonly assetDeletionQueue: IAssetDeletionQueue) {}
|
||||
|
||||
async purgeEmoji(id: string): Promise<void> {
|
||||
await this.queueAsset('emojis', id, this.buildEmojiCdnUrls(id));
|
||||
}
|
||||
|
||||
async purgeSticker(id: string): Promise<void> {
|
||||
await this.queueAsset('stickers', id, this.buildStickerCdnUrls(id));
|
||||
}
|
||||
|
||||
private async queueAsset(prefix: string, id: string, cdnUrls: Array<string>): Promise<void> {
|
||||
const uniqueUrls = Array.from(new Set(cdnUrls));
|
||||
const [primaryUrl, ...additionalUrls] = uniqueUrls;
|
||||
|
||||
await this.assetDeletionQueue.queueDeletion({
|
||||
s3Key: `${prefix}/${id}`,
|
||||
cdnUrl: primaryUrl ?? null,
|
||||
reason: 'asset_purge',
|
||||
});
|
||||
|
||||
await Promise.all(additionalUrls.map((url) => this.assetDeletionQueue.queueCdnPurge(url)));
|
||||
}
|
||||
|
||||
private buildEmojiCdnUrls(id: string): Array<string> {
|
||||
const base = Config.endpoints.media;
|
||||
return [`${base}/emojis/${id}.webp`, `${base}/emojis/${id}.gif`];
|
||||
}
|
||||
|
||||
private buildStickerCdnUrls(id: string): Array<string> {
|
||||
const base = Config.endpoints.media;
|
||||
return STICKER_EXTENSIONS.map((ext) => `${base}/stickers/${id}.${ext}`);
|
||||
}
|
||||
}
|
||||
360
packages/api/src/guild/services/content/StickerService.tsx
Normal file
360
packages/api/src/guild/services/content/StickerService.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createStickerID, type GuildID, type StickerID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {mapGuildStickersWithUsersToResponse, mapGuildStickerToResponse} from '@fluxer/api/src/guild/GuildModel';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {ContentHelpers} from '@fluxer/api/src/guild/services/content/ContentHelpers';
|
||||
import type {ExpressionAssetPurger} from '@fluxer/api/src/guild/services/content/ExpressionAssetPurger';
|
||||
import type {AvatarService} from '@fluxer/api/src/infrastructure/AvatarService';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {GuildSticker} from '@fluxer/api/src/models/GuildSticker';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import {getCachedUserPartialResponse} from '@fluxer/api/src/user/UserCacheHelpers';
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
|
||||
import {
|
||||
MAX_GUILD_EXPRESSION_SLOTS_UNLIMITED,
|
||||
MAX_GUILD_STICKERS,
|
||||
MAX_GUILD_STICKERS_MORE_STICKERS,
|
||||
} from '@fluxer/constants/src/LimitConstants';
|
||||
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
|
||||
import {MaxGuildStickersStaticError} from '@fluxer/errors/src/domains/guild/MaxGuildStickersStaticError';
|
||||
import {UnknownGuildStickerError} from '@fluxer/errors/src/domains/guild/UnknownGuildStickerError';
|
||||
import {resolveLimit} from '@fluxer/limits/src/LimitResolver';
|
||||
import type {
|
||||
GuildStickerResponse,
|
||||
GuildStickerWithUserResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
|
||||
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
|
||||
export class StickerService {
|
||||
constructor(
|
||||
private readonly guildRepository: IGuildRepositoryAggregate,
|
||||
private readonly userCacheService: UserCacheService,
|
||||
private readonly gatewayService: IGatewayService,
|
||||
private readonly avatarService: AvatarService,
|
||||
private readonly snowflakeService: SnowflakeService,
|
||||
private readonly contentHelpers: ContentHelpers,
|
||||
private readonly assetPurger: ExpressionAssetPurger,
|
||||
private readonly limitConfigService: LimitConfigService,
|
||||
) {}
|
||||
|
||||
private resolveGuildLimit(key: LimitKey, fallback: number, guildFeatures: Iterable<string> | null): number {
|
||||
const ctx = createLimitMatchContext({user: null, guildFeatures});
|
||||
const resolved = resolveLimit(this.limitConfigService.getConfigSnapshot(), ctx, key, {
|
||||
evaluationContext: 'guild',
|
||||
});
|
||||
if (!Number.isFinite(resolved) || resolved < 0) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.floor(resolved);
|
||||
}
|
||||
|
||||
async getStickers(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Array<GuildStickerWithUserResponse>> {
|
||||
const {userId, guildId, requestCache} = params;
|
||||
await this.contentHelpers.getGuildData({userId, guildId});
|
||||
|
||||
const stickers = await this.guildRepository.listStickers(guildId);
|
||||
return await mapGuildStickersWithUsersToResponse(stickers, this.userCacheService, requestCache);
|
||||
}
|
||||
|
||||
async getStickerUser(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
stickerId: StickerID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<UserPartialResponse> {
|
||||
const {userId, guildId, stickerId, requestCache} = params;
|
||||
await this.contentHelpers.getGuildData({userId, guildId});
|
||||
|
||||
const sticker = await this.guildRepository.getSticker(stickerId, guildId);
|
||||
if (!sticker) throw new UnknownGuildStickerError();
|
||||
|
||||
const userPartial = await getCachedUserPartialResponse({
|
||||
userId: sticker.creatorId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
return userPartial;
|
||||
}
|
||||
|
||||
async createSticker(
|
||||
params: {
|
||||
user: User;
|
||||
guildId: GuildID;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
tags: Array<string>;
|
||||
image: string;
|
||||
},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<GuildStickerResponse> {
|
||||
const {user, guildId, name, description, tags, image} = params;
|
||||
const guildData = await this.contentHelpers.getGuildData({userId: user.id, guildId});
|
||||
await this.contentHelpers.checkCreateExpressionsPermission({userId: user.id, guildId});
|
||||
|
||||
const allStickers = await this.guildRepository.listStickers(guildId);
|
||||
const stickerCount = allStickers.length;
|
||||
|
||||
const {animated, imageBuffer} = await this.avatarService.processSticker({errorPath: 'image', base64Image: image});
|
||||
|
||||
const guildFeatures = guildData.features;
|
||||
const hasMoreStickers = guildFeatures.includes(GuildFeatures.MORE_STICKERS);
|
||||
const hasUnlimitedStickers = guildFeatures.includes(GuildFeatures.UNLIMITED_STICKERS);
|
||||
const useElevatedStickerLimits = hasMoreStickers || hasUnlimitedStickers;
|
||||
const limitKey: LimitKey = useElevatedStickerLimits ? 'max_guild_stickers_more' : 'max_guild_stickers';
|
||||
const fallbackLimit = hasUnlimitedStickers
|
||||
? MAX_GUILD_EXPRESSION_SLOTS_UNLIMITED
|
||||
: useElevatedStickerLimits
|
||||
? MAX_GUILD_STICKERS_MORE_STICKERS
|
||||
: MAX_GUILD_STICKERS;
|
||||
const maxStickers = this.resolveGuildLimit(limitKey, fallbackLimit, guildFeatures);
|
||||
|
||||
if (stickerCount >= maxStickers) {
|
||||
throw new MaxGuildStickersStaticError(maxStickers);
|
||||
}
|
||||
|
||||
const stickerId = createStickerID(await this.snowflakeService.generate());
|
||||
await this.avatarService.uploadSticker({prefix: 'stickers', stickerId, imageBuffer});
|
||||
|
||||
const sticker = await this.guildRepository.upsertSticker({
|
||||
guild_id: guildId,
|
||||
sticker_id: stickerId,
|
||||
name,
|
||||
description: description ?? null,
|
||||
animated,
|
||||
tags,
|
||||
creator_id: user.id,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
const updatedStickers = [...allStickers, sticker];
|
||||
await this.dispatchGuildStickersUpdate({guildId, stickers: updatedStickers});
|
||||
|
||||
await this.contentHelpers.recordAuditLog({
|
||||
guildId,
|
||||
userId: user.id,
|
||||
action: AuditLogActionType.STICKER_CREATE,
|
||||
targetId: sticker.id,
|
||||
auditLogReason: auditLogReason ?? null,
|
||||
changes: this.contentHelpers.guildAuditLogService.computeChanges(
|
||||
null,
|
||||
this.contentHelpers.serializeStickerForAudit(sticker),
|
||||
),
|
||||
});
|
||||
|
||||
return mapGuildStickerToResponse(sticker);
|
||||
}
|
||||
|
||||
async bulkCreateStickers(
|
||||
params: {
|
||||
user: User;
|
||||
guildId: GuildID;
|
||||
stickers: Array<{name: string; description?: string | null; tags: Array<string>; image: string}>;
|
||||
},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<{
|
||||
success: Array<GuildStickerResponse>;
|
||||
failed: Array<{name: string; error: string}>;
|
||||
}> {
|
||||
const {user, guildId, stickers} = params;
|
||||
const guildData = await this.contentHelpers.getGuildData({userId: user.id, guildId});
|
||||
await this.contentHelpers.checkCreateExpressionsPermission({userId: user.id, guildId});
|
||||
|
||||
const allStickers = await this.guildRepository.listStickers(guildId);
|
||||
|
||||
const guildFeatures = guildData.features;
|
||||
const hasMoreStickers = guildFeatures.includes(GuildFeatures.MORE_STICKERS);
|
||||
const hasUnlimitedStickers = guildFeatures.includes(GuildFeatures.UNLIMITED_STICKERS);
|
||||
const useElevatedStickerLimits = hasMoreStickers || hasUnlimitedStickers;
|
||||
const limitKey: LimitKey = useElevatedStickerLimits ? 'max_guild_stickers_more' : 'max_guild_stickers';
|
||||
const fallbackLimit = hasUnlimitedStickers
|
||||
? MAX_GUILD_EXPRESSION_SLOTS_UNLIMITED
|
||||
: useElevatedStickerLimits
|
||||
? MAX_GUILD_STICKERS_MORE_STICKERS
|
||||
: MAX_GUILD_STICKERS;
|
||||
const maxStickers = this.resolveGuildLimit(limitKey, fallbackLimit, guildFeatures);
|
||||
|
||||
let stickerCount = allStickers.length;
|
||||
|
||||
const success: Array<GuildStickerResponse> = [];
|
||||
const failed: Array<{name: string; error: string}> = [];
|
||||
const newStickers: Array<GuildSticker> = [];
|
||||
|
||||
for (const stickerData of stickers) {
|
||||
try {
|
||||
const {animated, imageBuffer} = await this.avatarService.processSticker({
|
||||
errorPath: `stickers[${success.length + failed.length}].image`,
|
||||
base64Image: stickerData.image,
|
||||
});
|
||||
|
||||
if (stickerCount >= maxStickers) {
|
||||
const limitLabel = `${Math.floor(maxStickers)}`;
|
||||
failed.push({name: stickerData.name, error: `Maximum stickers reached (${limitLabel})`});
|
||||
continue;
|
||||
}
|
||||
|
||||
const stickerId = createStickerID(await this.snowflakeService.generate());
|
||||
await this.avatarService.uploadSticker({prefix: 'stickers', stickerId, imageBuffer});
|
||||
|
||||
const sticker = await this.guildRepository.upsertSticker({
|
||||
guild_id: guildId,
|
||||
sticker_id: stickerId,
|
||||
name: stickerData.name,
|
||||
description: stickerData.description ?? null,
|
||||
tags: stickerData.tags,
|
||||
animated,
|
||||
creator_id: user.id,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
stickerCount++;
|
||||
|
||||
newStickers.push(sticker);
|
||||
success.push(mapGuildStickerToResponse(sticker));
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
failed.push({name: stickerData.name, error: errorMessage});
|
||||
}
|
||||
}
|
||||
|
||||
if (newStickers.length > 0) {
|
||||
const updatedStickers = [...allStickers, ...newStickers];
|
||||
await this.dispatchGuildStickersUpdate({guildId, stickers: updatedStickers});
|
||||
|
||||
await Promise.all(
|
||||
newStickers.map((sticker) =>
|
||||
this.contentHelpers.recordAuditLog({
|
||||
guildId,
|
||||
userId: user.id,
|
||||
action: AuditLogActionType.STICKER_CREATE,
|
||||
targetId: sticker.id,
|
||||
auditLogReason: auditLogReason ?? null,
|
||||
changes: this.contentHelpers.guildAuditLogService.computeChanges(
|
||||
null,
|
||||
this.contentHelpers.serializeStickerForAudit(sticker),
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {success, failed};
|
||||
}
|
||||
|
||||
async updateSticker(
|
||||
params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
stickerId: StickerID;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
tags: Array<string>;
|
||||
},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<GuildStickerResponse> {
|
||||
const {userId, guildId, stickerId, name, description, tags} = params;
|
||||
|
||||
const allStickers = await this.guildRepository.listStickers(guildId);
|
||||
const sticker = allStickers.find((e) => e.id === stickerId);
|
||||
if (!sticker) throw new UnknownGuildStickerError();
|
||||
|
||||
await this.contentHelpers.checkModifyExpressionPermission({userId, guildId, creatorId: sticker.creatorId});
|
||||
const previousSnapshot = this.contentHelpers.serializeStickerForAudit(sticker);
|
||||
|
||||
const updatedSticker = await this.guildRepository.upsertSticker({
|
||||
...sticker.toRow(),
|
||||
name,
|
||||
description: description ?? null,
|
||||
tags,
|
||||
});
|
||||
const updatedStickers = allStickers.map((e) => (e.id === stickerId ? updatedSticker : e));
|
||||
await this.dispatchGuildStickersUpdate({guildId, stickers: updatedStickers});
|
||||
|
||||
await this.contentHelpers.recordAuditLog({
|
||||
guildId,
|
||||
userId,
|
||||
action: AuditLogActionType.STICKER_UPDATE,
|
||||
targetId: stickerId,
|
||||
auditLogReason: auditLogReason ?? null,
|
||||
changes: this.contentHelpers.guildAuditLogService.computeChanges(
|
||||
previousSnapshot,
|
||||
this.contentHelpers.serializeStickerForAudit(updatedSticker),
|
||||
),
|
||||
});
|
||||
|
||||
return mapGuildStickerToResponse(updatedSticker);
|
||||
}
|
||||
|
||||
async deleteSticker(
|
||||
params: {userId: UserID; guildId: GuildID; stickerId: StickerID; purge?: boolean},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
const {userId, guildId, stickerId, purge = false} = params;
|
||||
const guildData = await this.contentHelpers.getGuildData({userId, guildId});
|
||||
|
||||
if (purge && !guildData.features.includes(GuildFeatures.EXPRESSION_PURGE_ALLOWED)) {
|
||||
throw new MissingAccessError();
|
||||
}
|
||||
|
||||
const allStickers = await this.guildRepository.listStickers(guildId);
|
||||
const sticker = allStickers.find((e) => e.id === stickerId);
|
||||
if (!sticker) throw new UnknownGuildStickerError();
|
||||
|
||||
await this.contentHelpers.checkModifyExpressionPermission({userId, guildId, creatorId: sticker.creatorId});
|
||||
const previousSnapshot = this.contentHelpers.serializeStickerForAudit(sticker);
|
||||
|
||||
await this.guildRepository.deleteSticker(guildId, stickerId);
|
||||
const updatedStickers = allStickers.filter((e) => e.id !== stickerId);
|
||||
await this.dispatchGuildStickersUpdate({guildId, stickers: updatedStickers});
|
||||
|
||||
if (purge) {
|
||||
await this.assetPurger.purgeSticker(sticker.id.toString());
|
||||
}
|
||||
|
||||
await this.contentHelpers.recordAuditLog({
|
||||
guildId,
|
||||
userId,
|
||||
action: AuditLogActionType.STICKER_DELETE,
|
||||
targetId: stickerId,
|
||||
auditLogReason: auditLogReason ?? null,
|
||||
changes: this.contentHelpers.guildAuditLogService.computeChanges(previousSnapshot, null),
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatchGuildStickersUpdate(params: {guildId: GuildID; stickers: Array<GuildSticker>}): Promise<void> {
|
||||
const {guildId, stickers} = params;
|
||||
await this.gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_STICKERS_UPDATE',
|
||||
data: {stickers: stickers.map(mapGuildStickerToResponse)},
|
||||
});
|
||||
}
|
||||
}
|
||||
145
packages/api/src/guild/services/data/GuildDataHelpers.tsx
Normal file
145
packages/api/src/guild/services/data/GuildDataHelpers.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, EmojiID, GuildID, RoleID, StickerID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {GuildAuditLogService} from '@fluxer/api/src/guild/GuildAuditLogService';
|
||||
import type {GuildAuditLogChange} from '@fluxer/api/src/guild/GuildAuditLogTypes';
|
||||
import {mapGuildToGuildResponse} from '@fluxer/api/src/guild/GuildModel';
|
||||
import {GuildRepository} from '@fluxer/api/src/guild/repositories/GuildRepository';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import type {Guild} from '@fluxer/api/src/models/Guild';
|
||||
import {serializeGuildForAudit as serializeGuildForAuditUtil} from '@fluxer/api/src/utils/AuditSerializationUtils';
|
||||
import {requirePermission} from '@fluxer/api/src/utils/PermissionUtils';
|
||||
import type {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
|
||||
interface GuildAuth {
|
||||
guildData: GuildResponse;
|
||||
checkPermission: (permission: bigint) => Promise<void>;
|
||||
}
|
||||
|
||||
export class GuildDataHelpers {
|
||||
constructor(
|
||||
private readonly gatewayService: IGatewayService,
|
||||
private readonly guildAuditLogService: GuildAuditLogService,
|
||||
) {}
|
||||
private readonly guildRepository = new GuildRepository();
|
||||
|
||||
async getGuildAuthenticated(params: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
|
||||
const {userId, guildId} = params;
|
||||
try {
|
||||
const guildData = await this.gatewayService.getGuildData({guildId, userId});
|
||||
if (!guildData) throw new UnknownGuildError();
|
||||
return this.createGuildAuth({guildData, guildId, userId});
|
||||
} catch (error) {
|
||||
if (error instanceof UnknownGuildError && (await this.guildExists(guildId))) {
|
||||
throw new AccessDeniedError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private createGuildAuth(params: {guildData: GuildResponse; guildId: GuildID; userId: UserID}): GuildAuth {
|
||||
const {guildData, guildId, userId} = params;
|
||||
const checkPermission = async (permission: bigint) => {
|
||||
await requirePermission(this.gatewayService, {guildId, userId, permission});
|
||||
};
|
||||
return {guildData, checkPermission};
|
||||
}
|
||||
|
||||
private async guildExists(guildId: GuildID): Promise<boolean> {
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
return guild !== null;
|
||||
}
|
||||
|
||||
serializeGuildForAudit(guild: Guild): Record<string, unknown> {
|
||||
return serializeGuildForAuditUtil(guild);
|
||||
}
|
||||
|
||||
computeGuildChanges(
|
||||
previousSnapshot: Record<string, unknown> | null,
|
||||
guildOrSnapshot: Guild | Record<string, unknown> | null,
|
||||
): GuildAuditLogChange {
|
||||
const currentSnapshot = guildOrSnapshot
|
||||
? 'id' in guildOrSnapshot
|
||||
? this.serializeGuildForAudit(guildOrSnapshot as Guild)
|
||||
: guildOrSnapshot
|
||||
: null;
|
||||
return this.guildAuditLogService.computeChanges(previousSnapshot, currentSnapshot);
|
||||
}
|
||||
|
||||
async dispatchGuildUpdate(guild: Guild): Promise<void> {
|
||||
await this.gatewayService.dispatchGuild({
|
||||
guildId: guild.id,
|
||||
event: 'GUILD_UPDATE',
|
||||
data: mapGuildToGuildResponse(guild),
|
||||
});
|
||||
}
|
||||
|
||||
async recordAuditLog(params: {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
action: AuditLogActionType;
|
||||
targetId?: GuildID | ChannelID | RoleID | UserID | EmojiID | StickerID | string | null;
|
||||
auditLogReason?: string | null;
|
||||
metadata?: Map<string, string> | Record<string, string>;
|
||||
changes?: GuildAuditLogChange | null;
|
||||
createdAt?: Date;
|
||||
}): Promise<void> {
|
||||
const targetId =
|
||||
params.targetId === undefined || params.targetId === null
|
||||
? null
|
||||
: typeof params.targetId === 'string'
|
||||
? params.targetId
|
||||
: params.targetId.toString();
|
||||
|
||||
try {
|
||||
const builder = this.guildAuditLogService
|
||||
.createBuilder(params.guildId, params.userId)
|
||||
.withAction(params.action, targetId)
|
||||
.withReason(params.auditLogReason ?? null);
|
||||
|
||||
if (params.metadata) {
|
||||
builder.withMetadata(params.metadata);
|
||||
}
|
||||
if (params.changes) {
|
||||
builder.withChanges(params.changes);
|
||||
}
|
||||
if (params.createdAt) {
|
||||
builder.withCreatedAt(params.createdAt);
|
||||
}
|
||||
|
||||
await builder.commit();
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{
|
||||
error,
|
||||
guildId: params.guildId.toString(),
|
||||
userId: params.userId.toString(),
|
||||
action: params.action,
|
||||
targetId,
|
||||
},
|
||||
'Failed to record guild audit log',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
962
packages/api/src/guild/services/data/GuildOperationsService.tsx
Normal file
962
packages/api/src/guild/services/data/GuildOperationsService.tsx
Normal file
@@ -0,0 +1,962 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {createChannelID, createGuildID, guildIdToRoleId} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
|
||||
import type {ChannelService} from '@fluxer/api/src/channel/services/ChannelService';
|
||||
import {BatchBuilder} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {GuildRow} from '@fluxer/api/src/database/types/GuildTypes';
|
||||
import {mapGuildToGuildResponse, mapGuildToPartialResponse} from '@fluxer/api/src/guild/GuildModel';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {GuildDataHelpers} from '@fluxer/api/src/guild/services/data/GuildDataHelpers';
|
||||
import type {EntityAssetService, PreparedAssetUpload} from '@fluxer/api/src/infrastructure/EntityAssetService';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {InviteRepository} from '@fluxer/api/src/invite/InviteRepository';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import {Guild} from '@fluxer/api/src/models/Guild';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import {getGuildSearchService} from '@fluxer/api/src/SearchFactory';
|
||||
import {
|
||||
Channels,
|
||||
ChannelsByGuild,
|
||||
GuildMembers,
|
||||
GuildMembersByUserId,
|
||||
GuildRoles,
|
||||
Guilds,
|
||||
} from '@fluxer/api/src/Tables';
|
||||
import {withBusinessSpan} from '@fluxer/api/src/telemetry/BusinessSpans';
|
||||
import {withSpan} from '@fluxer/api/src/telemetry/Tracing';
|
||||
import type {GuildManagedTraitService} from '@fluxer/api/src/traits/GuildManagedTraitService';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {mapUserSettingsToResponse} from '@fluxer/api/src/user/UserMappers';
|
||||
import {removeGuildFromUserFolders} from '@fluxer/api/src/user/utils/GuildFolderUtils';
|
||||
import {areFeatureSetsEqual} from '@fluxer/api/src/utils/featureUtils';
|
||||
import type {IWebhookRepository} from '@fluxer/api/src/webhook/IWebhookRepository';
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {ChannelTypes, DEFAULT_PERMISSIONS, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {
|
||||
GuildFeatures,
|
||||
GuildSplashCardAlignment,
|
||||
type GuildSplashCardAlignmentValue,
|
||||
JoinSourceTypes,
|
||||
SystemChannelFlags,
|
||||
} from '@fluxer/constants/src/GuildConstants';
|
||||
import {MAX_GUILDS_NON_PREMIUM} from '@fluxer/constants/src/LimitConstants';
|
||||
import {DEFAULT_GUILD_FOLDER_ICON, UNCATEGORIZED_FOLDER_ID} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {MaxGuildsError} from '@fluxer/errors/src/domains/guild/MaxGuildsError';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import {resolveLimit} from '@fluxer/limits/src/LimitResolver';
|
||||
import type {GuildCreateRequest, GuildUpdateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
|
||||
import type {GuildPartialResponse, GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {extractTimestamp} from '@fluxer/snowflake/src/SnowflakeUtils';
|
||||
|
||||
interface PreparedGuildAssets {
|
||||
icon: PreparedAssetUpload | null;
|
||||
banner: PreparedAssetUpload | null;
|
||||
splash: PreparedAssetUpload | null;
|
||||
embed_splash: PreparedAssetUpload | null;
|
||||
}
|
||||
|
||||
const BASE_GUILD_FEATURES: ReadonlyArray<string> = [
|
||||
GuildFeatures.ANIMATED_ICON,
|
||||
GuildFeatures.ANIMATED_BANNER,
|
||||
GuildFeatures.BANNER,
|
||||
GuildFeatures.INVITE_SPLASH,
|
||||
];
|
||||
|
||||
export class GuildOperationsService {
|
||||
constructor(
|
||||
private readonly guildRepository: IGuildRepositoryAggregate,
|
||||
private readonly channelRepository: IChannelRepository,
|
||||
private readonly inviteRepository: InviteRepository,
|
||||
private readonly channelService: ChannelService,
|
||||
private readonly gatewayService: IGatewayService,
|
||||
private readonly entityAssetService: EntityAssetService,
|
||||
private readonly userRepository: IUserRepository,
|
||||
private readonly snowflakeService: SnowflakeService,
|
||||
private readonly webhookRepository: IWebhookRepository,
|
||||
private readonly helpers: GuildDataHelpers,
|
||||
private readonly limitConfigService: LimitConfigService,
|
||||
private readonly guildManagedTraitService?: GuildManagedTraitService,
|
||||
) {}
|
||||
|
||||
async getGuild({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildResponse> {
|
||||
try {
|
||||
const guild = await this.gatewayService.getGuildData({guildId, userId});
|
||||
if (!guild) throw new UnknownGuildError();
|
||||
return guild;
|
||||
} catch (error) {
|
||||
if (this.isGuildAccessError(error)) {
|
||||
if (await this.guildExists(guildId)) {
|
||||
throw new AccessDeniedError();
|
||||
}
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getUserGuilds(
|
||||
userId: UserID,
|
||||
options?: {
|
||||
before?: GuildID;
|
||||
after?: GuildID;
|
||||
limit?: number;
|
||||
withCounts?: boolean;
|
||||
},
|
||||
): Promise<Array<GuildResponse>> {
|
||||
let guilds = await this.guildRepository.listUserGuilds(userId);
|
||||
guilds.sort((a, b) => (a.id < b.id ? -1 : 1));
|
||||
|
||||
if (options?.after) {
|
||||
const index = guilds.findIndex((g) => g.id === options.after);
|
||||
if (index !== -1) {
|
||||
guilds = guilds.slice(index + 1);
|
||||
}
|
||||
} else if (options?.before) {
|
||||
const index = guilds.findIndex((g) => g.id === options.before);
|
||||
if (index !== -1) {
|
||||
guilds = guilds.slice(0, index);
|
||||
}
|
||||
}
|
||||
|
||||
const limit = options?.limit ?? 200;
|
||||
guilds = guilds.slice(0, limit);
|
||||
|
||||
const guildIds = guilds.map((g) => g.id);
|
||||
let permissionsMap = new Map<GuildID, bigint>();
|
||||
try {
|
||||
permissionsMap = await this.gatewayService.getUserPermissionsBatch({guildIds, userId});
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{userId: userId.toString(), guildCount: guildIds.length, error},
|
||||
'[GuildOperationsService] Failed to fetch guild permissions batch for list_guilds; returning without permissions',
|
||||
);
|
||||
}
|
||||
|
||||
const responses = guilds.map((guild) => {
|
||||
const permissions = permissionsMap.get(guild.id);
|
||||
if (permissions == null) {
|
||||
return mapGuildToGuildResponse(guild);
|
||||
}
|
||||
return mapGuildToGuildResponse(guild, {permissions});
|
||||
});
|
||||
|
||||
if (!options?.withCounts) {
|
||||
return responses;
|
||||
}
|
||||
|
||||
const guildsWithCounts: Array<GuildResponse> = [];
|
||||
const countBatchSize = 25;
|
||||
|
||||
for (let index = 0; index < guilds.length; index += countBatchSize) {
|
||||
const guildChunk = guilds.slice(index, index + countBatchSize);
|
||||
const responseChunk = responses.slice(index, index + countBatchSize);
|
||||
|
||||
const batchResults = await Promise.all(
|
||||
guildChunk.map(async (guild, chunkIndex) => {
|
||||
const baseResponse = responseChunk[chunkIndex] ?? mapGuildToGuildResponse(guild);
|
||||
try {
|
||||
const counts = await this.gatewayService.getGuildCounts(guild.id);
|
||||
return {
|
||||
...baseResponse,
|
||||
approximate_member_count: counts.memberCount,
|
||||
approximate_presence_count: counts.presenceCount,
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{guildId: guild.id.toString(), userId: userId.toString(), error},
|
||||
'[GuildOperationsService] Failed to fetch guild counts for list_guilds; returning without counts for guild',
|
||||
);
|
||||
return baseResponse;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
guildsWithCounts.push(...batchResults);
|
||||
}
|
||||
|
||||
return guildsWithCounts;
|
||||
}
|
||||
|
||||
async getPublicGuildData(guildId: GuildID): Promise<GuildPartialResponse> {
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
if (!guild) throw new UnknownGuildError();
|
||||
return mapGuildToPartialResponse(guild);
|
||||
}
|
||||
|
||||
async getGuildSystem(guildId: GuildID): Promise<Guild> {
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
if (!guild) throw new UnknownGuildError();
|
||||
return guild;
|
||||
}
|
||||
|
||||
async createGuild(
|
||||
params: {user: User; data: GuildCreateRequest},
|
||||
_auditLogReason?: string | null,
|
||||
): Promise<GuildResponse> {
|
||||
return await withBusinessSpan('fluxer.guild.create', 'fluxer.guilds.created', {}, () =>
|
||||
this.performCreateGuild(params, _auditLogReason),
|
||||
);
|
||||
}
|
||||
|
||||
private async performCreateGuild(
|
||||
params: {user: User; data: GuildCreateRequest},
|
||||
_auditLogReason?: string | null,
|
||||
): Promise<GuildResponse> {
|
||||
try {
|
||||
const {user, data} = params;
|
||||
const currentGuildCount = await this.guildRepository.countUserGuilds(user.id);
|
||||
let maxGuilds = MAX_GUILDS_NON_PREMIUM;
|
||||
const ctx = createLimitMatchContext({user});
|
||||
maxGuilds = resolveLimit(this.limitConfigService.getConfigSnapshot(), ctx, 'max_guilds');
|
||||
if (currentGuildCount >= maxGuilds) throw new MaxGuildsError(maxGuilds);
|
||||
|
||||
const guildId = createGuildID(await this.snowflakeService.generate());
|
||||
const textCategoryId = createChannelID(await this.snowflakeService.generate());
|
||||
const voiceCategoryId = createChannelID(await this.snowflakeService.generate());
|
||||
const generalChannelId = createChannelID(await this.snowflakeService.generate());
|
||||
const generalVoiceId = createChannelID(await this.snowflakeService.generate());
|
||||
|
||||
let preparedIcon: PreparedAssetUpload | null = null;
|
||||
if (data.icon) {
|
||||
preparedIcon = await this.entityAssetService.prepareAssetUpload({
|
||||
assetType: 'icon',
|
||||
entityType: 'guild',
|
||||
entityId: guildId,
|
||||
previousHash: null,
|
||||
base64Image: data.icon,
|
||||
errorPath: 'icon',
|
||||
});
|
||||
}
|
||||
const iconKey = preparedIcon?.newHash ?? null;
|
||||
const shouldUseEmptyFeatures = data.empty_features ?? false;
|
||||
const featuresSet = shouldUseEmptyFeatures ? new Set<string>() : new Set(BASE_GUILD_FEATURES);
|
||||
|
||||
const isUnclaimedOwner = !user.passwordHash;
|
||||
if (isUnclaimedOwner) {
|
||||
featuresSet.add(GuildFeatures.INVITES_DISABLED);
|
||||
}
|
||||
|
||||
const guildData: GuildRow = {
|
||||
guild_id: guildId,
|
||||
owner_id: user.id,
|
||||
name: data.name,
|
||||
vanity_url_code: null,
|
||||
icon_hash: iconKey,
|
||||
banner_hash: null,
|
||||
banner_width: null,
|
||||
banner_height: null,
|
||||
splash_hash: null,
|
||||
splash_width: null,
|
||||
splash_height: null,
|
||||
splash_card_alignment: GuildSplashCardAlignment.CENTER,
|
||||
embed_splash_hash: null,
|
||||
embed_splash_width: null,
|
||||
embed_splash_height: null,
|
||||
features: featuresSet,
|
||||
verification_level: 0,
|
||||
mfa_level: 0,
|
||||
nsfw_level: 0,
|
||||
explicit_content_filter: 0,
|
||||
default_message_notifications: 0,
|
||||
system_channel_id: generalChannelId,
|
||||
system_channel_flags: 0,
|
||||
rules_channel_id: null,
|
||||
afk_channel_id: null,
|
||||
afk_timeout: 300,
|
||||
disabled_operations: 0,
|
||||
member_count: 1,
|
||||
audit_logs_indexed_at: null,
|
||||
members_indexed_at: null,
|
||||
message_history_cutoff: null,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
batch.addPrepared(Guilds.insert(guildData));
|
||||
|
||||
const addChannel = (
|
||||
channelId: ChannelID,
|
||||
type: number,
|
||||
name: string,
|
||||
parentId: ChannelID | null,
|
||||
position: number,
|
||||
bitrate: number | null = null,
|
||||
) => {
|
||||
batch.addPrepared(
|
||||
Channels.insert({
|
||||
channel_id: channelId,
|
||||
guild_id: guildId,
|
||||
type,
|
||||
name,
|
||||
topic: null,
|
||||
icon_hash: null,
|
||||
url: null,
|
||||
parent_id: parentId,
|
||||
position,
|
||||
owner_id: null,
|
||||
recipient_ids: null,
|
||||
nsfw: false,
|
||||
rate_limit_per_user: 0,
|
||||
bitrate,
|
||||
user_limit: bitrate !== null ? 0 : null,
|
||||
rtc_region: null,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
nicks: null,
|
||||
soft_deleted: false,
|
||||
indexed_at: null,
|
||||
version: 1,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(
|
||||
ChannelsByGuild.upsertAll({
|
||||
guild_id: guildId,
|
||||
channel_id: channelId,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
addChannel(textCategoryId, ChannelTypes.GUILD_CATEGORY, 'Text Channels', null, 0);
|
||||
addChannel(voiceCategoryId, ChannelTypes.GUILD_CATEGORY, 'Voice Channels', null, 1);
|
||||
addChannel(generalChannelId, ChannelTypes.GUILD_TEXT, 'general', textCategoryId, 0);
|
||||
addChannel(generalVoiceId, ChannelTypes.GUILD_VOICE, 'General', voiceCategoryId, 0, 64000);
|
||||
|
||||
batch.addPrepared(
|
||||
GuildRoles.insert({
|
||||
guild_id: guildId,
|
||||
role_id: guildIdToRoleId(guildId),
|
||||
name: '@everyone',
|
||||
permissions: DEFAULT_PERMISSIONS,
|
||||
position: 0,
|
||||
hoist_position: null,
|
||||
color: 0,
|
||||
icon_hash: null,
|
||||
unicode_emoji: null,
|
||||
hoist: false,
|
||||
mentionable: false,
|
||||
version: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
batch.addPrepared(
|
||||
GuildMembers.insert({
|
||||
guild_id: guildId,
|
||||
user_id: user.id,
|
||||
joined_at: new Date(),
|
||||
nick: null,
|
||||
avatar_hash: null,
|
||||
banner_hash: null,
|
||||
bio: null,
|
||||
pronouns: null,
|
||||
accent_color: null,
|
||||
join_source_type: JoinSourceTypes.CREATOR,
|
||||
source_invite_code: null,
|
||||
inviter_id: null,
|
||||
deaf: false,
|
||||
mute: false,
|
||||
communication_disabled_until: null,
|
||||
role_ids: null,
|
||||
is_premium_sanitized: null,
|
||||
temporary: false,
|
||||
profile_flags: null,
|
||||
version: 1,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(GuildMembersByUserId.insert({user_id: user.id, guild_id: guildId}));
|
||||
|
||||
await batch.execute();
|
||||
|
||||
const guild = new Guild(guildData);
|
||||
|
||||
await this.gatewayService.startGuild(guildId);
|
||||
await this.gatewayService.joinGuild({userId: user.id, guildId});
|
||||
|
||||
if (!user.isBot) {
|
||||
const userSettings = await this.userRepository.findSettings(user.id);
|
||||
if (userSettings) {
|
||||
const settingsRow = userSettings.toRow();
|
||||
const existingFolders = settingsRow.guild_folders ?? [];
|
||||
const uncategorizedIndex = existingFolders.findIndex(
|
||||
(folder) => folder.folder_id === UNCATEGORIZED_FOLDER_ID,
|
||||
);
|
||||
|
||||
if (uncategorizedIndex !== -1) {
|
||||
const uncategorizedFolder = existingFolders[uncategorizedIndex];
|
||||
const updatedGuildIds = [guildId, ...(uncategorizedFolder.guild_ids ?? [])];
|
||||
existingFolders[uncategorizedIndex] = {
|
||||
...uncategorizedFolder,
|
||||
guild_ids: updatedGuildIds,
|
||||
};
|
||||
} else {
|
||||
existingFolders.push({
|
||||
folder_id: UNCATEGORIZED_FOLDER_ID,
|
||||
name: null,
|
||||
color: null,
|
||||
flags: 0,
|
||||
icon: DEFAULT_GUILD_FOLDER_ICON,
|
||||
guild_ids: [guildId],
|
||||
});
|
||||
}
|
||||
|
||||
settingsRow.guild_folders = existingFolders;
|
||||
const updatedSettings = await this.userRepository.upsertSettings(settingsRow);
|
||||
const guildIds = await this.userRepository.getUserGuildIds(user.id);
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: user.id,
|
||||
event: 'USER_SETTINGS_UPDATE',
|
||||
data: mapUserSettingsToResponse({settings: updatedSettings, memberGuildIds: guildIds}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const guildSearchService = getGuildSearchService();
|
||||
if (guildSearchService) {
|
||||
await guildSearchService.indexGuild(guild).catch((error) => {
|
||||
Logger.error({guildId: guild.id, error}, 'Failed to index guild in search');
|
||||
});
|
||||
}
|
||||
|
||||
getMetricsService().counter({
|
||||
name: 'fluxer.guilds.created',
|
||||
dimensions: {
|
||||
guild_id: guildId.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
return mapGuildToGuildResponse(guild);
|
||||
} catch (error) {
|
||||
getMetricsService().counter({name: 'guild.create.error'});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateGuild(
|
||||
params: {userId: UserID; guildId: GuildID; data: GuildUpdateRequest; requestCache: RequestCache},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<GuildResponse> {
|
||||
const {userId, guildId, data} = params;
|
||||
const {checkPermission, guildData} = await this.helpers.getGuildAuthenticated({userId, guildId});
|
||||
await checkPermission(Permissions.MANAGE_GUILD);
|
||||
|
||||
const currentGuild = await this.guildRepository.findUnique(guildId);
|
||||
if (!currentGuild) throw new UnknownGuildError();
|
||||
|
||||
const previousSnapshot = this.helpers.serializeGuildForAudit(currentGuild);
|
||||
|
||||
if (data.mfa_level !== undefined) {
|
||||
const isOwner = guildData.owner_id === userId.toString();
|
||||
if (!isOwner) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
const owner = await this.userRepository.findUniqueAssert(userId);
|
||||
if (owner.authenticatorTypes.size === 0) {
|
||||
throw InputValidationError.fromCode(
|
||||
'mfa_level',
|
||||
ValidationErrorCodes.MUST_ENABLE_2FA_BEFORE_REQUIRING_FOR_MODS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const preparedAssets: PreparedGuildAssets = {icon: null, banner: null, splash: null, embed_splash: null};
|
||||
|
||||
let iconHash = currentGuild.iconHash;
|
||||
if (data.icon !== undefined) {
|
||||
preparedAssets.icon = await this.entityAssetService.prepareAssetUpload({
|
||||
assetType: 'icon',
|
||||
entityType: 'guild',
|
||||
entityId: guildId,
|
||||
previousHash: currentGuild.iconHash,
|
||||
base64Image: data.icon,
|
||||
errorPath: 'icon',
|
||||
});
|
||||
iconHash = preparedAssets.icon.newHash;
|
||||
}
|
||||
|
||||
let bannerHash = currentGuild.bannerHash;
|
||||
let bannerHeight = currentGuild.bannerHeight;
|
||||
let bannerWidth = currentGuild.bannerWidth;
|
||||
if (data.banner !== undefined) {
|
||||
if (data.banner && !currentGuild.features.has(GuildFeatures.BANNER)) {
|
||||
await this.rollbackPreparedAssets(preparedAssets);
|
||||
throw InputValidationError.fromCode('banner', ValidationErrorCodes.GUILD_BANNER_REQUIRES_FEATURE);
|
||||
}
|
||||
|
||||
try {
|
||||
preparedAssets.banner = await this.entityAssetService.prepareAssetUpload({
|
||||
assetType: 'banner',
|
||||
entityType: 'guild',
|
||||
entityId: guildId,
|
||||
previousHash: currentGuild.bannerHash,
|
||||
base64Image: data.banner,
|
||||
errorPath: 'banner',
|
||||
});
|
||||
|
||||
if (preparedAssets.banner.isAnimated && !currentGuild.features.has(GuildFeatures.ANIMATED_BANNER)) {
|
||||
await this.rollbackPreparedAssets(preparedAssets);
|
||||
throw InputValidationError.fromCode('banner', ValidationErrorCodes.ANIMATED_GUILD_BANNER_REQUIRES_FEATURE);
|
||||
}
|
||||
|
||||
bannerHash = preparedAssets.banner.newHash;
|
||||
bannerHeight =
|
||||
preparedAssets.banner.newHash === currentGuild.bannerHash && bannerHeight != null
|
||||
? bannerHeight
|
||||
: (preparedAssets.banner.height ?? null);
|
||||
bannerWidth =
|
||||
preparedAssets.banner.newHash === currentGuild.bannerHash && bannerWidth != null
|
||||
? bannerWidth
|
||||
: (preparedAssets.banner.width ?? null);
|
||||
} catch (error) {
|
||||
await this.rollbackPreparedAssets(preparedAssets);
|
||||
throw error;
|
||||
}
|
||||
} else if (data.banner === null) {
|
||||
bannerHeight = null;
|
||||
bannerWidth = null;
|
||||
}
|
||||
|
||||
let splashHash = currentGuild.splashHash;
|
||||
let splashWidth = currentGuild.splashWidth;
|
||||
let splashHeight = currentGuild.splashHeight;
|
||||
if (data.splash !== undefined) {
|
||||
if (data.splash && !currentGuild.features.has(GuildFeatures.INVITE_SPLASH)) {
|
||||
await this.rollbackPreparedAssets(preparedAssets);
|
||||
throw InputValidationError.fromCode('splash', ValidationErrorCodes.INVITE_SPLASH_REQUIRES_FEATURE);
|
||||
}
|
||||
|
||||
try {
|
||||
preparedAssets.splash = await this.entityAssetService.prepareAssetUpload({
|
||||
assetType: 'splash',
|
||||
entityType: 'guild',
|
||||
entityId: guildId,
|
||||
previousHash: currentGuild.splashHash,
|
||||
base64Image: data.splash,
|
||||
errorPath: 'splash',
|
||||
});
|
||||
splashHash = preparedAssets.splash.newHash;
|
||||
splashHeight =
|
||||
preparedAssets.splash.newHash === currentGuild.splashHash && splashHeight != null
|
||||
? splashHeight
|
||||
: (preparedAssets.splash.height ?? null);
|
||||
splashWidth =
|
||||
preparedAssets.splash.newHash === currentGuild.splashHash && splashWidth != null
|
||||
? splashWidth
|
||||
: (preparedAssets.splash.width ?? null);
|
||||
} catch (error) {
|
||||
await this.rollbackPreparedAssets(preparedAssets);
|
||||
throw error;
|
||||
}
|
||||
} else if (data.splash === null) {
|
||||
splashHash = null;
|
||||
splashWidth = null;
|
||||
splashHeight = null;
|
||||
}
|
||||
|
||||
let embedSplashHash = currentGuild.embedSplashHash;
|
||||
let embedSplashWidth = currentGuild.embedSplashWidth;
|
||||
let embedSplashHeight = currentGuild.embedSplashHeight;
|
||||
if (data.embed_splash !== undefined) {
|
||||
if (data.embed_splash && !currentGuild.features.has(GuildFeatures.INVITE_SPLASH)) {
|
||||
await this.rollbackPreparedAssets(preparedAssets);
|
||||
throw InputValidationError.fromCode('embed_splash', ValidationErrorCodes.EMBED_SPLASH_REQUIRES_FEATURE);
|
||||
}
|
||||
|
||||
try {
|
||||
preparedAssets.embed_splash = await this.entityAssetService.prepareAssetUpload({
|
||||
assetType: 'embed_splash',
|
||||
entityType: 'guild',
|
||||
entityId: guildId,
|
||||
previousHash: currentGuild.embedSplashHash,
|
||||
base64Image: data.embed_splash,
|
||||
errorPath: 'embed_splash',
|
||||
});
|
||||
embedSplashHash = preparedAssets.embed_splash.newHash;
|
||||
embedSplashHeight =
|
||||
preparedAssets.embed_splash.newHash === currentGuild.embedSplashHash && embedSplashHeight != null
|
||||
? embedSplashHeight
|
||||
: (preparedAssets.embed_splash.height ?? null);
|
||||
embedSplashWidth =
|
||||
preparedAssets.embed_splash.newHash === currentGuild.embedSplashHash && embedSplashWidth != null
|
||||
? embedSplashWidth
|
||||
: (preparedAssets.embed_splash.width ?? null);
|
||||
} catch (error) {
|
||||
await this.rollbackPreparedAssets(preparedAssets);
|
||||
throw error;
|
||||
}
|
||||
} else if (data.embed_splash === null) {
|
||||
embedSplashHash = null;
|
||||
embedSplashWidth = null;
|
||||
embedSplashHeight = null;
|
||||
}
|
||||
|
||||
let afkChannelId: ChannelID | null | undefined;
|
||||
if (data.afk_channel_id !== undefined) {
|
||||
if (data.afk_channel_id) {
|
||||
afkChannelId = createChannelID(data.afk_channel_id);
|
||||
const afkChannel = await this.channelRepository.findUnique(afkChannelId);
|
||||
if (!afkChannel || afkChannel.guildId !== guildId) {
|
||||
throw InputValidationError.fromCode('afk_channel_id', ValidationErrorCodes.AFK_CHANNEL_MUST_BE_IN_GUILD);
|
||||
}
|
||||
if (afkChannel.type !== ChannelTypes.GUILD_VOICE) {
|
||||
throw InputValidationError.fromCode('afk_channel_id', ValidationErrorCodes.AFK_CHANNEL_MUST_BE_VOICE);
|
||||
}
|
||||
} else {
|
||||
afkChannelId = null;
|
||||
}
|
||||
}
|
||||
|
||||
let systemChannelId: ChannelID | null | undefined;
|
||||
if (data.system_channel_id !== undefined) {
|
||||
if (data.system_channel_id) {
|
||||
systemChannelId = createChannelID(data.system_channel_id);
|
||||
const systemChannel = await this.channelRepository.findUnique(systemChannelId);
|
||||
if (!systemChannel || systemChannel.guildId !== guildId) {
|
||||
throw InputValidationError.fromCode(
|
||||
'system_channel_id',
|
||||
ValidationErrorCodes.SYSTEM_CHANNEL_MUST_BE_IN_GUILD,
|
||||
);
|
||||
}
|
||||
if (systemChannel.type !== ChannelTypes.GUILD_TEXT) {
|
||||
throw InputValidationError.fromCode('system_channel_id', ValidationErrorCodes.SYSTEM_CHANNEL_MUST_BE_TEXT);
|
||||
}
|
||||
} else {
|
||||
systemChannelId = null;
|
||||
}
|
||||
}
|
||||
|
||||
let sanitizedSystemChannelFlags = currentGuild.systemChannelFlags;
|
||||
if (data.system_channel_flags !== undefined) {
|
||||
const SUPPORTED_SYSTEM_CHANNEL_FLAGS = SystemChannelFlags.SUPPRESS_JOIN_NOTIFICATIONS;
|
||||
sanitizedSystemChannelFlags = data.system_channel_flags & SUPPORTED_SYSTEM_CHANNEL_FLAGS;
|
||||
}
|
||||
|
||||
const previousFeatures = new Set(currentGuild.features);
|
||||
let updatedFeatures = currentGuild.features;
|
||||
let featuresChanged = false;
|
||||
if (data.features !== undefined) {
|
||||
const newFeatures = new Set(currentGuild.features);
|
||||
|
||||
const owner = await this.userRepository.findUnique(currentGuild.ownerId);
|
||||
const isOwnerUnclaimed = owner && !owner.passwordHash;
|
||||
|
||||
const toggleableFeatures = [
|
||||
GuildFeatures.INVITES_DISABLED,
|
||||
GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES,
|
||||
GuildFeatures.DETACHED_BANNER,
|
||||
];
|
||||
|
||||
for (const feature of toggleableFeatures) {
|
||||
if (feature === GuildFeatures.INVITES_DISABLED && isOwnerUnclaimed) {
|
||||
newFeatures.add(feature);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data.features.includes(feature)) {
|
||||
newFeatures.add(feature);
|
||||
} else {
|
||||
newFeatures.delete(feature);
|
||||
}
|
||||
}
|
||||
|
||||
updatedFeatures = newFeatures;
|
||||
featuresChanged = !areFeatureSetsEqual(previousFeatures, updatedFeatures);
|
||||
}
|
||||
|
||||
let messageHistoryCutoff: Date | null | undefined;
|
||||
if (data.message_history_cutoff !== undefined) {
|
||||
if (data.message_history_cutoff === null) {
|
||||
messageHistoryCutoff = null;
|
||||
} else {
|
||||
const cutoffDate = new Date(data.message_history_cutoff);
|
||||
const guildCreationTimestamp = extractTimestamp(guildId.toString());
|
||||
if (cutoffDate.getTime() < guildCreationTimestamp) {
|
||||
throw InputValidationError.fromCode(
|
||||
'message_history_cutoff',
|
||||
ValidationErrorCodes.MESSAGE_HISTORY_CUTOFF_BEFORE_GUILD_CREATION,
|
||||
);
|
||||
}
|
||||
if (cutoffDate.getTime() > Date.now()) {
|
||||
throw InputValidationError.fromCode(
|
||||
'message_history_cutoff',
|
||||
ValidationErrorCodes.MESSAGE_HISTORY_CUTOFF_IN_FUTURE,
|
||||
);
|
||||
}
|
||||
messageHistoryCutoff = cutoffDate;
|
||||
}
|
||||
}
|
||||
|
||||
const currentGuildRow = currentGuild.toRow();
|
||||
const splashCardAlignment: GuildSplashCardAlignmentValue =
|
||||
data.splash_card_alignment ?? currentGuildRow.splash_card_alignment ?? GuildSplashCardAlignment.CENTER;
|
||||
const upsertData = {
|
||||
...currentGuildRow,
|
||||
name: data.name ?? currentGuildRow.name,
|
||||
icon_hash: iconHash,
|
||||
banner_hash: bannerHash,
|
||||
banner_width: bannerWidth,
|
||||
banner_height: bannerHeight,
|
||||
splash_hash: splashHash,
|
||||
splash_width: splashWidth,
|
||||
splash_height: splashHeight,
|
||||
splash_card_alignment: splashCardAlignment,
|
||||
embed_splash_hash: embedSplashHash,
|
||||
embed_splash_width: embedSplashWidth,
|
||||
embed_splash_height: embedSplashHeight,
|
||||
features: updatedFeatures,
|
||||
system_channel_id: systemChannelId !== undefined ? systemChannelId : currentGuildRow.system_channel_id,
|
||||
system_channel_flags: sanitizedSystemChannelFlags,
|
||||
afk_channel_id: afkChannelId !== undefined ? afkChannelId : currentGuildRow.afk_channel_id,
|
||||
afk_timeout: data.afk_timeout ?? currentGuildRow.afk_timeout,
|
||||
default_message_notifications:
|
||||
data.default_message_notifications ?? currentGuildRow.default_message_notifications,
|
||||
verification_level: data.verification_level ?? currentGuildRow.verification_level,
|
||||
mfa_level: data.mfa_level ?? currentGuildRow.mfa_level,
|
||||
nsfw_level: data.nsfw_level ?? currentGuildRow.nsfw_level,
|
||||
explicit_content_filter: data.explicit_content_filter ?? currentGuildRow.explicit_content_filter,
|
||||
message_history_cutoff:
|
||||
messageHistoryCutoff !== undefined ? messageHistoryCutoff : currentGuildRow.message_history_cutoff,
|
||||
};
|
||||
let updatedGuild: Guild;
|
||||
try {
|
||||
updatedGuild = await this.guildRepository.upsert(upsertData);
|
||||
} catch (error) {
|
||||
await this.rollbackPreparedAssets(preparedAssets);
|
||||
Logger.error({error, guildId}, 'Guild update failed, rolled back asset uploads');
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.commitPreparedAssets(preparedAssets);
|
||||
} catch (error) {
|
||||
Logger.error({error, guildId}, 'Failed to commit asset changes after successful guild update');
|
||||
}
|
||||
|
||||
if (featuresChanged && this.guildManagedTraitService) {
|
||||
await this.guildManagedTraitService.reconcileTraitsForGuildFeatureChange({
|
||||
guildId,
|
||||
previousFeatures,
|
||||
newFeatures: updatedFeatures,
|
||||
});
|
||||
}
|
||||
|
||||
await this.helpers.dispatchGuildUpdate(updatedGuild);
|
||||
|
||||
const guildSearchService = getGuildSearchService();
|
||||
if (guildSearchService) {
|
||||
await guildSearchService.updateGuild(updatedGuild).catch((error) => {
|
||||
Logger.error({guildId: updatedGuild.id, error}, 'Failed to update guild in search');
|
||||
});
|
||||
}
|
||||
|
||||
await this.helpers.recordAuditLog({
|
||||
guildId,
|
||||
userId,
|
||||
action: AuditLogActionType.GUILD_UPDATE,
|
||||
targetId: guildId,
|
||||
auditLogReason: auditLogReason ?? null,
|
||||
metadata: {name: updatedGuild.name},
|
||||
changes: this.helpers.computeGuildChanges(previousSnapshot, updatedGuild),
|
||||
});
|
||||
|
||||
if (data.name !== undefined && currentGuild.name !== updatedGuild.name) {
|
||||
getMetricsService().counter({
|
||||
name: 'fluxer.guilds.updated',
|
||||
dimensions: {
|
||||
guild_id: guildId.toString(),
|
||||
update_type: 'name',
|
||||
},
|
||||
});
|
||||
}
|
||||
if (data.icon !== undefined && currentGuild.iconHash !== updatedGuild.iconHash) {
|
||||
getMetricsService().counter({
|
||||
name: 'fluxer.guilds.updated',
|
||||
dimensions: {
|
||||
guild_id: guildId.toString(),
|
||||
update_type: 'icon',
|
||||
},
|
||||
});
|
||||
}
|
||||
if (data.banner !== undefined && currentGuild.bannerHash !== updatedGuild.bannerHash) {
|
||||
getMetricsService().counter({
|
||||
name: 'fluxer.guilds.updated',
|
||||
dimensions: {
|
||||
guild_id: guildId.toString(),
|
||||
update_type: 'banner',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return mapGuildToGuildResponse(updatedGuild);
|
||||
}
|
||||
|
||||
private async rollbackPreparedAssets(assets: PreparedGuildAssets): Promise<void> {
|
||||
const rollbackPromises: Array<Promise<void>> = [];
|
||||
|
||||
if (assets.icon) {
|
||||
rollbackPromises.push(this.entityAssetService.rollbackAssetUpload(assets.icon));
|
||||
}
|
||||
if (assets.banner) {
|
||||
rollbackPromises.push(this.entityAssetService.rollbackAssetUpload(assets.banner));
|
||||
}
|
||||
if (assets.splash) {
|
||||
rollbackPromises.push(this.entityAssetService.rollbackAssetUpload(assets.splash));
|
||||
}
|
||||
if (assets.embed_splash) {
|
||||
rollbackPromises.push(this.entityAssetService.rollbackAssetUpload(assets.embed_splash));
|
||||
}
|
||||
|
||||
await Promise.all(rollbackPromises);
|
||||
}
|
||||
|
||||
private async commitPreparedAssets(assets: PreparedGuildAssets): Promise<void> {
|
||||
const commitPromises: Array<Promise<void>> = [];
|
||||
|
||||
if (assets.icon) {
|
||||
commitPromises.push(this.entityAssetService.commitAssetChange({prepared: assets.icon, deferDeletion: true}));
|
||||
}
|
||||
if (assets.banner) {
|
||||
commitPromises.push(this.entityAssetService.commitAssetChange({prepared: assets.banner, deferDeletion: true}));
|
||||
}
|
||||
if (assets.splash) {
|
||||
commitPromises.push(this.entityAssetService.commitAssetChange({prepared: assets.splash, deferDeletion: true}));
|
||||
}
|
||||
if (assets.embed_splash) {
|
||||
commitPromises.push(
|
||||
this.entityAssetService.commitAssetChange({prepared: assets.embed_splash, deferDeletion: true}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(commitPromises);
|
||||
}
|
||||
|
||||
async deleteGuild(params: {user: User; guildId: GuildID}, _auditLogReason?: string | null): Promise<void> {
|
||||
const {user, guildId} = params;
|
||||
const {guildData} = await this.helpers.getGuildAuthenticated({userId: user.id, guildId});
|
||||
if (!guildData || guildData.owner_id !== user.id.toString()) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
await this.performGuildDeletion(guildId);
|
||||
}
|
||||
|
||||
async deleteGuildById(guildId: GuildID): Promise<void> {
|
||||
await this.performGuildDeletion(guildId);
|
||||
}
|
||||
|
||||
private async performGuildDeletion(guildId: GuildID): Promise<void> {
|
||||
return await withSpan(
|
||||
{
|
||||
name: 'fluxer.guild.delete',
|
||||
attributes: {},
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
const members = await this.guildRepository.listMembers(guildId);
|
||||
|
||||
await this.gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_DELETE',
|
||||
data: {id: guildId.toString()},
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
members.map(async (member) => {
|
||||
await this.gatewayService.leaveGuild({userId: member.userId, guildId});
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(members.map((member) => this.userRepository.deleteGuildSettings(member.userId, guildId)));
|
||||
|
||||
await Promise.all(
|
||||
members.map(async (member) => {
|
||||
const user = await this.userRepository.findUnique(member.userId);
|
||||
if (user && !user.isBot) {
|
||||
await removeGuildFromUserFolders({
|
||||
userId: member.userId,
|
||||
guildId,
|
||||
userRepository: this.userRepository,
|
||||
gatewayService: this.gatewayService,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const invites = await this.inviteRepository.listGuildInvites(guildId);
|
||||
await Promise.all(invites.map((invite) => this.inviteRepository.delete(invite.code)));
|
||||
|
||||
const webhooks = await this.webhookRepository.listByGuild(guildId);
|
||||
await Promise.all(webhooks.map((webhook) => this.webhookRepository.delete(webhook.id)));
|
||||
|
||||
const channels = await this.channelRepository.listGuildChannels(guildId);
|
||||
|
||||
await Promise.all(channels.map((channel) => this.channelRepository.deleteAllChannelMessages(channel.id)));
|
||||
await Promise.all(channels.map((channel) => this.channelService.purgeChannelAttachments(channel)));
|
||||
|
||||
await this.guildRepository.delete(guildId, guild.ownerId);
|
||||
await this.gatewayService.stopGuild(guildId);
|
||||
|
||||
const guildSearchService = getGuildSearchService();
|
||||
if (guildSearchService) {
|
||||
await guildSearchService.deleteGuild(guildId).catch((error) => {
|
||||
Logger.error({guildId, error}, 'Failed to delete guild from search');
|
||||
});
|
||||
}
|
||||
|
||||
getMetricsService().counter({
|
||||
name: 'fluxer.guilds.deleted',
|
||||
dimensions: {
|
||||
guild_id: guildId.toString(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
getMetricsService().counter({name: 'guild.delete.error'});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private isGuildAccessError(error: unknown): boolean {
|
||||
return error instanceof UnknownGuildError;
|
||||
}
|
||||
|
||||
private async guildExists(guildId: GuildID): Promise<boolean> {
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
return guild !== null;
|
||||
}
|
||||
}
|
||||
100
packages/api/src/guild/services/data/GuildOwnershipService.tsx
Normal file
100
packages/api/src/guild/services/data/GuildOwnershipService.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {mapGuildToGuildResponse} from '@fluxer/api/src/guild/GuildModel';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {GuildDataHelpers} from '@fluxer/api/src/guild/services/data/GuildDataHelpers';
|
||||
import type {Guild} from '@fluxer/api/src/models/Guild';
|
||||
import type {GuildMember} from '@fluxer/api/src/models/GuildMember';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {checkGuildVerificationWithGuildModel} from '@fluxer/api/src/utils/GuildVerificationUtils';
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {CannotTransferOwnershipToBotError} from '@fluxer/errors/src/domains/guild/CannotTransferOwnershipToBotError';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import {UnknownGuildMemberError} from '@fluxer/errors/src/domains/guild/UnknownGuildMemberError';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
|
||||
export class GuildOwnershipService {
|
||||
constructor(
|
||||
private readonly guildRepository: IGuildRepositoryAggregate,
|
||||
private readonly userRepository: IUserRepository,
|
||||
private readonly helpers: GuildDataHelpers,
|
||||
) {}
|
||||
|
||||
async transferOwnership(
|
||||
params: {userId: UserID; guildId: GuildID; newOwnerId: UserID},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<GuildResponse> {
|
||||
const {userId, guildId, newOwnerId} = params;
|
||||
const {guildData} = await this.helpers.getGuildAuthenticated({userId, guildId});
|
||||
|
||||
if (guildData.owner_id !== userId.toString()) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user) throw new MissingAccessError();
|
||||
|
||||
const newOwner = await this.guildRepository.getMember(guildId, newOwnerId);
|
||||
if (!newOwner) {
|
||||
throw new UnknownGuildMemberError();
|
||||
}
|
||||
|
||||
const newOwnerUser = await this.userRepository.findUnique(newOwnerId);
|
||||
if (newOwnerUser?.isBot) {
|
||||
throw new CannotTransferOwnershipToBotError();
|
||||
}
|
||||
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
if (!guild) throw new UnknownGuildError();
|
||||
const previousSnapshot = this.helpers.serializeGuildForAudit(guild);
|
||||
const previousOwnerId = guild.ownerId;
|
||||
|
||||
const updatedGuild = await this.guildRepository.upsert(
|
||||
{
|
||||
...guild.toRow(),
|
||||
owner_id: newOwnerId,
|
||||
},
|
||||
undefined,
|
||||
previousOwnerId,
|
||||
);
|
||||
|
||||
await this.helpers.dispatchGuildUpdate(updatedGuild);
|
||||
|
||||
await this.helpers.recordAuditLog({
|
||||
guildId,
|
||||
userId,
|
||||
action: AuditLogActionType.GUILD_UPDATE,
|
||||
targetId: guildId,
|
||||
auditLogReason: auditLogReason ?? null,
|
||||
metadata: {new_owner_id: newOwnerId.toString()},
|
||||
changes: this.helpers.computeGuildChanges(previousSnapshot, updatedGuild),
|
||||
});
|
||||
return mapGuildToGuildResponse(updatedGuild);
|
||||
}
|
||||
|
||||
async checkGuildVerification(params: {user: User; guild: Guild; member: GuildMember}): Promise<void> {
|
||||
const {user, guild, member} = params;
|
||||
checkGuildVerificationWithGuildModel({user, guild, member});
|
||||
}
|
||||
}
|
||||
156
packages/api/src/guild/services/data/GuildVanityService.tsx
Normal file
156
packages/api/src/guild/services/data/GuildVanityService.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
createInviteCode,
|
||||
createVanityURLCode,
|
||||
type GuildID,
|
||||
type UserID,
|
||||
vanityCodeToInviteCode,
|
||||
} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {GuildDataHelpers} from '@fluxer/api/src/guild/services/data/GuildDataHelpers';
|
||||
import type {InviteRepository} from '@fluxer/api/src/invite/InviteRepository';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {InviteTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import type {GuildVanityURLResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
|
||||
export class GuildVanityService {
|
||||
constructor(
|
||||
private readonly guildRepository: IGuildRepositoryAggregate,
|
||||
private readonly inviteRepository: InviteRepository,
|
||||
private readonly helpers: GuildDataHelpers,
|
||||
) {}
|
||||
|
||||
async getVanityURL(params: {userId: UserID; guildId: GuildID}): Promise<GuildVanityURLResponse> {
|
||||
const {userId, guildId} = params;
|
||||
const {guildData, checkPermission} = await this.helpers.getGuildAuthenticated({userId, guildId});
|
||||
await checkPermission(Permissions.MANAGE_GUILD);
|
||||
|
||||
if (!guildData) throw new UnknownGuildError();
|
||||
|
||||
const vanityCodeString = guildData.vanity_url_code;
|
||||
|
||||
if (!vanityCodeString) {
|
||||
return {code: null, uses: 0};
|
||||
}
|
||||
|
||||
const vanityCode = createVanityURLCode(vanityCodeString);
|
||||
const invite = await this.inviteRepository.findUnique(vanityCodeToInviteCode(vanityCode));
|
||||
|
||||
return {
|
||||
code: vanityCodeString,
|
||||
uses: invite?.uses ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async updateVanityURL(
|
||||
params: {userId: UserID; guildId: GuildID; code: string | null; requestCache: RequestCache},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<{code: string}> {
|
||||
const {userId, guildId, code} = params;
|
||||
const {checkPermission} = await this.helpers.getGuildAuthenticated({userId, guildId});
|
||||
await checkPermission(Permissions.MANAGE_GUILD);
|
||||
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
if (!guild) throw new UnknownGuildError();
|
||||
|
||||
const previousSnapshot = this.helpers.serializeGuildForAudit(guild);
|
||||
|
||||
if (code && !guild.features.has(GuildFeatures.VANITY_URL)) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.VANITY_URL_REQUIRES_FEATURE);
|
||||
}
|
||||
|
||||
if (code?.includes('fluxer')) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.VANITY_URL_CODE_CANNOT_CONTAIN_FLUXER);
|
||||
}
|
||||
|
||||
if (code != null && guild.vanityUrlCode === code) {
|
||||
return {code};
|
||||
}
|
||||
|
||||
if (code == null) {
|
||||
if (guild.vanityUrlCode != null) {
|
||||
const oldInvite = await this.inviteRepository.findUnique(vanityCodeToInviteCode(guild.vanityUrlCode));
|
||||
if (oldInvite) {
|
||||
await this.inviteRepository.delete(oldInvite.code);
|
||||
}
|
||||
const updatedGuild = await this.guildRepository.upsert({...guild.toRow(), vanity_url_code: null});
|
||||
await this.helpers.dispatchGuildUpdate(updatedGuild);
|
||||
await this.helpers.recordAuditLog({
|
||||
guildId,
|
||||
userId,
|
||||
action: AuditLogActionType.GUILD_UPDATE,
|
||||
targetId: guildId,
|
||||
auditLogReason: auditLogReason ?? null,
|
||||
metadata: {vanity_url_code: ''},
|
||||
changes: this.helpers.computeGuildChanges(previousSnapshot, updatedGuild),
|
||||
});
|
||||
return {code: ''};
|
||||
}
|
||||
return {code: ''};
|
||||
}
|
||||
|
||||
const existingInvite = await this.inviteRepository.findUnique(createInviteCode(code));
|
||||
if (existingInvite != null) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.VANITY_URL_CODE_ALREADY_TAKEN);
|
||||
}
|
||||
|
||||
if (guild.vanityUrlCode != null) {
|
||||
const oldInvite = await this.inviteRepository.findUnique(vanityCodeToInviteCode(guild.vanityUrlCode));
|
||||
if (oldInvite) {
|
||||
await this.inviteRepository.delete(oldInvite.code);
|
||||
}
|
||||
}
|
||||
|
||||
await this.inviteRepository.create({
|
||||
code: createInviteCode(code),
|
||||
type: InviteTypes.GUILD,
|
||||
guild_id: guildId,
|
||||
channel_id: null,
|
||||
inviter_id: null,
|
||||
uses: 0,
|
||||
max_uses: 0,
|
||||
max_age: 0,
|
||||
});
|
||||
|
||||
const updatedGuild = await this.guildRepository.upsert({
|
||||
...guild.toRow(),
|
||||
vanity_url_code: createVanityURLCode(code),
|
||||
});
|
||||
await this.helpers.dispatchGuildUpdate(updatedGuild);
|
||||
|
||||
await this.helpers.recordAuditLog({
|
||||
guildId,
|
||||
userId,
|
||||
action: AuditLogActionType.GUILD_UPDATE,
|
||||
targetId: guildId,
|
||||
auditLogReason: auditLogReason ?? null,
|
||||
metadata: {vanity_url_code: code},
|
||||
changes: this.helpers.computeGuildChanges(previousSnapshot, updatedGuild),
|
||||
});
|
||||
|
||||
return {code};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {GuildAuditLogService} from '@fluxer/api/src/guild/GuildAuditLogService';
|
||||
import type {GuildAuditLogChange} from '@fluxer/api/src/guild/GuildAuditLogTypes';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import type {GuildMember} from '@fluxer/api/src/models/GuildMember';
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
|
||||
export class GuildMemberAuditService {
|
||||
constructor(private readonly guildAuditLogService: GuildAuditLogService) {}
|
||||
|
||||
serializeMemberForAudit(member: GuildMember): Record<string, unknown> {
|
||||
const roleIds = Array.from(member.roleIds)
|
||||
.map((roleId) => roleId.toString())
|
||||
.sort();
|
||||
return {
|
||||
user_id: member.userId.toString(),
|
||||
nick: member.nickname,
|
||||
roles: roleIds,
|
||||
avatar_hash: member.avatarHash ?? null,
|
||||
banner_hash: member.bannerHash ?? null,
|
||||
bio: member.bio ?? null,
|
||||
pronouns: member.pronouns ?? null,
|
||||
accent_color: member.accentColor ?? null,
|
||||
deaf: member.isDeaf,
|
||||
mute: member.isMute,
|
||||
communication_disabled_until: member.communicationDisabledUntil
|
||||
? member.communicationDisabledUntil.toISOString()
|
||||
: null,
|
||||
temporary: member.isTemporary,
|
||||
};
|
||||
}
|
||||
|
||||
async recordAuditLog(params: {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
action: AuditLogActionType;
|
||||
targetId?: UserID | string | null;
|
||||
auditLogReason?: string | null;
|
||||
metadata?: Map<string, string> | Record<string, string>;
|
||||
changes?: GuildAuditLogChange | null;
|
||||
}): Promise<void> {
|
||||
const targetId =
|
||||
params.targetId === undefined || params.targetId === null
|
||||
? null
|
||||
: typeof params.targetId === 'string'
|
||||
? params.targetId
|
||||
: params.targetId.toString();
|
||||
const changes = params.action === AuditLogActionType.MEMBER_KICK ? null : (params.changes ?? null);
|
||||
|
||||
try {
|
||||
const builder = this.guildAuditLogService
|
||||
.createBuilder(params.guildId, params.userId)
|
||||
.withAction(params.action, targetId)
|
||||
.withReason(params.auditLogReason ?? null);
|
||||
|
||||
if (params.metadata) {
|
||||
builder.withMetadata(params.metadata);
|
||||
}
|
||||
if (changes) {
|
||||
builder.withChanges(changes);
|
||||
}
|
||||
|
||||
await builder.commit();
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{
|
||||
error,
|
||||
guildId: params.guildId.toString(),
|
||||
userId: params.userId.toString(),
|
||||
action: params.action,
|
||||
targetId,
|
||||
},
|
||||
'Failed to record guild audit log',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, RoleID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
|
||||
interface GuildAuth {
|
||||
guildData: GuildResponse;
|
||||
checkPermission: (permission: bigint) => Promise<void>;
|
||||
checkTargetMember: (targetUserId: UserID) => Promise<void>;
|
||||
getMyPermissions: () => Promise<bigint>;
|
||||
hasPermission: (permission: bigint) => Promise<boolean>;
|
||||
canManageRoles: (targetUserId: UserID, targetRoleId: RoleID) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export class GuildMemberAuthService {
|
||||
constructor(private readonly gatewayService: IGatewayService) {}
|
||||
|
||||
async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
|
||||
const guildData = await this.gatewayService.getGuildData({guildId, userId});
|
||||
if (!guildData) throw new MissingAccessError();
|
||||
|
||||
const checkPermission = async (permission: bigint) => {
|
||||
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
|
||||
if (!hasPermission) throw new MissingPermissionsError();
|
||||
};
|
||||
|
||||
const checkTargetMember = async (targetUserId: UserID) => {
|
||||
const canManage = await this.gatewayService.checkTargetMember({guildId, userId, targetUserId});
|
||||
if (!canManage) throw new MissingPermissionsError();
|
||||
};
|
||||
|
||||
const getMyPermissions = async () => this.gatewayService.getUserPermissions({guildId, userId});
|
||||
const hasPermission = async (permission: bigint) =>
|
||||
this.gatewayService.checkPermission({guildId, userId, permission});
|
||||
const canManageRoles = async (targetUserId: UserID, targetRoleId: RoleID) =>
|
||||
this.gatewayService.canManageRoles({guildId, userId, targetUserId, roleId: targetRoleId});
|
||||
|
||||
return {
|
||||
guildData,
|
||||
checkPermission,
|
||||
checkTargetMember,
|
||||
getMyPermissions,
|
||||
hasPermission,
|
||||
canManageRoles,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {mapGuildMemberToResponse} from '@fluxer/api/src/guild/GuildModel';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {GuildMember} from '@fluxer/api/src/models/GuildMember';
|
||||
|
||||
export class GuildMemberEventService {
|
||||
constructor(
|
||||
private readonly gatewayService: IGatewayService,
|
||||
private readonly userCacheService: UserCacheService,
|
||||
) {}
|
||||
|
||||
async dispatchGuildMemberAdd({
|
||||
member,
|
||||
requestCache,
|
||||
}: {
|
||||
member: GuildMember;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
await this.gatewayService.dispatchGuild({
|
||||
guildId: member.guildId,
|
||||
event: 'GUILD_MEMBER_ADD',
|
||||
data: await mapGuildMemberToResponse(member, this.userCacheService, requestCache),
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchGuildMemberUpdate({
|
||||
guildId,
|
||||
member,
|
||||
requestCache,
|
||||
}: {
|
||||
guildId: GuildID;
|
||||
member: GuildMember;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const memberResponse = await mapGuildMemberToResponse(member, this.userCacheService, requestCache);
|
||||
await this.gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_MEMBER_UPDATE',
|
||||
data: memberResponse,
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchGuildMemberRemove({guildId, userId}: {guildId: GuildID; userId: UserID}): Promise<void> {
|
||||
await this.gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_MEMBER_REMOVE',
|
||||
data: {user: {id: userId.toString()}},
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, RoleID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {GuildMemberAuthService} from '@fluxer/api/src/guild/services/member/GuildMemberAuthService';
|
||||
import type {GuildMemberValidationService} from '@fluxer/api/src/guild/services/member/GuildMemberValidationService';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import {UnknownGuildMemberError} from '@fluxer/errors/src/domains/guild/UnknownGuildMemberError';
|
||||
|
||||
export class GuildMemberRoleService {
|
||||
constructor(
|
||||
private readonly guildRepository: IGuildRepositoryAggregate,
|
||||
private readonly gatewayService: IGatewayService,
|
||||
private readonly authService: GuildMemberAuthService,
|
||||
private readonly validationService: GuildMemberValidationService,
|
||||
) {}
|
||||
|
||||
async systemAddMemberRole(params: {targetId: UserID; guildId: GuildID; roleId: RoleID}): Promise<void> {
|
||||
const {targetId, guildId, roleId} = params;
|
||||
|
||||
const targetMember = await this.guildRepository.getMember(guildId, targetId);
|
||||
if (!targetMember) throw new UnknownGuildMemberError();
|
||||
|
||||
if (targetMember.roleIds.has(roleId)) return;
|
||||
|
||||
const updatedRoleIds = new Set(targetMember.roleIds);
|
||||
updatedRoleIds.add(roleId);
|
||||
|
||||
const updatedMemberData = {
|
||||
...targetMember.toRow(),
|
||||
role_ids: updatedRoleIds,
|
||||
temporary: targetMember.isTemporary ? false : targetMember.isTemporary,
|
||||
};
|
||||
await this.guildRepository.upsertMember(updatedMemberData);
|
||||
|
||||
getMetricsService().counter({
|
||||
name: 'fluxer.roles.assigned',
|
||||
dimensions: {
|
||||
guild_id: guildId.toString(),
|
||||
role_id: roleId.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
if (targetMember.isTemporary) {
|
||||
await this.gatewayService.removeTemporaryGuild({userId: targetId, guildId});
|
||||
}
|
||||
}
|
||||
|
||||
async addMemberRole(params: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
guildId: GuildID;
|
||||
roleId: RoleID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const {userId, targetId, guildId, roleId} = params;
|
||||
const {guildData, canManageRoles} = await this.authService.getGuildAuthenticated({userId, guildId});
|
||||
|
||||
const targetMember = await this.guildRepository.getMember(guildId, targetId);
|
||||
if (!targetMember) throw new UnknownGuildMemberError();
|
||||
|
||||
await this.validationService.validateRoleAssignment({
|
||||
guildData,
|
||||
guildId,
|
||||
userId,
|
||||
targetId,
|
||||
roleId,
|
||||
canManageRoles,
|
||||
});
|
||||
|
||||
if (targetMember.roleIds.has(roleId)) return;
|
||||
|
||||
const updatedRoleIds = new Set(targetMember.roleIds);
|
||||
updatedRoleIds.add(roleId);
|
||||
|
||||
const updatedMemberData = {
|
||||
...targetMember.toRow(),
|
||||
role_ids: updatedRoleIds,
|
||||
temporary: targetMember.isTemporary ? false : targetMember.isTemporary,
|
||||
};
|
||||
await this.guildRepository.upsertMember(updatedMemberData);
|
||||
|
||||
getMetricsService().counter({
|
||||
name: 'fluxer.roles.assigned',
|
||||
dimensions: {
|
||||
guild_id: guildId.toString(),
|
||||
role_id: roleId.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
if (targetMember.isTemporary) {
|
||||
await this.gatewayService.removeTemporaryGuild({userId: targetId, guildId});
|
||||
}
|
||||
}
|
||||
|
||||
async removeMemberRole(params: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
guildId: GuildID;
|
||||
roleId: RoleID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const {userId, targetId, guildId, roleId} = params;
|
||||
const {guildData, canManageRoles} = await this.authService.getGuildAuthenticated({userId, guildId});
|
||||
|
||||
const targetMember = await this.guildRepository.getMember(guildId, targetId);
|
||||
if (!targetMember) throw new UnknownGuildMemberError();
|
||||
|
||||
await this.validationService.validateRoleAssignment({
|
||||
guildData,
|
||||
guildId,
|
||||
userId,
|
||||
targetId,
|
||||
roleId,
|
||||
canManageRoles,
|
||||
});
|
||||
|
||||
if (!targetMember.roleIds.has(roleId)) return;
|
||||
|
||||
const updatedRoleIds = new Set(targetMember.roleIds);
|
||||
updatedRoleIds.delete(roleId);
|
||||
|
||||
const updatedMemberData = {...targetMember.toRow(), role_ids: updatedRoleIds};
|
||||
await this.guildRepository.upsertMember(updatedMemberData);
|
||||
|
||||
getMetricsService().counter({
|
||||
name: 'fluxer.roles.unassigned',
|
||||
dimensions: {
|
||||
guild_id: guildId.toString(),
|
||||
role_id: roleId.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import type {GuildMember} from '@fluxer/api/src/models/GuildMember';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import {getGuildMemberSearchService} from '@fluxer/api/src/SearchFactory';
|
||||
|
||||
export class GuildMemberSearchIndexService {
|
||||
async indexMember(member: GuildMember, user: User): Promise<void> {
|
||||
try {
|
||||
const searchService = getGuildMemberSearchService();
|
||||
if (!searchService) {
|
||||
return;
|
||||
}
|
||||
await searchService.indexMember(member, user);
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{
|
||||
guildId: member.guildId.toString(),
|
||||
userId: member.userId.toString(),
|
||||
error,
|
||||
},
|
||||
'Failed to index guild member in search',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async updateMember(member: GuildMember, user: User): Promise<void> {
|
||||
try {
|
||||
const searchService = getGuildMemberSearchService();
|
||||
if (!searchService) {
|
||||
return;
|
||||
}
|
||||
await searchService.updateMember(member, user);
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{
|
||||
guildId: member.guildId.toString(),
|
||||
userId: member.userId.toString(),
|
||||
error,
|
||||
},
|
||||
'Failed to update guild member in search index',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMember(guildId: GuildID, userId: UserID): Promise<void> {
|
||||
try {
|
||||
const searchService = getGuildMemberSearchService();
|
||||
if (!searchService) {
|
||||
return;
|
||||
}
|
||||
await searchService.deleteMember(guildId, userId);
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{
|
||||
guildId: guildId.toString(),
|
||||
userId: userId.toString(),
|
||||
error,
|
||||
},
|
||||
'Failed to delete guild member from search index',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, RoleID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {guildIdToRoleId} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {GuildMember} from '@fluxer/api/src/models/GuildMember';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {BannedFromGuildError} from '@fluxer/errors/src/domains/guild/BannedFromGuildError';
|
||||
import {IpBannedFromGuildError} from '@fluxer/errors/src/domains/guild/IpBannedFromGuildError';
|
||||
import {UnknownGuildRoleError} from '@fluxer/errors/src/domains/guild/UnknownGuildRoleError';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
|
||||
function ensureNotEveryoneRole(roleId: RoleID, guildId: GuildID, path: string): void {
|
||||
if (roleId === guildIdToRoleId(guildId)) {
|
||||
throw InputValidationError.fromCode(path, ValidationErrorCodes.INVALID_ROLE_ID);
|
||||
}
|
||||
}
|
||||
|
||||
export class GuildMemberValidationService {
|
||||
constructor(
|
||||
private readonly guildRepository: IGuildRepositoryAggregate,
|
||||
private readonly userRepository: IUserRepository,
|
||||
) {}
|
||||
|
||||
async validateAndGetRoleIds(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
guildData: GuildResponse;
|
||||
targetId: UserID;
|
||||
targetMember: GuildMember;
|
||||
newRoles: Array<RoleID>;
|
||||
hasPermission: (permission: bigint) => Promise<boolean>;
|
||||
canManageRoles: (targetUserId: UserID, targetRoleId: RoleID) => Promise<boolean>;
|
||||
}): Promise<Array<RoleID>> {
|
||||
const {userId, guildId, guildData, targetId, targetMember, newRoles, hasPermission, canManageRoles} = params;
|
||||
|
||||
for (const roleId of newRoles) {
|
||||
ensureNotEveryoneRole(roleId, guildId, 'roles');
|
||||
}
|
||||
|
||||
if (guildData && guildData.owner_id === userId.toString()) {
|
||||
const existingRoles = await this.guildRepository.listRolesByIds(newRoles, guildId);
|
||||
if (existingRoles.length !== newRoles.length) {
|
||||
throw new UnknownGuildRoleError();
|
||||
}
|
||||
return newRoles;
|
||||
}
|
||||
|
||||
if (!(await hasPermission(Permissions.MANAGE_ROLES))) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
const currentRoles = targetMember.roleIds;
|
||||
const rolesToRemove = [...currentRoles].filter((roleId) => !newRoles.includes(roleId));
|
||||
const rolesToAdd = newRoles.filter((roleId) => !currentRoles.has(roleId));
|
||||
|
||||
for (const roleId of [...rolesToAdd, ...rolesToRemove]) {
|
||||
if (roleId === guildIdToRoleId(guildId)) continue;
|
||||
if (!(await canManageRoles(targetId, roleId))) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
|
||||
const existingRoles = await this.guildRepository.listRolesByIds(newRoles, guildId);
|
||||
if (existingRoles.length !== newRoles.length) {
|
||||
throw new UnknownGuildRoleError();
|
||||
}
|
||||
|
||||
return newRoles;
|
||||
}
|
||||
|
||||
async validateRoleAssignment(params: {
|
||||
guildData: GuildResponse;
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
roleId: RoleID;
|
||||
canManageRoles: (targetUserId: UserID, targetRoleId: RoleID) => Promise<boolean>;
|
||||
}): Promise<void> {
|
||||
const {guildData, guildId, userId, targetId, roleId, canManageRoles} = params;
|
||||
|
||||
ensureNotEveryoneRole(roleId, guildId, 'role_id');
|
||||
|
||||
if (guildData && guildData.owner_id === userId.toString()) {
|
||||
const role = await this.guildRepository.getRole(roleId, guildId);
|
||||
if (!role || role.id === guildIdToRoleId(guildId)) {
|
||||
throw new UnknownGuildRoleError();
|
||||
}
|
||||
} else {
|
||||
if (!(await canManageRoles(targetId, roleId))) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async checkUserBanStatus({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<void> {
|
||||
const bans = await this.guildRepository.listBans(guildId);
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
const userIp = user?.lastActiveIp;
|
||||
|
||||
for (const ban of bans) {
|
||||
if (ban.userId === userId) {
|
||||
throw new BannedFromGuildError();
|
||||
}
|
||||
if (userIp && ban.ipAddress && ban.ipAddress === userIp) {
|
||||
throw new IpBannedFromGuildError();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount, setUserACLs} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createGuild, getGuild} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {DiscoveryCategories} from '@fluxer/constants/src/DiscoveryConstants';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {DiscoveryApplicationResponse} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
async function setGuildMemberCount(harness: ApiTestHarness, guildId: string, memberCount: number): Promise<void> {
|
||||
await createBuilder(harness, '').post(`/test/guilds/${guildId}/member-count`).body({member_count: memberCount}).execute();
|
||||
}
|
||||
|
||||
async function applyForDiscovery(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
description = 'A great community for testing discovery features',
|
||||
categoryId = DiscoveryCategories.GAMING,
|
||||
): Promise<DiscoveryApplicationResponse> {
|
||||
return createBuilder<DiscoveryApplicationResponse>(harness, token)
|
||||
.post(`/guilds/${guildId}/discovery`)
|
||||
.body({description, category_id: categoryId})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function adminApprove(
|
||||
harness: ApiTestHarness,
|
||||
adminToken: string,
|
||||
guildId: string,
|
||||
reason?: string,
|
||||
): Promise<DiscoveryApplicationResponse> {
|
||||
return createBuilder<DiscoveryApplicationResponse>(harness, `Bearer ${adminToken}`)
|
||||
.post(`/admin/discovery/applications/${guildId}/approve`)
|
||||
.body({reason})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function adminReject(
|
||||
harness: ApiTestHarness,
|
||||
adminToken: string,
|
||||
guildId: string,
|
||||
reason: string,
|
||||
): Promise<DiscoveryApplicationResponse> {
|
||||
return createBuilder<DiscoveryApplicationResponse>(harness, `Bearer ${adminToken}`)
|
||||
.post(`/admin/discovery/applications/${guildId}/reject`)
|
||||
.body({reason})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function createAdminAccount(harness: ApiTestHarness) {
|
||||
const admin = await createTestAccount(harness);
|
||||
return setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review', 'discovery:remove']);
|
||||
}
|
||||
|
||||
describe('Discovery Application Lifecycle', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('should apply for discovery and return pending application', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Discovery Test Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
const application = await applyForDiscovery(harness, owner.token, guild.id);
|
||||
|
||||
expect(application.guild_id).toBe(guild.id);
|
||||
expect(application.status).toBe('pending');
|
||||
expect(application.description).toBe('A great community for testing discovery features');
|
||||
expect(application.category_id).toBe(DiscoveryCategories.GAMING);
|
||||
expect(application.applied_at).toBeTruthy();
|
||||
expect(application.reviewed_at).toBeNull();
|
||||
expect(application.review_reason).toBeNull();
|
||||
});
|
||||
|
||||
test('should retrieve application status', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Status Test Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await applyForDiscovery(harness, owner.token, guild.id);
|
||||
|
||||
const status = await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/discovery`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(status.guild_id).toBe(guild.id);
|
||||
expect(status.status).toBe('pending');
|
||||
});
|
||||
|
||||
test('should edit pending application description', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Edit Test Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await applyForDiscovery(harness, owner.token, guild.id);
|
||||
|
||||
const updated = await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.patch(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Updated community description'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(updated.description).toBe('Updated community description');
|
||||
expect(updated.category_id).toBe(DiscoveryCategories.GAMING);
|
||||
});
|
||||
|
||||
test('should edit pending application category', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Category Edit Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await applyForDiscovery(harness, owner.token, guild.id);
|
||||
|
||||
const updated = await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.patch(`/guilds/${guild.id}/discovery`)
|
||||
.body({category_id: DiscoveryCategories.EDUCATION})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(updated.category_id).toBe(DiscoveryCategories.EDUCATION);
|
||||
});
|
||||
|
||||
test('should withdraw pending application', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Withdraw Test Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await applyForDiscovery(harness, owner.token, guild.id);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.delete(`/guilds/${guild.id}/discovery`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/discovery`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.DISCOVERY_APPLICATION_NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should complete full lifecycle: apply → approve → verify feature → withdraw', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Full Lifecycle Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
const admin = await createAdminAccount(harness);
|
||||
|
||||
const application = await applyForDiscovery(harness, owner.token, guild.id);
|
||||
expect(application.status).toBe('pending');
|
||||
|
||||
const approved = await adminApprove(harness, admin.token, guild.id, 'Meets all criteria');
|
||||
expect(approved.status).toBe('approved');
|
||||
expect(approved.reviewed_at).toBeTruthy();
|
||||
expect(approved.review_reason).toBe('Meets all criteria');
|
||||
|
||||
const guildData = await getGuild(harness, owner.token, guild.id);
|
||||
expect(guildData.features).toContain(GuildFeatures.DISCOVERABLE);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.delete(`/guilds/${guild.id}/discovery`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const guildAfterWithdraw = await getGuild(harness, owner.token, guild.id);
|
||||
expect(guildAfterWithdraw.features).not.toContain(GuildFeatures.DISCOVERABLE);
|
||||
});
|
||||
|
||||
test('should allow reapplication after rejection', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Reapply Test Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
const admin = await createAdminAccount(harness);
|
||||
|
||||
await applyForDiscovery(harness, owner.token, guild.id);
|
||||
|
||||
await adminReject(harness, admin.token, guild.id, 'Needs more detail');
|
||||
|
||||
const status = await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/discovery`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(status.status).toBe('rejected');
|
||||
|
||||
const reapplication = await applyForDiscovery(
|
||||
harness,
|
||||
owner.token,
|
||||
guild.id,
|
||||
'Improved description with more detail about the community',
|
||||
);
|
||||
expect(reapplication.status).toBe('pending');
|
||||
});
|
||||
|
||||
test('should edit approved application description', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Edit Approved Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
const admin = await createAdminAccount(harness);
|
||||
|
||||
await applyForDiscovery(harness, owner.token, guild.id);
|
||||
await adminApprove(harness, admin.token, guild.id);
|
||||
|
||||
const updated = await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.patch(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Updated description after approval'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(updated.description).toBe('Updated description after approval');
|
||||
expect(updated.status).toBe('approved');
|
||||
});
|
||||
|
||||
test('should apply with each valid category', async () => {
|
||||
const categories = [
|
||||
DiscoveryCategories.GAMING,
|
||||
DiscoveryCategories.MUSIC,
|
||||
DiscoveryCategories.ENTERTAINMENT,
|
||||
DiscoveryCategories.EDUCATION,
|
||||
DiscoveryCategories.SCIENCE_AND_TECHNOLOGY,
|
||||
DiscoveryCategories.CONTENT_CREATOR,
|
||||
DiscoveryCategories.ANIME_AND_MANGA,
|
||||
DiscoveryCategories.MOVIES_AND_TV,
|
||||
DiscoveryCategories.OTHER,
|
||||
];
|
||||
|
||||
for (const categoryId of categories) {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, `Cat ${categoryId} Guild`);
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
const application = await applyForDiscovery(
|
||||
harness,
|
||||
owner.token,
|
||||
guild.id,
|
||||
'Valid description for this category',
|
||||
categoryId,
|
||||
);
|
||||
expect(application.category_id).toBe(categoryId);
|
||||
}
|
||||
});
|
||||
|
||||
test('should allow applying with minimum description length', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Min Desc Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
const application = await applyForDiscovery(harness, owner.token, guild.id, 'Short desc');
|
||||
expect(application.description).toBe('Short desc');
|
||||
});
|
||||
|
||||
test('should allow applying with maximum description length', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Max Desc Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
const maxDescription = 'A'.repeat(300);
|
||||
const application = await applyForDiscovery(harness, owner.token, guild.id, maxDescription);
|
||||
expect(application.description).toBe(maxDescription);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount, setUserACLs} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createGuild, setupTestGuildWithMembers} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS, TEST_IDS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {DiscoveryCategories} from '@fluxer/constants/src/DiscoveryConstants';
|
||||
import type {DiscoveryApplicationResponse} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
async function setGuildMemberCount(harness: ApiTestHarness, guildId: string, memberCount: number): Promise<void> {
|
||||
await createBuilder(harness, '').post(`/test/guilds/${guildId}/member-count`).body({member_count: memberCount}).execute();
|
||||
}
|
||||
|
||||
describe('Discovery Application Validation', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
describe('member count requirements', () => {
|
||||
test('should allow application with 1 member in test mode', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Small Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
const application = await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Small but active community', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(application.status).toBe('pending');
|
||||
});
|
||||
|
||||
test('should reject application with 0 members', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Empty Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 0);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'No members yet', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.DISCOVERY_INSUFFICIENT_MEMBERS)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('category validation', () => {
|
||||
test('should reject invalid category ID above range', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Bad Category Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Valid description here', category_id: 99})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject negative category ID', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Negative Cat Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Valid description here', category_id: -1})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject invalid category on edit', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Edit Bad Cat Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Valid description here', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.patch(`/guilds/${guild.id}/discovery`)
|
||||
.body({category_id: 99})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('description validation', () => {
|
||||
test('should reject description shorter than minimum length', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Short Desc Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Too short', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject description longer than maximum length', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Long Desc Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'A'.repeat(301), category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject missing description', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'No Desc Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject missing category_id', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'No Cat Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Valid description here'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('duplicate application', () => {
|
||||
test('should reject duplicate application when pending', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Dupe Pending Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'First application attempt', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Second application attempt', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.CONFLICT, APIErrorCodes.DISCOVERY_ALREADY_APPLIED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject duplicate application when approved', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Dupe Approved Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
|
||||
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Application to be approved', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post(`/admin/discovery/applications/${guild.id}/approve`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Trying to reapply while approved', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.CONFLICT, APIErrorCodes.DISCOVERY_ALREADY_APPLIED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission requirements', () => {
|
||||
test('should require MANAGE_GUILD permission to apply', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Should not be allowed', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require MANAGE_GUILD permission to edit', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Owner applied for discovery', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Member tries to edit'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require MANAGE_GUILD permission to withdraw', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Owner applied for discovery', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.delete(`/guilds/${guild.id}/discovery`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require MANAGE_GUILD permission to get status', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.get(`/guilds/${guild.id}/discovery`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('authentication requirements', () => {
|
||||
test('should require login to apply', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/guilds/${TEST_IDS.NONEXISTENT_GUILD}/discovery`)
|
||||
.body({description: 'No auth attempt', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require login to get status', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.get(`/guilds/${TEST_IDS.NONEXISTENT_GUILD}/discovery`)
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require login to edit', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.patch(`/guilds/${TEST_IDS.NONEXISTENT_GUILD}/discovery`)
|
||||
.body({description: 'No auth edit'})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require login to withdraw', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.delete(`/guilds/${TEST_IDS.NONEXISTENT_GUILD}/discovery`)
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-existent application', () => {
|
||||
test('should return error when getting status with no application', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'No App Guild');
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/discovery`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.DISCOVERY_APPLICATION_NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should return error when editing non-existent application', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'No Edit App Guild');
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.patch(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Editing nothing'})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.DISCOVERY_APPLICATION_NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should return error when withdrawing non-existent application', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'No Withdraw App Guild');
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.delete(`/guilds/${guild.id}/discovery`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.DISCOVERY_APPLICATION_NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit restrictions', () => {
|
||||
test('should not allow editing rejected application', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Rejected Edit Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
|
||||
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'To be rejected for edit test', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post(`/admin/discovery/applications/${guild.id}/reject`)
|
||||
.body({reason: 'Not suitable'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.patch(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Trying to edit rejected'})
|
||||
.expect(HTTP_STATUS.CONFLICT, APIErrorCodes.DISCOVERY_APPLICATION_ALREADY_REVIEWED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
295
packages/api/src/guild/tests/DiscoverySearchAndJoin.test.tsx
Normal file
295
packages/api/src/guild/tests/DiscoverySearchAndJoin.test.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount, setUserACLs} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createGuild, getUserGuilds} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS, TEST_IDS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {DiscoveryCategories, DiscoveryCategoryLabels} from '@fluxer/constants/src/DiscoveryConstants';
|
||||
import type {
|
||||
DiscoveryApplicationResponse,
|
||||
DiscoveryCategoryResponse,
|
||||
DiscoveryGuildListResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
async function setGuildMemberCount(harness: ApiTestHarness, guildId: string, memberCount: number): Promise<void> {
|
||||
await createBuilder(harness, '').post(`/test/guilds/${guildId}/member-count`).body({member_count: memberCount}).execute();
|
||||
}
|
||||
|
||||
async function applyAndApprove(
|
||||
harness: ApiTestHarness,
|
||||
ownerToken: string,
|
||||
adminToken: string,
|
||||
guildId: string,
|
||||
description: string,
|
||||
categoryId: number,
|
||||
): Promise<void> {
|
||||
await createBuilder<DiscoveryApplicationResponse>(harness, ownerToken)
|
||||
.post(`/guilds/${guildId}/discovery`)
|
||||
.body({description, category_id: categoryId})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, `Bearer ${adminToken}`)
|
||||
.post(`/admin/discovery/applications/${guildId}/approve`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
describe('Discovery Search and Join', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
describe('categories', () => {
|
||||
test('should list all discovery categories', async () => {
|
||||
const user = await createTestAccount(harness);
|
||||
|
||||
const categories = await createBuilder<Array<DiscoveryCategoryResponse>>(harness, user.token)
|
||||
.get('/discovery/categories')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const expectedCount = Object.keys(DiscoveryCategoryLabels).length;
|
||||
expect(categories).toHaveLength(expectedCount);
|
||||
|
||||
for (const category of categories) {
|
||||
expect(category.id).toBeTypeOf('number');
|
||||
expect(category.name).toBeTypeOf('string');
|
||||
expect(category.name.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('should include known categories', async () => {
|
||||
const user = await createTestAccount(harness);
|
||||
|
||||
const categories = await createBuilder<Array<DiscoveryCategoryResponse>>(harness, user.token)
|
||||
.get('/discovery/categories')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const names = categories.map((c) => c.name);
|
||||
expect(names).toContain('Gaming');
|
||||
expect(names).toContain('Music');
|
||||
expect(names).toContain('Education');
|
||||
expect(names).toContain('Science & Technology');
|
||||
});
|
||||
|
||||
test('should require login to list categories', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.get('/discovery/categories')
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
test('should return empty results when no guilds are discoverable', async () => {
|
||||
const user = await createTestAccount(harness);
|
||||
|
||||
const results = await createBuilder<DiscoveryGuildListResponse>(harness, user.token)
|
||||
.get('/discovery/guilds')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(results.guilds).toHaveLength(0);
|
||||
expect(results.total).toBe(0);
|
||||
});
|
||||
|
||||
test('should return approved guilds in search results', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Searchable Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 10);
|
||||
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
|
||||
await applyAndApprove(
|
||||
harness,
|
||||
owner.token,
|
||||
admin.token,
|
||||
guild.id,
|
||||
'A searchable community for all',
|
||||
DiscoveryCategories.GAMING,
|
||||
);
|
||||
|
||||
const searcher = await createTestAccount(harness);
|
||||
const results = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get('/discovery/guilds')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(results.guilds.length).toBeGreaterThanOrEqual(1);
|
||||
const found = results.guilds.find((g) => g.id === guild.id);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.name).toBe('Searchable Guild');
|
||||
expect(found!.description).toBe('A searchable community for all');
|
||||
expect(found!.category_id).toBe(DiscoveryCategories.GAMING);
|
||||
});
|
||||
|
||||
test('should not return pending guilds in search results', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Pending Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Pending application guild', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const searcher = await createTestAccount(harness);
|
||||
const results = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get('/discovery/guilds')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const found = results.guilds.find((g) => g.id === guild.id);
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should filter by category', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
|
||||
const owner1 = await createTestAccount(harness);
|
||||
const gamingGuild = await createGuild(harness, owner1.token, 'Gaming Community');
|
||||
await setGuildMemberCount(harness, gamingGuild.id, 10);
|
||||
await applyAndApprove(harness, owner1.token, admin.token, gamingGuild.id, 'All about gaming', DiscoveryCategories.GAMING);
|
||||
|
||||
const owner2 = await createTestAccount(harness);
|
||||
const musicGuild = await createGuild(harness, owner2.token, 'Music Community');
|
||||
await setGuildMemberCount(harness, musicGuild.id, 10);
|
||||
await applyAndApprove(harness, owner2.token, admin.token, musicGuild.id, 'All about music', DiscoveryCategories.MUSIC);
|
||||
|
||||
const searcher = await createTestAccount(harness);
|
||||
const results = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get(`/discovery/guilds?category=${DiscoveryCategories.GAMING}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
for (const guild of results.guilds) {
|
||||
expect(guild.category_id).toBe(DiscoveryCategories.GAMING);
|
||||
}
|
||||
});
|
||||
|
||||
test('should respect limit parameter', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, `Limit Test Guild ${i}`);
|
||||
await setGuildMemberCount(harness, guild.id, 10);
|
||||
await applyAndApprove(harness, owner.token, admin.token, guild.id, `Community number ${i} for testing`, DiscoveryCategories.GAMING);
|
||||
}
|
||||
|
||||
const searcher = await createTestAccount(harness);
|
||||
const results = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get('/discovery/guilds?limit=2')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(results.guilds.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('should require login to search', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.get('/discovery/guilds')
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('join', () => {
|
||||
test('should join a discoverable guild', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Joinable Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 10);
|
||||
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
await applyAndApprove(harness, owner.token, admin.token, guild.id, 'Join this community', DiscoveryCategories.GAMING);
|
||||
|
||||
const joiner = await createTestAccount(harness);
|
||||
await createBuilder(harness, joiner.token)
|
||||
.post(`/discovery/guilds/${guild.id}/join`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const guilds = await getUserGuilds(harness, joiner.token);
|
||||
const joined = guilds.find((g) => g.id === guild.id);
|
||||
expect(joined).toBeDefined();
|
||||
});
|
||||
|
||||
test('should not allow joining non-discoverable guild', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Not Discoverable');
|
||||
|
||||
const joiner = await createTestAccount(harness);
|
||||
await createBuilder(harness, joiner.token)
|
||||
.post(`/discovery/guilds/${guild.id}/join`)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.DISCOVERY_NOT_DISCOVERABLE)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should not allow joining guild with only pending application', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Pending Join Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Pending but not yet approved', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const joiner = await createTestAccount(harness);
|
||||
await createBuilder(harness, joiner.token)
|
||||
.post(`/discovery/guilds/${guild.id}/join`)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.DISCOVERY_NOT_DISCOVERABLE)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should not allow joining with nonexistent guild ID', async () => {
|
||||
const joiner = await createTestAccount(harness);
|
||||
await createBuilder(harness, joiner.token)
|
||||
.post(`/discovery/guilds/${TEST_IDS.NONEXISTENT_GUILD}/join`)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.DISCOVERY_NOT_DISCOVERABLE)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require login to join', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/discovery/guilds/${TEST_IDS.NONEXISTENT_GUILD}/join`)
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
208
packages/api/src/guild/tests/GuildAssetUpload.test.tsx
Normal file
208
packages/api/src/guild/tests/GuildAssetUpload.test.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {getGifDataUrl, getPngDataUrl} from '@fluxer/api/src/emoji/tests/EmojiTestUtils';
|
||||
import {createGuild, updateGuild} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const AVATAR_MAX_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
function getTooLargeImageDataUrl(): string {
|
||||
const largeData = 'A'.repeat(AVATAR_MAX_SIZE + 10000);
|
||||
const base64 = Buffer.from(largeData).toString('base64');
|
||||
return getPngDataUrl(base64);
|
||||
}
|
||||
|
||||
async function grantGuildFeature(harness: ApiTestHarness, guildId: string, feature: string): Promise<void> {
|
||||
await createBuilder<{success: boolean}>(harness, '')
|
||||
.post(`/test/guilds/${guildId}/features`)
|
||||
.body({add_features: [feature]})
|
||||
.execute();
|
||||
}
|
||||
|
||||
describe('Guild Asset Upload', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
describe('Guild Icon', () => {
|
||||
it('allows uploading valid GIF icon', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
const guild = await createGuild(harness, account.token, 'Icon GIF Test Guild');
|
||||
|
||||
const updated = await updateGuild(harness, account.token, guild.id, {
|
||||
icon: getGifDataUrl(),
|
||||
});
|
||||
|
||||
expect(updated.icon).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects icon that exceeds size limit', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
const guild = await createGuild(harness, account.token, 'Icon Size Limit Test');
|
||||
|
||||
const json = await createBuilder<{errors?: Array<{path?: string; code?: string}>}>(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({icon: getTooLargeImageDataUrl()})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
|
||||
expect(json.errors?.[0]?.code).toBe('BASE64_LENGTH_INVALID');
|
||||
});
|
||||
|
||||
it('allows clearing icon by setting to null', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
const guild = await createGuild(harness, account.token, 'Icon Clear Test');
|
||||
|
||||
await updateGuild(harness, account.token, guild.id, {
|
||||
icon: getPngDataUrl(),
|
||||
});
|
||||
|
||||
const cleared = await updateGuild(harness, account.token, guild.id, {
|
||||
icon: null,
|
||||
});
|
||||
|
||||
expect(cleared.icon).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Guild Banner', () => {
|
||||
it('allows banner upload with BANNER feature', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
const guild = await createGuild(harness, account.token, 'Banner Feature Test');
|
||||
|
||||
await grantGuildFeature(harness, guild.id, 'BANNER');
|
||||
|
||||
const updated = await updateGuild(harness, account.token, guild.id, {
|
||||
banner: getPngDataUrl(),
|
||||
});
|
||||
|
||||
expect(updated.banner).toBeTruthy();
|
||||
});
|
||||
|
||||
it('allows animated banner with BANNER and ANIMATED_BANNER features', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
const guild = await createGuild(harness, account.token, 'Animated Banner Test');
|
||||
|
||||
await grantGuildFeature(harness, guild.id, 'BANNER');
|
||||
await grantGuildFeature(harness, guild.id, 'ANIMATED_BANNER');
|
||||
|
||||
const updated = await updateGuild(harness, account.token, guild.id, {
|
||||
banner: getGifDataUrl(),
|
||||
});
|
||||
|
||||
expect(updated.banner).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects banner that exceeds size limit', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
const guild = await createGuild(harness, account.token, 'Banner Size Test');
|
||||
|
||||
await grantGuildFeature(harness, guild.id, 'BANNER');
|
||||
|
||||
const json = await createBuilder<{errors?: Array<{path?: string; code?: string}>}>(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({banner: getTooLargeImageDataUrl()})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
|
||||
expect(json.errors?.[0]?.code).toBe('BASE64_LENGTH_INVALID');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Guild Splash', () => {
|
||||
it('allows splash upload with INVITE_SPLASH feature', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
const guild = await createGuild(harness, account.token, 'Splash Feature Test');
|
||||
|
||||
await grantGuildFeature(harness, guild.id, 'INVITE_SPLASH');
|
||||
|
||||
const updated = await updateGuild(harness, account.token, guild.id, {
|
||||
splash: getPngDataUrl(),
|
||||
});
|
||||
|
||||
expect(updated.splash).toBeTruthy();
|
||||
});
|
||||
|
||||
it('allows clearing splash by setting to null', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
const guild = await createGuild(harness, account.token, 'Splash Clear Test');
|
||||
|
||||
await grantGuildFeature(harness, guild.id, 'INVITE_SPLASH');
|
||||
|
||||
await updateGuild(harness, account.token, guild.id, {
|
||||
splash: getPngDataUrl(),
|
||||
});
|
||||
|
||||
const cleared = await updateGuild(harness, account.token, guild.id, {
|
||||
splash: null,
|
||||
});
|
||||
|
||||
expect(cleared.splash).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects splash that exceeds size limit', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
const guild = await createGuild(harness, account.token, 'Splash Size Test');
|
||||
|
||||
await grantGuildFeature(harness, guild.id, 'INVITE_SPLASH');
|
||||
|
||||
const json = await createBuilder<{errors?: Array<{path?: string; code?: string}>}>(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({splash: getTooLargeImageDataUrl()})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
|
||||
expect(json.errors?.[0]?.code).toBe('BASE64_LENGTH_INVALID');
|
||||
});
|
||||
});
|
||||
});
|
||||
249
packages/api/src/guild/tests/GuildAuditLogs.test.tsx
Normal file
249
packages/api/src/guild/tests/GuildAuditLogs.test.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
createChannel,
|
||||
createChannelInvite,
|
||||
createRole,
|
||||
setupTestGuildWithMembers,
|
||||
updateRole,
|
||||
} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {createWebhook} from '@fluxer/api/src/webhook/tests/WebhookTestUtils';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
interface AuditLogChange {
|
||||
key: string;
|
||||
old_value?: unknown;
|
||||
new_value?: unknown;
|
||||
}
|
||||
|
||||
interface AuditLogOptions {
|
||||
channel_id?: string;
|
||||
max_age?: number;
|
||||
max_uses?: number;
|
||||
temporary?: boolean;
|
||||
}
|
||||
|
||||
interface AuditLogEntry {
|
||||
id: string;
|
||||
action_type: number;
|
||||
user_id: string | null;
|
||||
target_id: string | null;
|
||||
reason?: string;
|
||||
options?: AuditLogOptions;
|
||||
changes?: Array<AuditLogChange>;
|
||||
}
|
||||
|
||||
interface AuditLogWebhook {
|
||||
id: string;
|
||||
type: number;
|
||||
guild_id: string | null;
|
||||
channel_id: string | null;
|
||||
name: string;
|
||||
avatar_hash: string | null;
|
||||
}
|
||||
|
||||
interface AuditLogResponse {
|
||||
audit_log_entries: Array<AuditLogEntry>;
|
||||
users: Array<{id: string}>;
|
||||
webhooks: Array<AuditLogWebhook>;
|
||||
}
|
||||
|
||||
interface PermissionsDiff {
|
||||
added: Array<string>;
|
||||
removed: Array<string>;
|
||||
}
|
||||
|
||||
describe('Guild audit log endpoint', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('rejects unauthenticated requests', async () => {
|
||||
const {guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.get(`/guilds/${guild.id}/audit-logs`)
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('requires view audit log permission', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await updateRole(harness, owner.token, guild.id, guild.id, {
|
||||
permissions: Permissions.VIEW_CHANNEL.toString(),
|
||||
});
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.get(`/guilds/${guild.id}/audit-logs`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('includes permissions diff entries for role updates', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
|
||||
const role = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Audit Role',
|
||||
permissions: Permissions.VIEW_CHANNEL.toString(),
|
||||
});
|
||||
|
||||
await updateRole(harness, owner.token, guild.id, role.id, {
|
||||
permissions: (Permissions.VIEW_CHANNEL | Permissions.SEND_MESSAGES).toString(),
|
||||
});
|
||||
|
||||
const response = await createBuilder<AuditLogResponse>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/audit-logs?action_type=${AuditLogActionType.ROLE_UPDATE}`)
|
||||
.execute();
|
||||
|
||||
const entry = response.audit_log_entries.find((log) => log.target_id === role.id);
|
||||
expect(entry).toBeDefined();
|
||||
|
||||
const diffChange = entry?.changes?.find((change) => change.key === 'permissions_diff');
|
||||
expect(diffChange).toBeDefined();
|
||||
|
||||
const permissionsDiff = diffChange?.new_value as PermissionsDiff | undefined;
|
||||
expect(permissionsDiff).toBeDefined();
|
||||
expect(permissionsDiff?.added).toContain('SEND_MESSAGES');
|
||||
expect(permissionsDiff?.removed).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns webhook references for webhook audit log entries', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const channel = await createChannel(harness, owner.token, guild.id, 'hooks');
|
||||
const webhook = await createWebhook(harness, channel.id, owner.token, 'Audit Webhook');
|
||||
|
||||
const response = await createBuilder<AuditLogResponse>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/audit-logs?action_type=${AuditLogActionType.WEBHOOK_CREATE}`)
|
||||
.execute();
|
||||
|
||||
const entry = response.audit_log_entries.find((log) => log.target_id === webhook.id);
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.options?.channel_id).toBe(channel.id);
|
||||
|
||||
const webhookEntry = response.webhooks.find((record) => record.id === webhook.id);
|
||||
expect(webhookEntry).toBeDefined();
|
||||
expect(webhookEntry?.channel_id).toBe(channel.id);
|
||||
expect(webhookEntry?.guild_id).toBe(guild.id);
|
||||
});
|
||||
|
||||
test('maps invite metadata into audit log options', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const channel = await createChannel(harness, owner.token, guild.id, 'invites');
|
||||
|
||||
await createChannelInvite(harness, owner.token, channel.id);
|
||||
|
||||
const response = await createBuilder<AuditLogResponse>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/audit-logs?action_type=${AuditLogActionType.INVITE_CREATE}`)
|
||||
.execute();
|
||||
|
||||
const entry = response.audit_log_entries.find((log) => log.options?.channel_id === channel.id);
|
||||
expect(entry).toBeDefined();
|
||||
expect(typeof entry?.options?.max_age).toBe('number');
|
||||
expect(typeof entry?.options?.max_uses).toBe('number');
|
||||
expect(typeof entry?.options?.temporary).toBe('boolean');
|
||||
});
|
||||
|
||||
test('filters audit logs by user and action type', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Owner Role',
|
||||
permissions: Permissions.VIEW_CHANNEL.toString(),
|
||||
});
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({nick: 'Audit Nick'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const responseByUser = await createBuilder<AuditLogResponse>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/audit-logs?user_id=${member.userId}`)
|
||||
.execute();
|
||||
|
||||
expect(responseByUser.audit_log_entries.length).toBeGreaterThan(0);
|
||||
for (const entry of responseByUser.audit_log_entries) {
|
||||
expect(entry.user_id).toBe(member.userId);
|
||||
}
|
||||
|
||||
const responseByAction = await createBuilder<AuditLogResponse>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/audit-logs?action_type=${AuditLogActionType.ROLE_CREATE}`)
|
||||
.execute();
|
||||
|
||||
expect(responseByAction.audit_log_entries.length).toBeGreaterThan(0);
|
||||
for (const entry of responseByAction.audit_log_entries) {
|
||||
expect(entry.action_type).toBe(AuditLogActionType.ROLE_CREATE);
|
||||
}
|
||||
});
|
||||
|
||||
test('includes target users for user-target audit log entries', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.put(`/guilds/${guild.id}/bans/${member.userId}`)
|
||||
.body({reason: 'Audit log user list check'})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const response = await createBuilder<AuditLogResponse>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/audit-logs?action_type=${AuditLogActionType.MEMBER_BAN_ADD}`)
|
||||
.execute();
|
||||
|
||||
const userIds = response.users.map((user) => user.id);
|
||||
expect(userIds).toContain(owner.userId);
|
||||
expect(userIds).toContain(member.userId);
|
||||
});
|
||||
|
||||
test('rejects specifying before and after together', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Pagination Role',
|
||||
permissions: Permissions.VIEW_CHANNEL.toString(),
|
||||
});
|
||||
|
||||
const response = await createBuilder<AuditLogResponse>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/audit-logs?action_type=${AuditLogActionType.ROLE_CREATE}`)
|
||||
.execute();
|
||||
|
||||
const logId = response.audit_log_entries[0]?.id;
|
||||
expect(logId).toBeDefined();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/audit-logs?before=${logId}&after=${logId}`)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
659
packages/api/src/guild/tests/GuildChannelManagement.test.tsx
Normal file
659
packages/api/src/guild/tests/GuildChannelManagement.test.tsx
Normal file
@@ -0,0 +1,659 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createChannel,
|
||||
createRole,
|
||||
getChannel,
|
||||
setupTestGuildWithMembers,
|
||||
updateChannel,
|
||||
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {createGuild, getGuildChannels} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Guild Channel Management', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
describe('Channel Name Updates', () => {
|
||||
test('should normalize channel name with spaces to hyphens', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const updated = await updateChannel(harness, account.token, channel.id, {name: 'my new channel'});
|
||||
|
||||
expect(updated.name).toBe('my-new-channel');
|
||||
});
|
||||
|
||||
test('should reject channel name exceeding maximum length', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const longName = 'a'.repeat(101);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/channels/${channel.id}`)
|
||||
.body({name: longName, type: ChannelTypes.GUILD_TEXT})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should preserve channel name when update name is empty', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const updated = await updateChannel(harness, account.token, channel.id, {name: ''});
|
||||
|
||||
expect(updated.name).toBe('test-channel');
|
||||
});
|
||||
|
||||
test('should convert channel name to lowercase', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const updated = await updateChannel(harness, account.token, channel.id, {name: 'MyChannel'});
|
||||
|
||||
expect(updated.name).toBe('mychannel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Channel Topic Updates', () => {
|
||||
test('should allow clearing channel topic', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
await updateChannel(harness, account.token, channel.id, {topic: 'Initial topic'});
|
||||
const updated = await updateChannel(harness, account.token, channel.id, {topic: null});
|
||||
|
||||
expect(updated.topic).toBeNull();
|
||||
});
|
||||
|
||||
test('should reject channel topic exceeding maximum length', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const longTopic = 'a'.repeat(1025);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/channels/${channel.id}`)
|
||||
.body({topic: longTopic, type: ChannelTypes.GUILD_TEXT})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should accept topic at maximum length', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const maxTopic = 'a'.repeat(1024);
|
||||
const updated = await updateChannel(harness, account.token, channel.id, {topic: maxTopic});
|
||||
|
||||
expect(updated.topic).toBe(maxTopic);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Channel Slowmode (rate_limit_per_user)', () => {
|
||||
test('should set slowmode on text channel', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const updated = await updateChannel(harness, account.token, channel.id, {rate_limit_per_user: 60});
|
||||
|
||||
expect(updated.rate_limit_per_user).toBe(60);
|
||||
});
|
||||
|
||||
test('should disable slowmode by setting to zero', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
await updateChannel(harness, account.token, channel.id, {rate_limit_per_user: 60});
|
||||
const updated = await updateChannel(harness, account.token, channel.id, {rate_limit_per_user: 0});
|
||||
|
||||
expect(updated.rate_limit_per_user).toBe(0);
|
||||
});
|
||||
|
||||
test('should reject negative slowmode value', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/channels/${channel.id}`)
|
||||
.body({rate_limit_per_user: -1, type: ChannelTypes.GUILD_TEXT})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject slowmode exceeding maximum (21600 seconds)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/channels/${channel.id}`)
|
||||
.body({rate_limit_per_user: 21601, type: ChannelTypes.GUILD_TEXT})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should accept maximum slowmode value (21600 seconds)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const updated = await updateChannel(harness, account.token, channel.id, {rate_limit_per_user: 21600});
|
||||
|
||||
expect(updated.rate_limit_per_user).toBe(21600);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Channel Position Updates', () => {
|
||||
test('should update single channel position via direct PATCH', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
await createChannel(harness, account.token, guild.id, 'channel-1');
|
||||
const channel2 = await createChannel(harness, account.token, guild.id, 'channel-2');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}/channels`)
|
||||
.body([{id: channel2.id, position: 0}])
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const channels = await getGuildChannels(harness, account.token, guild.id);
|
||||
const updatedChannel2 = channels.find((c) => c.id === channel2.id);
|
||||
expect(updatedChannel2).toBeDefined();
|
||||
});
|
||||
|
||||
test('should update multiple channel positions in bulk via direct PATCH', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const channel1 = await createChannel(harness, account.token, guild.id, 'channel-1');
|
||||
const channel2 = await createChannel(harness, account.token, guild.id, 'channel-2');
|
||||
const channel3 = await createChannel(harness, account.token, guild.id, 'channel-3');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}/channels`)
|
||||
.body([
|
||||
{id: channel3.id, position: 0},
|
||||
{id: channel2.id, position: 1},
|
||||
{id: channel1.id, position: 2},
|
||||
])
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const channels = await getGuildChannels(harness, account.token, guild.id);
|
||||
const textChannels = channels.filter((c) => c.type === ChannelTypes.GUILD_TEXT);
|
||||
|
||||
expect(textChannels.some((c) => c.id === channel1.id)).toBe(true);
|
||||
expect(textChannels.some((c) => c.id === channel2.id)).toBe(true);
|
||||
expect(textChannels.some((c) => c.id === channel3.id)).toBe(true);
|
||||
});
|
||||
|
||||
test('should move channel into category via direct PATCH', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const category = await createChannel(harness, account.token, guild.id, 'Category', ChannelTypes.GUILD_CATEGORY);
|
||||
const textChannel = await createChannel(harness, account.token, guild.id, 'text-channel');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}/channels`)
|
||||
.body([{id: textChannel.id, parent_id: category.id}])
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const updatedChannel = await getChannel(harness, account.token, textChannel.id);
|
||||
expect(updatedChannel.parent_id).toBe(category.id);
|
||||
});
|
||||
|
||||
test('should move channel out of category via direct PATCH', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const category = await createChannel(harness, account.token, guild.id, 'Category', ChannelTypes.GUILD_CATEGORY);
|
||||
const textChannel = await createChannel(harness, account.token, guild.id, 'text-channel');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}/channels`)
|
||||
.body([{id: textChannel.id, parent_id: category.id}])
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
let updatedChannel = await getChannel(harness, account.token, textChannel.id);
|
||||
expect(updatedChannel.parent_id).toBe(category.id);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}/channels`)
|
||||
.body([{id: textChannel.id, parent_id: null}])
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
updatedChannel = await getChannel(harness, account.token, textChannel.id);
|
||||
expect(updatedChannel.parent_id).toBeNull();
|
||||
});
|
||||
|
||||
test('should lock permissions when moving to category via direct PATCH', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const category = await createChannel(harness, account.token, guild.id, 'Category', ChannelTypes.GUILD_CATEGORY);
|
||||
const textChannel = await createChannel(harness, account.token, guild.id, 'text-channel');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}/channels`)
|
||||
.body([{id: textChannel.id, parent_id: category.id, lock_permissions: true}])
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const updatedChannel = await getChannel(harness, account.token, textChannel.id);
|
||||
expect(updatedChannel.parent_id).toBe(category.id);
|
||||
});
|
||||
|
||||
test('should reject or forbid invalid channel id in position update', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}/channels`)
|
||||
.body([{id: '999999999999999999', position: 0}])
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Channel Permission Overwrites Operations', () => {
|
||||
test('should create permission overwrite for role', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
const role = await createRole(harness, account.token, guild.id, {name: 'Test Role'});
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.put(`/channels/${channel.id}/permissions/${role.id}`)
|
||||
.body({
|
||||
type: 0,
|
||||
allow: Permissions.SEND_MESSAGES.toString(),
|
||||
deny: '0',
|
||||
})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const channelData = await getChannel(harness, account.token, channel.id);
|
||||
const overwrite = channelData.permission_overwrites?.find((o) => o.id === role.id);
|
||||
expect(overwrite).toBeDefined();
|
||||
expect(overwrite?.type).toBe(0);
|
||||
});
|
||||
|
||||
test('should create permission overwrite for member', async () => {
|
||||
const {owner, members, systemChannel} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.put(`/channels/${systemChannel.id}/permissions/${member.userId}`)
|
||||
.body({
|
||||
type: 1,
|
||||
allow: Permissions.VIEW_CHANNEL.toString(),
|
||||
deny: Permissions.SEND_MESSAGES.toString(),
|
||||
})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const channelData = await getChannel(harness, owner.token, systemChannel.id);
|
||||
const overwrite = channelData.permission_overwrites?.find((o) => o.id === member.userId);
|
||||
expect(overwrite).toBeDefined();
|
||||
expect(overwrite?.type).toBe(1);
|
||||
});
|
||||
|
||||
test('should update existing permission overwrite', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
const role = await createRole(harness, account.token, guild.id, {name: 'Test Role'});
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.put(`/channels/${channel.id}/permissions/${role.id}`)
|
||||
.body({
|
||||
type: 0,
|
||||
allow: Permissions.SEND_MESSAGES.toString(),
|
||||
deny: '0',
|
||||
})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.put(`/channels/${channel.id}/permissions/${role.id}`)
|
||||
.body({
|
||||
type: 0,
|
||||
allow: (Permissions.SEND_MESSAGES | Permissions.EMBED_LINKS).toString(),
|
||||
deny: '0',
|
||||
})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const channelData = await getChannel(harness, account.token, channel.id);
|
||||
const overwrite = channelData.permission_overwrites?.find((o) => o.id === role.id);
|
||||
expect(BigInt(overwrite!.allow)).toBe(Permissions.SEND_MESSAGES | Permissions.EMBED_LINKS);
|
||||
});
|
||||
|
||||
test('should delete permission overwrite', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
const role = await createRole(harness, account.token, guild.id, {name: 'Test Role'});
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.put(`/channels/${channel.id}/permissions/${role.id}`)
|
||||
.body({
|
||||
type: 0,
|
||||
allow: Permissions.SEND_MESSAGES.toString(),
|
||||
deny: '0',
|
||||
})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.delete(`/channels/${channel.id}/permissions/${role.id}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const channelData = await getChannel(harness, account.token, channel.id);
|
||||
const overwrite = channelData.permission_overwrites?.find((o) => o.id === role.id);
|
||||
expect(overwrite).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should show overwrites in channel response', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
const role = await createRole(harness, account.token, guild.id, {name: 'Test Role'});
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.put(`/channels/${channel.id}/permissions/${role.id}`)
|
||||
.body({
|
||||
type: 0,
|
||||
allow: Permissions.SEND_MESSAGES.toString(),
|
||||
deny: '0',
|
||||
})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const channelData = await getChannel(harness, account.token, channel.id);
|
||||
expect(channelData.permission_overwrites).toBeDefined();
|
||||
expect(channelData.permission_overwrites?.some((o) => o.id === role.id)).toBe(true);
|
||||
});
|
||||
|
||||
test('should reject invalid overwrite type', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.put(`/channels/${channel.id}/permissions/123456789`)
|
||||
.body({
|
||||
type: 999,
|
||||
allow: '0',
|
||||
deny: '0',
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require MANAGE_ROLES permission to create overwrites', async () => {
|
||||
const {owner, members, guild, systemChannel} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
const role = await createRole(harness, owner.token, guild.id, {name: 'Test Role'});
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.put(`/channels/${systemChannel.id}/permissions/${role.id}`)
|
||||
.body({
|
||||
type: 0,
|
||||
allow: Permissions.SEND_MESSAGES.toString(),
|
||||
deny: '0',
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Voice Channel Bitrate and User Limit Updates', () => {
|
||||
test('should update voice channel bitrate', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 96000})
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(96000);
|
||||
});
|
||||
|
||||
test('should update voice channel user limit', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, user_limit: 10})
|
||||
.execute();
|
||||
expect(data.user_limit).toBe(10);
|
||||
});
|
||||
|
||||
test('should set unlimited user capacity with zero', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, user_limit: 0})
|
||||
.execute();
|
||||
expect(data.user_limit).toBe(0);
|
||||
});
|
||||
|
||||
test('should reject bitrate below minimum (8000)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 7999})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject bitrate above maximum (320000)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 320001})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject negative user limit', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, user_limit: -1})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject user limit above maximum (99)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, user_limit: 100})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should accept minimum bitrate (8000)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 8000})
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(8000);
|
||||
});
|
||||
|
||||
test('should accept maximum bitrate (320000)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 320000})
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(320000);
|
||||
});
|
||||
|
||||
test('should accept maximum user limit (99)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, user_limit: 99})
|
||||
.execute();
|
||||
expect(data.user_limit).toBe(99);
|
||||
});
|
||||
|
||||
test('should update both bitrate and user limit together', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 128000, user_limit: 25})
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(128000);
|
||||
expect(data.user_limit).toBe(25);
|
||||
});
|
||||
});
|
||||
});
|
||||
260
packages/api/src/guild/tests/GuildChannelPositions.test.tsx
Normal file
260
packages/api/src/guild/tests/GuildChannelPositions.test.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
addMemberRole,
|
||||
createChannel,
|
||||
createChannelInvite,
|
||||
createGuild,
|
||||
createRole,
|
||||
getChannel,
|
||||
getGuildChannels,
|
||||
updateChannelPositions,
|
||||
} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Guild Channel Positions', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('should reorder channels within guild', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const channel1 = await createChannel(harness, account.token, guild.id, 'channel-1');
|
||||
const channel2 = await createChannel(harness, account.token, guild.id, 'channel-2');
|
||||
const channel3 = await createChannel(harness, account.token, guild.id, 'channel-3');
|
||||
|
||||
await updateChannelPositions(harness, account.token, guild.id, [
|
||||
{id: channel3.id, position: 0},
|
||||
{id: channel1.id, position: 1},
|
||||
{id: channel2.id, position: 2},
|
||||
]);
|
||||
|
||||
const channels = await getGuildChannels(harness, account.token, guild.id);
|
||||
const textChannels = channels.filter((c) => c.type === ChannelTypes.GUILD_TEXT);
|
||||
|
||||
expect(textChannels.some((c) => c.id === channel1.id)).toBe(true);
|
||||
expect(textChannels.some((c) => c.id === channel2.id)).toBe(true);
|
||||
expect(textChannels.some((c) => c.id === channel3.id)).toBe(true);
|
||||
});
|
||||
|
||||
test('should move channel to category', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const category = await createChannel(harness, account.token, guild.id, 'Category', ChannelTypes.GUILD_CATEGORY);
|
||||
const textChannel = await createChannel(harness, account.token, guild.id, 'text-channel');
|
||||
|
||||
await updateChannelPositions(harness, account.token, guild.id, [{id: textChannel.id, parent_id: category.id}]);
|
||||
|
||||
const updatedChannel = await getChannel(harness, account.token, textChannel.id);
|
||||
expect(updatedChannel.parent_id).toBe(category.id);
|
||||
});
|
||||
|
||||
test('should move channel out of category', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const category = await createChannel(harness, account.token, guild.id, 'Category', ChannelTypes.GUILD_CATEGORY);
|
||||
const textChannel = await createChannel(harness, account.token, guild.id, 'text-channel');
|
||||
|
||||
await updateChannelPositions(harness, account.token, guild.id, [{id: textChannel.id, parent_id: category.id}]);
|
||||
|
||||
let updatedChannel = await getChannel(harness, account.token, textChannel.id);
|
||||
expect(updatedChannel.parent_id).toBe(category.id);
|
||||
|
||||
await updateChannelPositions(harness, account.token, guild.id, [{id: textChannel.id, parent_id: null}]);
|
||||
|
||||
updatedChannel = await getChannel(harness, account.token, textChannel.id);
|
||||
expect(updatedChannel.parent_id).toBeNull();
|
||||
});
|
||||
|
||||
test('should require MANAGE_CHANNELS permission to reorder', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
const channel1 = await createChannel(harness, owner.token, guild.id, 'channel-1');
|
||||
const channel2 = await createChannel(harness, owner.token, guild.id, 'channel-2');
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/channels`)
|
||||
.body([
|
||||
{id: channel1.id, position: 1},
|
||||
{id: channel2.id, position: 0},
|
||||
])
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow MANAGE_CHANNELS role to reorder channels', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const managerRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Channel Manager',
|
||||
permissions: Permissions.MANAGE_CHANNELS.toString(),
|
||||
});
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, managerRole.id);
|
||||
|
||||
const channel1 = await createChannel(harness, owner.token, guild.id, 'channel-1');
|
||||
const channel2 = await createChannel(harness, owner.token, guild.id, 'channel-2');
|
||||
|
||||
await updateChannelPositions(harness, member.token, guild.id, [
|
||||
{id: channel1.id, position: 1},
|
||||
{id: channel2.id, position: 0},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should reject invalid channel id in position update', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}/channels`)
|
||||
.body([{id: '999999999999999999', position: 0}])
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should lock permissions when moving to category', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const category = await createChannel(harness, account.token, guild.id, 'Category', ChannelTypes.GUILD_CATEGORY);
|
||||
const textChannel = await createChannel(harness, account.token, guild.id, 'text-channel');
|
||||
|
||||
await updateChannelPositions(harness, account.token, guild.id, [
|
||||
{id: textChannel.id, parent_id: category.id, lock_permissions: true},
|
||||
]);
|
||||
|
||||
const updatedChannel = await getChannel(harness, account.token, textChannel.id);
|
||||
expect(updatedChannel.parent_id).toBe(category.id);
|
||||
});
|
||||
|
||||
test('should handle moving multiple channels at once', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const channel1 = await createChannel(harness, account.token, guild.id, 'channel-1');
|
||||
const channel2 = await createChannel(harness, account.token, guild.id, 'channel-2');
|
||||
const channel3 = await createChannel(harness, account.token, guild.id, 'channel-3');
|
||||
|
||||
await updateChannelPositions(harness, account.token, guild.id, [
|
||||
{id: channel1.id, position: 2},
|
||||
{id: channel2.id, position: 0},
|
||||
{id: channel3.id, position: 1},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should reject category as parent of category', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const category1 = await createChannel(harness, account.token, guild.id, 'Category 1', ChannelTypes.GUILD_CATEGORY);
|
||||
const category2 = await createChannel(harness, account.token, guild.id, 'Category 2', ChannelTypes.GUILD_CATEGORY);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}/channels`)
|
||||
.body([{id: category2.id, parent_id: category1.id}])
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should move a voice channel into a text category with provided position', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const textCategory = await createChannel(harness, account.token, guild.id, 'Text', ChannelTypes.GUILD_CATEGORY);
|
||||
const voiceCategory = await createChannel(harness, account.token, guild.id, 'Voice', ChannelTypes.GUILD_CATEGORY);
|
||||
const textChannel = await createChannel(harness, account.token, guild.id, 'general', ChannelTypes.GUILD_TEXT);
|
||||
const voiceChannel = await createChannel(harness, account.token, guild.id, 'lounge', ChannelTypes.GUILD_VOICE);
|
||||
|
||||
await updateChannelPositions(harness, account.token, guild.id, [{id: textChannel.id, parent_id: textCategory.id}]);
|
||||
await updateChannelPositions(harness, account.token, guild.id, [
|
||||
{id: voiceChannel.id, parent_id: voiceCategory.id},
|
||||
]);
|
||||
|
||||
await updateChannelPositions(harness, account.token, guild.id, [
|
||||
{id: voiceChannel.id, parent_id: textCategory.id, position: 1},
|
||||
]);
|
||||
|
||||
const channels = await getGuildChannels(harness, account.token, guild.id);
|
||||
const movedVoice = channels.find((channel) => channel.id === voiceChannel.id);
|
||||
expect(movedVoice?.parent_id).toBe(textCategory.id);
|
||||
|
||||
const destinationSiblings = channels
|
||||
.filter((channel) => channel.parent_id === textCategory.id)
|
||||
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
||||
expect(destinationSiblings.map((channel) => channel.id)).toEqual([textChannel.id, voiceChannel.id]);
|
||||
});
|
||||
|
||||
test('should place moved voice channels after all text siblings', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const textCategory = await createChannel(harness, account.token, guild.id, 'Text', ChannelTypes.GUILD_CATEGORY);
|
||||
const voiceCategory = await createChannel(harness, account.token, guild.id, 'Voice', ChannelTypes.GUILD_CATEGORY);
|
||||
const textOne = await createChannel(harness, account.token, guild.id, 'one', ChannelTypes.GUILD_TEXT);
|
||||
const textTwo = await createChannel(harness, account.token, guild.id, 'two', ChannelTypes.GUILD_TEXT);
|
||||
const voiceChannel = await createChannel(harness, account.token, guild.id, 'lounge', ChannelTypes.GUILD_VOICE);
|
||||
|
||||
await updateChannelPositions(harness, account.token, guild.id, [
|
||||
{id: textOne.id, parent_id: textCategory.id},
|
||||
{id: textTwo.id, parent_id: textCategory.id},
|
||||
{id: voiceChannel.id, parent_id: voiceCategory.id},
|
||||
]);
|
||||
|
||||
await updateChannelPositions(harness, account.token, guild.id, [
|
||||
{id: voiceChannel.id, parent_id: textCategory.id, position: 2},
|
||||
]);
|
||||
|
||||
const channels = await getGuildChannels(harness, account.token, guild.id);
|
||||
const destinationSiblings = channels
|
||||
.filter((channel) => channel.parent_id === textCategory.id)
|
||||
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
||||
expect(destinationSiblings.map((channel) => channel.id)).toEqual([textOne.id, textTwo.id, voiceChannel.id]);
|
||||
});
|
||||
});
|
||||
311
packages/api/src/guild/tests/GuildFeatures.test.tsx
Normal file
311
packages/api/src/guild/tests/GuildFeatures.test.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
addMemberRole,
|
||||
createGuild,
|
||||
createRole,
|
||||
setupTestGuildWithMembers,
|
||||
updateGuild,
|
||||
updateMember,
|
||||
updateRolePositions,
|
||||
} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
interface AuditLogEntry {
|
||||
id: string;
|
||||
action_type: number;
|
||||
user_id: string | null;
|
||||
target_id: string | null;
|
||||
reason?: string;
|
||||
options?: Record<string, unknown>;
|
||||
changes?: Array<{key: string; old_value?: unknown; new_value?: unknown}>;
|
||||
}
|
||||
|
||||
interface AuditLogResponse {
|
||||
audit_log_entries: Array<AuditLogEntry>;
|
||||
users: Array<{id: string}>;
|
||||
webhooks: Array<unknown>;
|
||||
}
|
||||
|
||||
describe('Guild Features', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
describe('ADMINISTRATOR Permission Effects', () => {
|
||||
test('should allow owner to assign ADMINISTRATOR role to member', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const adminRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Admin Role',
|
||||
permissions: Permissions.ADMINISTRATOR.toString(),
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, adminRole.id);
|
||||
|
||||
const memberData = await createBuilder<{roles: Array<string>}>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members/${member.userId}`)
|
||||
.execute();
|
||||
|
||||
expect(memberData.roles).toContain(adminRole.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Guild Audit Log Does Not Leak IP Addresses in Ban Entries', () => {
|
||||
test('should not include IP address in ban audit log changes', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const targetMember = members[0];
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.put(`/guilds/${guild.id}/bans/${targetMember.userId}`)
|
||||
.body({reason: 'Test ban for audit log check'})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const auditLogResponse = await createBuilder<AuditLogResponse>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/audit-logs?action_type=22`)
|
||||
.execute();
|
||||
|
||||
expect(auditLogResponse.audit_log_entries.length).toBeGreaterThan(0);
|
||||
|
||||
for (const entry of auditLogResponse.audit_log_entries) {
|
||||
if (entry.changes) {
|
||||
for (const change of entry.changes) {
|
||||
expect(change.key).not.toBe('ip');
|
||||
expect(change.key).not.toBe('ip_address');
|
||||
}
|
||||
}
|
||||
if (entry.options) {
|
||||
expect(entry.options).not.toHaveProperty('ip');
|
||||
expect(entry.options).not.toHaveProperty('ip_address');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should preserve other ban details in audit log while scrubbing IP', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const targetMember = members[0];
|
||||
const banReason = 'Ban reason for audit test';
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.put(`/guilds/${guild.id}/bans/${targetMember.userId}`)
|
||||
.body({reason: banReason})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const auditLogResponse = await createBuilder<AuditLogResponse>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/audit-logs?action_type=22`)
|
||||
.execute();
|
||||
|
||||
const banEntry = auditLogResponse.audit_log_entries.find(
|
||||
(entry) => entry.target_id === targetMember.userId && entry.action_type === 22,
|
||||
);
|
||||
|
||||
expect(banEntry).toBeDefined();
|
||||
expect(banEntry!.user_id).toBe(owner.userId);
|
||||
expect(banEntry!.target_id).toBe(targetMember.userId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CHANGE_NICKNAME Permission Enforcement', () => {
|
||||
test('should allow member with CHANGE_NICKNAME to change their own nickname', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({nick: 'My New Nickname'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should not allow member without CHANGE_NICKNAME to change their own nickname when @everyone denies it', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.patch(`/guilds/${guild.id}/roles/${guild.id}`)
|
||||
.body({
|
||||
permissions: (Permissions.VIEW_CHANNEL | Permissions.SEND_MESSAGES).toString(),
|
||||
})
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({nick: 'Cannot Set This'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow MANAGE_NICKNAMES holder to change other member nicknames', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [moderator, target] = members;
|
||||
|
||||
const modRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Moderator',
|
||||
permissions: Permissions.MANAGE_NICKNAMES.toString(),
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, moderator.userId, modRole.id);
|
||||
|
||||
const updatedMember = await updateMember(harness, moderator.token, guild.id, target.userId, {
|
||||
nick: 'Mod Set Nick',
|
||||
});
|
||||
|
||||
expect(updatedMember.nick).toBe('Mod Set Nick');
|
||||
});
|
||||
|
||||
test('should not allow CHANGE_NICKNAME holder to change other member nicknames', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [member1, member2] = members;
|
||||
|
||||
const changeNickRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Change Nick Only',
|
||||
permissions: Permissions.CHANGE_NICKNAME.toString(),
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member1.userId, changeNickRole.id);
|
||||
|
||||
await createBuilder(harness, member1.token)
|
||||
.patch(`/guilds/${guild.id}/members/${member2.userId}`)
|
||||
.body({nick: 'Should Fail'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Guild Feature Flags Validation', () => {
|
||||
test('should allow toggling INVITES_DISABLED feature', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Feature Test Guild');
|
||||
|
||||
const updatedGuild = await updateGuild(harness, account.token, guild.id, {
|
||||
features: [GuildFeatures.INVITES_DISABLED],
|
||||
});
|
||||
|
||||
expect(updatedGuild.features).toContain(GuildFeatures.INVITES_DISABLED);
|
||||
|
||||
const enabledGuild = await updateGuild(harness, account.token, guild.id, {
|
||||
features: [],
|
||||
});
|
||||
|
||||
expect(enabledGuild.features).not.toContain(GuildFeatures.INVITES_DISABLED);
|
||||
});
|
||||
|
||||
test('should allow toggling TEXT_CHANNEL_FLEXIBLE_NAMES feature', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Flexible Names Test');
|
||||
|
||||
const updatedGuild = await updateGuild(harness, account.token, guild.id, {
|
||||
features: [GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES],
|
||||
});
|
||||
|
||||
expect(updatedGuild.features).toContain(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES);
|
||||
});
|
||||
|
||||
test('should preserve base features when toggling user-controlled features', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Base Features Test');
|
||||
|
||||
expect(guild.features).toContain(GuildFeatures.ANIMATED_ICON);
|
||||
expect(guild.features).toContain(GuildFeatures.BANNER);
|
||||
|
||||
const updatedGuild = await updateGuild(harness, account.token, guild.id, {
|
||||
features: [GuildFeatures.INVITES_DISABLED],
|
||||
});
|
||||
|
||||
expect(updatedGuild.features).toContain(GuildFeatures.ANIMATED_ICON);
|
||||
expect(updatedGuild.features).toContain(GuildFeatures.BANNER);
|
||||
expect(updatedGuild.features).toContain(GuildFeatures.INVITES_DISABLED);
|
||||
});
|
||||
|
||||
test('should require MANAGE_GUILD permission to update features', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({features: [GuildFeatures.INVITES_DISABLED]})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Role Hierarchy With ADMINISTRATOR', () => {
|
||||
test('should not allow non-owner ADMINISTRATOR to modify roles above their highest role', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const adminMember = members[0];
|
||||
|
||||
const highRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'High Role',
|
||||
permissions: '0',
|
||||
});
|
||||
|
||||
const adminRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Admin Role',
|
||||
permissions: Permissions.ADMINISTRATOR.toString(),
|
||||
});
|
||||
|
||||
await updateRolePositions(harness, owner.token, guild.id, [
|
||||
{id: highRole.id, position: 3},
|
||||
{id: adminRole.id, position: 2},
|
||||
]);
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, adminMember.userId, adminRole.id);
|
||||
|
||||
await createBuilder(harness, adminMember.token)
|
||||
.patch(`/guilds/${guild.id}/roles/${highRole.id}`)
|
||||
.body({name: 'Trying to Modify'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow owner to modify any role regardless of hierarchy', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Owner Hierarchy Test');
|
||||
|
||||
const highRole = await createRole(harness, account.token, guild.id, {
|
||||
name: 'High Role',
|
||||
permissions: Permissions.ADMINISTRATOR.toString(),
|
||||
});
|
||||
|
||||
await updateRolePositions(harness, account.token, guild.id, [{id: highRole.id, position: 10}]);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}/roles/${highRole.id}`)
|
||||
.body({name: 'Modified By Owner'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
260
packages/api/src/guild/tests/GuildFolderOperations.test.tsx
Normal file
260
packages/api/src/guild/tests/GuildFolderOperations.test.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {authorizeBot, createTestBotAccount} from '@fluxer/api/src/bot/tests/BotTestUtils';
|
||||
import {acceptInvite, createChannelInvite, createGuild, getChannel} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {UNCATEGORIZED_FOLDER_ID} from '@fluxer/constants/src/UserConstants';
|
||||
import type {UserSettingsResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Guild Folder Operations', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('should prepend newly created guild to uncategorized folder', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const guild1 = await createGuild(harness, account.token, 'Guild 1');
|
||||
const guild2 = await createGuild(harness, account.token, 'Guild 2');
|
||||
|
||||
const {json: settings} = await createBuilder<UserSettingsResponse>(harness, account.token)
|
||||
.get('/users/@me/settings')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
|
||||
expect(settings.guild_folders).toBeDefined();
|
||||
const uncategorizedFolder = settings.guild_folders?.find((folder) => folder.id === UNCATEGORIZED_FOLDER_ID);
|
||||
|
||||
expect(uncategorizedFolder).toBeDefined();
|
||||
expect(uncategorizedFolder?.guild_ids).toEqual([guild2.id, guild1.id]);
|
||||
});
|
||||
|
||||
test('should prepend newly joined guild to uncategorized folder', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild1 = await createGuild(harness, owner.token, 'Guild 1');
|
||||
const channel1 = await getChannel(harness, owner.token, guild1.system_channel_id!);
|
||||
const invite1 = await createChannelInvite(harness, owner.token, channel1.id);
|
||||
|
||||
const guild2 = await createGuild(harness, owner.token, 'Guild 2');
|
||||
const channel2 = await getChannel(harness, owner.token, guild2.system_channel_id!);
|
||||
const invite2 = await createChannelInvite(harness, owner.token, channel2.id);
|
||||
|
||||
await acceptInvite(harness, member.token, invite1.code);
|
||||
await acceptInvite(harness, member.token, invite2.code);
|
||||
|
||||
const {json: settings} = await createBuilder<UserSettingsResponse>(harness, member.token)
|
||||
.get('/users/@me/settings')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
|
||||
const uncategorizedFolder = settings.guild_folders?.find((folder) => folder.id === UNCATEGORIZED_FOLDER_ID);
|
||||
|
||||
expect(uncategorizedFolder).toBeDefined();
|
||||
expect(uncategorizedFolder?.guild_ids).toEqual([guild2.id, guild1.id]);
|
||||
});
|
||||
|
||||
test('should remove guild from all folders when leaving', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild1 = await createGuild(harness, owner.token, 'Guild 1');
|
||||
const channel1 = await getChannel(harness, owner.token, guild1.system_channel_id!);
|
||||
const invite1 = await createChannelInvite(harness, owner.token, channel1.id);
|
||||
|
||||
const guild2 = await createGuild(harness, owner.token, 'Guild 2');
|
||||
const channel2 = await getChannel(harness, owner.token, guild2.system_channel_id!);
|
||||
const invite2 = await createChannelInvite(harness, owner.token, channel2.id);
|
||||
|
||||
await acceptInvite(harness, member.token, invite1.code);
|
||||
await acceptInvite(harness, member.token, invite2.code);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch('/users/@me/settings')
|
||||
.body({
|
||||
guild_folders: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'My Folder',
|
||||
guild_ids: [guild1.id],
|
||||
},
|
||||
{
|
||||
id: UNCATEGORIZED_FOLDER_ID,
|
||||
guild_ids: [guild2.id],
|
||||
},
|
||||
],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.delete(`/users/@me/guilds/${guild1.id}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.executeWithResponse();
|
||||
|
||||
const {json: settings} = await createBuilder<UserSettingsResponse>(harness, member.token)
|
||||
.get('/users/@me/settings')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
|
||||
const customFolder = settings.guild_folders?.find((folder) => folder.id === 1);
|
||||
expect(customFolder).toBeUndefined();
|
||||
|
||||
const uncategorizedFolder = settings.guild_folders?.find((folder) => folder.id === UNCATEGORIZED_FOLDER_ID);
|
||||
expect(uncategorizedFolder?.guild_ids).toEqual([guild2.id]);
|
||||
});
|
||||
|
||||
test('should remove guild from folders across multiple custom folders', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild1 = await createGuild(harness, owner.token, 'Guild 1');
|
||||
const channel1 = await getChannel(harness, owner.token, guild1.system_channel_id!);
|
||||
const invite1 = await createChannelInvite(harness, owner.token, channel1.id);
|
||||
|
||||
const guild2 = await createGuild(harness, owner.token, 'Guild 2');
|
||||
const channel2 = await getChannel(harness, owner.token, guild2.system_channel_id!);
|
||||
const invite2 = await createChannelInvite(harness, owner.token, channel2.id);
|
||||
|
||||
const guild3 = await createGuild(harness, owner.token, 'Guild 3');
|
||||
const channel3 = await getChannel(harness, owner.token, guild3.system_channel_id!);
|
||||
const invite3 = await createChannelInvite(harness, owner.token, channel3.id);
|
||||
|
||||
await acceptInvite(harness, member.token, invite1.code);
|
||||
await acceptInvite(harness, member.token, invite2.code);
|
||||
await acceptInvite(harness, member.token, invite3.code);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch('/users/@me/settings')
|
||||
.body({
|
||||
guild_folders: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Folder 1',
|
||||
guild_ids: [guild1.id, guild2.id],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Folder 2',
|
||||
guild_ids: [guild3.id],
|
||||
},
|
||||
],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.delete(`/users/@me/guilds/${guild2.id}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.executeWithResponse();
|
||||
|
||||
const {json: settings} = await createBuilder<UserSettingsResponse>(harness, member.token)
|
||||
.get('/users/@me/settings')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
|
||||
const folder1 = settings.guild_folders?.find((folder) => folder.id === 1);
|
||||
expect(folder1?.guild_ids).toEqual([guild1.id]);
|
||||
|
||||
const folder2 = settings.guild_folders?.find((folder) => folder.id === 2);
|
||||
expect(folder2?.guild_ids).toEqual([guild3.id]);
|
||||
});
|
||||
|
||||
test('should remove guild from all members folders when guild is deleted', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member1 = await createTestAccount(harness);
|
||||
const member2 = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const channel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, channel.id);
|
||||
|
||||
await acceptInvite(harness, member1.token, invite.code);
|
||||
await acceptInvite(harness, member2.token, invite.code);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/delete`)
|
||||
.body({password: owner.password})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.executeWithResponse();
|
||||
|
||||
const {json: ownerSettings} = await createBuilder<UserSettingsResponse>(harness, owner.token)
|
||||
.get('/users/@me/settings')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
|
||||
const {json: member1Settings} = await createBuilder<UserSettingsResponse>(harness, member1.token)
|
||||
.get('/users/@me/settings')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
|
||||
const {json: member2Settings} = await createBuilder<UserSettingsResponse>(harness, member2.token)
|
||||
.get('/users/@me/settings')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
|
||||
const ownerUncategorized = ownerSettings.guild_folders?.find((folder) => folder.id === UNCATEGORIZED_FOLDER_ID);
|
||||
const member1Uncategorized = member1Settings.guild_folders?.find((folder) => folder.id === UNCATEGORIZED_FOLDER_ID);
|
||||
const member2Uncategorized = member2Settings.guild_folders?.find((folder) => folder.id === UNCATEGORIZED_FOLDER_ID);
|
||||
|
||||
expect(ownerUncategorized?.guild_ids ?? []).not.toContain(guild.id);
|
||||
expect(member1Uncategorized?.guild_ids ?? []).not.toContain(guild.id);
|
||||
expect(member2Uncategorized?.guild_ids ?? []).not.toContain(guild.id);
|
||||
});
|
||||
|
||||
test('should not update guild folders for bot users when joining', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const botAccount = await createTestBotAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
await authorizeBot(harness, owner.token, botAccount.appId, ['bot'], guild.id, '0');
|
||||
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
test('should not update guild folders for bot users when leaving', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const botAccount = await createTestBotAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
await authorizeBot(harness, owner.token, botAccount.appId, ['bot'], guild.id, '0');
|
||||
|
||||
const {json: ownerGuilds} = await createBuilder<Array<{id: string}>>(harness, owner.token)
|
||||
.get('/users/@me/guilds')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
|
||||
const botIsInGuild = ownerGuilds.some((g) => g.id === guild.id);
|
||||
expect(botIsInGuild).toBe(true);
|
||||
});
|
||||
});
|
||||
730
packages/api/src/guild/tests/GuildMemberManagement.test.tsx
Normal file
730
packages/api/src/guild/tests/GuildMemberManagement.test.tsx
Normal file
@@ -0,0 +1,730 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {getPngDataUrl, getTooLargePngDataUrl} from '@fluxer/api/src/emoji/tests/EmojiTestUtils';
|
||||
import {
|
||||
addMemberRole,
|
||||
createRole,
|
||||
getMember,
|
||||
removeMemberRole,
|
||||
setupTestGuildWithMembers,
|
||||
updateMember,
|
||||
updateRolePositions,
|
||||
} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {grantPremium} from '@fluxer/api/src/user/tests/UserTestUtils';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Guild Member Management', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('should remove role from member', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const role = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Test Role',
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, role.id);
|
||||
|
||||
let memberInfo = await getMember(harness, owner.token, guild.id, member.userId);
|
||||
expect(memberInfo.roles).toContain(role.id);
|
||||
|
||||
await removeMemberRole(harness, owner.token, guild.id, member.userId, role.id);
|
||||
|
||||
memberInfo = await getMember(harness, owner.token, guild.id, member.userId);
|
||||
expect(memberInfo.roles).not.toContain(role.id);
|
||||
});
|
||||
|
||||
test('should reject assigning @everyone via member update', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
type InvalidFormResponse = {errors: Array<{path: string}>; code: string};
|
||||
const {json} = await createBuilder<InvalidFormResponse>(harness, owner.token)
|
||||
.patch(`/guilds/${guild.id}/members/${member.userId}`)
|
||||
.body({roles: [guild.id]})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.executeWithResponse();
|
||||
|
||||
expect(json.errors?.some((error) => error.path === 'roles')).toBe(true);
|
||||
});
|
||||
|
||||
test('should reject adding @everyone role explicitly', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
type InvalidFormResponse = {errors: Array<{path: string}>; code: string};
|
||||
const {json} = await createBuilder<InvalidFormResponse>(harness, owner.token)
|
||||
.put(`/guilds/${guild.id}/members/${member.userId}/roles/${guild.id}`)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.executeWithResponse();
|
||||
|
||||
expect(json.errors?.some((error) => error.path === 'role_id')).toBe(true);
|
||||
});
|
||||
|
||||
test('should update member nickname', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const updatedMember = await updateMember(harness, owner.token, guild.id, member.userId, {
|
||||
nick: 'New Nickname',
|
||||
});
|
||||
|
||||
expect(updatedMember.nick).toBe('New Nickname');
|
||||
});
|
||||
|
||||
test('should require MANAGE_NICKNAMES to change others nickname', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [member1, member2] = members;
|
||||
|
||||
await createBuilder(harness, member1.token)
|
||||
.patch(`/guilds/${guild.id}/members/${member2.userId}`)
|
||||
.body({nick: 'New Nick'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow changing own nickname with CHANGE_NICKNAME', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({nick: 'My Nickname'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require MANAGE_ROLES to add roles', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [member1, member2] = members;
|
||||
|
||||
const role = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Test Role',
|
||||
});
|
||||
|
||||
await createBuilder(harness, member1.token)
|
||||
.put(`/guilds/${guild.id}/members/${member2.userId}/roles/${role.id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should enforce role hierarchy when adding roles', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const higherRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Higher Role',
|
||||
permissions: Permissions.MANAGE_ROLES.toString(),
|
||||
});
|
||||
|
||||
const lowerRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Lower Role',
|
||||
});
|
||||
|
||||
await updateRolePositions(harness, owner.token, guild.id, [
|
||||
{id: higherRole.id, position: 2},
|
||||
{id: lowerRole.id, position: 1},
|
||||
]);
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, lowerRole.id);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.put(`/guilds/${guild.id}/members/${owner.userId}/roles/${higherRole.id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should kick member from guild', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.delete(`/guilds/${guild.id}/members/${member.userId}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members/${member.userId}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require KICK_MEMBERS to kick members', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [member1, member2] = members;
|
||||
|
||||
await createBuilder(harness, member1.token)
|
||||
.delete(`/guilds/${guild.id}/members/${member2.userId}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should not allow kicking the owner', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const modRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Moderator',
|
||||
permissions: Permissions.KICK_MEMBERS.toString(),
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, modRole.id);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.delete(`/guilds/${guild.id}/members/${owner.userId}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should ban member from guild', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.put(`/guilds/${guild.id}/bans/${member.userId}`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require BAN_MEMBERS to ban members', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [member1, member2] = members;
|
||||
|
||||
await createBuilder(harness, member1.token)
|
||||
.put(`/guilds/${guild.id}/bans/${member2.userId}`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should not allow member to ban themselves', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const modRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Moderator',
|
||||
permissions: Permissions.BAN_MEMBERS.toString(),
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, modRole.id);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.put(`/guilds/${guild.id}/bans/${member.userId}`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should clear member nickname by setting to null', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await updateMember(harness, owner.token, guild.id, member.userId, {
|
||||
nick: 'Temporary Nick',
|
||||
});
|
||||
|
||||
const memberInfo = await getMember(harness, owner.token, guild.id, member.userId);
|
||||
expect(memberInfo.nick).toBe('Temporary Nick');
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.patch(`/guilds/${guild.id}/members/${member.userId}`)
|
||||
.body({nick: null})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
describe('List Members Permission Checks', () => {
|
||||
test('should reject non-member from listing guild members', async () => {
|
||||
const {guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const nonMember = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, nonMember.token)
|
||||
.get(`/guilds/${guild.id}/members`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow regular member to list guild members', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await createBuilder(harness, member.token).get(`/guilds/${guild.id}/members`).expect(HTTP_STATUS.OK).execute();
|
||||
});
|
||||
|
||||
test('should support limit parameter for listing members', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 3);
|
||||
|
||||
const memberList = await createBuilder<Array<{user: {id: string}}>>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members?limit=2`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(memberList.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Get Member Permission Checks', () => {
|
||||
test('should reject non-member from getting member info', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const nonMember = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, nonMember.token)
|
||||
.get(`/guilds/${guild.id}/members/${members[0].userId}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow member to get their own member info', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const memberInfo = await createBuilder<GuildMemberResponse>(harness, member.token)
|
||||
.get(`/guilds/${guild.id}/members/${member.userId}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(memberInfo.user?.id).toBe(member.userId);
|
||||
});
|
||||
|
||||
test('should allow member to get other member info', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [member1, member2] = members;
|
||||
|
||||
const memberInfo = await createBuilder<GuildMemberResponse>(harness, member1.token)
|
||||
.get(`/guilds/${guild.id}/members/${member2.userId}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(memberInfo.user?.id).toBe(member2.userId);
|
||||
});
|
||||
|
||||
test('should return 404 for non-existent member', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members/999999999999999999`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Update Member Nick Permission Checks', () => {
|
||||
test('should reject member without CHANGE_NICKNAME from changing own nickname when permission revoked', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const restrictedRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Restricted',
|
||||
permissions: '0',
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, restrictedRole.id);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({nick: 'Test Nick'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow owner to change any member nickname', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const updatedMember = await updateMember(harness, owner.token, guild.id, member.userId, {
|
||||
nick: 'Owner Set Nick',
|
||||
});
|
||||
|
||||
expect(updatedMember.nick).toBe('Owner Set Nick');
|
||||
});
|
||||
|
||||
test('should allow member with MANAGE_NICKNAMES to change others nickname', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [moderator, target] = members;
|
||||
|
||||
const modRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Moderator',
|
||||
permissions: Permissions.MANAGE_NICKNAMES.toString(),
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, moderator.userId, modRole.id);
|
||||
|
||||
const updatedMember = await updateMember(harness, moderator.token, guild.id, target.userId, {
|
||||
nick: 'Mod Set Nick',
|
||||
});
|
||||
|
||||
expect(updatedMember.nick).toBe('Mod Set Nick');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Kick Member Permission Checks', () => {
|
||||
test('should not allow member to kick someone with higher role', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [moderator, target] = members;
|
||||
|
||||
const modRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Moderator',
|
||||
permissions: Permissions.KICK_MEMBERS.toString(),
|
||||
});
|
||||
|
||||
const targetRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Target Role',
|
||||
});
|
||||
|
||||
await updateRolePositions(harness, owner.token, guild.id, [
|
||||
{id: targetRole.id, position: 3},
|
||||
{id: modRole.id, position: 2},
|
||||
]);
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, moderator.userId, modRole.id);
|
||||
await addMemberRole(harness, owner.token, guild.id, target.userId, targetRole.id);
|
||||
|
||||
await createBuilder(harness, moderator.token)
|
||||
.delete(`/guilds/${guild.id}/members/${target.userId}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow member with KICK_MEMBERS to kick lower ranked member', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [moderator, target] = members;
|
||||
|
||||
const modRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Moderator',
|
||||
permissions: Permissions.KICK_MEMBERS.toString(),
|
||||
});
|
||||
|
||||
await updateRolePositions(harness, owner.token, guild.id, [{id: modRole.id, position: 2}]);
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, moderator.userId, modRole.id);
|
||||
|
||||
await createBuilder(harness, moderator.token)
|
||||
.delete(`/guilds/${guild.id}/members/${target.userId}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should not allow member to kick themselves', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const modRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Moderator',
|
||||
permissions: Permissions.KICK_MEMBERS.toString(),
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, modRole.id);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.delete(`/guilds/${guild.id}/members/${member.userId}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Member Avatar Upload Validation', () => {
|
||||
test('should reject member avatar upload without premium', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
|
||||
const memberInfo = await createBuilder<GuildMemberResponse>(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({avatar: getPngDataUrl()})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(memberInfo.avatar).toBeNull();
|
||||
});
|
||||
|
||||
test('should allow member avatar upload with premium', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
await grantPremium(harness, member.userId, 2);
|
||||
|
||||
const updatedMember = await createBuilder<GuildMemberResponse>(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({avatar: getPngDataUrl()})
|
||||
.execute();
|
||||
|
||||
expect(updatedMember.avatar).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should reject avatar that exceeds size limit', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
await grantPremium(harness, member.userId, 2);
|
||||
|
||||
await createBuilder<GuildMemberResponse>(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({avatar: getTooLargePngDataUrl()})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow clearing member avatar by setting to null', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
await grantPremium(harness, member.userId, 2);
|
||||
|
||||
await createBuilder<GuildMemberResponse>(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({avatar: getPngDataUrl()})
|
||||
.execute();
|
||||
|
||||
const clearedMember = await createBuilder<GuildMemberResponse>(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({avatar: null})
|
||||
.execute();
|
||||
|
||||
expect(clearedMember.avatar).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Member Banner Upload Validation', () => {
|
||||
test('should reject member banner upload without premium', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
|
||||
const memberInfo = await createBuilder<GuildMemberResponse>(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({banner: getPngDataUrl()})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(memberInfo.banner).toBeNull();
|
||||
});
|
||||
|
||||
test('should allow member banner upload with premium', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
await grantPremium(harness, member.userId, 2);
|
||||
|
||||
const updatedMember = await createBuilder<GuildMemberResponse>(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({banner: getPngDataUrl()})
|
||||
.execute();
|
||||
|
||||
expect(updatedMember.banner).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should reject banner that exceeds size limit', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
await grantPremium(harness, member.userId, 2);
|
||||
|
||||
await createBuilder<GuildMemberResponse>(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({banner: getTooLargePngDataUrl()})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow clearing member banner by setting to null', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
await grantPremium(harness, member.userId, 2);
|
||||
|
||||
await createBuilder<GuildMemberResponse>(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({banner: getPngDataUrl()})
|
||||
.execute();
|
||||
|
||||
const clearedMember = await createBuilder<GuildMemberResponse>(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({banner: null})
|
||||
.execute();
|
||||
|
||||
expect(clearedMember.banner).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Member Role Assignment/Removal', () => {
|
||||
test('should not allow assigning role higher than own highest role', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [moderator, target] = members;
|
||||
|
||||
const highRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'High Role',
|
||||
permissions: '0',
|
||||
});
|
||||
|
||||
const modRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Moderator',
|
||||
permissions: '268435456',
|
||||
});
|
||||
|
||||
await updateRolePositions(harness, owner.token, guild.id, [
|
||||
{id: highRole.id, position: 3},
|
||||
{id: modRole.id, position: 2},
|
||||
]);
|
||||
|
||||
await updateMember(harness, owner.token, guild.id, moderator.userId, {
|
||||
roles: [modRole.id],
|
||||
});
|
||||
|
||||
await createBuilder(harness, moderator.token)
|
||||
.put(`/guilds/${guild.id}/members/${target.userId}/roles/${highRole.id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow owner to assign any role to a member', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const newRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'New Role',
|
||||
permissions: '0',
|
||||
});
|
||||
|
||||
const updatedMember = await updateMember(harness, owner.token, guild.id, member.userId, {
|
||||
roles: [newRole.id],
|
||||
});
|
||||
|
||||
expect(updatedMember.roles).toContain(newRole.id);
|
||||
});
|
||||
|
||||
test('should not allow removing role higher than own highest role', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [moderator, target] = members;
|
||||
|
||||
const highRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'High Role',
|
||||
permissions: '0',
|
||||
});
|
||||
|
||||
const modRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Moderator',
|
||||
permissions: '268435456',
|
||||
});
|
||||
|
||||
await updateRolePositions(harness, owner.token, guild.id, [
|
||||
{id: highRole.id, position: 3},
|
||||
{id: modRole.id, position: 2},
|
||||
]);
|
||||
|
||||
await updateMember(harness, owner.token, guild.id, moderator.userId, {
|
||||
roles: [modRole.id],
|
||||
});
|
||||
await updateMember(harness, owner.token, guild.id, target.userId, {
|
||||
roles: [highRole.id],
|
||||
});
|
||||
|
||||
await createBuilder(harness, moderator.token)
|
||||
.delete(`/guilds/${guild.id}/members/${target.userId}/roles/${highRole.id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow owner to remove any role from a member', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const role = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Removable Role',
|
||||
permissions: '0',
|
||||
});
|
||||
|
||||
await updateMember(harness, owner.token, guild.id, member.userId, {
|
||||
roles: [role.id],
|
||||
});
|
||||
|
||||
const updatedMember = await updateMember(harness, owner.token, guild.id, member.userId, {
|
||||
roles: [],
|
||||
});
|
||||
|
||||
expect(updatedMember.roles).not.toContain(role.id);
|
||||
});
|
||||
|
||||
test('should not allow member without MANAGE_ROLES to assign roles', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [member1, member2] = members;
|
||||
|
||||
const role = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Test Role',
|
||||
});
|
||||
|
||||
await createBuilder(harness, member1.token)
|
||||
.put(`/guilds/${guild.id}/members/${member2.userId}/roles/${role.id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should not allow member without MANAGE_ROLES to remove roles', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [member1, member2] = members;
|
||||
|
||||
const role = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Test Role',
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member2.userId, role.id);
|
||||
|
||||
await createBuilder(harness, member1.token)
|
||||
.delete(`/guilds/${guild.id}/members/${member2.userId}/roles/${role.id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should not allow assigning non-existent role', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.put(`/guilds/${guild.id}/members/${member.userId}/roles/999999999999999999`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
164
packages/api/src/guild/tests/GuildMfaLevel.test.tsx
Normal file
164
packages/api/src/guild/tests/GuildMfaLevel.test.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount, type TestAccount, totpCodeNow} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createGuild, setupTestGuildWithMembers} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {GuildMFALevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
|
||||
|
||||
async function enableTotp(harness: ApiTestHarness, account: TestAccount): Promise<void> {
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret: TOTP_SECRET, code: totpCodeNow(TOTP_SECRET), password: account.password})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function loginWithTotp(harness: ApiTestHarness, account: TestAccount): Promise<TestAccount> {
|
||||
const loginResp = await createBuilderWithoutAuth<{mfa: true; ticket: string}>(harness)
|
||||
.post('/auth/login')
|
||||
.body({email: account.email, password: account.password})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const mfaResp = await createBuilderWithoutAuth<{token: string}>(harness)
|
||||
.post('/auth/login/mfa/totp')
|
||||
.body({code: totpCodeNow(TOTP_SECRET), ticket: loginResp.ticket})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
return {...account, token: mfaResp.token};
|
||||
}
|
||||
|
||||
describe('Guild MFA level', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
it('rejects enabling mfa_level when owner has no 2FA', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'MFA Test Guild');
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({mfa_level: GuildMFALevel.ELEVATED, password: owner.password})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('rejects disabling mfa_level when owner has no 2FA', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'MFA Test Guild');
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({mfa_level: GuildMFALevel.NONE, password: owner.password})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('requires sudo mode when changing mfa_level', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
await enableTotp(harness, owner);
|
||||
const loggedIn = await loginWithTotp(harness, owner);
|
||||
const guild = await createGuild(harness, loggedIn.token, 'MFA Test Guild');
|
||||
|
||||
await createBuilder(harness, loggedIn.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({mfa_level: GuildMFALevel.ELEVATED})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('allows enabling mfa_level with sudo verification via TOTP', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
await enableTotp(harness, owner);
|
||||
const loggedIn = await loginWithTotp(harness, owner);
|
||||
const guild = await createGuild(harness, loggedIn.token, 'MFA Test Guild');
|
||||
|
||||
const updated = await createBuilder<GuildResponse>(harness, loggedIn.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({mfa_level: GuildMFALevel.ELEVATED, mfa_method: 'totp', mfa_code: totpCodeNow(TOTP_SECRET)})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(updated.mfa_level).toBe(GuildMFALevel.ELEVATED);
|
||||
});
|
||||
|
||||
it('allows disabling mfa_level with sudo verification via TOTP', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
await enableTotp(harness, owner);
|
||||
const loggedIn = await loginWithTotp(harness, owner);
|
||||
const guild = await createGuild(harness, loggedIn.token, 'MFA Test Guild');
|
||||
|
||||
await createBuilder<GuildResponse>(harness, loggedIn.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({mfa_level: GuildMFALevel.ELEVATED, mfa_method: 'totp', mfa_code: totpCodeNow(TOTP_SECRET)})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const updated = await createBuilder<GuildResponse>(harness, loggedIn.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({mfa_level: GuildMFALevel.NONE, mfa_method: 'totp', mfa_code: totpCodeNow(TOTP_SECRET)})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(updated.mfa_level).toBe(GuildMFALevel.NONE);
|
||||
});
|
||||
|
||||
it('rejects mfa_level change from non-owner', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0]!;
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({mfa_level: GuildMFALevel.ELEVATED, password: member.password})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('does not require sudo mode for non-mfa_level guild updates', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'MFA Test Guild');
|
||||
|
||||
const updated = await createBuilder<GuildResponse>(harness, owner.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({name: 'Renamed Guild'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(updated.name).toBe('Renamed Guild');
|
||||
});
|
||||
});
|
||||
136
packages/api/src/guild/tests/GuildOperationPermissions.test.tsx
Normal file
136
packages/api/src/guild/tests/GuildOperationPermissions.test.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
addMemberRole,
|
||||
createChannelInvite,
|
||||
createGuild,
|
||||
createRole,
|
||||
getChannel,
|
||||
leaveGuild,
|
||||
} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildNSFWLevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Guild Operation Permissions', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
it('should reject member from updating guild without MANAGE_GUILD', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Perms Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({name: 'Hacked Guild'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should reject nonmember from getting guild', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
const nonmember = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Perms Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await createBuilder(harness, nonmember.token).get(`/guilds/${guild.id}`).expect(HTTP_STATUS.FORBIDDEN).execute();
|
||||
});
|
||||
|
||||
it('should allow member to leave guild', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Perms Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await leaveGuild(harness, member.token, guild.id);
|
||||
|
||||
await createBuilder(harness, member.token).get(`/guilds/${guild.id}`).expect(HTTP_STATUS.FORBIDDEN).execute();
|
||||
});
|
||||
|
||||
it('should reject owner from leaving guild without deleting', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Perms Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.delete(`/users/@me/guilds/${guild.id}`)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should allow member with MANAGE_GUILD to update guild nsfw_level', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'NSFW Perms Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
const manageGuildRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Manage Guild',
|
||||
permissions: Permissions.MANAGE_GUILD.toString(),
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, manageGuildRole.id);
|
||||
|
||||
const updated = await createBuilder<GuildResponse>(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({nsfw_level: GuildNSFWLevel.AGE_RESTRICTED})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(updated.nsfw_level).toBe(GuildNSFWLevel.AGE_RESTRICTED);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS, TEST_IDS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
|
||||
|
||||
describe('Guild Operation Validation', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
it('should reject getting nonexistent guild', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.get(`/guilds/${TEST_IDS.NONEXISTENT_GUILD}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should reject updating nonexistent guild', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/guilds/${TEST_IDS.NONEXISTENT_GUILD}`)
|
||||
.body({name: 'New Name'})
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should reject leaving nonexistent guild', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.delete(`/users/@me/guilds/${TEST_IDS.NONEXISTENT_GUILD}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
77
packages/api/src/guild/tests/GuildOwnershipTransfer.test.tsx
Normal file
77
packages/api/src/guild/tests/GuildOwnershipTransfer.test.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {acceptInvite, createChannelInvite, createGuild, getChannel} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS, TEST_CREDENTIALS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Guild Ownership Transfer', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
it('rejects transfer to a bot user', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const botAccount = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Transfer Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, botAccount.token, invite.code);
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/test/users/${botAccount.userId}/set-bot-flag`)
|
||||
.body({is_bot: true})
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/transfer-ownership`)
|
||||
.body({new_owner_id: botAccount.userId, password: TEST_CREDENTIALS.STRONG_PASSWORD})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CANNOT_TRANSFER_OWNERSHIP_TO_BOT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('allows transfer to a non-bot user', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Transfer Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
const updatedGuild = await createBuilder<GuildResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/transfer-ownership`)
|
||||
.body({new_owner_id: member.userId, password: TEST_CREDENTIALS.STRONG_PASSWORD})
|
||||
.execute();
|
||||
|
||||
expect(updatedGuild.owner_id).toBe(member.userId);
|
||||
});
|
||||
});
|
||||
468
packages/api/src/guild/tests/GuildRoleManagement.test.tsx
Normal file
468
packages/api/src/guild/tests/GuildRoleManagement.test.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
addMemberRole,
|
||||
createChannelInvite,
|
||||
createGuild,
|
||||
createRole,
|
||||
deleteRole,
|
||||
getChannel,
|
||||
getRoles,
|
||||
updateRolePositions,
|
||||
} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Guild Role Management', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
describe('New Role Position', () => {
|
||||
test('should have @everyone role at position 0', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const roles = await getRoles(harness, account.token, guild.id);
|
||||
const everyoneRole = roles.find((r) => r.id === guild.id);
|
||||
|
||||
expect(everyoneRole).toBeDefined();
|
||||
expect(everyoneRole!.position).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Role Hierarchy Delete Restrictions', () => {
|
||||
test('should prevent deleting role higher in hierarchy than your highest role', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const moderator = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
const highRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'High Role',
|
||||
permissions: Permissions.MANAGE_ROLES.toString(),
|
||||
});
|
||||
|
||||
const lowRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Low Role',
|
||||
permissions: Permissions.MANAGE_ROLES.toString(),
|
||||
});
|
||||
|
||||
await updateRolePositions(harness, owner.token, guild.id, [
|
||||
{id: highRole.id, position: 3},
|
||||
{id: lowRole.id, position: 2},
|
||||
]);
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, moderator.token, invite.code);
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, moderator.userId, lowRole.id);
|
||||
|
||||
await createBuilder(harness, moderator.token)
|
||||
.delete(`/guilds/${guild.id}/roles/${highRole.id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow deleting role lower in hierarchy than your highest role', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const moderator = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
const highRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'High Role',
|
||||
permissions: Permissions.MANAGE_ROLES.toString(),
|
||||
});
|
||||
|
||||
const lowRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Low Role',
|
||||
});
|
||||
|
||||
await updateRolePositions(harness, owner.token, guild.id, [
|
||||
{id: highRole.id, position: 3},
|
||||
{id: lowRole.id, position: 2},
|
||||
]);
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, moderator.token, invite.code);
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, moderator.userId, highRole.id);
|
||||
|
||||
await deleteRole(harness, moderator.token, guild.id, lowRole.id);
|
||||
|
||||
const roles = await getRoles(harness, owner.token, guild.id);
|
||||
expect(roles.find((r) => r.id === lowRole.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should use ID comparison as tiebreaker when roles have same position', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const moderator = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
const roleA = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Role A',
|
||||
permissions: Permissions.MANAGE_ROLES.toString(),
|
||||
});
|
||||
|
||||
const roleB = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Role B',
|
||||
});
|
||||
|
||||
await updateRolePositions(harness, owner.token, guild.id, [
|
||||
{id: roleA.id, position: 2},
|
||||
{id: roleB.id, position: 2},
|
||||
]);
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, moderator.token, invite.code);
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, moderator.userId, roleA.id);
|
||||
|
||||
const roleAIdLower = String(roleA.id) < String(roleB.id);
|
||||
if (roleAIdLower) {
|
||||
await createBuilder(harness, moderator.token)
|
||||
.delete(`/guilds/${guild.id}/roles/${roleB.id}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
} else {
|
||||
await createBuilder(harness, moderator.token)
|
||||
.delete(`/guilds/${guild.id}/roles/${roleB.id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
|
||||
test('should allow owner to delete any role regardless of position', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
const highRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'High Role',
|
||||
});
|
||||
|
||||
await updateRolePositions(harness, owner.token, guild.id, [{id: highRole.id, position: 10}]);
|
||||
|
||||
await deleteRole(harness, owner.token, guild.id, highRole.id);
|
||||
|
||||
const roles = await getRoles(harness, owner.token, guild.id);
|
||||
expect(roles.find((r) => r.id === highRole.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Role Permissions Validation', () => {
|
||||
test('should prevent user from granting permissions they do not have', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const manager = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
const managerRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Manager',
|
||||
permissions: Permissions.MANAGE_ROLES.toString(),
|
||||
});
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, manager.token, invite.code);
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, manager.userId, managerRole.id);
|
||||
|
||||
await createBuilder(harness, manager.token)
|
||||
.post(`/guilds/${guild.id}/roles`)
|
||||
.body({name: 'New Role', permissions: Permissions.ADMINISTRATOR.toString()})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow user to create role with permissions they have', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const manager = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
const permissions = Permissions.MANAGE_ROLES | Permissions.SEND_MESSAGES;
|
||||
const managerRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Manager',
|
||||
permissions: permissions.toString(),
|
||||
});
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, manager.token, invite.code);
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, manager.userId, managerRole.id);
|
||||
|
||||
const newRole = await createRole(harness, manager.token, guild.id, {
|
||||
name: 'New Role',
|
||||
permissions: Permissions.SEND_MESSAGES.toString(),
|
||||
});
|
||||
|
||||
expect(newRole.name).toBe('New Role');
|
||||
expect(BigInt(newRole.permissions)).toBe(Permissions.SEND_MESSAGES);
|
||||
});
|
||||
|
||||
test('should prevent updating role with permissions user does not have', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const manager = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
const managerRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Manager',
|
||||
permissions: Permissions.MANAGE_ROLES.toString(),
|
||||
});
|
||||
|
||||
const targetRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Target Role',
|
||||
permissions: '0',
|
||||
});
|
||||
|
||||
await updateRolePositions(harness, owner.token, guild.id, [
|
||||
{id: managerRole.id, position: 3},
|
||||
{id: targetRole.id, position: 2},
|
||||
]);
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, manager.token, invite.code);
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, manager.userId, managerRole.id);
|
||||
|
||||
await createBuilder(harness, manager.token)
|
||||
.patch(`/guilds/${guild.id}/roles/${targetRole.id}`)
|
||||
.body({permissions: Permissions.BAN_MEMBERS.toString()})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow owner to grant any permissions', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
const role = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Admin Role',
|
||||
permissions: Permissions.ADMINISTRATOR.toString(),
|
||||
});
|
||||
|
||||
expect(BigInt(role.permissions)).toBe(Permissions.ADMINISTRATOR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bulk Update Role Positions', () => {
|
||||
test('should update multiple role positions at once', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const role1 = await createRole(harness, account.token, guild.id, {name: 'Role 1'});
|
||||
const role2 = await createRole(harness, account.token, guild.id, {name: 'Role 2'});
|
||||
const role3 = await createRole(harness, account.token, guild.id, {name: 'Role 3'});
|
||||
|
||||
await updateRolePositions(harness, account.token, guild.id, [
|
||||
{id: role1.id, position: 3},
|
||||
{id: role2.id, position: 2},
|
||||
{id: role3.id, position: 1},
|
||||
]);
|
||||
|
||||
const roles = await getRoles(harness, account.token, guild.id);
|
||||
const updatedRole1 = roles.find((r) => r.id === role1.id);
|
||||
const updatedRole2 = roles.find((r) => r.id === role2.id);
|
||||
const updatedRole3 = roles.find((r) => r.id === role3.id);
|
||||
|
||||
expect(updatedRole1!.position).toBeGreaterThan(updatedRole2!.position);
|
||||
expect(updatedRole2!.position).toBeGreaterThan(updatedRole3!.position);
|
||||
});
|
||||
|
||||
test('should not allow reordering @everyone role', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}/roles`)
|
||||
.body([{id: guild.id, position: 5}])
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require MANAGE_ROLES permission for bulk position update', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
const role = await createRole(harness, owner.token, guild.id, {name: 'Test Role'});
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/roles`)
|
||||
.body([{id: role.id, position: 5}])
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject invalid role ID in bulk update', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}/roles`)
|
||||
.body([{id: '999999999999999999', position: 5}])
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Role Name Validation', () => {
|
||||
test('should reject role name exceeding 100 characters', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/guilds/${guild.id}/roles`)
|
||||
.body({name: 'a'.repeat(101)})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should accept role name at exactly 100 characters', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const role = await createRole(harness, account.token, guild.id, {
|
||||
name: 'a'.repeat(100),
|
||||
});
|
||||
|
||||
expect(role.name).toBe('a'.repeat(100));
|
||||
});
|
||||
|
||||
test('should require name when creating role', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/guilds/${guild.id}/roles`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should accept role name with unicode characters', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const role = await createRole(harness, account.token, guild.id, {
|
||||
name: 'Moderator',
|
||||
});
|
||||
|
||||
expect(role.name).toBe('Moderator');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Role Color Validation', () => {
|
||||
test('should accept valid color value', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const role = await createRole(harness, account.token, guild.id, {
|
||||
name: 'Colored Role',
|
||||
color: 0xff0000,
|
||||
});
|
||||
|
||||
expect(role.color).toBe(0xff0000);
|
||||
});
|
||||
|
||||
test('should accept color value of 0 (no color)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const role = await createRole(harness, account.token, guild.id, {
|
||||
name: 'No Color Role',
|
||||
color: 0,
|
||||
});
|
||||
|
||||
expect(role.color).toBe(0);
|
||||
});
|
||||
|
||||
test('should accept maximum valid color value (0xFFFFFF)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const role = await createRole(harness, account.token, guild.id, {
|
||||
name: 'Max Color Role',
|
||||
color: 0xffffff,
|
||||
});
|
||||
|
||||
expect(role.color).toBe(0xffffff);
|
||||
});
|
||||
|
||||
test('should reject color value exceeding maximum (> 0xFFFFFF)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/guilds/${guild.id}/roles`)
|
||||
.body({name: 'Invalid Color', color: 0x1000000})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject negative color value', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/guilds/${guild.id}/roles`)
|
||||
.body({name: 'Negative Color', color: -1})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should default to color 0 when not specified', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const role = await createRole(harness, account.token, guild.id, {
|
||||
name: 'Default Color Role',
|
||||
});
|
||||
|
||||
expect(role.color).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
178
packages/api/src/guild/tests/GuildRoleOperations.test.tsx
Normal file
178
packages/api/src/guild/tests/GuildRoleOperations.test.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
addMemberRole,
|
||||
createChannelInvite,
|
||||
createGuild,
|
||||
createRole,
|
||||
deleteRole,
|
||||
getChannel,
|
||||
getRoles,
|
||||
updateRolePositions,
|
||||
} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Guild Role Operations', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('should delete a role', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const role = await createRole(harness, account.token, guild.id, {
|
||||
name: 'Delete Me',
|
||||
});
|
||||
|
||||
await deleteRole(harness, account.token, guild.id, role.id);
|
||||
|
||||
const roles = await getRoles(harness, account.token, guild.id);
|
||||
expect(roles.find((r) => r.id === role.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should not delete @everyone role', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.delete(`/guilds/${guild.id}/roles/${guild.id}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should update role positions', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const role1 = await createRole(harness, account.token, guild.id, {name: 'Role 1'});
|
||||
const role2 = await createRole(harness, account.token, guild.id, {name: 'Role 2'});
|
||||
|
||||
await updateRolePositions(harness, account.token, guild.id, [
|
||||
{id: role1.id, position: 2},
|
||||
{id: role2.id, position: 1},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should require MANAGE_ROLES permission to create role', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/guilds/${guild.id}/roles`)
|
||||
.body({name: 'Unauthorized Role'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require MANAGE_ROLES permission to delete role', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const role = await createRole(harness, owner.token, guild.id, {name: 'Protected Role'});
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.delete(`/guilds/${guild.id}/roles/${role.id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should create role with unicode_emoji', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const role = await createRole(harness, account.token, guild.id, {
|
||||
name: 'Emoji Role',
|
||||
unicode_emoji: '',
|
||||
});
|
||||
|
||||
expect(role.name).toBe('Emoji Role');
|
||||
});
|
||||
|
||||
test('should validate role name length', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/guilds/${guild.id}/roles`)
|
||||
.body({name: 'a'.repeat(101)})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require a name when creating role', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/guilds/${guild.id}/roles`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow MANAGE_ROLES role to create roles', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const managerRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Role Manager',
|
||||
permissions: Permissions.MANAGE_ROLES.toString(),
|
||||
});
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, managerRole.id);
|
||||
|
||||
const newRole = await createRole(harness, member.token, guild.id, {
|
||||
name: 'Member Created Role',
|
||||
});
|
||||
|
||||
expect(newRole.name).toBe('Member Created Role');
|
||||
});
|
||||
});
|
||||
287
packages/api/src/guild/tests/GuildTestUtils.tsx
Normal file
287
packages/api/src/guild/tests/GuildTestUtils.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount, type TestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {GuildRoleResponse} from '@fluxer/schema/src/domains/guild/GuildRoleSchemas';
|
||||
import type {GuildInviteMetadataResponse} from '@fluxer/schema/src/domains/invite/InviteSchemas';
|
||||
|
||||
export async function createGuild(harness: ApiTestHarness, token: string, name: string): Promise<GuildResponse> {
|
||||
return createBuilder<GuildResponse>(harness, token).post('/guilds').body({name}).execute();
|
||||
}
|
||||
|
||||
export async function getGuild(harness: ApiTestHarness, token: string, guildId: string): Promise<GuildResponse> {
|
||||
return createBuilder<GuildResponse>(harness, token).get(`/guilds/${guildId}`).execute();
|
||||
}
|
||||
|
||||
export async function updateGuild(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
updates: Partial<GuildResponse>,
|
||||
): Promise<GuildResponse> {
|
||||
return createBuilder<GuildResponse>(harness, token).patch(`/guilds/${guildId}`).body(updates).execute();
|
||||
}
|
||||
|
||||
export async function leaveGuild(harness: ApiTestHarness, token: string, guildId: string): Promise<void> {
|
||||
await createBuilder(harness, token).delete(`/users/@me/guilds/${guildId}`).expect(204).execute();
|
||||
}
|
||||
|
||||
export async function deleteGuild(harness: ApiTestHarness, token: string, guildId: string): Promise<void> {
|
||||
await createBuilder(harness, token).delete(`/guilds/${guildId}`).expect(204).execute();
|
||||
}
|
||||
|
||||
export async function getUserGuilds(harness: ApiTestHarness, token: string): Promise<Array<GuildResponse>> {
|
||||
return createBuilder<Array<GuildResponse>>(harness, token).get('/users/@me/guilds').execute();
|
||||
}
|
||||
|
||||
export async function createChannel(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
name: string,
|
||||
type = 0,
|
||||
): Promise<ChannelResponse> {
|
||||
return createBuilder<ChannelResponse>(harness, token)
|
||||
.post(`/guilds/${guildId}/channels`)
|
||||
.body({name, type})
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function getChannel(harness: ApiTestHarness, token: string, channelId: string): Promise<ChannelResponse> {
|
||||
return createBuilder<ChannelResponse>(harness, token).get(`/channels/${channelId}`).execute();
|
||||
}
|
||||
|
||||
export async function updateChannel(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
channelId: string,
|
||||
updates: Partial<ChannelResponse>,
|
||||
): Promise<ChannelResponse> {
|
||||
return createBuilder<ChannelResponse>(harness, token).patch(`/channels/${channelId}`).body(updates).execute();
|
||||
}
|
||||
|
||||
export async function deleteChannel(harness: ApiTestHarness, token: string, channelId: string): Promise<void> {
|
||||
await createBuilder(harness, token).delete(`/channels/${channelId}`).expect(204).execute();
|
||||
}
|
||||
|
||||
export async function getGuildChannels(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
): Promise<Array<ChannelResponse>> {
|
||||
return createBuilder<Array<ChannelResponse>>(harness, token).get(`/guilds/${guildId}/channels`).execute();
|
||||
}
|
||||
|
||||
export async function updateChannelPositions(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
positions: Array<{id: string; position?: number; lock_permissions?: boolean | null; parent_id?: string | null}>,
|
||||
): Promise<void> {
|
||||
await createBuilder(harness, token).patch(`/guilds/${guildId}/channels`).body(positions).expect(204).execute();
|
||||
}
|
||||
|
||||
export async function createRole(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
role: Partial<Omit<GuildRoleResponse, 'id' | 'position'>>,
|
||||
): Promise<GuildRoleResponse> {
|
||||
return createBuilder<GuildRoleResponse>(harness, token).post(`/guilds/${guildId}/roles`).body(role).execute();
|
||||
}
|
||||
|
||||
export async function getRoles(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
): Promise<Array<GuildRoleResponse>> {
|
||||
return createBuilder<Array<GuildRoleResponse>>(harness, token).get(`/guilds/${guildId}/roles`).execute();
|
||||
}
|
||||
|
||||
export async function updateRole(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
roleId: string,
|
||||
updates: Partial<GuildRoleResponse>,
|
||||
): Promise<GuildRoleResponse> {
|
||||
return createBuilder<GuildRoleResponse>(harness, token)
|
||||
.patch(`/guilds/${guildId}/roles/${roleId}`)
|
||||
.body(updates)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function deleteRole(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
roleId: string,
|
||||
): Promise<void> {
|
||||
await createBuilder(harness, token).delete(`/guilds/${guildId}/roles/${roleId}`).expect(204).execute();
|
||||
}
|
||||
|
||||
export async function updateRolePositions(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
positions: Array<{id: string; position: number}>,
|
||||
): Promise<void> {
|
||||
await createBuilder(harness, token).patch(`/guilds/${guildId}/roles`).body(positions).expect(204).execute();
|
||||
}
|
||||
|
||||
export async function addMemberRole(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
userId: string,
|
||||
roleId: string,
|
||||
): Promise<void> {
|
||||
await createBuilder(harness, token).put(`/guilds/${guildId}/members/${userId}/roles/${roleId}`).expect(204).execute();
|
||||
}
|
||||
|
||||
export async function removeMemberRole(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
userId: string,
|
||||
roleId: string,
|
||||
): Promise<void> {
|
||||
await createBuilder(harness, token)
|
||||
.delete(`/guilds/${guildId}/members/${userId}/roles/${roleId}`)
|
||||
.expect(204)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function updateMember(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
userId: string,
|
||||
updates: {roles?: Array<string>; nick?: string},
|
||||
): Promise<GuildMemberResponse> {
|
||||
return createBuilder<GuildMemberResponse>(harness, token)
|
||||
.patch(`/guilds/${guildId}/members/${userId}`)
|
||||
.body(updates)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function getMember(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
userId: string,
|
||||
): Promise<GuildMemberResponse> {
|
||||
return createBuilder<GuildMemberResponse>(harness, token).get(`/guilds/${guildId}/members/${userId}`).execute();
|
||||
}
|
||||
|
||||
export async function createChannelInvite(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
channelId: string,
|
||||
): Promise<GuildInviteMetadataResponse> {
|
||||
return createBuilder<GuildInviteMetadataResponse>(harness, token)
|
||||
.post(`/channels/${channelId}/invites`)
|
||||
.body({})
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function acceptInvite(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
inviteCode: string,
|
||||
): Promise<{guild: GuildResponse}> {
|
||||
return createBuilder<{guild: GuildResponse}>(harness, token).post(`/invites/${inviteCode}`).body(null).execute();
|
||||
}
|
||||
|
||||
export async function getInvite(harness: ApiTestHarness, inviteCode: string): Promise<GuildInviteMetadataResponse> {
|
||||
return createBuilder<GuildInviteMetadataResponse>(harness, '').get(`/invites/${inviteCode}`).execute();
|
||||
}
|
||||
|
||||
export async function deleteInvite(harness: ApiTestHarness, token: string, inviteCode: string): Promise<void> {
|
||||
await createBuilder(harness, token).delete(`/invites/${inviteCode}`).expect(204).execute();
|
||||
}
|
||||
|
||||
export async function setupTestGuild(
|
||||
harness: ApiTestHarness,
|
||||
account?: TestAccount,
|
||||
): Promise<{
|
||||
account: TestAccount;
|
||||
guild: GuildResponse;
|
||||
}> {
|
||||
const testAccount = account ?? (await createTestAccount(harness));
|
||||
const guild = await createGuild(harness, testAccount.token, 'Test Guild');
|
||||
return {account: testAccount, guild};
|
||||
}
|
||||
|
||||
export async function setupTestGuildWithChannels(
|
||||
harness: ApiTestHarness,
|
||||
account?: TestAccount,
|
||||
): Promise<{
|
||||
account: TestAccount;
|
||||
guild: GuildResponse;
|
||||
channels: Array<ChannelResponse>;
|
||||
}> {
|
||||
const testAccount = account ?? (await createTestAccount(harness));
|
||||
const guild = await createGuild(harness, testAccount.token, 'Test Guild');
|
||||
|
||||
const channels: Array<ChannelResponse> = [];
|
||||
if (guild.system_channel_id) {
|
||||
channels.push(await getChannel(harness, testAccount.token, guild.system_channel_id));
|
||||
}
|
||||
|
||||
return {account: testAccount, guild, channels};
|
||||
}
|
||||
|
||||
export async function setupTestGuildWithMembers(
|
||||
harness: ApiTestHarness,
|
||||
memberCount = 2,
|
||||
): Promise<{
|
||||
owner: TestAccount;
|
||||
members: Array<TestAccount>;
|
||||
guild: GuildResponse;
|
||||
channels: Array<ChannelResponse>;
|
||||
}> {
|
||||
const owner = await createTestAccount(harness);
|
||||
const members: Array<TestAccount> = [];
|
||||
|
||||
for (let i = 0; i < memberCount; i++) {
|
||||
members.push(await createTestAccount(harness));
|
||||
}
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const channels: Array<ChannelResponse> = [];
|
||||
|
||||
if (guild.system_channel_id) {
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id);
|
||||
channels.push(systemChannel);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
for (const member of members) {
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
}
|
||||
}
|
||||
|
||||
return {owner, members, guild, channels};
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {acceptInvite, createChannelInvite, createGuild} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
|
||||
|
||||
const STAFF_TEST_FLAGS = UserFlags.HAS_SESSION_STARTED | UserFlags.STAFF;
|
||||
|
||||
async function addGuildFeaturesForTesting(
|
||||
harness: ApiTestHarness,
|
||||
guildId: string,
|
||||
features: Array<string>,
|
||||
): Promise<void> {
|
||||
await createBuilder<{success: boolean}>(harness, '')
|
||||
.post(`/test/guilds/${guildId}/features`)
|
||||
.body({add_features: features})
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function setUserFlagsForTesting(harness: ApiTestHarness, userId: string, flags: bigint): Promise<void> {
|
||||
await createBuilder<{success: boolean}>(harness, '')
|
||||
.patch(`/test/users/${userId}/flags`)
|
||||
.body({flags: flags.toString()})
|
||||
.execute();
|
||||
}
|
||||
|
||||
describe('Guild unavailable feature access checks', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
it('blocks /guilds/* and /channels/* when UNAVAILABLE_FOR_EVERYONE is enabled', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Unavailable for everyone');
|
||||
|
||||
if (!guild.system_channel_id) {
|
||||
throw new Error('Guild system channel is missing');
|
||||
}
|
||||
|
||||
await setUserFlagsForTesting(harness, owner.userId, STAFF_TEST_FLAGS);
|
||||
await addGuildFeaturesForTesting(harness, guild.id, [GuildFeatures.UNAVAILABLE_FOR_EVERYONE]);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_ACCESS)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/channels/${guild.system_channel_id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_ACCESS)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('blocks non-staff and allows staff for UNAVAILABLE_FOR_EVERYONE_BUT_STAFF', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Unavailable for everyone but staff');
|
||||
|
||||
if (!guild.system_channel_id) {
|
||||
throw new Error('Guild system channel is missing');
|
||||
}
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await addGuildFeaturesForTesting(harness, guild.id, [GuildFeatures.UNAVAILABLE_FOR_EVERYONE_BUT_STAFF]);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.get(`/guilds/${guild.id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_ACCESS)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.get(`/channels/${guild.system_channel_id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_ACCESS)
|
||||
.execute();
|
||||
|
||||
await setUserFlagsForTesting(harness, owner.userId, STAFF_TEST_FLAGS);
|
||||
|
||||
await createBuilder(harness, owner.token).get(`/guilds/${guild.id}`).expect(HTTP_STATUS.OK).execute();
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/channels/${guild.system_channel_id}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
237
packages/api/src/guild/tests/InvitePermissions.test.tsx
Normal file
237
packages/api/src/guild/tests/InvitePermissions.test.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
addMemberRole,
|
||||
createChannelInvite,
|
||||
createGuild,
|
||||
createRole,
|
||||
deleteInvite,
|
||||
getChannel,
|
||||
getRoles,
|
||||
setupTestGuildWithMembers,
|
||||
updateRole,
|
||||
} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {GuildInviteMetadataResponse} from '@fluxer/schema/src/domains/invite/InviteSchemas';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Invite Permissions', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('cannot create invite without CREATE_INSTANT_INVITE permission', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const roles = await getRoles(harness, owner.token, guild.id);
|
||||
const everyoneRole = roles.find((r) => r.id === guild.id);
|
||||
|
||||
if (everyoneRole) {
|
||||
const permissions = BigInt(everyoneRole.permissions);
|
||||
const newPermissions = permissions & ~Permissions.CREATE_INSTANT_INVITE;
|
||||
await updateRole(harness, owner.token, guild.id, everyoneRole.id, {
|
||||
permissions: newPermissions.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/channels/${systemChannel.id}/invites`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('can create invite with CREATE_INSTANT_INVITE permission', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const inviterRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Inviter',
|
||||
permissions: Permissions.CREATE_INSTANT_INVITE.toString(),
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, inviterRole.id);
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createBuilder<GuildInviteMetadataResponse>(harness, member.token)
|
||||
.post(`/channels/${systemChannel.id}/invites`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(invite.code).toBeTruthy();
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
});
|
||||
|
||||
test('owner can always create invites', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
expect(invite.code).toBeTruthy();
|
||||
expect(invite.inviter?.id).toBe(owner.userId);
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
});
|
||||
|
||||
test('non-member cannot create invite', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const nonMember = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
await createBuilder(harness, nonMember.token)
|
||||
.post(`/channels/${systemChannel.id}/invites`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('can create unlimited invite with max_age 0', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createBuilder<GuildInviteMetadataResponse>(harness, owner.token)
|
||||
.post(`/channels/${systemChannel.id}/invites`)
|
||||
.body({max_age: 0})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(invite.max_age).toBe(0);
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
});
|
||||
|
||||
test('can create unlimited invite with max_uses 0', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createBuilder<GuildInviteMetadataResponse>(harness, owner.token)
|
||||
.post(`/channels/${systemChannel.id}/invites`)
|
||||
.body({max_uses: 0})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(invite.max_uses).toBe(0);
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
});
|
||||
|
||||
test('invite can be retrieved after use', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const joiner = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await acceptInvite(harness, joiner.token, invite.code);
|
||||
|
||||
const updatedInvite = await createBuilder<GuildInviteMetadataResponse>(harness, owner.token)
|
||||
.get(`/invites/${invite.code}?with_counts=true`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(updatedInvite.code).toBe(invite.code);
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
});
|
||||
|
||||
test('invite includes inviter information', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
expect(invite.inviter?.id).toBe(owner.userId);
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
});
|
||||
|
||||
test('list channel invites requires MANAGE_CHANNELS permission', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.get(`/channels/${systemChannel.id}/invites`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('owner can list channel invites', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
const invites = await createBuilder<Array<GuildInviteMetadataResponse>>(harness, owner.token)
|
||||
.get(`/channels/${systemChannel.id}/invites`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(invites.length).toBeGreaterThanOrEqual(1);
|
||||
expect(invites.some((i) => i.code === invite.code)).toBe(true);
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
});
|
||||
|
||||
test('cannot create invite for channel in another guild', async () => {
|
||||
const owner1 = await createTestAccount(harness);
|
||||
const owner2 = await createTestAccount(harness);
|
||||
|
||||
const guild1 = await createGuild(harness, owner1.token, 'Guild 1');
|
||||
await createGuild(harness, owner2.token, 'Guild 2');
|
||||
|
||||
const channel1 = await getChannel(harness, owner1.token, guild1.system_channel_id!);
|
||||
|
||||
await createBuilder(harness, owner2.token)
|
||||
.post(`/channels/${channel1.id}/invites`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
225
packages/api/src/guild/tests/InviteSecurity.test.tsx
Normal file
225
packages/api/src/guild/tests/InviteSecurity.test.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
addMemberRole,
|
||||
createChannelInvite,
|
||||
createGuild,
|
||||
createRole,
|
||||
deleteInvite,
|
||||
getChannel,
|
||||
setupTestGuildWithMembers,
|
||||
} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Invite Security', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('guild members can view invites', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
const inviteData = await createBuilder<{code: string}>(harness, member.token)
|
||||
.get(`/invites/${invite.code}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(inviteData.code).toBe(invite.code);
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
});
|
||||
|
||||
test('non-members can view public invites', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const nonMember = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await createBuilder(harness, nonMember.token).get(`/invites/${invite.code}`).expect(HTTP_STATUS.OK).execute();
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
});
|
||||
|
||||
test('only owner can delete invites by default', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.delete(`/invites/${invite.code}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.delete(`/invites/${invite.code}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('member with MANAGE_GUILD permission can delete invites', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const managerRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Manager',
|
||||
permissions: Permissions.MANAGE_GUILD.toString(),
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, managerRole.id);
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.delete(`/invites/${invite.code}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('deleted invites become inaccessible', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
const inviteCode = invite.code;
|
||||
|
||||
await createBuilder(harness, owner.token).get(`/invites/${inviteCode}`).expect(HTTP_STATUS.OK).execute();
|
||||
|
||||
await deleteInvite(harness, owner.token, inviteCode);
|
||||
|
||||
await createBuilder(harness, owner.token).get(`/invites/${inviteCode}`).expect(HTTP_STATUS.NOT_FOUND).execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/invites/${inviteCode}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('unauthenticated requests can view public invites', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
const inviteData = await createBuilderWithoutAuth<{code: string; guild: {name: string}}>(harness)
|
||||
.get(`/invites/${invite.code}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(inviteData.code).toBe(invite.code);
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
});
|
||||
|
||||
test('unauthenticated requests cannot accept invites', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/invites/${invite.code}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
});
|
||||
|
||||
test('invite creator can delete their own invite', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const createInvitesRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Inviter',
|
||||
permissions: Permissions.CREATE_INSTANT_INVITE.toString(),
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, createInvitesRole.id);
|
||||
|
||||
const memberInvite = await createChannelInvite(harness, member.token, systemChannel.id);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.delete(`/invites/${memberInvite.code}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('member cannot delete invites created by others without permission', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [member1, member2] = members;
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const inviterRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Inviter',
|
||||
permissions: Permissions.CREATE_INSTANT_INVITE.toString(),
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member1.userId, inviterRole.id);
|
||||
await addMemberRole(harness, owner.token, guild.id, member2.userId, inviterRole.id);
|
||||
|
||||
const member1Invite = await createChannelInvite(harness, member1.token, systemChannel.id);
|
||||
|
||||
await createBuilder(harness, member2.token)
|
||||
.delete(`/invites/${member1Invite.code}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
|
||||
await deleteInvite(harness, owner.token, member1Invite.code);
|
||||
});
|
||||
|
||||
test('double deletion returns not found', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
|
||||
await createBuilder(harness, owner.token).delete(`/invites/${invite.code}`).expect(HTTP_STATUS.NOT_FOUND).execute();
|
||||
});
|
||||
});
|
||||
102
packages/api/src/guild/tests/InviteValidation.test.tsx
Normal file
102
packages/api/src/guild/tests/InviteValidation.test.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createGuild, deleteInvite, getChannel} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import type {GuildInviteMetadataResponse} from '@fluxer/schema/src/domains/invite/InviteSchemas';
|
||||
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Invite Validation', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
it('should reject getting nonexistent invite', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token).get('/invites/invalidcode123').expect(HTTP_STATUS.NOT_FOUND).execute();
|
||||
});
|
||||
|
||||
it('should reject accepting nonexistent invite', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/invites/invalidcode123')
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should reject deleting nonexistent invite', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.delete('/invites/invalidcode123')
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should reject invalid max_uses value', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Invite Validation Guild');
|
||||
const channel = await getChannel(harness, account.token, guild.system_channel_id!);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/channels/${channel.id}/invites`)
|
||||
.body({max_uses: -1})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should reject invalid max_age value', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Invite Validation Guild');
|
||||
const channel = await getChannel(harness, account.token, guild.system_channel_id!);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/channels/${channel.id}/invites`)
|
||||
.body({max_age: -1})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should accept valid max_uses and max_age', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Invite Validation Guild');
|
||||
const channel = await getChannel(harness, account.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createBuilder<GuildInviteMetadataResponse>(harness, account.token)
|
||||
.post(`/channels/${channel.id}/invites`)
|
||||
.body({max_uses: 5, max_age: 3600})
|
||||
.execute();
|
||||
|
||||
expect(invite.code).toBeTruthy();
|
||||
|
||||
await deleteInvite(harness, account.token, invite.code);
|
||||
});
|
||||
});
|
||||
146
packages/api/src/guild/tests/RoleHierarchyEnforcement.test.tsx
Normal file
146
packages/api/src/guild/tests/RoleHierarchyEnforcement.test.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
createChannelInvite,
|
||||
createGuild,
|
||||
createRole,
|
||||
getChannel,
|
||||
getMember,
|
||||
updateMember,
|
||||
updateRole,
|
||||
updateRolePositions,
|
||||
} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Role Hierarchy Enforcement', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
it('should allow moderator to modify lower role but not equal/higher roles', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const moderator = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Role Hierarchy Guild');
|
||||
|
||||
const modRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Moderator',
|
||||
color: 65280,
|
||||
permissions: '268435456',
|
||||
hoist: true,
|
||||
});
|
||||
|
||||
const memberRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Member',
|
||||
color: 16711680,
|
||||
permissions: '0',
|
||||
hoist: false,
|
||||
});
|
||||
|
||||
await updateRolePositions(harness, owner.token, guild.id, [
|
||||
{id: modRole.id, position: 2},
|
||||
{id: memberRole.id, position: 1},
|
||||
]);
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await acceptInvite(harness, moderator.token, invite.code);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await updateMember(harness, owner.token, guild.id, moderator.userId, {
|
||||
roles: [modRole.id],
|
||||
});
|
||||
|
||||
await updateMember(harness, owner.token, guild.id, member.userId, {
|
||||
roles: [memberRole.id],
|
||||
});
|
||||
|
||||
const updatedMemberRole = await updateRole(harness, moderator.token, guild.id, memberRole.id, {
|
||||
color: 255,
|
||||
});
|
||||
|
||||
expect(updatedMemberRole.color).toBe(255);
|
||||
|
||||
await createBuilder(harness, moderator.token)
|
||||
.patch(`/guilds/${guild.id}/roles/${modRole.id}`)
|
||||
.body({permissions: '8'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should prevent member from assigning higher role to themselves via @me endpoint', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const moderator = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Role Hierarchy Guild');
|
||||
|
||||
const modRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Moderator',
|
||||
color: 65280,
|
||||
permissions: '268435456',
|
||||
hoist: true,
|
||||
});
|
||||
|
||||
const memberRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Member',
|
||||
color: 16711680,
|
||||
permissions: '0',
|
||||
hoist: false,
|
||||
});
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await acceptInvite(harness, moderator.token, invite.code);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await updateMember(harness, owner.token, guild.id, moderator.userId, {
|
||||
roles: [modRole.id],
|
||||
});
|
||||
|
||||
await updateMember(harness, owner.token, guild.id, member.userId, {
|
||||
roles: [memberRole.id],
|
||||
});
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({roles: [modRole.id]})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const fetchedMember = await getMember(harness, owner.token, guild.id, member.userId);
|
||||
expect(fetchedMember.roles).not.toContain(modRole.id);
|
||||
expect(fetchedMember.roles).toContain(memberRole.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
createChannelInvite,
|
||||
createGuild,
|
||||
createRole,
|
||||
getChannel,
|
||||
updateRole,
|
||||
} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Role Permission Assignment Hierarchy', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
it('should prevent users from granting permissions they do not possess', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const manager = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Role Hierarchy Guild');
|
||||
|
||||
const roleHigh = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'High',
|
||||
permissions: String(1 << 28),
|
||||
});
|
||||
|
||||
const roleMid = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Mid',
|
||||
permissions: String(1 << 11),
|
||||
});
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await acceptInvite(harness, manager.token, invite.code);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.put(`/guilds/${guild.id}/members/${manager.userId}/roles/${roleMid.id}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, manager.token)
|
||||
.patch(`/guilds/${guild.id}/roles/${roleHigh.id}`)
|
||||
.body({permissions: String(1 << 28)})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, manager.token)
|
||||
.patch(`/guilds/${guild.id}/roles/${roleMid.id}`)
|
||||
.body({permissions: String(1 << 28)})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
|
||||
const updatedRole = await updateRole(harness, owner.token, guild.id, roleMid.id, {
|
||||
permissions: String((1 << 11) | (1 << 13)),
|
||||
});
|
||||
|
||||
expect(updatedRole.permissions).toBe(String((1 << 11) | (1 << 13)));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user