mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2a68847fd | ||
|
|
2019909a5e | ||
|
|
d46c8d49c6 | ||
|
|
45530ebbf5 | ||
|
|
9cdad046b1 | ||
|
|
b6c6928073 | ||
|
|
dd1ee999a4 | ||
|
|
c506d6d5e3 | ||
|
|
8a65832a65 | ||
|
|
fd6ae4abd7 | ||
|
|
24b84c419c | ||
|
|
746a75187a |
No files matched your search
@@ -188,7 +188,7 @@ FLUXER_CAPTCHA_TURNSTILE_SITE_KEY=
|
||||
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY=
|
||||
FLUXER_DISCOVERY_ENABLED=true
|
||||
|
||||
# Container memory. The 25 limits sum to 16.75 GiB, which is a sum of ceilings and
|
||||
# Container memory. The 25 limits sum to 18.25 GiB, which is a sum of ceilings and
|
||||
# not an allocation, so the defaults fit a host with 8 GB and are sized for 16 GB.
|
||||
# The four reservations are cgroup memory.low, which biases the kernel away from
|
||||
# reclaiming from the services whose death takes the whole instance down. They do
|
||||
@@ -199,7 +199,7 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_VALKEY_MEMORY_LIMIT=256mb
|
||||
#FLUXER_NATS_MEMORY_LIMIT=256mb
|
||||
#FLUXER_MEILISEARCH_MEMORY_LIMIT=768mb
|
||||
#FLUXER_SEAWEEDFS_MEMORY_LIMIT=512mb
|
||||
#FLUXER_SEAWEEDFS_MEMORY_LIMIT=2gb
|
||||
#FLUXER_SEAWEEDFS_INIT_MEMORY_LIMIT=128mb
|
||||
#FLUXER_LIVEKIT_MEMORY_LIMIT=512mb
|
||||
#FLUXER_API_MEMORY_LIMIT=2560mb
|
||||
@@ -227,6 +227,14 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
# which is the container ceiling the indexer shares with the search process.
|
||||
#FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=384mb
|
||||
|
||||
# SeaweedFS heap ceiling. Go collects against this value instead of against the
|
||||
# container limit, which it cannot see, so without it an upload burst grows the
|
||||
# heap past FLUXER_SEAWEEDFS_MEMORY_LIMIT and the kernel OOM-kills the container
|
||||
# mid-upload (exit 137). Keep it near three quarters of that limit, and raise both
|
||||
# together: the peak is the parts of one upload in flight at once, which is 25 MB
|
||||
# times 20 for a 500 MB attachment.
|
||||
#FLUXER_SEAWEEDFS_GOMEMLIMIT=1536MiB
|
||||
|
||||
# Node sizes its own heap from the container memory limit by default, at roughly
|
||||
# 55 percent of it, which always leaves room for the buffers and stacks that live
|
||||
# outside the heap. Leave these unset unless you have a reason to pin the value.
|
||||
|
||||
@@ -13,73 +13,51 @@
|
||||
}
|
||||
|
||||
handle_path /api/* {
|
||||
reverse_proxy api:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
reverse_proxy api:8080
|
||||
}
|
||||
|
||||
handle /gateway {
|
||||
rewrite * /
|
||||
reverse_proxy gateway:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
reverse_proxy gateway:8080
|
||||
}
|
||||
|
||||
handle_path /gateway/* {
|
||||
reverse_proxy gateway:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
reverse_proxy gateway:8080
|
||||
}
|
||||
|
||||
handle_path /media/* {
|
||||
reverse_proxy media-proxy:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
reverse_proxy media-proxy:8080
|
||||
}
|
||||
|
||||
handle_path /livekit/* {
|
||||
reverse_proxy livekit:7880 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
reverse_proxy livekit:7880
|
||||
}
|
||||
|
||||
handle /admin {
|
||||
rewrite * /
|
||||
reverse_proxy admin:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
reverse_proxy admin:8080
|
||||
}
|
||||
|
||||
handle_path /admin/* {
|
||||
reverse_proxy admin:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
reverse_proxy admin:8080
|
||||
}
|
||||
|
||||
@staticAssets path /web/* /emoji/* /libs/* /avatars/* /badges/* /desktop/* /embeds/*
|
||||
handle @staticAssets {
|
||||
reverse_proxy static-proxy:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
reverse_proxy static-proxy:8080
|
||||
}
|
||||
|
||||
handle /.well-known/fluxer {
|
||||
reverse_proxy api:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
reverse_proxy api:8080
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy app-proxy:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
reverse_proxy app-proxy:8080
|
||||
}
|
||||
}
|
||||
|
||||
:8088 {
|
||||
handle /.well-known/fluxer {
|
||||
reverse_proxy api:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
reverse_proxy api:8080
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_KV_URL: redis://valkey:6379/0
|
||||
FLUXER_NATS_URL: nats://nats:4222
|
||||
FLUXER_NATS_JETSTREAM_URL: nats://nats:4222
|
||||
FLUXER_NATS_AUTH_TOKEN: ${FLUXER_NATS_AUTH_TOKEN:-}
|
||||
FLUXER_SVC_NATS_URL: nats://nats:4222
|
||||
FLUXER_SVC_SHARD_COUNT: "1"
|
||||
|
||||
@@ -258,9 +259,11 @@ services:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_SEAWEEDFS_MEMORY_LIMIT:-512mb}
|
||||
memory: ${FLUXER_SEAWEEDFS_MEMORY_LIMIT:-2gb}
|
||||
restart: unless-stopped
|
||||
networks: [fluxer]
|
||||
environment:
|
||||
GOMEMLIMIT: ${FLUXER_SEAWEEDFS_GOMEMLIMIT:-1536MiB}
|
||||
command: ["server", "-s3", "-dir=/data"]
|
||||
volumes:
|
||||
- seaweedfs-data:/data
|
||||
|
||||
@@ -123,6 +123,7 @@ export function ChannelController(app: HonoApp) {
|
||||
const existing = await ctx.get('channelService').channelData.operations.getChannel({
|
||||
userId: ctx.get('user').id,
|
||||
channelId,
|
||||
skipNsfwValidation: true,
|
||||
});
|
||||
ctx.set('channelUpdateType', existing.type);
|
||||
return undefined;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@fluxer/constants/src/LimitConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {CannotSendMessageToNonTextChannelError} from '@fluxer/errors/src/domains/channel/CannotSendMessageToNonTextChannelError';
|
||||
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import {FileSizeTooLargeError} from '@fluxer/errors/src/domains/core/FileSizeTooLargeError';
|
||||
@@ -18,6 +19,7 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import {ServiceUnavailableError} from '@fluxer/errors/src/HttpErrors';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {
|
||||
CompleteMultipartAttachmentUploadItem,
|
||||
CompleteMultipartAttachmentUploadResult,
|
||||
@@ -26,7 +28,9 @@ import type {
|
||||
} from '@fluxer/schema/src/domains/message/AttachmentUploadSchemas';
|
||||
import type {AttachmentID, ChannelID, MessageID, UserID} from '../../BrandedTypes';
|
||||
import {Config} from '../../Config';
|
||||
import {SYSTEM_USER_ID} from '../../constants/Core';
|
||||
import type {IPurgeQueue} from '../../infrastructure/BunnyPurgeQueue';
|
||||
import type {IGatewayService} from '../../infrastructure/IGatewayService';
|
||||
import type {IStorageService} from '../../infrastructure/IStorageService';
|
||||
import type {LimitConfigService} from '../../limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '../../limits/LimitConfigUtils';
|
||||
@@ -64,6 +68,8 @@ interface DeleteAttachmentParams {
|
||||
requestCache: RequestCache;
|
||||
}
|
||||
|
||||
type UploadActor = 'member' | 'webhook';
|
||||
|
||||
interface UploadFormDataAttachmentsParams {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
@@ -76,6 +82,7 @@ interface UploadFormDataAttachmentsParams {
|
||||
id: number;
|
||||
filename: string;
|
||||
}>;
|
||||
actor?: UploadActor;
|
||||
}
|
||||
|
||||
interface RequestPresignedAttachmentUploadUrlsParams {
|
||||
@@ -104,6 +111,7 @@ export class AttachmentUploadService {
|
||||
private messageInteractionService: MessageInteractionService,
|
||||
private messageService: MessageService,
|
||||
private limitConfigService: LimitConfigService,
|
||||
private gatewayService: IGatewayService,
|
||||
) {}
|
||||
|
||||
async uploadFormDataAttachments({
|
||||
@@ -112,8 +120,9 @@ export class AttachmentUploadService {
|
||||
clientIp,
|
||||
files,
|
||||
attachmentMetadata,
|
||||
actor = 'member',
|
||||
}: UploadFormDataAttachmentsParams): Promise<Array<UploadedAttachment>> {
|
||||
const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, channelId});
|
||||
const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, channelId, actor});
|
||||
assertAttachmentFileSizesWithinLimit(
|
||||
files.map(({file}) => file.size),
|
||||
maxFileSize,
|
||||
@@ -168,7 +177,7 @@ export class AttachmentUploadService {
|
||||
if (!Config.presignedAttachmentUploadsEnabled) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, channelId});
|
||||
const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, channelId, actor: 'member'});
|
||||
assertAttachmentFileSizesWithinLimit(
|
||||
attachments.map(({file_size}) => file_size),
|
||||
maxFileSize,
|
||||
@@ -275,7 +284,7 @@ export class AttachmentUploadService {
|
||||
if (!Config.presignedAttachmentUploadsEnabled) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, channelId});
|
||||
const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, channelId, actor: 'member'});
|
||||
const bucket = Config.s3.buckets.uploads;
|
||||
return Promise.all(
|
||||
uploads.map(async ({upload_filename, upload_id}, index) => {
|
||||
@@ -412,21 +421,24 @@ export class AttachmentUploadService {
|
||||
}
|
||||
}
|
||||
|
||||
private async getUploadPermissionAndLimit({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<{
|
||||
private async getUploadPermissionAndLimit({
|
||||
userId,
|
||||
channelId,
|
||||
actor,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
actor: UploadActor;
|
||||
}): Promise<{
|
||||
maxFileSize: number;
|
||||
}> {
|
||||
const {channel, guild, checkPermission, member} =
|
||||
await this.messageInteractionService.authService.getChannelAuthenticated({
|
||||
userId,
|
||||
channelId,
|
||||
});
|
||||
const {channel, guild} =
|
||||
actor === 'webhook'
|
||||
? await this.getWebhookUploadChannel(channelId)
|
||||
: await this.getMemberUploadChannel({userId, channelId});
|
||||
if (!TEXT_BASED_CHANNEL_TYPES.has(channel.type)) {
|
||||
throw new CannotSendMessageToNonTextChannelError();
|
||||
}
|
||||
if (guild) {
|
||||
await checkPermission(Permissions.SEND_MESSAGES | Permissions.ATTACH_FILES);
|
||||
assertGuildMemberCanCommunicate(member);
|
||||
}
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
@@ -439,6 +451,41 @@ export class AttachmentUploadService {
|
||||
const maxFileSize = user.isBot ? Math.min(resolvedMaxFileSize, ATTACHMENT_MAX_SIZE_BOT) : resolvedMaxFileSize;
|
||||
return {maxFileSize};
|
||||
}
|
||||
|
||||
private async getMemberUploadChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<{
|
||||
channel: Channel;
|
||||
guild: GuildResponse | null;
|
||||
}> {
|
||||
const {channel, guild, checkPermission, member} =
|
||||
await this.messageInteractionService.authService.getChannelAuthenticated({
|
||||
userId,
|
||||
channelId,
|
||||
});
|
||||
if (guild) {
|
||||
await checkPermission(Permissions.SEND_MESSAGES | Permissions.ATTACH_FILES);
|
||||
assertGuildMemberCanCommunicate(member);
|
||||
}
|
||||
return {channel, guild};
|
||||
}
|
||||
|
||||
private async getWebhookUploadChannel(channelId: ChannelID): Promise<{
|
||||
channel: Channel;
|
||||
guild: GuildResponse | null;
|
||||
}> {
|
||||
const channel = await this.channelRepository.channelData.findUnique(channelId);
|
||||
if (!channel) {
|
||||
throw new UnknownChannelError();
|
||||
}
|
||||
if (!channel.guildId) {
|
||||
return {channel, guild: null};
|
||||
}
|
||||
const guild = await this.gatewayService.getGuildData({
|
||||
guildId: channel.guildId,
|
||||
userId: SYSTEM_USER_ID,
|
||||
skipMembershipCheck: true,
|
||||
});
|
||||
return {channel, guild};
|
||||
}
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T, TResult>(
|
||||
|
||||
@@ -111,7 +111,7 @@ export class ChannelDataService {
|
||||
clientFeatures: ReadonlySet<string>;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Channel> {
|
||||
const {channel} = await this.auth.getChannelAuthenticated({userId, channelId});
|
||||
const {channel} = await this.auth.getChannelAuthenticated({userId, channelId, skipNsfwValidation: true});
|
||||
if (channel.type === ChannelTypes.GROUP_DM) {
|
||||
return await this.groupDmUpdate.updateGroupDmChannel({
|
||||
userId,
|
||||
|
||||
@@ -162,6 +162,7 @@ export class ChannelService {
|
||||
this.interactions,
|
||||
this.messages,
|
||||
limitConfigService,
|
||||
gatewayService,
|
||||
);
|
||||
this.groupDms = new GroupDmOperationsService(
|
||||
channelRepository,
|
||||
|
||||
@@ -90,8 +90,20 @@ export class ChannelOperationsService {
|
||||
private rateLimitService: IRateLimitService,
|
||||
) {}
|
||||
|
||||
async getChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<Channel> {
|
||||
const {channel} = await this.channelAuthService.getChannelAuthenticated({userId, channelId});
|
||||
async getChannel({
|
||||
userId,
|
||||
channelId,
|
||||
skipNsfwValidation,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
skipNsfwValidation?: boolean;
|
||||
}): Promise<Channel> {
|
||||
const {channel} = await this.channelAuthService.getChannelAuthenticated({
|
||||
userId,
|
||||
channelId,
|
||||
skipNsfwValidation,
|
||||
});
|
||||
return channel;
|
||||
}
|
||||
|
||||
@@ -127,6 +139,7 @@ export class ChannelOperationsService {
|
||||
const {channel, guild, checkPermission} = await this.channelAuthService.getChannelAuthenticated({
|
||||
userId,
|
||||
channelId,
|
||||
skipNsfwValidation: true,
|
||||
});
|
||||
if (channel.type === ChannelTypes.GROUP_DM) {
|
||||
throw new InvalidChannelTypeError();
|
||||
@@ -456,7 +469,11 @@ export class ChannelOperationsService {
|
||||
if (this.voiceAvailabilityService === null) {
|
||||
return [];
|
||||
}
|
||||
const {channel, guild} = await this.channelAuthService.getChannelAuthenticated({userId, channelId});
|
||||
const {channel, guild} = await this.channelAuthService.getChannelAuthenticated({
|
||||
userId,
|
||||
channelId,
|
||||
skipNsfwValidation: true,
|
||||
});
|
||||
if (channel.type !== ChannelTypes.GUILD_VOICE) {
|
||||
throw new InvalidChannelTypeError();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {
|
||||
type ChannelID,
|
||||
createChannelID,
|
||||
createMessageID,
|
||||
createUserID,
|
||||
type MessageID,
|
||||
type UserID,
|
||||
} from '../../../BrandedTypes';
|
||||
import type {ChannelRow} from '../../../database/types/ChannelTypes';
|
||||
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
|
||||
import type {UserCacheService} from '../../../infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '../../../middleware/RequestCacheMiddleware';
|
||||
import {Channel} from '../../../models/Channel';
|
||||
import type {IUserRepository} from '../../../user/IUserRepository';
|
||||
import {MessageProcessingService} from './MessageProcessingService';
|
||||
|
||||
const CHANNEL_ID = createChannelID(1532860318772891648n);
|
||||
const AUTHOR_ID = createUserID(1471426754353995881n);
|
||||
const RECIPIENT_ID = createUserID(1485344055661987728n);
|
||||
const MESSAGE_ID = createMessageID(1546325276953149440n);
|
||||
|
||||
function dmChannelRow(lastMessageId: MessageID | null): ChannelRow {
|
||||
return {
|
||||
channel_id: CHANNEL_ID,
|
||||
guild_id: null,
|
||||
type: ChannelTypes.DM,
|
||||
name: null,
|
||||
topic: null,
|
||||
icon_hash: null,
|
||||
url: null,
|
||||
parent_id: null,
|
||||
position: null,
|
||||
owner_id: null,
|
||||
recipient_ids: new Set<UserID>([AUTHOR_ID, RECIPIENT_ID]),
|
||||
nsfw: null,
|
||||
content_warning_level: null,
|
||||
content_warning_text: null,
|
||||
rate_limit_per_user: null,
|
||||
bitrate: null,
|
||||
user_limit: null,
|
||||
voice_connection_limit: null,
|
||||
rtc_region: null,
|
||||
last_message_id: lastMessageId,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
nicks: null,
|
||||
soft_deleted: false,
|
||||
indexed_at: null,
|
||||
version: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function buildService(): {service: MessageProcessingService; opened: Array<Channel>} {
|
||||
const opened: Array<Channel> = [];
|
||||
const userRepository = {
|
||||
isDmChannelOpen: async (userId: UserID, _channelId: ChannelID) => userId === AUTHOR_ID,
|
||||
openPrivateChannelForUser: async (_userId: UserID, channel: Channel) => {
|
||||
opened.push(channel);
|
||||
},
|
||||
} as unknown as IUserRepository;
|
||||
const userCacheService = {
|
||||
getUserPartialResponses: async (userIds: Array<UserID>) =>
|
||||
new Map(userIds.map((userId) => [userId, {id: userId.toString()}])),
|
||||
} as unknown as UserCacheService;
|
||||
const gatewayService = {
|
||||
dispatchPresence: async () => {},
|
||||
} as unknown as IGatewayService;
|
||||
const service = new MessageProcessingService(
|
||||
undefined as never,
|
||||
userRepository,
|
||||
userCacheService,
|
||||
gatewayService,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
);
|
||||
return {service, opened};
|
||||
}
|
||||
|
||||
describe('MessageProcessingService.updateDMRecipients', () => {
|
||||
it('snapshots the new message id when the in-request channel is stale', async () => {
|
||||
const {service, opened} = buildService();
|
||||
await service.updateDMRecipients({
|
||||
channel: new Channel(dmChannelRow(null)),
|
||||
channelId: CHANNEL_ID,
|
||||
messageId: MESSAGE_ID,
|
||||
requestCache: {} as RequestCache,
|
||||
});
|
||||
expect(opened).toHaveLength(1);
|
||||
expect(opened[0].lastMessageId).toBe(MESSAGE_ID);
|
||||
});
|
||||
|
||||
it('keeps a newer last message id already present on the channel', async () => {
|
||||
const {service, opened} = buildService();
|
||||
const newer = createMessageID(MESSAGE_ID + 10n);
|
||||
await service.updateDMRecipients({
|
||||
channel: new Channel(dmChannelRow(newer)),
|
||||
channelId: CHANNEL_ID,
|
||||
messageId: MESSAGE_ID,
|
||||
requestCache: {} as RequestCache,
|
||||
});
|
||||
expect(opened).toHaveLength(1);
|
||||
expect(opened[0].lastMessageId).toBe(newer);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import type {GatewayChannelMention, IGatewayService} from '../../../infrastructu
|
||||
import type {UserCacheService} from '../../../infrastructure/UserCacheService';
|
||||
import {Logger} from '../../../Logger';
|
||||
import type {RequestCache} from '../../../middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '../../../models/Channel';
|
||||
import {Channel} from '../../../models/Channel';
|
||||
import type {Message} from '../../../models/Message';
|
||||
import type {User} from '../../../models/User';
|
||||
import type {ReadStateService} from '../../../read_state/ReadStateService';
|
||||
@@ -33,6 +33,13 @@ interface MentionProcessingResult {
|
||||
mentionChannels: Array<GatewayChannelMention>;
|
||||
}
|
||||
|
||||
function channelWithLastMessageId(channel: Channel, messageId: MessageID): Channel {
|
||||
if (channel.lastMessageId != null && channel.lastMessageId >= messageId) {
|
||||
return channel;
|
||||
}
|
||||
return new Channel({...channel.toRow(), last_message_id: messageId});
|
||||
}
|
||||
|
||||
export class MessageProcessingService {
|
||||
constructor(
|
||||
private channelRepository: IChannelRepositoryAggregate,
|
||||
@@ -64,10 +71,12 @@ export class MessageProcessingService {
|
||||
async updateDMRecipients({
|
||||
channel,
|
||||
channelId,
|
||||
messageId,
|
||||
requestCache,
|
||||
}: {
|
||||
channel: Channel;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
if (channel.guildId || channel.type !== ChannelTypes.DM) return;
|
||||
@@ -76,11 +85,12 @@ export class MessageProcessingService {
|
||||
const openStates = await this.batchCheckDmChannelOpen(recipientIds, channelId);
|
||||
const closedRecipients = openStates.filter((state) => !state.isOpen);
|
||||
if (closedRecipients.length === 0) return;
|
||||
const snapshotChannel = channelWithLastMessageId(channel, messageId);
|
||||
await Promise.all(
|
||||
closedRecipients.map((state) =>
|
||||
this.openDmAndDispatch({
|
||||
recipientId: state.recipientId,
|
||||
channel,
|
||||
channel: snapshotChannel,
|
||||
requestCache,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -34,6 +34,7 @@ type AttachmentMetadata = ClientAttachmentRequest | ClientUploadedAttachmentRequ
|
||||
|
||||
interface ParseMultipartMessageDataOptions {
|
||||
onPayloadParsed?: (payload: unknown) => void;
|
||||
actor?: 'member' | 'webhook';
|
||||
}
|
||||
|
||||
export async function parseMultipartMessageData(
|
||||
@@ -158,6 +159,7 @@ export async function parseMultipartMessageData(
|
||||
clientIp,
|
||||
files: filesWithIndices,
|
||||
attachmentMetadata: inlineNewAttachments,
|
||||
actor: options?.actor,
|
||||
});
|
||||
const uploadedMap = new Map(uploadedAttachments.map((attachment) => [attachment.id, attachment]));
|
||||
const processedInlineAttachments = inlineNewAttachments.map((clientData) => {
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
SENDABLE_MESSAGE_FLAGS,
|
||||
} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildNSFWLevel, GuildOperations} from '@fluxer/constants/src/GuildConstants';
|
||||
import {RelationshipTypes, SensitiveMediaFilterLevel, UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {
|
||||
DELETED_USER_ID,
|
||||
RelationshipTypes,
|
||||
SensitiveMediaFilterLevel,
|
||||
UserFlags,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
@@ -211,15 +216,18 @@ export class MessageSendService {
|
||||
return processed.length > 0 ? processed : undefined;
|
||||
}
|
||||
|
||||
private resolveWebhookAttachmentUploadUserId(
|
||||
private async resolveWebhookAttachmentUploadUserId(
|
||||
webhook: Webhook,
|
||||
attachments?: Array<AttachmentRequestData>,
|
||||
): UserID | undefined {
|
||||
const uploadUserId = webhook.creatorId ?? undefined;
|
||||
if (uploadUserId === undefined && this.attachmentsToProcess(attachments) !== undefined) {
|
||||
throw InputValidationError.fromCode('attachments', ValidationErrorCodes.INVALID_MESSAGE_DATA);
|
||||
): Promise<UserID | undefined> {
|
||||
if (this.attachmentsToProcess(attachments) === undefined) {
|
||||
return webhook.creatorId ?? undefined;
|
||||
}
|
||||
return uploadUserId;
|
||||
if (!webhook.creatorId) {
|
||||
return createUserID(DELETED_USER_ID);
|
||||
}
|
||||
const creator = await this.deps.userRepository.findUnique(webhook.creatorId);
|
||||
return creator ? webhook.creatorId : createUserID(DELETED_USER_ID);
|
||||
}
|
||||
|
||||
private getOneToOneDmRecipientId(channel: Channel, senderId: UserID): UserID | null {
|
||||
@@ -980,7 +988,7 @@ export class MessageSendService {
|
||||
await this.settlePostCreateWork(messageId, [
|
||||
{
|
||||
step: 'update_dm_recipients',
|
||||
promise: this.deps.processingService.updateDMRecipients({channel, channelId, requestCache}),
|
||||
promise: this.deps.processingService.updateDMRecipients({channel, channelId, messageId, requestCache}),
|
||||
},
|
||||
{
|
||||
step: 'process_message_after_creation',
|
||||
@@ -1184,7 +1192,7 @@ export class MessageSendService {
|
||||
flags: this.deps.validationService.calculateMessageFlags(data),
|
||||
embeds: data.embeds,
|
||||
attachments: this.attachmentsToProcess(data.attachments),
|
||||
attachmentUploadUserId: this.resolveWebhookAttachmentUploadUserId(webhook, data.attachments),
|
||||
attachmentUploadUserId: await this.resolveWebhookAttachmentUploadUserId(webhook, data.attachments),
|
||||
stickerIds: data.sticker_ids ? data.sticker_ids.flatMap((stickerId) => createStickerID(stickerId)) : undefined,
|
||||
messageReference,
|
||||
messageSnapshots,
|
||||
@@ -1269,7 +1277,7 @@ export class MessageSendService {
|
||||
data,
|
||||
channel,
|
||||
guild,
|
||||
attachmentUploadUserId: this.resolveWebhookAttachmentUploadUserId(webhook, data.attachments),
|
||||
attachmentUploadUserId: await this.resolveWebhookAttachmentUploadUserId(webhook, data.attachments),
|
||||
allowEmbeds: true,
|
||||
});
|
||||
await this.deps.dispatchService.dispatchMessageUpdate({channel, message: updatedMessage, requestCache});
|
||||
|
||||
@@ -46,6 +46,18 @@ describe('Channel Operation Permissions', () => {
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
it('should let a minor manage a mature channel without reading it', async () => {
|
||||
const owner = await createTestAccount(harness, {dateOfBirth: '2010-01-01'});
|
||||
const guild = await createGuild(harness, owner.token, 'Mature Channel Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
await updateChannel(harness, owner.token, systemChannel.id, {nsfw: true});
|
||||
const renamed = await updateChannel(harness, owner.token, systemChannel.id, {name: 'still-manageable'});
|
||||
expect(renamed.name).toBe('still-manageable');
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/channels/${systemChannel.id}/messages`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
it('should reject member from updating channel without MANAGE_CHANNELS', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '../Config';
|
||||
|
||||
let cachedCollectDateOfBirth: boolean | null = null;
|
||||
|
||||
export function getDefaultDateOfBirthCollection(): boolean {
|
||||
return !Config.instance.selfHosted;
|
||||
}
|
||||
|
||||
export function instanceCollectsDateOfBirth(): boolean {
|
||||
return cachedCollectDateOfBirth ?? getDefaultDateOfBirthCollection();
|
||||
}
|
||||
|
||||
export function setCachedDateOfBirthCollection(collect: boolean): void {
|
||||
cachedCollectDateOfBirth = collect;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {resolveDeferredPhoneGateEnabled, setCachedDeferredPhoneGateEnabled} from
|
||||
import {InstanceConfiguration} from '../Tables';
|
||||
import {DEFAULT_DECAY_CONSTANTS, DEFAULT_RENEWAL_CONSTANTS} from '../utils/AttachmentDecay';
|
||||
import {isJsonRecord, parseJsonArray, parseJsonRecord} from '../utils/JsonBoundaryUtils';
|
||||
import {getDefaultDateOfBirthCollection, setCachedDateOfBirthCollection} from './DateOfBirthCollectionCache';
|
||||
import {normalizeSsoAllowedEmailDomains} from './SsoConfigValidation';
|
||||
|
||||
const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config';
|
||||
@@ -357,11 +358,21 @@ function getDefaultAppPublicConfig(): InstanceAppPublicConfig {
|
||||
privacy_url: null,
|
||||
},
|
||||
registration: {
|
||||
collect_date_of_birth: !Config.instance.selfHosted,
|
||||
collect_date_of_birth: getDefaultDateOfBirthCollection(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseAppPublicConfig(raw: string): InstanceAppPublicConfig {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return normalizeAppPublicConfig(parsed);
|
||||
} catch (error) {
|
||||
Logger.warn({error}, 'Invalid app public config JSON, returning defaults');
|
||||
return getDefaultAppPublicConfig();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAppPublicConfig(value: unknown): InstanceAppPublicConfig {
|
||||
const defaults = getDefaultAppPublicConfig();
|
||||
if (!isJsonRecord(value)) {
|
||||
@@ -939,6 +950,7 @@ export class InstanceConfigRepository {
|
||||
this.configCache = await this.fetchAllConfigsFromDatabase();
|
||||
} while (this.refreshRequested);
|
||||
this.syncDeferredPhoneGateCache(this.configCache.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
|
||||
this.syncDateOfBirthCollectionCache(this.configCache.get(APP_PUBLIC_CONFIG_KEY) ?? null);
|
||||
})().finally(() => {
|
||||
this.refreshPromise = null;
|
||||
});
|
||||
@@ -950,6 +962,11 @@ export class InstanceConfigRepository {
|
||||
setCachedDeferredPhoneGateEnabled(resolveDeferredPhoneGateEnabled(policy));
|
||||
}
|
||||
|
||||
private syncDateOfBirthCollectionCache(raw: string | null): void {
|
||||
const appPublic = raw ? normalizeAppPublicConfig(parseJsonRecord(raw)) : getDefaultAppPublicConfig();
|
||||
setCachedDateOfBirthCollection(appPublic.registration.collect_date_of_birth);
|
||||
}
|
||||
|
||||
private updateCachedConfigs(entries: Array<[string, string]>): void {
|
||||
if (!this.configCache) {
|
||||
return;
|
||||
@@ -1067,16 +1084,9 @@ export class InstanceConfigRepository {
|
||||
|
||||
async getAppPublicConfig(): Promise<InstanceAppPublicConfig> {
|
||||
const raw = await this.getConfig(APP_PUBLIC_CONFIG_KEY);
|
||||
if (!raw) {
|
||||
return getDefaultAppPublicConfig();
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return normalizeAppPublicConfig(parsed);
|
||||
} catch (error) {
|
||||
Logger.warn({error}, 'Invalid app public config JSON, returning defaults');
|
||||
return getDefaultAppPublicConfig();
|
||||
}
|
||||
const config = raw ? parseAppPublicConfig(raw) : getDefaultAppPublicConfig();
|
||||
setCachedDateOfBirthCollection(config.registration.collect_date_of_birth);
|
||||
return config;
|
||||
}
|
||||
|
||||
async setAppPublicConfig(config: {
|
||||
@@ -1105,6 +1115,7 @@ export class InstanceConfigRepository {
|
||||
},
|
||||
});
|
||||
await this.setConfig(APP_PUBLIC_CONFIG_KEY, JSON.stringify(next));
|
||||
setCachedDateOfBirthCollection(next.registration.collect_date_of_birth);
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
@@ -197,7 +197,7 @@ export class UserSettings {
|
||||
return {
|
||||
user_id: userId,
|
||||
locale,
|
||||
theme: theme ?? ThemeTypes.SYSTEM,
|
||||
theme: theme ?? ThemeTypes.DARK,
|
||||
status: 'online',
|
||||
status_resets_at: null,
|
||||
status_resets_to: null,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {afterEach, describe, expect, it} from 'vitest';
|
||||
import {setCachedDateOfBirthCollection} from '../instance/DateOfBirthCollectionCache';
|
||||
import {canUserAccessNsfwContent} from './AgeUtils';
|
||||
|
||||
const ADULT_DATE_OF_BIRTH = '1990-01-01';
|
||||
const MINOR_DATE_OF_BIRTH = '2020-01-01';
|
||||
|
||||
describe('canUserAccessNsfwContent', () => {
|
||||
afterEach(() => {
|
||||
setCachedDateOfBirthCollection(true);
|
||||
});
|
||||
|
||||
it('allows a bot whatever the instance collects', () => {
|
||||
setCachedDateOfBirthCollection(true);
|
||||
expect(canUserAccessNsfwContent({isBot: true, dateOfBirth: null})).toBe(true);
|
||||
});
|
||||
|
||||
it('allows an adult when the instance collects a date of birth', () => {
|
||||
setCachedDateOfBirthCollection(true);
|
||||
expect(canUserAccessNsfwContent({isBot: false, dateOfBirth: ADULT_DATE_OF_BIRTH})).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks a minor when the instance collects a date of birth', () => {
|
||||
setCachedDateOfBirthCollection(true);
|
||||
expect(canUserAccessNsfwContent({isBot: false, dateOfBirth: MINOR_DATE_OF_BIRTH})).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks a missing date of birth when the instance collects one', () => {
|
||||
setCachedDateOfBirthCollection(true);
|
||||
expect(canUserAccessNsfwContent({isBot: false, dateOfBirth: null})).toBe(false);
|
||||
});
|
||||
|
||||
it('allows a missing date of birth when the instance collects none', () => {
|
||||
setCachedDateOfBirthCollection(false);
|
||||
expect(canUserAccessNsfwContent({isBot: false, dateOfBirth: null})).toBe(true);
|
||||
});
|
||||
|
||||
it('allows an account with a minor date of birth when the instance collects none', () => {
|
||||
setCachedDateOfBirthCollection(false);
|
||||
expect(canUserAccessNsfwContent({isBot: false, dateOfBirth: MINOR_DATE_OF_BIRTH})).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {instanceCollectsDateOfBirth} from '../instance/DateOfBirthCollectionCache';
|
||||
|
||||
export function calculateAge(
|
||||
dateOfBirth:
|
||||
| {
|
||||
@@ -53,5 +55,8 @@ export function canUserAccessNsfwContent(user: NsfwEligibilityUser): boolean {
|
||||
if (user.isBot) {
|
||||
return true;
|
||||
}
|
||||
if (!instanceCollectsDateOfBirth()) {
|
||||
return true;
|
||||
}
|
||||
return isUserAdult(user.dateOfBirth);
|
||||
}
|
||||
@@ -102,6 +102,7 @@ async function parseWebhookMultipartMessageData(
|
||||
onPayloadParsed(payload) {
|
||||
parsedPayload = payload;
|
||||
},
|
||||
actor: 'webhook',
|
||||
},
|
||||
);
|
||||
if (!parsedPayload) {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
|
||||
import {loadFixture} from '../../channel/tests/AttachmentTestUtils';
|
||||
import {createGuild} from '../../guild/tests/GuildTestUtils';
|
||||
import {createPermissionOverwrite} from '../../channel/tests/ChannelTestUtils';
|
||||
import {acceptInvite, addMemberRole, createGuild, createRole} from '../../guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '../../test/TestConstants';
|
||||
import {createWebhook, deleteWebhook, executeWebhookWithAttachments} from './WebhookTestUtils';
|
||||
import {createChannelInvite, createWebhook, deleteWebhook, executeWebhookWithAttachments} from './WebhookTestUtils';
|
||||
|
||||
describe('Webhook multipart attachment uploads', () => {
|
||||
let harness: ApiTestHarness;
|
||||
@@ -92,6 +94,36 @@ describe('Webhook multipart attachment uploads', () => {
|
||||
expect(response.status).toBe(HTTP_STATUS.BAD_REQUEST);
|
||||
await deleteWebhook(harness, webhook.id, owner.token);
|
||||
});
|
||||
it('executes multipart webhook requests when the creator is denied attach files', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const creator = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Webhook creator denied attach files guild');
|
||||
const channelId = guild.system_channel_id!;
|
||||
const invite = await createChannelInvite(harness, owner.token, channelId);
|
||||
await acceptInvite(harness, creator.token, invite.code);
|
||||
const webhookManagerRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Webhook Manager',
|
||||
permissions: Permissions.MANAGE_WEBHOOKS.toString(),
|
||||
});
|
||||
await addMemberRole(harness, owner.token, guild.id, creator.userId, webhookManagerRole.id);
|
||||
const webhook = await createWebhook(harness, channelId, creator.token, 'Denied Creator Webhook');
|
||||
await createPermissionOverwrite(harness, owner.token, channelId, creator.userId, {
|
||||
type: 1,
|
||||
allow: '0',
|
||||
deny: Permissions.ATTACH_FILES.toString(),
|
||||
});
|
||||
const {response, json} = await executeWebhookWithAttachments(harness, {
|
||||
webhookId: webhook.id,
|
||||
webhookToken: webhook.token,
|
||||
payload: {
|
||||
attachments: [{id: 0, filename: 'denied_creator.png'}],
|
||||
},
|
||||
files: [{index: 0, filename: 'denied_creator.png', data: loadFixture('yeah.png')}],
|
||||
});
|
||||
expect(response.status).toBe(HTTP_STATUS.OK);
|
||||
expect(json?.attachments?.length).toBe(1);
|
||||
await deleteWebhook(harness, webhook.id, owner.token);
|
||||
});
|
||||
it('rejects multipart webhook requests when file indices do not match metadata IDs', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Webhook multipart id mismatch guild');
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {DELETED_USER_ID, DELETED_USER_USERNAME} from '@fluxer/constants/src/UserConstants';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
|
||||
@@ -13,10 +12,6 @@ import {createWebhook, executeWebhook, executeWebhookWithAttachments, getChannel
|
||||
|
||||
const VANISHED_CREATOR_ID = createUserID(999999999999999997n);
|
||||
|
||||
function parseErrorCode(text: string): string | undefined {
|
||||
return (JSON.parse(text) as {code?: string}).code;
|
||||
}
|
||||
|
||||
describe('Webhook whose creating account cannot be resolved', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeEach(async () => {
|
||||
@@ -49,13 +44,13 @@ describe('Webhook whose creating account cannot be resolved', () => {
|
||||
expect(listed?.user.id).toBe(VANISHED_CREATOR_ID.toString());
|
||||
expect(listed?.user.username).toBe(DELETED_USER_USERNAME);
|
||||
});
|
||||
it('answers a multipart execution for a webhook with no creator id with an access decision', async () => {
|
||||
it('executes a multipart payload for a webhook with no creator id', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Null creator multipart guild');
|
||||
const channelId = guild.system_channel_id!;
|
||||
const webhook = await createWebhook(harness, channelId, owner.token, 'Null Creator Multipart Webhook');
|
||||
await new WebhookRepository().update(createWebhookID(BigInt(webhook.id)), {creatorId: null});
|
||||
const {response, text} = await executeWebhookWithAttachments(harness, {
|
||||
const {response, json} = await executeWebhookWithAttachments(harness, {
|
||||
webhookId: webhook.id,
|
||||
webhookToken: webhook.token,
|
||||
payload: {
|
||||
@@ -63,16 +58,16 @@ describe('Webhook whose creating account cannot be resolved', () => {
|
||||
},
|
||||
files: [{index: 0, filename: 'orphaned.txt', data: Buffer.from('uploaded by an orphaned webhook')}],
|
||||
});
|
||||
expect(response.status).toBe(HTTP_STATUS.FORBIDDEN);
|
||||
expect(parseErrorCode(text)).toBe(APIErrorCodes.ACCESS_DENIED);
|
||||
expect(response.status).toBe(HTTP_STATUS.OK);
|
||||
expect(json?.attachments?.[0].filename).toBe('orphaned.txt');
|
||||
});
|
||||
it('answers a multipart execution for a webhook whose creator row is gone with an access decision', async () => {
|
||||
it('executes a multipart payload for a webhook whose creator row is gone', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Vanished creator multipart guild');
|
||||
const channelId = guild.system_channel_id!;
|
||||
const webhook = await createWebhook(harness, channelId, owner.token, 'Vanished Creator Multipart Webhook');
|
||||
await new WebhookRepository().update(createWebhookID(BigInt(webhook.id)), {creatorId: VANISHED_CREATOR_ID});
|
||||
const {response, text} = await executeWebhookWithAttachments(harness, {
|
||||
const {response, json} = await executeWebhookWithAttachments(harness, {
|
||||
webhookId: webhook.id,
|
||||
webhookToken: webhook.token,
|
||||
payload: {
|
||||
@@ -80,8 +75,8 @@ describe('Webhook whose creating account cannot be resolved', () => {
|
||||
},
|
||||
files: [{index: 0, filename: 'vanished.txt', data: Buffer.from('uploaded by a vanished creator')}],
|
||||
});
|
||||
expect(response.status).toBe(HTTP_STATUS.FORBIDDEN);
|
||||
expect(parseErrorCode(text)).toBe(APIErrorCodes.ACCESS_DENIED);
|
||||
expect(response.status).toBe(HTTP_STATUS.OK);
|
||||
expect(json?.attachments?.[0].filename).toBe('vanished.txt');
|
||||
});
|
||||
it('executes a json payload for a webhook with no creator id', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
|
||||
@@ -430,7 +430,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [stepNavigationLocked, setStepNavigationLocked] = useState(false);
|
||||
const [wizardSnapshot, setWizardSnapshot] = useState(createSetupWizardSnapshot);
|
||||
const [setupTheme, setSetupTheme] = useState<ThemeType>(ThemeTypes.SYSTEM);
|
||||
const [setupTheme, setSetupTheme] = useState<ThemeType>(ThemeTypes.DARK);
|
||||
const [forceUnauthenticatedSetup, setForceUnauthenticatedSetup] = useState(false);
|
||||
const [integrationDraft, setIntegrationDraft] = useState<ServiceIntegrationDraft>(() => ({
|
||||
...DEFAULT_INTEGRATION_DRAFT,
|
||||
|
||||
@@ -71,6 +71,8 @@ import type React from 'react';
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
const TAIL_PROBE_MAX_ATTEMPTS = 2;
|
||||
const TAIL_PROBE_MIN_INTERVAL_MS = 10_000;
|
||||
const tailProbeAttemptedAt = new Map<string, number>();
|
||||
|
||||
const MESSAGE_LIST_FOR_DESCRIPTOR = msg({
|
||||
message: 'Message list for {channelName}',
|
||||
@@ -483,7 +485,12 @@ export const Messages = observer(function Messages({
|
||||
if (selectedChannelId !== channel.id || tailProbeKeyRef.current === tailProbeKey) {
|
||||
return;
|
||||
}
|
||||
const lastAttemptAt = tailProbeAttemptedAt.get(tailProbeKey);
|
||||
if (lastAttemptAt != null && Date.now() - lastAttemptAt < TAIL_PROBE_MIN_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
tailProbeKeyRef.current = tailProbeKey;
|
||||
tailProbeAttemptedAt.set(tailProbeKey, Date.now());
|
||||
void MessageCommands.fetchMessages(channel.id, null, tailProbeMessageId, MAX_MESSAGES_PER_CHANNEL, undefined, {
|
||||
tailProbe: {
|
||||
watermarkMessageId: tailWatermarkMessageId,
|
||||
|
||||
+4
-14
@@ -17,7 +17,6 @@ import {
|
||||
UNPIN_GROUP_DM_DESCRIPTOR,
|
||||
} from '@app/features/channel/utils/ChannelMessageDescriptors';
|
||||
import {isGroupDmFull} from '@app/features/channel/utils/GroupDmUtils';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import {
|
||||
ADD_TO_FAVORITES_DESCRIPTOR,
|
||||
COPY_CHANNEL_ID_DESCRIPTOR,
|
||||
@@ -32,7 +31,6 @@ import {
|
||||
REMOVE_FROM_FAVORITES_DESCRIPTOR,
|
||||
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import * as InviteUtils from '@app/features/invite/utils/InviteUtils';
|
||||
import {getEffectiveChannelMatureContent} from '@app/features/messaging/utils/ContentWarningUtils';
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
import ReadStates from '@app/features/read_state/state/ReadStates';
|
||||
import {AddFriendsToGroupModal} from '@app/features/relationship/components/modals/AddFriendsToGroupModal';
|
||||
@@ -56,7 +54,6 @@ import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import type {MenuGroupType, MenuItemType} from '@app/features/ui/menu_bottom_sheet/MenuBottomSheet';
|
||||
import {MenuBottomSheet} from '@app/features/ui/menu_bottom_sheet/MenuBottomSheet';
|
||||
import type {User} from '@app/features/user/models/User';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import type React from 'react';
|
||||
@@ -251,21 +248,14 @@ export const MoreOptionsSheet: React.FC<MoreOptionsSheetProps> = ({
|
||||
guildId: channel.guildId,
|
||||
});
|
||||
if (canManageChannels) {
|
||||
const guildForChannel = channel.guildId ? Guilds.getGuild(channel.guildId) : undefined;
|
||||
const currentUser = Users.getCurrentUser();
|
||||
const nsfwBlockedForMinor =
|
||||
getEffectiveChannelMatureContent(channel, guildForChannel ?? null) &&
|
||||
!!currentUser &&
|
||||
!currentUser.matureContentAllowed;
|
||||
const managerItems: Array<MenuItemType> = [];
|
||||
if (!nsfwBlockedForMinor) {
|
||||
managerItems.push({
|
||||
const managerItems: Array<MenuItemType> = [
|
||||
{
|
||||
id: 'edit-channel',
|
||||
icon: <EditIcon size={20} data-flx="channel.channel-details-bottom-sheet.edit-icon--2" />,
|
||||
label: i18n._(EDIT_CHANNEL_DESCRIPTOR),
|
||||
onClick: onEditChannel,
|
||||
});
|
||||
}
|
||||
},
|
||||
];
|
||||
managerItems.push({
|
||||
id: 'delete-channel',
|
||||
icon: <DeleteIcon size={20} data-flx="channel.channel-details-bottom-sheet.delete-icon" />,
|
||||
|
||||
@@ -13,7 +13,6 @@ import {handleFavoriteMemeDelete} from '@app/features/expressions/events/Favorit
|
||||
import {handleFavoriteMemeUpdate} from '@app/features/expressions/events/FavoriteMemeUpdate';
|
||||
import {handleWebhooksUpdate} from '@app/features/expressions/events/WebhooksUpdate';
|
||||
import {handleReady} from '@app/features/gateway/events/GatewayReady';
|
||||
import {handleResumed} from '@app/features/gateway/events/GatewayResumed';
|
||||
import type {GatewaySocket} from '@app/features/gateway/transport/GatewaySocket';
|
||||
import {handleChannelMemberCountsUpdate} from '@app/features/guild/events/ChannelMemberCountsUpdate';
|
||||
import {handleGuildBanAdd, handleGuildBanRemove} from '@app/features/guild/events/GuildBan';
|
||||
@@ -92,7 +91,6 @@ export type GatewayHandlerRegistry = Map<string, GatewayEventHandler>;
|
||||
export function createHandlerRegistry(): GatewayHandlerRegistry {
|
||||
const registry: GatewayHandlerRegistry = new Map();
|
||||
registry.set('READY', handleReady as GatewayEventHandler);
|
||||
registry.set('RESUMED', handleResumed as GatewayEventHandler);
|
||||
registry.set('AUTH_SESSION_CHANGE', handleAuthSessionChange as GatewayEventHandler);
|
||||
registry.set('USER_UPDATE', handleUserUpdate as GatewayEventHandler);
|
||||
registry.set('USER_SETTINGS_UPDATE', handleUserSettingsUpdate as GatewayEventHandler);
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Messages from '@app/features/messaging/state/MessagingMessages';
|
||||
|
||||
export function handleResumed(): void {
|
||||
Messages.handleResumed();
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import GeoIP from '@app/features/app/state/GeoIP';
|
||||
import Initialization from '@app/features/app/state/Initialization';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import RuntimeCrash from '@app/features/app/state/RuntimeCrash';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
import FavoriteMemes from '@app/features/expressions/state/FavoriteMemes';
|
||||
import {
|
||||
createHandlerRegistry,
|
||||
@@ -23,10 +24,12 @@ import {
|
||||
GatewayState,
|
||||
type GatewayVoiceStateUpdateParams,
|
||||
} from '@app/features/gateway/transport/GatewaySocket';
|
||||
import {selectGuildActivationTarget} from '@app/features/gateway/transport/GuildActivationTarget';
|
||||
import GuildMatureContentAgree from '@app/features/guild/state/GuildMatureContentAgree';
|
||||
import GuildMembers from '@app/features/member/state/GuildMembers';
|
||||
import MemberSearch from '@app/features/member/state/MemberSearch';
|
||||
import Messages from '@app/features/messaging/state/MessagingMessages';
|
||||
import Navigation from '@app/features/navigation/state/Navigation';
|
||||
import SelectedGuild from '@app/features/navigation/state/SelectedGuild';
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
import SessionManager from '@app/features/platform/state/AuthSession';
|
||||
@@ -55,6 +58,7 @@ interface DesiredSession {
|
||||
class GatewayConnection {
|
||||
socket: GatewaySocket | null = null;
|
||||
isConnected: boolean = false;
|
||||
connectionEpoch: number = 0;
|
||||
isConnecting: boolean = false;
|
||||
isReady: boolean = false;
|
||||
sessionId: string | null = null;
|
||||
@@ -166,11 +170,11 @@ class GatewayConnection {
|
||||
deferUntilModulesLoaded(() => {
|
||||
reaction(
|
||||
() => ({
|
||||
guildId: SelectedGuild.selectedGuildId,
|
||||
guildId: this.activationGuildId,
|
||||
nonce: SelectedGuild.selectionNonce,
|
||||
}),
|
||||
({guildId}) => {
|
||||
if (!guildId || guildId === FAVORITES_GUILD_ID) {
|
||||
if (!guildId) {
|
||||
this.pendingGuildSyncId = null;
|
||||
return;
|
||||
}
|
||||
@@ -339,6 +343,9 @@ class GatewayConnection {
|
||||
if (!isCurrent()) {
|
||||
return;
|
||||
}
|
||||
if (newState === GatewayState.Connected || previousState === GatewayState.Connected) {
|
||||
this.connectionEpoch += 1;
|
||||
}
|
||||
this.isConnected = newState === GatewayState.Connected;
|
||||
this.isConnecting = newState === GatewayState.Connecting || newState === GatewayState.Reconnecting;
|
||||
if (newState === GatewayState.Connected) {
|
||||
@@ -445,8 +452,17 @@ class GatewayConnection {
|
||||
}
|
||||
}
|
||||
|
||||
private get activationGuildId(): string | null {
|
||||
const channelId = Navigation.channelId;
|
||||
const channel = channelId ? Channels.getChannel(channelId) : undefined;
|
||||
return selectGuildActivationTarget({
|
||||
selectedGuildId: SelectedGuild.selectedGuildId,
|
||||
openChannelGuildId: channel?.guildId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
private flushPendingGuildSync(): void {
|
||||
const guildId = this.pendingGuildSyncId ?? SelectedGuild.selectedGuildId;
|
||||
const guildId = this.pendingGuildSyncId ?? this.activationGuildId;
|
||||
if (!guildId || guildId === FAVORITES_GUILD_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {FAVORITES_GUILD_ID} from '@fluxer/constants/src/AppConstants';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {selectGuildActivationTarget} from './GuildActivationTarget';
|
||||
|
||||
describe('selectGuildActivationTarget', () => {
|
||||
it('returns the selected guild on a guild route', () => {
|
||||
expect(selectGuildActivationTarget({selectedGuildId: '1', openChannelGuildId: '1'})).toBe('1');
|
||||
});
|
||||
|
||||
it('returns the selected guild when no channel is open', () => {
|
||||
expect(selectGuildActivationTarget({selectedGuildId: '1', openChannelGuildId: null})).toBe('1');
|
||||
});
|
||||
|
||||
it('returns the owning guild of a channel opened from the favorites route', () => {
|
||||
expect(selectGuildActivationTarget({selectedGuildId: null, openChannelGuildId: '1'})).toBe('1');
|
||||
});
|
||||
|
||||
it('ignores the favorites pseudo guild and falls through to the open channel', () => {
|
||||
expect(selectGuildActivationTarget({selectedGuildId: FAVORITES_GUILD_ID, openChannelGuildId: '1'})).toBe('1');
|
||||
});
|
||||
|
||||
it('returns null on the favorites route while no channel is open', () => {
|
||||
expect(selectGuildActivationTarget({selectedGuildId: FAVORITES_GUILD_ID, openChannelGuildId: null})).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a private channel', () => {
|
||||
expect(selectGuildActivationTarget({selectedGuildId: null, openChannelGuildId: null})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {FAVORITES_GUILD_ID} from '@fluxer/constants/src/AppConstants';
|
||||
|
||||
export function selectGuildActivationTarget(input: {
|
||||
selectedGuildId: string | null;
|
||||
openChannelGuildId: string | null;
|
||||
}): string | null {
|
||||
if (input.selectedGuildId && input.selectedGuildId !== FAVORITES_GUILD_ID) {
|
||||
return input.selectedGuildId;
|
||||
}
|
||||
return input.openChannelGuildId;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {Endpoints} from '@app/features/app/constants/Endpoints';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import GuildMatureContentAgree from '@app/features/guild/state/GuildMatureContentAgree';
|
||||
import {DELETE_MESSAGE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import GuildMembers from '@app/features/member/state/GuildMembers';
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
type MessageFetchCacheHit,
|
||||
resolveMessageFetchExecutionDecision,
|
||||
resolveMessageFetchPreflightDecision,
|
||||
resolveMessageFetchWindowCached,
|
||||
} from '@app/features/messaging/commands/MessageFetchStateMachine';
|
||||
import {resolveMessagePageState} from '@app/features/messaging/commands/MessagePageStateMachine';
|
||||
import {MessageDeleteFailedModal} from '@app/features/messaging/components/alerts/MessageDeleteFailedModal';
|
||||
@@ -258,6 +260,7 @@ function handleMessageFetchSuccess(
|
||||
channelId: string,
|
||||
messages: Array<WireMessage>,
|
||||
pageState: MessagePageState,
|
||||
cached: boolean,
|
||||
jump?: JumpOptions,
|
||||
tailProbe?: TailProbeContext,
|
||||
): void {
|
||||
@@ -268,7 +271,7 @@ function handleMessageFetchSuccess(
|
||||
isAfter: pageState.isAfter,
|
||||
hasMoreBefore: pageState.hasMoreBefore,
|
||||
hasMoreAfter: pageState.hasMoreAfter,
|
||||
cached: false,
|
||||
cached,
|
||||
jump,
|
||||
tailProbe: tailProbe != null,
|
||||
});
|
||||
@@ -466,17 +469,25 @@ export async function fetchMessages(
|
||||
const started = Messages.getMessages(channelId);
|
||||
probeEpoch = {loadGeneration: started.loadGeneration, jumpTicket: started.jumpTicket};
|
||||
}
|
||||
const connectedAtRequest = GatewayConnection.isConnected;
|
||||
const epochAtRequest = GatewayConnection.connectionEpoch;
|
||||
try {
|
||||
const timeStart = Date.now();
|
||||
logger.debug(`Fetching messages for channel ${channelId}`);
|
||||
const messages = await requestChannelMessages(channelId, before, after, limit, jump);
|
||||
const cached = resolveMessageFetchWindowCached({
|
||||
connectedAtRequest,
|
||||
connectedAtResponse: GatewayConnection.isConnected,
|
||||
epochAtRequest,
|
||||
epochAtResponse: GatewayConnection.connectionEpoch,
|
||||
});
|
||||
if (probeEpoch != null && !isTailProbeApplicable(channelId, probeEpoch, after)) {
|
||||
settleTailProbe(options, 'retry');
|
||||
return [];
|
||||
}
|
||||
const pageState = calculateMessagePageState(channelId, before, after, limit, messages, jump);
|
||||
logger.info(`Fetched ${messages.length} messages for channel ${channelId}, took ${Date.now() - timeStart}ms`);
|
||||
handleMessageFetchSuccess(channelId, messages, pageState, jump, options?.tailProbe);
|
||||
handleMessageFetchSuccess(channelId, messages, pageState, cached, jump, options?.tailProbe);
|
||||
settleTailProbe(options, 'applied');
|
||||
return messages;
|
||||
} catch (error) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type MessageFetchPreflightInput,
|
||||
resolveMessageFetchExecutionDecision,
|
||||
resolveMessageFetchPreflightDecision,
|
||||
resolveMessageFetchWindowCached,
|
||||
selectMessageFetchExecutionDecision,
|
||||
selectMessageFetchPreflightDecision,
|
||||
transitionMessageFetchExecutionSnapshot,
|
||||
@@ -104,3 +105,31 @@ describe('messageFetchExecutionMachine', () => {
|
||||
expect(selectMessageFetchExecutionDecision(networkSnapshot)).toEqual({type: 'requestNetwork'});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveMessageFetchWindowCached', () => {
|
||||
function trust(overrides: Partial<Parameters<typeof resolveMessageFetchWindowCached>[0]> = {}) {
|
||||
return {
|
||||
connectedAtRequest: true,
|
||||
connectedAtResponse: true,
|
||||
epochAtRequest: 7,
|
||||
epochAtResponse: 7,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('trusts a window loaded inside one uninterrupted connection', () => {
|
||||
expect(resolveMessageFetchWindowCached(trust())).toBe(false);
|
||||
});
|
||||
|
||||
it('distrusts a window whose request left while the socket was down', () => {
|
||||
expect(resolveMessageFetchWindowCached(trust({connectedAtRequest: false}))).toBe(true);
|
||||
});
|
||||
|
||||
it('distrusts a window whose response landed while the socket was down', () => {
|
||||
expect(resolveMessageFetchWindowCached(trust({connectedAtResponse: false}))).toBe(true);
|
||||
});
|
||||
|
||||
it('distrusts a window whose connection epoch moved under it', () => {
|
||||
expect(resolveMessageFetchWindowCached(trust({epochAtResponse: 8}))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -197,3 +197,15 @@ export function selectMessageFetchExecutionDecision(
|
||||
export function resolveMessageFetchExecutionDecision(input: MessageFetchExecutionInput): MessageFetchExecutionDecision {
|
||||
return buildExecutionDecision(input);
|
||||
}
|
||||
|
||||
export interface MessageFetchWindowTrustInput {
|
||||
connectedAtRequest: boolean;
|
||||
connectedAtResponse: boolean;
|
||||
epochAtRequest: number;
|
||||
epochAtResponse: number;
|
||||
}
|
||||
|
||||
export function resolveMessageFetchWindowCached(input: MessageFetchWindowTrustInput): boolean {
|
||||
if (!input.connectedAtRequest || !input.connectedAtResponse) return true;
|
||||
return input.epochAtRequest !== input.epochAtResponse;
|
||||
}
|
||||
@@ -3,7 +3,10 @@
|
||||
import {Message} from '@app/features/messaging/models/MessagingMessage';
|
||||
import {UploadingAttachment} from '@app/features/messaging/models/UploadingAttachment';
|
||||
import {resolveChannelIncomingMessageDecision} from '@app/features/messaging/state/ChannelIncomingMessageStateMachine';
|
||||
import {resolveChannelMessagesLoadDecision} from '@app/features/messaging/state/ChannelMessagesLoadStateMachine';
|
||||
import {
|
||||
resolveChannelMessagesLoadDecision,
|
||||
selectChannelMessagesLoadRestoresTrust,
|
||||
} from '@app/features/messaging/state/ChannelMessagesLoadStateMachine';
|
||||
import MessageReactions from '@app/features/messaging/state/MessageReactions';
|
||||
import {mergeAscendingById} from '@app/features/messaging/utils/MessagePaginationUtils';
|
||||
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
|
||||
@@ -358,6 +361,10 @@ export class ChannelMessages {
|
||||
ChannelMessages.retainedChannelIds.delete(channelId);
|
||||
}
|
||||
|
||||
static isRetained(channelId: string): boolean {
|
||||
return ChannelMessages.retainedChannelIds.has(channelId);
|
||||
}
|
||||
|
||||
static dropBuffers(channelId: string): void {
|
||||
const instance = ChannelMessages.channelCache.get(channelId);
|
||||
if (!instance) return;
|
||||
@@ -900,6 +907,11 @@ export class ChannelMessages {
|
||||
jumpReturnChannelId: jump?.returnToMessageId ? (jump.returnChannelId ?? this.channelId) : null,
|
||||
jumpReturnGuildId: jump?.returnToMessageId ? (jump.returnGuildId ?? null) : null,
|
||||
};
|
||||
const reachesLiveEdge = selectChannelMessagesLoadRestoresTrust({
|
||||
mode: loadDecision.mode,
|
||||
isAfter,
|
||||
hasMoreAfter,
|
||||
});
|
||||
next = next.cloneAnd({
|
||||
ready: true,
|
||||
loadingMore: false,
|
||||
@@ -907,7 +919,7 @@ export class ChannelMessages {
|
||||
...jumpPatch,
|
||||
hasMoreBefore: loadDecision.preserveHasMoreBefore ? next.hasMoreBefore : hasMoreBefore,
|
||||
hasMoreAfter: loadDecision.preserveHasMoreAfter ? next.hasMoreAfter : hasMoreAfter,
|
||||
cached,
|
||||
cached: reachesLiveEdge ? cached : next.cached || cached,
|
||||
error: false,
|
||||
});
|
||||
return next;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
resolveChannelMessagesWindowStatus,
|
||||
selectChannelMessagesFillerVisible,
|
||||
selectChannelMessagesLoadDecision,
|
||||
selectChannelMessagesLoadRestoresTrust,
|
||||
selectChannelMessagesSpacerHeight,
|
||||
selectChannelMessagesTailGapId,
|
||||
selectChannelMessagesTailProbeId,
|
||||
@@ -518,3 +519,23 @@ describe('selectChannelMessagesTailProbeOutcome', () => {
|
||||
expect(selectChannelMessagesTailProbeOutcome(outcomeInput({currentJumpTicket: 3}))).toBe('discard');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectChannelMessagesLoadRestoresTrust', () => {
|
||||
it('trusts a window a replacement load rebuilt from scratch', () => {
|
||||
expect(selectChannelMessagesLoadRestoresTrust({mode: 'replace', isAfter: false, hasMoreAfter: false})).toBe(true);
|
||||
});
|
||||
|
||||
it('trusts a window whose tail merge caught up to the live edge', () => {
|
||||
expect(selectChannelMessagesLoadRestoresTrust({mode: 'mergeAfter', isAfter: true, hasMoreAfter: false})).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps distrusting a tail merge that stopped short of the live edge', () => {
|
||||
expect(selectChannelMessagesLoadRestoresTrust({mode: 'mergeAfter', isAfter: true, hasMoreAfter: true})).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps distrusting a page of older history', () => {
|
||||
expect(selectChannelMessagesLoadRestoresTrust({mode: 'mergeBefore', isAfter: false, hasMoreAfter: false})).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -301,6 +301,15 @@ export function selectChannelMessagesTailProbeId(input: ChannelMessagesTailInput
|
||||
return selectChannelMessagesTailGapId(input);
|
||||
}
|
||||
|
||||
export function selectChannelMessagesLoadRestoresTrust(input: {
|
||||
mode: ChannelMessagesLoadMode;
|
||||
isAfter: boolean;
|
||||
hasMoreAfter: boolean;
|
||||
}): boolean {
|
||||
if (input.mode === 'replace') return true;
|
||||
return input.isAfter && !input.hasMoreAfter;
|
||||
}
|
||||
|
||||
export interface ChannelMessagesTailProbeResultInput {
|
||||
probeGeneration: number;
|
||||
currentGeneration: number;
|
||||
|
||||
@@ -201,7 +201,7 @@ class Messages {
|
||||
}
|
||||
|
||||
private hasLoadedPage(messages: ChannelMessages): boolean {
|
||||
return messages.ready && messages.length > 0;
|
||||
return messages.ready && messages.length > 0 && !messages.cached;
|
||||
}
|
||||
|
||||
shouldPreloadLatestPage(channelId: string): boolean {
|
||||
@@ -209,7 +209,9 @@ class Messages {
|
||||
return false;
|
||||
}
|
||||
const messages = ChannelMessages.get(channelId);
|
||||
return !messages || (messages.length === 0 && !messages.loadingMore && !messages.ready);
|
||||
if (!messages) return true;
|
||||
if (messages.loadingMore || ChannelMessages.isRetained(channelId)) return false;
|
||||
return messages.length === 0 ? !messages.ready : messages.cached;
|
||||
}
|
||||
|
||||
@action
|
||||
@@ -286,15 +288,6 @@ class Messages {
|
||||
return true;
|
||||
}
|
||||
|
||||
@action
|
||||
handleResumed(): boolean {
|
||||
ChannelMessages.forEach((messages) => {
|
||||
this.commitMessages(messages.withPatch({ready: true}));
|
||||
});
|
||||
this.notifyChange();
|
||||
return true;
|
||||
}
|
||||
|
||||
@action
|
||||
handleGatewayReady(): boolean {
|
||||
const selectedChannelId = SelectedChannel.currentChannelId;
|
||||
@@ -386,9 +379,10 @@ class Messages {
|
||||
if (!isNonGuildChannel && !guildExists) {
|
||||
return false;
|
||||
}
|
||||
const distrustedTailId = messages.ready && messages.cached ? (messages.last()?.id ?? null) : null;
|
||||
this.commitMessages(messages.withPatch({loadingMore: true}));
|
||||
this.notifyChange();
|
||||
MessageCommands.fetchMessages(channelId, null, null, MAX_MESSAGES_PER_CHANNEL);
|
||||
MessageCommands.fetchMessages(channelId, null, distrustedTailId, MAX_MESSAGES_PER_CHANNEL);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -92,14 +92,22 @@ describe('ReadStates unread invariant', () => {
|
||||
expect(ReadStates.hasUnread(channelId)).toBe(true);
|
||||
});
|
||||
|
||||
it('clears a stale unread once the server walks the last message id back', () => {
|
||||
it('ignores a passive update that walks the last message id back', () => {
|
||||
const {channelId} = seedReadChannel();
|
||||
ReadStates.handlePassiveLastMessageUpdates({[channelId]: ID.newer}, 'guild-1');
|
||||
expect(ReadStates.hasUnread(channelId)).toBe(true);
|
||||
ReadStates.handlePassiveLastMessageUpdates({[channelId]: ID.ack}, 'guild-1');
|
||||
expect(ReadStates.lastMessageId(channelId)).toBe(ID.newer);
|
||||
expect(ReadStates.hasUnread(channelId)).toBe(true);
|
||||
});
|
||||
|
||||
it('still lets its own probe lower a watermark a passive update raised', () => {
|
||||
const {channelId} = seedReadChannel();
|
||||
loadedMessages.push({id: ID.ack, author: {id: 'someone'}});
|
||||
ReadStates.handlePassiveLastMessageUpdates({[channelId]: ID.newer}, 'guild-1');
|
||||
ReadStates.handleLoadMessages({channelId, isAfter: true, messages: [], tailProbeWatermarkId: ID.newer});
|
||||
expect(ReadStates.lastMessageId(channelId)).toBe(ID.ack);
|
||||
expect(ReadStates.hasUnread(channelId)).toBe(false);
|
||||
expect(ReadStates.getUnreadCount(channelId)).toBe(0);
|
||||
expect(ReadStates.getVisualUnreadMessageId(channelId)).toBeNull();
|
||||
});
|
||||
|
||||
it('lowers a watermark its own probe finds nothing behind', () => {
|
||||
|
||||
@@ -599,7 +599,7 @@ class ReadStates {
|
||||
changed = state.guildId !== guildId;
|
||||
state.storedGuildId = guildId;
|
||||
}
|
||||
if (lastMessageId !== state.lastMessageId) {
|
||||
if (isNewerMessageId(lastMessageId, state.lastMessageId)) {
|
||||
state.lastMessageId = lastMessageId;
|
||||
changed = true;
|
||||
this.clearUnreadStateIfRead(state);
|
||||
|
||||
@@ -54,7 +54,6 @@ import {
|
||||
import {InviteModal} from '@app/features/invite/components/modals/InviteModal';
|
||||
import * as InviteUtils from '@app/features/invite/utils/InviteUtils';
|
||||
import Favorites from '@app/features/messaging/state/Favorites';
|
||||
import {getEffectiveChannelMatureContent} from '@app/features/messaging/utils/ContentWarningUtils';
|
||||
import {buildChannelLink} from '@app/features/messaging/utils/MessageLinkUtils';
|
||||
import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands';
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
@@ -88,7 +87,6 @@ import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
|
||||
import type {MenuActionEvent, MenuGroupType, MenuItemType} from '@app/features/ui/menu_bottom_sheet/MenuBottomSheet';
|
||||
import UserGuildSettings from '@app/features/user/state/UserGuildSettings';
|
||||
import UserSettings from '@app/features/user/state/UserSettings';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import CompactVoiceCallHeight, {getGuildVoiceCallExpansionKey} from '@app/features/voice/state/CompactVoiceCallHeight';
|
||||
import {getMutedText} from '@app/lib/overlay/OverlayContextMenu';
|
||||
import {ME} from '@fluxer/constants/src/AppConstants';
|
||||
@@ -203,7 +201,6 @@ export interface ChannelMenuState {
|
||||
hasUnread: boolean;
|
||||
canManageChannels: boolean;
|
||||
canEditChannel: boolean;
|
||||
nsfwBlockedForMinor: boolean;
|
||||
canInvite: boolean;
|
||||
developerMode: boolean;
|
||||
isPinned: boolean;
|
||||
@@ -237,10 +234,6 @@ function getChannelMenuState(channel: Channel, guild: Guild | undefined): Channe
|
||||
guildId: channel.guildId,
|
||||
});
|
||||
const canEditChannel = canManageChannels || canUpdateRtcRegion;
|
||||
const currentUser = Users.getCurrentUser();
|
||||
const channelIsNsfw =
|
||||
(isTextChannel || isVoiceChannel || isLinkChannel) && getEffectiveChannelMatureContent(channel, guild ?? null);
|
||||
const nsfwBlockedForMinor = channelIsNsfw && !!currentUser && !currentUser.matureContentAllowed;
|
||||
const canInvite = InviteUtils.canInviteToChannel(channel.id, channel.guildId);
|
||||
const developerMode = UserSettings.developerMode;
|
||||
const isPinned = channel.isPinned;
|
||||
@@ -256,7 +249,6 @@ function getChannelMenuState(channel: Channel, guild: Guild | undefined): Channe
|
||||
hasUnread,
|
||||
canManageChannels,
|
||||
canEditChannel,
|
||||
nsfwBlockedForMinor,
|
||||
canInvite,
|
||||
developerMode,
|
||||
isPinned,
|
||||
@@ -711,24 +703,21 @@ export function useChannelMenuData(
|
||||
});
|
||||
menuGroups.push({items: notificationItems});
|
||||
if (state.canEditChannel) {
|
||||
const manageItems: Array<MenuItemType> = [];
|
||||
if (!state.nsfwBlockedForMinor) {
|
||||
manageItems.push({
|
||||
const manageItems: Array<MenuItemType> = [
|
||||
{
|
||||
icon: <SettingsIcon size={20} data-flx="ui.action-menu.items.channel-menu-data.groups.settings-icon" />,
|
||||
label: i18n._(EDIT_CHANNEL_DESCRIPTOR),
|
||||
onClick: handlers.handleChannelSettings,
|
||||
});
|
||||
}
|
||||
if (state.canManageChannels && !state.nsfwBlockedForMinor) {
|
||||
},
|
||||
];
|
||||
if (state.canManageChannels) {
|
||||
manageItems.push({
|
||||
icon: <CopyIcon size={20} data-flx="ui.action-menu.items.channel-menu-data.groups.copy-icon" />,
|
||||
label: i18n._(DUPLICATE_CHANNEL_DESCRIPTOR),
|
||||
onClick: handlers.handleDuplicateChannel,
|
||||
});
|
||||
}
|
||||
if (manageItems.length > 0) {
|
||||
menuGroups.push({items: manageItems});
|
||||
}
|
||||
menuGroups.push({items: manageItems});
|
||||
}
|
||||
const debugItems: Array<MenuItemType> = [];
|
||||
if (state.developerMode) {
|
||||
|
||||
@@ -257,7 +257,7 @@ class UserSettingsState {
|
||||
status: StatusType = StatusTypes.ONLINE;
|
||||
statusResetsAt: string | null = null;
|
||||
statusResetsTo: string | null = null;
|
||||
theme: string = ThemeTypes.SYSTEM;
|
||||
theme: string = ThemeTypes.DARK;
|
||||
timeFormat: number = TimeFormatTypes.AUTO;
|
||||
locale: string = 'en-US';
|
||||
restrictedGuilds: Array<string> = [];
|
||||
|
||||
@@ -53,7 +53,7 @@ The `Type` column uses this notation.
|
||||
|
||||
The description of a duration field names its unit.
|
||||
|
||||
The type of a union lists its alternatives separated by a vertical bar, written as `type \| type` in a table cell. A field that accepts a small fixed set of literal values lists those exact wire values in the same form, as in `emoji \| sticker`.
|
||||
The type of a union lists its alternatives separated by a vertical bar, written as `type | type` in a table cell. A field that accepts a small fixed set of literal values lists those exact wire values in the same form, as in `emoji | sticker`.
|
||||
|
||||
A superscript marker such as <sup>1</sup> refers to the numbered footnote written beneath its table or paragraph. Numbering restarts in every table. A footnote records a presence condition, gate, bound, or computed value that does not fit in a description cell.
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ An OAuth2 protocol failure raised by the [OAuth2 resource](/http-api/oauth2/) an
|
||||
|
||||
## Supplementary members
|
||||
|
||||
The members a failure adds are fixed by its code, so a client reads only the members documented for the code it matched. `errors` reports field violations. `retry_after` states the delay before another attempt is admitted, `global` distinguishes a global rate limit denial from a route one, `required_scope` names a missing OAuth2 scope, and `has_mfa` and `methods` state the [sudo mode](/http-api/users/mfa/#sudo-mode) proofs an account can supply.
|
||||
The error code determines which supplementary members a failure has, and most codes have none. A client reads only the members documented for the code it matched. `errors` is the list of field violations. `retry_after` is the delay before another attempt is admitted, `global` is `true` on a global rate limit denial and `false` on a route one, `required_scope` is the OAuth2 scope the request is missing, and `has_mfa` and `methods` are the [sudo mode](/http-api/users/mfa/#sudo-mode) proofs an account can supply.
|
||||
|
||||
The two IP ban codes have their own members. `GLOBAL_IP_BANNED` and `GLOBAL_IP_TEMPORARILY_BANNED` both have `ip_address` with the normalised client address, `appeal_email` with the address an appeal is sent to, `appeals_supported` which is `true` only for the permanent ban, and `ban_kind` which is `permanent` or `temporary_24h`. `expires_at` is an ISO 8601 timestamp when the ban records an expiry and `null` otherwise, including on every permanent ban.
|
||||
|
||||
|
||||
@@ -320,7 +320,7 @@ A new account starts with these values. Every field not listed starts empty, so
|
||||
|
||||
| Setting | Initial value |
|
||||
| --- | --- |
|
||||
| Theme | The theme chosen at registration, or `system` |
|
||||
| Theme | The theme chosen at registration, or `dark` |
|
||||
| Status | `online` |
|
||||
| Rendering | Animated custom emoji, always-animated stickers, GIF autoplay, embeds and reactions rendered, spoilers on click, inline attachment and embed media |
|
||||
| Incoming calls and group DM additions | Friends only |
|
||||
|
||||
@@ -581,7 +581,7 @@ Default `nats://127.0.0.1:4222`. The JetStream address. Read by `api` and `worke
|
||||
|
||||
#### `FLUXER_NATS_AUTH_TOKEN`
|
||||
|
||||
Default empty. NATS authentication. Read by `api`, `worker`, and `gateway`. The shipped NATS runs without authentication.
|
||||
Default empty. NATS authentication. Read by `api`, `worker`, `gateway`, and the five internal services. The shipped NATS runs without authentication, and Compose forwards this name to every container that connects to it.
|
||||
|
||||
#### `FLUXER_SVC_NATS_URL`
|
||||
|
||||
@@ -1769,7 +1769,11 @@ Default `384mb`. Becomes `MEILI_MAX_INDEXING_MEMORY`. The memory the indexer may
|
||||
|
||||
#### `FLUXER_SEAWEEDFS_MEMORY_LIMIT`
|
||||
|
||||
Default `512mb`. The ceiling for `seaweedfs`. One process runs the master, the volume server and the S3 gateway.
|
||||
Default `2gb`. The ceiling for `seaweedfs`. One process runs the master, the volume server and the S3 gateway. The peak is the parts of one upload in flight at once, which the client uploads in parallel: a 500 MB attachment is 20 parts of 25 MB.
|
||||
|
||||
#### `FLUXER_SEAWEEDFS_GOMEMLIMIT`
|
||||
|
||||
Default `1536MiB`. The heap ceiling the Go runtime collects against. Go cannot see the container limit, so without this value an upload burst grows the heap past `FLUXER_SEAWEEDFS_MEMORY_LIMIT` and the kernel OOM-kills the container mid-upload with exit 137. Raising `FLUXER_SEAWEEDFS_MEMORY_LIMIT` on its own does not fix that, because the runtime grows to fill whatever it is given. Keep this near three quarters of the container limit and move the two together.
|
||||
|
||||
#### `FLUXER_SEAWEEDFS_INIT_MEMORY_LIMIT`
|
||||
|
||||
@@ -1930,10 +1934,12 @@ FLUXER_GATEWAY_MEMORY_LIMIT=512mb
|
||||
FLUXER_GATEWAY_MEMORY_RESERVATION=256mb
|
||||
FLUXER_MEILISEARCH_MEMORY_LIMIT=512mb
|
||||
FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=256mb
|
||||
FLUXER_SEAWEEDFS_MEMORY_LIMIT=1gb
|
||||
FLUXER_SEAWEEDFS_GOMEMLIMIT=768MiB
|
||||
FLUXER_GIFS_SHARD_CACHE_MAX_BYTES=134217728
|
||||
```
|
||||
|
||||
At 8 GB the api and worker heap ceilings fall to 664 MB each and Postgres caches less of the working set, which shows up as slower search and slower history scrolling under load. Nothing is turned off.
|
||||
At 8 GB the api and worker heap ceilings fall to 664 MB each and Postgres caches less of the working set, which shows up as slower search and slower history scrolling under load. Nothing is turned off, though the largest attachments need the 16 GB SeaweedFS ceiling to upload every part at once.
|
||||
|
||||
The 4 GB profile trades more.
|
||||
|
||||
@@ -1954,6 +1960,7 @@ FLUXER_MEILISEARCH_MEMORY_LIMIT=384mb
|
||||
FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=128mb
|
||||
FLUXER_MEDIA_PROXY_MEMORY_LIMIT=320mb
|
||||
FLUXER_SEAWEEDFS_MEMORY_LIMIT=320mb
|
||||
FLUXER_SEAWEEDFS_GOMEMLIMIT=240MiB
|
||||
FLUXER_LIVEKIT_MEMORY_LIMIT=320mb
|
||||
FLUXER_VALKEY_MEMORY_LIMIT=192mb
|
||||
FLUXER_VALKEY_MAXMEMORY=128mb
|
||||
|
||||
@@ -162,17 +162,15 @@ Set `X-Forwarded-For` from `$remote_addr`. `$proxy_add_x_forwarded_for` appends
|
||||
|
||||
## Caddy
|
||||
|
||||
An external Caddy needs one site block. It forwards WebSocket upgrades natively, requests the certificate itself, and applies no request body limit and no read timeout of its own.
|
||||
An external Caddy needs one site block. It forwards WebSocket upgrades natively, requests the certificate itself, and applies no request body limit and no read timeout of its own. By default it ignores the inbound `X-Forwarded-*` headers from untrusted clients and writes its own, so there is no header line to add.
|
||||
|
||||
```caddy
|
||||
chat.example.com {
|
||||
reverse_proxy 127.0.0.1:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
```
|
||||
|
||||
`{client_ip}` resolves to the peer address unless the peer is in this Caddy's own `trusted_proxies`. The `header_up` line replaces `X-Forwarded-For` with that one address.
|
||||
Caddy trusts no proxy by default and takes the client address from the connection. Set the global `trusted_proxies` option when another proxy or a CDN sits in front of Caddy, or it records that hop as the client. Caddy appends its own peer to the address list rather than replacing it, and Fluxer reads the first entry.
|
||||
|
||||
## Traefik
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ use shard_impl::GifsShard;
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
fluxer_svc::init_tracing();
|
||||
let config = ServiceConfig::from_env()?;
|
||||
let transport = NatsTransport::connect(&config.nats_url).await?;
|
||||
let transport =
|
||||
NatsTransport::connect(&config.nats_url, config.nats_auth_token.as_deref()).await?;
|
||||
|
||||
tracing::info!(
|
||||
service = config.service_name,
|
||||
|
||||
@@ -9,7 +9,8 @@ use fluxer_svc::transport::NatsTransport;
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
fluxer_svc::init_tracing();
|
||||
let config = ServiceConfig::from_env()?;
|
||||
let transport = NatsTransport::connect(&config.nats_url).await?;
|
||||
let transport =
|
||||
NatsTransport::connect(&config.nats_url, config.nats_auth_token.as_deref()).await?;
|
||||
|
||||
tracing::info!(
|
||||
service = config.service_name,
|
||||
|
||||
@@ -14,7 +14,8 @@ use types::SERVICE_NAME;
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
fluxer_svc::init_tracing();
|
||||
let config = ServiceConfig::from_env()?;
|
||||
let transport = NatsTransport::connect(&config.nats_url).await?;
|
||||
let transport =
|
||||
NatsTransport::connect(&config.nats_url, config.nats_auth_token.as_deref()).await?;
|
||||
tracing::info!(
|
||||
service = SERVICE_NAME,
|
||||
mode = ?config.mode,
|
||||
|
||||
@@ -28,7 +28,8 @@ COPY --chown=65532:65532 fluxer_static/emoji /srv/fluxer-static/emoji
|
||||
COPY --chown=65532:65532 fluxer_static/libs /srv/fluxer-static/libs
|
||||
COPY --chown=65532:65532 fluxer_static/web /srv/fluxer-static/web
|
||||
|
||||
RUN mkdir -p /config/caddy /data/caddy \
|
||||
RUN setcap -r /usr/bin/caddy \
|
||||
&& mkdir -p /config/caddy /data/caddy \
|
||||
&& chown -R 65532:65532 /config /data /srv/fluxer-static
|
||||
|
||||
USER 65532:65532
|
||||
|
||||
@@ -17,6 +17,7 @@ pub struct ServiceConfig {
|
||||
pub shard_count: u32,
|
||||
pub listen_addr: SocketAddr,
|
||||
pub nats_url: String,
|
||||
pub nats_auth_token: Option<String>,
|
||||
pub cache_max_entries: u64,
|
||||
pub cache_ttl: Duration,
|
||||
pub cache_hard_ttl: Duration,
|
||||
@@ -105,6 +106,8 @@ impl ServiceConfig {
|
||||
let nats_url = optional_from(&get, "FLUXER_SVC_NATS_URL")
|
||||
.unwrap_or_else(|| "nats://127.0.0.1:4222".to_owned());
|
||||
|
||||
let nats_auth_token = optional_from(&get, "FLUXER_NATS_AUTH_TOKEN");
|
||||
|
||||
let cache_ttl_ms = optional_from(&get, "FLUXER_SVC_CACHE_TTL_MS")
|
||||
.map(|v| v.parse::<u64>())
|
||||
.transpose()?
|
||||
@@ -165,6 +168,7 @@ impl ServiceConfig {
|
||||
shard_count,
|
||||
listen_addr: format!("{listen_host}:{listen_port}").parse()?,
|
||||
nats_url,
|
||||
nats_auth_token,
|
||||
cache_max_entries: optional_from(&get, "FLUXER_SVC_CACHE_MAX_ENTRIES")
|
||||
.map(|v| v.parse::<u64>())
|
||||
.transpose()?
|
||||
|
||||
@@ -33,7 +33,9 @@ where
|
||||
{
|
||||
init_tracing();
|
||||
let config = config::ServiceConfig::from_env()?;
|
||||
let transport = transport::NatsTransport::connect(&config.nats_url).await?;
|
||||
let transport =
|
||||
transport::NatsTransport::connect(&config.nats_url, config.nats_auth_token.as_deref())
|
||||
.await?;
|
||||
tracing::info!(
|
||||
service = config.service_name,
|
||||
mode = ?config.mode,
|
||||
|
||||
@@ -868,6 +868,7 @@ mod tests {
|
||||
shard_count: 1,
|
||||
listen_addr: "127.0.0.1:0".parse().unwrap(),
|
||||
nats_url: "memory".to_owned(),
|
||||
nats_auth_token: None,
|
||||
cache_max_entries: 100,
|
||||
cache_ttl: Duration::from_secs(30),
|
||||
cache_hard_ttl: Duration::from_secs(600),
|
||||
|
||||
@@ -417,6 +417,7 @@ mod tests {
|
||||
shard_count: 1,
|
||||
listen_addr: "127.0.0.1:0".parse().unwrap(),
|
||||
nats_url: "memory".to_owned(),
|
||||
nats_auth_token: None,
|
||||
cache_max_entries: 100,
|
||||
cache_ttl: Duration::from_secs(30),
|
||||
cache_hard_ttl: Duration::from_secs(600),
|
||||
|
||||
@@ -77,7 +77,7 @@ pub struct NatsMessage {
|
||||
}
|
||||
|
||||
impl NatsTransport {
|
||||
pub async fn connect(url: &str) -> anyhow::Result<Self> {
|
||||
pub async fn connect(url: &str, auth_token: Option<&str>) -> anyhow::Result<Self> {
|
||||
let reconnect_notify = Arc::new(Notify::new());
|
||||
|
||||
let event_notify = reconnect_notify.clone();
|
||||
@@ -112,6 +112,11 @@ impl NatsTransport {
|
||||
}
|
||||
});
|
||||
|
||||
let options = match auth_token {
|
||||
Some(token) => options.token(token.to_owned()),
|
||||
None => options,
|
||||
};
|
||||
|
||||
let client = options
|
||||
.subscription_capacity(NATS_SUBSCRIPTION_CAPACITY)
|
||||
.connect(url)
|
||||
|
||||
@@ -27,7 +27,8 @@ use shard_impl::UnfurlShard;
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
fluxer_svc::init_tracing();
|
||||
let config = ServiceConfig::from_env()?;
|
||||
let transport = NatsTransport::connect(&config.nats_url).await?;
|
||||
let transport =
|
||||
NatsTransport::connect(&config.nats_url, config.nats_auth_token.as_deref()).await?;
|
||||
|
||||
tracing::info!(
|
||||
service = config.service_name,
|
||||
|
||||
@@ -13,7 +13,8 @@ use shard_impl::UsersShard;
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
fluxer_svc::init_tracing();
|
||||
let config = ServiceConfig::from_env()?;
|
||||
let transport = NatsTransport::connect(&config.nats_url).await?;
|
||||
let transport =
|
||||
NatsTransport::connect(&config.nats_url, config.nats_auth_token.as_deref()).await?;
|
||||
|
||||
tracing::info!(
|
||||
service = config.service_name,
|
||||
|
||||
Reference in new issue
Block a user