feat(discovery): more work on discovery plus a few fixes

This commit is contained in:
Hampus Kraft
2026-02-17 15:41:08 +00:00
parent b19e9fb243
commit 302c0d2a0c
137 changed files with 7116 additions and 2047 deletions

View File

@@ -190,7 +190,7 @@ export const DiscoveryPage: FC<DiscoveryPageProps> = ({
{app.guild_id}
</a>
</TableCell>
<TableCell>{getCategoryLabel(app.category_id)}</TableCell>
<TableCell>{getCategoryLabel(app.category_type)}</TableCell>
<TableCell>
<span class="block max-w-xs truncate" title={app.description}>
{app.description}

View File

@@ -362,6 +362,10 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
enabled: master.federation.enabled,
}
: undefined,
discovery: {
enabled: master.discovery.enabled,
minMemberCount: master.discovery.min_member_count,
},
dev: {
relaxRegistrationRateLimits: master.dev.relax_registration_rate_limits,
disableRateLimits: master.dev.disable_rate_limits,

View File

@@ -36,6 +36,7 @@ import {SystemDmService} from '@fluxer/api/src/admin/services/SystemDmService';
import type {AuthService} from '@fluxer/api/src/auth/AuthService';
import type {AttachmentID, ChannelID, GuildID, ReportID, UserID} from '@fluxer/api/src/BrandedTypes';
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
import {GuildDiscoveryRepository} from '@fluxer/api/src/guild/repositories/GuildDiscoveryRepository';
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
import type {GuildService} from '@fluxer/api/src/guild/services/GuildService';
import type {IDiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService';
@@ -206,6 +207,7 @@ export class AdminService {
gatewayService: this.gatewayService,
entityAssetService: this.entityAssetService,
auditService: this.auditService,
discoveryRepository: new GuildDiscoveryRepository(),
});
this.assetPurgeService = new AdminAssetPurgeService({
guildRepository: this.guildRepository,

View File

@@ -41,7 +41,7 @@ function mapDiscoveryRowToResponse(row: GuildDiscoveryRow) {
guild_id: row.guild_id.toString(),
status: row.status,
description: row.description,
category_id: row.category_id,
category_type: row.category_type,
applied_at: row.applied_at.toISOString(),
reviewed_at: row.reviewed_at?.toISOString() ?? null,
review_reason: row.review_reason ?? null,

View File

@@ -27,6 +27,7 @@ import {AdminGuildUpdateService} from '@fluxer/api/src/admin/services/guild/Admi
import {AdminGuildVanityService} from '@fluxer/api/src/admin/services/guild/AdminGuildVanityService';
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
import type {IGuildDiscoveryRepository} from '@fluxer/api/src/guild/repositories/GuildDiscoveryRepository';
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
import type {GuildService} from '@fluxer/api/src/guild/services/GuildService';
import type {EntityAssetService} from '@fluxer/api/src/infrastructure/EntityAssetService';
@@ -60,6 +61,7 @@ interface AdminGuildServiceDeps {
gatewayService: IGatewayService;
entityAssetService: EntityAssetService;
auditService: AdminAuditService;
discoveryRepository: IGuildDiscoveryRepository;
}
export class AdminGuildService {
@@ -74,6 +76,7 @@ export class AdminGuildService {
constructor(deps: AdminGuildServiceDeps) {
this.updatePropagator = new AdminGuildUpdatePropagator({
gatewayService: deps.gatewayService,
discoveryRepository: deps.discoveryRepository,
});
this.lookupService = new AdminGuildLookupService({

View File

@@ -19,22 +19,45 @@
import type {GuildID} from '@fluxer/api/src/BrandedTypes';
import {mapGuildToGuildResponse} from '@fluxer/api/src/guild/GuildModel';
import type {IGuildDiscoveryRepository} from '@fluxer/api/src/guild/repositories/GuildDiscoveryRepository';
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 {getGuildSearchService} from '@fluxer/api/src/SearchFactory';
import type {GuildDiscoveryContext} from '@fluxer/api/src/search/guild/GuildSearchSerializer';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
interface AdminGuildUpdatePropagatorDeps {
gatewayService: IGatewayService;
discoveryRepository: IGuildDiscoveryRepository;
}
export class AdminGuildUpdatePropagator {
constructor(private readonly deps: AdminGuildUpdatePropagatorDeps) {}
async dispatchGuildUpdate(guildId: GuildID, updatedGuild: Guild): Promise<void> {
const {gatewayService} = this.deps;
const {gatewayService, discoveryRepository} = this.deps;
await gatewayService.dispatchGuild({
guildId,
event: 'GUILD_UPDATE',
data: mapGuildToGuildResponse(updatedGuild),
});
const guildSearchService = getGuildSearchService();
if (guildSearchService) {
let discoveryContext: GuildDiscoveryContext | undefined;
if (updatedGuild.features.has(GuildFeatures.DISCOVERABLE)) {
const discoveryRow = await discoveryRepository.findByGuildId(guildId);
if (discoveryRow) {
discoveryContext = {
description: discoveryRow.description,
categoryId: discoveryRow.category_type,
};
}
}
await guildSearchService.updateGuild(updatedGuild, discoveryContext).catch((error) => {
Logger.error({guildId, error}, 'Failed to update guild in search after admin update');
});
}
}
}

View File

@@ -30,7 +30,10 @@ import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponse
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();
await createBuilder(harness, '')
.post(`/test/guilds/${guildId}/member-count`)
.body({member_count: memberCount})
.execute();
}
async function createGuildWithApplication(
@@ -45,7 +48,7 @@ async function createGuildWithApplication(
const application = await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description, category_id: categoryId})
.body({description, category_type: categoryId})
.expect(HTTP_STATUS.OK)
.execute();

View File

@@ -23,6 +23,7 @@ import type {IChannelRepositoryAggregate} from '@fluxer/api/src/channel/reposito
import type {ChannelAuthService} from '@fluxer/api/src/channel/services/channel_data/ChannelAuthService';
import type {ChannelUtilsService} from '@fluxer/api/src/channel/services/channel_data/ChannelUtilsService';
import type {GuildAuditLogService} from '@fluxer/api/src/guild/GuildAuditLogService';
import {mapGuildToGuildResponse} from '@fluxer/api/src/guild/GuildModel';
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
import {ChannelHelpers} from '@fluxer/api/src/guild/services/channel/ChannelHelpers';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
@@ -404,6 +405,30 @@ export class ChannelOperationsService {
});
await this.channelRepository.channelData.delete(channelId, guildId);
const guildModel = await this.guildRepository.findUnique(guildId);
if (guildModel) {
const guildRow = guildModel.toRow();
const needsUpdate =
guildRow.system_channel_id === channelId ||
guildRow.rules_channel_id === channelId ||
guildRow.afk_channel_id === channelId;
if (needsUpdate) {
const updatedGuild = await this.guildRepository.upsert({
...guildRow,
system_channel_id: guildRow.system_channel_id === channelId ? null : guildRow.system_channel_id,
rules_channel_id: guildRow.rules_channel_id === channelId ? null : guildRow.rules_channel_id,
afk_channel_id: guildRow.afk_channel_id === channelId ? null : guildRow.afk_channel_id,
});
await this.gatewayService.dispatchGuild({
guildId,
event: 'GUILD_UPDATE',
data: mapGuildToGuildResponse(updatedGuild),
});
}
}
} else {
await this.userRepository.closeDmForUser(userId, channelId);
await this.channelUtilsService.dispatchDmChannelDelete({channel, userId, requestCache});

View File

@@ -246,7 +246,7 @@ export class MessageSendService {
await checkPermission(Permissions.READ_MESSAGE_HISTORY);
}
if (channel && !isForwardMessage && (data.content !== undefined || referencedMessage !== null)) {
if (channel && !isForwardMessage && (data.content !== undefined || data.message_reference != null)) {
const mentionContent = data.content ?? '';
const mentions = this.deps.mentionService.extractMentions({
content: mentionContent,
@@ -315,7 +315,7 @@ export class MessageSendService {
this.ensureForwardGuildMatches({data, referencedChannelGuildId});
if (channel && !isForwardMessage && (data.content !== undefined || referencedMessage !== null)) {
if (channel && !isForwardMessage && (data.content !== undefined || data.message_reference != null)) {
const mentionContent = data.content ?? '';
const mentions = this.deps.mentionService.extractMentions({
content: mentionContent,
@@ -785,7 +785,7 @@ export class MessageSendService {
mentionHere: boolean;
}
| undefined;
if (channel && !isForwardMessage && (data.content !== undefined || referencedMessage !== null)) {
if (channel && !isForwardMessage && (data.content !== undefined || data.message_reference != null)) {
const mentionContent = data.content ?? '';
const mentions = this.deps.mentionService.extractMentions({
content: mentionContent,

View File

@@ -248,6 +248,11 @@ export interface APIConfig {
enabled: boolean;
};
discovery: {
enabled: boolean;
minMemberCount: number;
};
dev: {
relaxRegistrationRateLimits: boolean;
disableRateLimits: boolean;

View File

@@ -24,7 +24,7 @@ type Nullish<T> = T | null;
export interface GuildDiscoveryRow {
guild_id: GuildID;
status: string;
category_id: number;
category_type: number;
description: string;
applied_at: Date;
reviewed_at: Nullish<Date>;
@@ -38,7 +38,7 @@ export interface GuildDiscoveryRow {
export const GUILD_DISCOVERY_COLUMNS = [
'guild_id',
'status',
'category_id',
'category_type',
'description',
'applied_at',
'reviewed_at',

View File

@@ -18,6 +18,7 @@
*/
import {createGuildID} from '@fluxer/api/src/BrandedTypes';
import {Config} from '@fluxer/api/src/Config';
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';
@@ -28,7 +29,7 @@ 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 {DiscoveryDisabledError} from '@fluxer/errors/src/domains/discovery/DiscoveryDisabledError';
import {DiscoveryNotDiscoverableError} from '@fluxer/errors/src/domains/discovery/DiscoveryNotDiscoverableError';
import {GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {
@@ -38,14 +39,21 @@ import {
DiscoveryCategoryListResponse,
DiscoveryGuildListResponse,
DiscoverySearchQuery,
DiscoveryStatusResponse,
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
function ensureDiscoveryEnabled(): void {
if (!Config.discovery.enabled) {
throw new DiscoveryDisabledError();
}
}
function mapDiscoveryRowToResponse(row: GuildDiscoveryRow) {
return {
guild_id: row.guild_id.toString(),
status: row.status,
description: row.description,
category_id: row.category_id,
category_type: row.category_type,
applied_at: row.applied_at.toISOString(),
reviewed_at: row.reviewed_at?.toISOString() ?? null,
review_reason: row.review_reason ?? null,
@@ -68,6 +76,7 @@ export function GuildDiscoveryController(app: HonoApp) {
tags: ['Discovery'],
}),
async (ctx) => {
ensureDiscoveryEnabled();
const query = ctx.req.valid('query');
const discoveryService = ctx.get('discoveryService');
@@ -120,6 +129,7 @@ export function GuildDiscoveryController(app: HonoApp) {
tags: ['Discovery'],
}),
async (ctx) => {
ensureDiscoveryEnabled();
const user = ctx.get('user');
const {guild_id} = ctx.req.valid('param');
const guildId = createGuildID(guild_id);
@@ -157,6 +167,7 @@ export function GuildDiscoveryController(app: HonoApp) {
tags: ['Discovery'],
}),
async (ctx) => {
ensureDiscoveryEnabled();
const user = ctx.get('user');
const {guild_id} = ctx.req.valid('param');
const guildId = createGuildID(guild_id);
@@ -175,7 +186,7 @@ export function GuildDiscoveryController(app: HonoApp) {
guildId,
userId: user.id,
description: data.description,
categoryId: data.category_id,
categoryId: data.category_type,
});
return ctx.json(mapDiscoveryRowToResponse(row));
@@ -199,6 +210,7 @@ export function GuildDiscoveryController(app: HonoApp) {
tags: ['Discovery'],
}),
async (ctx) => {
ensureDiscoveryEnabled();
const user = ctx.get('user');
const {guild_id} = ctx.req.valid('param');
const guildId = createGuildID(guild_id);
@@ -239,6 +251,7 @@ export function GuildDiscoveryController(app: HonoApp) {
tags: ['Discovery'],
}),
async (ctx) => {
ensureDiscoveryEnabled();
const user = ctx.get('user');
const {guild_id} = ctx.req.valid('param');
const guildId = createGuildID(guild_id);
@@ -266,8 +279,8 @@ export function GuildDiscoveryController(app: HonoApp) {
OpenAPI({
operationId: 'get_discovery_status',
summary: 'Get discovery status',
description: 'Get the current discovery status of a guild. Requires MANAGE_GUILD permission.',
responseSchema: DiscoveryApplicationResponse,
description: 'Get the current discovery status and eligibility of a guild. Requires MANAGE_GUILD permission.',
responseSchema: DiscoveryStatusResponse,
statusCode: 200,
security: ['sessionToken', 'bearerToken', 'botToken'],
tags: ['Discovery'],
@@ -286,12 +299,15 @@ export function GuildDiscoveryController(app: HonoApp) {
throw new MissingPermissionsError();
}
const row = await ctx.get('discoveryService').getStatus(guildId);
if (!row) {
throw new DiscoveryApplicationNotFoundError();
}
const discoveryService = ctx.get('discoveryService');
const row = await discoveryService.getStatus(guildId);
const eligibility = await discoveryService.getEligibility(guildId);
return ctx.json(mapDiscoveryRowToResponse(row));
return ctx.json({
application: row ? mapDiscoveryRowToResponse(row) : null,
eligible: Config.discovery.enabled && eligibility.eligible,
min_member_count: eligibility.min_member_count,
});
},
);
}

View File

@@ -21,6 +21,7 @@ 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';
import {DiscoveryCategories} from '@fluxer/constants/src/DiscoveryConstants';
const FETCH_DISCOVERY_BY_GUILD_ID = GuildDiscovery.selectCql({
where: GuildDiscovery.where.eq('guild_id'),
@@ -46,9 +47,13 @@ export abstract class IGuildDiscoveryRepository {
export class GuildDiscoveryRepository extends IGuildDiscoveryRepository {
async findByGuildId(guildId: GuildID): Promise<GuildDiscoveryRow | null> {
return fetchOne<GuildDiscoveryRow>(FETCH_DISCOVERY_BY_GUILD_ID, {
const row = await fetchOne<GuildDiscoveryRow>(FETCH_DISCOVERY_BY_GUILD_ID, {
guild_id: guildId,
});
if (row) {
row.category_type ??= DiscoveryCategories.GAMING;
}
return row;
}
async listByStatus(status: string, limit: number): Promise<Array<GuildDiscoveryByStatusRow>> {
@@ -64,7 +69,7 @@ export class GuildDiscoveryRepository extends IGuildDiscoveryRepository {
GuildDiscovery.insert({
guild_id: row.guild_id,
status: row.status,
category_id: row.category_id,
category_type: row.category_type,
description: row.description,
applied_at: row.applied_at,
reviewed_at: row.reviewed_at,
@@ -116,7 +121,7 @@ export class GuildDiscoveryRepository extends IGuildDiscoveryRepository {
GuildDiscovery.insert({
guild_id: updatedRow.guild_id,
status: updatedRow.status,
category_id: updatedRow.category_id,
category_type: updatedRow.category_type,
description: updatedRow.description,
applied_at: updatedRow.applied_at,
reviewed_at: updatedRow.reviewed_at,

View File

@@ -21,6 +21,7 @@ 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 {GuildDiscoveryRepository} from '@fluxer/api/src/guild/repositories/GuildDiscoveryRepository';
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';
@@ -79,6 +80,7 @@ export class GuildDataService {
this.webhookRepository,
this.helpers,
this.limitConfigService,
new GuildDiscoveryRepository(),
this.guildManagedTraitService,
);

View File

@@ -26,8 +26,6 @@ import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories
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,
@@ -42,7 +40,7 @@ import {DiscoveryNotDiscoverableError} from '@fluxer/errors/src/domains/discover
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));
const VALID_CATEGORY_TYPES = new Set<number>(Object.values(DiscoveryCategories));
export abstract class IGuildDiscoveryService {
abstract apply(params: {
@@ -68,6 +66,8 @@ export abstract class IGuildDiscoveryService {
abstract remove(params: {guildId: GuildID; adminUserId: UserID; reason: string}): Promise<GuildDiscoveryRow>;
abstract getEligibility(guildId: GuildID): Promise<{eligible: boolean; min_member_count: number}>;
abstract listByStatus(params: {status: string; limit: number}): Promise<Array<GuildDiscoveryRow>>;
abstract searchDiscoverable(params: {
@@ -84,7 +84,7 @@ export interface DiscoveryGuildResult {
name: string;
icon: string | null;
description: string | null;
category_id: number;
category_type: number;
member_count: number;
online_count: number;
features: Array<string>;
@@ -109,7 +109,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
}): Promise<GuildDiscoveryRow> {
const {guildId, description, categoryId} = params;
if (!VALID_CATEGORY_IDS.has(categoryId)) {
if (!VALID_CATEGORY_TYPES.has(categoryId)) {
throw new DiscoveryInvalidCategoryError();
}
@@ -118,8 +118,8 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
throw new DiscoveryApplicationNotFoundError();
}
const minMembers = Config.dev.testModeEnabled ? DISCOVERY_MIN_MEMBER_COUNT_DEV : DISCOVERY_MIN_MEMBER_COUNT;
if (guild.memberCount < minMembers) {
const {eligible} = await this.getEligibility(guildId);
if (!eligible) {
throw new DiscoveryInsufficientMembersError();
}
@@ -137,7 +137,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
const row: GuildDiscoveryRow = {
guild_id: guildId,
status: DiscoveryApplicationStatus.PENDING,
category_id: categoryId as DiscoveryCategory,
category_type: categoryId as DiscoveryCategory,
description,
applied_at: now,
reviewed_at: null,
@@ -175,14 +175,15 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
throw new DiscoveryApplicationAlreadyReviewedError();
}
if (data.category_id !== undefined && !VALID_CATEGORY_IDS.has(data.category_id)) {
if (data.category_type !== undefined && !VALID_CATEGORY_TYPES.has(data.category_type)) {
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,
category_type:
data.category_type !== undefined ? (data.category_type as DiscoveryCategory) : existing.category_type,
};
await this.discoveryRepository.updateStatus(guildId, existing.status, existing.applied_at, updatedRow);
@@ -192,7 +193,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
if (guild) {
await this.guildSearchService.updateGuild(guild, {
description: updatedRow.description,
categoryId: updatedRow.category_id,
categoryId: updatedRow.category_type,
});
}
}
@@ -222,6 +223,13 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
return this.discoveryRepository.findByGuildId(guildId);
}
async getEligibility(guildId: GuildID): Promise<{eligible: boolean; min_member_count: number}> {
const minMemberCount = Config.discovery.minMemberCount;
const guild = await this.guildRepository.findUnique(guildId);
const memberCount = guild?.memberCount ?? 0;
return {eligible: memberCount >= minMemberCount, min_member_count: minMemberCount};
}
async approve(params: {guildId: GuildID; adminUserId: UserID; reason?: string}): Promise<GuildDiscoveryRow> {
const {guildId, adminUserId, reason} = params;
@@ -251,7 +259,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
if (guild) {
await this.guildSearchService.updateGuild(guild, {
description: updatedRow.description,
categoryId: updatedRow.category_id,
categoryId: updatedRow.category_type,
});
}
}
@@ -352,7 +360,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
name: hit.name,
icon: hit.iconHash,
description: hit.discoveryDescription,
category_id: hit.discoveryCategory ?? 0,
category_type: hit.discoveryCategory ?? 0,
member_count: hit.memberCount,
online_count: hit.onlineCount,
features: hit.features,
@@ -374,7 +382,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
const discoveryRow = await this.discoveryRepository.findByGuildId(statusRow.guild_id);
if (!discoveryRow) continue;
if (params.categoryId !== undefined && discoveryRow.category_id !== params.categoryId) {
if (params.categoryId !== undefined && discoveryRow.category_type !== params.categoryId) {
continue;
}
@@ -386,7 +394,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
name: guild.name,
icon: guild.iconHash,
description: discoveryRow.description,
category_id: discoveryRow.category_id,
category_type: discoveryRow.category_type,
member_count: guild.memberCount,
online_count: 0,
features: Array.from(guild.features),

View File

@@ -51,11 +51,6 @@ import type {
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>;
@@ -580,15 +575,15 @@ export class GuildRoleService {
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]));
const everyoneRoleId = guildIdToRoleId(guildId);
for (const update of updates) {
if (update.roleId === guildIdToRoleId(guildId)) {
if (update.roleId === everyoneRoleId) {
throw InputValidationError.fromCode('id', ValidationErrorCodes.CANNOT_REORDER_EVERYONE_ROLE);
}
if (!roleMap.has(update.roleId)) {
@@ -598,7 +593,6 @@ export class GuildRoleService {
}
}
const everyoneRoleId = guildIdToRoleId(guildId);
const isOwner = guildData && guildData.owner_id === userId.toString();
let myHighestRole: GuildRole | null = null;
@@ -642,137 +636,18 @@ export class GuildRoleService {
return 0;
});
for (const role of currentOrder) {
for (let i = 0; i < currentOrder.length; i++) {
const role = currentOrder[i]!;
if (!canManageRole(role)) {
const originalIndex = currentOrder.findIndex((r) => r.id === role.id);
const newIndex = targetOrder.findIndex((r) => r.id === role.id);
if (originalIndex !== newIndex) {
if (i !== 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 reorderedRoles = this.reorderRolePositions({allRoles, reorderedIds, guildId});
const updatePromises = reorderedRoles.map((role) => this.guildRoleRepository.upsertRole(role.toRow()));
await Promise.all(updatePromises);

View File

@@ -24,6 +24,7 @@ import type {ChannelService} from '@fluxer/api/src/channel/services/ChannelServi
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 {IGuildDiscoveryRepository} from '@fluxer/api/src/guild/repositories/GuildDiscoveryRepository';
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';
@@ -38,6 +39,7 @@ import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddlewa
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 type {GuildDiscoveryContext} from '@fluxer/api/src/search/guild/GuildSearchSerializer';
import {
Channels,
ChannelsByGuild,
@@ -103,6 +105,7 @@ export class GuildOperationsService {
private readonly webhookRepository: IWebhookRepository,
private readonly helpers: GuildDataHelpers,
private readonly limitConfigService: LimitConfigService,
private readonly discoveryRepository: IGuildDiscoveryRepository,
private readonly guildManagedTraitService?: GuildManagedTraitService,
) {}
@@ -773,7 +776,17 @@ export class GuildOperationsService {
const guildSearchService = getGuildSearchService();
if (guildSearchService) {
await guildSearchService.updateGuild(updatedGuild).catch((error) => {
let discoveryContext: GuildDiscoveryContext | undefined;
if (updatedGuild.features.has(GuildFeatures.DISCOVERABLE)) {
const discoveryRow = await this.discoveryRepository.findByGuildId(updatedGuild.id).catch(() => null);
if (discoveryRow) {
discoveryContext = {
description: discoveryRow.description,
categoryId: discoveryRow.category_type,
};
}
}
await guildSearchService.updateGuild(updatedGuild, discoveryContext).catch((error) => {
Logger.error({guildId: updatedGuild.id, error}, 'Failed to update guild in search');
});
}

View File

@@ -22,14 +22,19 @@ 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 {DiscoveryCategories, type DiscoveryCategory} from '@fluxer/constants/src/DiscoveryConstants';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import type {DiscoveryApplicationResponse} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
import type {
DiscoveryApplicationResponse,
DiscoveryStatusResponse,
} 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();
await createBuilder(harness, '')
.post(`/test/guilds/${guildId}/member-count`)
.body({member_count: memberCount})
.execute();
}
async function applyForDiscovery(
@@ -37,11 +42,11 @@ async function applyForDiscovery(
token: string,
guildId: string,
description = 'A great community for testing discovery features',
categoryId = DiscoveryCategories.GAMING,
categoryId: DiscoveryCategory = DiscoveryCategories.GAMING,
): Promise<DiscoveryApplicationResponse> {
return createBuilder<DiscoveryApplicationResponse>(harness, token)
.post(`/guilds/${guildId}/discovery`)
.body({description, category_id: categoryId})
.body({description, category_type: categoryId})
.expect(HTTP_STATUS.OK)
.execute();
}
@@ -98,7 +103,7 @@ describe('Discovery Application Lifecycle', () => {
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.category_type).toBe(DiscoveryCategories.GAMING);
expect(application.applied_at).toBeTruthy();
expect(application.reviewed_at).toBeNull();
expect(application.review_reason).toBeNull();
@@ -111,13 +116,16 @@ describe('Discovery Application Lifecycle', () => {
await applyForDiscovery(harness, owner.token, guild.id);
const status = await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
const status = await createBuilder<DiscoveryStatusResponse>(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');
expect(status.application).not.toBeNull();
expect(status.application!.guild_id).toBe(guild.id);
expect(status.application!.status).toBe('pending');
expect(status.eligible).toBe(true);
expect(status.min_member_count).toBeGreaterThan(0);
});
test('should edit pending application description', async () => {
@@ -134,7 +142,7 @@ describe('Discovery Application Lifecycle', () => {
.execute();
expect(updated.description).toBe('Updated community description');
expect(updated.category_id).toBe(DiscoveryCategories.GAMING);
expect(updated.category_type).toBe(DiscoveryCategories.GAMING);
});
test('should edit pending application category', async () => {
@@ -146,11 +154,11 @@ describe('Discovery Application Lifecycle', () => {
const updated = await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
.patch(`/guilds/${guild.id}/discovery`)
.body({category_id: DiscoveryCategories.EDUCATION})
.body({category_type: DiscoveryCategories.EDUCATION})
.expect(HTTP_STATUS.OK)
.execute();
expect(updated.category_id).toBe(DiscoveryCategories.EDUCATION);
expect(updated.category_type).toBe(DiscoveryCategories.EDUCATION);
});
test('should withdraw pending application', async () => {
@@ -165,10 +173,12 @@ describe('Discovery Application Lifecycle', () => {
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
await createBuilder(harness, owner.token)
const status = await createBuilder<DiscoveryStatusResponse>(harness, owner.token)
.get(`/guilds/${guild.id}/discovery`)
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.DISCOVERY_APPLICATION_NOT_FOUND)
.expect(HTTP_STATUS.OK)
.execute();
expect(status.application).toBeNull();
});
test('should complete full lifecycle: apply → approve → verify feature → withdraw', async () => {
@@ -207,11 +217,11 @@ describe('Discovery Application Lifecycle', () => {
await adminReject(harness, admin.token, guild.id, 'Needs more detail');
const status = await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
const status = await createBuilder<DiscoveryStatusResponse>(harness, owner.token)
.get(`/guilds/${guild.id}/discovery`)
.expect(HTTP_STATUS.OK)
.execute();
expect(status.status).toBe('rejected');
expect(status.application!.status).toBe('rejected');
const reapplication = await applyForDiscovery(
harness,
@@ -266,7 +276,7 @@ describe('Discovery Application Lifecycle', () => {
'Valid description for this category',
categoryId,
);
expect(application.category_id).toBe(categoryId);
expect(application.category_type).toBe(categoryId);
}
});

View File

@@ -24,11 +24,17 @@ 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 type {
DiscoveryApplicationResponse,
DiscoveryStatusResponse,
} 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();
await createBuilder(harness, '')
.post(`/test/guilds/${guildId}/member-count`)
.body({member_count: memberCount})
.execute();
}
describe('Discovery Application Validation', () => {
@@ -50,7 +56,7 @@ describe('Discovery Application Validation', () => {
const application = await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'Small but active community', category_id: DiscoveryCategories.GAMING})
.body({description: 'Small but active community', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.OK)
.execute();
@@ -64,7 +70,7 @@ describe('Discovery Application Validation', () => {
await createBuilder(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'No members yet', category_id: DiscoveryCategories.GAMING})
.body({description: 'No members yet', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.DISCOVERY_INSUFFICIENT_MEMBERS)
.execute();
});
@@ -78,7 +84,7 @@ describe('Discovery Application Validation', () => {
await createBuilder(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'Valid description here', category_id: 99})
.body({description: 'Valid description here', category_type: 99})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
@@ -90,7 +96,7 @@ describe('Discovery Application Validation', () => {
await createBuilder(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'Valid description here', category_id: -1})
.body({description: 'Valid description here', category_type: -1})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
@@ -102,13 +108,13 @@ describe('Discovery Application Validation', () => {
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'Valid description here', category_id: DiscoveryCategories.GAMING})
.body({description: 'Valid description here', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, owner.token)
.patch(`/guilds/${guild.id}/discovery`)
.body({category_id: 99})
.body({category_type: 99})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
@@ -122,7 +128,7 @@ describe('Discovery Application Validation', () => {
await createBuilder(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'Too short', category_id: DiscoveryCategories.GAMING})
.body({description: 'Too short', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
@@ -134,7 +140,7 @@ describe('Discovery Application Validation', () => {
await createBuilder(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'A'.repeat(301), category_id: DiscoveryCategories.GAMING})
.body({description: 'A'.repeat(301), category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
@@ -146,12 +152,12 @@ describe('Discovery Application Validation', () => {
await createBuilder(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({category_id: DiscoveryCategories.GAMING})
.body({category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should reject missing category_id', async () => {
test('should reject missing category_type', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'No Cat Guild');
await setGuildMemberCount(harness, guild.id, 1);
@@ -172,13 +178,13 @@ describe('Discovery Application Validation', () => {
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'First application attempt', category_id: DiscoveryCategories.GAMING})
.body({description: 'First application attempt', category_type: 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})
.body({description: 'Second application attempt', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.CONFLICT, APIErrorCodes.DISCOVERY_ALREADY_APPLIED)
.execute();
});
@@ -192,7 +198,7 @@ describe('Discovery Application Validation', () => {
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'Application to be approved', category_id: DiscoveryCategories.GAMING})
.body({description: 'Application to be approved', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.OK)
.execute();
@@ -204,7 +210,7 @@ describe('Discovery Application Validation', () => {
await createBuilder(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'Trying to reapply while approved', category_id: DiscoveryCategories.GAMING})
.body({description: 'Trying to reapply while approved', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.CONFLICT, APIErrorCodes.DISCOVERY_ALREADY_APPLIED)
.execute();
});
@@ -218,7 +224,7 @@ describe('Discovery Application Validation', () => {
await createBuilder(harness, member.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'Should not be allowed', category_id: DiscoveryCategories.GAMING})
.body({description: 'Should not be allowed', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
.execute();
});
@@ -230,7 +236,7 @@ describe('Discovery Application Validation', () => {
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'Owner applied for discovery', category_id: DiscoveryCategories.GAMING})
.body({description: 'Owner applied for discovery', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.OK)
.execute();
@@ -248,7 +254,7 @@ describe('Discovery Application Validation', () => {
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'Owner applied for discovery', category_id: DiscoveryCategories.GAMING})
.body({description: 'Owner applied for discovery', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.OK)
.execute();
@@ -273,7 +279,7 @@ describe('Discovery Application Validation', () => {
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})
.body({description: 'No auth attempt', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
});
@@ -302,14 +308,17 @@ describe('Discovery Application Validation', () => {
});
describe('non-existent application', () => {
test('should return error when getting status with no application', async () => {
test('should return null application when none exists', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'No App Guild');
await createBuilder(harness, owner.token)
const status = await createBuilder<DiscoveryStatusResponse>(harness, owner.token)
.get(`/guilds/${guild.id}/discovery`)
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.DISCOVERY_APPLICATION_NOT_FOUND)
.expect(HTTP_STATUS.OK)
.execute();
expect(status.application).toBeNull();
expect(status.min_member_count).toBeGreaterThan(0);
});
test('should return error when editing non-existent application', async () => {
@@ -344,7 +353,7 @@ describe('Discovery Application Validation', () => {
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'To be rejected for edit test', category_id: DiscoveryCategories.GAMING})
.body({description: 'To be rejected for edit test', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.OK)
.execute();

View File

@@ -32,7 +32,10 @@ import type {
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();
await createBuilder(harness, '')
.post(`/test/guilds/${guildId}/member-count`)
.body({member_count: memberCount})
.execute();
}
async function applyAndApprove(
@@ -45,7 +48,7 @@ async function applyAndApprove(
): Promise<void> {
await createBuilder<DiscoveryApplicationResponse>(harness, ownerToken)
.post(`/guilds/${guildId}/discovery`)
.body({description, category_id: categoryId})
.body({description, category_type: categoryId})
.expect(HTTP_STATUS.OK)
.execute();
@@ -102,10 +105,7 @@ describe('Discovery Search and Join', () => {
});
test('should require login to list categories', async () => {
await createBuilderWithoutAuth(harness)
.get('/discovery/categories')
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
await createBuilderWithoutAuth(harness).get('/discovery/categories').expect(HTTP_STATUS.UNAUTHORIZED).execute();
});
});
@@ -150,7 +150,7 @@ describe('Discovery Search and Join', () => {
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);
expect(found!.category_type).toBe(DiscoveryCategories.GAMING);
});
test('should not return pending guilds in search results', async () => {
@@ -160,7 +160,7 @@ describe('Discovery Search and Join', () => {
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'Pending application guild', category_id: DiscoveryCategories.GAMING})
.body({description: 'Pending application guild', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.OK)
.execute();
@@ -181,12 +181,26 @@ describe('Discovery Search and Join', () => {
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);
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);
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)
@@ -195,7 +209,7 @@ describe('Discovery Search and Join', () => {
.execute();
for (const guild of results.guilds) {
expect(guild.category_id).toBe(DiscoveryCategories.GAMING);
expect(guild.category_type).toBe(DiscoveryCategories.GAMING);
}
});
@@ -207,7 +221,14 @@ describe('Discovery Search and Join', () => {
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);
await applyAndApprove(
harness,
owner.token,
admin.token,
guild.id,
`Community number ${i} for testing`,
DiscoveryCategories.GAMING,
);
}
const searcher = await createTestAccount(harness);
@@ -220,10 +241,7 @@ describe('Discovery Search and Join', () => {
});
test('should require login to search', async () => {
await createBuilderWithoutAuth(harness)
.get('/discovery/guilds')
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
await createBuilderWithoutAuth(harness).get('/discovery/guilds').expect(HTTP_STATUS.UNAUTHORIZED).execute();
});
});
@@ -235,7 +253,14 @@ describe('Discovery Search and Join', () => {
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);
await applyAndApprove(
harness,
owner.token,
admin.token,
guild.id,
'Join this community',
DiscoveryCategories.GAMING,
);
const joiner = await createTestAccount(harness);
await createBuilder(harness, joiner.token)
@@ -266,7 +291,7 @@ describe('Discovery Search and Join', () => {
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'Pending but not yet approved', category_id: DiscoveryCategories.GAMING})
.body({description: 'Pending but not yet approved', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.OK)
.execute();

View File

@@ -0,0 +1,396 @@
/*
* 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,
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 Reorder', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
describe('Owner Reordering', () => {
test('should allow owner to reorder any roles', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const roleA = await createRole(harness, owner.token, guild.id, {name: 'Role A'});
const roleB = await createRole(harness, owner.token, guild.id, {name: 'Role B'});
const roleC = await createRole(harness, owner.token, guild.id, {name: 'Role C'});
await updateRolePositions(harness, owner.token, guild.id, [
{id: roleC.id, position: 3},
{id: roleB.id, position: 2},
{id: roleA.id, position: 1},
]);
const roles = await getRoles(harness, owner.token, guild.id);
const updatedA = roles.find((r) => r.id === roleA.id)!;
const updatedB = roles.find((r) => r.id === roleB.id)!;
const updatedC = roles.find((r) => r.id === roleC.id)!;
expect(updatedC.position).toBeGreaterThan(updatedB.position);
expect(updatedB.position).toBeGreaterThan(updatedA.position);
});
test('should allow owner to reverse all role positions', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const roleA = await createRole(harness, owner.token, guild.id, {name: 'Role A'});
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: 1},
]);
const rolesBefore = await getRoles(harness, owner.token, guild.id);
const beforeA = rolesBefore.find((r) => r.id === roleA.id)!;
const beforeB = rolesBefore.find((r) => r.id === roleB.id)!;
expect(beforeA.position).toBeGreaterThan(beforeB.position);
await updateRolePositions(harness, owner.token, guild.id, [
{id: roleB.id, position: 2},
{id: roleA.id, position: 1},
]);
const rolesAfter = await getRoles(harness, owner.token, guild.id);
const afterA = rolesAfter.find((r) => r.id === roleA.id)!;
const afterB = rolesAfter.find((r) => r.id === roleB.id)!;
expect(afterB.position).toBeGreaterThan(afterA.position);
});
});
describe('Non-Owner with MANAGE_ROLES', () => {
test('should allow member with MANAGE_ROLES to reorder roles below their highest role', 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 lowRoleA = await createRole(harness, owner.token, guild.id, {name: 'Low A'});
const lowRoleB = await createRole(harness, owner.token, guild.id, {name: 'Low B'});
await updateRolePositions(harness, owner.token, guild.id, [
{id: managerRole.id, position: 4},
{id: lowRoleA.id, position: 3},
{id: lowRoleB.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 updateRolePositions(harness, manager.token, guild.id, [
{id: lowRoleB.id, position: 3},
{id: lowRoleA.id, position: 2},
]);
const roles = await getRoles(harness, owner.token, guild.id);
const updatedA = roles.find((r) => r.id === lowRoleA.id)!;
const updatedB = roles.find((r) => r.id === lowRoleB.id)!;
expect(updatedB.position).toBeGreaterThan(updatedA.position);
});
test('should reject reordering a role above the user highest role', async () => {
const owner = await createTestAccount(harness);
const manager = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const highRole = await createRole(harness, owner.token, guild.id, {name: 'High Role'});
const managerRole = await createRole(harness, owner.token, guild.id, {
name: 'Manager',
permissions: Permissions.MANAGE_ROLES.toString(),
});
await updateRolePositions(harness, owner.token, guild.id, [
{id: highRole.id, position: 4},
{id: managerRole.id, position: 3},
]);
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`)
.body([{id: highRole.id, position: 1}])
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('should reject reordering that indirectly shifts an unmanageable role', async () => {
const owner = await createTestAccount(harness);
const manager = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const highRole = await createRole(harness, owner.token, guild.id, {name: 'High Role'});
const managerRole = await createRole(harness, owner.token, guild.id, {
name: 'Manager',
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: 4},
{id: managerRole.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, 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`)
.body([{id: lowRole.id, position: 5}])
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('should allow reordering multiple roles below highest when unmanageable roles stay in place', async () => {
const owner = await createTestAccount(harness);
const manager = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const highRole = await createRole(harness, owner.token, guild.id, {name: 'High Role'});
const managerRole = await createRole(harness, owner.token, guild.id, {
name: 'Manager',
permissions: Permissions.MANAGE_ROLES.toString(),
});
const lowA = await createRole(harness, owner.token, guild.id, {name: 'Low A'});
const lowB = await createRole(harness, owner.token, guild.id, {name: 'Low B'});
const lowC = await createRole(harness, owner.token, guild.id, {name: 'Low C'});
await updateRolePositions(harness, owner.token, guild.id, [
{id: highRole.id, position: 6},
{id: managerRole.id, position: 5},
{id: lowA.id, position: 4},
{id: lowB.id, position: 3},
{id: lowC.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 updateRolePositions(harness, manager.token, guild.id, [
{id: lowC.id, position: 4},
{id: lowA.id, position: 3},
{id: lowB.id, position: 2},
]);
const roles = await getRoles(harness, owner.token, guild.id);
const updatedA = roles.find((r) => r.id === lowA.id)!;
const updatedB = roles.find((r) => r.id === lowB.id)!;
const updatedC = roles.find((r) => r.id === lowC.id)!;
expect(updatedC.position).toBeGreaterThan(updatedA.position);
expect(updatedA.position).toBeGreaterThan(updatedB.position);
});
});
describe('Permission Requirements', () => {
test('should require MANAGE_ROLES permission to reorder roles', 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 reorder from a non-guild member', async () => {
const owner = await createTestAccount(harness);
const outsider = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const role = await createRole(harness, owner.token, guild.id, {name: 'Test Role'});
await createBuilder(harness, outsider.token)
.patch(`/guilds/${guild.id}/roles`)
.body([{id: role.id, position: 5}])
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
});
describe('Validation', () => {
test('should reject reordering @everyone role', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
await createBuilder(harness, owner.token)
.patch(`/guilds/${guild.id}/roles`)
.body([{id: guild.id, position: 5}])
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should reject reorder with invalid role ID', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
await createBuilder(harness, owner.token)
.patch(`/guilds/${guild.id}/roles`)
.body([{id: '999999999999999999', position: 1}])
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should accept reorder with no position changes (no-op)', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const roleA = await createRole(harness, owner.token, guild.id, {name: 'Role A'});
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: 1},
]);
const rolesBefore = await getRoles(harness, owner.token, guild.id);
const beforeA = rolesBefore.find((r) => r.id === roleA.id)!;
const beforeB = rolesBefore.find((r) => r.id === roleB.id)!;
await updateRolePositions(harness, owner.token, guild.id, [
{id: roleA.id, position: 2},
{id: roleB.id, position: 1},
]);
const rolesAfter = await getRoles(harness, owner.token, guild.id);
const afterA = rolesAfter.find((r) => r.id === roleA.id)!;
const afterB = rolesAfter.find((r) => r.id === roleB.id)!;
expect(afterA.position).toBe(beforeA.position);
expect(afterB.position).toBe(beforeB.position);
});
});
describe('Position Assignment', () => {
test('should keep @everyone at position 0 after reorder', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const roleA = await createRole(harness, owner.token, guild.id, {name: 'Role A'});
const roleB = await createRole(harness, owner.token, guild.id, {name: 'Role B'});
await updateRolePositions(harness, owner.token, guild.id, [
{id: roleB.id, position: 2},
{id: roleA.id, position: 1},
]);
const roles = await getRoles(harness, owner.token, guild.id);
const everyone = roles.find((r) => r.id === guild.id)!;
expect(everyone.position).toBe(0);
});
test('should assign positions to all roles after reorder', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const roleA = await createRole(harness, owner.token, guild.id, {name: 'Role A'});
const roleB = await createRole(harness, owner.token, guild.id, {name: 'Role B'});
const roleC = await createRole(harness, owner.token, guild.id, {name: 'Role C'});
await updateRolePositions(harness, owner.token, guild.id, [
{id: roleA.id, position: 3},
{id: roleB.id, position: 2},
{id: roleC.id, position: 1},
]);
const roles = await getRoles(harness, owner.token, guild.id);
const nonEveryoneRoles = roles.filter((r) => r.id !== guild.id);
for (const role of nonEveryoneRoles) {
expect(role.position).toBeGreaterThan(0);
}
const positions = nonEveryoneRoles.map((r) => r.position);
const uniquePositions = new Set(positions);
expect(uniquePositions.size).toBe(nonEveryoneRoles.length);
});
test('should correctly order roles with a partial position update', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const roleA = await createRole(harness, owner.token, guild.id, {name: 'Role A'});
const roleB = await createRole(harness, owner.token, guild.id, {name: 'Role B'});
const roleC = await createRole(harness, owner.token, guild.id, {name: 'Role C'});
await updateRolePositions(harness, owner.token, guild.id, [
{id: roleA.id, position: 3},
{id: roleB.id, position: 2},
{id: roleC.id, position: 1},
]);
await updateRolePositions(harness, owner.token, guild.id, [{id: roleC.id, position: 4}]);
const roles = await getRoles(harness, owner.token, guild.id);
const updatedA = roles.find((r) => r.id === roleA.id)!;
const updatedB = roles.find((r) => r.id === roleB.id)!;
const updatedC = roles.find((r) => r.id === roleC.id)!;
expect(updatedC.position).toBeGreaterThan(updatedA.position);
expect(updatedA.position).toBeGreaterThan(updatedB.position);
});
});
});

View File

@@ -1236,7 +1236,8 @@ export class RpcService {
});
}
const repairedBannerGuild = await this.repairGuildBannerHeight(guildResult);
const repairedChannelRefsGuild = await this.repairDanglingChannelReferences({guild: guildResult, channels});
const repairedBannerGuild = await this.repairGuildBannerHeight(repairedChannelRefsGuild);
const repairedSplashGuild = await this.repairGuildSplashDimensions(repairedBannerGuild);
const repairedEmbedSplashGuild = await this.repairGuildEmbedSplashDimensions(repairedSplashGuild);
const updatedGuild = await this.updateGuildMemberCount(repairedEmbedSplashGuild, members.length);
@@ -1449,6 +1450,36 @@ export class RpcService {
return hash.startsWith('a_') ? hash.slice(2) : hash;
}
private async repairDanglingChannelReferences(params: {guild: Guild; channels: Array<Channel>}): Promise<Guild> {
const {guild, channels} = params;
const channelIds = new Set(channels.map((channel) => channel.id));
const danglingSystemChannel = guild.systemChannelId != null && !channelIds.has(guild.systemChannelId);
const danglingRulesChannel = guild.rulesChannelId != null && !channelIds.has(guild.rulesChannelId);
const danglingAfkChannel = guild.afkChannelId != null && !channelIds.has(guild.afkChannelId);
if (!danglingSystemChannel && !danglingRulesChannel && !danglingAfkChannel) {
return guild;
}
Logger.info(
{
guildId: guild.id.toString(),
danglingSystemChannel,
danglingRulesChannel,
danglingAfkChannel,
},
'Repairing dangling guild channel references',
);
return this.guildRepository.upsert({
...guild.toRow(),
system_channel_id: danglingSystemChannel ? null : guild.systemChannelId,
rules_channel_id: danglingRulesChannel ? null : guild.rulesChannelId,
afk_channel_id: danglingAfkChannel ? null : guild.afkChannelId,
});
}
private async repairGuildBannerHeight(guild: Guild): Promise<Guild> {
if (!guild.bannerHash || (guild.bannerHeight != null && guild.bannerWidth != null)) {
return guild;

View File

@@ -178,7 +178,7 @@ export class NoopGatewayService extends IGatewayService {
}
async getUserPermissions(params: {guildId: GuildID; userId: UserID; channelId?: ChannelID}): Promise<bigint> {
const {guildId, userId} = params;
const {guildId, userId, channelId} = params;
const guild = await guildRepository.findUnique(guildId);
if (!guild) {
@@ -195,8 +195,20 @@ export class NoopGatewayService extends IGatewayService {
}
const roles = await roleRepository.listRoles(guildId);
const guildPermissions = this.calculateGuildPermissions(member.roleIds, roles, guildId);
return this.calculatePermissions(Array.from(member.roleIds), roles, userId === guild.ownerId, guildId);
if (!channelId) {
return guildPermissions;
}
const {ChannelDataRepository} = await import('@fluxer/api/src/channel/repositories/ChannelDataRepository');
const channelRepo = new ChannelDataRepository();
const channel = await channelRepo.findUnique(channelId);
if (!channel) {
return guildPermissions;
}
return this.applyChannelOverwrites(guildPermissions, member.roleIds, channel, userId, guildId);
}
async getUserPermissionsBatch(_params: {
@@ -207,16 +219,12 @@ export class NoopGatewayService extends IGatewayService {
return new Map();
}
async canManageRoles(_params: {
async canManageRoles(params: {
guildId: GuildID;
userId: UserID;
targetUserId: UserID;
roleId: RoleID;
}): Promise<boolean> {
return false;
}
async canManageRole(params: {guildId: GuildID; userId: UserID; roleId: RoleID}): Promise<boolean> {
const {guildId, userId, roleId} = params;
const guild = await guildRepository.findUnique(guildId);
@@ -234,16 +242,10 @@ export class NoopGatewayService extends IGatewayService {
}
const roles = await roleRepository.listRoles(guildId);
const userPermissions = this.calculateGuildPermissions(member.roleIds, roles, guildId);
const userPermissions = this.calculatePermissions(
Array.from(member.roleIds),
roles,
userId === guild.ownerId,
guildId,
);
if ((userPermissions & Permissions.ADMINISTRATOR) !== 0n) {
return true;
if ((userPermissions & Permissions.MANAGE_ROLES) === 0n) {
return false;
}
const targetRole = roles.find((r) => r.id === roleId);
@@ -251,24 +253,70 @@ export class NoopGatewayService extends IGatewayService {
return false;
}
let userHighestPosition = -1;
for (const roleId of member.roleIds) {
const role = roles.find((r) => r.id === roleId);
if (role && (role as {position?: number}).position !== undefined) {
userHighestPosition = Math.max(userHighestPosition, (role as {position: number}).position);
const userMaxPosition = this.getMaxRolePosition(member.roleIds, roles);
return userMaxPosition > targetRole.position;
}
async canManageRole(params: {guildId: GuildID; userId: UserID; roleId: RoleID}): Promise<boolean> {
const {guildId, userId, roleId} = params;
const member = await guildMemberRepository.getMember(guildId, userId);
if (!member) {
return false;
}
const roles = await roleRepository.listRoles(guildId);
const targetRole = roles.find((r) => r.id === roleId);
if (!targetRole) {
return false;
}
const userMaxPosition = this.getMaxRolePosition(member.roleIds, roles);
if (userMaxPosition > targetRole.position) {
return true;
}
if (userMaxPosition === targetRole.position) {
const highestRole = this.getHighestRole(member.roleIds, roles);
if (highestRole) {
return String(highestRole.id) < String(targetRole.id);
}
}
const targetPosition = (targetRole as {position?: number}).position ?? 0;
return userHighestPosition > targetPosition;
return false;
}
private getHighestRole(
memberRoleIds: Set<RoleID>,
allRoles: Array<{id: RoleID; position: number}>,
): {id: RoleID; position: number} | null {
let highest: {id: RoleID; position: number} | null = null;
for (const roleId of memberRoleIds) {
const role = allRoles.find((r) => r.id === roleId);
if (!role) continue;
if (!highest) {
highest = role;
} else if (role.position > highest.position) {
highest = role;
} else if (role.position === highest.position && String(role.id) < String(highest.id)) {
highest = role;
}
}
return highest;
}
async getAssignableRoles(_params: {guildId: GuildID; userId: UserID}): Promise<Array<RoleID>> {
return [];
}
async getUserMaxRolePosition(_params: {guildId: GuildID; userId: UserID}): Promise<number> {
return 0;
async getUserMaxRolePosition(params: {guildId: GuildID; userId: UserID}): Promise<number> {
const {guildId, userId} = params;
const member = await guildMemberRepository.getMember(guildId, userId);
if (!member) {
return 0;
}
const roles = await roleRepository.listRoles(guildId);
return this.getMaxRolePosition(member.roleIds, roles);
}
async checkTargetMember(params: {guildId: GuildID; userId: UserID; targetUserId: UserID}): Promise<boolean> {
@@ -283,6 +331,10 @@ export class NoopGatewayService extends IGatewayService {
return true;
}
if (guild.ownerId === targetUserId) {
return false;
}
const member = await guildMemberRepository.getMember(guildId, userId);
const targetMember = await guildMemberRepository.getMember(guildId, targetUserId);
if (!member || !targetMember) {
@@ -290,35 +342,9 @@ export class NoopGatewayService extends IGatewayService {
}
const roles = await roleRepository.listRoles(guildId);
const userPermissions = this.calculatePermissions(
Array.from(member.roleIds),
roles,
userId === guild.ownerId,
guildId,
);
if ((userPermissions & Permissions.ADMINISTRATOR) !== 0n) {
return true;
}
let targetHighestPosition = -1;
for (const roleId of targetMember.roleIds) {
const role = roles.find((r) => r.id === roleId);
if (role && (role as {position?: number}).position !== undefined) {
targetHighestPosition = Math.max(targetHighestPosition, (role as {position: number}).position);
}
}
let userHighestPosition = -1;
for (const roleId of member.roleIds) {
const role = roles.find((r) => r.id === roleId);
if (role && (role as {position?: number}).position !== undefined) {
userHighestPosition = Math.max(userHighestPosition, (role as {position: number}).position);
}
}
return userHighestPosition > targetHighestPosition;
const userMaxPosition = this.getMaxRolePosition(member.roleIds, roles);
const targetMaxPosition = this.getMaxRolePosition(targetMember.roleIds, roles);
return userMaxPosition > targetMaxPosition;
}
async getViewableChannels(params: {guildId: GuildID; userId: UserID}): Promise<Array<ChannelID>> {
@@ -339,37 +365,21 @@ export class NoopGatewayService extends IGatewayService {
}
const roles = await roleRepository.listRoles(guildId);
const userPermissions = this.calculatePermissions(
Array.from(member.roleIds),
roles,
userId === guild?.ownerId,
guildId,
);
const guildPermissions = this.calculateGuildPermissions(member.roleIds, roles, guildId);
if ((guildPermissions & Permissions.ADMINISTRATOR) !== 0n) {
return channels.map((ch) => ch.id);
}
const everyoneRoleId = guildIdToRoleId(guildId);
const viewable: Array<ChannelID> = [];
for (const channel of channels) {
let channelPermissions = userPermissions;
if (channel.permissionOverwrites) {
const everyoneOverwrite = channel.permissionOverwrites.get(everyoneRoleId);
if (everyoneOverwrite) {
channelPermissions = (channelPermissions & ~everyoneOverwrite.deny) | everyoneOverwrite.allow;
}
for (const roleId of member.roleIds) {
const overwrite = channel.permissionOverwrites.get(roleId);
if (overwrite) {
channelPermissions = (channelPermissions & ~overwrite.deny) | overwrite.allow;
}
}
const userOverwrite = channel.permissionOverwrites.get(userId);
if (userOverwrite) {
channelPermissions = (channelPermissions & ~userOverwrite.deny) | userOverwrite.allow;
}
}
const channelPermissions = this.applyChannelOverwrites(
guildPermissions,
member.roleIds,
channel,
userId,
guildId,
);
if ((channelPermissions & Permissions.VIEW_CHANNEL) !== 0n) {
viewable.push(channel.id);
}
@@ -492,53 +502,32 @@ export class NoopGatewayService extends IGatewayService {
}
const roles = await roleRepository.listRoles(guildId);
const guildPermissions = this.calculateGuildPermissions(member.roleIds, roles, guildId);
let userPermissions = this.calculatePermissions(
Array.from(member.roleIds),
roles,
userId === guild.ownerId,
guildId,
);
if ((guildPermissions & Permissions.ADMINISTRATOR) !== 0n) {
return true;
}
let userPermissions = guildPermissions;
if (channelId) {
const {ChannelDataRepository} = await import('@fluxer/api/src/channel/repositories/ChannelDataRepository');
const channelRepo = new ChannelDataRepository();
const channel = await channelRepo.findUnique(channelId);
if (channel?.permissionOverwrites) {
const everyoneRoleId = guildIdToRoleId(guildId);
const everyoneOverwrite = channel.permissionOverwrites.get(everyoneRoleId);
if (everyoneOverwrite) {
userPermissions = (userPermissions & ~everyoneOverwrite.deny) | everyoneOverwrite.allow;
}
for (const roleId of member.roleIds) {
const overwrite = channel.permissionOverwrites.get(roleId);
if (overwrite) {
userPermissions = (userPermissions & ~overwrite.deny) | overwrite.allow;
}
}
const userOverwrite = channel.permissionOverwrites.get(userId);
if (userOverwrite) {
userPermissions = (userPermissions & ~userOverwrite.deny) | userOverwrite.allow;
}
if (channel) {
userPermissions = this.applyChannelOverwrites(guildPermissions, member.roleIds, channel, userId, guildId);
}
}
return (userPermissions & permission) === permission;
}
private calculatePermissions(
memberRoleIds: Array<RoleID>,
private calculateGuildPermissions(
memberRoleIds: Set<RoleID>,
allRoles: Array<{id: RoleID; permissions: bigint}>,
isOwner: boolean,
guildId: GuildID,
): bigint {
if (isOwner) {
return ALL_PERMISSIONS;
}
let permissions = 0n;
const everyoneRoleId = guildIdToRoleId(guildId);
@@ -550,11 +539,10 @@ export class NoopGatewayService extends IGatewayService {
for (const roleId of memberRoleIds) {
const role = allRoles.find((r) => r.id === roleId);
if (role) {
const rolePermissions = role.permissions;
permissions |= rolePermissions;
permissions |= role.permissions;
if ((rolePermissions & Permissions.ADMINISTRATOR) !== 0n) {
return Permissions.ADMINISTRATOR;
if ((permissions & Permissions.ADMINISTRATOR) !== 0n) {
return ALL_PERMISSIONS;
}
}
}
@@ -562,6 +550,59 @@ export class NoopGatewayService extends IGatewayService {
return permissions;
}
private applyChannelOverwrites(
basePermissions: bigint,
memberRoleIds: Set<RoleID>,
channel: {permissionOverwrites?: Map<RoleID | UserID, {allow: bigint; deny: bigint}>},
userId: UserID,
guildId: GuildID,
): bigint {
if ((basePermissions & Permissions.ADMINISTRATOR) !== 0n) {
return ALL_PERMISSIONS;
}
if (!channel.permissionOverwrites) {
return basePermissions;
}
let permissions = basePermissions;
const everyoneRoleId = guildIdToRoleId(guildId);
const everyoneOverwrite = channel.permissionOverwrites.get(everyoneRoleId);
if (everyoneOverwrite) {
permissions = (permissions & ~everyoneOverwrite.deny) | everyoneOverwrite.allow;
}
let roleAllow = 0n;
let roleDeny = 0n;
for (const roleId of memberRoleIds) {
const overwrite = channel.permissionOverwrites.get(roleId);
if (overwrite) {
roleAllow |= overwrite.allow;
roleDeny |= overwrite.deny;
}
}
permissions = (permissions & ~roleDeny) | roleAllow;
const userOverwrite = channel.permissionOverwrites.get(userId);
if (userOverwrite) {
permissions = (permissions & ~userOverwrite.deny) | userOverwrite.allow;
}
return permissions;
}
private getMaxRolePosition(memberRoleIds: Set<RoleID>, allRoles: Array<{id: RoleID; position: number}>): number {
let maxPosition = -1;
for (const roleId of memberRoleIds) {
const role = allRoles.find((r) => r.id === roleId);
if (role) {
maxPosition = Math.max(maxPosition, role.position);
}
}
return maxPosition;
}
async getVanityUrlChannel(_guildId: GuildID): Promise<ChannelID | null> {
return null;
}

View File

@@ -21,11 +21,7 @@ import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
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 {
deletePushSubscription,
listPushSubscriptions,
subscribePush,
} from '@fluxer/api/src/user/tests/UserTestUtils';
import {deletePushSubscription, listPushSubscriptions, subscribePush} from '@fluxer/api/src/user/tests/UserTestUtils';
import {beforeEach, describe, expect, test} from 'vitest';
describe('Push Subscription Lifecycle', () => {
@@ -180,10 +176,7 @@ describe('Push Subscription Lifecycle', () => {
});
test('list subscriptions requires authentication', async () => {
await createBuilder(harness, '')
.get('/users/@me/push/subscriptions')
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
await createBuilder(harness, '').get('/users/@me/push/subscriptions').expect(HTTP_STATUS.UNAUTHORIZED).execute();
});
test('delete subscription requires authentication', async () => {

View File

@@ -378,9 +378,7 @@ export async function listPushSubscriptions(
harness: ApiTestHarness,
token: string,
): Promise<PushSubscriptionsListResponse> {
return createBuilder<PushSubscriptionsListResponse>(harness, token)
.get('/users/@me/push/subscriptions')
.execute();
return createBuilder<PushSubscriptionsListResponse>(harness, token).get('/users/@me/push/subscriptions').execute();
}
export async function deletePushSubscription(
@@ -388,7 +386,5 @@ export async function deletePushSubscription(
token: string,
subscriptionId: string,
): Promise<void> {
await createBuilder<void>(harness, token)
.delete(`/users/@me/push/subscriptions/${subscriptionId}`)
.execute();
await createBuilder<void>(harness, token).delete(`/users/@me/push/subscriptions/${subscriptionId}`).execute();
}

View File

@@ -75,8 +75,8 @@ describe('GitHub Issue Transformer', () => {
expect(result?.url).toBe('https://github.com/org/repo/issues/99');
expect(result?.color).toBe(0xeb4841);
expect(result?.description).toContain('When I try to do X');
expect(result?.author?.name).toBe('reporter');
expect(result?.author?.url).toBe('https://github.com/reporter');
expect(result?.author?.name).toBe('testuser');
expect(result?.author?.url).toBe('https://github.com/testuser');
});
it('transforms a closed issue', async () => {
@@ -93,6 +93,7 @@ describe('GitHub Issue Transformer', () => {
expect(result?.title).toContain('Issue closed');
expect(result?.color).toBe(0x000000);
expect(result?.description).toBeUndefined();
expect(result?.author?.name).toBe('testuser');
});
it('transforms a reopened issue', async () => {
@@ -108,6 +109,7 @@ describe('GitHub Issue Transformer', () => {
expect(result).not.toBeNull();
expect(result?.title).toContain('Issue reopened');
expect(result?.color).toBe(0xfcbd1f);
expect(result?.author?.name).toBe('testuser');
});
it('returns null for unsupported action types', async () => {

View File

@@ -26,9 +26,9 @@ export async function transformIssue(body: GitHubWebhook): Promise<RichEmbedRequ
return null;
}
const authorIconUrl = body.issue.user.avatar_url;
const authorName = body.issue.user.login;
const authorUrl = body.issue.user.html_url;
const authorIconUrl = body.sender.avatar_url;
const authorName = body.sender.login;
const authorUrl = body.sender.html_url;
const repoName = body.repository.full_name;
const issueNumber = body.issue.number;
const issueTitle = body.issue.title;

View File

@@ -67,7 +67,7 @@ const syncDiscoveryIndex: WorkerTaskHandler = async (_payload, helpers) => {
await guildSearchService.updateGuild(guild, {
description: discoveryRow.description,
categoryId: discoveryRow.category_id,
categoryId: discoveryRow.category_type,
onlineCount: onlineCounts.get(guildId) ?? 0,
});

View File

@@ -168,6 +168,11 @@
"$ref": "#/$defs/proxy",
"default": {}
},
"discovery": {
"description": "Guild discovery listing configuration.",
"$ref": "#/$defs/discovery",
"default": {}
},
"attachment_decay_enabled": {
"type": "boolean",
"description": "Whether to automatically delete old attachments.",
@@ -570,6 +575,22 @@
}
}
},
"discovery": {
"type": "object",
"description": "Guild discovery listing configuration.",
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether guild discovery is enabled on this instance.",
"default": true
},
"min_member_count": {
"type": "number",
"description": "Minimum number of members a guild needs before it can apply for discovery listing.",
"default": 50
}
}
},
"domain": {
"type": "object",
"description": "Configuration for domains and ports used to construct public URLs.",

View File

@@ -0,0 +1,19 @@
{
"discovery": {
"type": "object",
"description": "Guild discovery listing configuration.",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether guild discovery is enabled on this instance.",
"default": true
},
"min_member_count": {
"type": "number",
"description": "Minimum number of members a guild needs before it can apply for discovery listing.",
"default": 50
}
}
}
}

View File

@@ -132,6 +132,11 @@
"$ref": "#/$defs/proxy",
"default": {}
},
"discovery": {
"description": "Guild discovery listing configuration.",
"$ref": "#/$defs/discovery",
"default": {}
},
"attachment_decay_enabled": {
"type": "boolean",
"description": "Whether to automatically delete old attachments.",

View File

@@ -86,6 +86,7 @@ export const APIErrorCodes = {
DISCOVERY_APPLICATION_ALREADY_REVIEWED: 'DISCOVERY_APPLICATION_ALREADY_REVIEWED',
DISCOVERY_APPLICATION_NOT_FOUND: 'DISCOVERY_APPLICATION_NOT_FOUND',
DISCOVERY_DESCRIPTION_REQUIRED: 'DISCOVERY_DESCRIPTION_REQUIRED',
DISCOVERY_DISABLED: 'DISCOVERY_DISABLED',
DISCOVERY_INSUFFICIENT_MEMBERS: 'DISCOVERY_INSUFFICIENT_MEMBERS',
DISCOVERY_INVALID_CATEGORY: 'DISCOVERY_INVALID_CATEGORY',
DISCOVERY_NOT_DISCOVERABLE: 'DISCOVERY_NOT_DISCOVERABLE',

View File

@@ -86,6 +86,7 @@ export const APIErrorCodesDescriptions: Record<keyof typeof APIErrorCodes, strin
DISCOVERY_APPLICATION_ALREADY_REVIEWED: 'This discovery application has already been reviewed',
DISCOVERY_APPLICATION_NOT_FOUND: 'Discovery application not found',
DISCOVERY_DESCRIPTION_REQUIRED: 'A description is required for discovery',
DISCOVERY_DISABLED: 'Discovery is not available on this instance',
DISCOVERY_INSUFFICIENT_MEMBERS: 'Community does not meet the minimum member count for discovery',
DISCOVERY_INVALID_CATEGORY: 'Invalid discovery category',
DISCOVERY_NOT_DISCOVERABLE: 'This community is not listed in discovery',

View File

@@ -54,7 +54,5 @@ export const DiscoveryApplicationStatus = {
export type DiscoveryApplicationStatusValue = ValueOf<typeof DiscoveryApplicationStatus>;
export const DISCOVERY_MIN_MEMBER_COUNT = 50;
export const DISCOVERY_MIN_MEMBER_COUNT_DEV = 1;
export const DISCOVERY_DESCRIPTION_MIN_LENGTH = 10;
export const DISCOVERY_DESCRIPTION_MAX_LENGTH = 300;

View File

@@ -17,24 +17,13 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
import {Locales} from '@fluxer/constants/src/Locales';
import {FlagSvg} from '@fluxer/marketing/src/components/Flags';
import type {MarketingContext} from '@fluxer/marketing/src/MarketingContext';
interface MadeInSwedenBadgeProps {
ctx: MarketingContext;
}
export function MadeInSwedenBadge(props: MadeInSwedenBadgeProps): JSX.Element {
const {ctx} = props;
return (
<span class="inline-flex items-center gap-2 rounded-full bg-white px-4 py-1.5 font-medium text-[#4641D9] text-sm">
<FlagSvg locale={Locales.SV_SE} ctx={ctx} class="h-4 w-4 rounded-sm" />
<span>{ctx.i18n.getMessage('general.made_in_sweden', ctx.locale)}</span>
</span>
);
export class DiscoveryDisabledError extends BadRequestError {
constructor() {
super({
code: APIErrorCodes.DISCOVERY_DISABLED,
});
}
}

View File

@@ -98,6 +98,7 @@ export const ErrorCodeToI18nKey = {
[APIErrorCodes.DISCOVERY_APPLICATION_ALREADY_REVIEWED]: 'discovery.application_already_reviewed',
[APIErrorCodes.DISCOVERY_APPLICATION_NOT_FOUND]: 'discovery.application_not_found',
[APIErrorCodes.DISCOVERY_DESCRIPTION_REQUIRED]: 'discovery.description_required',
[APIErrorCodes.DISCOVERY_DISABLED]: 'discovery.disabled',
[APIErrorCodes.DISCOVERY_INSUFFICIENT_MEMBERS]: 'discovery.insufficient_members',
[APIErrorCodes.DISCOVERY_INVALID_CATEGORY]: 'discovery.invalid_category',
[APIErrorCodes.DISCOVERY_NOT_DISCOVERABLE]: 'discovery.not_discoverable',

View File

@@ -238,6 +238,7 @@ export type ErrorI18nKey =
| 'discovery.application_already_reviewed'
| 'discovery.application_not_found'
| 'discovery.description_required'
| 'discovery.disabled'
| 'discovery.insufficient_members'
| 'discovery.invalid_category'
| 'discovery.not_discoverable'

View File

@@ -218,6 +218,7 @@ discovery.already_applied: هذا المجتمع تقدّم بالفعل بطل
discovery.application_already_reviewed: تمت مراجعة طلب الاستكشاف هذا بالفعل.
discovery.application_not_found: طلب الاستكشاف غير موجود.
discovery.description_required: الوصف مطلوب لإدراجك في الاستكشاف.
discovery.disabled: الاكتشاف غير متاح على هذا المثيل.
discovery.insufficient_members: هذا المجتمع ما يحقق الحد الأدنى من الأعضاء للظهور في الاستكشاف.
discovery.invalid_category: فئة الاستكشاف غير صالحة.
discovery.not_discoverable: هذا المجتمع غير مدرج في الاستكشاف.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: Тази общност вече е кандидат
discovery.application_already_reviewed: Тази заявка за открития вече е разгледана.
discovery.application_not_found: Заявката за открития не беше намерена.
discovery.description_required: Описание е задължително за списъка с открития.
discovery.disabled: Откритието не е налично на този екземпляр.
discovery.insufficient_members: Тази общност не отговаря на минималния брой членове за открития.
discovery.invalid_category: Невалидна категория за открития.
discovery.not_discoverable: Тази общност не е включена в откритията.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: Tato komunita o zápis do discovery už požádala.
discovery.application_already_reviewed: Tato žádost o zápis do discovery už byla přezkoumána.
discovery.application_not_found: Žádost o zápis do discovery nebyla nalezena.
discovery.description_required: Pro zápis do discovery je potřeba popis.
discovery.disabled: Objevování není na této instanci k dispozici.
discovery.insufficient_members: Tato komunita nesplňuje minimální počet členů pro zápis do discovery.
discovery.invalid_category: Neplatná kategorie discovery.
discovery.not_discoverable: Tato komunita není zapsaná v discovery.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: Dette fællesskab har allerede ansøgt om opdagelse.
discovery.application_already_reviewed: Denne opdagelsesansøgning er allerede blevet behandlet.
discovery.application_not_found: Opdagelsesansøgningen blev ikke fundet.
discovery.description_required: En beskrivelse er påkrævet for at blive vist i opdagelse.
discovery.disabled: Opdagelse er ikke tilgængelig på denne forekomst.
discovery.insufficient_members: Dette fællesskab opfylder ikke minimumsantallet af medlemmer for opdagelse.
discovery.invalid_category: Ugyldig opdagelseskategori.
discovery.not_discoverable: Dette fællesskab er ikke vist i opdagelse.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: Diese Community hat sich bereits für die Entdeckung
discovery.application_already_reviewed: Dieser Entdeckungsantrag wurde bereits geprüft.
discovery.application_not_found: Entdeckungsantrag wurde nicht gefunden.
discovery.description_required: Eine Beschreibung ist für den Entdeckungseintrag erforderlich.
discovery.disabled: Entdeckung ist auf dieser Instanz nicht verfügbar.
discovery.insufficient_members: Diese Community erfüllt nicht die Mindestanzahl an Mitgliedern für die Entdeckung.
discovery.invalid_category: Ungültige Entdeckungskategorie.
discovery.not_discoverable: Diese Community ist nicht in der Entdeckung gelistet.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: Αυτή η κοινότητα έχει ήδη υπο
discovery.application_already_reviewed: Αυτή η αίτηση ανακάλυψης έχει ήδη εξεταστεί.
discovery.application_not_found: Η αίτηση ανακάλυψης δεν βρέθηκε.
discovery.description_required: Απαιτείται περιγραφή για την καταχώρηση στην ανακάλυψη.
discovery.disabled: Η ανακάλυψη δεν είναι διαθέσιμη σε αυτήν την παρουσία.
discovery.insufficient_members: Αυτή η κοινότητα δεν πληροί τον ελάχιστο αριθμό μελών για ανακάλυψη.
discovery.invalid_category: Μη έγκυρη κατηγορία ανακάλυψης.
discovery.not_discoverable: Αυτή η κοινότητα δεν είναι καταχωρημένη στην ανακάλυψη.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: This community has already applied for discovery.
discovery.application_already_reviewed: This discovery application has already been reviewed.
discovery.application_not_found: Discovery application wasn't found.
discovery.description_required: A description is required for discovery listing.
discovery.disabled: Discovery isn't available on this instance.
discovery.insufficient_members: This community doesn't meet the minimum member count for discovery.
discovery.invalid_category: Invalid discovery category.
discovery.not_discoverable: This community isn't listed in discovery.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: Esta comunidad ya aplicó al descubrimiento.
discovery.application_already_reviewed: Esta solicitud de descubrimiento ya fue revisada.
discovery.application_not_found: No se encontró la solicitud de descubrimiento.
discovery.description_required: Se requiere una descripción para el listado en descubrimiento.
discovery.disabled: El descubrimiento no está disponible en esta instancia.
discovery.insufficient_members: Esta comunidad no cumple con el mínimo de miembros para el descubrimiento.
discovery.invalid_category: Categoría de descubrimiento inválida.
discovery.not_discoverable: Esta comunidad no está listada en descubrimiento.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: Esta comunidad ya ha solicitado ser listada en el dir
discovery.application_already_reviewed: Esta solicitud de listado ya ha sido revisada.
discovery.application_not_found: No se ha encontrado la solicitud de listado.
discovery.description_required: Se requiere una descripción para el listado en el directorio.
discovery.disabled: El descubrimiento no está disponible en esta instancia.
discovery.insufficient_members: Esta comunidad no cumple el mínimo de miembros necesario para el directorio.
discovery.invalid_category: Categoría de directorio no válida.
discovery.not_discoverable: Esta comunidad no está listada en el directorio.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: Tämä yhteisö on jo hakenut hakemistoon.
discovery.application_already_reviewed: Tämä hakemistohakemus on jo käsitelty.
discovery.application_not_found: Hakemistohakemusta ei löytynyt.
discovery.description_required: Hakemistoluetteloa varten vaaditaan kuvaus.
discovery.disabled: Haku ei ole saatavilla tässä instanssissa.
discovery.insufficient_members: Tällä yhteisöllä ei ole tarpeeksi jäseniä hakemistoon.
discovery.invalid_category: Virheellinen hakemistokategoria.
discovery.not_discoverable: Tämä yhteisö ei ole hakemistossa.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: Cette communauté a déjà postulé pour la découver
discovery.application_already_reviewed: Cette candidature de découverte a déjà été examinée.
discovery.application_not_found: La candidature de découverte est introuvable.
discovery.description_required: Une description est requise pour le référencement dans la découverte.
discovery.disabled: La découverte n'est pas disponible sur cette instance.
discovery.insufficient_members: Cette communauté n'atteint pas le nombre minimum de membres requis pour la découverte.
discovery.invalid_category: Catégorie de découverte invalide.
discovery.not_discoverable: Cette communauté n'est pas référencée dans la découverte.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: הקהילה הזו כבר הגישה בקשה לגי
discovery.application_already_reviewed: בקשת הגילוי הזו כבר נבדקה.
discovery.application_not_found: בקשת הגילוי לא נמצאה.
discovery.description_required: נדרש תיאור לרישום בגילוי.
discovery.disabled: הגילוי אינו זמין בהופעה זו.
discovery.insufficient_members: הקהילה הזו לא עומדת במספר המינימלי של חברים לגילוי.
discovery.invalid_category: קטגוריית גילוי לא תקינה.
discovery.not_discoverable: הקהילה הזו לא מופיעה ברשימת הגילוי.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: यह समुदाय पहले ही डि
discovery.application_already_reviewed: इस डिस्कवरी आवेदन की पहले ही समीक्षा की जा चुकी है।
discovery.application_not_found: डिस्कवरी आवेदन नहीं मिला।
discovery.description_required: डिस्कवरी सूची के लिए विवरण जरूरी है।
discovery.disabled: यह उदाहरण पर आविष्कार उपलब्ध नहीं है।
discovery.insufficient_members: यह समुदाय डिस्कवरी के लिए न्यूनतम सदस्य संख्या की शर्त पूरी नहीं करता।
discovery.invalid_category: डिस्कवरी श्रेणी अमान्य है।
discovery.not_discoverable: यह समुदाय डिस्कवरी में सूचीबद्ध नहीं है।

View File

@@ -218,6 +218,7 @@ discovery.already_applied: Ova zajednica se već prijavila za otkrivanje.
discovery.application_already_reviewed: Ovaj zahtjev za otkrivanje je već pregledan.
discovery.application_not_found: Zahtjev za otkrivanje nije pronađen.
discovery.description_required: Opis je obavezan za popis u otkrivanju.
discovery.disabled: Otkrivanje nije dostupno na ovoj instanci.
discovery.insufficient_members: Ova zajednica ne ispunjava minimalan broj članova za otkrivanje.
discovery.invalid_category: Neispravna kategorija otkrivanja.
discovery.not_discoverable: Ova zajednica nije navedena u otkrivanju.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: Ez a közösség már jelentkezett a felfedezésbe.
discovery.application_already_reviewed: Ezt a felfedezési kérelmet már elbírálták.
discovery.application_not_found: A felfedezési kérelem nem található.
discovery.description_required: A felfedezési listázáshoz leírás szükséges.
discovery.disabled: A felfedezés nem érhető el ezen a példányon.
discovery.insufficient_members: Ez a közösség nem éri el a felfedezéshez szükséges minimális taglétszámot.
discovery.invalid_category: Érvénytelen felfedezési kategória.
discovery.not_discoverable: Ez a közösség nem szerepel a felfedezésben.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: Komunitas ini sudah mengajukan permohonan untuk penem
discovery.application_already_reviewed: Permohonan penemuan ini sudah ditinjau.
discovery.application_not_found: Permohonan penemuan tidak ditemukan.
discovery.description_required: Deskripsi wajib diisi untuk pendaftaran penemuan.
discovery.disabled: Penemuan tidak tersedia di instans ini.
discovery.insufficient_members: Komunitas ini tidak memenuhi jumlah anggota minimum untuk penemuan.
discovery.invalid_category: Kategori penemuan tidak valid.
discovery.not_discoverable: Komunitas ini tidak terdaftar dalam penemuan.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: Questa comunità ha già fatto richiesta per la disco
discovery.application_already_reviewed: Questa richiesta di discovery è già stata esaminata.
discovery.application_not_found: Richiesta di discovery non trovata.
discovery.description_required: È necessaria una descrizione per la scheda di discovery.
discovery.disabled: La scoperta non è disponibile su questa istanza.
discovery.insufficient_members: Questa comunità non soddisfa il numero minimo di membri per la discovery.
discovery.invalid_category: Categoria di discovery non valida.
discovery.not_discoverable: Questa comunità non è presente nella discovery.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: このコミュニティはすでにディスカバ
discovery.application_already_reviewed: このディスカバリー申請はすでに審査済みだよ。
discovery.application_not_found: ディスカバリー申請が見つからないよ。
discovery.description_required: ディスカバリーの掲載には説明文が必要だよ。
discovery.disabled: このインスタンスではDiscoveryを利用できません。
discovery.insufficient_members: このコミュニティはディスカバリーに必要な最小メンバー数を満たしていないよ。
discovery.invalid_category: ディスカバリーのカテゴリが無効だよ。
discovery.not_discoverable: このコミュニティはディスカバリーに掲載されていないよ。

View File

@@ -218,6 +218,7 @@ discovery.already_applied: 이 커뮤니티는 이미 디스커버리에 신청
discovery.application_already_reviewed: 이 디스커버리 신청서는 이미 검토됐어요.
discovery.application_not_found: 디스커버리 신청서를 찾을 수 없어요.
discovery.description_required: 디스커버리 등록에는 설명이 필요해요.
discovery.disabled: 이 인스턴스에서는 발견을 사용할 수 없습니다.
discovery.insufficient_members: 이 커뮤니티는 디스커버리에 필요한 최소 멤버 수를 충족하지 못해요.
discovery.invalid_category: 디스커버리 카테고리가 올바르지 않아요.
discovery.not_discoverable: 이 커뮤니티는 디스커버리에 등록돼 있지 않아요.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: Ši bendruomenė jau kreipėsi dėl atradimų.
discovery.application_already_reviewed: Ši atradimų paraiška jau peržiūrėta.
discovery.application_not_found: Atradimų paraiška nerasta.
discovery.description_required: Atradimų sąraše reikalingas aprašymas.
discovery.disabled: Atradimas nėra pasiekiamas šioje egzemplioriuje.
discovery.insufficient_members: Ši bendruomenė neatitinka minimalaus narių skaičiaus atradimams.
discovery.invalid_category: Neteisinga atradimų kategorija.
discovery.not_discoverable: Ši bendruomenė nėra įtraukta į atradimų sąrašą.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: This community has already applied for discovery.
discovery.application_already_reviewed: This discovery application has already been reviewed.
discovery.application_not_found: Discovery application wasn't found.
discovery.description_required: A description is required for discovery listing.
discovery.disabled: Discovery isn't available on this instance.
discovery.insufficient_members: This community doesn't meet the minimum member count for discovery.
discovery.invalid_category: Invalid discovery category.
discovery.not_discoverable: This community isn't listed in discovery.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: Deze community heeft al een aanvraag ingediend voor o
discovery.application_already_reviewed: Deze ontdekkingsaanvraag is al beoordeeld.
discovery.application_not_found: Ontdekkingsaanvraag is niet gevonden.
discovery.description_required: Een beschrijving is verplicht voor de ontdekkingsvermelding.
discovery.disabled: Ontdekking is niet beschikbaar op deze instantie.
discovery.insufficient_members: Deze community voldoet niet aan het minimale ledenaantal voor ontdekking.
discovery.invalid_category: Ongeldige ontdekkingscategorie.
discovery.not_discoverable: Deze community staat niet vermeld in ontdekking.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: Dette fellesskapet har allerede søkt om oppføring i
discovery.application_already_reviewed: Denne oppdagelsessøknaden er allerede gjennomgått.
discovery.application_not_found: Fant ikke oppdagelsessøknaden.
discovery.description_required: En beskrivelse er påkrevd for oppføring i oppdagelse.
discovery.disabled: Oppdagelse er ikke tilgjengelig på denne instansen.
discovery.insufficient_members: Dette fellesskapet oppfyller ikke minimumskravet for antall medlemmer for oppdagelse.
discovery.invalid_category: Ugyldig oppdagelseskategori.
discovery.not_discoverable: Dette fellesskapet er ikke oppført i oppdagelse.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: Ta społeczność złożyła już wniosek o odkrywaln
discovery.application_already_reviewed: Ten wniosek o odkrywalność został już rozpatrzony.
discovery.application_not_found: Nie znaleziono wniosku o odkrywalność.
discovery.description_required: Opis jest wymagany do umieszczenia w odkrywalności.
discovery.disabled: Odkrywanie nie jest dostępne na tym serwerze.
discovery.insufficient_members: Ta społeczność nie spełnia minimalnej liczby członków wymaganej do odkrywalności.
discovery.invalid_category: Nieprawidłowa kategoria odkrywalności.
discovery.not_discoverable: Ta społeczność nie jest wymieniona w odkrywalności.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: Esta comunidade já se candidatou à descoberta.
discovery.application_already_reviewed: Esta candidatura de descoberta já foi analisada.
discovery.application_not_found: A candidatura de descoberta não foi encontrada.
discovery.description_required: É obrigatória uma descrição para a listagem de descoberta.
discovery.disabled: A descoberta não está disponível nesta instância.
discovery.insufficient_members: Esta comunidade não atinge o número mínimo de membros para a descoberta.
discovery.invalid_category: Categoria de descoberta inválida.
discovery.not_discoverable: Esta comunidade não está listada na descoberta.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: Această comunitate a aplicat deja pentru descoperire
discovery.application_already_reviewed: Această cerere de descoperire a fost deja analizată.
discovery.application_not_found: Cererea de descoperire nu a fost găsită.
discovery.description_required: O descriere este obligatorie pentru listarea în descoperire.
discovery.disabled: Descoperirea nu este disponibilă pe această instanță.
discovery.insufficient_members: Această comunitate nu întrunește numărul minim de membri pentru descoperire.
discovery.invalid_category: Categorie de descoperire invalidă.
discovery.not_discoverable: Această comunitate nu este listată în descoperire.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: Это сообщество уже подало за
discovery.application_already_reviewed: Эта заявка на участие в каталоге уже рассмотрена.
discovery.application_not_found: Заявка на участие в каталоге не найдена.
discovery.description_required: Для размещения в каталоге нужно добавить описание.
discovery.disabled: Обнаружение недоступно на этом экземпляре.
discovery.insufficient_members: В этом сообществе недостаточно участников для участия в каталоге.
discovery.invalid_category: Недопустимая категория каталога.
discovery.not_discoverable: Это сообщество не размещено в каталоге.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: Den här gemenskapen har redan ansökt om discovery.
discovery.application_already_reviewed: Den här discovery-ansökan har redan granskats.
discovery.application_not_found: Discovery-ansökan hittades inte.
discovery.description_required: En beskrivning krävs för att listas i discovery.
discovery.disabled: Upptäckten är inte tillgänglig på denna instans.
discovery.insufficient_members: Den här gemenskapen uppfyller inte minimikravet för antal medlemmar för discovery.
discovery.invalid_category: Ogiltig discovery-kategori.
discovery.not_discoverable: Den här gemenskapen är inte listad i discovery.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: ชุมชนนี้สมัครเพื่
discovery.application_already_reviewed: ใบสมัครการค้นพบนี้ถูกตรวจสอบแล้ว
discovery.application_not_found: ไม่พบใบสมัครการค้นพบ
discovery.description_required: ต้องมีคำอธิบายสำหรับการลงรายการการค้นพบ
discovery.disabled: ไม่มีการค้นพบบนอินสแตนซ์นี้
discovery.insufficient_members: ชุมชนนี้มีจำนวนสมาชิกไม่ถึงขั้นต่ำที่กำหนดสำหรับการค้นพบ
discovery.invalid_category: หมวดหมู่การค้นพบไม่ถูกต้อง
discovery.not_discoverable: ชุมชนนี้ไม่ได้อยู่ในรายการการค้นพบ

View File

@@ -218,6 +218,7 @@ discovery.already_applied: Bu topluluk keşif için zaten başvurmuş.
discovery.application_already_reviewed: Bu keşif başvurusu zaten incelenmiş.
discovery.application_not_found: Keşif başvurusu bulunamadı.
discovery.description_required: Keşif listesi için bir açıklama gerekli.
discovery.disabled: Keşif bu örnekte kullanılamaz.
discovery.insufficient_members: Bu topluluk keşif için gereken minimum üye sayısını karşılamıyor.
discovery.invalid_category: Geçersiz keşif kategorisi.
discovery.not_discoverable: Bu topluluk keşifte listelenmemiş.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: Ця спільнота вже подала заяв
discovery.application_already_reviewed: Цю заявку на виявлення вже розглянуто.
discovery.application_not_found: Заявку на виявлення не знайдено.
discovery.description_required: Для виявлення в списку потрібен опис.
discovery.disabled: Виявлення недоступне на цьому екземплярі.
discovery.insufficient_members: Ця спільнота не відповідає мінімальній кількості учасників для виявлення.
discovery.invalid_category: Недійсна категорія виявлення.
discovery.not_discoverable: Ця спільнота не включена у виявлення.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: Cộng đồng này đã nộp đơn đăng ký khám
discovery.application_already_reviewed: Đơn đăng ký khám phá này đã được xem xét rồi.
discovery.application_not_found: Không tìm thấy đơn đăng ký khám phá.
discovery.description_required: Cần có mô tả để đăng ký danh sách khám phá.
discovery.disabled: Khám phá không có sẵn trên phiên bản này.
discovery.insufficient_members: Cộng đồng này chưa đáp ứng số lượng thành viên tối thiểu để tham gia khám phá.
discovery.invalid_category: Danh mục khám phá không hợp lệ.
discovery.not_discoverable: Cộng đồng này không có trong danh sách khám phá.

View File

@@ -218,6 +218,7 @@ discovery.already_applied: 该社区已申请过发现功能。
discovery.application_already_reviewed: 该发现申请已审核过了。
discovery.application_not_found: 未找到发现申请。
discovery.description_required: 发现列表需要填写简介。
discovery.disabled: 此实例上不提供发现。
discovery.insufficient_members: 该社区的成员数量不满足发现功能的最低要求。
discovery.invalid_category: 发现分类无效。
discovery.not_discoverable: 该社区未在发现中列出。

View File

@@ -218,6 +218,7 @@ discovery.already_applied: 這個社群已經申請過探索功能了。
discovery.application_already_reviewed: 這份探索申請已經審核過了。
discovery.application_not_found: 找不到探索申請。
discovery.description_required: 加入探索列表需要提供描述。
discovery.disabled: 此實例上無法使用發現。
discovery.insufficient_members: 這個社群的成員數量未達探索功能的最低要求。
discovery.invalid_category: 探索分類無效。
discovery.not_discoverable: 這個社群未列於探索列表中。

View File

@@ -30,7 +30,6 @@ import {createMarketingContextFactory} from '@fluxer/marketing/src/app/Marketing
import {applyMarketingMiddlewareStack} from '@fluxer/marketing/src/app/MarketingMiddlewareStack';
import {registerMarketingRoutes} from '@fluxer/marketing/src/app/MarketingRouteRegistrar';
import {applyMarketingStaticAssets} from '@fluxer/marketing/src/app/MarketingStaticAssets';
import {createBadgeCache, productHuntFeaturedUrl, productHuntTopPostUrl} from '@fluxer/marketing/src/BadgeProxy';
import type {MarketingConfig} from '@fluxer/marketing/src/MarketingConfig';
import {createMarketingMetricsMiddleware} from '@fluxer/marketing/src/MarketingTelemetry';
import {initializeMarketingCsrf} from '@fluxer/marketing/src/middleware/Csrf';
@@ -59,14 +58,9 @@ export function createMarketingApp(options: CreateMarketingAppOptions): Marketin
const publicDir = resolve(publicDirOption ?? fileURLToPath(new URL('../public', import.meta.url)));
const app = new Hono();
const badgeFeaturedCache = createBadgeCache(productHuntFeaturedUrl);
const badgeTopPostCache = createBadgeCache(productHuntTopPostUrl);
const contextFactory = createMarketingContextFactory({
config,
publicDir,
badgeFeaturedCache,
badgeTopPostCache,
});
initializeMarketingCsrf(config.secretKeyBase, config.env === 'production');
@@ -93,8 +87,6 @@ export function createMarketingApp(options: CreateMarketingAppOptions): Marketin
app,
config,
contextFactory,
badgeFeaturedCache,
badgeTopPostCache,
});
const shutdown = (): void => {

View File

@@ -1,110 +0,0 @@
/*
* 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/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {readMarketingResponseAsText, sendMarketingRequest} from '@fluxer/marketing/src/MarketingHttpClient';
import type {Context} from 'hono';
export const productHuntFeaturedUrl =
'https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1057558&theme=light';
export const productHuntTopPostUrl =
'https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=1057558&theme=light&period=daily&t=1767529639613';
const staleAfterMs = 300_000;
const fetchTimeoutMs = 4_500;
export interface BadgeCache {
getBadge(): Promise<string | null>;
}
interface CacheEntry {
svg: string;
fetchedAt: number;
}
export function createBadgeCache(url: string): BadgeCache {
let cache: CacheEntry | null = null;
let isRefreshing = false;
async function refreshBadge(): Promise<void> {
if (isRefreshing) return;
isRefreshing = true;
try {
const svg = await fetchBadgeSvg(url);
if (svg) {
cache = {svg, fetchedAt: Date.now()};
}
} finally {
isRefreshing = false;
}
}
return {
async getBadge() {
const now = Date.now();
if (!cache) {
const svg = await fetchBadgeSvg(url);
if (svg) {
cache = {svg, fetchedAt: now};
}
return svg;
}
const isStale = now - cache.fetchedAt > staleAfterMs;
if (isStale) {
void refreshBadge();
}
return cache.svg;
},
};
}
export async function createBadgeResponse(cache: BadgeCache, c: Context): Promise<Response> {
const svg = await cache.getBadge();
if (!svg) {
c.header('content-type', 'text/plain');
c.header('retry-after', '60');
return c.text('Badge temporarily unavailable', 503);
}
c.header('content-type', 'image/svg+xml');
c.header('cache-control', 'public, max-age=300, stale-while-revalidate=600');
c.header('vary', 'Accept');
return c.body(svg, 200);
}
async function fetchBadgeSvg(url: string): Promise<string | null> {
try {
const response = await sendMarketingRequest({
url,
method: 'GET',
headers: {
Accept: 'image/svg+xml',
},
timeout: fetchTimeoutMs,
serviceName: 'marketing_badges',
});
if (response.status < 200 || response.status >= 300) return null;
return await readMarketingResponseAsText(response.stream);
} catch {
return null;
}
}

View File

@@ -21,7 +21,6 @@
/** @jsxImportSource hono/jsx */
import type {LocaleCode} from '@fluxer/constants/src/Locales';
import type {BadgeCache} from '@fluxer/marketing/src/BadgeProxy';
import type {MarketingI18nService} from '@fluxer/marketing/src/marketing_i18n/MarketingI18nService';
export type MarketingPlatform = 'windows' | 'macos' | 'linux' | 'ios' | 'android' | 'unknown';
@@ -40,8 +39,6 @@ export interface MarketingContext {
platform: MarketingPlatform;
architecture: MarketingArchitecture;
releaseChannel: string;
badgeFeaturedCache: BadgeCache;
badgeTopPostCache: BadgeCache;
csrfToken: string;
isDev: boolean;
}

View File

@@ -21,7 +21,6 @@
/** @jsxImportSource hono/jsx */
import {CdnEndpoints} from '@fluxer/constants/src/CdnEndpoints';
import type {BadgeCache} from '@fluxer/marketing/src/BadgeProxy';
import {createI18n} from '@fluxer/marketing/src/I18n';
import type {MarketingConfig} from '@fluxer/marketing/src/MarketingConfig';
import type {MarketingContext} from '@fluxer/marketing/src/MarketingContext';
@@ -33,8 +32,6 @@ import type {Context as HonoContext} from 'hono';
export interface CreateMarketingContextFactoryOptions {
config: MarketingConfig;
publicDir: string;
badgeFeaturedCache: BadgeCache;
badgeTopPostCache: BadgeCache;
}
export type MarketingContextFactory = (c: HonoContext) => Promise<MarketingContext>;
@@ -62,8 +59,6 @@ export function createMarketingContextFactory(options: CreateMarketingContextFac
platform: requestInfo.platform,
architecture: requestInfo.architecture,
releaseChannel: options.config.releaseChannel,
badgeFeaturedCache: options.badgeFeaturedCache,
badgeTopPostCache: options.badgeTopPostCache,
csrfToken,
isDev: options.config.env === 'development',
};

View File

@@ -26,7 +26,6 @@ import {HttpStatus, MimeType} from '@fluxer/constants/src/HttpConstants';
import {isPressAssetId, PressAssets} from '@fluxer/constants/src/PressAssets';
import {createSession} from '@fluxer/hono/src/Session';
import {getLocaleFromCode} from '@fluxer/locale/src/LocaleService';
import {type BadgeCache, createBadgeResponse} from '@fluxer/marketing/src/BadgeProxy';
import type {MarketingConfig} from '@fluxer/marketing/src/MarketingConfig';
import {sendMarketingRequest} from '@fluxer/marketing/src/MarketingHttpClient';
import {renderCareersPage} from '@fluxer/marketing/src/pages/CareersPage';
@@ -54,8 +53,6 @@ export interface RegisterMarketingRoutesOptions {
app: Hono;
config: MarketingConfig;
contextFactory: MarketingContextFactory;
badgeFeaturedCache: BadgeCache;
badgeTopPostCache: BadgeCache;
}
interface LocaleCookieSession {
@@ -88,7 +85,6 @@ const PAGE_ROUTE_DEFINITIONS: ReadonlyArray<{
];
export function registerMarketingRoutes(options: RegisterMarketingRoutesOptions): void {
registerBadgeRoutes(options.app, options.badgeFeaturedCache, options.badgeTopPostCache);
registerLocaleRoute(options.app, options.config);
registerExternalRedirects(options.app);
registerSystemContentRoutes(options.app, options.contextFactory);
@@ -99,16 +95,6 @@ export function registerMarketingRoutes(options: RegisterMarketingRoutesOptions)
registerNotFoundRoute(options.app, options.contextFactory);
}
function registerBadgeRoutes(app: Hono, badgeFeaturedCache: BadgeCache, badgeTopPostCache: BadgeCache): void {
app.get('/api/badges/product-hunt', async (c) => {
return await createBadgeResponse(badgeFeaturedCache, c);
});
app.get('/api/badges/product-hunt-top-post', async (c) => {
return await createBadgeResponse(badgeTopPostCache, c);
});
}
function registerLocaleRoute(app: Hono, config: MarketingConfig): void {
app.post('/_locale', async (c) => {
const body = await c.req.parseBody();

View File

@@ -20,11 +20,12 @@
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {Locales} from '@fluxer/constants/src/Locales';
import {FlagSvg} from '@fluxer/marketing/src/components/Flags';
import {HackernewsBanner} from '@fluxer/marketing/src/components/HackernewsBanner';
import {MadeInSwedenBadge} from '@fluxer/marketing/src/components/MadeInSwedenBadge';
import {ArrowRightIcon} from '@fluxer/marketing/src/components/icons/ArrowRightIcon';
import {renderSecondaryButton, renderWithOverlay} from '@fluxer/marketing/src/components/PlatformDownloadButton';
import type {MarketingContext} from '@fluxer/marketing/src/MarketingContext';
import {href} from '@fluxer/marketing/src/UrlUtils';
interface HeroProps {
ctx: MarketingContext;
@@ -41,13 +42,35 @@ export function Hero(props: HeroProps): JSX.Element {
<span class="font-bold text-3xl text-white">Fluxer</span>
</div>
) : null}
<div class="flex flex-wrap justify-center gap-3 pb-2">
<span class="rounded-full bg-white px-4 py-1.5 font-medium text-[#4641D9] text-sm">
{ctx.i18n.getMessage('beta_and_access.public_beta', ctx.locale)}
</span>
<MadeInSwedenBadge ctx={ctx} />
<div class="flex flex-wrap items-center justify-center gap-3 pb-2">
<a
href="https://blog.fluxer.app/how-i-built-fluxer-a-discord-like-chat-app/"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 rounded-full bg-white/10 px-4 py-1.5 font-medium text-sm text-white transition-colors hover:bg-white/20"
>
{ctx.i18n.getMessage('launch.heading', ctx.locale)}
<ArrowRightIcon class="h-3.5 w-3.5" />
</a>
<a
href="https://blog.fluxer.app/roadmap-2026"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 rounded-full bg-white/10 px-4 py-1.5 font-medium text-sm text-white transition-colors hover:bg-white/20"
>
{ctx.i18n.getMessage('launch.view_full_roadmap', ctx.locale)}
<ArrowRightIcon class="h-3.5 w-3.5" />
</a>
</div>
<h1 class="hero">{ctx.i18n.getMessage('general.tagline', ctx.locale)}</h1>
<div class="-mt-4 flex items-center justify-center gap-2 font-medium text-sm text-white/80">
<span>{ctx.i18n.getMessage('beta_and_access.public_beta', ctx.locale)}</span>
<span class="text-white/40">·</span>
<span class="inline-flex items-center gap-1.5">
<FlagSvg locale={Locales.SV_SE} ctx={ctx} class="h-3.5 w-3.5 rounded-sm" />
{ctx.i18n.getMessage('general.made_in_sweden', ctx.locale)}
</span>
</div>
<p class="lead mx-auto max-w-2xl text-white/90">
{ctx.i18n.getMessage('product_positioning.intro', ctx.locale)}
</p>
@@ -105,32 +128,6 @@ export function Hero(props: HeroProps): JSX.Element {
</picture>
</div>
</div>
<div class="mt-10 flex flex-wrap items-center justify-center gap-4 md:mt-12">
<a
href="https://www.producthunt.com/products/fluxer?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-fluxer"
target="_blank"
rel="noopener noreferrer"
>
<img
alt="Fluxer - Open-source Discord-like instant messaging & VoIP platform | Product Hunt"
width="250"
height="54"
src={href(ctx, '/api/badges/product-hunt')}
/>
</a>
<a
href="https://www.producthunt.com/products/fluxer?embed=true&utm_source=badge-top-post-badge&utm_medium=badge&utm_campaign=badge-fluxer"
target="_blank"
rel="noopener noreferrer"
>
<img
alt={ctx.i18n.getMessage('misc_labels.product_hunt_badge_title', ctx.locale)}
width="250"
height="54"
src={href(ctx, '/api/badges/product-hunt-top-post')}
/>
</a>
</div>
</main>
);
}

View File

@@ -1,67 +0,0 @@
/*
* 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/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {ArrowRightIcon} from '@fluxer/marketing/src/components/icons/ArrowRightIcon';
import {MarketingButton, MarketingButtonSecondary} from '@fluxer/marketing/src/components/MarketingButton';
import {Section} from '@fluxer/marketing/src/components/Section';
import type {MarketingContext} from '@fluxer/marketing/src/MarketingContext';
interface LaunchBlogSectionProps {
ctx: MarketingContext;
}
export function LaunchBlogSection(props: LaunchBlogSectionProps): JSX.Element {
const {ctx} = props;
return (
<Section
variant="light"
title={ctx.i18n.getMessage('launch.heading', ctx.locale)}
description={ctx.i18n.getMessage('launch.description', ctx.locale)}
>
<div class="mx-auto max-w-4xl">
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row sm:items-stretch">
<MarketingButton
href="https://blog.fluxer.app/how-i-built-fluxer-a-discord-like-chat-app/"
size="large"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 md:text-xl"
>
{ctx.i18n.getMessage('launch.read_more', ctx.locale)}
<ArrowRightIcon class="h-5 w-5 md:h-6 md:w-6" />
</MarketingButton>
<MarketingButtonSecondary
href="https://blog.fluxer.app/roadmap-2026"
size="large"
target="_blank"
rel="noopener noreferrer"
class="md:text-xl"
>
{ctx.i18n.getMessage('launch.view_full_roadmap', ctx.locale)}
<ArrowRightIcon class="h-5 w-5 md:h-6 md:w-6" />
</MarketingButtonSecondary>
</div>
</div>
</Section>
);
}

View File

@@ -24,7 +24,6 @@ import {CurrentFeaturesSection} from '@fluxer/marketing/src/components/CurrentFe
import {FinalCtaSection} from '@fluxer/marketing/src/components/FinalCtaSection';
import {GetInvolvedSection} from '@fluxer/marketing/src/components/GetInvolvedSection';
import {Hero} from '@fluxer/marketing/src/components/Hero';
import {LaunchBlogSection} from '@fluxer/marketing/src/components/LaunchBlogSection';
import type {MarketingContext} from '@fluxer/marketing/src/MarketingContext';
import {renderLayout} from '@fluxer/marketing/src/pages/Layout';
import {defaultPageMeta} from '@fluxer/marketing/src/pages/layout/Meta';
@@ -34,7 +33,6 @@ export async function renderHomePage(c: Context, ctx: MarketingContext): Promise
const getInvolved = await GetInvolvedSection({ctx});
const content: ReadonlyArray<JSX.Element> = [
<Hero ctx={ctx} />,
<LaunchBlogSection ctx={ctx} />,
<CurrentFeaturesSection ctx={ctx} />,
getInvolved,
<FinalCtaSection ctx={ctx} />,

View File

@@ -30,7 +30,7 @@ export const DiscoveryApplicationRequest = z.object({
.min(DISCOVERY_DESCRIPTION_MIN_LENGTH)
.max(DISCOVERY_DESCRIPTION_MAX_LENGTH)
.describe('Description for discovery listing'),
category_id: z.number().int().min(0).max(8).describe('Discovery category ID'),
category_type: z.number().int().min(0).max(8).describe('Discovery category type'),
});
export type DiscoveryApplicationRequest = z.infer<typeof DiscoveryApplicationRequest>;
@@ -42,7 +42,7 @@ export const DiscoveryApplicationPatchRequest = z.object({
.max(DISCOVERY_DESCRIPTION_MAX_LENGTH)
.optional()
.describe('Updated description for discovery listing'),
category_id: z.number().int().min(0).max(8).optional().describe('Updated discovery category ID'),
category_type: z.number().int().min(0).max(8).optional().describe('Updated discovery category type'),
});
export type DiscoveryApplicationPatchRequest = z.infer<typeof DiscoveryApplicationPatchRequest>;
@@ -62,7 +62,7 @@ export const DiscoveryGuildResponse = z.object({
name: z.string().describe('Guild name'),
icon: z.string().nullish().describe('Guild icon hash'),
description: z.string().nullish().describe('Discovery description'),
category_id: z.number().describe('Discovery category ID'),
category_type: z.number().describe('Discovery category type'),
member_count: z.number().describe('Approximate member count'),
online_count: z.number().describe('Approximate online member count'),
features: z.array(z.string()).describe('Guild feature flags'),
@@ -82,7 +82,7 @@ export const DiscoveryApplicationResponse = z.object({
guild_id: SnowflakeStringType.describe('Guild ID'),
status: z.string().describe('Application status'),
description: z.string().describe('Discovery description'),
category_id: z.number().describe('Discovery category ID'),
category_type: z.number().describe('Discovery category type'),
applied_at: z.string().describe('Application timestamp'),
reviewed_at: z.string().nullish().describe('Review timestamp'),
review_reason: z.string().nullish().describe('Review reason'),
@@ -90,6 +90,14 @@ export const DiscoveryApplicationResponse = z.object({
export type DiscoveryApplicationResponse = z.infer<typeof DiscoveryApplicationResponse>;
export const DiscoveryStatusResponse = z.object({
application: DiscoveryApplicationResponse.nullish().describe('Current discovery application, if any'),
eligible: z.boolean().describe('Whether the guild meets the requirements to apply for discovery'),
min_member_count: z.number().describe('Minimum member count required for discovery eligibility'),
});
export type DiscoveryStatusResponse = z.infer<typeof DiscoveryStatusResponse>;
export const DiscoveryCategoryResponse = z.object({
id: z.number().describe('Category ID'),
name: z.string().describe('Category display name'),