mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(discovery): resolve message links into discoverable guilds (#3159)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createGuildID} from '@app/api/BrandedTypes';
|
||||
import {createChannelID, createGuildID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {GuildDiscoveryRow} from '@app/api/database/types/GuildDiscoveryTypes';
|
||||
import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware';
|
||||
@@ -15,12 +15,13 @@ import {GuildFeatures, JoinSourceTypes} from '@fluxer/constants/src/GuildConstan
|
||||
import {DiscoveryDisabledError} from '@fluxer/errors/src/domains/discovery/DiscoveryDisabledError';
|
||||
import {DiscoveryNotDiscoverableError} from '@fluxer/errors/src/domains/discovery/DiscoveryNotDiscoverableError';
|
||||
import {InvitesDisabledError} from '@fluxer/errors/src/domains/invite/InvitesDisabledError';
|
||||
import {GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {GuildIdChannelIdParam, GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {
|
||||
DiscoveryApplicationPatchRequest,
|
||||
DiscoveryApplicationRequest,
|
||||
DiscoveryApplicationResponse,
|
||||
DiscoveryCategoryListResponse,
|
||||
DiscoveryChannelPreviewResponse,
|
||||
DiscoveryGuildListResponse,
|
||||
DiscoverySearchQuery,
|
||||
DiscoveryStatusResponse,
|
||||
@@ -100,6 +101,31 @@ export function GuildDiscoveryController(app: HonoApp) {
|
||||
return ctx.json(categories);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/discovery/guilds/:guild_id/channels/:channel_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_CHANNEL_PREVIEW),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', GuildIdChannelIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_discovery_channel_preview',
|
||||
summary: 'Preview a channel in a discoverable guild',
|
||||
description:
|
||||
'Returns the guild and channel behind a channel or message link when the guild is listed in discovery and new members can read the channel.',
|
||||
responseSchema: DiscoveryChannelPreviewResponse,
|
||||
statusCode: 200,
|
||||
security: ['sessionToken', 'bearerToken'],
|
||||
tags: ['Discovery'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
ensureDiscoveryEnabled();
|
||||
const {guild_id, channel_id} = ctx.req.valid('param');
|
||||
const preview = await ctx
|
||||
.get('discoveryService')
|
||||
.getChannelPreview(createGuildID(guild_id), createChannelID(channel_id));
|
||||
return ctx.json(preview);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/discovery/guilds/:guild_id/join',
|
||||
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_JOIN),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {GuildID, UserID} from '@app/api/BrandedTypes';
|
||||
import {type ChannelID, type GuildID, guildIdToRoleId, type UserID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {IChannelDataRepository} from '@app/api/channel/repositories/IChannelDataRepository';
|
||||
import type {GuildDiscoveryRow} from '@app/api/database/types/GuildDiscoveryTypes';
|
||||
import {mapGuildToGuildResponse} from '@app/api/guild/GuildModel';
|
||||
import type {IGuildDiscoveryRepository} from '@app/api/guild/repositories/GuildDiscoveryRepository';
|
||||
@@ -10,6 +11,7 @@ import {contentModerationService} from '@app/api/infrastructure/ContentModeratio
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {IGuildSearchService} from '@app/api/search/IGuildSearchService';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {
|
||||
DISCOVERY_DEFAULT_LANGUAGE,
|
||||
DISCOVERY_MAX_TAGS,
|
||||
@@ -83,6 +85,8 @@ export abstract class IGuildDiscoveryService {
|
||||
|
||||
abstract listByStatus(params: {status: string}): Promise<Array<GuildDiscoveryRow>>;
|
||||
|
||||
abstract getChannelPreview(guildId: GuildID, channelId: ChannelID): Promise<DiscoveryChannelPreview>;
|
||||
|
||||
abstract searchDiscoverable(params: {
|
||||
query?: string;
|
||||
categoryId?: number;
|
||||
@@ -118,7 +122,13 @@ interface DiscoveryGuildResult {
|
||||
verification_level: number;
|
||||
}
|
||||
|
||||
interface DiscoveryChannelPreview {
|
||||
guild: {id: string; name: string; icon: string | null};
|
||||
channel: {id: string; name: string | null; type: number};
|
||||
}
|
||||
|
||||
const DISCOVERY_CATEGORY_FACET = 'discoveryCategory';
|
||||
const PUBLIC_CHANNEL_PERMISSIONS = Permissions.VIEW_CHANNEL | Permissions.READ_MESSAGE_HISTORY;
|
||||
|
||||
function toDiscoveryCategoryCounts(
|
||||
counts: Readonly<Record<string, number>> | undefined,
|
||||
@@ -143,6 +153,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
|
||||
private readonly guildRepository: IGuildRepositoryAggregate,
|
||||
private readonly gatewayService: IGatewayService,
|
||||
private readonly guildSearchService: IGuildSearchService | null,
|
||||
private readonly channelDataRepository: IChannelDataRepository,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -382,6 +393,38 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
|
||||
return this.discoveryRepository.listFullByStatus(params.status);
|
||||
}
|
||||
|
||||
async getChannelPreview(guildId: GuildID, channelId: ChannelID): Promise<DiscoveryChannelPreview> {
|
||||
const [status, guild, channel, everyoneRole] = await Promise.all([
|
||||
this.discoveryRepository.findByGuildId(guildId),
|
||||
this.guildRepository.findUnique(guildId),
|
||||
this.channelDataRepository.findUnique(channelId),
|
||||
this.guildRepository.getRole(guildIdToRoleId(guildId), guildId),
|
||||
]);
|
||||
if (
|
||||
status?.status !== DiscoveryApplicationStatus.APPROVED ||
|
||||
!guild ||
|
||||
guild.features.has(GuildFeatures.INVITES_DISABLED) ||
|
||||
!channel ||
|
||||
channel.guildId !== guildId ||
|
||||
!everyoneRole
|
||||
) {
|
||||
throw new DiscoveryNotDiscoverableError();
|
||||
}
|
||||
if ((everyoneRole.permissions & Permissions.ADMINISTRATOR) === 0n) {
|
||||
const overwrite = channel.permissionOverwrites.get(everyoneRole.id);
|
||||
const permissions = overwrite
|
||||
? (everyoneRole.permissions & ~overwrite.deny) | overwrite.allow
|
||||
: everyoneRole.permissions;
|
||||
if ((permissions & PUBLIC_CHANNEL_PERMISSIONS) !== PUBLIC_CHANNEL_PERMISSIONS) {
|
||||
throw new DiscoveryNotDiscoverableError();
|
||||
}
|
||||
}
|
||||
return {
|
||||
guild: {id: guild.id.toString(), name: guild.name, icon: guild.iconHash},
|
||||
channel: {id: channel.id.toString(), name: channel.name, type: channel.type},
|
||||
};
|
||||
}
|
||||
|
||||
async searchDiscoverable(params: {
|
||||
query?: string;
|
||||
categoryId?: number;
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import type {GuildID} from '@app/api/BrandedTypes';
|
||||
import {createTestBotAccount} from '@app/api/bot/tests/BotTestUtils';
|
||||
import {createGuild, getUserGuilds} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {createPermissionOverwrite} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {createChannel, createGuild, getUserGuilds} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {setInjectedGatewayService} from '@app/api/middleware/ServiceRegistry';
|
||||
import {getGuildRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {banUser} from '@app/api/moderation/tests/ModerationTestUtils';
|
||||
@@ -15,11 +16,13 @@ import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequest
|
||||
import syncDiscoveryIndex from '@app/api/worker/tasks/SyncDiscoveryIndex';
|
||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {DiscoveryCategories, DiscoveryCategoryLabels} from '@fluxer/constants/src/DiscoveryConstants';
|
||||
import {GuildVerificationLevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {
|
||||
DiscoveryApplicationResponse,
|
||||
DiscoveryCategoryResponse,
|
||||
DiscoveryChannelPreviewResponse,
|
||||
DiscoveryGuildListResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
|
||||
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
@@ -507,4 +510,65 @@ describe('Discovery Search and Join', () => {
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
describe('channel preview', () => {
|
||||
async function createListedGuild(name: string): Promise<{ownerToken: string; guildId: string; channelId: string}> {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, name);
|
||||
await setGuildMemberCount(harness, guild.id, 10);
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
await applyAndApprove(
|
||||
harness,
|
||||
owner.token,
|
||||
admin.token,
|
||||
guild.id,
|
||||
`${name} welcomes everyone`,
|
||||
DiscoveryCategories.GAMING,
|
||||
);
|
||||
return {ownerToken: owner.token, guildId: guild.id, channelId: guild.system_channel_id!};
|
||||
}
|
||||
test('should preview a channel that new members can read', async () => {
|
||||
const {guildId, channelId} = await createListedGuild('Preview Guild');
|
||||
const viewer = await createTestAccount(harness);
|
||||
const preview = await createBuilder<DiscoveryChannelPreviewResponse>(harness, viewer.token)
|
||||
.get(`/discovery/guilds/${guildId}/channels/${channelId}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(preview.guild.id).toBe(guildId);
|
||||
expect(preview.guild.name).toBe('Preview Guild');
|
||||
expect(preview.channel.id).toBe(channelId);
|
||||
});
|
||||
test('should not preview a channel hidden from everyone', async () => {
|
||||
const {ownerToken, guildId} = await createListedGuild('Hidden Channel Guild');
|
||||
const hidden = await createChannel(harness, ownerToken, guildId, 'staff');
|
||||
await createPermissionOverwrite(harness, ownerToken, hidden.id, guildId, {
|
||||
type: 0,
|
||||
allow: '0',
|
||||
deny: Permissions.VIEW_CHANNEL.toString(),
|
||||
});
|
||||
const viewer = await createTestAccount(harness);
|
||||
await createBuilder(harness, viewer.token)
|
||||
.get(`/discovery/guilds/${guildId}/channels/${hidden.id}`)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.DISCOVERY_NOT_DISCOVERABLE)
|
||||
.execute();
|
||||
});
|
||||
test('should not preview a channel from another guild', async () => {
|
||||
const {guildId} = await createListedGuild('Listed Guild');
|
||||
const other = await createListedGuild('Other Listed Guild');
|
||||
const viewer = await createTestAccount(harness);
|
||||
await createBuilder(harness, viewer.token)
|
||||
.get(`/discovery/guilds/${guildId}/channels/${other.channelId}`)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.DISCOVERY_NOT_DISCOVERABLE)
|
||||
.execute();
|
||||
});
|
||||
test('should not preview a guild that is not listed', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Unlisted Guild');
|
||||
const viewer = await createTestAccount(harness);
|
||||
await createBuilder(harness, viewer.token)
|
||||
.get(`/discovery/guilds/${guild.id}/channels/${guild.system_channel_id}`)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.DISCOVERY_NOT_DISCOVERABLE)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -463,6 +463,7 @@ export const getGuildDiscoveryService = singleton(
|
||||
getGuildRepository(),
|
||||
getGatewayService(),
|
||||
getGuildSearchService(),
|
||||
getChannelRepository().channelData,
|
||||
),
|
||||
);
|
||||
export const getReadStateRequestService = singleton(() => new ReadStateRequestService(getReadStateService()));
|
||||
|
||||
@@ -5541,6 +5541,77 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/discovery/guilds/{guild_id}/channels/{channel_id}": {
|
||||
"get": {
|
||||
"operationId": "get_discovery_channel_preview",
|
||||
"summary": "Preview a channel in a discoverable guild",
|
||||
"tags": ["Discovery"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/DiscoveryChannelPreviewResponse"}}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Returns the guild and channel behind a channel or message link when the guild is listed in discovery and new members can read the channel.",
|
||||
"security": [{"sessionToken": []}],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "guild_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"description": "The ID of the guild", "$ref": "#/components/schemas/SnowflakeType"},
|
||||
"description": "The ID of the guild"
|
||||
},
|
||||
{
|
||||
"name": "channel_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"description": "The ID of the channel", "$ref": "#/components/schemas/SnowflakeType"},
|
||||
"description": "The ID of the channel"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/discovery/guilds/{guild_id}/join": {
|
||||
"post": {
|
||||
"operationId": "join_discovery_guild",
|
||||
@@ -28579,6 +28650,35 @@
|
||||
"required": ["url"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DiscoveryChannelPreviewResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"guild": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"description": "Guild ID", "$ref": "#/components/schemas/SnowflakeStringType"},
|
||||
"name": {"type": "string", "description": "Guild name"},
|
||||
"icon": {"description": "Guild icon hash", "type": ["string", "null"]}
|
||||
},
|
||||
"required": ["id", "name", "icon"],
|
||||
"additionalProperties": false,
|
||||
"description": "The discoverable guild the channel belongs to"
|
||||
},
|
||||
"channel": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"description": "Channel ID", "$ref": "#/components/schemas/SnowflakeStringType"},
|
||||
"name": {"description": "Channel name", "type": ["string", "null"]},
|
||||
"type": {"type": "number", "description": "Channel type"}
|
||||
},
|
||||
"required": ["id", "name", "type"],
|
||||
"additionalProperties": false,
|
||||
"description": "A channel that new members can view"
|
||||
}
|
||||
},
|
||||
"required": ["guild", "channel"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DiscoveryGuildListResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -16,6 +16,10 @@ export const DiscoveryRateLimitConfigs = {
|
||||
bucket: 'discovery:join',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
DISCOVERY_CHANNEL_PREVIEW: {
|
||||
bucket: 'discovery:channel_preview',
|
||||
config: {limit: 60, windowMs: ms('10 seconds')},
|
||||
} as RouteRateLimitConfig,
|
||||
DISCOVERY_APPLY: {
|
||||
bucket: 'discovery:apply::guild_id',
|
||||
config: {limit: 5, windowMs: ms('1 minute')},
|
||||
|
||||
Reference in New Issue
Block a user