Compare commits

...
56 changed files with 633 additions and 176 deletions

No files matched your search

+10 -2
View File
@@ -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.
+11 -33
View File
@@ -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
}
}
+4 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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,
+44
View File
@@ -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);
});
});
+5
View File
@@ -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,
@@ -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> = [];
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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,
+2 -1
View File
@@ -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,
+2 -1
View File
@@ -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,
+2 -1
View File
@@ -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
+4
View File
@@ -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()?
+3 -1
View File
@@ -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,
+1
View File
@@ -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),
+1
View File
@@ -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),
+6 -1
View File
@@ -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)
+2 -1
View File
@@ -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,
+2 -1
View File
@@ -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,