Compare commits

...
Author SHA1 Message Date
HampusandGitHub a2a68847fd fix(api): let channel managers edit a mature channel (#2583) 2026-09-08 14:51:47 +02:00
HampusandGitHub 2019909a5e fix(api): skip the mature gate when no birth date is collected (#2582) 2026-09-08 14:51:41 +02:00
HampusandGitHub d46c8d49c6 fix(svc): authenticate to nats with the configured token (#2581) 2026-09-08 14:51:34 +02:00
HampusandGitHub 45530ebbf5 docs(operator): drop the redundant caddy forwarded-for setter (#2580) 2026-09-08 14:51:29 +02:00
HampusandGitHub 9cdad046b1 fix(self-host): keep seaweedfs inside its memory ceiling (#2579) 2026-09-08 14:51:24 +02:00
HampusandGitHub b6c6928073 fix(self-host): strip the caddy file capability in fluxer-static (#2578) 2026-09-08 14:51:19 +02:00
HampusandGitHub dd1ee999a4 fix(docs): drop visible pipe escapes from union notation prose (#2577) 2026-09-08 14:27:41 +02:00
HampusandGitHub c506d6d5e3 fix(webhook): stop gating webhook file uploads on creator perms (#2576) 2026-09-08 14:25:59 +02:00
HampusandGitHub 8a65832a65 feat(theme): default new accounts to the dark theme (#2575) 2026-09-08 14:05:10 +02:00
HampusandGitHub fd6ae4abd7 fix(app): distrust windows loaded across a connection gap (#2574) 2026-09-08 13:06:53 +02:00
HampusandGitHub 24b84c419c docs(http-api): reword the supplementary members paragraph (#2573) 2026-09-08 12:53:43 +02:00
HampusandGitHub 746a75187a fix(api): snapshot the new message id when opening a closed DM (#2569) 2026-09-07 11:00:03 +02:00
HampusandGitHub 10ba2ca896 fix(app): show the format toolbar on double-click selections (#2566) 2026-09-07 00:13:30 +02:00
HampusandGitHub 977b6767cd fix(app): refetch the tail when a channel window falls behind (#2565) 2026-09-06 23:51:08 +02:00
HampusandGitHub f00c6ee47a docs(http-api): name the endpoint a third-party client reads (#2564) 2026-09-06 23:50:52 +02:00
HampusandGitHub 82859dc2f6 fix(api): keep a deferral while the phone gate state is unknown (#2554) 2026-09-06 23:41:29 +02:00
HampusandGitHub 328dc06ab0 feat(installer): drive podman as well as docker (#2563) 2026-09-06 23:28:40 +02:00
HampusandGitHub ea6e4a75db fix(markdown): let a backslash escape a code fence (#2562) 2026-09-06 22:45:29 +02:00
HampusandGitHub 69ca462930 fix(app): keep popouts in the window they were opened in (#2561) 2026-09-06 22:42:32 +02:00
HampusandGitHub f38619d974 fix(app): isolate bidi usernames from message timestamps (#2560) 2026-09-06 22:41:57 +02:00
HampusandGitHub 6c0ce9369b fix(app): refresh mutual communities on membership change (#2559) 2026-09-06 22:38:31 +02:00
HampusandGitHub 00c1b19809 fix(app): inherit category mute when hiding muted channels (#2558) 2026-09-06 22:10:09 +02:00
HampusandGitHub 2fd5daf104 fix(app): keep the client active while the user is typing (#2557) 2026-09-06 21:29:06 +02:00
HampusandGitHub fbf0f6adfe fix(app): load more bookmarks as the list scrolls (#2556) 2026-09-06 21:05:57 +02:00
HampusandGitHub d91b5bec66 fix(app): show unread channels in muted collapsed categories (#2555) 2026-09-06 20:45:11 +02:00
HampusandGitHub 0f24cfb6ef ci(docs): check the installer upgrade key lists for drift (#2553) 2026-09-06 20:43:50 +02:00
HampusandGitHub 7a6691cdbe fix(installer): make the record and rollback paths trustworthy (#2552) 2026-09-06 20:36:59 +02:00
HampusandGitHub 73d3a4f843 fix(app): widen the custom status modal (#2551) 2026-09-06 20:19:28 +02:00
HampusandGitHub 091755fe78 fix(self-hosting): adapt the upgrade to existing instances (#2550) 2026-09-06 19:40:32 +02:00
HampusandGitHub 1fb2790bb9 fix(api): stop bounding the pin listing by the wall clock (#2549) 2026-09-06 19:03:15 +02:00
111 changed files with 2989 additions and 523 deletions
+20 -2
View File
@@ -157,6 +157,16 @@ LIVEKIT_API_SECRET=CHANGE_ME
#FLUXER_LIVEKIT_TCP_PORT=7881
#FLUXER_LIVEKIT_UDP_PORT=7882
# LiveKit finds the address browsers dial by asking a STUN server. A host that
# cannot reach one over UDP stops with "could not resolve external IP", and the
# address is then set by hand: put it in FLUXER_LIVEKIT_NODE_IP and set
# FLUXER_LIVEKIT_USE_EXTERNAL_IP to false. Point the two STUN entries at another
# server to keep the lookup and leave Google out of it.
#FLUXER_LIVEKIT_USE_EXTERNAL_IP=false
#FLUXER_LIVEKIT_NODE_IP=203.0.113.10
#FLUXER_LIVEKIT_STUN_PRIMARY=stun.l.google.com:19302
#FLUXER_LIVEKIT_STUN_SECONDARY=stun1.l.google.com:19302
FLUXER_KLIPY_API_KEY=
FLUXER_EMAIL_ENABLED=false
@@ -178,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
@@ -189,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
@@ -217,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
}
}
+8 -4
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
@@ -332,10 +335,11 @@ services:
rtc:
tcp_port: ${FLUXER_LIVEKIT_TCP_PORT:-7881}
udp_port: ${FLUXER_LIVEKIT_UDP_PORT:-7882}
use_external_ip: true
use_external_ip: ${FLUXER_LIVEKIT_USE_EXTERNAL_IP:-true}
node_ip: "${FLUXER_LIVEKIT_NODE_IP:-}"
stun_servers:
- stun.l.google.com:19302
- stun1.l.google.com:19302
- ${FLUXER_LIVEKIT_STUN_PRIMARY:-stun.l.google.com:19302}
- ${FLUXER_LIVEKIT_STUN_SECONDARY:-stun1.l.google.com:19302}
webhook:
api_key: ${LIVEKIT_API_KEY:?set LIVEKIT_API_KEY in .env}
urls:
@@ -632,6 +632,8 @@ describe('Deferred phone verification gate', () => {
await configurePhoneGate({deferred_phone_gate_window_hours: 0.0001});
const outsideWindow = await createGuildWithInvite(harness);
await addFillerMember(outsideWindow.inviteCode);
const beforeJoin = await readFlags(subject.userId);
expect(beforeJoin & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
await createBuilder(harness, subject.token).post(`/invites/${outsideWindow.inviteCode}`).expect(200).execute();
const flags = await readFlags(subject.userId);
@@ -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();
}
@@ -24,6 +24,8 @@ import type {MessagePersistenceService} from '../message/MessagePersistenceServi
import {createMessageResponseDataService} from '../message/MessageResponseDataService';
import {MessageInteractionBase} from './MessageInteractionBase';
const PIN_LIST_UNBOUNDED_TIMESTAMP = new Date('9999-12-31T23:59:59.999Z');
export class MessagePinService extends MessageInteractionBase {
constructor(
gatewayService: IGatewayService,
@@ -81,7 +83,7 @@ export class MessagePinService extends MessageInteractionBase {
const pageSize = Math.min(limit ?? 50, 50);
const cutoffTimestamp = hasReadHistory ? null : new Date(authChannel.guild!.message_history_cutoff!).getTime();
const filtered: Array<Message> = [];
let before = beforeTimestamp ?? new Date();
let before = beforeTimestamp ?? PIN_LIST_UNBOUNDED_TIMESTAMP;
let exhausted = false;
while (filtered.length <= pageSize && !exhausted) {
const messages = await this.channelRepository.messageInteractions.listChannelPins(
@@ -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,
+2 -1
View File
@@ -22705,7 +22705,8 @@
"type": "string",
"description": "Maximum number of saved messages to return (1-100, default 25)"
}
}
},
{"name": "before", "in": "query", "required": false, "schema": {"$ref": "#/components/schemas/SnowflakeType"}}
]
},
"post": {
@@ -1,6 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
let cachedEnabled = false;
// Null until a policy has actually been read. A deferral is only ever granted
// while the gate is on, so reading "not known yet" as "off" revokes it from every
// account holding one and answers their next request with the phone requirement
// the deferral exists to hold back.
let cachedEnabled: boolean | null = null;
export function resolveDeferredPhoneGateEnabled(policy: {
deferred_phone_gate_enabled: boolean;
@@ -9,7 +13,7 @@ export function resolveDeferredPhoneGateEnabled(policy: {
return policy.deferred_phone_gate_enabled && !policy.single_community_enabled;
}
export function getCachedDeferredPhoneGateEnabled(): boolean {
export function getCachedDeferredPhoneGateEnabled(): boolean | null {
return cachedEnabled;
}
+1 -1
View File
@@ -134,7 +134,7 @@ function suppressDeferredPhoneFlags(rawFlags: number): number {
if ((rawFlags & DEFERRED_PHONE_ON_COMMUNITY_JOIN) === 0) {
return rawFlags;
}
if (!getCachedDeferredPhoneGateEnabled()) {
if (getCachedDeferredPhoneGateEnabled() === false) {
return rawFlags & ~DEFERRED_PHONE_ON_COMMUNITY_JOIN;
}
return rawFlags & ~DEFERRABLE_PHONE_FLAGS;
@@ -123,9 +123,11 @@ export function UserContentController(app: HonoApp) {
'Retrieves all messages saved by the current user. Messages are saved privately for easy reference. Returns paginated list of saved messages with metadata.',
}),
async (ctx) => {
const {limit, before} = ctx.req.valid('query');
const response = await ctx.get('userContentRequestService').listSavedMessages({
userId: ctx.get('user').id,
limit: ctx.req.valid('query').limit,
limit,
before: before ? createMessageID(before) : undefined,
requestCache: ctx.get('requestCache'),
});
return ctx.json(response, 200);
@@ -46,6 +46,7 @@ interface UserMentionsReadParams {
interface SavedMessagesParams {
userId: UserID;
limit: number;
before?: MessageID;
requestCache: RequestCache;
}
@@ -108,7 +109,11 @@ export class UserContentRequestService {
}
async listSavedMessages(params: SavedMessagesParams): Promise<SavedMessageEntryListResponse> {
const entries = await this.userContentService.getSavedMessages({userId: params.userId, limit: params.limit});
const entries = await this.userContentService.getSavedMessages({
userId: params.userId,
limit: params.limit,
before: params.before,
});
const messages = entries.map((entry) => entry.message).filter((message): message is Message => message != null);
const responses = await this.userContentService.buildMessageResponsesForUser(params.userId, messages);
const responseByMessageId = new Map(responses.map((response) => [response.id, response] as const));
@@ -100,6 +100,7 @@ function createUserContentService({
}) {
const batchCalls: Array<ChannelBatchCall> = [];
const deletedSavedMessageIds: Array<string> = [];
const savedMessageListCalls: Array<{limit?: number; before?: MessageID}> = [];
const readableByChannel = new Map<string, Map<string, Message>>();
for (const entry of readable) {
const key = entry.channelId.toString();
@@ -109,7 +110,10 @@ function createUserContentService({
}
const userRepository = {
listRecentMentions: async () => entries,
listSavedMessages: async () => entries,
listSavedMessages: async (_userId: UserID, limit?: number, before?: MessageID) => {
savedMessageListCalls.push({limit, before});
return entries;
},
deleteSavedMessage: async (_userId: UserID, messageId: MessageID) => {
deletedSavedMessageIds.push(messageId.toString());
},
@@ -146,7 +150,7 @@ function createUserContentService({
{} as unknown as KVBulkMessageDeletionQueueService,
{} as unknown as LimitConfigService,
);
return {service, batchCalls, deletedSavedMessageIds};
return {service, batchCalls, deletedSavedMessageIds, savedMessageListCalls};
}
const CHANNEL_A = createChannelID(100n);
@@ -238,6 +242,15 @@ describe('getRecentMentions', () => {
});
describe('getSavedMessages', () => {
it('passes the page cursor to the repository', async () => {
const entries = [{channelId: CHANNEL_A, messageId: createMessageID(11n)}];
const {service, savedMessageListCalls} = createUserContentService({entries, readable: entries});
await service.getSavedMessages({userId: VIEWER_ID, limit: 50, before: createMessageID(20n)});
expect(savedMessageListCalls).toEqual([{limit: 50, before: createMessageID(20n)}]);
});
it('marks every entry of an unreachable channel as missing permissions without deleting it', async () => {
const entries = [
{channelId: CHANNEL_A, messageId: createMessageID(11n)},
@@ -229,8 +229,16 @@ export class UserContentService {
);
}
async getSavedMessages({userId, limit}: {userId: UserID; limit: number}): Promise<Array<SavedMessageEntry>> {
const savedMessages = await this.userRepository.listSavedMessages(userId, limit);
async getSavedMessages({
userId,
limit,
before,
}: {
userId: UserID;
limit: number;
before?: MessageID;
}): Promise<Array<SavedMessageEntry>> {
const savedMessages = await this.userRepository.listSavedMessages(userId, limit, before);
const messagesByChannel = await this.readMessagesByChannel(userId, savedMessages);
const results: Array<SavedMessageEntry> = [];
const staleMessageIds: Array<MessageID> = [];
+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);
@@ -338,9 +338,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
const unreadCount = ReadStates.getUnreadCount(channelId);
const hasUnread = ReadStates.hasUnread(channelId);
const mentionCount = ReadStates.getMentionCount(channelId);
const isMuted =
UserGuildSettings.isParentCategoryMuted(guild.id, channelId) ||
UserGuildSettings.isChannelDirectlyMuted(guild.id, channelId);
const isMuted = UserGuildSettings.isChannelDirectlyMuted(guild.id, channelId);
const channel = Channels.getChannel(channelId);
const unreadBadgesLevel = channel
? UserGuildSettings.resolvedUnreadBadgesLevel({
@@ -364,18 +362,21 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
for (const group of channelGroups) {
const isCollapsed = group.category ? (collapsedCategories?.has(group.category.id) ?? false) : false;
const isNullSpace = !group.category;
const isCategoryMuted = group.category
? UserGuildSettings.isChannelDirectlyMuted(guild.id, group.category.id)
: false;
const isCategoryMuted =
hideMutedChannels && group.category
? UserGuildSettings.isChannelDirectlyMuted(guild.id, group.category.id)
: false;
let filteredTextChannels: Array<Channel>;
let filteredVoiceChannels: Array<Channel>;
if (hideMutedChannels) {
filteredTextChannels = [];
for (const ch of group.textChannels) {
const isMuted = UserGuildSettings.isChannelDirectlyMuted(guild.id, ch.id);
const isChannelMuted = UserGuildSettings.isChannelDirectlyMuted(guild.id, ch.id);
const isMuted = isCategoryMuted || isChannelMuted;
if (
shouldShowChannelWhenHidingMutedChannels({
isMuted,
isCategoryMuted,
isChannelMuted,
isSelected: ch.id === selectedChannelInGuildId,
isConnected: false,
hasVisibleUnread: isMuted && hasVisibleUnreadInChannel(ch.id),
@@ -386,10 +387,12 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
}
filteredVoiceChannels = [];
for (const ch of group.voiceChannels) {
const isMuted = UserGuildSettings.isChannelDirectlyMuted(guild.id, ch.id);
const isChannelMuted = UserGuildSettings.isChannelDirectlyMuted(guild.id, ch.id);
const isMuted = isCategoryMuted || isChannelMuted;
if (
shouldShowChannelWhenHidingMutedChannels({
isMuted,
isCategoryMuted,
isChannelMuted,
isSelected: ch.id === selectedChannelInGuildId,
isConnected: ch.id === connectedChannelId,
hasVisibleUnread: isMuted && hasVisibleUnreadInChannel(ch.id),
@@ -411,8 +414,8 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
for (const ch of filteredTextChannels) {
if (
shouldShowChannelInCollapsedCategory({
isCategoryMuted,
isSelected: ch.id === showTextSelected,
isConnected: false,
hasVisibleUnread: hasVisibleUnreadInChannel(ch.id),
})
) {
@@ -442,8 +445,8 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
for (const ch of filteredVoiceChannels) {
if (
shouldShowChannelInCollapsedCategory({
isCategoryMuted,
isSelected: ch.id === selectedChannelInGuildId,
isConnected: ch.id === connectedChannelId,
hasVisibleUnread: hasVisibleUnreadInChannel(ch.id),
})
) {
@@ -11,7 +11,8 @@ describe('shouldShowChannelWhenHidingMutedChannels', () => {
it('keeps muted channels with visible unread state so mentions are findable', () => {
expect(
shouldShowChannelWhenHidingMutedChannels({
isMuted: true,
isCategoryMuted: false,
isChannelMuted: true,
isSelected: false,
isConnected: false,
hasVisibleUnread: true,
@@ -22,7 +23,8 @@ describe('shouldShowChannelWhenHidingMutedChannels', () => {
it('hides muted channels without visible unread state', () => {
expect(
shouldShowChannelWhenHidingMutedChannels({
isMuted: true,
isCategoryMuted: false,
isChannelMuted: true,
isSelected: false,
isConnected: false,
hasVisibleUnread: false,
@@ -30,10 +32,35 @@ describe('shouldShowChannelWhenHidingMutedChannels', () => {
).toBe(false);
});
it('inherits the mute from a muted category', () => {
expect(
shouldShowChannelWhenHidingMutedChannels({
isCategoryMuted: true,
isChannelMuted: false,
isSelected: false,
isConnected: false,
hasVisibleUnread: false,
}),
).toBe(false);
});
it('keeps channels of a muted category that have visible unread state', () => {
expect(
shouldShowChannelWhenHidingMutedChannels({
isCategoryMuted: true,
isChannelMuted: false,
isSelected: false,
isConnected: false,
hasVisibleUnread: true,
}),
).toBe(true);
});
it('keeps selected and connected muted channels visible', () => {
expect(
shouldShowChannelWhenHidingMutedChannels({
isMuted: true,
isCategoryMuted: true,
isChannelMuted: true,
isSelected: true,
isConnected: false,
hasVisibleUnread: false,
@@ -41,7 +68,8 @@ describe('shouldShowChannelWhenHidingMutedChannels', () => {
).toBe(true);
expect(
shouldShowChannelWhenHidingMutedChannels({
isMuted: true,
isCategoryMuted: true,
isChannelMuted: false,
isSelected: false,
isConnected: true,
hasVisibleUnread: false,
@@ -52,7 +80,8 @@ describe('shouldShowChannelWhenHidingMutedChannels', () => {
it('keeps unmuted channels visible', () => {
expect(
shouldShowChannelWhenHidingMutedChannels({
isMuted: false,
isCategoryMuted: false,
isChannelMuted: false,
isSelected: false,
isConnected: false,
hasVisibleUnread: false,
@@ -94,27 +123,37 @@ describe('shouldShowChannelInCollapsedCategory', () => {
it('keeps visible unread channels reachable in collapsed categories', () => {
expect(
shouldShowChannelInCollapsedCategory({
isCategoryMuted: false,
isSelected: false,
isConnected: false,
hasVisibleUnread: true,
}),
).toBe(true);
});
it('does not reveal unread channels from muted collapsed categories unless selected', () => {
it('keeps selected and connected channels visible without unread state', () => {
expect(
shouldShowChannelInCollapsedCategory({
isCategoryMuted: true,
isSelected: false,
hasVisibleUnread: true,
}),
).toBe(false);
expect(
shouldShowChannelInCollapsedCategory({
isCategoryMuted: true,
isSelected: true,
isConnected: false,
hasVisibleUnread: false,
}),
).toBe(true);
expect(
shouldShowChannelInCollapsedCategory({
isSelected: false,
isConnected: true,
hasVisibleUnread: false,
}),
).toBe(true);
});
it('hides read channels in collapsed categories', () => {
expect(
shouldShowChannelInCollapsedCategory({
isSelected: false,
isConnected: false,
hasVisibleUnread: false,
}),
).toBe(false);
});
});
@@ -1,19 +1,21 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export interface HiddenMutedChannelVisibilityInput {
isMuted: boolean;
isCategoryMuted: boolean;
isChannelMuted: boolean;
isSelected: boolean;
isConnected: boolean;
hasVisibleUnread: boolean;
}
export function shouldShowChannelWhenHidingMutedChannels({
isMuted,
isCategoryMuted,
isChannelMuted,
isSelected,
isConnected,
hasVisibleUnread,
}: HiddenMutedChannelVisibilityInput): boolean {
return isSelected || isConnected || !isMuted || hasVisibleUnread;
return isSelected || isConnected || !(isCategoryMuted || isChannelMuted) || hasVisibleUnread;
}
export interface HiddenMutedCategoryVisibilityInput {
@@ -29,15 +31,15 @@ export function shouldShowCategoryWhenHidingMutedChannels({
}
export interface CollapsedCategoryChannelVisibilityInput {
isCategoryMuted: boolean;
isSelected: boolean;
isConnected: boolean;
hasVisibleUnread: boolean;
}
export function shouldShowChannelInCollapsedCategory({
isCategoryMuted,
isSelected,
isConnected,
hasVisibleUnread,
}: CollapsedCategoryChannelVisibilityInput): boolean {
return isSelected || (!isCategoryMuted && hasVisibleUnread);
return isSelected || isConnected || hasVisibleUnread;
}
@@ -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,
@@ -0,0 +1,56 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {useAntiShiftFloating} from '@app/features/app/hooks/useAntiShiftFloating';
import {act, createElement, useLayoutEffect, useState} from 'react';
import {createRoot, type Root} from 'react-dom/client';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
let host: HTMLDivElement;
let root: Root;
let target: HTMLDivElement;
function PortalLikeFloating({onReady}: {onReady: (isReady: boolean) => void}) {
const {setFloating, state} = useAntiShiftFloating(target, true, {placement: 'top'});
const [mounted, setMounted] = useState(false);
useLayoutEffect(() => {
setMounted(true);
}, []);
onReady(state.isReady);
return mounted ? createElement('div', {ref: setFloating, 'data-testid': 'floating'}) : null;
}
async function settle(): Promise<void> {
for (let attempt = 0; attempt < 20; attempt += 1) {
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
await Promise.resolve();
});
}
}
beforeEach(() => {
host = document.createElement('div');
target = document.createElement('div');
document.body.append(host, target);
root = createRoot(host);
});
afterEach(() => {
act(() => {
root.unmount();
});
document.body.replaceChildren();
});
describe('useAntiShiftFloating', () => {
it('positions a floating element that mounts after the first commit', async () => {
const readyStates: Array<boolean> = [];
act(() => {
root.render(createElement(PortalLikeFloating, {onReady: (isReady) => readyStates.push(isReady)}));
});
await settle();
expect(readyStates.at(-1)).toBe(true);
expect(host.querySelector<HTMLElement>('[data-testid="floating"]')?.style.visibility).toBe('visible');
});
});
@@ -279,6 +279,11 @@ export function useAntiShiftFloating(
constrainHeight = false,
} = options;
const floatingRef = useRef<HTMLElement>(null);
const [floatingElement, setFloatingElement] = useState<HTMLElement | null>(null);
const setFloating = useCallback((element: HTMLElement | null) => {
floatingRef.current = element;
setFloatingElement(element);
}, []);
const [state, setState] = useState<FloatingState>(() => {
const {x, y} = target ? getInitialGuess(target, placement, offsetMainAxis, offsetCrossAxis) : {x: -9999, y: -9999};
return {x, y, isReady: false, offsetX: 0, offsetY: 0, placement};
@@ -481,7 +486,7 @@ export function useAntiShiftFloating(
};
}, []);
useLayoutEffect(() => {
if (!enabled || target == null || floatingRef.current == null) {
if (!enabled || target == null || floatingElement == null) {
setState((prev) => ({...prev, isReady: false, offsetX: 0, offsetY: 0}));
return;
}
@@ -489,7 +494,7 @@ export function useAntiShiftFloating(
setState((prev) => ({...prev, isReady: false, offsetX: 0, offsetY: 0}));
return;
}
const floating = floatingRef.current;
const floating = floatingElement;
updatePosition();
if (shouldAutoUpdate) {
const cleanupCallbacks = [
@@ -502,7 +507,7 @@ export function useAntiShiftFloating(
}
};
} else if (shouldObserveFloatingResize) {
cleanupRef.current = observeFloatingResize(floatingRef.current, updatePosition);
cleanupRef.current = observeFloatingResize(floating, updatePosition);
}
return () => {
positionRevisionRef.current += 1;
@@ -519,9 +524,18 @@ export function useAntiShiftFloating(
}
setState((prev) => ({...prev, isReady: false, offsetX: 0, offsetY: 0}));
};
}, [enabled, target, shouldAutoUpdate, shouldObserveFloatingResize, updatePosition, updatePositionNow]);
}, [
enabled,
target,
floatingElement,
shouldAutoUpdate,
shouldObserveFloatingResize,
updatePosition,
updatePositionNow,
]);
return {
ref: floatingRef,
setFloating,
state,
style: {
position: 'fixed' as const,
@@ -29,6 +29,8 @@ import {
resolveChannelMessagesWindowStatus,
selectChannelMessagesFillerVisible,
selectChannelMessagesSpacerHeight,
selectChannelMessagesTailGapId,
selectChannelMessagesTailProbeId,
selectChannelMessagesWindowBar,
} from '@app/features/messaging/state/ChannelMessagesLoadStateMachine';
import MessageEdit from '@app/features/messaging/state/MessageEdit';
@@ -66,7 +68,11 @@ import {clsx} from 'clsx';
import {runInAction} from 'mobx';
import {observer, useLocalObservable} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useEffect, useMemo, useRef} 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}',
@@ -167,6 +173,9 @@ export const Messages = observer(function Messages({
const scrollerContainerRef = useRef<HTMLDivElement | null>(null);
const lastStateSnapshotRef = useRef<MessagesStateSnapshot | null>(null);
const recoveryFetchChannelIdRef = useRef<string | null>(null);
const tailProbeKeyRef = useRef<string | null>(null);
const tailProbeAttemptsRef = useRef<{key: string; attempts: number} | null>(null);
const [settledTailProbeKey, setSettledTailProbeKey] = useState<string | null>(null);
interface MessageState extends MessagesStateSnapshot {
highlightedMessageId: string | null;
isAtBottom: boolean;
@@ -202,6 +211,17 @@ export const Messages = observer(function Messages({
});
const windowBar = selectChannelMessagesWindowBar(windowStatus);
const windowNeedsPage = windowStatus.needsPage;
const tailInput = {
status: windowStatus,
loading: safeMessages.loadingMore || safeMessages.probeLoading,
newestLoadedMessageId: safeMessages.last()?.id ?? null,
knownLatestMessageId: state.lastReadStateMessageId,
};
const tailWatermarkMessageId = state.lastReadStateMessageId;
const tailGapMessageId = selectChannelMessagesTailGapId(tailInput);
const tailProbeMessageId = selectChannelMessagesTailProbeId(tailInput);
const tailGapKey = tailGapMessageId == null ? null : `${tailGapMessageId}:${tailWatermarkMessageId}`;
const tailProbeKey = tailProbeMessageId == null ? null : `${tailProbeMessageId}:${tailWatermarkMessageId}`;
const canAutoAck = shouldAutoAck({
channelActive: allowAutoAck,
windowFocused: isWindowFocused,
@@ -454,6 +474,47 @@ export const Messages = observer(function Messages({
}
});
}, [channel.id, isGatewayConnected, selectedChannelId, windowNeedsPage, state.messageVersion]);
useEffect(() => {
if (!isGatewayConnected) {
tailProbeKeyRef.current = null;
return;
}
if (tailProbeMessageId == null || tailProbeKey == null || tailWatermarkMessageId == null) {
return;
}
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,
onSettled: (settlement) => {
if (settlement === 'applied') {
setSettledTailProbeKey(tailProbeKey);
return;
}
if (settlement === 'failed') {
const attempts =
tailProbeAttemptsRef.current?.key === tailProbeKey ? tailProbeAttemptsRef.current.attempts : 0;
if (attempts >= TAIL_PROBE_MAX_ATTEMPTS) {
setSettledTailProbeKey(tailProbeKey);
return;
}
tailProbeAttemptsRef.current = {key: tailProbeKey, attempts: attempts + 1};
}
if (tailProbeKeyRef.current === tailProbeKey) {
tailProbeKeyRef.current = null;
}
},
},
});
}, [channel.id, isGatewayConnected, selectedChannelId, tailProbeKey, tailProbeMessageId, tailWatermarkMessageId]);
useMessageListKeyboardNavigation({
containerRef: scrollManager.ref,
channelId: channel.id,
@@ -488,10 +549,11 @@ export const Messages = observer(function Messages({
}, []);
useEffect(() => {
if (!canAutoAck || !state.isAtBottom || !state.messages?.ready) return;
if (tailGapKey != null && settledTailProbeKey !== tailGapKey) return;
if (ReadStates.hasUnread(channel.id)) {
ReadStateCommands.ackWithStickyUnread(channel.id);
}
}, [canAutoAck, state.isAtBottom, state.messages?.ready, channel.id]);
}, [canAutoAck, state.isAtBottom, state.messages?.ready, tailGapKey, settledTailProbeKey, channel.id]);
useEffect(() => {
return () => {
const readState = ReadStates.getIfExists(channel.id);
@@ -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" />,
@@ -41,6 +41,13 @@
padding: 0 1rem 1rem;
}
.loadingState {
display: flex;
height: 5rem;
align-items: center;
justify-content: center;
}
.messagePreviewCard {
position: relative;
cursor: pointer;
@@ -17,6 +17,7 @@ import {focusChannelTextareaAfterNavigation} from '@app/features/messaging/utils
import {goToMessage} from '@app/features/messaging/utils/MessageNavigator';
import {BottomSheet} from '@app/features/ui/bottom_sheet/BottomSheet';
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
import {Spinner} from '@app/features/ui/components/Spinner';
import type {MenuGroupType} from '@app/features/ui/menu_bottom_sheet/MenuBottomSheet';
import {MenuBottomSheet} from '@app/features/ui/menu_bottom_sheet/MenuBottomSheet';
import {MessagePreviewContext} from '@fluxer/constants/src/ChannelConstants';
@@ -44,6 +45,8 @@ export const BookmarksBottomSheet = observer(({isOpen, onClose}: BookmarksBottom
const {i18n} = useLingui();
const {savedMessages, missingSavedMessages, fetched} = SavedMessages;
const hasBookmarks = savedMessages.length > 0 || missingSavedMessages.length > 0;
const hasMore = SavedMessages.getHasMore();
const isLoadingMore = SavedMessages.getIsLoadingMore();
const scrollerRef = useRef<ScrollerHandle | null>(null);
const resolveBookmarksScrollSurface = useMemo(() => () => scrollerRef.current?.getViewportElement() ?? null, []);
const [selectedMessage, setSelectedMessage] = useState<Message | null>(null);
@@ -61,6 +64,13 @@ export const BookmarksBottomSheet = observer(({isOpen, onClose}: BookmarksBottom
useMessageListKeyboardNavigation({
containerRef: scrollerRef,
});
const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
const target = event.currentTarget;
const scrollPercentage = (target.scrollTop + target.offsetHeight) / target.scrollHeight;
if (scrollPercentage > 0.8 && hasMore && !isLoadingMore) {
SavedMessageCommands.loadMore();
}
};
const handleLongPress = (message: Message) => {
setSelectedMessage(message);
setMenuOpen(true);
@@ -127,6 +137,7 @@ export const BookmarksBottomSheet = observer(({isOpen, onClose}: BookmarksBottom
<NearViewportSurfaceContext.Provider value={resolveBookmarksScrollSurface}>
<Scroller
className={styles.messageList}
onScroll={handleScroll}
key="bookmarks-bottom-sheet-scroller"
ref={scrollerRef}
onCopy={onCopySelectedMessages}
@@ -157,6 +168,11 @@ export const BookmarksBottomSheet = observer(({isOpen, onClose}: BookmarksBottom
/>
))}
</div>
{isLoadingMore && (
<div className={styles.loadingState} data-flx="channel.bookmarks-bottom-sheet.loading-state">
<Spinner data-flx="channel.bookmarks-bottom-sheet.spinner" />
</div>
)}
</Scroller>
</NearViewportSurfaceContext.Provider>
) : (
@@ -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;
}
@@ -23,6 +23,7 @@ import ReadStates from '@app/features/read_state/state/ReadStates';
import QuickSwitcher from '@app/features/search/state/QuickSwitcher';
import Nagbar from '@app/features/ui/state/Nagbar';
import UserGuildSettings from '@app/features/user/state/UserGuildSettings';
import UserProfile from '@app/features/user/state/UserProfile';
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
import {FAVORITES_GUILD_ID} from '@fluxer/constants/src/AppConstants';
@@ -51,6 +52,7 @@ export function handleGuildCreate(data: GuildReadyData, _context: GatewayHandler
GuildCount.handleGuildCreate(data);
if (!isSync) {
MemberSidebar.handleGuildCreate(data.id);
UserProfile.handleGuildCreate();
}
if (!data.unavailable) {
Channels.handleGuildCreate(data);
@@ -20,6 +20,7 @@ import MentionFeed from '@app/features/notification/state/MentionFeed';
import Permission from '@app/features/permissions/state/Permission';
import Presence from '@app/features/presence/state/Presence';
import QuickSwitcher from '@app/features/search/state/QuickSwitcher';
import UserProfile from '@app/features/user/state/UserProfile';
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
import Webhooks from '@app/features/webhook/state/Webhooks';
import type {Guild} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
@@ -52,5 +53,6 @@ export function handleGuildDelete(data: GuildDeletePayload, _context: GatewayHan
Messages.handleGuildUnavailable(data.id, data.unavailable ?? false);
Messages.handleCleanup();
MentionFeed.handleGuildDelete(data.id);
UserProfile.handleGuildDelete(data.unavailable);
QuickSwitcher.recomputeIfOpen();
}
@@ -6,6 +6,7 @@ import GuildMembers from '@app/features/member/state/GuildMembers';
import MemberSearch from '@app/features/member/state/MemberSearch';
import Permission from '@app/features/permissions/state/Permission';
import Presence from '@app/features/presence/state/Presence';
import UserProfile from '@app/features/user/state/UserProfile';
import Users from '@app/features/user/state/Users';
import type {GuildMemberData} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {User} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
@@ -21,4 +22,5 @@ export function handleGuildMemberAdd(data: GuildMemberAddPayload, _context: Gate
GuildVerification.handleGuildMemberUpdate(data.guild_id);
Presence.handleGuildMemberAdd(data.guild_id, data.user.id);
MemberSearch.handleMemberAdd(data.guild_id, data.user.id);
UserProfile.handleGuildMemberAdd(data.user.id);
}
@@ -5,6 +5,7 @@ import GuildMembers from '@app/features/member/state/GuildMembers';
import MemberSearch from '@app/features/member/state/MemberSearch';
import Permission from '@app/features/permissions/state/Permission';
import Presence from '@app/features/presence/state/Presence';
import UserProfile from '@app/features/user/state/UserProfile';
import Users from '@app/features/user/state/Users';
import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
@@ -19,4 +20,5 @@ export function handleGuildMemberRemove(data: GuildMemberRemovePayload, _context
GuildMembers.handleMemberRemove(data.guild_id, data.user.id);
Permission.handleGuildMemberUpdate(data.user.id);
Presence.handleGuildMemberRemove(data.guild_id, data.user.id);
UserProfile.handleGuildMemberRemove(data.user.id);
}
@@ -271,7 +271,7 @@ function SelectionToolbarSurface({
}),
[editor],
);
const {ref, state, style, updatePosition} = useAntiShiftFloating(virtualReference, true, {
const {setFloating, state, style, updatePosition} = useAntiShiftFloating(virtualReference, true, {
placement: 'top',
offsetMainAxis: 8,
shouldAutoUpdate: true,
@@ -381,7 +381,7 @@ function SelectionToolbarSurface({
>
<flx-lexical-selection-formatting-toolbar
ref={(element) => {
(ref as React.MutableRefObject<HTMLElement | null>).current = element;
setFloating(element);
toolbarRef.current = element;
}}
className={flxElementClassName(styles.toolbar)}
@@ -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';
@@ -23,6 +25,7 @@ import {MessageEditFailedModal} from '@app/features/messaging/components/alerts/
import {MessageEditTooQuickModal} from '@app/features/messaging/components/alerts/MessageEditTooQuickModal';
import type {Message as MessageModel} from '@app/features/messaging/models/MessagingMessage';
import type {JumpOptions} from '@app/features/messaging/state/ChannelMessages';
import {selectChannelMessagesTailProbeOutcome} from '@app/features/messaging/state/ChannelMessagesLoadStateMachine';
import MessageEdit from '@app/features/messaging/state/MessageEdit';
import MessageEditMobile from '@app/features/messaging/state/MessageEditMobile';
import MessageQueue from '@app/features/messaging/state/MessageQueue';
@@ -115,6 +118,19 @@ export interface JumpToMessageOptions {
interface FetchMessagesOptions {
throwOnError?: boolean;
tailProbe?: TailProbeContext;
}
export type TailProbeSettlement = 'applied' | 'retry' | 'failed';
export interface TailProbeContext {
watermarkMessageId: string;
onSettled: (settlement: TailProbeSettlement) => void;
}
interface TailProbeEpoch {
loadGeneration: number;
jumpTicket: number;
}
interface MessagePageState {
@@ -142,11 +158,12 @@ function makeFetchKey(
): string {
const SEP = '\x1f';
const throwOnError = options?.throwOnError ? '1' : '0';
const tailProbe = options?.tailProbe ? `1.${options.tailProbe.watermarkMessageId}` : '0';
if (!jump) {
return `${channelId}${SEP}${before ?? ''}${SEP}${after ?? ''}${SEP}${limit}${SEP}${throwOnError}`;
return `${channelId}${SEP}${before ?? ''}${SEP}${after ?? ''}${SEP}${limit}${SEP}${throwOnError}${SEP}${tailProbe}`;
}
return (
`${channelId}${SEP}${before ?? ''}${SEP}${after ?? ''}${SEP}${limit}${SEP}${throwOnError}${SEP}` +
`${channelId}${SEP}${before ?? ''}${SEP}${after ?? ''}${SEP}${limit}${SEP}${throwOnError}${SEP}${tailProbe}${SEP}` +
`${jump.present ? '1' : '0'}${SEP}${jump.messageId ?? ''}${SEP}${jump.offset ?? 0}${SEP}` +
`${jump.flash ? '1' : '0'}${SEP}${jump.returnToMessageId ?? ''}${SEP}` +
`${jump.returnChannelId ?? ''}${SEP}${jump.returnGuildId ?? ''}${SEP}${jump.jumpType ?? ''}`
@@ -243,7 +260,9 @@ function handleMessageFetchSuccess(
channelId: string,
messages: Array<WireMessage>,
pageState: MessagePageState,
cached: boolean,
jump?: JumpOptions,
tailProbe?: TailProbeContext,
): void {
Messages.handleLoadMessagesSuccess({
channelId,
@@ -252,13 +271,15 @@ function handleMessageFetchSuccess(
isAfter: pageState.isAfter,
hasMoreBefore: pageState.hasMoreBefore,
hasMoreAfter: pageState.hasMoreAfter,
cached: false,
cached,
jump,
tailProbe: tailProbe != null,
});
ReadStates.handleLoadMessages({
channelId,
isAfter: pageState.isAfter,
messages,
tailProbeWatermarkId: tailProbe?.watermarkMessageId ?? null,
});
MessageReferences.handleMessagesFetchSuccess(channelId, messages);
void requestMissingGuildMembers(channelId, messages);
@@ -374,6 +395,30 @@ function applyMessageFetchCacheHit(
}
}
function isTailProbeApplicable(channelId: string, probe: TailProbeEpoch, after: string | null): boolean {
const current = Messages.getMessages(channelId);
const outcome = selectChannelMessagesTailProbeOutcome({
probeGeneration: probe.loadGeneration,
currentGeneration: current.loadGeneration,
probeJumpTicket: probe.jumpTicket,
currentJumpTicket: current.jumpTicket,
ready: current.ready,
hasMoreAfter: current.hasMoreAfter,
anchorMessageId: after,
newestLoadedMessageId: current.last()?.id ?? null,
});
if (outcome === 'apply') {
return true;
}
logger.debug(`Discarding tail probe for channel ${channelId} (${outcome})`);
Messages.handleTailProbeSettled({channelId});
return false;
}
function settleTailProbe(options: FetchMessagesOptions | undefined, settlement: TailProbeSettlement): void {
options?.tailProbe?.onSettled(settlement);
}
export async function fetchMessages(
channelId: string,
before: string | null,
@@ -392,13 +437,16 @@ export async function fetchMessages(
switch (preflightDecision.type) {
case 'useInFlightRequest':
logger.debug(`Using in-flight fetchMessages for channel ${channelId} (deduped)`);
settleTailProbe(options, 'retry');
return inFlight as Promise<Array<WireMessage>>;
case 'blockForGate':
logger.debug(`Skipping message fetch for gated channel ${channelId}`);
Messages.handleLoadMessagesBlocked({channelId});
settleTailProbe(options, 'retry');
return [];
case 'useCache':
applyMessageFetchCacheHit(channelId, preflightDecision.cacheHit, before, after, limit, jump);
settleTailProbe(options, 'retry');
return [];
case 'startFetch':
break;
@@ -409,19 +457,46 @@ export async function fetchMessages(
forceFailure: DeveloperOptions.forceFailMessageLoads,
});
if (executionDecision.type === 'simulateFailure') {
if (options?.tailProbe != null) {
settleTailProbe(options, 'failed');
return [];
}
return handleForcedMessageLoadFailure(channelId, jump);
}
Messages.handleLoadMessages({channelId, jump});
Messages.handleLoadMessages({channelId, jump, tailProbe: options?.tailProbe != null});
let probeEpoch: TailProbeEpoch | null = null;
if (options?.tailProbe) {
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);
handleMessageFetchSuccess(channelId, messages, pageState, cached, jump, options?.tailProbe);
settleTailProbe(options, 'applied');
return messages;
} catch (error) {
logger.error(`Failed to fetch messages for channel ${channelId}:`, error);
if (probeEpoch != null) {
Messages.handleTailProbeSettled({channelId});
settleTailProbe(options, 'failed');
return [];
}
Messages.handleLoadMessagesFailure({channelId});
if (options?.throwOnError) {
throw 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;
}
@@ -13,6 +13,7 @@ import {modal} from '@app/features/ui/commands/ModalCommands';
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
import Users from '@app/features/user/state/Users';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {SAVED_MESSAGES_PAGE_SIZE} from '@fluxer/constants/src/LimitConstants';
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
@@ -31,8 +32,14 @@ interface SaveMessageRequest {
message_id: string;
}
async function requestSavedMessages(): Promise<Array<SavedMessageEntryWire>> {
const response = await http.get<Array<SavedMessageEntryWire>>(Endpoints.USER_SAVED_MESSAGES);
interface SavedMessagesFetchOptions {
before?: string;
}
async function requestSavedMessages(options: SavedMessagesFetchOptions = {}): Promise<Array<SavedMessageEntryWire>> {
const response = await http.get<Array<SavedMessageEntryWire>>(Endpoints.USER_SAVED_MESSAGES, {
query: {limit: SAVED_MESSAGES_PAGE_SIZE, ...options},
});
return response.body ?? [];
}
@@ -78,20 +85,37 @@ function showMaxBookmarksModal(): boolean {
return true;
}
export async function fetch(): Promise<Array<SavedMessageEntry>> {
async function runSavedMessagesFetch(
label: string,
append: boolean,
options: SavedMessagesFetchOptions = {},
): Promise<Array<SavedMessageEntry>> {
const requestId = SavedMessages.handleFetchPending();
try {
logger.debug('Fetching saved messages');
const entries = savedMessageEntries(await requestSavedMessages());
SavedMessages.fetchSuccess(entries);
logger.debug(label);
const entries = savedMessageEntries(await requestSavedMessages(options));
SavedMessages.fetchSuccess(requestId, entries, append);
logger.debug(`Successfully fetched ${entries.length} saved messages`);
return entries;
} catch (error) {
SavedMessages.fetchError();
logger.error('Failed to fetch saved messages:', error);
SavedMessages.fetchError(requestId, append);
logger.error(`${label} failed:`, error);
throw error;
}
}
export async function fetch(): Promise<Array<SavedMessageEntry>> {
return runSavedMessagesFetch('Fetching saved messages', false);
}
export async function loadMore(): Promise<Array<SavedMessageEntry>> {
const before = SavedMessages.getCursor();
if (!before || !SavedMessages.getHasMore() || SavedMessages.getIsLoadingMore()) {
return [];
}
return runSavedMessagesFetch(`Loading more saved messages before ${before}`, true, {before});
}
export async function create(i18n: I18n, channelId: string, messageId: string): Promise<void> {
try {
logger.debug(`Saving message ${messageId} from channel ${channelId}`);
@@ -15,13 +15,14 @@ import {focusChannelTextareaAfterNavigation} from '@app/features/messaging/utils
import {goToMessage} from '@app/features/messaging/utils/MessageNavigator';
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
import {Spinner} from '@app/features/ui/components/Spinner';
import {MessagePreviewContext} from '@fluxer/constants/src/ChannelConstants';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {FlagCheckeredIcon, SparkleIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useMemo, useRef} from 'react';
import {useCallback, useMemo, useRef} from 'react';
const YOU_VE_REACHED_THE_END_DESCRIPTOR = msg({
message: "You've reached the end",
@@ -37,6 +38,9 @@ interface MessageListPageProps {
endStateDescription: string;
renderActionButtons: (message: Message) => React.ReactNode;
renderMissingMessage?: (message: Message) => React.ReactNode;
hasMore?: boolean;
isLoadingMore?: boolean;
onLoadMore?: () => void;
}
export const MessageListPage = observer(
@@ -49,6 +53,9 @@ export const MessageListPage = observer(
endStateDescription,
renderActionButtons,
renderMissingMessage,
hasMore = false,
isLoadingMore = false,
onLoadMore,
}: MessageListPageProps) => {
const {i18n} = useLingui();
const scrollerRef = useRef<ScrollerHandle | null>(null);
@@ -66,6 +73,16 @@ export const MessageListPage = observer(
containerRef: scrollerRef,
allowWhenInactive: true,
});
const handleScroll = useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
const target = event.currentTarget;
const scrollPercentage = (target.scrollTop + target.offsetHeight) / target.scrollHeight;
if (scrollPercentage > 0.8 && hasMore && !isLoadingMore) {
onLoadMore?.();
}
},
[hasMore, isLoadingMore, onLoadMore],
);
return (
<div className={styles.container} data-flx="messaging.message-list-page.container">
<ChannelHeader
@@ -78,6 +95,7 @@ export const MessageListPage = observer(
<NearViewportSurfaceContext.Provider value={resolveListPageScrollSurface}>
<Scroller
className={styles.scroller}
onScroll={handleScroll}
key="message-list-page-scroller"
ref={scrollerRef}
onCopy={onCopySelectedMessages}
@@ -127,25 +145,32 @@ export const MessageListPage = observer(
</div>
);
})}
<div className={styles.endState} data-flx="messaging.message-list-page.end-state">
<div className={styles.endStateContent} data-flx="messaging.message-list-page.end-state-content">
<FlagCheckeredIcon
className={styles.endStateIcon}
data-flx="messaging.message-list-page.end-state-icon"
/>
<div className={styles.endStateText} data-flx="messaging.message-list-page.end-state-text">
<h3 className={styles.endStateTitle} data-flx="messaging.message-list-page.end-state-title">
{i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)}
</h3>
<p
className={styles.endStateDescription}
data-flx="messaging.message-list-page.end-state-description"
>
{endStateDescription}
</p>
{isLoadingMore && (
<div className={previewStyles.loadingState} data-flx="messaging.message-list-page.loading-state">
<Spinner data-flx="messaging.message-list-page.spinner" />
</div>
)}
{!hasMore && !isLoadingMore && (
<div className={styles.endState} data-flx="messaging.message-list-page.end-state">
<div className={styles.endStateContent} data-flx="messaging.message-list-page.end-state-content">
<FlagCheckeredIcon
className={styles.endStateIcon}
data-flx="messaging.message-list-page.end-state-icon"
/>
<div className={styles.endStateText} data-flx="messaging.message-list-page.end-state-text">
<h3 className={styles.endStateTitle} data-flx="messaging.message-list-page.end-state-title">
{i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)}
</h3>
<p
className={styles.endStateDescription}
data-flx="messaging.message-list-page.end-state-description"
>
{endStateDescription}
</p>
</div>
</div>
</div>
</div>
)}
</Scroller>
</NearViewportSurfaceContext.Provider>
) : (
@@ -39,6 +39,8 @@ const THERE_S_NOTHING_MORE_TO_SEE_HERE_DESCRIPTOR = msg({
export const SavedMessagesPage = observer(() => {
const {i18n} = useLingui();
const {savedMessages, missingSavedMessages, fetched} = SavedMessages;
const hasMore = SavedMessages.getHasMore();
const isLoadingMore = SavedMessages.getIsLoadingMore();
useEffect(() => {
if (!fetched) {
SavedMessageCommands.fetch();
@@ -91,6 +93,9 @@ export const SavedMessagesPage = observer(() => {
emptyStateDescription={i18n._(BOOKMARK_MESSAGES_TO_SAVE_THEM_FOR_LATER_DESCRIPTOR)}
endStateDescription={i18n._(THERE_S_NOTHING_MORE_TO_SEE_HERE_DESCRIPTOR)}
renderActionButtons={renderActionButtons}
hasMore={hasMore}
isLoadingMore={isLoadingMore}
onLoadMore={SavedMessageCommands.loadMore}
data-flx="messaging.saved-messages-page.message-list-page"
/>
</div>
@@ -49,6 +49,8 @@ const readonlyBehaviorOverrides = {
export const SavedMessagesContent = observer(() => {
const {i18n} = useLingui();
const {savedMessages, missingSavedMessages, fetched} = SavedMessages;
const hasMore = SavedMessages.getHasMore();
const isLoadingMore = SavedMessages.getIsLoadingMore();
const scrollerRef = useRef<ScrollerHandle | null>(null);
const resolveSavedScrollSurface = useMemo(() => () => scrollerRef.current?.getViewportElement() ?? null, []);
const onCopySelectedMessages = useMessageSelectionCopyForMessages<HTMLDivElement>(savedMessages);
@@ -79,6 +81,16 @@ export const SavedMessagesContent = observer(() => {
goToMessage(channelId, messageId);
InboxCommands.closeInboxAndFocusChannelTextarea(channelId);
}, []);
const handleScroll = useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
const target = event.currentTarget;
const scrollPercentage = (target.scrollTop + target.offsetHeight) / target.scrollHeight;
if (scrollPercentage > 0.8 && hasMore && !isLoadingMore) {
SavedMessageCommands.loadMore();
}
},
[hasMore, isLoadingMore],
);
if (!fetched) {
return (
<div className={previewStyles.emptyState} data-flx="messaging.saved-messages-content.div">
@@ -110,6 +122,7 @@ export const SavedMessagesContent = observer(() => {
<NearViewportSurfaceContext.Provider value={resolveSavedScrollSurface}>
<Scroller
className={styles.scroller}
onScroll={handleScroll}
key="saved-messages-scroller"
ref={scrollerRef}
onCopy={onCopySelectedMessages}
@@ -189,22 +202,29 @@ export const SavedMessagesContent = observer(() => {
</div>
);
})}
<div className={previewStyles.endState} data-flx="messaging.saved-messages-content.div--7">
<div className={previewStyles.endStateContent} data-flx="messaging.saved-messages-content.div--8">
<FlagCheckeredIcon
className={previewStyles.endStateIcon}
data-flx="messaging.saved-messages-content.flag-checkered-icon"
/>
<div className={previewStyles.endStateTextContainer} data-flx="messaging.saved-messages-content.div--9">
<h3 className={previewStyles.endStateTitle} data-flx="messaging.saved-messages-content.h3--2">
{i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)}
</h3>
<p className={previewStyles.endStateDescription} data-flx="messaging.saved-messages-content.p--2">
{i18n._(THERE_S_NOTHING_MORE_TO_SEE_HERE_DESCRIPTOR)}
</p>
{isLoadingMore && (
<div className={previewStyles.loadingState} data-flx="messaging.saved-messages-content.div--10">
<Spinner data-flx="messaging.saved-messages-content.spinner--2" />
</div>
)}
{!hasMore && !isLoadingMore && (
<div className={previewStyles.endState} data-flx="messaging.saved-messages-content.div--7">
<div className={previewStyles.endStateContent} data-flx="messaging.saved-messages-content.div--8">
<FlagCheckeredIcon
className={previewStyles.endStateIcon}
data-flx="messaging.saved-messages-content.flag-checkered-icon"
/>
<div className={previewStyles.endStateTextContainer} data-flx="messaging.saved-messages-content.div--9">
<h3 className={previewStyles.endStateTitle} data-flx="messaging.saved-messages-content.h3--2">
{i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)}
</h3>
<p className={previewStyles.endStateDescription} data-flx="messaging.saved-messages-content.p--2">
{i18n._(THERE_S_NOTHING_MORE_TO_SEE_HERE_DESCRIPTOR)}
</p>
</div>
</div>
</div>
</div>
)}
</Scroller>
</NearViewportSurfaceContext.Provider>
);
@@ -3,8 +3,12 @@
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';
import type {JumpType} from '@fluxer/constants/src/JumpConstants';
import {JumpTypes} from '@fluxer/constants/src/JumpConstants';
@@ -48,6 +52,7 @@ interface LoadCompleteOptions {
hasMoreBefore?: boolean;
hasMoreAfter?: boolean;
cached?: boolean;
tailProbe?: boolean;
}
type MessageInput = Message | WireMessage;
@@ -281,6 +286,8 @@ class MessageBufferSegment {
}
}
let nextLoadGeneration = 0;
export class ChannelMessages {
private static readonly channelCache = new Map<string, ChannelMessages>();
private static readonly maxChannelsInMemory = 50;
@@ -292,6 +299,8 @@ export class ChannelMessages {
jumpDestinationId: string | null = null;
jumpDestinationOffset = 0;
jumpTicket = 1;
loadGeneration = 0;
probeLoading = false;
hasJumped = false;
landedAtLiveEdge = false;
jumpHighlight = true;
@@ -352,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;
@@ -605,9 +618,9 @@ export class ChannelMessages {
}, true);
}
merge(records: Array<Message>, prepend = false, clearBuffer = false): ChannelMessages {
merge(records: Array<Message>, prepend = false, clearBuffer = false, ordered = false): ChannelMessages {
return this.cloneAnd((draft) => {
draft.mergeInto(records, prepend, clearBuffer);
draft.mergeInto(records, prepend, clearBuffer, ordered);
}, true);
}
@@ -822,8 +835,18 @@ export class ChannelMessages {
return this.cloneAnd({ready: true, cached: true}).merge([hydrateMessage(this, wire, 'preserve')]);
}
beginProbeLoad(): ChannelMessages {
return this.cloneAnd({loadGeneration: ++nextLoadGeneration, probeLoading: true});
}
endProbeLoad(): ChannelMessages {
if (!this.probeLoading) return this;
return this.cloneAnd({probeLoading: false});
}
beginLoad(jump?: JumpOptions): ChannelMessages {
return this.cloneAnd({
loadGeneration: ++nextLoadGeneration,
loadingMore: true,
hasJumped: jump != null,
landedAtLiveEdge: jump?.present ?? false,
@@ -845,6 +868,7 @@ export class ChannelMessages {
hasMoreBefore = false,
hasMoreAfter = false,
cached = false,
tailProbe = false,
} = options;
const records = [...windowMessages].reverse().map((m) => hydrateMessage(this, m, 'empty'));
const loadDecision = resolveChannelMessagesLoadDecision({
@@ -862,29 +886,40 @@ export class ChannelMessages {
next = next.merge(unsent);
}
} else {
next = this.merge(records, loadDecision.prepend, true);
if (loadDecision.trimBottom) {
next = this.merge(records, loadDecision.prepend, true, true);
if (!tailProbe && loadDecision.trimBottom) {
next = next.trimToWindow(true, false);
} else if (loadDecision.trimTop) {
} else if (!tailProbe && loadDecision.trimTop) {
next = next.trimToWindow(false, true);
}
}
const jumpPatch = tailProbe
? {}
: {
jumpType: jump?.jumpType ?? JumpTypes.ANIMATED,
jumpHighlight: jump?.flash ?? false,
hasJumped: jump != null,
landedAtLiveEdge: jump?.present ?? false,
jumpDestinationId: jump?.messageId ?? null,
jumpDestinationOffset: jump && jump.messageId != null && jump.offset != null ? jump.offset : 0,
jumpTicket: jump ? next.jumpTicket + 1 : next.jumpTicket,
jumpReturnMessageId: jump?.returnToMessageId ?? null,
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,
jumpType: jump?.jumpType ?? JumpTypes.ANIMATED,
jumpHighlight: jump?.flash ?? false,
hasJumped: jump != null,
landedAtLiveEdge: jump?.present ?? false,
jumpDestinationId: jump?.messageId ?? null,
jumpDestinationOffset: jump && jump.messageId != null && jump.offset != null ? jump.offset : 0,
jumpTicket: jump ? next.jumpTicket + 1 : next.jumpTicket,
jumpReturnMessageId: jump?.returnToMessageId ?? null,
jumpReturnChannelId: jump?.returnToMessageId ? (jump.returnChannelId ?? this.channelId) : null,
jumpReturnGuildId: jump?.returnToMessageId ? (jump.returnGuildId ?? null) : null,
probeLoading: false,
...jumpPatch,
hasMoreBefore: loadDecision.preserveHasMoreBefore ? next.hasMoreBefore : hasMoreBefore,
hasMoreAfter: loadDecision.preserveHasMoreAfter ? next.hasMoreAfter : hasMoreAfter,
cached,
cached: reachesLiveEdge ? cached : next.cached || cached,
error: false,
});
return next;
@@ -914,7 +949,7 @@ export class ChannelMessages {
this.messageIndex = {};
}
private mergeInto(incoming: Array<Message>, prepend = false, clearSideBuffer = false): void {
private mergeInto(incoming: Array<Message>, prepend = false, clearSideBuffer = false, ordered = false): void {
const newItems: Array<Message> = [];
for (const msg of incoming) {
const existing = this.messageIndex[msg.id];
@@ -937,7 +972,11 @@ export class ChannelMessages {
buffer.clear();
}
if (newItems.length === 0) return;
this.messageList = prepend ? newItems.concat(this.messageList) : this.messageList.concat(newItems);
if (prepend) {
this.messageList = newItems.concat(this.messageList);
return;
}
this.messageList = ordered ? mergeAscendingById(this.messageList, newItems) : this.messageList.concat(newItems);
}
private cloneAnd(
@@ -956,6 +995,8 @@ export class ChannelMessages {
clone.jumpDestinationId = this.jumpDestinationId;
clone.jumpDestinationOffset = this.jumpDestinationOffset;
clone.jumpTicket = this.jumpTicket;
clone.loadGeneration = this.loadGeneration;
clone.probeLoading = this.probeLoading;
clone.hasJumped = this.hasJumped;
clone.landedAtLiveEdge = this.landedAtLiveEdge;
clone.jumpHighlight = this.jumpHighlight;
@@ -978,6 +1019,8 @@ export class ChannelMessages {
clone.jumpDestinationOffset =
patch.jumpDestinationOffset !== undefined ? patch.jumpDestinationOffset : this.jumpDestinationOffset;
clone.jumpTicket = patch.jumpTicket !== undefined ? patch.jumpTicket : this.jumpTicket;
clone.loadGeneration = patch.loadGeneration !== undefined ? patch.loadGeneration : this.loadGeneration;
clone.probeLoading = 'probeLoading' in patch ? !!patch.probeLoading : this.probeLoading;
clone.hasJumped = 'hasJumped' in patch ? !!patch.hasJumped : this.hasJumped;
clone.landedAtLiveEdge = 'landedAtLiveEdge' in patch ? !!patch.landedAtLiveEdge : this.landedAtLiveEdge;
clone.jumpHighlight = 'jumpHighlight' in patch ? !!patch.jumpHighlight : this.jumpHighlight;
@@ -11,7 +11,11 @@ import {
resolveChannelMessagesWindowStatus,
selectChannelMessagesFillerVisible,
selectChannelMessagesLoadDecision,
selectChannelMessagesLoadRestoresTrust,
selectChannelMessagesSpacerHeight,
selectChannelMessagesTailGapId,
selectChannelMessagesTailProbeId,
selectChannelMessagesTailProbeOutcome,
selectChannelMessagesWindowBar,
selectChannelMessagesWindowStatus,
transitionChannelMessagesLoadSnapshot,
@@ -389,3 +393,149 @@ describe('selectChannelMessagesFillerVisible', () => {
expect(streamCases).toBeGreaterThan(0);
});
});
describe('selectChannelMessagesTailProbeId', () => {
const ID = {older: '1519773906704011264', newer: '1519773906708205568'};
function tailInput(
overrides: Partial<ChannelMessagesWindowInput> = {},
tail: {
loading?: boolean;
newestLoadedMessageId?: string | null;
knownLatestMessageId?: string | null;
} = {},
) {
const windowInput: ChannelMessagesWindowInput = {
ready: true,
loading: false,
failed: false,
messageCount: 12,
hasMoreBefore: true,
hasMoreAfter: false,
...overrides,
};
return {
status: resolveChannelMessagesWindowStatus(windowInput),
loading: tail.loading ?? windowInput.loading,
newestLoadedMessageId: tail.newestLoadedMessageId === undefined ? ID.older : tail.newestLoadedMessageId,
knownLatestMessageId: tail.knownLatestMessageId === undefined ? ID.newer : tail.knownLatestMessageId,
};
}
it('probes from the newest loaded message when the live edge is known to be ahead', () => {
expect(selectChannelMessagesTailProbeId(tailInput())).toBe(ID.older);
});
it('stays silent for a window that already advertises a newer page', () => {
expect(selectChannelMessagesTailProbeId(tailInput({hasMoreAfter: true}))).toBeNull();
});
it('stays silent for a failed window so a failed probe cannot re-arm itself', () => {
expect(selectChannelMessagesTailProbeId(tailInput({failed: true}))).toBeNull();
});
it('stays silent while a load is in flight', () => {
expect(selectChannelMessagesTailProbeId(tailInput({loading: true}))).toBeNull();
});
it('stays silent for a window that has not loaded a page yet', () => {
expect(selectChannelMessagesTailProbeId(tailInput({ready: false}))).toBeNull();
});
it('never probes an empty window, so a channel with no readable history cannot arm it', () => {
expect(
selectChannelMessagesTailProbeId(
tailInput({messageCount: 0, hasMoreBefore: false}, {newestLoadedMessageId: null}),
),
).toBeNull();
});
it('stays silent when the watermark matches or trails the newest loaded message', () => {
expect(selectChannelMessagesTailProbeId(tailInput({}, {knownLatestMessageId: ID.older}))).toBeNull();
expect(
selectChannelMessagesTailProbeId(
tailInput({}, {newestLoadedMessageId: ID.newer, knownLatestMessageId: ID.older}),
),
).toBeNull();
expect(selectChannelMessagesTailProbeId(tailInput({}, {knownLatestMessageId: null}))).toBeNull();
});
it('keeps reporting the tail gap while the probe it armed is in flight', () => {
const inFlight = tailInput({loading: true});
expect(selectChannelMessagesTailProbeId(inFlight)).toBeNull();
expect(selectChannelMessagesTailGapId(inFlight)).toBe(ID.older);
});
it('reports no tail gap once the newest loaded message reaches the watermark', () => {
expect(selectChannelMessagesTailGapId(tailInput({}, {newestLoadedMessageId: ID.newer}))).toBeNull();
});
it('still reports the gap on a deep window so auto-ack cannot run past it', () => {
expect(selectChannelMessagesTailGapId(tailInput({messageCount: 200}))).toBe(ID.older);
expect(selectChannelMessagesTailProbeId(tailInput({messageCount: 200}))).toBe(ID.older);
});
});
describe('selectChannelMessagesTailProbeOutcome', () => {
const ID = {older: '1519773906704011264', newer: '1519773906708205568'};
function outcomeInput(overrides: Partial<Parameters<typeof selectChannelMessagesTailProbeOutcome>[0]> = {}) {
return {
probeGeneration: 4,
currentGeneration: 4,
probeJumpTicket: 2,
currentJumpTicket: 2,
ready: true,
hasMoreAfter: false,
anchorMessageId: ID.older,
newestLoadedMessageId: ID.older,
...overrides,
};
}
it('applies a probe onto the window it was issued against', () => {
expect(selectChannelMessagesTailProbeOutcome(outcomeInput())).toBe('apply');
});
it('discards without touching shared state once another load owns the window', () => {
expect(selectChannelMessagesTailProbeOutcome(outcomeInput({currentGeneration: 5}))).toBe('discard');
});
it('releases a probe whose window a jump has taken out of the ready state', () => {
expect(selectChannelMessagesTailProbeOutcome(outcomeInput({ready: false}))).toBe('release');
expect(selectChannelMessagesTailProbeOutcome(outcomeInput({currentGeneration: 9, ready: false}))).toBe('discard');
});
it('releases a probe whose tail moved under it', () => {
expect(selectChannelMessagesTailProbeOutcome(outcomeInput({newestLoadedMessageId: ID.newer}))).toBe('release');
expect(selectChannelMessagesTailProbeOutcome(outcomeInput({newestLoadedMessageId: null}))).toBe('release');
});
it('releases a probe whose window started advertising a newer page', () => {
expect(selectChannelMessagesTailProbeOutcome(outcomeInput({hasMoreAfter: true}))).toBe('release');
});
it('discards a probe whose window a cached jump took over without starting a load', () => {
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,
);
});
});
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {compare as compareSnowflakes} from '@fluxer/snowflake/src/SnowflakeUtils';
import {assign, getInitialSnapshot, type SnapshotFrom, setup, transition} from 'xstate';
export interface ChannelMessagesLoadInput {
@@ -281,6 +282,56 @@ export function selectChannelMessagesSpacerHeight(status: ChannelMessagesWindowS
return status.olderPageAvailable || status.newerPageAvailable ? fillerHeight : 0;
}
export interface ChannelMessagesTailInput {
status: ChannelMessagesWindowStatus;
loading: boolean;
newestLoadedMessageId: string | null;
knownLatestMessageId: string | null;
}
export function selectChannelMessagesTailGapId(input: ChannelMessagesTailInput): string | null {
if (input.status.phase !== 'stream' || input.status.retryVisible || input.status.newerPageAvailable) return null;
if (input.newestLoadedMessageId == null || input.knownLatestMessageId == null) return null;
if (compareSnowflakes(input.knownLatestMessageId, input.newestLoadedMessageId) <= 0) return null;
return input.newestLoadedMessageId;
}
export function selectChannelMessagesTailProbeId(input: ChannelMessagesTailInput): string | null {
if (input.loading) return null;
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;
probeJumpTicket: number;
currentJumpTicket: number;
ready: boolean;
hasMoreAfter: boolean;
anchorMessageId: string | null;
newestLoadedMessageId: string | null;
}
export type ChannelMessagesTailProbeOutcome = 'apply' | 'release' | 'discard';
export function selectChannelMessagesTailProbeOutcome(
input: ChannelMessagesTailProbeResultInput,
): ChannelMessagesTailProbeOutcome {
if (input.currentGeneration !== input.probeGeneration) return 'discard';
if (input.currentJumpTicket !== input.probeJumpTicket) return 'discard';
if (!input.ready || input.hasMoreAfter || input.newestLoadedMessageId !== input.anchorMessageId) return 'release';
return 'apply';
}
export function selectChannelMessagesWindowBar(status: ChannelMessagesWindowStatus): ChannelMessagesWindowBar {
if (status.phase === 'placeholder') return 'none';
if (status.phase === 'retry' || status.retryVisible) return 'retry';
@@ -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;
}
@@ -449,9 +443,9 @@ class Messages {
}
@action
handleLoadMessages(action: {channelId: string; jump?: JumpOptions}): boolean {
handleLoadMessages(action: {channelId: string; jump?: JumpOptions; tailProbe?: boolean}): boolean {
const messages = ChannelMessages.getOrCreate(action.channelId);
this.commitMessages(messages.beginLoad(action.jump));
this.commitMessages(action.tailProbe ? messages.beginProbeLoad() : messages.beginLoad(action.jump));
this.notifyChange();
return false;
}
@@ -505,6 +499,7 @@ class Messages {
hasMoreBefore?: boolean;
hasMoreAfter?: boolean;
cached?: boolean;
tailProbe?: boolean;
messages: Array<WireMessage>;
}): boolean {
const messages = ChannelMessages.getOrCreate(action.channelId).applyLoadedWindow({
@@ -515,6 +510,7 @@ class Messages {
hasMoreBefore: action.hasMoreBefore,
hasMoreAfter: action.hasMoreAfter,
cached: action.cached,
tailProbe: action.tailProbe,
});
this.commitMessages(messages);
this.notifyChange();
@@ -529,6 +525,15 @@ class Messages {
return false;
}
@action
handleTailProbeSettled(action: {channelId: string}): boolean {
const messages = ChannelMessages.get(action.channelId);
if (!messages?.probeLoading) return false;
this.commitMessages(messages.endProbeLoad());
this.notifyChange();
return true;
}
@action
handleLoadMessagesBlocked(action: {channelId: string}): boolean {
const messages = ChannelMessages.getOrCreate(action.channelId);
@@ -0,0 +1,80 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {SavedMessageEntry} from '@app/features/messaging/models/SavedMessageEntry';
import SavedMessages from '@app/features/messaging/state/SavedMessages';
import {SAVED_MESSAGES_PAGE_SIZE} from '@fluxer/constants/src/LimitConstants';
import {beforeEach, describe, expect, it, vi} from 'vitest';
vi.mock('@app/features/messaging/models/MessagingMessage', () => ({
Message: class {
readonly id: string;
constructor(data: {id: string}) {
this.id = data.id;
}
},
}));
function entryPage(startId: number, count: number): Array<SavedMessageEntry> {
return Array.from({length: count}, (_unused, index) =>
SavedMessageEntry.fromResponse({
id: String(startId - index),
channel_id: '10',
message_id: String(startId - index),
status: 'missing_permissions',
message: null,
}),
);
}
describe('SavedMessages pagination', () => {
beforeEach(() => {
SavedMessages.handleGatewayReady();
});
it('reports more pages while a full page comes back and tracks the oldest entry', () => {
const requestId = SavedMessages.handleFetchPending();
SavedMessages.fetchSuccess(requestId, entryPage(1000, SAVED_MESSAGES_PAGE_SIZE), false);
expect(SavedMessages.getHasMore()).toBe(true);
expect(SavedMessages.getIsLoadingMore()).toBe(false);
expect(SavedMessages.getCursor()).toBe(String(1000 - (SAVED_MESSAGES_PAGE_SIZE - 1)));
});
it('appends the next page instead of replacing the loaded one', () => {
const first = SavedMessages.handleFetchPending();
SavedMessages.fetchSuccess(first, entryPage(1000, SAVED_MESSAGES_PAGE_SIZE), false);
const second = SavedMessages.handleFetchPending();
SavedMessages.fetchSuccess(second, entryPage(900, 10), true);
expect(SavedMessages.getMissingEntries()).toHaveLength(SAVED_MESSAGES_PAGE_SIZE + 10);
expect(SavedMessages.getHasMore()).toBe(false);
expect(SavedMessages.getCursor()).toBe('891');
});
it('ignores a page that a newer request has superseded', () => {
const stale = SavedMessages.handleFetchPending();
SavedMessages.handleFetchPending();
SavedMessages.fetchSuccess(stale, entryPage(1000, 5), false);
expect(SavedMessages.fetched).toBe(false);
expect(SavedMessages.getMissingEntries()).toHaveLength(0);
});
it('keeps the loaded pages when loading more fails', () => {
const first = SavedMessages.handleFetchPending();
SavedMessages.fetchSuccess(first, entryPage(1000, SAVED_MESSAGES_PAGE_SIZE), false);
const second = SavedMessages.handleFetchPending();
SavedMessages.fetchError(second, true);
expect(SavedMessages.getMissingEntries()).toHaveLength(SAVED_MESSAGES_PAGE_SIZE);
expect(SavedMessages.getIsLoadingMore()).toBe(false);
expect(SavedMessages.fetched).toBe(true);
});
it('clears everything when the first page fails', () => {
const first = SavedMessages.handleFetchPending();
SavedMessages.fetchSuccess(first, entryPage(1000, 5), false);
const retry = SavedMessages.handleFetchPending();
SavedMessages.fetchError(retry, false);
expect(SavedMessages.getMissingEntries()).toHaveLength(0);
expect(SavedMessages.fetched).toBe(false);
expect(SavedMessages.getCursor()).toBeNull();
expect(SavedMessages.getHasMore()).toBe(true);
});
});
@@ -2,14 +2,23 @@
import {Message} from '@app/features/messaging/models/MessagingMessage';
import type {SavedMessageEntry, SavedMessageMissingEntry} from '@app/features/messaging/models/SavedMessageEntry';
import {SAVED_MESSAGES_PAGE_SIZE} from '@fluxer/constants/src/LimitConstants';
import type {Channel} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {Message as WireMessage} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {makeAutoObservable} from 'mobx';
function byIdDescending(a: {id: string}, b: {id: string}): number {
return b.id > a.id ? 1 : a.id > b.id ? -1 : 0;
}
class SavedMessages {
savedMessages: Array<Message> = [];
missingSavedMessages: Array<SavedMessageMissingEntry> = [];
fetched = false;
hasMore = true;
isLoadingMore = false;
cursor: string | null = null;
private fetchGeneration = 0;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
@@ -26,27 +35,74 @@ class SavedMessages {
return this.missingSavedMessages.slice();
}
fetchSuccess(entries: ReadonlyArray<SavedMessageEntry>): void {
this.savedMessages = entries
getHasMore(): boolean {
return this.hasMore;
}
getIsLoadingMore(): boolean {
return this.isLoadingMore;
}
getCursor(): string | null {
return this.cursor;
}
handleFetchPending(): number {
this.isLoadingMore = true;
this.fetchGeneration++;
return this.fetchGeneration;
}
fetchSuccess(requestId: number, entries: ReadonlyArray<SavedMessageEntry>, append: boolean): void {
if (requestId !== this.fetchGeneration) return;
const available = entries
.filter((entry) => entry.status === 'available' && entry.message)
.map((entry) => entry.message!)
.sort((a, b) => (b.id > a.id ? 1 : a.id > b.id ? -1 : 0));
this.missingSavedMessages = entries
.map((entry) => entry.message!);
const missing = entries
.filter((entry) => entry.status === 'missing_permissions' || entry.message === null)
.map((entry) => entry.toMissingEntry());
if (append) {
const knownMessageIds = new Set(this.savedMessages.map((message) => message.id));
const knownMissingIds = new Set(this.missingSavedMessages.map((entry) => entry.id));
this.savedMessages = [
...this.savedMessages,
...available.filter((message) => !knownMessageIds.has(message.id)),
].sort(byIdDescending);
this.missingSavedMessages = [
...this.missingSavedMessages,
...missing.filter((entry) => !knownMissingIds.has(entry.id)),
];
} else {
this.savedMessages = available.sort(byIdDescending);
this.missingSavedMessages = missing;
}
if (entries.length > 0) {
this.cursor = entries[entries.length - 1].messageId;
}
this.hasMore = entries.length === SAVED_MESSAGES_PAGE_SIZE;
this.isLoadingMore = false;
this.fetched = true;
}
fetchError(): void {
fetchError(requestId: number, append: boolean): void {
if (requestId !== this.fetchGeneration) return;
this.isLoadingMore = false;
if (append) return;
this.reset();
}
private reset(): void {
this.savedMessages = [];
this.missingSavedMessages = [];
this.fetched = false;
this.hasMore = true;
this.isLoadingMore = false;
this.cursor = null;
}
handleGatewayReady(): void {
this.savedMessages = [];
this.missingSavedMessages = [];
this.fetched = false;
this.reset();
this.fetchGeneration++;
}
handleChannelDelete(channel: Channel): void {
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {calculateAroundPaginationState, getAroundWindowCounts} from './MessagePaginationUtils';
import {calculateAroundPaginationState, getAroundWindowCounts, mergeAscendingById} from './MessagePaginationUtils';
describe('MessagePaginationUtils', () => {
it('splits around windows with the newer side receiving the extra item for even limits', () => {
@@ -42,3 +42,30 @@ describe('MessagePaginationUtils', () => {
expect(state.hasMoreAfter).toBe(false);
});
});
describe('mergeAscendingById', () => {
const ID = {a: '1519773906704011264', b: '1519773906708205568', c: '1519773906712399872'};
it('appends when every incoming message is newer than the tail', () => {
const existing = [{id: ID.a}];
expect(mergeAscendingById(existing, [{id: ID.b}, {id: ID.c}])).toEqual([{id: ID.a}, {id: ID.b}, {id: ID.c}]);
});
it('returns the existing list untouched for an empty page', () => {
const existing = [{id: ID.a}];
expect(mergeAscendingById(existing, [])).toBe(existing);
});
it('interleaves a recovered message that is older than a live message already at the tail', () => {
expect(mergeAscendingById([{id: ID.a}, {id: ID.c}], [{id: ID.b}])).toEqual([{id: ID.a}, {id: ID.b}, {id: ID.c}]);
});
it('keeps the whole page in order when it spans a live message already at the tail', () => {
expect(mergeAscendingById([{id: ID.a}, {id: ID.b}], [{id: ID.c}])).toEqual([{id: ID.a}, {id: ID.b}, {id: ID.c}]);
expect(mergeAscendingById([{id: ID.b}], [{id: ID.a}, {id: ID.c}])).toEqual([{id: ID.a}, {id: ID.b}, {id: ID.c}]);
});
it('appends onto an empty window', () => {
expect(mergeAscendingById([], [{id: ID.a}])).toEqual([{id: ID.a}]);
});
});
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {compare as compareSnowflakes} from '@fluxer/snowflake/src/SnowflakeUtils';
export interface AroundWindowCounts {
newer: number;
older: number;
@@ -49,3 +51,32 @@ export function calculateAroundPaginationState(input: AroundPaginationStateInput
isAtKnownLatest,
};
}
export function mergeAscendingById<T extends {id: string}>(existing: Array<T>, incoming: Array<T>): Array<T> {
if (incoming.length === 0) return existing;
const tail = existing[existing.length - 1];
if (tail == null || compareSnowflakes(incoming[0].id, tail.id) > 0) {
return existing.concat(incoming);
}
const merged: Array<T> = [];
let left = 0;
let right = 0;
while (left < existing.length && right < incoming.length) {
if (compareSnowflakes(incoming[right].id, existing[left].id) < 0) {
merged.push(incoming[right]);
right += 1;
} else {
merged.push(existing[left]);
left += 1;
}
}
while (left < existing.length) {
merged.push(existing[left]);
left += 1;
}
while (right < incoming.length) {
merged.push(incoming[right]);
right += 1;
}
return merged;
}
@@ -92,14 +92,64 @@ 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', () => {
const {channelId, state} = seedReadChannel();
state.lastMessageId = ID.newer;
loadedMessages.push({id: ID.ack, author: {id: 'someone'}});
ReadStates.handleLoadMessages({channelId, isAfter: true, messages: [], tailProbeWatermarkId: ID.newer});
expect(ReadStates.lastMessageId(channelId)).toBe(ID.ack);
expect(ReadStates.hasUnread(channelId)).toBe(false);
});
it('keeps a watermark that is ahead when an ordinary after page comes back empty', () => {
const {channelId, state} = seedReadChannel();
state.lastMessageId = ID.newer;
loadedMessages.push({id: ID.ack, author: {id: 'someone'}});
ReadStates.handleLoadMessages({channelId, isAfter: true, messages: []});
expect(ReadStates.lastMessageId(channelId)).toBe(ID.newer);
});
it('keeps a watermark that advanced while its own probe was in flight', () => {
const {channelId, state} = seedReadChannel();
state.lastMessageId = ID.newer;
loadedMessages.push({id: ID.ack, author: {id: 'someone'}});
ReadStates.handleLoadMessages({channelId, isAfter: true, messages: [], tailProbeWatermarkId: ID.ack});
expect(ReadStates.lastMessageId(channelId)).toBe(ID.newer);
});
it('keeps a watermark that is ahead when the page was not an after page', () => {
const {channelId, state} = seedReadChannel();
state.lastMessageId = ID.newer;
loadedMessages.push({id: ID.ack, author: {id: 'someone'}});
ReadStates.handleLoadMessages({channelId, messages: [], tailProbeWatermarkId: ID.newer});
expect(ReadStates.lastMessageId(channelId)).toBe(ID.newer);
});
it('keeps a watermark that is ahead when the window is not at the live edge', () => {
const {channelId, state} = seedReadChannel();
hasNewestMessages = false;
state.lastMessageId = ID.newer;
loadedMessages.push({id: ID.ack, author: {id: 'someone'}});
ReadStates.handleLoadMessages({channelId, isAfter: true, messages: [], tailProbeWatermarkId: ID.newer});
expect(ReadStates.lastMessageId(channelId)).toBe(ID.newer);
});
it('anchors the divider when a window is loaded whose ack sits outside it', () => {
@@ -439,7 +439,12 @@ class ReadStates {
});
}
handleLoadMessages(action: {channelId: string; isAfter?: boolean; messages: Array<WireMessage>}): void {
handleLoadMessages(action: {
channelId: string;
isAfter?: boolean;
messages: Array<WireMessage>;
tailProbeWatermarkId?: string | null;
}): void {
const state = this.get(action.channelId);
state.messagesLoaded = true;
const messages = Messages.getMessages(action.channelId);
@@ -448,6 +453,17 @@ class ReadStates {
state.lastMessageId = newestMessage.id;
}
const landedOnNewestWindow = messages.hasNewestMessages();
if (
action.isAfter &&
action.tailProbeWatermarkId != null &&
action.tailProbeWatermarkId === state.lastMessageId &&
action.messages.length === 0 &&
landedOnNewestWindow &&
newestMessage != null &&
isNewerMessageId(state.lastMessageId, newestMessage.id)
) {
state.lastMessageId = newestMessage.id;
}
const landedOnAck = state.ackMessageId != null && messages.jumpDestinationId === state.ackMessageId;
if (state.hasUnread() || landedOnNewestWindow || landedOnAck) {
state.rebuild();
@@ -583,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);
@@ -428,6 +428,7 @@
max-inline-size: none;
overflow: visible;
white-space: nowrap;
unicode-bidi: plaintext;
user-select: text;
-webkit-user-select: text;
}
@@ -505,6 +506,7 @@
font-weight: 400;
color: var(--message-timestamp-color);
line-height: var(--message-line-height);
unicode-bidi: isolate;
}
.messageTimestamp {
@@ -557,6 +559,7 @@
font-size: var(--message-timestamp-compact-font-size);
font-weight: 500;
line-height: var(--message-line-height);
unicode-bidi: isolate;
}
.messageTimestampHover {
@@ -948,6 +951,7 @@
white-space: nowrap;
max-width: 30%;
vertical-align: baseline;
unicode-bidi: plaintext;
font-weight: 500;
color: color-mix(
in srgb,
@@ -0,0 +1,58 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {readFileSync} from 'node:fs';
import {fileURLToPath} from 'node:url';
import {describe, expect, it} from 'vitest';
const MESSAGE_CSS = readFileSync(fileURLToPath(new URL('./Message.module.css', import.meta.url)), 'utf8');
const ISOLATED_NAMES = ['.messageUsername', '.repliedUsername'];
const ISOLATED_TIMESTAMPS = [
'.messageTimestamp',
'.messageTimestampCompact',
'.messageTimestampHover',
'.messageTimestampCompactHover',
];
function rules(css: string): Array<{selector: string; declarations: string}> {
const source = css.replace(/\/\*[\s\S]*?\*\//g, '');
const out: Array<{selector: string; declarations: string}> = [];
const chain: Array<string> = [];
let buffer = '';
for (const char of source) {
if (char === '{') {
chain.push(buffer.split(/[;}]/).pop()?.trim().replace(/\s+/g, ' ') ?? '');
buffer = '';
} else if (char === '}') {
if (chain.length > 0) {
out.push({selector: chain.join(' '), declarations: buffer});
chain.pop();
}
buffer = '';
} else {
buffer += char;
}
}
return out;
}
function declaredBidi(className: string): Array<string> {
const selectorPattern = new RegExp(`\\${className}(?![\\w-])`);
const out: Array<string> = [];
for (const rule of rules(MESSAGE_CSS)) {
if (!rule.selector.split(',').some((selector) => selectorPattern.test(selector))) continue;
const match = rule.declarations.match(/unicode-bidi\s*:\s*([^;]+)/);
if (match) out.push(match[1].trim());
}
return out;
}
describe('message bidi isolation', () => {
it.each(ISOLATED_NAMES)('resolves %s in its own bidi paragraph', (className) => {
expect(declaredBidi(className)).toContain('plaintext');
});
it.each(ISOLATED_TIMESTAMPS)('keeps %s out of a neighbouring right-to-left run', (className) => {
expect(declaredBidi(className)).toContain('isolate');
});
});
@@ -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) {
@@ -0,0 +1,23 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {scopePortalHostToDocument} from '@app/features/ui/overlay/PortalHostContext';
import {describe, expect, it} from 'vitest';
describe('scopePortalHostToDocument', () => {
it('keeps a host that belongs to the scoped document', () => {
const host = document.createElement('div');
expect(scopePortalHostToDocument(host, document)).toBe(host);
});
it('drops a host owned by another document', () => {
const popoutDocument = document.implementation.createHTMLDocument('popout');
const popoutHost = popoutDocument.createElement('div');
expect(scopePortalHostToDocument(popoutHost, document)).toBeNull();
expect(scopePortalHostToDocument(popoutHost, popoutDocument)).toBe(popoutHost);
});
it('passes a missing host through', () => {
expect(scopePortalHostToDocument(null, document)).toBeNull();
});
});
@@ -39,3 +39,8 @@ export function usePortalHost(): PortalHostElement {
export function resolvePortalHost(host: PortalHostElement): HTMLElement {
return host ?? document.body;
}
export function scopePortalHostToDocument(host: PortalHostElement, scopeDocument: Document): PortalHostElement {
if (host == null) return null;
return host.ownerDocument === scopeDocument ? host : null;
}
@@ -4,7 +4,7 @@ import Accessibility from '@app/features/accessibility/state/Accessibility';
import {useAntiShiftFloating} from '@app/features/app/hooks/useAntiShiftFloating';
import {shouldDisableAutofocusOnMobile} from '@app/features/platform/utils/AutofocusUtils';
import * as PopoutCommands from '@app/features/ui/commands/PopoutCommands';
import {usePortalHost} from '@app/features/ui/overlay/PortalHostContext';
import {scopePortalHostToDocument, usePortalHost} from '@app/features/ui/overlay/PortalHostContext';
import {type Popout, PopoutKeyContext, type PopoutReferenceRect} from '@app/features/ui/popover';
import {PopoutResizePositionContext} from '@app/features/ui/popover/PopoutResizePositionContext';
import {getPopoutFocusManagerInsideElements} from '@app/features/ui/popover/PopoverFocusManagerUtils';
@@ -186,6 +186,7 @@ const PopoutItem: React.FC<PopoutItemProps> = observer(
);
const {
ref: popoutRef,
setFloating,
state,
style,
beginManualPositioning,
@@ -206,7 +207,7 @@ const PopoutItem: React.FC<PopoutItemProps> = observer(
useLayoutEffect(() => {
focusRefs.setReference(target);
}, [focusRefs, target]);
const mergedPopoutRef = useMergeRefs([popoutRef, focusRefs.setFloating]);
const mergedPopoutRef = useMergeRefs([setFloating, focusRefs.setFloating]);
const prefersReducedMotion = Accessibility.useReducedMotion;
const [isVisible, setIsVisible] = useState(true);
const [targetInDOM, setTargetInDOM] = useState(() => ownerDocument.contains(target));
@@ -426,10 +427,11 @@ interface PopoutsProps {
export const Popouts: React.FC<PopoutsProps> = observer(({ownerDocument}) => {
const prevPopoutKeysRef = useRef<Set<string>>(new Set());
const portalHost = usePortalHost();
const activePortalHost = usePortalHost();
let scopeDocument = document;
if (portalHost != null) scopeDocument = portalHost.ownerDocument;
if (activePortalHost != null) scopeDocument = activePortalHost.ownerDocument;
if (ownerDocument != null) scopeDocument = ownerDocument;
const portalHost = scopePortalHostToDocument(activePortalHost, scopeDocument);
const popouts = PopoutState.getPopouts(scopeDocument);
const topPopout = popouts.length ? popouts[popouts.length - 1] : null;
const needsBackdrop = Boolean(topPopout && !topPopout.disableBackdrop);
@@ -0,0 +1,99 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const IDLE_DURATION_MS = 600_000;
const IDLE_CHECK_INTERVAL_MS = 30_000;
vi.mock('@app/features/platform/types/Env', () => ({
IS_DEV: false,
IS_PROD: true,
MODE: 'test',
}));
vi.mock('@app/features/presence/state/LocalPresence', () => ({
default: {updatePresence: vi.fn()},
}));
async function loadIdle(getSystemIdleTimeMs?: () => Promise<number>) {
vi.resetModules();
if (getSystemIdleTimeMs) {
Object.defineProperty(window, 'electron', {value: {getSystemIdleTimeMs}, configurable: true, writable: true});
} else {
Reflect.deleteProperty(window as unknown as Record<string, unknown>, 'electron');
}
const {default: Idle} = await import('@app/features/ui/state/Idle');
return Idle;
}
describe('Idle', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
Reflect.deleteProperty(window as unknown as Record<string, unknown>, 'electron');
vi.useRealTimers();
});
it('goes idle once nothing has reported activity for the idle duration', async () => {
const Idle = await loadIdle();
await vi.advanceTimersByTimeAsync(IDLE_DURATION_MS - IDLE_CHECK_INTERVAL_MS);
expect(Idle.isIdle()).toBe(false);
await vi.advanceTimersByTimeAsync(IDLE_CHECK_INTERVAL_MS);
expect(Idle.isIdle()).toBe(true);
});
it('stays active while the app keeps seeing input', async () => {
const Idle = await loadIdle();
for (let elapsed = 0; elapsed < IDLE_DURATION_MS * 2; elapsed += IDLE_CHECK_INTERVAL_MS) {
Idle.recordActivity();
await vi.advanceTimersByTimeAsync(IDLE_CHECK_INTERVAL_MS);
}
expect(Idle.isIdle()).toBe(false);
});
it('stays active while the app sees input even when the system idle clock keeps climbing', async () => {
const startedAt = Date.now();
const Idle = await loadIdle(async () => Date.now() - startedAt);
for (let elapsed = 0; elapsed < IDLE_DURATION_MS * 2; elapsed += IDLE_CHECK_INTERVAL_MS) {
Idle.recordActivity();
await vi.advanceTimersByTimeAsync(IDLE_CHECK_INTERVAL_MS);
}
expect(Idle.isIdle()).toBe(false);
});
it('leaves idle as soon as the app sees input again', async () => {
const startedAt = Date.now();
const Idle = await loadIdle(async () => Date.now() - startedAt);
await vi.advanceTimersByTimeAsync(IDLE_DURATION_MS);
expect(Idle.isIdle()).toBe(true);
Idle.recordActivity();
expect(Idle.isIdle()).toBe(false);
await vi.advanceTimersByTimeAsync(IDLE_CHECK_INTERVAL_MS);
expect(Idle.isIdle()).toBe(false);
});
it('stays active while the system reports input the app never sees', async () => {
const Idle = await loadIdle(async () => 0);
await vi.advanceTimersByTimeAsync(IDLE_DURATION_MS * 2);
expect(Idle.isIdle()).toBe(false);
});
it('goes idle when neither the app nor the system reports activity', async () => {
const startedAt = Date.now();
const Idle = await loadIdle(async () => Date.now() - startedAt);
await vi.advanceTimersByTimeAsync(IDLE_DURATION_MS);
expect(Idle.isIdle()).toBe(true);
});
it('falls back to app activity when the system idle clock is unreadable', async () => {
const Idle = await loadIdle(async () => Number.NaN);
await vi.advanceTimersByTimeAsync(IDLE_DURATION_MS);
expect(Idle.isIdle()).toBe(true);
Idle.recordActivity();
await vi.advanceTimersByTimeAsync(IDLE_CHECK_INTERVAL_MS);
expect(Idle.isIdle()).toBe(false);
});
});
+19 -21
View File
@@ -30,11 +30,11 @@ function normalizeIdleTimeMs(value: number): number | null {
class Idle {
idle = false;
private lastActivityTime = Date.now();
private lastLocalActivityTime = Date.now();
private lastSystemActivityTime = 0;
private checkInterval: NodeJS.Timeout | null = null;
private systemIdleCheckInFlight = false;
private lastSystemIdleFailureAt = 0;
private activityVersion = 0;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
@@ -56,16 +56,15 @@ class Idle {
}
recordActivity(): void {
this.lastActivityTime = Date.now();
this.activityVersion++;
this.lastLocalActivityTime = Date.now();
if (this.idle) {
this.applyIdleState(false);
}
}
markBackground(): void {
this.lastActivityTime = 0;
this.activityVersion++;
this.lastLocalActivityTime = 0;
this.lastSystemActivityTime = 0;
this.applyIdleState(true);
}
@@ -74,16 +73,24 @@ class Idle {
}
getIdleSince(): number {
return this.idle ? this.lastActivityTime : 0;
return this.idle ? this.getLastActivityTime() : 0;
}
getInactiveDurationMs(now = Date.now()): number {
return Math.max(0, now - this.lastActivityTime);
return Math.max(0, now - this.getLastActivityTime());
}
private getLastActivityTime(): number {
return Math.max(this.lastLocalActivityTime, this.lastSystemActivityTime);
}
private updateIdleState(): void {
const desktopIdleApi = getDesktopIdleApi();
if (desktopIdleApi && Date.now() - this.lastSystemIdleFailureAt >= SYSTEM_IDLE_RETRY_DELAY_MS) {
if (
desktopIdleApi &&
!this.systemIdleCheckInFlight &&
Date.now() - this.lastSystemIdleFailureAt >= SYSTEM_IDLE_RETRY_DELAY_MS
) {
void this.updateIdleStateFromSystem(desktopIdleApi);
return;
}
@@ -96,29 +103,20 @@ class Idle {
}
private async updateIdleStateFromSystem(desktopIdleApi: Required<DesktopIdleApi>): Promise<void> {
if (this.systemIdleCheckInFlight) return;
this.systemIdleCheckInFlight = true;
const activityVersion = this.activityVersion;
const requestedAt = Date.now();
try {
const idleTimeMs = normalizeIdleTimeMs(await desktopIdleApi.getSystemIdleTimeMs());
if (idleTimeMs === null) {
this.lastSystemIdleFailureAt = Date.now();
this.updateIdleStateFromLocalActivity();
return;
} else {
this.lastSystemActivityTime = Math.max(this.lastSystemActivityTime, Date.now() - idleTimeMs);
}
if (activityVersion !== this.activityVersion && this.lastActivityTime >= requestedAt) {
return;
}
const now = Date.now();
this.lastActivityTime = Math.max(0, now - idleTimeMs);
this.applyIdleState(idleTimeMs >= IDLE_DURATION_MS);
} catch {
this.lastSystemIdleFailureAt = Date.now();
this.updateIdleStateFromLocalActivity();
} finally {
this.systemIdleCheckInFlight = false;
}
this.updateIdleStateFromLocalActivity();
}
private applyIdleState(idle: boolean): void {
@@ -1,6 +1,6 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.modalRoot {
.modalRoot.modalRoot {
--profile-popout-content-width: 18.75rem;
--profile-popout-border-width: 0.15625rem;
--profile-card-width: calc(
@@ -9,9 +9,16 @@
var(--profile-popout-border-width)
);
--custom-status-modal-padding-inline: 2rem;
--custom-status-modal-card-gutter: 3.75rem;
--custom-status-modal-width: calc(
var(--profile-card-width) +
var(--custom-status-modal-padding-inline) +
var(--custom-status-modal-card-gutter) +
var(--custom-status-modal-card-gutter)
);
width: min(calc(var(--profile-card-width) + var(--custom-status-modal-padding-inline)), 90vw);
max-width: calc(var(--profile-card-width) + var(--custom-status-modal-padding-inline));
width: min(var(--custom-status-modal-width), 90vw);
max-width: var(--custom-status-modal-width);
}
.previewSection {
@@ -31,8 +38,6 @@
.statusInputWrapper {
width: 100%;
max-width: var(--profile-card-width);
margin-inline: auto;
display: flex;
flex-direction: column;
gap: 0.35rem;
@@ -24,26 +24,30 @@ class UserProfile {
}
handleGatewayReady(): void {
Object.values(this.profileTimeouts).forEach(clearTimeout);
this.profiles = {};
this.profileTimeouts = {};
this.clearAllProfiles();
}
handleProfileInvalidate(userId: string, guildId?: string): void {
const targetGuildId = guildId ?? ME;
this.clearProfileTimeout(userId, targetGuildId);
const userProfiles = this.profiles[userId];
if (!userProfiles) return;
const {[targetGuildId]: _, ...remainingGuildProfiles} = userProfiles;
if (Object.keys(remainingGuildProfiles).length === 0) {
const {[userId]: __, ...remainingProfiles} = this.profiles;
this.profiles = remainingProfiles;
} else {
this.profiles = {
...this.profiles,
[userId]: remainingGuildProfiles,
};
}
this.removeProfile(userId, targetGuildId);
}
handleGuildMemberAdd(userId: string): void {
this.clearUserProfiles(userId);
}
handleGuildMemberRemove(userId: string): void {
this.clearUserProfiles(userId);
}
handleGuildCreate(): void {
this.clearAllProfiles();
}
handleGuildDelete(unavailable?: boolean): void {
if (unavailable) return;
this.clearAllProfiles();
}
handleProfileCreate(profile: Profile): void {
@@ -82,6 +86,37 @@ class UserProfile {
this.profileTimeouts = updatedTimeouts;
}
private clearAllProfiles(): void {
Object.values(this.profileTimeouts).forEach(clearTimeout);
this.profiles = {};
this.profileTimeouts = {};
}
private clearUserProfiles(userId: string): void {
const userProfiles = this.profiles[userId];
if (!userProfiles) return;
for (const guildId of Object.keys(userProfiles)) {
this.clearProfileTimeout(userId, guildId);
}
const {[userId]: _, ...remainingProfiles} = this.profiles;
this.profiles = remainingProfiles;
}
private removeProfile(userId: string, guildId: string): void {
const userProfiles = this.profiles[userId];
if (!userProfiles) return;
const {[guildId]: _, ...remainingGuildProfiles} = userProfiles;
if (Object.keys(remainingGuildProfiles).length === 0) {
const {[userId]: __, ...remainingProfiles} = this.profiles;
this.profiles = remainingProfiles;
} else {
this.profiles = {
...this.profiles,
[userId]: remainingGuildProfiles,
};
}
}
private createTimeoutKey(userId: string, guildId: string): string {
return `${userId}:${guildId}`;
}
@@ -101,23 +136,8 @@ class UserProfile {
this.clearProfileTimeout(userId, guildId);
const timeout = setTimeout(() => {
runInAction(() => {
const userProfiles = this.profiles[userId];
if (!userProfiles) {
const {[timeoutKey]: _, ...remainingTimeouts} = this.profileTimeouts;
this.profileTimeouts = remainingTimeouts;
return;
}
const {[guildId]: _, ...remainingGuildProfiles} = userProfiles;
if (Object.keys(remainingGuildProfiles).length === 0) {
const {[userId]: __, ...remainingProfiles} = this.profiles;
this.profiles = remainingProfiles;
} else {
this.profiles = {
...this.profiles,
[userId]: remainingGuildProfiles,
};
}
const {[timeoutKey]: ___, ...remainingTimeouts} = this.profileTimeouts;
this.removeProfile(userId, guildId);
const {[timeoutKey]: _, ...remainingTimeouts} = this.profileTimeouts;
this.profileTimeouts = remainingTimeouts;
});
}, PROFILE_TIMEOUT_MS);
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Profile} from '@app/features/user/models/Profile';
import {afterEach, describe, expect, it, vi} from 'vitest';
vi.mock('@app/features/auth/state/Authentication', () => ({default: {currentUserId: null}}));
const {default: UserProfile} = await import('@app/features/user/state/UserProfile');
const GUILD_ID = '1000';
const TARGET_ID = '2000';
function cache(userId: string = TARGET_ID, guildId?: string): void {
UserProfile.handleProfileCreate({
userId,
guildId: guildId ?? null,
mutualGuilds: [{id: GUILD_ID, nick: null}],
} as unknown as Profile);
}
describe('UserProfile cache invalidation', () => {
afterEach(() => {
UserProfile.handleGatewayReady();
});
it('drops every cached scope for a member removed from a community', () => {
cache();
cache(TARGET_ID, GUILD_ID);
expect(UserProfile.getProfile(TARGET_ID)?.mutualGuilds).toHaveLength(1);
UserProfile.handleGuildMemberRemove(TARGET_ID);
expect(UserProfile.getProfile(TARGET_ID)).toBeNull();
expect(UserProfile.getProfile(TARGET_ID, GUILD_ID)).toBeNull();
});
it('drops every cached scope for a member who joined a community', () => {
cache();
UserProfile.handleGuildMemberAdd(TARGET_ID);
expect(UserProfile.getProfile(TARGET_ID)).toBeNull();
});
it('keeps other users cached when one member is removed', () => {
cache();
cache('3000');
UserProfile.handleGuildMemberRemove(TARGET_ID);
expect(UserProfile.getProfile(TARGET_ID)).toBeNull();
expect(UserProfile.getProfile('3000')).not.toBeNull();
});
it('drops cached profiles when the viewer leaves a community', () => {
cache();
UserProfile.handleGuildDelete(false);
expect(UserProfile.getProfile(TARGET_ID)).toBeNull();
});
it('keeps cached profiles when a community only goes unavailable', () => {
cache();
UserProfile.handleGuildDelete(true);
expect(UserProfile.getProfile(TARGET_ID)).not.toBeNull();
});
it('drops cached profiles when the viewer joins a community', () => {
cache();
UserProfile.handleGuildCreate();
expect(UserProfile.getProfile(TARGET_ID)).toBeNull();
});
});
@@ -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> = [];
@@ -43,6 +43,7 @@ export function useWindowEventListeners({preventDocumentScroll}: WindowEventList
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('mousedown', handleImmediateActivity);
window.addEventListener('keydown', handleUserActivity);
window.addEventListener('input', handleUserActivity);
window.addEventListener('resize', handleResize);
window.addEventListener('touchstart', handleImmediateActivity);
document.addEventListener('touchstart', preventPinchZoom, {passive: false});
@@ -58,6 +59,7 @@ export function useWindowEventListeners({preventDocumentScroll}: WindowEventList
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('mousedown', handleImmediateActivity);
window.removeEventListener('keydown', handleUserActivity);
window.removeEventListener('input', handleUserActivity);
window.removeEventListener('resize', handleResize);
window.removeEventListener('touchstart', handleImmediateActivity);
document.removeEventListener('touchstart', preventPinchZoom);
+23
View File
@@ -1242,6 +1242,29 @@ console.log('self-hosting guide against deploy/self-hosting');
compareKeyLists('secret', shellSecrets, powershellSecrets);
compareKeyLists('non-secret value', shellNonSecrets, powershellNonSecrets);
const shellUpgrade = parseShellKeys('fluxer_upgrade_secret_keys', 'the install.sh upgrade secret list');
const powershellUpgrade = parsePowerShellKeys('FluxerUpgradeSecretKeys', 'the install.ps1 upgrade secret list');
compareKeyLists('upgrade secret', shellUpgrade, powershellUpgrade);
for (const [script, upgrade, secrets] of [
['install.sh', shellUpgrade, shellSecrets],
['install.ps1', powershellUpgrade, powershellSecrets],
] as const) {
if (upgrade.size === 0) {
problems.push(`${script} declares no upgrade secret keys, so an upgrade mints nothing`);
}
for (const [name, kind] of upgrade) {
const asInstalled = secrets.get(name);
if (asInstalled == null) {
problems.push(`${script} mints ${name} on an upgrade and never writes it on an install`);
continue;
}
if (asInstalled !== kind) {
problems.push(`${name} is ${kind} on an upgrade and ${asInstalled} on an install in ${script}`);
}
}
}
const expectedSecretKind = (name: string): string => {
if (name === 'FLUXER_VAPID_PUBLIC_KEY') {
return 'vapid_public';
+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.
@@ -4,7 +4,7 @@ title: HTTP API
description: Request format, body representations, shared headers, cross-origin policy, and the error objects.
---
The Fluxer HTTP API is the set of routes a client calls to read and change data. Every route is published under `/v1`, and `/v1` is the only version. The base URL comes from `endpoints.api` in the [instance discovery document](/http-api/instance/#get-instance-discovery), which is served unversioned at `/.well-known/fluxer`.
The Fluxer HTTP API is the set of routes a client calls to read and change data. Every route is published under `/v1`, and `/v1` is the only version. The base URL comes from the [instance discovery document](/http-api/instance/#get-instance-discovery), which is served unversioned at `/.well-known/fluxer`. A third-party client reads `endpoints.api_public` there, and the first-party web application reads `endpoints.api_client`.
Every route is also mounted at the root, so a path resolves with or without the prefix. A client MUST use the `/v1` form. [Download stored object](/http-api/downloads/#download-stored-object) is the one exception, and it resolves at the root alone.
@@ -57,7 +57,7 @@ Each value is an absolute URL supplied by the operator. A value can be a bare or
| gift | string | Gift link base URL |
| webapp | string | Web application base URL |
<sup>1</sup> Both fields are the same configured client API endpoint, while `api_public` is the separately configured public API endpoint
<sup>1</sup> Both fields are the same configured client API endpoint, which the first-party web application reads. `api_public` is the separately configured public API endpoint, which a bot, a library, or any other third-party client reads. A deployment can point the two at one origin, and the instance Fluxer hosts does not
<sup>2</sup> The value is the configured Gateway endpoint and its scheme is `ws` or `wss` as the operator configured it
@@ -178,13 +178,14 @@ A matching entry is deleted and [Recent Mention Delete](/gateway/events/#recent-
Lists the caller's saved message collection. Returns an array of [saved message](#saved-message-object) objects in descending message ID order.
The response is a bare array with no cursor metadata, so `limit` is the only control and an account holding more than 100 entries cannot page past the first 100. An entry whose channel the caller can no longer reach, or whose message the caller can no longer read, comes back with status `missing_permissions` and a null message. An entry whose message no longer exists is deleted and omitted. The array can hold fewer entries than `limit`.
The response is a bare array with no cursor metadata, so `before` and `limit` are the only pagination controls. An entry whose channel the caller can no longer reach, or whose message the caller can no longer read, comes back with status `missing_permissions` and a null message. An entry whose message no longer exists is deleted and omitted. The array can hold fewer entries than `limit`.
### Query parameters
| Field | Type | Description |
| --- | --- | --- |
| limit?<sup>1</sup> <sup>2</sup> | integer | The maximum number of entries read from the collection (1-100, default 25) |
| before?<sup>2</sup> | snowflake | The upper bound on entry message ID, exclusive |
<sup>1</sup> A value outside the range fails with `VALUE_MUST_BE_INTEGER_IN_RANGE` at the `limit` path
@@ -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 |
+3 -1
View File
@@ -43,9 +43,11 @@ A client that knows only a Fluxer origin reads endpoint discovery first. `GET /.
GET https://example.com/.well-known/fluxer
```
The instance Fluxer hosts answers discovery at `https://fluxer.app/.well-known/fluxer`. That origin is the one thing a client is given. Every base URL below it still comes from the response.
It returns the [instance discovery object](/http-api/instance/#instance-discovery-object). Every base URL a client uses comes from the [instance endpoints object](/http-api/instance/#instance-endpoints-object) inside it. A client MUST read every base URL from that response, and it MUST NOT derive one from the origin it was given or assume an official Fluxer domain.
Take `endpoints.api` from that response, then send a credential in the `Authorization` header.
Take the base URL for the kind of client being built, then send a credential in the `Authorization` header. A bot, a library, or any other third-party client takes `endpoints.api_public`. `endpoints.api_client` is the endpoint the first-party web application uses, and `endpoints.api` repeats it.
```text
GET https://api.example.com/v1/users/@me
@@ -18,6 +18,15 @@ The installer in [Get started](/operator/get-started/) writes `.env` for you and
Compose reads `.env` and passes the values it names into containers. A name reaches a container only when `docker-compose.yml` lists it, either in the shared `x-fluxer-env` block or in that service's own `environment` block. No service declares `env_file`, so a name in `.env` that appears in neither block never arrives, whatever it is set to.
A `$` inside a value is a variable reference to Compose, not a character. `POSTGRES_PASSWORD=ab$cd` reaches the container as `ab`, and every command against the stack prints `The "cd" variable is not set. Defaulting to a blank string.` first. Write the `$` as `$$`, or put single quotes around the whole value. Both deliver one literal `$`:
```ini
POSTGRES_PASSWORD=ab$$cd
POSTGRES_PASSWORD='ab$cd'
```
Double quotes do not escape it. `docker compose config` prints a literal `$` back as `$$`, so a value that reads `ab$$cd` in that output is the correct one. The fourteen secrets the installer generates are hex or base64 and hold no `$`, so this reaches an instance through a password, an API key or an SMTP secret pasted in by hand.
A container's environment is fixed when the container is created, and `api` and `worker` cache their configuration at first load. Either way a change needs the process restarted, which `docker compose up -d` does by recreating the service.
Precedence, highest first:
@@ -572,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`
@@ -638,6 +647,22 @@ Default empty. The LiveKit secret. Compose fills it from `LIVEKIT_API_SECRET`.
Default empty. The client-facing LiveKit URL. When empty the API derives it from the public API origin as `wss://host/livekit`, or `ws://` under `http`, and keeps a non-default port. Set it only when LiveKit is served from another host. Compose forwards the `.env` value, empty by default.
#### `FLUXER_LIVEKIT_USE_EXTERNAL_IP`
Default `true`. Whether LiveKit discovers the address browsers dial by asking a STUN server. A host that cannot reach one over UDP fails to start with `could not resolve external IP: context deadline exceeded`. Set it to `false` and give `FLUXER_LIVEKIT_NODE_IP` the address instead. Read only by the `livekit` service.
#### `FLUXER_LIVEKIT_NODE_IP`
Default empty. The address LiveKit publishes in its ICE candidates. Empty leaves the choice to the discovery above, and with that discovery off LiveKit falls back to the container's own address, which no browser can reach. Set both together. `docker compose logs livekit` names the address in use as `nodeIP` on the starting line.
#### `FLUXER_LIVEKIT_STUN_PRIMARY`
Default `stun.l.google.com:19302`. The first STUN server the discovery asks. Point it at another server to keep the discovery without reaching Google.
#### `FLUXER_LIVEKIT_STUN_SECONDARY`
Default `stun1.l.google.com:19302`. The second STUN server, used the same way.
#### `FLUXER_LIVEKIT_INTERNAL_URL`
Default empty. The server-side LiveKit control API. Compose sets `http://livekit:7880`.
@@ -1535,9 +1560,11 @@ Defaults to the crate version. The reported build. `BUILD_VERSION` is preferred
`app-proxy` builds a per-request nonce-based policy for the client HTML and the assets it serves. Each variable appends sources to one directive on top of the built-in ones. All are empty by default, and Compose forwards all eleven.
Every name below goes in `.env`. `app-proxy` reads its environment at container start, so a change takes effect on `docker compose up -d app-proxy` and not on `docker compose restart app-proxy`.
`FLUXER_CSP_EXTRA_DEFAULT_SRC`, `FLUXER_CSP_EXTRA_CONNECT_SRC`, `FLUXER_CSP_EXTRA_IMG_SRC`, `FLUXER_CSP_EXTRA_MEDIA_SRC`, `FLUXER_CSP_EXTRA_FONT_SRC`, `FLUXER_CSP_EXTRA_SCRIPT_SRC`, `FLUXER_CSP_EXTRA_STYLE_SRC`, `FLUXER_CSP_EXTRA_FRAME_SRC`, `FLUXER_CSP_EXTRA_WORKER_SRC`, and `FLUXER_CSP_EXTRA_MANIFEST_SRC` take one or more sources separated by commas, spaces, tabs, or newlines. Blank entries and sources the directive already lists are dropped. `FLUXER_CSP_REPORT_URI` sets a single `report-uri` value.
`object-src`, `base-uri`, and `frame-ancestors` are fixed and have no override. `app-proxy` reads the discovery document and adds the static CDN endpoint, the media endpoint, and the origins of the configured branding images, so a stack on one hostname needs no extra sources. The usual reason to set one is a voice server on another hostname, which needs its WebSocket origin in `FLUXER_CSP_EXTRA_CONNECT_SRC`.
`object-src`, `base-uri`, and `frame-ancestors` are fixed and have no override. `app-proxy` reads the discovery document and adds the static CDN endpoint, the media endpoint, and the origins of the configured branding images, so a stack on one hostname needs no extra sources. The usual reason to set one is a voice server on another hostname, which needs its WebSocket origin in `FLUXER_CSP_EXTRA_CONNECT_SRC`. [Voice media does not use the proxy](/operator/reverse-proxy/#voice-media-does-not-use-the-proxy) has that line in place.
A front proxy must not add a Content-Security-Policy of its own.
@@ -1742,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`
@@ -1903,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.
@@ -1927,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
@@ -17,7 +17,7 @@ By the end you have a Fluxer instance on a hostname you own, with the web app, H
| Requirement | Value |
| --- | --- |
| Host | Any machine that runs Docker with Linux containers. A Linux server, or a Mac or Windows PC with Docker Desktop |
| Runtime | Docker Engine 24 or newer with the Compose plugin v2.20.2 or newer. Docker Desktop ships both |
| Runtime | Docker Engine 24 or newer, or Podman 5 or newer, with the Compose plugin v2.20.2 or newer |
| Processor | Intel, AMD and Apple Silicon all work. Every image ships for `amd64` and `arm64`, and Docker pulls the matching one |
| CPU | 2 vCPU minimum, 4 vCPU for a small active community. No service sets a CPU limit, so every container sees all of them |
| RAM | 8 GB minimum with the shipped defaults, 16 GB for a small active community. 4 GB needs the [low-memory overrides](/operator/configuration/#resources) |
@@ -148,7 +148,7 @@ The run prints one line per phase and ends with the URL to open. It takes severa
| --- | --- |
| `--domain <host>` | Hostname the instance answers on. Prompted when absent |
| `--email <address>` | Contact address written as `FLUXER_VAPID_EMAIL`. Prompted when absent |
| `--dir <path>` | Working directory. Default `~/fluxer` |
| `--dir <path>` | Working directory. Default `~/fluxer`, or the current directory when it holds an instance |
| `--ref <git ref>` | Ref the stack files come from. Defaults to the image tag, and to `main` when that tag is `v1` or `latest` |
| `--image-tag <tag>` | Image tag the stack runs. Default `v1` |
| `--tls <mode>` | `bundled` or `proxy`. Default `bundled` |
@@ -159,6 +159,7 @@ The run prints one line per phase and ends with the URL to open. It takes severa
| `--update` | Upgrade: record, back up, refresh, pull, recreate, verify |
| `--rollback` | Put back the images and stack files of the newest record |
| `--allow-root` | Permit running as root |
| `--engine <command>` | Container engine to drive. Default `docker`, or `podman` when `docker` is absent |
Run `--dry-run` first to see what a set of flags does. The PowerShell script takes the same flags as `-Domain`, `-Email` and so on, and has no `-AllowRoot`. [Upgrading](/operator/upgrading/#run-the-upgrade) has the flags that only `--update` and `--rollback` read, and the exit codes.
@@ -255,14 +256,16 @@ A dump and a tarball in `backups`, and every service back to `running`, is the s
### Remove the instance
:::danger[This deletes every account, message and upload]
`-v` deletes all seven named volumes. Every account, message, upload, search index and certificate the instance holds is gone, and no `docker compose up -d` brings any of it back. Take the copies above first. To upgrade rather than delete, the command is `sh install.sh --update`, below.
:::
Take the instance down and delete its data:
```bash
docker compose down -v
```
`-v` deletes all seven named volumes, so every account, message, upload, search index and certificate the instance holds is gone, and no `docker compose up -d` brings them back. Take the copies above first.
## Upgrading
The default tag `v1` tracks the latest compatible release. The same script upgrades the instance:
@@ -272,7 +275,7 @@ cd ~/fluxer
sh install.sh --update
```
`--update` records the running images, backs up the database and the uploads, refreshes the four stack files, pulls, recreates, and verifies. It leaves every secret in `.env` untouched. On Windows it is `.\install.ps1 -Update`. Run `sh install.sh --update --dry-run` first to see the plan.
`--update` records the running images, backs up the database and the uploads, refreshes the five stack files, pulls, recreates, and verifies. It leaves every secret in `.env` untouched. On Windows it is `.\install.ps1 -Update`. Run `sh install.sh --update --dry-run` first to see the plan.
[Upgrading](/operator/upgrading/) covers what the backup holds, rolling back, pinning a release and reclaiming disk.
@@ -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
@@ -483,8 +481,10 @@ LiveKit signalling goes through `/livekit/*` like everything else. WebRTC media
Both ports are published directly by the stack and must reach the host. A proxy or tunnel in front of `443` does nothing for them.
Hosting LiveKit on a hostname other than `FLUXER_DOMAIN` means widening the Content-Security-Policy the web app runs under:
Hosting LiveKit on a hostname other than `FLUXER_DOMAIN` means widening the Content-Security-Policy the web app runs under. The line goes in `.env`, beside `FLUXER_DOMAIN`:
```ini
FLUXER_CSP_EXTRA_CONNECT_SRC=wss://livekit.example.com:7881
```
`app-proxy` builds the policy and reads its environment at container start, so apply the change with `docker compose up -d app-proxy`. `docker compose restart app-proxy` reuses the existing container with its old environment. [Content Security Policy](/operator/configuration/#content-security-policy) has the other ten variables.
@@ -31,6 +31,7 @@ The repository holds no ref by the name of a pinned image tag. A release tags ea
| Step | What it does |
| --- | --- |
| Mint | Writes any key the refreshed stack requires that `.env` does not hold, listed under [Run the upgrade](#run-the-upgrade) |
| Record | Writes the image references, the image ID each container has, and the tag `.env` names, into a new record directory |
| Save | Copies `.env` and the five stack files into that record |
| Dump | Dumps the database with the stack still serving, and checks the custom-format header on the result |
@@ -46,20 +47,75 @@ A failed run leaves the instance running on the images it already had.
`docker compose up -d` does not notice a changed `Caddyfile`. The script restarts `edge` by name to pick it up.
The refreshed `docker-compose.yml` renames the `caddy` service to `edge`, and the two publish the same host ports. `--remove-orphans` takes the old container down in the same call, so the new one can bind them. Without it the step stops with `Bind for 0.0.0.0:443 failed`. It also removes any container in the project whose service the loaded Compose files no longer define, so keep `COMPOSE_FILE` the same across an upgrade.
An instance older than the `/livekit` routing needs one edit no upgrade can make for it. [Voice signalling moved to /livekit](#voice-signalling-moved-to-livekit) has it, and voice stays silent until it is made.
The refreshed `docker-compose.yml` renames the `caddy` service to `edge`, and the two publish the same host ports. `--remove-orphans` takes the old container down in the same call, so the new one can bind them. Without it the step stops with `Bind for 0.0.0.0:443 failed`. It also removes any container in the project whose service the loaded Compose files no longer define, so keep `COMPOSE_FILE` the same across an upgrade. [Keep a local compose change](#keep-a-local-compose-change) has the file layout that survives one, and [When the compose file list names a missing file](#when-the-compose-file-list-names-a-missing-file) has the failure a line naming an absent file produces.
The rename also moves the `caddy-data` and `caddy-config` volumes to `edge-data` and `edge-config`, so the old two are left unused and the edge requests its certificate again on the first start.
`FLUXER_IMAGE_TAG` is the only line in `.env` that either upgrade mode writes. Every secret is left as it is.
`FLUXER_IMAGE_TAG` is the only line in `.env` that either upgrade mode rewrites once the instance holds every key the stack requires. An upgrade also writes a key the refreshed stack requires that `.env` does not hold at all, listed below, and it leaves every value already set as it is.
The refreshed `docker-compose.yml` requires `FLUXER_ERLANG_COOKIE`. An `.env` written by an earlier installer has no such line, so add `FLUXER_ERLANG_COOKIE=$(openssl rand -hex 32)` to it before you upgrade. The value is 64 hex characters, which is what a fresh install writes. Until `.env` sets it, every Compose command against the stack stops with `set FLUXER_ERLANG_COOKIE in .env`.
The refreshed `docker-compose.yml` requires `FLUXER_ERLANG_COOKIE`. An `.env` written by an earlier installer holds no such line, and `--update` writes one into `.env` before it reads anything, so an upgrade needs no edit for it. The value is 64 hex characters, which is what a fresh install writes. Until `.env` sets it, every Compose command against the stack stops with `set FLUXER_ERLANG_COOKIE in .env`.
`api` and `media-proxy` now refuse to start unless `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64` decodes to at least 32 bytes. The line has been in `.env.example` for a while as `CHANGE_ME`, which decodes to 6 bytes, and nothing read it before, so an instance can be running today with the placeholder. Set it with `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=$(openssl rand -base64 32)` before you upgrade. Both services read the same value, so give them one secret rather than two. A fresh install writes it for you. Without it `api` stops at boot with `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 is required for the API`.
To write it by hand, run the generator in a shell:
```bash
printf '\nFLUXER_ERLANG_COOKIE=%s\n' "$(openssl rand -hex 32)" >> .env
```
Run that as a command. Pasting `FLUXER_ERLANG_COOKIE=$(openssl rand -hex 32)` into `.env` as text stores those characters as the value, because Compose reads a line literally and runs nothing in it. The leading newline is what keeps the key on its own line when the last line of `.env` ends without one, which an editor can leave behind and which would otherwise join the two.
`api` and `media-proxy` now refuse to start unless `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64` decodes to at least 32 bytes. The line has been in `.env.example` for a while as `CHANGE_ME`, which decodes to 6 bytes, and nothing read it before, so an instance can be running today with the placeholder. `--update` replaces an absent or `CHANGE_ME` value with a generated one before it reads anything. Both services read the same value, so they take one secret rather than two, and writing it in one place is what gives them that. A `CHANGE_ME` value stops `api` at boot with `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 must decode to at least 32 bytes`, and an empty one with `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 is required for the API`.
To write it by hand, run the generator in a shell the same way:
```bash
printf '\nFLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=%s\n' "$(openssl rand -base64 32)" >> .env
```
## The script is the reference
Every step above sits in the script beside a comment holding the command that does that step alone and the reason the step exists. Read [https://fluxer.dev/install.sh](https://fluxer.dev/install.sh), or [https://fluxer.dev/install.ps1](https://fluxer.dev/install.ps1) for Windows.
## Keep a local compose change
The Place step replaces all five stack files with the copies at the ref. An edit made directly in `docker-compose.yml`, `docker-compose.proxy.yml`, `tunnel.compose.yml` or the `Caddyfile` is gone once that step runs. The run prints one line for the whole step and never names the files it replaced. `--dry-run` names every one of them, as `changes`, `unchanged` or `is new`.
The supported way to hold a local choice is a separate file, listed in `COMPOSE_FILE` in `.env`:
```ini
COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml:local.compose.yml
```
Compose merges the files left to right, so `local.compose.yml` wins over the ones before it. An upgrade refreshes the five stack files and nothing else, so a file outside that set survives every upgrade untouched. A record copies `.env` and those five files, and no other file from the working directory, so an override file is never backed up with them and belongs wherever the rest of the configuration lives.
`.env` needs no such file. The only value either upgrade mode replaces there is `FLUXER_IMAGE_TAG`, and the Mint step adds a required key the file does not hold at all.
[Enable the overlay](/operator/reverse-proxy/#enable-the-overlay) has the overlay this is most often used for, and [Docker labels](/operator/reverse-proxy/#docker-labels) has a worked third file.
## When the compose file list names a missing file
`COMPOSE_FILE` names files relative to the working directory, and Compose refuses to load the set when one of them is absent:
```
stat /srv/fluxer/docker-compose.proxy.yml: no such file or directory
```
Every `docker compose` command stops there. An upgrade checks the list before it runs any of them, so it stops with a sentence naming the file and the `curl` that puts it back, and writes nothing.
This happens on an instance older than the installer. `docker-compose.proxy.yml` and `tunnel.compose.yml` are files the upgrade downloads, so a `COMPOSE_FILE` line naming one of them in a directory that never held it fails long before the Fetch step that would have supplied it.
Download the missing file at the ref the upgrade moves to, then run the upgrade:
```bash
curl -fsSL --proto '=https' --tlsv1.2 -o docker-compose.proxy.yml \
https://raw.githubusercontent.com/fluxerapp/fluxer/main/deploy/self-hosting/docker-compose.proxy.yml
```
`main` is the ref for `FLUXER_IMAGE_TAG=v1` and for `latest`. [Match the images to the stack files](#match-the-images-to-the-stack-files) has the ref a pinned tag needs.
Removing the line from `.env` also gets the run moving and is the wrong fix. Without the overlay the edge publishes `80` and `443` and takes them from the reverse proxy already on the host.
## Run the upgrade
Keep the installer beside the stack files. [Get started](/operator/get-started/#step-4-bring-up-the-instance) has the download and the checksum check for a fresh copy.
@@ -98,6 +154,32 @@ The run ends by naming the record it wrote and the command that rolls back to it
`--dir` and `--ref` mean what they mean on an install, and the installer derives an omitted `--ref` from the `FLUXER_IMAGE_TAG` line in `.env`. Every failure prints a sentence on standard error and exits non-zero, and the script header lists what each code means. A Postgres major version change is exit 3.
## Voice signalling moved to /livekit
The edge serves LiveKit at `/livekit/*`. An instance set up before that routing served it under `/gateway/livekit`, and that path is gone. Voice stops connecting after the upgrade and the run still reports success, because the readiness poll and the `/_health` probe never reach voice.
Before:
```
wss://chat.example.com/gateway/livekit
```
After:
```
wss://chat.example.com/livekit
```
Only the path is wrong. `docker-compose.yml` derives `https://chat.example.com/livekit` when `.env` sets no `FLUXER_LIVEKIT_URL`, and the client rewrites a leading `http` to `ws` itself, so `https` and `wss` both work.
The address browsers dial is a column on a voice server row in the database. No upgrade migrates that row, and it is corrected in two places.
First `.env`. When it sets `FLUXER_LIVEKIT_URL`, put the new path in that line and run `docker compose up -d api`. `api` writes that value onto the default voice server row at every start, so a row corrected in the dashboard while `.env` still names the old path goes back to the old path on the next restart. Removing the line is also correct, and Compose then derives the URL from `FLUXER_PUBLIC_ORIGIN`, or from the scheme and the domain.
Then the dashboard, at `https://chat.example.com/admin` under **Voice Servers**. The **Endpoint** field on that page holds the address. `api` writes one row only, the one in region `default` with server id `default-server-1`, and leaves every other row as it is. That row corrects itself on the first `api` start after `.env` is right, so the dashboard is for every other row. When that region or that server is absent, its log says `skipping config sync` and it writes nothing. A region added by hand, or one left from an older layout under another id, keeps the endpoint it already holds until that field is edited.
Voice media is unaffected. It never went through the edge, and `7881/tcp` and `7882/udp` still reach the host directly.
## What the backup covers
Each upgrade writes one record directory, named `record-` and a UTC stamp, under `backups` or wherever `--backup-dir` points. It holds:
+371 -61
View File
@@ -19,8 +19,9 @@
# types to do that step by hand, and the reason the step exists.
#
# Read this file before running it. The default mode writes .env, which holds every secret the
# instance has. The upgrade generates no secret and rewrites no secret. The only line either
# upgrade mode ever changes in .env is FLUXER_IMAGE_TAG, and only during a rollback that moves
# instance has. The upgrade generates a secret only for a key the refreshed stack requires that
# .env does not hold, and rewrites no secret. The only value either
# upgrade mode ever replaces in .env is FLUXER_IMAGE_TAG, and only during a rollback that moves
# off a pinned tag.
#
# Rewriting a secret in .env against volumes that already exist is the one thing this script
@@ -39,6 +40,7 @@ param(
[string]$Domain = '',
[string]$Email = '',
[string]$Dir = '',
[string]$Engine = '',
[string]$Ref = '',
[string]$ImageTag = 'v1',
[string]$Tls = 'bundled',
@@ -64,6 +66,7 @@ $FluxerRawBase = 'https://raw.githubusercontent.com/fluxerapp/fluxer'
$FluxerStackPath = 'deploy/self-hosting'
$FluxerHealthPath = '/_health'
$FluxerInitService = 'seaweedfs-init'
$FluxerMinimumPodmanVersion = '5.0.0'
$FluxerMinimumEngineVersion = '24.0.0'
$FluxerMinimumComposeVersion = '2.20.2'
$FluxerReadyTimeoutSeconds = 600
@@ -136,6 +139,15 @@ $FluxerBackupVolumes = @(
'seaweedfs-data'
)
# The keys a refreshed stack requires that an .env written by an older installer does not hold.
# Only keys with no state anywhere else are here. A value like POSTGRES_PASSWORD is matched by a
# password stored inside the database, so minting a new one locks the stack out of its own data
# and it is not listed. CHANGE_ME counts as absent.
$FluxerUpgradeSecretKeys = @(
@{Name = 'FLUXER_ERLANG_COOKIE'; Kind = 'hex'}
@{Name = 'FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64'; Kind = 'base64'}
)
$FluxerSecretKeys = @(
@{Name = 'POSTGRES_PASSWORD'; Kind = 'hex'}
@{Name = 'MEILI_MASTER_KEY'; Kind = 'hex'}
@@ -184,7 +196,11 @@ function Show-FluxerUsage {
Write-FluxerLine 'Options:'
Write-FluxerLine ' -Domain <host> Hostname the instance answers on. Prompted when absent.'
Write-FluxerLine ' -Email <address> Address written as FLUXER_VAPID_EMAIL. Prompted when absent.'
Write-FluxerLine ' -Dir <path> Working directory. Default: the fluxer folder in the home directory.'
Write-FluxerLine ' -Engine <command> Container engine to drive. Default: docker, or podman when'
Write-FluxerLine ' docker is absent.'
Write-FluxerLine ' -Dir <path> Working directory. Default: the fluxer folder in the home'
Write-FluxerLine ' directory, or the working directory itself when -Update or'
Write-FluxerLine ' -Rollback is given and it holds an instance.'
Write-FluxerLine ' -Ref <git ref> Ref the stack files come from. Default: the image tag, and main when that tag is v1 or latest.'
Write-FluxerLine ' -ImageTag <tag> Value written as FLUXER_IMAGE_TAG. Default: v1.'
Write-FluxerLine ' -Tls <bundled|proxy> Certificate mode. Default: bundled.'
@@ -240,21 +256,33 @@ function Test-FluxerVersionAtLeast([string]$Found, [string]$Minimum) {
return $true
}
# docker writes the reason a command failed to stderr and nothing else ever repeats it, so stderr
# is kept rather than discarded. The two streams stay apart because every caller reads Text as
# data, and one warning line on stderr would be one more line of JSON, one more image reference or
# one more container ID.
$script:FluxerEngine = 'docker'
$script:FluxerEngineLabel = 'Docker Engine'
function Invoke-FluxerCapture([string[]]$CommandArgs) {
$previous = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
$text = ''
$out = @()
$err = @()
$code = 0
try {
$output = & docker @CommandArgs 2>$null
$output = & $script:FluxerEngine @CommandArgs 2>&1
$code = $LASTEXITCODE
if ($null -ne $output) {
$text = (@($output) | ForEach-Object {[string]$_}) -join "`n"
foreach ($item in @($output)) {
if ($item -is [System.Management.Automation.ErrorRecord]) {
$err += [string]$item
} else {
$out += [string]$item
}
}
} finally {
$ErrorActionPreference = $previous
}
return @{Code = $code; Text = $text.Trim()}
return @{Code = $code; Text = (($out -join "`n").Trim()); Error = (($err -join "`n").Trim())}
}
function Invoke-FluxerDocker([string[]]$CommandArgs) {
@@ -262,7 +290,7 @@ function Invoke-FluxerDocker([string[]]$CommandArgs) {
$ErrorActionPreference = 'Continue'
$code = 0
try {
& docker @CommandArgs
& $script:FluxerEngine @CommandArgs
$code = $LASTEXITCODE
} finally {
$ErrorActionPreference = $previous
@@ -274,7 +302,7 @@ function Invoke-FluxerDocker([string[]]$CommandArgs) {
# it, so the bytes go to the file through the process itself.
function Invoke-FluxerDockerToFile([string[]]$CommandArgs, [string]$OutFile, [string]$WorkingDir) {
$errorFile = "$OutFile.stderr"
$process = Start-Process -FilePath 'docker' -ArgumentList $CommandArgs -RedirectStandardOutput $OutFile -RedirectStandardError $errorFile -WorkingDirectory $WorkingDir -NoNewWindow -Wait -PassThru
$process = Start-Process -FilePath $script:FluxerEngine -ArgumentList $CommandArgs -RedirectStandardOutput $OutFile -RedirectStandardError $errorFile -WorkingDirectory $WorkingDir -NoNewWindow -Wait -PassThru
$code = $process.ExitCode
if (Test-Path -LiteralPath $errorFile) {
Remove-Item -LiteralPath $errorFile -Force
@@ -309,22 +337,40 @@ function Invoke-FluxerPreflight {
if ($PSVersionTable.PSVersion.Major -lt 6) {
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
}
if ($null -eq (Get-Command docker -ErrorAction SilentlyContinue)) {
Stop-Fluxer 'docker is not on PATH. Install Docker Desktop with the WSL2 backend.' $FluxerExitPrerequisite
if ($Engine.Length -gt 0) {
$script:FluxerEngine = $Engine
} elseif ($null -ne (Get-Command docker -ErrorAction SilentlyContinue)) {
$script:FluxerEngine = 'docker'
} elseif ($null -ne (Get-Command podman -ErrorAction SilentlyContinue)) {
$script:FluxerEngine = 'podman'
} else {
Stop-Fluxer 'Neither docker nor podman is on PATH. Install Docker Desktop with the WSL2 backend, or pass -Engine with the command that drives your containers.' $FluxerExitPrerequisite
}
if ($null -eq (Get-Command $script:FluxerEngine -ErrorAction SilentlyContinue)) {
Stop-Fluxer "$($script:FluxerEngine) is not on PATH. Pass -Engine with the command that drives your containers." $FluxerExitPrerequisite
}
# Every engine that speaks the Docker CLI reports itself as "<name> version X",
# and Podman's 5.x is not Docker's 5.x, so the floor has to belong to whichever
# one answered.
$reported = Invoke-FluxerCapture @('--version')
$minimumEngine = $FluxerMinimumEngineVersion
if ($reported.Code -eq 0 -and $reported.Text -match '^\s*podman\s+version') {
$script:FluxerEngineLabel = 'Podman'
$minimumEngine = $FluxerMinimumPodmanVersion
}
$engine = Invoke-FluxerCapture @('version', '--format', '{{.Server.Version}}')
if ($engine.Code -ne 0) {
Stop-Fluxer 'The Docker daemon does not answer. Start Docker Desktop and run this script again.' $FluxerExitPrerequisite
Stop-Fluxer "$($script:FluxerEngine) does not answer. Start it and run this script again." $FluxerExitPrerequisite
}
$compose = Invoke-FluxerCapture @('compose', 'version', '--short')
if ($compose.Code -ne 0) {
Stop-Fluxer 'The Docker Compose v2 plugin is missing. Docker Desktop ships it.' $FluxerExitPrerequisite
}
if (-not (Test-FluxerVersionAtLeast $engine.Text $FluxerMinimumEngineVersion)) {
Stop-Fluxer "Docker Engine $($engine.Text) is older than $FluxerMinimumEngineVersion. Compose is $($compose.Text)." $FluxerExitPrerequisite
if (-not (Test-FluxerVersionAtLeast $engine.Text $minimumEngine)) {
Stop-Fluxer "$($script:FluxerEngineLabel) $($engine.Text) is older than $minimumEngine. Compose is $($compose.Text)." $FluxerExitPrerequisite
}
if (-not (Test-FluxerVersionAtLeast $compose.Text $FluxerMinimumComposeVersion)) {
Stop-Fluxer "Docker Compose $($compose.Text) is older than $FluxerMinimumComposeVersion. Engine is $($engine.Text)." $FluxerExitPrerequisite
Stop-Fluxer "Compose $($compose.Text) is older than $FluxerMinimumComposeVersion. $($script:FluxerEngineLabel) is $($engine.Text)." $FluxerExitPrerequisite
}
$osType = Invoke-FluxerCapture @('info', '--format', '{{.OSType}}')
if ($osType.Code -ne 0) {
@@ -333,7 +379,7 @@ function Invoke-FluxerPreflight {
if ($osType.Text -ne 'linux') {
Stop-Fluxer "Docker runs $($osType.Text) containers. Switch Docker Desktop to Linux containers." $FluxerExitPrerequisite
}
Write-FluxerLine "Docker Engine $($engine.Text) and Compose $($compose.Text) are ready."
Write-FluxerLine "$($script:FluxerEngineLabel) $($engine.Text) and Compose $($compose.Text) are ready."
if (-not (Test-FluxerWsl2)) {
Write-FluxerLine 'WSL 2 is not present. Docker Desktop with the WSL2 backend is the supported Windows setup.'
}
@@ -753,19 +799,51 @@ function Get-FluxerComposeProject([string]$TargetDir) {
return ''
}
# The image references the stack resolves to, deduplicated. Compose expands them from
# docker-compose.yml and .env, so this is the same resolution docker compose pull performs. It
# names the images, not the versions of them.
$script:FluxerComposeOut = ''
$script:FluxerComposeSelector = ''
$script:FluxerComposeErr = ''
# One read-only Compose query, with what Compose printed on stderr kept.
#
# Compose loads the whole file set and interpolates every variable in it before it answers either
# query below. A file that is not there, a file it cannot read and a required variable .env does
# not set all fail at that point, and the stderr text is the only thing that says which of them it
# was.
function Invoke-FluxerComposeQuery([string]$Selector) {
$result = Invoke-FluxerCapture @('compose', 'config', $Selector)
$script:FluxerComposeOut = $result.Text
$script:FluxerComposeErr = $result.Error
$script:FluxerComposeSelector = $Selector
return $result.Code
}
# The two readers below parse whatever the last query returned, so each one names
# the selector it belongs to. Reading the wrong one would otherwise hand back the
# other kind of list with nothing to say it was wrong.
function Assert-FluxerComposeSelector([string]$Selector) {
if ($script:FluxerComposeSelector -ne $Selector) {
Stop-Fluxer "Internal error: read of $Selector after a $($script:FluxerComposeSelector) query." $FluxerExitSecret
}
}
# What Compose printed on the last query, indented one level for the caller.
function Get-FluxerComposeError([string]$Indent) {
if ($script:FluxerComposeErr.Length -eq 0) {
return "${Indent}nothing"
}
return (($script:FluxerComposeErr.Split("`n") | ForEach-Object {"$Indent$_"}) -join "`n")
}
# The image references the stack resolves to, deduplicated, from the last query. Compose expands
# them from docker-compose.yml and .env, so this is the same resolution docker compose pull
# performs. It names the images, not the versions of them.
#
# By hand:
# docker compose config --images
function Get-FluxerComposeImages {
$result = Invoke-FluxerCapture @('compose', 'config', '--images')
if ($result.Code -ne 0 -or $result.Text.Length -eq 0) {
return @()
}
Assert-FluxerComposeSelector '--images'
$refs = @()
foreach ($line in $result.Text.Split("`n")) {
foreach ($line in $script:FluxerComposeOut.Split("`n")) {
$candidate = $line.Trim()
if ($candidate.Length -gt 0) {
$refs += $candidate
@@ -794,10 +872,22 @@ function Get-FluxerImageId([string]$Reference) {
#
# By hand:
# docker compose ps -aq | xargs docker inspect --format '{{.Config.Image}} {{.Image}}'
# The text of a captured stream, indented one level for the caller, or a word
# saying there was none.
function Get-FluxerIndented([string]$Text, [string]$Indent) {
if ([string]::IsNullOrWhiteSpace($Text)) {
return "${Indent}nothing"
}
return (($Text -split "`n") | ForEach-Object {"$Indent$_"}) -join "`n"
}
function Get-FluxerRunningImageIds {
$ids = Invoke-FluxerCapture @('compose', 'ps', '-aq')
$map = @{}
if ($ids.Code -ne 0 -or $ids.Text.Length -eq 0) {
if ($ids.Code -ne 0) {
Stop-Fluxer "docker compose ps failed, so what is running cannot be read and the record would name no image ID at all. Compose printed:`n$(Get-FluxerIndented $ids.Error ' ')" $FluxerExitPrerequisite
}
if ($ids.Text.Length -eq 0) {
return $map
}
foreach ($container in $ids.Text.Split("`n")) {
@@ -807,7 +897,7 @@ function Get-FluxerRunningImageIds {
}
$row = Invoke-FluxerCapture @('inspect', '--format', '{{.Config.Image}} {{.Image}}', $candidate)
if ($row.Code -ne 0) {
continue
Stop-Fluxer "docker inspect failed for $candidate, so the image ID under its reference cannot be read and a rollback would have nothing to go back to. Docker printed:`n$(Get-FluxerIndented $row.Error ' ')" $FluxerExitPrerequisite
}
$parts = $row.Text.Trim().Split(' ')
if ($parts.Count -lt 2) {
@@ -839,6 +929,16 @@ function Get-FluxerPostgresMajor([string]$Path) {
return ''
}
# -Force, because Get-Item without it returns nothing for a file the filesystem marks hidden while
# Test-Path still reports that file as present, and the pair reads as a file of zero bytes.
function Get-FluxerFileLength([string]$Path) {
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if ($null -eq $item -or $item.PSIsContainer) {
return 0
}
return [long]$item.Length
}
function Test-FluxerSameFile([string]$Left, [string]$Right) {
if (-not (Test-Path -LiteralPath $Left)) {
return $false
@@ -876,12 +976,9 @@ function Get-FluxerChangedMounts([string]$TargetDir, [string]$StagingDir) {
# By hand:
# docker compose config --services
function Get-FluxerComposeServices {
$result = Invoke-FluxerCapture @('compose', 'config', '--services')
if ($result.Code -ne 0 -or $result.Text.Length -eq 0) {
return @()
}
Assert-FluxerComposeSelector '--services'
$services = @()
foreach ($line in $result.Text.Split("`n")) {
foreach ($line in $script:FluxerComposeOut.Split("`n")) {
$candidate = $line.Trim()
if ($candidate.Length -gt 0) {
$services += $candidate
@@ -890,14 +987,17 @@ function Get-FluxerComposeServices {
return @($services)
}
function Restart-FluxerMounts($Entries) {
function Restart-FluxerMounts($Entries, [string]$TargetDir) {
if (@($Entries).Count -eq 0) {
return
}
$services = Get-FluxerComposeServices
if ((Invoke-FluxerComposeQuery '--services') -ne 0) {
Stop-Fluxer "docker compose config --services failed in $TargetDir, so the services that mount a refreshed file cannot be restarted. Compose printed:`n$(Get-FluxerComposeError ' ')" $FluxerExitUnhealthy
}
$services = @(Get-FluxerComposeServices)
foreach ($entry in $Entries) {
if ($services -notcontains $entry.Service) {
Write-FluxerLine "Skipping the restart of $($entry.Service), because the docker-compose.yml in place defines no service by that name."
Write-FluxerLine "Skipping the restart of $($entry.Service), because the docker-compose.yml in $TargetDir defines no service by that name."
continue
}
Write-FluxerLine "Restarting $($entry.Service), because $($entry.Name) is mounted into it and up -d does not reload a mounted file."
@@ -939,6 +1039,76 @@ function Get-FluxerRecordTag([string]$Record) {
return ([string]$lines[0]).Trim()
}
function Set-FluxerEnvValue([string]$EnvPath, [string]$Name, [string]$Value) {
$lines = @(Get-FluxerEnvLines $EnvPath)
$written = $false
$out = @()
foreach ($line in $lines) {
if ($line.StartsWith("$Name=")) {
$out += "$Name=$Value"
$written = $true
} else {
$out += $line
}
}
if (-not $written) {
$out += "$Name=$Value"
}
Write-FluxerEnvFile $EnvPath $out
}
# Step 0 of an upgrade: mint the values the refreshed stack requires.
#
# Compose stops on a ${NAME:?} it cannot resolve, so a key the running .env does not hold fails
# every command this script runs before it reaches the step that would have reported it. Writing
# the value first is what makes the rest of the run possible. This runs before the record, so the
# .env the record saves is the one that works.
# The keys the run would write, without writing any of them. The plan and the run
# read the same list, so a plan cannot describe a run that does something else.
function Get-FluxerMissingRequiredSecrets([string]$EnvPath) {
$missing = @()
foreach ($entry in $FluxerUpgradeSecretKeys) {
$current = Get-FluxerEnvValue $EnvPath $entry.Name
if ($current.Length -eq 0 -or $current -eq 'CHANGE_ME') {
$missing += $entry.Name
}
}
return @($missing)
}
# A plan writes nothing itself. The run it describes writes .env before it reads
# anything when a required key is absent, and saying otherwise would describe a
# different run.
function Write-FluxerPlanFooter($Missing) {
if ($Missing.Count -gt 0) {
Write-FluxerLine 'This plan wrote nothing. The run it describes writes the keys above into .env before anything else.'
} else {
Write-FluxerLine 'Nothing outside a temporary directory was written.'
}
}
function Add-FluxerRequiredSecrets([string]$EnvPath) {
foreach ($entry in $FluxerUpgradeSecretKeys) {
$current = Get-FluxerEnvValue $EnvPath $entry.Name
if ($current.Length -ne 0 -and $current -ne 'CHANGE_ME') {
continue
}
$value = ''
if ($entry.Kind -eq 'hex') {
$value = ConvertTo-FluxerHex (New-FluxerRandomBytes 32)
} elseif ($entry.Kind -eq 'base64') {
$value = [System.Convert]::ToBase64String((New-FluxerRandomBytes 32))
} else {
Stop-Fluxer "Unknown secret kind $($entry.Kind) for $($entry.Name)." $FluxerExitSecret
}
if ($value.Length -eq 0) {
Stop-Fluxer "Generated an empty value for $($entry.Name)." $FluxerExitSecret
}
Set-FluxerEnvValue $EnvPath $entry.Name $value
Write-FluxerLine "Wrote $($entry.Name) into .env. The refreshed stack requires it and this instance held no usable value."
}
}
function Get-FluxerNewestRecord([string]$BackupRoot) {
if (-not (Test-Path -LiteralPath $BackupRoot)) {
return ''
@@ -970,13 +1140,21 @@ function Write-FluxerTextFile([string]$Path, [string[]]$Lines) {
#
# By hand:
# docker compose images
function Save-FluxerVersionRecord([string]$Record, [string]$EnvPath) {
Write-FluxerTextFile (Join-Path $Record $FluxerTagFile) @((Get-FluxerEnvValue $EnvPath 'FLUXER_IMAGE_TAG'))
# The record directory is created once both refusals above have passed, so a run
# that stops here leaves no record behind. A record with no images file would
# otherwise sort newest and take the place of the last usable one, and a rollback
# reads the newest.
function Save-FluxerVersionRecord([string]$BackupRoot, [string]$EnvPath, [string]$TargetDir) {
$running = Get-FluxerRunningImageIds
if ((Invoke-FluxerComposeQuery '--images') -ne 0) {
Stop-Fluxer "docker compose config --images failed in $TargetDir, so the running version cannot be recorded. Compose printed:`n$(Get-FluxerComposeError ' ')" $FluxerExitPrerequisite
}
$refs = @(Get-FluxerComposeImages)
if ($refs.Count -eq 0) {
Stop-Fluxer 'docker compose config --images returned nothing, so the running version cannot be recorded.' $FluxerExitPrerequisite
Stop-Fluxer "docker compose config --images returned nothing in $TargetDir, so the running version cannot be recorded. The stack files there declare no service with an image." $FluxerExitPrerequisite
}
$running = Get-FluxerRunningImageIds
$Record = New-FluxerRecord $BackupRoot
Write-FluxerTextFile (Join-Path $Record $FluxerTagFile) @((Get-FluxerEnvValue $EnvPath 'FLUXER_IMAGE_TAG'))
$lines = @()
foreach ($reference in $refs) {
$id = Get-FluxerRunningImageId $running $reference
@@ -987,6 +1165,7 @@ function Save-FluxerVersionRecord([string]$Record, [string]$EnvPath) {
}
Write-FluxerTextFile (Join-Path $Record $FluxerImagesFile) $lines
Write-FluxerLine "Recorded $($lines.Count) image references in $Record."
return $Record
}
# .env and the stack files go into the record because nothing else regenerates them. .env holds
@@ -998,8 +1177,11 @@ function Save-FluxerCurrentFiles([string]$Record, [string]$TargetDir, [string]$E
Set-FluxerPrivateFile $envCopy
foreach ($name in $FluxerStackFiles) {
$source = Join-Path $TargetDir $name
if (Test-Path -LiteralPath $source) {
$length = Get-FluxerFileLength $source
if ($length -gt 0) {
Copy-Item -LiteralPath $source -Destination (Join-Path $Record $name) -Force
} elseif (Test-Path -LiteralPath $source) {
Stop-Fluxer "$source is empty, so the record would hold a file a rollback could not use. Put the file back before upgrading." $FluxerExitBackup
}
}
}
@@ -1066,13 +1248,23 @@ function Backup-FluxerDatabase([string]$Record, [string]$TargetDir) {
}
if (-not (Test-FluxerDumpHeader $dump)) {
Remove-FluxerTemporary $dump
Stop-Fluxer 'The dump does not carry the custom-format header. The instance is untouched.' $FluxerExitBackup
Stop-Fluxer 'The dump does not begin with the custom-format header. The instance is untouched.' $FluxerExitBackup
}
Write-FluxerLine "Dumped the database to $dump."
}
$script:FluxerVolumeError = ''
function Get-FluxerVolumeError([string]$Indent) {
if ([string]::IsNullOrWhiteSpace($script:FluxerVolumeError)) {
return "${Indent}nothing"
}
return (($script:FluxerVolumeError -split "`n") | ForEach-Object {"$Indent$_"}) -join "`n"
}
function Get-FluxerVolumeSizeKb([string]$Volume) {
$result = Invoke-FluxerCapture @('run', '--rm', '-v', "${Volume}:/data:ro", $FluxerHelperImage, 'du', '-sk', '/data')
$script:FluxerVolumeError = $result.Error
if ($result.Code -ne 0) {
return -1
}
@@ -1100,27 +1292,32 @@ function Get-FluxerFreeKb([string]$Path) {
# docker run --rm -v fluxer_seaweedfs-data:/data -v "${PWD}\backups:/backup" alpine tar czf /backup/seaweedfs-data.tgz -C /data .
# docker compose up -d
function Copy-FluxerVolumes([string]$Record, [string]$Project) {
$present = @()
foreach ($volume in $FluxerBackupVolumes) {
$full = "${Project}_$volume"
$inspect = Invoke-FluxerCapture @('volume', 'inspect', $full)
if ($inspect.Code -ne 0) {
Stop-Fluxer "The volume $full does not exist, so the uploads cannot be copied." $FluxerExitBackup
Stop-Fluxer "The volume $full does not exist, so the uploads cannot be copied. The stack declares that volume, so this is a project name other than $Project or a volume that was removed. Pass -NoVolumeBackup to take the database dump alone when the uploads of this instance live somewhere this script cannot reach." $FluxerExitBackup
}
$size = Get-FluxerVolumeSizeKb $full
if ($size -lt 0) {
Stop-Fluxer "Cannot measure the volume $full." $FluxerExitBackup
Stop-Fluxer "Cannot measure the volume $full, so the uploads copy cannot be sized. Pass -NoVolumeBackup to take the database dump alone. Docker printed:`n$(Get-FluxerVolumeError ' ')" $FluxerExitBackup
}
$free = Get-FluxerFreeKb $Record
$need = [long]($size * $FluxerVolumeHeadroomPercent / 100)
if ($free -lt $need) {
Stop-Fluxer "$full holds $([long]($size / 1024)) MB and $Record has $([long]($free / 1024)) MB free. Point -BackupDir at a drive with room, or pass -NoVolumeBackup to take the database dump alone." $FluxerExitBackup
}
$present += $volume
}
if ($present.Count -eq 0) {
return
}
Write-FluxerLine 'Stopping the stack for a consistent copy of the uploads.'
if ((Invoke-FluxerDocker @('compose', 'stop')) -ne 0) {
Stop-Fluxer 'docker compose stop failed.' $FluxerExitBackup
}
foreach ($volume in $FluxerBackupVolumes) {
foreach ($volume in $present) {
$full = "${Project}_$volume"
Write-FluxerLine "Copying $full."
$code = Invoke-FluxerDocker @('run', '--rm', '-v', "${full}:/data:ro", '-v', "${Record}:/backup", $FluxerHelperImage, 'tar', 'czf', "/backup/$volume.tgz", '-C', '/data', '.')
@@ -1166,7 +1363,7 @@ function Assert-FluxerPostgresMajor([string]$TargetDir, [string]$StagingDir) {
# Puts one line of .env back, and proves it touched nothing else.
#
# FLUXER_IMAGE_TAG is the only key either upgrade mode writes. Every other line, which is every
# FLUXER_IMAGE_TAG is the only key this function writes. Every other line, which is every
# secret, is compared before the new file replaces the old one, so a rewrite that lost or changed
# a secret cannot land.
function Set-FluxerImageTag([string]$EnvPath, [string]$Tag) {
@@ -1199,12 +1396,31 @@ function Show-FluxerUpdatePlan([string]$TargetDir, [string]$EnvPath, [string]$Ba
Write-FluxerLine " Ref: $Ref"
Write-FluxerLine " Image tag: $(Get-FluxerEnvValue $EnvPath 'FLUXER_IMAGE_TAG') from .env"
Write-FluxerLine " Backup dir: $BackupRoot"
Write-FluxerLine ' Running now:'
$missing = Get-FluxerMissingRequiredSecrets $EnvPath
if ($missing.Count -gt 0) {
Write-FluxerLine ' Writes .env: the run mints these before it reads anything, because the refreshed stack requires them'
foreach ($name in $missing) {
Write-FluxerLine " $name"
}
}
$running = Get-FluxerRunningImageIds
if ((Invoke-FluxerComposeQuery '--images') -ne 0) {
Write-FluxerLine ' Refusal: docker compose config --images fails here, and step 1 of the upgrade reads that list'
Write-FluxerLine ' Compose said:'
Write-FluxerLine (Get-FluxerComposeError ' ')
if ($missing.Count -gt 0) {
Write-FluxerLine ' Outcome: the run writes the keys above first, which may be what Compose is missing, so this refusal may not stand'
} else {
Write-FluxerLine ' Outcome: the run stops on step 1 and changes nothing'
}
Write-FluxerPlanFooter $missing
return
}
Write-FluxerLine ' Running now:'
foreach ($reference in Get-FluxerComposeImages) {
$id = Get-FluxerRunningImageId $running $reference
if ($id.Length -eq 0) {
$id = 'no container carries this image'
$id = 'no container runs this image'
}
Write-FluxerLine " $reference $id"
}
@@ -1244,7 +1460,7 @@ function Show-FluxerUpdatePlan([string]$TargetDir, [string]$EnvPath, [string]$Ba
if ($old.Length -gt 0 -and $new.Length -gt 0 -and $old -ne $new) {
Write-FluxerLine " Refusal: postgres moves from $old to $new, which this script does not do"
Write-FluxerLine ' Outcome: the run stops at that refusal and changes nothing'
Write-FluxerLine 'Nothing outside a temporary directory was written.'
Write-FluxerPlanFooter $missing
return
}
foreach ($entry in Get-FluxerChangedMounts $TargetDir $staging) {
@@ -1254,7 +1470,8 @@ function Show-FluxerUpdatePlan([string]$TargetDir, [string]$EnvPath, [string]$Ba
Remove-FluxerStagingDirectory $staging
}
Write-FluxerLine ' Commands: docker compose pull, docker compose up -d'
Write-FluxerLine 'Nothing outside a temporary directory was written. Drop -DryRun to run this.'
Write-FluxerPlanFooter $missing
Write-FluxerLine 'Drop -DryRun to run this.'
}
function Show-FluxerRollbackPlan([string]$TargetDir, [string]$EnvPath, [string]$BackupRoot) {
@@ -1293,8 +1510,8 @@ function Show-FluxerRollbackPlan([string]$TargetDir, [string]$EnvPath, [string]$
# The full upgrade, in the order that leaves a working instance behind at every point it can fail.
function Invoke-FluxerUpgrade([string]$TargetDir, [string]$EnvPath, [string]$BackupRoot, [string]$Project) {
$record = New-FluxerRecord $BackupRoot
Save-FluxerVersionRecord $record $EnvPath
Add-FluxerRequiredSecrets $EnvPath
$record = Save-FluxerVersionRecord $BackupRoot $EnvPath $TargetDir
Save-FluxerCurrentFiles $record $TargetDir $EnvPath
Backup-FluxerInstance $record $TargetDir $Project
@@ -1339,7 +1556,7 @@ function Invoke-FluxerUpgrade([string]$TargetDir, [string]$EnvPath, [string]$Bac
if ((Invoke-FluxerDocker @('compose', 'up', '-d', '--remove-orphans')) -ne 0) {
Stop-Fluxer 'docker compose up -d failed. Read docker compose logs.' $FluxerExitUnhealthy
}
Restart-FluxerMounts $changedMounts
Restart-FluxerMounts $changedMounts $TargetDir
Wait-FluxerStack 'Waiting for every service to report ready.'
$domainValue = Get-FluxerEnvValue $EnvPath 'FLUXER_DOMAIN'
if ($domainValue.Length -gt 0) {
@@ -1413,8 +1630,11 @@ function Invoke-FluxerRollback([string]$TargetDir, [string]$EnvPath, [string]$Ba
foreach ($name in $FluxerStackFiles) {
$source = Join-Path $record $name
if (Test-Path -LiteralPath $source) {
$length = Get-FluxerFileLength $source
if ($length -gt 0) {
Copy-Item -LiteralPath $source -Destination (Join-Path $TargetDir $name) -Force
} elseif (Test-Path -LiteralPath $source) {
Stop-Fluxer "$source is empty, so restoring it would replace a working file with nothing. Nothing was restored. Take the file from another record or from the ref the record names." $FluxerExitRefused
}
}
Write-FluxerLine "Stack files in $TargetDir are the ones the record holds."
@@ -1425,7 +1645,7 @@ function Invoke-FluxerRollback([string]$TargetDir, [string]$EnvPath, [string]$Ba
}
# The mounted file came back from the record, so its service restarts. Comparing it first
# would save one restart and cost the reader a reason.
Restart-FluxerMounts $FluxerMountedFiles
Restart-FluxerMounts $FluxerMountedFiles $TargetDir
Wait-FluxerStack 'Waiting for every service to report ready.'
$domainValue = Get-FluxerEnvValue $EnvPath 'FLUXER_DOMAIN'
if ($domainValue.Length -gt 0) {
@@ -1441,6 +1661,88 @@ function Invoke-FluxerRollback([string]$TargetDir, [string]$EnvPath, [string]$Ba
exit 0
}
function Assert-FluxerInstance([string]$TargetDir, [string]$EnvPath) {
if (-not (Test-Path -LiteralPath $EnvPath)) {
Stop-Fluxer "No .env in $TargetDir. That directory holds no instance. Run install.ps1 with neither -Update nor -Rollback to set one up." $FluxerExitPrerequisite
}
$compose = Join-Path $TargetDir 'docker-compose.yml'
if (-not (Test-Path -LiteralPath $compose)) {
Stop-Fluxer "No docker-compose.yml in $TargetDir. That directory does not hold an instance." $FluxerExitPrerequisite
}
if ((Get-FluxerFileLength $compose) -eq 0) {
Stop-Fluxer "$compose is empty. A redirect that captured a failed download leaves that, and Compose refuses an empty compose file. Put the file back from a backup or from the record of the last upgrade, then run this again." $FluxerExitPrerequisite
}
}
# Compose reads COMPOSE_FILE from .env and loads every file it names before it answers anything, so
# one file the directory does not hold fails every docker compose command run in it. What Compose
# prints for that is a single stat line that names neither COMPOSE_FILE nor .env.
#
# Two of the files this script downloads sit in .env.example as a COMPOSE_FILE line to uncomment,
# so an instance set up before this script existed can hold the line and not the file. An upgrade
# reads the running images before it refreshes the stack files, so such an instance stops on the
# first step and no re-run gets any further.
#
# The line stays. Without the file it names the edge container binds 80 and 443 and requests its
# own certificate.
#
# By hand:
# Select-String COMPOSE_FILE .env
# A value as Compose reads it: no surrounding quotes and no trailing blanks.
# Get-FluxerEnvLines already drops a carriage return. Reading it any other way
# invents a filename the operator cannot see and refuses a run that would have
# worked.
function Get-FluxerEnvScalar([string]$EnvPath, [string]$Name) {
$raw = (Get-FluxerEnvValue $EnvPath $Name).TrimEnd()
if ($raw.Length -ge 2) {
if (($raw[0] -eq '"' -and $raw[-1] -eq '"') -or ($raw[0] -eq "'" -and $raw[-1] -eq "'")) {
return $raw.Substring(1, $raw.Length - 2)
}
}
return $raw
}
function Assert-FluxerComposeFiles([string]$TargetDir, [string]$EnvPath) {
$value = ''
$source = 'the environment'
if ($null -ne $env:COMPOSE_FILE) {
$value = [string]$env:COMPOSE_FILE
}
if ($value.Length -eq 0) {
$value = Get-FluxerEnvScalar $EnvPath 'COMPOSE_FILE'
$source = $EnvPath
}
if ($value.Length -eq 0) {
return
}
$separator = ''
if ($null -ne $env:COMPOSE_PATH_SEPARATOR) {
$separator = [string]$env:COMPOSE_PATH_SEPARATOR
}
if ($separator.Length -eq 0) {
$separator = Get-FluxerEnvScalar $EnvPath 'COMPOSE_PATH_SEPARATOR'
}
if ($separator.Length -eq 0) {
$separator = [System.IO.Path]::PathSeparator
}
foreach ($name in $value.Split([string[]]$separator, [System.StringSplitOptions]::None)) {
if ($name.Length -eq 0) {
continue
}
$path = $name
if (-not [System.IO.Path]::IsPathRooted($path)) {
$path = Join-Path $TargetDir $name
}
if (Test-Path -LiteralPath $path) {
continue
}
if ($FluxerStackFiles -contains $name) {
Stop-Fluxer "COMPOSE_FILE from $source names $name and $path is not there, so every docker compose command in $TargetDir fails and this run stops before it changes anything. This script downloads $name, and an instance set up before it existed does not hold that file yet. Put it in place and run this again:`n Invoke-WebRequest -Uri $FluxerRawBase/$Ref/$FluxerStackPath/$name -OutFile $path -UseBasicParsing`nLeave the COMPOSE_FILE line as it is. Without $name the edge container binds 80 and 443 and requests its own certificate." $FluxerExitPrerequisite
}
Stop-Fluxer "COMPOSE_FILE from $source names $name and $path is not there, so every docker compose command in $TargetDir fails. This script does not download $name. Put that file back, or take it out of the COMPOSE_FILE line." $FluxerExitPrerequisite
}
}
function Invoke-FluxerInstall {
if ($Help) {
Show-FluxerUsage
@@ -1478,8 +1780,17 @@ function Invoke-FluxerInstall {
Invoke-FluxerPreflight
$targetPath = $Dir
$adoptedCwd = $false
if ($targetPath.Length -eq 0) {
$targetPath = Join-Path $HOME 'fluxer'
$here = (Get-Location).Path
$hereEnv = Join-Path $here '.env'
$hereIsFluxer = (Test-Path -LiteralPath $hereEnv) -and (@(Get-FluxerEnvLines $hereEnv | Where-Object {$_.StartsWith('FLUXER_')}).Count -gt 0)
if (($Update -or $Rollback) -and (Get-FluxerFileLength (Join-Path $here 'docker-compose.yml')) -gt 0 -and $hereIsFluxer) {
$targetPath = $here
$adoptedCwd = $true
} else {
$targetPath = Join-Path $HOME 'fluxer'
}
}
$targetDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($targetPath)
$envPath = Join-Path $targetDir '.env'
@@ -1488,18 +1799,17 @@ function Invoke-FluxerInstall {
$backupPath = Join-Path $targetDir 'backups'
}
$backupRoot = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($backupPath)
if ($adoptedCwd) {
Write-FluxerLine "Acting on the instance in $targetDir, the working directory. Pass -Dir to name another."
}
if ($Update -or $Rollback) {
if (-not (Test-Path -LiteralPath $envPath)) {
Stop-Fluxer "No .env in $targetDir. That directory holds no instance. Run install.ps1 with neither -Update nor -Rollback to set one up." $FluxerExitPrerequisite
}
if (-not (Test-Path -LiteralPath (Join-Path $targetDir 'docker-compose.yml'))) {
Stop-Fluxer "No docker-compose.yml in $targetDir. That directory does not hold an instance." $FluxerExitPrerequisite
}
Assert-FluxerInstance $targetDir $envPath
if ($Ref.Length -eq 0) {
$script:Ref = Get-FluxerRefForTag (Get-FluxerEnvValue $envPath 'FLUXER_IMAGE_TAG')
Assert-FluxerDerivedRef $Ref $envPath
}
Assert-FluxerComposeFiles $targetDir $envPath
$project = Get-FluxerComposeProject $targetDir
if ($project.Length -eq 0) {
Stop-Fluxer "docker-compose.yml in $targetDir declares no project name, so the volume names cannot be derived." $FluxerExitPrerequisite
+418 -76
View File
@@ -22,8 +22,9 @@
# an operator types to do that step by hand, and the reason the step exists.
#
# Read this file before you run it. The default mode writes .env, which holds
# every secret the instance has. The upgrade generates no secret and rewrites no
# secret. The only line either upgrade mode ever changes in .env is
# every secret the instance has. The upgrade generates a secret only for a key
# the refreshed stack requires that .env does not hold, and rewrites no
# secret. The only value either upgrade mode ever replaces in .env is
# FLUXER_IMAGE_TAG, and only during a rollback that moves off a pinned tag.
#
# Rewriting a secret in .env against volumes that already exist is the one thing
@@ -52,7 +53,15 @@ export LC_ALL
FLUXER_RAW_BASE='https://raw.githubusercontent.com/fluxerapp/fluxer'
FLUXER_STACK_PATH='deploy/self-hosting'
FLUXER_MIN_ENGINE='24.0.0'
# Podman numbers its releases on its own scale, so the Docker Engine floor says
# nothing about it. 5.0 is the first release that runs this stack's compose file
# as written, healthcheck conditions and all.
FLUXER_MIN_PODMAN='5.0.0'
FLUXER_MIN_COMPOSE='2.20.2'
# Both overlays this script downloads use the !override tag, which Compose learned
# in 2.24.4. A stack that loads neither runs on the lower minimum, so the higher
# one is required only once COMPOSE_FILE names more than one file.
FLUXER_MIN_COMPOSE_OVERLAY='2.24.4'
FLUXER_READY_TIMEOUT=600
FLUXER_READY_INTERVAL=5
FLUXER_VAPID_ATTEMPTS=8
@@ -169,7 +178,11 @@ Usage: sh install.sh --domain <host> --email <address> [options]
Options:
--domain <host> Hostname the instance answers on. Prompted when absent.
--email <address> Address the operator reads. Prompted when absent.
--dir <path> Working directory. Default ~/fluxer.
--engine <command> Container engine to drive. Default docker, or podman
when docker is absent.
--dir <path> Working directory. Default ~/fluxer, or the working
directory itself when --update or --rollback is given
and it holds an instance.
--ref <git ref> Ref the stack files come from. Default: the image tag,
and main when that tag is v1 or latest.
--image-tag <tag> Image tag the stack runs. Default v1.
@@ -177,7 +190,7 @@ Options:
--edge-bind <addr:port> Plain HTTP bind under --tls proxy. Default 127.0.0.1:8080.
--non-interactive Never prompt. A missing required value is an error.
--dry-run Print the plan. Change nothing.
--no-start Write everything and skip docker compose up -d.
--no-start Write everything and skip $fluxer_engine compose up -d.
--update Upgrade: record, back up, refresh, pull, recreate, verify.
--rollback Restore the images and stack files of the last record.
--backup-dir <path> Where records go. Default <dir>/backups.
@@ -240,6 +253,9 @@ trap fluxer_on_signal TERM
opt_domain=''
opt_email=''
opt_dir=''
opt_engine=''
fluxer_engine='docker'
fluxer_engine_label='Docker Engine'
opt_ref=''
opt_image_tag='v1'
opt_tls='bundled'
@@ -266,6 +282,11 @@ while [ $# -gt 0 ]; do
opt_email=$2
shift 2
;;
--engine)
fluxer_take_value '--engine' $# "${2:-}"
opt_engine=$2
shift 2
;;
--dir)
fluxer_take_value '--dir' $# "${2:-}"
opt_dir=$2
@@ -338,8 +359,25 @@ while [ $# -gt 0 ]; do
esac
done
# An upgrade acts on an instance that already exists, and the directory holding
# one is recognisable: a compose file with content, an .env beside it, and at
# least one FLUXER_ key in that .env. Taking the working directory when it holds
# those is what an operator standing in their own instance means, and it is the
# only case where the default is not ~/fluxer.
#
# The FLUXER_ test is what keeps this off somebody else's stack. A compose file
# and an .env together describe every Compose project there is, and an upgrade
# replaces the stack files in the directory it acts on, so a looser test would
# overwrite a project that has nothing to do with Fluxer.
#
# An install writes a new instance, so it never adopts the working directory.
if [ -z "$opt_dir" ]; then
if [ -n "${HOME:-}" ]; then
if { [ "$opt_update" -eq 1 ] || [ "$opt_rollback" -eq 1 ]; } &&
[ -s "$(pwd)/docker-compose.yml" ] && [ -e "$(pwd)/.env" ] &&
grep -q '^FLUXER_' "$(pwd)/.env" 2>/dev/null; then
opt_dir="$(pwd)"
fluxer_say "Acting on the instance in $opt_dir, the working directory. Pass --dir to name another."
elif [ -n "${HOME:-}" ]; then
opt_dir="$HOME/fluxer"
else
opt_dir="$(pwd)/fluxer"
@@ -443,29 +481,59 @@ fluxer_version_ge() {
fluxer_engine_version=''
fluxer_compose_version=''
# Every engine that speaks the Docker CLI reports itself as "<name> version X".
# Docker says "Docker version 28.0.0, build ...", Podman says "podman version
# 5.8.4", and the podman-docker shim answers as Podman under the name docker.
# Reading the name as well as the number is what lets the floor below belong to
# the engine actually in use, because Podman's 5.x is not Docker's 5.x.
fluxer_resolve_engine() {
if [ -n "$opt_engine" ]; then
fluxer_engine=$opt_engine
elif command -v docker >/dev/null 2>&1; then
fluxer_engine='docker'
elif command -v podman >/dev/null 2>&1; then
fluxer_engine='podman'
else
fluxer_fail 2 "Neither docker nor podman is installed. $(fluxer_docker_hint)"
fi
if ! command -v "$fluxer_engine" >/dev/null 2>&1; then
fluxer_fail 2 "$fluxer_engine is not installed. Pass --engine with the command that drives your containers."
fi
}
fluxer_preflight() {
if [ "$opt_allow_root" -eq 0 ] && [ "$(id -u)" -eq 0 ]; then
fluxer_fail 2 'Running as root. Use an account in the docker group, or pass --allow-root.'
fi
if ! command -v docker >/dev/null 2>&1; then
fluxer_fail 2 "Docker is not installed. $(fluxer_docker_hint)"
fluxer_resolve_engine
if ! $fluxer_engine compose version >/dev/null 2>&1; then
fluxer_fail 2 "$fluxer_engine has no compose subcommand. $(fluxer_docker_hint)"
fi
if ! docker compose version >/dev/null 2>&1; then
fluxer_fail 2 "The Docker Compose v2 plugin is missing. $(fluxer_docker_hint)"
fi
fluxer_engine_version=$(docker --version 2>/dev/null | sed -n 's/^Docker version \([0-9][0-9.]*\).*/\1/p')
fluxer_compose_version=$(docker compose version --short 2>/dev/null | sed -n 's/^v\{0,1\}\([0-9][0-9.]*\).*/\1/p')
fluxer_engine_report=$($fluxer_engine --version 2>/dev/null)
fluxer_engine_kind=$(printf '%s\n' "$fluxer_engine_report" | sed -n 's/^\([A-Za-z][A-Za-z]*\) version .*/\1/p' | tr 'A-Z' 'a-z')
fluxer_engine_version=$(printf '%s\n' "$fluxer_engine_report" | sed -n 's/^[A-Za-z][A-Za-z]* version v\{0,1\}\([0-9][0-9.]*\).*/\1/p')
fluxer_compose_version=$($fluxer_engine compose version --short 2>/dev/null | sed -n 's/^v\{0,1\}\([0-9][0-9.]*\).*/\1/p')
if [ -z "$fluxer_engine_version" ] || [ -z "$fluxer_compose_version" ]; then
fluxer_fail 2 'Cannot read the Docker Engine and Compose versions.'
fluxer_fail 2 "Cannot read the engine and Compose versions from $fluxer_engine. It answered \"$fluxer_engine_report\" to --version."
fi
if ! fluxer_version_ge "$fluxer_engine_version" "$FLUXER_MIN_ENGINE"; then
fluxer_fail 2 "Docker Engine $fluxer_engine_version with Compose $fluxer_compose_version. Fluxer needs Engine $FLUXER_MIN_ENGINE or newer."
case $fluxer_engine_kind in
podman)
fluxer_engine_label='Podman'
fluxer_min_engine=$FLUXER_MIN_PODMAN
;;
*)
fluxer_engine_label='Docker Engine'
fluxer_min_engine=$FLUXER_MIN_ENGINE
;;
esac
if ! fluxer_version_ge "$fluxer_engine_version" "$fluxer_min_engine"; then
fluxer_fail 2 "$fluxer_engine_label $fluxer_engine_version with Compose $fluxer_compose_version. Fluxer needs $fluxer_engine_label $fluxer_min_engine or newer."
fi
if ! fluxer_version_ge "$fluxer_compose_version" "$FLUXER_MIN_COMPOSE"; then
fluxer_fail 2 "Docker Engine $fluxer_engine_version with Compose $fluxer_compose_version. Fluxer needs Compose $FLUXER_MIN_COMPOSE or newer."
fluxer_fail 2 "$fluxer_engine_label $fluxer_engine_version with Compose $fluxer_compose_version. Fluxer needs Compose $FLUXER_MIN_COMPOSE or newer."
fi
if ! docker info >/dev/null 2>&1; then
fluxer_fail 2 'The Docker daemon does not answer. Start Docker and run this again.'
if ! $fluxer_engine info >/dev/null 2>&1; then
fluxer_fail 2 "$fluxer_engine does not answer. Start it and run this again."
fi
if ! command -v curl >/dev/null 2>&1; then
fluxer_fail 2 "curl is not installed. $(fluxer_host_tool_hint curl)"
@@ -473,7 +541,7 @@ fluxer_preflight() {
if ! command -v openssl >/dev/null 2>&1; then
fluxer_fail 2 "openssl is not installed. $(fluxer_host_tool_hint openssl)"
fi
fluxer_say "Docker Engine $fluxer_engine_version with Compose $fluxer_compose_version."
fluxer_say "$fluxer_engine_label $fluxer_engine_version with Compose $fluxer_compose_version."
}
fluxer_valid_domain() {
@@ -661,7 +729,7 @@ fluxer_fetch_stack() {
fluxer_fail 4 "Downloaded $fluxer_file is empty."
fi
if [ "$fluxer_file" = 'docker-compose.yml' ] && ! grep -q '^services:' "$fluxer_part"; then
fluxer_fail 4 'The downloaded docker-compose.yml carries no services block.'
fluxer_fail 4 "The docker-compose.yml downloaded from $opt_ref holds no services block."
fi
done < "$fluxer_scratch/files"
}
@@ -711,9 +779,9 @@ fluxer_check_volumes() {
if [ -z "$fluxer_project" ]; then
return 0
fi
docker volume ls -q --filter "label=com.docker.compose.project=$fluxer_project" > "$fluxer_scratch/volumes" 2>/dev/null || return 0
$fluxer_engine volume ls -q --filter "label=com.docker.compose.project=$fluxer_project" > "$fluxer_scratch/volumes" 2>/dev/null || return 0
if [ -s "$fluxer_scratch/volumes" ]; then
fluxer_fail 3 "Docker already holds volumes for the $fluxer_project project. They carry the secrets of an earlier install, and this .env does not open them. Run install.sh --update in the directory that holds that instance, or remove the volumes with docker volume rm before you install again."
fluxer_fail 3 "Docker already holds volumes for the $fluxer_project project. They hold the secrets of an earlier install, and this .env does not open them. Run install.sh --update in the directory that holds that instance, or remove the volumes with $fluxer_engine volume rm before you install again."
fi
}
@@ -821,9 +889,9 @@ fluxer_write_env() {
}
fluxer_stack_ready() {
docker compose ps -aq > "$fluxer_scratch/ids" 2>/dev/null || return 1
$fluxer_engine compose ps -aq > "$fluxer_scratch/ids" 2>/dev/null || return 1
[ -s "$fluxer_scratch/ids" ] || return 1
xargs docker inspect --format "$FLUXER_INSPECT_FORMAT" < "$fluxer_scratch/ids" > "$fluxer_scratch/state" 2>/dev/null || return 1
xargs $fluxer_engine inspect --format "$FLUXER_INSPECT_FORMAT" < "$fluxer_scratch/ids" > "$fluxer_scratch/state" 2>/dev/null || return 1
fluxer_ready=1
fluxer_init_done=0
while read -r fluxer_service fluxer_status fluxer_health fluxer_code; do
@@ -886,6 +954,97 @@ fluxer_env_value() {
sed -n "s/^$1=\\(.*\\)\$/\\1/p" "$opt_dir/.env" | head -n 1
}
# The keys a refreshed stack requires that an .env written by an older installer
# does not hold. Only keys with no state anywhere else are here. A value like
# POSTGRES_PASSWORD is matched by a password stored inside the database, so
# minting a new one locks the stack out of its own data and it is not listed.
#
# CHANGE_ME counts as absent. .env.example shipped the relay secret with that
# value for a while, and nothing read it until the API began refusing to start
# on a value under 32 bytes.
fluxer_upgrade_secret_keys() {
cat <<'KEYS'
FLUXER_ERLANG_COOKIE hex
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 base64
KEYS
}
# One key written into .env, through a temporary file that replaces the whole
# file at once.
#
# Appending is not an option. A .env whose last line has no newline takes an
# appended line onto the end of that line instead, which reads as one key whose
# value ends in the name of the next, and destroys the secret that was there.
# awk ends every line it prints, so reading the file and writing it back gives
# the missing newline as a side effect.
#
# The replacement is a rename, so an interrupt leaves the old file whole rather
# than a truncated one, and this runs before the record exists.
fluxer_env_set() {
fluxer_old_umask=$(umask)
umask 077
awk -v fluxer_k="$1" -v fluxer_v="$2" '
index($0, fluxer_k "=") == 1 && !fluxer_done {print fluxer_k "=" fluxer_v; fluxer_done = 1; next}
{print}
END {if (!fluxer_done) print fluxer_k "=" fluxer_v}
' "$opt_dir/.env" > "$fluxer_scratch/env.set"
if [ ! -s "$fluxer_scratch/env.set" ]; then
umask "$fluxer_old_umask"
fluxer_fail 5 "Rewriting $1 into .env produced an empty file. Nothing was written."
fi
if ! grep -q "^$1=" "$fluxer_scratch/env.set"; then
umask "$fluxer_old_umask"
fluxer_fail 5 "Rewriting $1 into .env did not write that key. Nothing was written."
fi
mv "$fluxer_scratch/env.set" "$opt_dir/.env"
chmod 600 "$opt_dir/.env"
umask "$fluxer_old_umask"
}
# Step 0 of an upgrade: mint the values the refreshed stack requires.
#
# Compose stops on a ${NAME:?} it cannot resolve, so a key the running .env does
# not hold fails every command this script runs before it reaches the step that
# would have reported it. Writing the value first is what makes the rest of the
# run possible, and a generated secret is as good as a hand-written one for
# every key listed above.
#
# This runs before the record, so the .env the record saves is the one that
# works and a rollback does not reintroduce the gap.
# The keys the run would write, one per line, without writing any of them. The
# plan and the run read the same list, so a plan cannot describe a run that does
# something else.
fluxer_missing_required_secrets() {
fluxer_upgrade_secret_keys > "$fluxer_scratch/upgrade-keys"
while read -r fluxer_key fluxer_kind; do
[ -n "$fluxer_key" ] || continue
fluxer_current=$(fluxer_env_value "$fluxer_key")
case ${fluxer_current:-} in
''|CHANGE_ME) printf '%s\n' "$fluxer_key" ;;
esac
done < "$fluxer_scratch/upgrade-keys"
}
fluxer_fill_required_secrets() {
fluxer_upgrade_secret_keys > "$fluxer_scratch/upgrade-keys"
while read -r fluxer_key fluxer_kind; do
[ -n "$fluxer_key" ] || continue
fluxer_current=$(fluxer_env_value "$fluxer_key")
case ${fluxer_current:-} in
''|CHANGE_ME) ;;
*) continue ;;
esac
case $fluxer_kind in
hex) fluxer_new=$(openssl rand -hex 32) ;;
base64) fluxer_new=$(openssl rand -base64 32) ;;
*) fluxer_fail 5 "Unknown secret kind $fluxer_kind for $fluxer_key." ;;
esac
[ -n "$fluxer_new" ] || fluxer_fail 5 "Generated an empty value for $fluxer_key."
fluxer_env_set "$fluxer_key" "$fluxer_new"
fluxer_say "Wrote $fluxer_key into .env. The refreshed stack requires it and this instance held no usable value."
done < "$fluxer_scratch/upgrade-keys"
}
fluxer_require_instance() {
if [ ! -e "$opt_dir/.env" ]; then
fluxer_fail 2 "No .env in $opt_dir. That directory holds no instance. Run install.sh with neither --update nor --rollback to set one up."
@@ -893,6 +1052,111 @@ fluxer_require_instance() {
if [ ! -e "$opt_dir/docker-compose.yml" ]; then
fluxer_fail 2 "No docker-compose.yml in $opt_dir. That directory does not hold an instance."
fi
if [ ! -s "$opt_dir/docker-compose.yml" ]; then
fluxer_fail 2 "$opt_dir/docker-compose.yml is empty. A redirect that captured a failed download leaves that, and Compose refuses an empty compose file. Put the file back from a backup or from the record of the last upgrade, then run this again."
fi
}
# Compose reads COMPOSE_FILE from .env and loads every file it names before it
# answers anything, so one file the directory does not hold fails every docker
# compose command run in it. What Compose prints for that is a single stat line
# that names neither COMPOSE_FILE nor .env.
#
# Two of the files this script downloads sit in .env.example as a COMPOSE_FILE
# line to uncomment, so an instance set up before this script existed can hold
# the line and not the file. An upgrade reads the running images before it
# refreshes the stack files, so such an instance stops on the first step and no
# re-run gets any further.
#
# The line stays. Without the file it names the edge container binds 80 and 443
# and requests its own certificate.
#
# By hand:
# grep COMPOSE_FILE .env
# A value as Compose reads it: no surrounding quotes, no carriage return from an
# editor that writes CRLF, no trailing blanks. Reading it any other way invents a
# filename the operator cannot see and refuses a run that would have worked.
fluxer_env_scalar() {
fluxer_scalar=$(fluxer_env_value "$1" | tr -d '\r')
fluxer_scalar=${fluxer_scalar%"${fluxer_scalar##*[! ]}"}
case $fluxer_scalar in
\"*\") fluxer_scalar=${fluxer_scalar#\"}; fluxer_scalar=${fluxer_scalar%\"} ;;
"'"*"'") fluxer_scalar=${fluxer_scalar#"'"}; fluxer_scalar=${fluxer_scalar%"'"} ;;
esac
printf '%s' "$fluxer_scalar"
}
fluxer_require_compose_files() {
fluxer_compose_file=${COMPOSE_FILE:-}
fluxer_compose_from="the environment"
if [ -z "$fluxer_compose_file" ]; then
fluxer_compose_file=$(fluxer_env_scalar COMPOSE_FILE)
fluxer_compose_from="$opt_dir/.env"
fi
[ -n "$fluxer_compose_file" ] || return 0
fluxer_path_sep=${COMPOSE_PATH_SEPARATOR:-}
if [ -z "$fluxer_path_sep" ]; then
fluxer_path_sep=$(fluxer_env_scalar COMPOSE_PATH_SEPARATOR)
fi
if [ -z "$fluxer_path_sep" ]; then
fluxer_path_sep=':'
fi
fluxer_compose_count=0
fluxer_rest=$fluxer_compose_file
while [ -n "$fluxer_rest" ]; do
case $fluxer_rest in
*"$fluxer_path_sep"*)
fluxer_name=${fluxer_rest%%"$fluxer_path_sep"*}
fluxer_rest=${fluxer_rest#*"$fluxer_path_sep"}
;;
*)
fluxer_name=$fluxer_rest
fluxer_rest=''
;;
esac
[ -n "$fluxer_name" ] || continue
case $fluxer_name in
/*) fluxer_path=$fluxer_name ;;
*) fluxer_path="$opt_dir/$fluxer_name" ;;
esac
fluxer_compose_count=$((${fluxer_compose_count:-0} + 1))
[ ! -e "$fluxer_path" ] || continue
if fluxer_stack_files | grep -qxF "$fluxer_name"; then
fluxer_fail 2 "COMPOSE_FILE from $fluxer_compose_from names $fluxer_name and $fluxer_path is not there, so every $fluxer_engine compose command in $opt_dir fails and this run stops before it changes anything. This script downloads $fluxer_name, and an instance set up before it existed does not hold that file yet. Put it in place and run this again:
curl -fsSL --proto '=https' --tlsv1.2 -o $fluxer_path $FLUXER_RAW_BASE/$opt_ref/$FLUXER_STACK_PATH/$fluxer_name
Leave the COMPOSE_FILE line as it is. Without $fluxer_name the edge container binds 80 and 443 and requests its own certificate."
fi
fluxer_fail 2 "COMPOSE_FILE from $fluxer_compose_from names $fluxer_name and $fluxer_path is not there, so every $fluxer_engine compose command in $opt_dir fails. This script does not download $fluxer_name. Put that file back, or take it out of the COMPOSE_FILE line."
done
if [ "$fluxer_compose_count" -gt 1 ] &&
! fluxer_version_ge "$fluxer_compose_version" "$FLUXER_MIN_COMPOSE_OVERLAY"; then
fluxer_fail 2 "COMPOSE_FILE from $fluxer_compose_from loads $fluxer_compose_count files and this host runs Compose $fluxer_compose_version. Every overlay this script downloads uses the !override tag, which needs Compose $FLUXER_MIN_COMPOSE_OVERLAY or newer. Upgrade Compose, or load only docker-compose.yml."
fi
}
# One read-only Compose query, with what Compose printed on stderr kept.
#
# Compose loads the whole file set and interpolates every variable in it before
# it answers either query below. A file that is not there, a file it cannot
# read and a required variable .env does not set all fail at that point, and the
# stderr text is the only thing that says which of them it was.
#
# The query writes to files rather than through a pipe, because the status of a
# pipeline is the status of its last command and the query is not the last
# command.
fluxer_compose_query() {
fluxer_query_status=0
$fluxer_engine compose config "$1" > "$fluxer_scratch/compose-out" 2> "$fluxer_scratch/compose-err" || fluxer_query_status=$?
return "$fluxer_query_status"
}
# What Compose printed on the last query, indented one level for the caller.
fluxer_compose_error() {
if [ -s "$fluxer_scratch/compose-err" ]; then
sed "s/^/$1/" "$fluxer_scratch/compose-err"
else
printf '%snothing\n' "$1"
fi
}
# The image references the stack resolves to, one per line, deduplicated. Compose
@@ -902,7 +1166,8 @@ fluxer_require_instance() {
# By hand:
# docker compose config --images
fluxer_compose_images() {
docker compose config --images 2>/dev/null | sort -u
fluxer_compose_query --images || return 1
sort -u "$fluxer_scratch/compose-out"
}
# The image ID each container was actually created from, against the reference
@@ -918,10 +1183,29 @@ fluxer_compose_images() {
#
# By hand:
# docker compose ps -aq | xargs docker inspect --format '{{.Config.Image}} {{.Image}}'
# The text of a captured stream, indented one level for the caller, or a word
# saying there was none. A command that fails without printing is rarer than one
# that prints the reason, and both read the same way here.
fluxer_indent_file() {
if [ -s "$1" ]; then
sed "s/^/$2/" "$1"
else
printf '%snothing\n' "$2"
fi
}
fluxer_running_image_ids() {
docker compose ps -aq > "$fluxer_scratch/containers" 2>/dev/null || return 0
if ! $fluxer_engine compose ps -aq > "$fluxer_scratch/containers" 2> "$fluxer_scratch/ps-err"; then
fluxer_fail 2 "$fluxer_engine compose ps failed in $opt_dir, so what is running cannot be read and the record would name no image ID at all. Compose printed:
$(fluxer_indent_file "$fluxer_scratch/ps-err" ' ')"
fi
[ -s "$fluxer_scratch/containers" ] || return 0
xargs docker inspect --format '{{.Config.Image}} {{.Image}}' < "$fluxer_scratch/containers" 2>/dev/null | sort -u
if ! xargs $fluxer_engine inspect --format '{{.Config.Image}} {{.Image}}' \
< "$fluxer_scratch/containers" > "$fluxer_scratch/inspected" 2> "$fluxer_scratch/inspect-err"; then
fluxer_fail 2 "$fluxer_engine inspect failed in $opt_dir, so the image ID under each reference cannot be read and a rollback would have nothing to go back to. Docker printed:
$(fluxer_indent_file "$fluxer_scratch/inspect-err" ' ')"
fi
sort -u "$fluxer_scratch/inspected"
}
# The recorded ID for one reference, or nothing when no container carries it.
@@ -945,12 +1229,16 @@ fluxer_recorded_id_for() {
# By hand:
# docker compose images
fluxer_record_state() {
printf '%s\n' "$(fluxer_env_value FLUXER_IMAGE_TAG)" > "$fluxer_record/$FLUXER_TAG_FILE"
fluxer_running_image_ids > "$fluxer_scratch/running"
fluxer_compose_images > "$fluxer_scratch/refs"
if [ ! -s "$fluxer_scratch/refs" ]; then
fluxer_fail 2 "docker compose config --images returned nothing in $opt_dir, so the running version cannot be recorded."
if ! fluxer_compose_images > "$fluxer_scratch/refs"; then
fluxer_fail 2 "$fluxer_engine compose config --images failed in $opt_dir, so the running version cannot be recorded. Compose printed:
$(fluxer_compose_error ' ')"
fi
if [ ! -s "$fluxer_scratch/refs" ]; then
fluxer_fail 2 "$fluxer_engine compose config --images returned nothing in $opt_dir, so the running version cannot be recorded. The stack files there declare no service with an image."
fi
fluxer_prepare_record
printf '%s\n' "$(fluxer_env_value FLUXER_IMAGE_TAG)" > "$fluxer_record/$FLUXER_TAG_FILE"
: > "$fluxer_record/$FLUXER_IMAGES_FILE"
while read -r fluxer_ref; do
[ -n "$fluxer_ref" ] || continue
@@ -972,16 +1260,18 @@ fluxer_save_current_files() {
chmod 600 "$fluxer_record/.env"
while read -r fluxer_file; do
[ -n "$fluxer_file" ] || continue
if [ -e "$opt_dir/$fluxer_file" ]; then
if [ -s "$opt_dir/$fluxer_file" ]; then
cp -p "$opt_dir/$fluxer_file" "$fluxer_record/$fluxer_file"
elif [ -e "$opt_dir/$fluxer_file" ]; then
fluxer_fail 7 "$opt_dir/$fluxer_file is empty, so the record would hold a file a rollback could not use. Put the file back before upgrading."
fi
done < "$fluxer_scratch/files"
}
fluxer_postgres_running() {
fluxer_pg_id=$(docker compose ps -q postgres 2>/dev/null | head -n 1)
fluxer_pg_id=$($fluxer_engine compose ps -q postgres 2>/dev/null | head -n 1)
[ -n "$fluxer_pg_id" ] || return 1
[ "$(docker inspect --format '{{.State.Status}}' "$fluxer_pg_id" 2>/dev/null)" = 'running' ]
[ "$($fluxer_engine inspect --format '{{.State.Status}}' "$fluxer_pg_id" 2>/dev/null)" = 'running' ]
}
# Step 2 of an upgrade, and the part that gates the rest.
@@ -1009,25 +1299,35 @@ fluxer_postgres_running() {
fluxer_dump_postgres() {
if ! fluxer_postgres_running; then
fluxer_say 'Postgres is not running. Starting it for the dump.'
if ! docker compose up -d --wait postgres; then
if ! $fluxer_engine compose up -d --wait postgres; then
fluxer_fail 7 "Postgres does not start in $opt_dir, so no dump can be taken."
fi
fi
fluxer_dump_path="$fluxer_record/$FLUXER_DUMP_FILE"
fluxer_say 'Dumping the database.'
if ! docker compose exec -T postgres pg_dump -U fluxer -d fluxer --format=custom > "$fluxer_dump_path"; then
if ! $fluxer_engine compose exec -T postgres pg_dump -U fluxer -d fluxer --format=custom > "$fluxer_dump_path"; then
rm -f "$fluxer_dump_path"
fluxer_fail 7 'pg_dump failed. The instance is untouched.'
fi
if [ "$(head -c 5 "$fluxer_dump_path")" != 'PGDMP' ]; then
rm -f "$fluxer_dump_path"
fluxer_fail 7 'The dump does not carry the custom-format header. The instance is untouched.'
fluxer_fail 7 'The dump does not begin with the custom-format header. The instance is untouched.'
fi
fluxer_say "Dumped the database to $fluxer_dump_path."
}
# What the sizing container printed, indented one level for the caller.
fluxer_volume_error() {
if [ -s "$fluxer_scratch/volume-err" ]; then
sed "s/^/$1/" "$fluxer_scratch/volume-err"
else
printf '%snothing\n' "$1"
fi
}
fluxer_volume_size_kb() {
docker run --rm -v "$1:/data:ro" "$FLUXER_HELPER_IMAGE" du -sk /data 2>/dev/null | awk 'NR==1 {print $1}'
$fluxer_engine run --rm -v "$1:/data:ro" "$FLUXER_HELPER_IMAGE" du -sk /data 2> "$fluxer_scratch/volume-err" |
awk 'NR==1 {print $1}'
}
fluxer_free_kb() {
@@ -1046,16 +1346,19 @@ fluxer_free_kb() {
# docker compose up -d
fluxer_copy_volumes() {
fluxer_backup_volumes > "$fluxer_scratch/backup-volumes"
: > "$fluxer_scratch/copy-volumes"
fluxer_copy_any=0
while read -r fluxer_volume; do
[ -n "$fluxer_volume" ] || continue
fluxer_full="${fluxer_project}_${fluxer_volume}"
if ! docker volume inspect "$fluxer_full" >/dev/null 2>&1; then
fluxer_fail 7 "The volume $fluxer_full does not exist, so the uploads cannot be copied."
if ! $fluxer_engine volume inspect "$fluxer_full" >/dev/null 2>&1; then
fluxer_fail 7 "The volume $fluxer_full does not exist, so the uploads cannot be copied. The stack declares that volume, so this is a project name other than $fluxer_project or a volume that was removed. Pass --no-volume-backup to take the database dump alone when the uploads of this instance live somewhere this script cannot reach."
fi
fluxer_size=$(fluxer_volume_size_kb "$fluxer_full")
case ${fluxer_size:-} in
''|*[!0-9]*) fluxer_fail 7 "Cannot measure the volume $fluxer_full." ;;
''|*[!0-9]*)
fluxer_fail 7 "Cannot measure the volume $fluxer_full, so the uploads copy cannot be sized. Pass --no-volume-backup to take the database dump alone. Docker printed:
$(fluxer_volume_error ' ')" ;;
esac
fluxer_free=$(fluxer_free_kb "$fluxer_record")
case ${fluxer_free:-} in
@@ -1065,27 +1368,28 @@ fluxer_copy_volumes() {
if [ "$fluxer_free" -lt "$fluxer_need" ]; then
fluxer_fail 7 "$fluxer_full holds $((fluxer_size / 1024)) MB and $fluxer_record has $((fluxer_free / 1024)) MB free. Point --backup-dir at a filesystem with room, or pass --no-volume-backup to take the database dump alone."
fi
printf '%s\n' "$fluxer_volume" >> "$fluxer_scratch/copy-volumes"
fluxer_copy_any=1
done < "$fluxer_scratch/backup-volumes"
if [ "$fluxer_copy_any" -eq 0 ]; then
return 0
fi
fluxer_say 'Stopping the stack for a consistent copy of the uploads.'
if ! docker compose stop; then
fluxer_fail 7 "docker compose stop failed in $opt_dir."
if ! $fluxer_engine compose stop; then
fluxer_fail 7 "$fluxer_engine compose stop failed in $opt_dir."
fi
while read -r fluxer_volume; do
[ -n "$fluxer_volume" ] || continue
fluxer_full="${fluxer_project}_${fluxer_volume}"
fluxer_say "Copying $fluxer_full."
if ! docker run --rm -v "$fluxer_full:/data:ro" -v "$fluxer_record:/backup" "$FLUXER_HELPER_IMAGE" tar czf "/backup/$fluxer_volume.tgz" -C /data .; then
docker compose up -d --remove-orphans || true
if ! $fluxer_engine run --rm -v "$fluxer_full:/data:ro" -v "$fluxer_record:/backup" "$FLUXER_HELPER_IMAGE" tar czf "/backup/$fluxer_volume.tgz" -C /data .; then
$fluxer_engine compose up -d --remove-orphans || true
fluxer_fail 7 "Copying $fluxer_full failed. The stack is started again on the images it was running."
fi
done < "$fluxer_scratch/backup-volumes"
done < "$fluxer_scratch/copy-volumes"
fluxer_say 'Starting the stack again before the upgrade continues.'
if ! docker compose up -d --remove-orphans; then
fluxer_fail 7 "docker compose up -d failed in $opt_dir after the copy. Read docker compose logs there."
if ! $fluxer_engine compose up -d --remove-orphans; then
fluxer_fail 7 "$fluxer_engine compose up -d failed in $opt_dir after the copy. Read $fluxer_engine compose logs there."
fi
}
@@ -1153,12 +1457,16 @@ fluxer_note_mounted_changes() {
# By hand:
# docker compose config --services
fluxer_compose_services() {
docker compose config --services 2>/dev/null | sort -u
fluxer_compose_query --services || return 1
sort -u "$fluxer_scratch/compose-out"
}
fluxer_restart_mounted() {
[ -s "$fluxer_scratch/restart" ] || return 0
fluxer_compose_services > "$fluxer_scratch/services"
if ! fluxer_compose_services > "$fluxer_scratch/services"; then
fluxer_fail 6 "$fluxer_engine compose config --services failed in $opt_dir, so the services that mount a refreshed file cannot be restarted. Compose printed:
$(fluxer_compose_error ' ')"
fi
while read -r fluxer_file fluxer_service; do
[ -n "$fluxer_service" ] || continue
if ! grep -qxF "$fluxer_service" "$fluxer_scratch/services"; then
@@ -1166,8 +1474,8 @@ fluxer_restart_mounted() {
continue
fi
fluxer_say "Restarting $fluxer_service, because $fluxer_file is mounted into it and up -d does not reload a mounted file."
if ! docker compose restart "$fluxer_service"; then
fluxer_fail 6 "docker compose restart $fluxer_service failed in $opt_dir."
if ! $fluxer_engine compose restart "$fluxer_service"; then
fluxer_fail 6 "$fluxer_engine compose restart $fluxer_service failed in $opt_dir."
fi
done < "$fluxer_scratch/restart"
}
@@ -1200,7 +1508,7 @@ fluxer_newest_record() {
fluxer_verify_stack() {
fluxer_say 'Waiting for every service to report ready.'
if ! fluxer_wait_ready; then
fluxer_fail 6 "The stack is not ready after $FLUXER_READY_TIMEOUT seconds. Read docker compose logs in $opt_dir."
fluxer_fail 6 "The stack is not ready after $FLUXER_READY_TIMEOUT seconds. Read $fluxer_engine compose logs in $opt_dir."
fi
fluxer_domain_value=$(fluxer_env_value FLUXER_DOMAIN)
if [ -n "$fluxer_domain_value" ]; then
@@ -1208,20 +1516,50 @@ fluxer_verify_stack() {
fi
}
# A plan writes nothing itself. The run it describes writes .env before it reads
# anything when a required key is absent, and saying otherwise would describe a
# different run.
fluxer_plan_footer() {
if [ -s "$fluxer_scratch/plan-secrets" ]; then
fluxer_say 'This plan wrote nothing. The run it describes writes the keys above into .env before anything else.'
else
fluxer_say 'Nothing outside a temporary directory was written.'
fi
}
fluxer_plan_update() {
fluxer_say 'Plan: upgrade'
fluxer_say " directory $opt_dir"
fluxer_say " ref $opt_ref"
fluxer_say " image tag $(fluxer_env_value FLUXER_IMAGE_TAG) from .env"
fluxer_say " backup dir $opt_backup_dir"
fluxer_say ' running now'
fluxer_missing_required_secrets > "$fluxer_scratch/plan-secrets"
if [ -s "$fluxer_scratch/plan-secrets" ]; then
fluxer_say ' writes .env the run mints these before it reads anything, because the refreshed stack requires them'
while read -r fluxer_key; do
[ -n "$fluxer_key" ] || continue
fluxer_say " $fluxer_key"
done < "$fluxer_scratch/plan-secrets"
fi
fluxer_running_image_ids > "$fluxer_scratch/running"
fluxer_compose_images > "$fluxer_scratch/refs" || true
if ! fluxer_compose_images > "$fluxer_scratch/refs"; then
fluxer_say ' refusal $fluxer_engine compose config --images fails here, and step 1 of the upgrade reads that list'
fluxer_say ' compose said'
fluxer_compose_error ' '
if [ -s "$fluxer_scratch/plan-secrets" ]; then
fluxer_say ' outcome the run writes the keys above first, which may be what Compose is missing, so this refusal may not stand'
else
fluxer_say ' outcome the run stops on step 1 and changes nothing'
fi
fluxer_plan_footer
return 0
fi
fluxer_say ' running now'
while read -r fluxer_ref; do
[ -n "$fluxer_ref" ] || continue
fluxer_id=$(fluxer_recorded_id_for "$fluxer_ref")
if [ -z "$fluxer_id" ]; then
fluxer_id='no container carries this image'
fluxer_id='no container runs this image'
fi
fluxer_say " $fluxer_ref $fluxer_id"
done < "$fluxer_scratch/refs"
@@ -1256,7 +1594,7 @@ fluxer_plan_update() {
if [ -n "$fluxer_old_major" ] && [ -n "$fluxer_new_major" ] && [ "$fluxer_old_major" != "$fluxer_new_major" ]; then
fluxer_say " refusal postgres moves from $fluxer_old_major to $fluxer_new_major, which this script does not do"
fluxer_say ' outcome the run stops at that refusal and changes nothing'
fluxer_say 'Nothing outside a temporary directory was written.'
fluxer_plan_footer
return 0
fi
fluxer_note_mounted_changes
@@ -1266,8 +1604,9 @@ fluxer_plan_update() {
fluxer_say " restart $fluxer_service, because $fluxer_file changes and a mounted file survives up -d"
done < "$fluxer_scratch/restart"
fi
fluxer_say ' commands docker compose pull, docker compose up -d'
fluxer_say 'Nothing outside a temporary directory was written. Drop --dry-run to run this.'
fluxer_say ' commands $fluxer_engine compose pull, $fluxer_engine compose up -d'
fluxer_plan_footer
fluxer_say 'Drop --dry-run to run this.'
}
fluxer_plan_rollback() {
@@ -1291,7 +1630,7 @@ fluxer_plan_rollback() {
[ -n "$fluxer_ref" ] || continue
if [ "$fluxer_id" = '-' ]; then
fluxer_say " $fluxer_ref was not recorded with an ID"
elif docker image inspect --format '{{.Id}}' "$fluxer_id" >/dev/null 2>&1; then
elif $fluxer_engine image inspect --format '{{.Id}}' "$fluxer_id" >/dev/null 2>&1; then
fluxer_say " $fluxer_ref back to $fluxer_id"
else
fluxer_say " $fluxer_ref is gone from this host, so $fluxer_id cannot come back"
@@ -1307,7 +1646,7 @@ fluxer_plan_rollback() {
fluxer_run_update() {
fluxer_open_scratch "$opt_dir"
fluxer_stack_files > "$fluxer_scratch/files"
fluxer_prepare_record
fluxer_fill_required_secrets
fluxer_record_state
fluxer_save_current_files
fluxer_backup
@@ -1321,8 +1660,8 @@ fluxer_run_update() {
# By hand:
# docker compose pull
fluxer_say 'Pulling images.'
if ! docker compose pull; then
fluxer_fail 4 "docker compose pull failed in $opt_dir. The stack files are refreshed and the instance still runs the old images."
if ! $fluxer_engine compose pull; then
fluxer_fail 4 "$fluxer_engine compose pull failed in $opt_dir. The stack files are refreshed and the instance still runs the old images."
fi
# Recreates the containers whose image or configuration changed and leaves
# the rest running.
@@ -1344,8 +1683,8 @@ fluxer_run_update() {
# Gateway, so the hostname returns errors for a minute or two after this
# call. The api healthcheck allows 90 seconds before it counts a failure.
fluxer_say 'Recreating the stack.'
if ! docker compose up -d --remove-orphans; then
fluxer_fail 6 "docker compose up -d failed in $opt_dir. Read docker compose logs there."
if ! $fluxer_engine compose up -d --remove-orphans; then
fluxer_fail 6 "$fluxer_engine compose up -d failed in $opt_dir. Read $fluxer_engine compose logs there."
fi
fluxer_restart_mounted
fluxer_verify_stack
@@ -1357,7 +1696,7 @@ fluxer_run_update() {
# Puts one line of .env back, and proves it touched nothing else.
#
# FLUXER_IMAGE_TAG is the only key either upgrade mode writes. Every other line,
# FLUXER_IMAGE_TAG is the only key this function writes. Every other line,
# which is every secret, is compared byte for byte before the new file replaces
# the old one, so a rewrite that lost or changed a secret cannot land.
fluxer_set_image_tag() {
@@ -1426,31 +1765,33 @@ fluxer_run_rollback() {
while read -r fluxer_ref fluxer_id; do
[ -n "$fluxer_ref" ] || continue
[ "$fluxer_id" != '-' ] || continue
if ! docker image inspect --format '{{.Id}}' "$fluxer_id" >/dev/null 2>&1; then
if ! $fluxer_engine image inspect --format '{{.Id}}' "$fluxer_id" >/dev/null 2>&1; then
fluxer_say "$fluxer_ref is gone from this host, so it keeps the image it has now."
continue
fi
if ! docker image tag "$fluxer_id" "$fluxer_ref"; then
if ! $fluxer_engine image tag "$fluxer_id" "$fluxer_ref"; then
fluxer_fail 3 "Cannot put $fluxer_id back on $fluxer_ref."
fi
fluxer_moved=1
done < "$fluxer_rollback_dir/$FLUXER_IMAGES_FILE"
fi
if [ "$fluxer_moved" -eq 0 ]; then
fluxer_fail 2 "Nothing in $fluxer_rollback_dir can be put back. The recorded tag is the one in .env and every recorded image has been removed from this host, which a docker image prune does."
fluxer_fail 2 "Nothing in $fluxer_rollback_dir can be put back. The recorded tag is the one in .env and every recorded image has been removed from this host, which a $fluxer_engine image prune does."
fi
while read -r fluxer_file; do
[ -n "$fluxer_file" ] || continue
if [ -e "$fluxer_rollback_dir/$fluxer_file" ]; then
if [ -s "$fluxer_rollback_dir/$fluxer_file" ]; then
cp -p "$fluxer_rollback_dir/$fluxer_file" "$opt_dir/$fluxer_file"
elif [ -e "$fluxer_rollback_dir/$fluxer_file" ]; then
fluxer_fail 3 "$fluxer_rollback_dir/$fluxer_file is empty, so restoring it would replace a working file with nothing. Nothing was restored. Take the file from another record or from the ref the record names."
fi
done < "$fluxer_scratch/files"
fluxer_say "Stack files in $opt_dir are the ones the record holds."
fluxer_say 'Recreating the stack.'
if ! docker compose up -d --remove-orphans; then
fluxer_fail 6 "docker compose up -d failed in $opt_dir. Read docker compose logs there."
if ! $fluxer_engine compose up -d --remove-orphans; then
fluxer_fail 6 "$fluxer_engine compose up -d failed in $opt_dir. Read $fluxer_engine compose logs there."
fi
# The mounted file came back from the record, so its service restarts.
# Comparing it first would save one restart and cost the reader a reason.
@@ -1473,6 +1814,7 @@ fluxer_resolve_values
if [ "$opt_update" -eq 1 ] || [ "$opt_rollback" -eq 1 ]; then
fluxer_require_instance
fluxer_resolve_ref
fluxer_require_compose_files
cd "$opt_dir"
fluxer_set_project
if [ "$opt_dry_run" -eq 1 ]; then
@@ -1522,19 +1864,19 @@ fluxer_write_env
fluxer_say "Wrote $opt_dir/.env, readable by you alone."
if [ "$opt_no_start" -eq 1 ]; then
fluxer_say "Start the instance with docker compose up -d in $opt_dir."
fluxer_say "Start the instance with $fluxer_engine compose up -d in $opt_dir."
fluxer_say 'Open it and create the first admin account. Finish the setup wizard in the same sitting.'
fluxer_say "Secrets live in $opt_dir/.env. Back that file up."
exit 0
fi
fluxer_say 'Starting the stack.'
if ! docker compose up -d; then
fluxer_fail 6 "docker compose up -d failed in $opt_dir. Read docker compose logs there."
if ! $fluxer_engine compose up -d; then
fluxer_fail 6 "$fluxer_engine compose up -d failed in $opt_dir. Read $fluxer_engine compose logs there."
fi
fluxer_say 'Waiting for every service to report ready. This takes several minutes on the first start, which pulls eighteen images.'
if ! fluxer_wait_ready; then
fluxer_fail 6 "The stack is not ready after $FLUXER_READY_TIMEOUT seconds. Read docker compose logs in $opt_dir."
fluxer_fail 6 "The stack is not ready after $FLUXER_READY_TIMEOUT seconds. Read $fluxer_engine compose logs in $opt_dir."
fi
fluxer_probe "$opt_domain"
+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,

Some files were not shown because too many files have changed in this diff Show More