Compare commits

...
55 changed files with 899 additions and 559 deletions

No files matched your search

+9 -9
View File
@@ -1,24 +1,24 @@
# Contributing to Fluxer
This policy applies to all issues, discussions, commits and pull requests.
This policy applies to all commits and pull requests.
## Scope
To prevent spam, only approved contributors may submit pull requests.
To request approval, comment on an existing issue and ask to implement it. For work that extends beyond a defect fix, open a [discussion](https://github.com/orgs/fluxerapp/discussions) first.
To request approval, comment on the [feedback.fluxer.com](https://feedback.fluxer.com) post you want to implement and ask to work on it. For work that extends beyond a defect fix, post a feature request there first.
Every pull request must:
- Target the repository's default branch.
- Include a closing reference for each repository issue it resolves.
- Link each feedback.fluxer.com post it resolves.
- Receive approval from a maintainer before it is merged.
Place each closing reference on a separate line:
Place each link on a separate line:
```text
Closes #123
Closes #456
Resolves https://feedback.fluxer.com/p/123
Resolves https://feedback.fluxer.com/p/456
```
## Authorship
@@ -78,11 +78,11 @@ Complete every section of the pull request template. Clearly describe:
## Reports and other contributions
Use the [bug report form](https://github.com/fluxerapp/fluxer/issues/new?template=bug-report.yaml) to report reproducible defects.
Report bugs and request features at [feedback.fluxer.com](https://feedback.fluxer.com).
Report security vulnerabilities privately through the channels specified in the [security policy](https://github.com/fluxerapp/fluxer/blob/main/.github/SECURITY.md). Do not report vulnerabilities in public issues or discussions.
Report security vulnerabilities privately through [fluxer.app/security](https://fluxer.app/security). Never post them publicly.
Use [discussions](https://github.com/orgs/fluxerapp/discussions) for feature proposals and self-hosting questions.
Read the [operator documentation](https://fluxer.dev) for self-hosting questions.
Submit translations through [Weblate](https://weblate.fluxer.tools), not through pull requests.
-41
View File
@@ -1,41 +0,0 @@
# yaml-language-server: $schema=https://www.schemastore.org/github-discussion.json
body:
- type: markdown
attributes:
value: |
Search existing discussions before posting a feature proposal.
Report vulnerabilities through the [private form](https://github.com/fluxerapp/fluxer/security/advisories/new) or <[email protected]>.
- type: textarea
id: problem
attributes:
label: Current problem
description: State what you are trying to do and what prevents it.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed change
description: State the expected behaviour.
validations:
required: true
- type: textarea
id: notes
attributes:
label: Additional information
description: Optional. Include constraints, trade-offs, related discussions, screenshots or mockups.
validations:
required: false
- type: checkboxes
id: checks
attributes:
label: Acknowledgements
options:
- label: I searched existing discussions.
required: true
-83
View File
@@ -1,83 +0,0 @@
# yaml-language-server: $schema=https://www.schemastore.org/github-issue-forms.json
name: Bug report
description: Report a reproducible defect in Fluxer.
type: Bug
body:
- type: markdown
attributes:
value: |
Search [open and closed issues](https://github.com/fluxerapp/fluxer/issues?q=is%3Aissue) before filing a report.
Report vulnerabilities through the [private form](https://github.com/fluxerapp/fluxer/security/advisories/new) or <[email protected]>. Send account and billing requests to <[email protected]>.
- type: textarea
id: summary
attributes:
label: Observed behaviour
description: State what happened and what you expected.
validations:
required: true
- type: textarea
id: steps
attributes:
label: Reproduction steps
description: Give numbered steps starting from a fresh app or session.
placeholder: |
1. Go to ...
2. Select ...
3. Observe ...
validations:
required: true
- type: input
id: build
attributes:
label: Build information
description: >-
Open User Settings, scroll to the bottom of the left sidebar, and select
the build information. Fluxer copies it to the clipboard.
validations:
required: true
- type: dropdown
id: surface
attributes:
label: Affected surface
multiple: true
options:
- Desktop app
- Web app
- Voice, video, or Go Live
- Self-hosted instance
- HTTP API or Gateway
- Documentation site
validations:
required: true
- type: input
id: instance
attributes:
label: Instance
description: For a self-hosted instance, include the release tag and database backend.
placeholder: fluxer.app
validations:
required: false
- type: textarea
id: evidence
attributes:
label: Evidence
description: Attach relevant logs, screenshots or recordings. Remove tokens, keys, private messages and other personal data. Configuration files may contain secrets.
validations:
required: false
- type: checkboxes
id: checks
attributes:
label: Acknowledgements
options:
- label: I searched open and closed issues.
required: true
- label: I removed secrets and unrelated personal data from the report.
required: true
-18
View File
@@ -1,18 +0,0 @@
# yaml-language-server: $schema=https://www.schemastore.org/github-issue-config.json
blank_issues_enabled: false
contact_links:
- name: Mobile client bugs
url: https://github.com/fluxerapp/flutter_client#bug-reporting
about: Read the reporting instructions for the Fluxer mobile client.
- name: Account and billing support
url: https://fluxer.app/help
about: Find account help and support contact details.
- name: Feature proposals
url: https://github.com/orgs/fluxerapp/discussions
about: Propose a feature in a discussion.
- name: Translations
url: https://weblate.fluxer.tools
about: Improve an existing locale or start a new one.
- name: Self-hosting support
url: https://fluxer.dev
about: Read the operator documentation, then open a discussion if the problem remains.
-44
View File
@@ -1,44 +0,0 @@
# yaml-language-server: $schema=https://www.schemastore.org/github-issue-forms.json
name: Documentation
description: Report incorrect, missing or unclear documentation.
type: Task
labels:
- docs
body:
- type: markdown
attributes:
value: |
This form covers <https://fluxer.dev> and operator documentation.
- type: textarea
id: issue
attributes:
label: Documentation defect
description: State what the page says and what is correct. For missing content, state what information you needed.
validations:
required: true
- type: input
id: location
attributes:
label: Location
description: Provide the page URL or file path and heading.
placeholder: https://fluxer.dev/gateway/overview/
validations:
required: false
- type: textarea
id: suggestion
attributes:
label: Proposed wording
description: Optional.
validations:
required: false
- type: checkboxes
id: checks
attributes:
label: Acknowledgements
options:
- label: I searched open and closed issues.
required: true
+2 -2
View File
@@ -1,7 +1,7 @@
# Security policy
Do not report a vulnerability in an issue, pull request, or discussion.
Do not report a vulnerability in a pull request or on feedback.fluxer.com.
Submit a report through [GitHub private vulnerability reporting](https://github.com/fluxerapp/fluxer/security/advisories/new) or email <security@fluxer.com>. Include the affected component, impact, reproduction steps, and supporting evidence. Remove unrelated personal data and secrets.
Submit a report through <https://fluxer.app/security> or email <security@fluxer.com>. Include the affected component, impact, reproduction steps, and supporting evidence. Remove unrelated personal data and secrets.
The programme scope, testing rules, safe harbour, disclosure process, and reward terms are published at <https://fluxer.app/security>. That page is authoritative.
+2 -2
View File
@@ -1,6 +1,6 @@
Closes #
Resolves https://feedback.fluxer.com/p/
<!-- Repeat this line for each resolved issue, up to 20. Remove the placeholder only if no issue is resolved and the approval gate does not apply. -->
<!-- Repeat this line for each feedback.fluxer.com post this resolves, up to 20. Remove the placeholder only if no post is resolved and the approval gate does not apply. -->
## Summary
+3
View File
@@ -23,6 +23,9 @@
# Fluxer
> [!IMPORTANT]
> Bug reports and feature requests have moved to [feedback.fluxer.com](https://feedback.fluxer.com). Sign in with your Fluxer account to post, vote and follow updates. GitHub Issues and Discussions are closed. Report security vulnerabilities privately through [fluxer.app/security](https://fluxer.app/security).
Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.
<p align="center">
+3
View File
@@ -219,6 +219,9 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
#FLUXER_PUSH_SERVICE_FCM_BASE_URL=https://fcm.googleapis.com
#FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS=push.fluxer.com
#FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS=
# Push hosts on your own network, such as a ntfy server, that may resolve to
# private addresses. Comma separated.
#FLUXER_PUSH_SERVICE_PRIVATE_HOSTS=ntfy.example.com
#FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED=false
# Direct mobile push through your own APNs and FCM credentials, off by default.
+1
View File
@@ -615,6 +615,7 @@ services:
FLUXER_PUSH_SERVICE_FCM_BASE_URL: ${FLUXER_PUSH_SERVICE_FCM_BASE_URL:-}
FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS: ${FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS:-}
FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS: ${FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS:-}
FLUXER_PUSH_SERVICE_PRIVATE_HOSTS: ${FLUXER_PUSH_SERVICE_PRIVATE_HOSTS:-}
FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED: ${FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED:-}
FLUXER_PUSH_APNS_DEFAULT_ENVIRONMENT: ${FLUXER_PUSH_APNS_DEFAULT_ENVIRONMENT:-}
FLUXER_PUSH_FCM_ENABLED: ${FLUXER_PUSH_FCM_ENABLED:-}
+1
View File
@@ -187,6 +187,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
headersTimeoutMs: master.services.api.headers_timeout_ms,
requestTimeoutMs: master.services.api.request_timeout_ms,
maxInflightRequests: master.services.api.max_inflight_requests,
automatedMessageDeletionDelayDays: master.services.api.automated_message_deletion_delay_days,
ipBanExemptIps: normalizeIpBanExemptIps(master.services.api.ip_ban_exempt_ips),
cassandra: {
hosts: cassandraSource?.hosts.join(',') ?? '',
+1
View File
@@ -54,6 +54,7 @@ export interface APIConfig {
headersTimeoutMs: number;
requestTimeoutMs: number;
maxInflightRequests: number;
automatedMessageDeletionDelayDays: number;
ipBanExemptIps: Array<string>;
cassandra: {
hosts: string;
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {BillingRefundRow} from '@app/api/database/types/BillingTypes';
import {getBillingRepository} from '@app/api/middleware/ServiceRegistry';
import type {User} from '@app/api/models/User';
import type {IUserRepository} from '@app/api/user/IUserRepository';
export const REFUND_ALLOWANCE_BLOCK_THRESHOLD = 2;
function isCountedAgainstAllowance(refund: BillingRefundRow): boolean {
if (refund.status !== 'succeeded') {
return false;
}
return (refund.metadata?.get('rejection_reason') ?? null) === null;
}
export async function listCountedRefundIds(user: User, userRepository: IUserRepository): Promise<Array<string>> {
const payments = await userRepository.findPaymentsByUserId(user.id);
const paymentIntentIds = [
...new Set(payments.map((payment) => payment.paymentIntentId).filter((id): id is string => id !== null)),
];
const counted = new Set<string>();
for (const paymentIntentId of paymentIntentIds) {
for (const refund of await getBillingRepository().refunds.listByPaymentIntent(paymentIntentId)) {
if (isCountedAgainstAllowance(refund)) {
counted.add(refund.provider_id);
}
}
}
return [...counted].sort();
}
export async function shouldBlockFurtherPurchases(
user: User,
userRepository: IUserRepository,
): Promise<{blocked: boolean; countedRefundIds: Array<string>}> {
const countedRefundIds = await listCountedRefundIds(user, userRepository);
return {blocked: countedRefundIds.length >= REFUND_ALLOWANCE_BLOCK_THRESHOLD, countedRefundIds};
}
@@ -11,6 +11,7 @@ import {getBillingRepository} from '@app/api/middleware/ServiceRegistry';
import type {GiftCode} from '@app/api/models/GiftCode';
import type {User} from '@app/api/models/User';
import {extractId} from '@app/api/stripe/StripeUtils';
import {shouldBlockFurtherPurchases} from '@app/api/stripe/services/RefundAllowance';
import type {StripeGiftReversalHandler} from '@app/api/stripe/services/StripeGiftReversalHandler';
import type {StripePaymentFraudService} from '@app/api/stripe/services/StripePaymentFraudService';
import type {IUserRepository} from '@app/api/user/IUserRepository';
@@ -236,9 +237,25 @@ export class StripeDisputeWebhookHandler {
return;
}
const isFirstRefund = !user.firstRefundAt;
const patch: Partial<UserRow> = isFirstRefund
? {first_refund_at: new Date()}
: {premium_flags: user.premiumFlags | PremiumFlags.PURCHASE_DISABLED};
let patch: Partial<UserRow>;
let countedRefundIds: Array<string> = [];
if (isFirstRefund) {
patch = {first_refund_at: new Date()};
} else {
const outcome = await shouldBlockFurtherPurchases(user, this.userRepository);
countedRefundIds = outcome.countedRefundIds;
if (!outcome.blocked) {
for (const claimKey of claimedKeys) {
await getBillingRepository().webhookEvents.markProcessed(claimKey);
}
Logger.info(
{userId: user.id, chargeId: charge.id, paymentIntentId, countedRefundIds},
'Refund redelivered after the allowance claim expired; not counting it twice',
);
return;
}
patch = {premium_flags: user.premiumFlags | PremiumFlags.PURCHASE_DISABLED};
}
let updatedUser: User;
try {
updatedUser = await this.userRepository.patchUpsert(user.id, patch, user.toRow());
@@ -253,7 +270,7 @@ export class StripeDisputeWebhookHandler {
}
await this.dispatchUser(updatedUser);
Logger.debug(
{userId: user.id, chargeId: charge.id, paymentIntentId},
{userId: user.id, chargeId: charge.id, paymentIntentId, countedRefundIds},
isFirstRefund
? 'First refund recorded - 30 day self-serve refund cooldown applied'
: 'Second refund recorded - permanent purchase block applied',
@@ -7,6 +7,7 @@ import {Logger} from '@app/api/Logger';
import {getBillingRepository} from '@app/api/middleware/ServiceRegistry';
import type {User} from '@app/api/models/User';
import {extractId} from '@app/api/stripe/StripeUtils';
import {shouldBlockFurtherPurchases} from '@app/api/stripe/services/RefundAllowance';
import {REFUND_ALLOWANCE_CLAIM_PREFIX} from '@app/api/stripe/services/StripeDisputeWebhookHandler';
import type {StripeSubscriptionService} from '@app/api/stripe/services/StripeSubscriptionService';
import type {IUserRepository} from '@app/api/user/IUserRepository';
@@ -271,17 +272,31 @@ export class StripeRefundService {
}
const claimKey = `${REFUND_ALLOWANCE_CLAIM_PREFIX}:${refund.id}`;
const claim = await getBillingRepository().webhookEvents.tryClaim(claimKey);
if (claim !== 'claimed') {
if (claim === 'in_flight') {
Logger.debug(
{userId: user.id.toString(), refundId: refund.id, claim},
'Self-serve refund already counted against the user refund allowance',
'Self-serve refund allowance is being counted by another delivery',
);
return;
}
const isFirstRefund = !user.firstRefundAt;
const patch: Partial<UserRow> = isFirstRefund
? {first_refund_at: new Date()}
: {premium_flags: user.premiumFlags | PremiumFlags.PURCHASE_DISABLED};
let patch: Partial<UserRow>;
let countedRefundIds: Array<string> = [];
if (isFirstRefund) {
patch = {first_refund_at: new Date()};
} else {
const outcome = await shouldBlockFurtherPurchases(user, this.userRepository);
countedRefundIds = outcome.countedRefundIds;
if (!outcome.blocked) {
await getBillingRepository().webhookEvents.markProcessed(claimKey);
Logger.info(
{userId: user.id.toString(), refundId: refund.id, countedRefundIds},
'Self-serve refund redelivered after the allowance claim expired; not counting it twice',
);
return;
}
patch = {premium_flags: user.premiumFlags | PremiumFlags.PURCHASE_DISABLED};
}
try {
await this.userRepository.patchUpsert(user.id, patch, user.toRow());
} catch (error) {
@@ -290,7 +305,13 @@ export class StripeRefundService {
}
await getBillingRepository().webhookEvents.markProcessed(claimKey);
Logger.info(
{userId: user.id.toString(), refundId: refund.id, subscriptionId: subscriptionId || null, isFirstRefund},
{
userId: user.id.toString(),
refundId: refund.id,
subscriptionId: subscriptionId || null,
isFirstRefund,
countedRefundIds,
},
'Self-serve refund confirmed succeeded; refund allowance and cancellation finalized',
);
}
@@ -105,23 +105,14 @@ describe('Stripe Webhook Refund', () => {
expect(updatedPayment).not.toBeNull();
expect(updatedPayment!.status).toBe('refunded');
});
test('applies permanent purchase block on second refund', async () => {
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
const {UserRepository} = await import('@app/api/user/repositories/UserRepository');
const userRepository = new UserRepository();
const firstRefundDate = new Date('2024-01-01');
await userRepository.patchUpsert(
userId,
{
first_refund_at: firstRefundDate,
},
(await userRepository.findUnique(userId))!.toRow(),
);
async function seedRefundedPayment(
userId: ReturnType<typeof createUserID>,
suffix: string,
): Promise<{paymentIntentId: string; chargeId: string}> {
const {PaymentRepository} = await import('@app/api/user/repositories/PaymentRepository');
const paymentRepository = new PaymentRepository();
const paymentIntentId = 'pi_test_refund_second_456';
const checkoutSessionId = 'cs_test_refund_second_456';
const paymentIntentId = `pi_test_refund_${suffix}`;
const checkoutSessionId = `cs_test_refund_${suffix}`;
await paymentRepository.createPayment({
checkout_session_id: checkoutSessionId,
user_id: userId,
@@ -136,14 +127,41 @@ describe('Stripe Webhook Refund', () => {
payment_intent_id: paymentIntentId,
completed_at: new Date(),
});
useRefundListHandler('ch_test_refund_456');
return {paymentIntentId, chargeId: `ch_test_refund_${suffix}`};
}
test('applies permanent purchase block when a second distinct refund exists', async () => {
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
const {UserRepository} = await import('@app/api/user/repositories/UserRepository');
const userRepository = new UserRepository();
const firstRefundDate = new Date('2024-01-01');
await userRepository.patchUpsert(
userId,
{first_refund_at: firstRefundDate},
(await userRepository.findUnique(userId))!.toRow(),
);
const {paymentIntentId, chargeId} = await seedRefundedPayment(userId, 'second_456');
const nowSeconds = Math.floor(Date.now() / 1000);
const earlier = {
id: 're_test_refund_second_456_1',
object: 'refund',
charge: chargeId,
payment_intent: paymentIntentId,
amount: 1200,
currency: 'usd',
status: 'succeeded',
created: nowSeconds - 600,
metadata: {},
};
const latest = {...earlier, id: 're_test_refund_second_456_2', amount: 1300, created: nowSeconds};
await sendWebhook({
type: 'charge.refunded',
data: {
object: {
id: 'ch_test_refund_456',
id: chargeId,
payment_intent: paymentIntentId,
amount_refunded: 2500,
refunds: {object: 'list', has_more: false, url: `/v1/charges/${chargeId}/refunds`, data: [earlier, latest]},
},
},
});
@@ -155,6 +173,45 @@ describe('Stripe Webhook Refund', () => {
expect(updatedPayment).not.toBeNull();
expect(updatedPayment!.status).toBe('refunded');
});
test('does not block purchases when one refund is redelivered after the allowance claim expired', async () => {
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
const {UserRepository} = await import('@app/api/user/repositories/UserRepository');
const userRepository = new UserRepository();
const firstRefundDate = new Date('2024-01-01');
await userRepository.patchUpsert(
userId,
{first_refund_at: firstRefundDate},
(await userRepository.findUnique(userId))!.toRow(),
);
const {paymentIntentId, chargeId} = await seedRefundedPayment(userId, 'replay_789');
const onlyRefund = {
id: 're_test_refund_replay_789',
object: 'refund',
charge: chargeId,
payment_intent: paymentIntentId,
amount: 2500,
currency: 'usd',
status: 'succeeded',
created: Math.floor(Date.now() / 1000),
metadata: {},
};
await sendWebhook({
type: 'charge.refunded',
data: {
object: {
id: chargeId,
payment_intent: paymentIntentId,
amount_refunded: 2500,
refunds: {object: 'list', has_more: false, url: `/v1/charges/${chargeId}/refunds`, data: [onlyRefund]},
},
},
});
const updatedUser = await userRepository.findUnique(userId);
expect(updatedUser).not.toBeNull();
expect(updatedUser!.firstRefundAt).toEqual(firstRefundDate);
expect(updatedUser!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
});
test('counts a refund once when the same charge.refunded event is redelivered', async () => {
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
@@ -74,6 +74,7 @@ export interface IUserContentRepository {
checkout_session_id: string;
},
): Promise<void>;
findPaymentsByUserId(userId: UserID): Promise<Array<Payment>>;
getPaymentByCheckoutSession(checkoutSessionId: string): Promise<Payment | null>;
getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null>;
getSubscriptionInfo(subscriptionId: string): Promise<PaymentBySubscriptionRow | null>;
@@ -116,6 +116,10 @@ export class UserContentRepository implements IUserContentRepository {
return this.paymentRepository.getPaymentByCheckoutSession(checkoutSessionId);
}
async findPaymentsByUserId(userId: UserID): Promise<Array<Payment>> {
return this.paymentRepository.findPaymentsByUserId(userId);
}
async getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null> {
return this.paymentRepository.getPaymentByPaymentIntent(paymentIntentId);
}
@@ -740,6 +740,10 @@ export class UserRepository implements IUserRepositoryAggregate {
return this.contentRepo.getPaymentByCheckoutSession(checkoutSessionId);
}
async findPaymentsByUserId(userId: UserID): Promise<Array<Payment>> {
return this.contentRepo.findPaymentsByUserId(userId);
}
async getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null> {
return this.contentRepo.getPaymentByPaymentIntent(paymentIntentId);
}
@@ -2,7 +2,7 @@
import type {ApiContext} from '@app/api/ApiContext';
import type {AdminRepository} from '@app/api/admin/AdminRepository';
import type {AdminMessageDeletionService} from '@app/api/admin/services/AdminMessageDeletionService';
import type {AdminArchiveService} from '@app/api/admin/services/AdminArchiveService';
import {type ChannelID, createUserID, type MessageID, type UserID} from '@app/api/BrandedTypes';
import {isIpBanExempt} from '@app/api/ban/IpBanExemptions';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
@@ -17,6 +17,7 @@ import type {
OutcomeStatus,
} from '@app/api/infrastructure/activity/Contract.generated';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {KVBulkMessageDeletionQueueService} from '@app/api/infrastructure/KVBulkMessageDeletionQueueService';
import type {User} from '@app/api/models/User';
import {isAccountLimitExempt} from '@app/api/user/AccountLimit';
import type {IUserRepository} from '@app/api/user/IUserRepository';
@@ -34,6 +35,7 @@ import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {getSameIpDecisionKey, isPublicIpAddress, parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
import {ms} from 'itty-time';
export type ActionOf<T extends ActionEnvelope['type']> = Extract<ActionEnvelope, {type: T}>;
@@ -49,7 +51,9 @@ export interface AccountStateDeps {
dispatch: AccountUpdateDispatch;
ipBans: Pick<AdminRepository, 'isIpBanned' | 'banIpTemp'>;
cache: Pick<ICacheService, 'publish' | 'get' | 'set' | 'delete'>;
messages: Pick<AdminMessageDeletionService, 'deleteAllUserMessages'>;
archives: Pick<AdminArchiveService, 'triggerUserArchive' | 'listArchives'>;
messageDeletionQueue: Pick<KVBulkMessageDeletionQueueService, 'scheduleDeletion' | 'removeFromQueue'>;
messageDeletionDelayMs: number;
authored: Pick<IChannelRepository, 'listMessagesByAuthor'>;
now?: () => number;
}
@@ -57,6 +61,8 @@ export interface AccountStateDeps {
const FLAGS_WRITE_ATTEMPTS = 3;
const AUTHORED_PAGE_SIZE = 200;
const MIN_TEMP_BAN_SECONDS = 60;
const RECENT_ARCHIVE_MS = ms('1 day');
const RECENT_ARCHIVE_SCAN = 20;
type ProfilePropagation = Omit<PartialUserChangePropagationDeps, 'gatewayService'>;
@@ -94,7 +100,7 @@ export function accountStateDepsFromContext(
ctx: ApiContext,
ipBans: AccountStateDeps['ipBans'],
profile: Omit<ProfilePropagation, 'userRepository'>,
messages: AccountStateDeps['messages'],
messageDeletion: Pick<AccountStateDeps, 'archives' | 'messageDeletionQueue' | 'messageDeletionDelayMs'>,
channels: Pick<IChannelRepository, 'findUnique' | 'listMessagesByAuthor'>,
): AccountStateDeps {
return {
@@ -102,7 +108,7 @@ export function accountStateDepsFromContext(
dispatch: gatewayDispatch(ctx.services.gateway, {...profile, userRepository: ctx.services.users}, channels),
ipBans,
cache: ctx.services.cache,
messages,
...messageDeletion,
authored: channels,
};
}
@@ -135,6 +141,10 @@ function isIneligible(user: User): boolean {
return user.isBot || (user.flags & UserFlags.DELETED) !== 0n;
}
function isDeletionComplete(user: User): boolean {
return (user.flags & UserFlags.DELETED) !== 0n && user.pendingDeletionAt === null;
}
export async function applySetAccountLimit(
deps: AccountStateDeps,
env: ActionOf<'set_account_limit'>,
@@ -190,7 +200,7 @@ export async function applyHideRecentMessages(
return withAccountChangeSource('action', async () => {
const user = await deps.users.findUnique(createUserID(BigInt(env.user_id)));
if (!user) return outcomeOf(env, 'ineligible');
if (isIneligible(user)) return outcomeOf(env, 'ineligible', user);
if (user.isBot || isDeletionComplete(user)) return outcomeOf(env, 'ineligible', user);
const current = user.contentHiddenSince?.getTime() ?? null;
if (!env.on) {
if (current === null) return outcomeOf(env, 'noop', user);
@@ -235,18 +245,64 @@ export async function applyDeleteUserMessages(
deps: AccountStateDeps,
env: ActionOf<'delete_user_messages'>,
): Promise<ActionOutcome> {
if (!env.on) return outcomeOf(env, 'unsupported', null, 'a message purge cannot be reversed');
const user = await deps.users.findUnique(createUserID(BigInt(env.user_id)));
if (!user) return outcomeOf(env, 'ineligible');
if (user.isBot) return outcomeOf(env, 'ineligible', user);
if (!env.on) return cancelScheduledMessageDeletion(deps, env, user);
if (user.isBot || isDeletionComplete(user)) return outcomeOf(env, 'ineligible', user);
if (isAccountLimitExempt(user)) return outcomeOf(env, 'exempt', user);
const purge = await deps.messages.deleteAllUserMessages(
{user_id: user.id, dry_run: false},
SYSTEM_USER_ID,
`Automated action ${env.id}`,
const now = deps.now?.() ?? Date.now();
const archive = await archiveBeforeDeletion(deps, user.id, now);
const target = now + deps.messageDeletionDelayMs;
const current = user.pendingBulkMessageDeletionAt?.getTime() ?? null;
const scheduledAt = current === null ? target : Math.min(current, target);
await deps.messageDeletionQueue.scheduleDeletion(user.id, new Date(scheduledAt));
const detail = `${archive} scheduled_at=${new Date(scheduledAt).toISOString()}`;
if (scheduledAt === current) return outcomeOf(env, 'noop', user, detail);
const scheduled = await deps.users.patchUpsert(
user.id,
{
pending_bulk_message_deletion_at: new Date(scheduledAt),
pending_bulk_message_deletion_channel_count: null,
pending_bulk_message_deletion_message_count: null,
},
user.toRow(),
);
if (purge.message_count === 0) return outcomeOf(env, 'noop', user);
return outcomeOf(env, 'applied', user, `messages=${purge.message_count} job=${purge.job_id ?? ''}`);
await deps.dispatch.userUpdated(scheduled);
return outcomeOf(env, 'applied', scheduled, detail);
}
async function archiveBeforeDeletion(deps: AccountStateDeps, userId: UserID, now: number): Promise<string> {
const archives = await deps.archives.listArchives({
subjectType: 'user',
subjectId: userId,
limit: RECENT_ARCHIVE_SCAN,
});
const recent = archives.find(
(archive) => archive.failed_at === null && now - Date.parse(archive.requested_at) < RECENT_ARCHIVE_MS,
);
if (recent) return `archive=${recent.archive_id} reused=true`;
const created = await deps.archives.triggerUserArchive(userId, SYSTEM_USER_ID, true);
return `archive=${created.archive_id} reused=false`;
}
async function cancelScheduledMessageDeletion(
deps: AccountStateDeps,
env: ActionOf<'delete_user_messages'>,
user: User,
): Promise<ActionOutcome> {
if (user.pendingBulkMessageDeletionAt === null) return outcomeOf(env, 'noop', user);
const cancelled = await deps.users.patchUpsert(
user.id,
{
pending_bulk_message_deletion_at: null,
pending_bulk_message_deletion_channel_count: null,
pending_bulk_message_deletion_message_count: null,
},
user.toRow(),
);
await deps.messageDeletionQueue.removeFromQueue(user.id);
await deps.dispatch.userUpdated(cancelled);
return outcomeOf(env, 'applied', cancelled);
}
function parseBanTarget(value: string): ReturnType<typeof parseIpAddress> {
@@ -42,6 +42,7 @@ import {UserHarvestRepository} from '@app/api/user/UserHarvestRepository';
import {serializeSelfMessageFilter} from '@app/api/worker/utils/SelfMessageFilterPayload';
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
import {MAX_BOOKMARKS_NON_PREMIUM} from '@fluxer/constants/src/LimitConstants';
import {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';
@@ -748,6 +749,7 @@ export class UserContentService {
async cancelBulkMessageDeletion(userId: UserID): Promise<void> {
Logger.debug({userId: userId.toString()}, 'Canceling pending bulk message deletion');
const user = await this.userRepository.findUniqueAssert(userId);
if ((user.flags & UserFlags.SPAMMER) !== 0n) return;
const updatedUser = await this.userRepository.patchUpsert(
userId,
{
@@ -7,6 +7,7 @@ import {createMessageID, createUserID, type MessageID, type UserID} from '@app/a
import {Config} from '@app/api/Config';
import {mapChannelToResponse} from '@app/api/channel/ChannelMappers';
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
import {UserMessageDeletionService} from '@app/api/channel/services/message/UserMessageDeletionService';
import type {IConnectionRepository} from '@app/api/connection/IConnectionRepository';
import type {FavoriteMemeRepository} from '@app/api/favorite_meme/FavoriteMemeRepository';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
@@ -371,6 +372,25 @@ export async function processUserDeletion(
Logger.error({error, userId, channelId: channel.id}, 'Failed to leave group DM');
}
}
if (user.pendingBulkMessageDeletionAt) {
const deleted = await new UserMessageDeletionService({
channelRepository,
gatewayService,
storageService,
purgeQueue,
workerService,
}).deleteUserMessagesBulk(userId);
await userRepository.patchUpsert(
userId,
{
pending_bulk_message_deletion_at: null,
pending_bulk_message_deletion_channel_count: null,
pending_bulk_message_deletion_message_count: null,
},
user.toRow(),
);
Logger.debug({userId, deleted}, 'Deleted messages scheduled for deletion before anonymizing the rest');
}
Logger.debug({userId}, 'Anonymizing user messages');
let lastMessageId: MessageID | undefined;
let processedCount = 0;
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {acceptInvite, createChannel, createChannelInvite, createGuild} from '@app/api/guild/tests/GuildTestUtils';
import {ensureSessionStarted, sendMessage} from '@app/api/message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {
deleteAccount,
setPendingDeletionAt,
triggerDeletionWorker,
waitForDeletionCompletion,
} from '@app/api/user/tests/UserTestUtils';
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
describe('Account deletion with a scheduled message deletion', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('deletes the scheduled messages instead of anonymizing them', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Scheduled Deletion Guild');
let channelId = guild.system_channel_id;
if (!channelId) {
const channel = await createChannel(harness, account.token, guild.id, 'general');
channelId = channel.id;
}
await ensureSessionStarted(harness, account.token);
for (let i = 0; i < 3; i++) {
await sendMessage(harness, account.token, channelId, `Scheduled ${i + 1}`);
}
const newOwner = await createTestAccount(harness);
const invite = await createChannelInvite(harness, account.token, channelId);
await acceptInvite(harness, newOwner.token, invite.code);
await createBuilder(harness, account.token)
.post(`/guilds/${guild.id}/transfer-ownership`)
.body({new_owner_id: newOwner.userId, password: account.password})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder<void>(harness, account.token)
.post('/users/@me/messages/delete')
.body({password: account.password})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
await deleteAccount(harness, account.token, account.password);
await setPendingDeletionAt(harness, account.userId, new Date(Date.now() - 60_000));
await triggerDeletionWorker(harness);
await waitForDeletionCompletion(harness, account.userId);
const messages = await createBuilder<Array<MessageResponse>>(harness, newOwner.token)
.get(`/channels/${channelId}/messages?limit=100`)
.expect(HTTP_STATUS.OK)
.execute();
expect(messages.filter((message) => message.content.startsWith('Scheduled '))).toEqual([]);
const countJson = await createBuilderWithoutAuth<{count: number}>(harness)
.get(`/test/users/${account.userId}/messages/count`)
.expect(HTTP_STATUS.OK)
.execute();
expect(countJson.count).toBe(0);
}, 60000);
});
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
interface Me {
id: string;
flags?: string | number;
pending_bulk_message_deletion: {scheduled_at: string} | null;
}
describe('Cancelling a scheduled message deletion', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('a flagged account cannot cancel it until the flag is cleared', async () => {
const account = await createTestAccount(harness);
const me = () => createBuilder<Me>(harness, account.token).get('/users/@me').expect(HTTP_STATUS.OK).execute();
const setFlags = (flags: bigint) =>
createBuilder<unknown>(harness, account.token)
.patch(`/test/users/${account.userId}/flags`)
.body({flags: flags.toString()})
.execute();
const cancel = () =>
createBuilder<{success: boolean}>(harness, account.token)
.delete('/users/@me/messages/delete')
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder<void>(harness, account.token)
.post('/users/@me/messages/delete')
.body({password: account.password})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const scheduled = (await me()).pending_bulk_message_deletion;
expect(scheduled).not.toBeNull();
const flags = BigInt((await me()).flags ?? 0);
await setFlags(flags | UserFlags.SPAMMER);
await cancel();
expect((await me()).pending_bulk_message_deletion?.scheduled_at).toBe(scheduled?.scheduled_at);
await setFlags(flags);
await cancel();
expect((await me()).pending_bulk_message_deletion).toBeNull();
}, 60000);
});
+7 -10
View File
@@ -1,8 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
import {AdminMessageDeletionService} from '@app/api/admin/services/AdminMessageDeletionService';
import {AdminMessageShredService} from '@app/api/admin/services/AdminMessageShredService';
import {Config} from '@app/api/Config';
import {createApiContext} from '@app/api/CreateApiContext';
import {setDatabaseQueryExecutor} from '@app/api/database/CassandraQueryExecution';
@@ -29,6 +26,7 @@ import {
shutdownVoiceResources,
} from '@app/api/middleware/ServiceRegistry';
import {
getAdminArchiveService,
getAdminRepository,
getCacheService,
getInstanceConfigRepository,
@@ -58,6 +56,7 @@ import {BACKGROUND_READ_TIMEOUT_MS, initCassandra, shutdownCassandra} from '@pkg
import {JetStreamConnectionManager} from '@pkgs/nats/src/JetStreamConnectionManager';
import {getDefaultPostgresClient, initPostgres, shutdownPostgres} from '@pkgs/postgres/src/Client';
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
import {ms} from 'itty-time';
function registerCronJobs(cron: CronScheduler, jobsStreamMaxAgeMs: number): void {
cron.upsert('processAssetDeletionQueue', 'processAssetDeletionQueue', {}, '0 */5 * * * *', {ledger: false});
@@ -286,12 +285,6 @@ export async function startWorkerMain(): Promise<void> {
startSharedListWatch(jsConnectionManager.getJetStreamClient());
if (activeWorkerLanes.some((lane) => lane.name === 'lifecycle')) {
const apiContext = createApiContext();
const auditService = new AdminAuditService(getAdminRepository(), apiContext.services.snowflake);
const messagePurge = new AdminMessageDeletionService({
channelRepository: dependencies.channelRepository,
messageShredService: new AdminMessageShredService({apiContext, auditService}),
auditService,
});
startAccountActionConsumer({
js: jsConnectionManager.getJetStreamClient(),
state: accountStateDepsFromContext(
@@ -301,7 +294,11 @@ export async function startWorkerMain(): Promise<void> {
userCacheService: dependencies.userCacheService,
guildRepository: dependencies.guildRepository,
},
messagePurge,
{
archives: getAdminArchiveService(),
messageDeletionQueue: dependencies.bulkMessageDeletionQueueService,
messageDeletionDelayMs: Config.automatedMessageDeletionDelayDays * ms('1 day'),
},
dependencies.channelRepository,
),
});
@@ -1,6 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {AdminMessageDeletionService} from '@app/api/admin/services/AdminMessageDeletionService';
import {
type ChannelID,
createChannelID,
@@ -29,6 +28,7 @@ import {
stopAccountActionConsumer,
} from '@app/api/worker/AccountActionConsumer';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import type {AdminArchiveResponse} from '@fluxer/schema/src/domains/admin/AdminArchiveSchemas';
import {createSnowflakeFromTimestamp} from '@fluxer/snowflake/src/Snowflake';
import {AckPolicy, DeliverPolicy, type JsMsg, jetstream, jetstreamManager} from '@nats-io/jetstream';
import {connect} from '@nats-io/transport-node';
@@ -37,6 +37,33 @@ import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const USER_ID = '1174109840998400001';
const NOW = 1_759_000_000_000;
const NATS_URL = process.env.FLUXER_TEST_ACTIVITY_NATS_URL;
const DAY_MS = 86_400_000;
const DELETION_DELAY_MS = 7 * DAY_MS;
function archiveAt(
requestedAtMs: number,
userId: UserID,
requestedBy: UserID,
archiveId: string,
failed = false,
): AdminArchiveResponse {
return {
archive_id: archiveId,
subject_type: 'user',
subject_id: userId.toString(),
requested_by: requestedBy.toString(),
requested_at: new Date(requestedAtMs).toISOString(),
started_at: null,
completed_at: null,
failed_at: failed ? new Date(requestedAtMs + 1_000).toISOString() : null,
file_size: null,
progress_percent: 0,
progress_step: 'Queued',
error_message: null,
download_url_expires_at: null,
expires_at: null,
};
}
function limitEnvelope(overrides: Partial<Extract<ActionEnvelope, {type: 'set_account_limit'}>> = {}) {
return envelope<'set_account_limit'>({
@@ -108,8 +135,9 @@ interface Harness {
authored: Array<{channelId: ChannelID; messageId: MessageID}>;
visibility: Array<UserID>;
removed: Array<{channelId: ChannelID; authorId: UserID; messageIds: Array<MessageID>}>;
audits: Array<{adminUserId: UserID; action: string; targetId: bigint; auditLogReason: string | null}>;
shreds: Array<{userId: bigint; entries: number; adminUserId: UserID; auditLogReason: string | null}>;
archives: Array<AdminArchiveResponse>;
archiveRequests: Array<{userId: UserID; requestedBy: UserID; includeAttachments: boolean}>;
queued: Map<string, number>;
deps: AccountActionDeps;
}
@@ -125,8 +153,9 @@ function harness(): Harness {
authored: [],
visibility: [],
removed: [],
audits: [],
shreds: [],
archives: [],
archiveRequests: [],
queued: new Map(),
deps: null as never,
};
const authored = {
@@ -136,34 +165,6 @@ function harness(): Harness {
.filter((m) => before === undefined || m.messageId < before)
.slice(0, limit),
};
const messages = new AdminMessageDeletionService({
channelRepository: authored,
messageShredService: {
queueMessageShred: async (
data: {user_id: bigint; entries: Array<unknown>},
adminUserId: UserID,
auditLogReason: string | null,
) => {
h.shreds.push({userId: data.user_id, entries: data.entries.length, adminUserId, auditLogReason});
return {success: true, job_id: '77', requested: data.entries.length};
},
},
auditService: {
createAuditLog: async (log: {
adminUserId: UserID;
action: string;
targetId: bigint;
auditLogReason: string | null;
}) => {
h.audits.push({
adminUserId: log.adminUserId,
action: log.action,
targetId: log.targetId,
auditLogReason: log.auditLogReason,
});
},
},
} as unknown as ConstructorParameters<typeof AdminMessageDeletionService>[0]);
const state: AccountStateDeps = {
users: users as unknown as AccountStateDeps['users'],
dispatch: {
@@ -198,7 +199,25 @@ function harness(): Harness {
h.cached.delete(key);
},
} as unknown as AccountStateDeps['cache'],
messages,
archives: {
listArchives: async ({subjectId}) =>
h.archives.filter((archive) => archive.subject_id === subjectId?.toString()).reverse(),
triggerUserArchive: async (userId, requestedBy, includeAttachments) => {
h.archiveRequests.push({userId, requestedBy, includeAttachments});
const archive = archiveAt(NOW, userId, requestedBy, String(9000 + h.archives.length));
h.archives.push(archive);
return archive;
},
},
messageDeletionQueue: {
scheduleDeletion: async (userId, scheduledAt) => {
h.queued.set(userId.toString(), scheduledAt.getTime());
},
removeFromQueue: async (userId) => {
h.queued.delete(userId.toString());
},
},
messageDeletionDelayMs: DELETION_DELAY_MS,
authored: authored as unknown as AccountStateDeps['authored'],
now: () => NOW,
};
@@ -453,7 +472,7 @@ describe('account action apply', () => {
}
}
expect(h.users.current().flags).toBe(0n);
expect(h.shreds).toHaveLength(0);
expect(h.queued.size).toBe(0);
});
it('keeps the wider window when asked again and resends the deletes as a noop', async () => {
@@ -529,103 +548,121 @@ describe('account action apply', () => {
expect(h.users.current().contentHiddenSince?.getTime()).toBe(since);
});
it('still purges a hidden account through the admin purge', async () => {
const since = NOW - 86_400_000;
authorAt(since - 5_000, 100);
authorAt(since + 5_000, 101);
await applyAction(h.deps, hideEnvelope(true, since));
const purge = await applyAction(
h.deps,
envelope<'delete_user_messages'>({type: 'delete_user_messages', user_id: USER_ID, on: true}),
);
expect(purge).toMatchObject({status: 'applied', detail: 'messages=2 job=77'});
expect(h.shreds).toEqual([expect.objectContaining({entries: 2})]);
it('hides every message the account ever sent when the window starts at zero', async () => {
const all = [authorAt(1_420_070_401_000, 100), authorAt(NOW - 400 * DAY_MS, 101), authorAt(NOW - 1_000, 102)];
const outcome = await applyAction(h.deps, hideEnvelope(true, 0));
expect(outcome).toMatchObject({status: 'applied', detail: 'messages=3'});
expect(h.users.current().contentHiddenSince?.getTime()).toBe(0);
expect(removedIds()).toEqual([...all].sort((a, b) => (a < b ? -1 : 1)));
expect((await applyAction(h.deps, hideEnvelope(true, NOW - DAY_MS))).status).toBe('noop');
expect(h.users.current().contentHiddenSince?.getTime()).toBe(0);
expect((await applyAction(h.deps, hideEnvelope(false, 0))).status).toBe('applied');
expect(h.users.current().contentHiddenSince).toBeNull();
});
it('hides the messages of an account whose deletion is only scheduled', async () => {
authorAt(NOW - 1_000, 100);
h.users.put({flags: UserFlags.DELETED | UserFlags.SPAMMER, pending_deletion_at: new Date(NOW + 60 * DAY_MS)});
expect((await applyAction(h.deps, hideEnvelope(true, 0))).status).toBe('applied');
expect(h.users.current().contentHiddenSince?.getTime()).toBe(0);
});
function purgeEnvelope(on = true) {
return envelope<'delete_user_messages'>({type: 'delete_user_messages', user_id: USER_ID, on});
}
function author(count: number): void {
for (let i = 0; i < count; i++) {
h.authored.push({
channelId: createChannelID(BigInt(100 + (i % 3))),
messageId: createMessageID(BigInt(1000 + i)),
});
}
}
const userId = createUserID(BigInt(USER_ID));
it('purges every message through the admin purge and audits it as the system', async () => {
author(450);
it('archives with attachments and schedules the deletion instead of deleting now', async () => {
authorAt(NOW - 1_000, 100);
const outcome = await applyAction(h.deps, purgeEnvelope());
const scheduledAt = NOW + DELETION_DELAY_MS;
expect(outcome).toEqual({
action_id: 'a:07:4242:0',
action_type: 'delete_user_messages',
status: 'applied',
detail: 'messages=450 job=77',
detail: `archive=9000 reused=false scheduled_at=${new Date(scheduledAt).toISOString()}`,
observed: {flags: '0', deleted: false},
user_id: USER_ID,
});
expect(h.shreds).toEqual([
{
userId: BigInt(USER_ID),
entries: 450,
adminUserId: SYSTEM_USER_ID,
auditLogReason: 'Automated action a:07:4242:0',
},
]);
expect(h.audits).toEqual([
{
adminUserId: SYSTEM_USER_ID,
action: 'delete_all_user_messages',
targetId: BigInt(USER_ID),
auditLogReason: 'Automated action a:07:4242:0',
},
]);
expect(h.users.current().flags).toBe(0n);
expect(h.presence).toHaveLength(0);
expect(h.archiveRequests).toEqual([{userId, requestedBy: SYSTEM_USER_ID, includeAttachments: true}]);
expect(h.users.current().pendingBulkMessageDeletionAt?.getTime()).toBe(scheduledAt);
expect(h.queued.get(USER_ID)).toBe(scheduledAt);
expect(h.removed).toEqual([]);
expect(h.authored).toHaveLength(1);
expect(h.presence).toEqual([userId]);
});
it('purges accounts already scheduled for deletion and paid accounts', async () => {
author(2);
it('reuses an archive from the last day and makes a new one when it is older or failed', async () => {
h.archives.push(archiveAt(NOW - DAY_MS + 60_000, userId, createUserID(42n), '777'));
expect((await applyAction(h.deps, purgeEnvelope())).detail).toMatch(/^archive=777 reused=true /);
expect(h.archiveRequests).toEqual([]);
h.archives.length = 0;
h.archives.push(archiveAt(NOW - DAY_MS - 60_000, userId, createUserID(42n), '778'));
h.archives.push(archiveAt(NOW - 60_000, userId, createUserID(42n), '779', true));
h.users.put();
expect((await applyAction(h.deps, purgeEnvelope())).detail).toMatch(/^archive=9002 reused=false /);
expect(h.archiveRequests).toHaveLength(1);
});
it('keeps an earlier schedule and pulls a later one forward', async () => {
const earlier = NOW + DAY_MS;
h.users.put({pending_bulk_message_deletion_at: new Date(earlier), pending_bulk_message_deletion_message_count: 4});
const kept = await applyAction(h.deps, purgeEnvelope());
expect(kept).toMatchObject({status: 'noop'});
expect(kept.detail).toContain(`scheduled_at=${new Date(earlier).toISOString()}`);
expect(h.users.current().pendingBulkMessageDeletionAt?.getTime()).toBe(earlier);
expect(h.users.current().pendingBulkMessageDeletionMessageCount).toBe(4);
expect(h.queued.get(USER_ID)).toBe(earlier);
expect(h.presence).toHaveLength(0);
h.users.put({pending_bulk_message_deletion_at: new Date(NOW + 30 * DAY_MS)});
expect((await applyAction(h.deps, purgeEnvelope())).status).toBe('applied');
expect(h.users.current().pendingBulkMessageDeletionAt?.getTime()).toBe(NOW + DELETION_DELAY_MS);
expect(h.queued.get(USER_ID)).toBe(NOW + DELETION_DELAY_MS);
expect(h.archiveRequests).toHaveLength(1);
});
it('schedules for accounts already scheduled for deletion and paid accounts', async () => {
for (const overrides of [
{flags: UserFlags.DELETED | UserFlags.SPAMMER, pending_deletion_at: new Date(NOW + 86_400_000)},
{flags: UserFlags.DELETED | UserFlags.SPAMMER, pending_deletion_at: new Date(NOW + 60 * DAY_MS)},
{has_ever_purchased: true, premium_type: 2},
] satisfies Array<Partial<UserRow>>) {
h.users.put(overrides);
expect((await applyAction(h.deps, purgeEnvelope())).status).toBe('applied');
expect(h.users.current().pendingBulkMessageDeletionAt?.getTime()).toBe(NOW + DELETION_DELAY_MS);
}
expect(h.shreds).toHaveLength(2);
});
it('reports a noop when nothing is left to purge', async () => {
const outcome = await applyAction(h.deps, purgeEnvelope());
expect(outcome).toMatchObject({status: 'noop', detail: null});
expect(h.shreds).toHaveLength(0);
it('cancels the schedule on off and is a noop when nothing is scheduled', async () => {
await applyAction(h.deps, purgeEnvelope());
const cancelled = await applyAction(h.deps, purgeEnvelope(false));
expect(cancelled).toMatchObject({status: 'applied', detail: null, observed: {flags: '0', deleted: false}});
expect(h.users.current().pendingBulkMessageDeletionAt).toBeNull();
expect(h.queued.has(USER_ID)).toBe(false);
expect(h.presence).toHaveLength(2);
expect((await applyAction(h.deps, purgeEnvelope(false))).status).toBe('noop');
expect(h.archives).toHaveLength(1);
h.users.put({flags: UserFlags.STAFF, pending_bulk_message_deletion_at: new Date(NOW + DAY_MS)});
expect((await applyAction(h.deps, purgeEnvelope(false))).status).toBe('applied');
h.users.rows.clear();
expect(await applyAction(h.deps, purgeEnvelope(false))).toMatchObject({status: 'ineligible', observed: null});
});
it('never purges staff, trusted, system, bot or missing accounts', async () => {
author(3);
it('never schedules for staff, trusted, system, bot, deleted or missing accounts', async () => {
for (const overrides of [{flags: UserFlags.STAFF}, {flags: UserFlags.LIMIT_EXEMPT}, {system: true}] satisfies Array<
Partial<UserRow>
>) {
h.users.put(overrides);
expect((await applyAction(h.deps, purgeEnvelope())).status).toBe('exempt');
}
h.users.put({bot: true});
expect((await applyAction(h.deps, purgeEnvelope())).status).toBe('ineligible');
for (const overrides of [{bot: true}, {flags: UserFlags.DELETED}] satisfies Array<Partial<UserRow>>) {
h.users.put(overrides);
expect((await applyAction(h.deps, purgeEnvelope())).status).toBe('ineligible');
}
h.users.rows.clear();
expect(await applyAction(h.deps, purgeEnvelope())).toMatchObject({status: 'ineligible', observed: null});
expect(h.shreds).toHaveLength(0);
expect(h.audits).toHaveLength(0);
});
it('answers a purge with on false as unsupported, since it cannot be reversed', async () => {
author(3);
const outcome = await applyAction(h.deps, purgeEnvelope(false));
expect(outcome).toMatchObject({status: 'unsupported', detail: 'a message purge cannot be reversed'});
expect(h.shreds).toHaveLength(0);
expect(h.audits).toHaveLength(0);
expect(h.archiveRequests).toEqual([]);
expect(h.queued.size).toBe(0);
});
it('answers expired actions and unknown shapes without touching the account', async () => {
@@ -69,6 +69,7 @@ import {shouldUseKeyboardShortcutsOverlayFallbackFromEvent} from '@app/features/
import {jsKeyToUiohookKeycode} from '@app/features/input/utils/UiohookKeycodes';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import MessageFocus from '@app/features/messaging/state/MessageFocus';
import {getSortedDmChannels} from '@app/features/messaging/utils/DmChannelUtils';
import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands';
import Navigation from '@app/features/navigation/state/Navigation';
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
@@ -374,7 +375,7 @@ class KeybindManager {
}
private cycleDirectMessageContext(direction: 1 | -1): void {
const dmChannels = Channels.dmChannels;
const dmChannels = getSortedDmChannels(Channels.dmChannels, Authentication.currentUserId);
const slotCount = dmChannels.length + 1;
const currentChannelId = this.currentChannelId;
const currentIndex = currentChannelId ? dmChannels.findIndex((channel) => channel.id === currentChannelId) + 1 : 0;
@@ -237,7 +237,7 @@ export const DMList = observer(() => {
},
[requestPurgePersonalNotes],
);
const filteredDmChannels = useMemo(() => getSortedDmChannels(dmChannels, currentUserId), [dmChannels, currentUserId]);
const filteredDmChannels = getSortedDmChannels(dmChannels, currentUserId);
const dmListNavigationRef = useRovingFocusList<HTMLDivElement>({
focusableSelector: '[data-dm-list-focus-item="true"]',
orientation: 'vertical',
@@ -926,6 +926,7 @@ export async function crosspost(i18n: I18n, channelId: string, messageId: string
export function deleteLocal(channelId: string, messageId: string): void {
logger.debug(`Deleting message ${messageId} locally in channel ${channelId}`);
Messages.handleMessageDelete({id: messageId, channelId});
MessageReply.handleMessageDelete(channelId, messageId);
}
export function revealMessage(channelId: string, messageId: string | null): void {
@@ -3,6 +3,7 @@
import ChannelPins from '@app/features/channel/state/ChannelPins';
import type {GatewayHandlerContext} from '@app/features/gateway/events/EventRouter';
import MessageReferences from '@app/features/messaging/state/MessageReferences';
import MessageReply from '@app/features/messaging/state/MessageReply';
import Messages from '@app/features/messaging/state/MessagingMessages';
import SavedMessages from '@app/features/messaging/state/SavedMessages';
import MentionFeed from '@app/features/notification/state/MentionFeed';
@@ -20,6 +21,7 @@ export function handleMessageDelete(data: MessageDeletePayload, _context: Gatewa
ChannelPins.handleMessageDelete(data.channel_id, data.id);
Messages.handleMessageDelete({channelId: data.channel_id, id: data.id});
MessageReferences.handleMessageDelete(data.channel_id, data.id);
MessageReply.handleMessageDelete(data.channel_id, data.id);
ReadStates.handleMessageDelete({channelId: data.channel_id});
MentionFeed.handleMessageDelete(data.id);
Notification.handleMessageDelete({channelId: data.channel_id});
@@ -2,6 +2,7 @@
import type {GatewayHandlerContext} from '@app/features/gateway/events/EventRouter';
import MessageReferences from '@app/features/messaging/state/MessageReferences';
import MessageReply from '@app/features/messaging/state/MessageReply';
import Messages from '@app/features/messaging/state/MessagingMessages';
import ReadStates from '@app/features/read_state/state/ReadStates';
import Notification from '@app/features/ui/state/Notification';
@@ -14,6 +15,7 @@ interface MessageDeleteBulkPayload {
export function handleMessageDeleteBulk(data: MessageDeleteBulkPayload, _context: GatewayHandlerContext): void {
Messages.handleMessageDeleteBulk({channelId: data.channel_id, ids: data.ids});
MessageReferences.handleMessageDeleteBulk(data.channel_id, data.ids);
MessageReply.handleMessageDeleteBulk(data.channel_id, data.ids);
ReadStates.handleMessageDelete({channelId: data.channel_id});
Notification.handleMessageDelete({channelId: data.channel_id});
}
@@ -1,170 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import MessageEdit from '@app/features/messaging/state/MessageEdit';
import MessageFocus from '@app/features/messaging/state/MessageFocus';
import {insertTextAtCursor} from '@app/features/messaging/utils/TextInputEditUtils';
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
import {canFocusTextarea, safeFocus} from '@app/features/platform/utils/InputFocusManager';
import {isTextInputKeyEvent} from '@app/features/platform/utils/IsTextInputKeyEvent';
import QuickSwitcher from '@app/features/search/state/QuickSwitcher';
import ContextMenu from '@app/features/ui/state/ContextMenu';
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
import MobileLayout from '@app/features/ui/state/MobileLayout';
import {useCallback, useEffect} from 'react';
interface UseTextareaKeyboardOptions {
channelId: string;
isFocused: boolean;
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
value: string;
setValue: React.Dispatch<React.SetStateAction<string>>;
handleTextChange: (newValue: string, previousValue: string) => void;
previousValueRef: React.RefObject<string>;
clearSegments: () => void;
replyingMessage: {
messageId: string;
mentioning: boolean;
} | null;
editingMessage: Message | null;
getLastEditableMessage: () => Message | null;
enabled: boolean;
}
type ArrowUpEditShortcutEvent = Pick<
React.KeyboardEvent | KeyboardEvent,
'key' | 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey' | 'defaultPrevented'
>;
export const shouldStartLastMessageEditFromArrowUp = (event: ArrowUpEditShortcutEvent, value: string): boolean => {
if (event.defaultPrevented) return false;
if (event.key !== 'ArrowUp') return false;
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return false;
return value.length === 0;
};
export const useTextareaKeyboard = ({
channelId,
isFocused,
textareaRef,
value,
setValue,
handleTextChange,
previousValueRef,
clearSegments,
replyingMessage,
editingMessage,
getLastEditableMessage,
enabled,
}: UseTextareaKeyboardOptions) => {
const mobileLayout = MobileLayout;
const editingMessageId = MessageEdit.getEditingMessageId(channelId);
useEffect(() => {
if (!enabled) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
const textarea = textareaRef.current;
if (!canFocusTextarea(textarea || undefined)) {
return;
}
if (isFocused) {
return;
}
if (QuickSwitcher.getIsOpen()) {
return;
}
if (ContextMenu.contextMenu) {
return;
}
if (KeyboardMode.keyboardModeEnabled && MessageFocus.focusedMessageId) {
return;
}
if (!isTextInputKeyEvent(event)) {
return;
}
if (!textarea) {
return;
}
if (event.key === 'Dead') {
safeFocus(textarea, true);
return;
}
event.preventDefault();
safeFocus(textarea, true);
const inserted = insertTextAtCursor(textarea, event.key);
if (!inserted) {
setValue((prev) => {
const newValue = prev + event.key;
handleTextChange(newValue, previousValueRef.current ?? '');
return newValue;
});
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [
editingMessageId,
isFocused,
mobileLayout.enabled,
handleTextChange,
previousValueRef,
textareaRef,
setValue,
enabled,
]);
useEffect(() => {
if (!enabled) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
const isEditingInline = MessageEdit.getEditingMessageId(channelId) != null;
if (isEditingInline) {
event.preventDefault();
event.stopPropagation();
MessageCommands.stopEdit(channelId);
return;
}
if (editingMessage && mobileLayout.enabled) {
event.preventDefault();
MessageCommands.stopEditMobile(channelId);
setValue('');
clearSegments();
} else if (replyingMessage) {
event.preventDefault();
MessageCommands.stopReply(channelId);
} else {
event.preventDefault();
ComponentBus.dispatch('ESCAPE_PRESSED');
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [channelId, replyingMessage, editingMessage, mobileLayout.enabled, clearSegments, setValue, enabled]);
const handleArrowUp = useCallback(
(event: React.KeyboardEvent) => {
if (!shouldStartLastMessageEditFromArrowUp(event, value)) {
return;
}
if (KeyboardMode.keyboardModeEnabled) {
event.preventDefault();
ComponentBus.dispatch('FOCUS_BOTTOMMOST_MESSAGE', {channelId});
return;
}
const message = getLastEditableMessage();
if (!message) {
return;
}
event.preventDefault();
MessageCommands.startEdit(channelId, message.id, message.content);
},
[channelId, value, getLastEditableMessage],
);
return {handleArrowUp};
};
@@ -13,7 +13,6 @@ import {emojiEquals} from '@app/features/messaging/utils/ReactionUtils';
import Relationships from '@app/features/relationship/state/Relationships';
import * as ThemeUtils from '@app/features/theme/utils/ThemeUtils';
import {User} from '@app/features/user/models/User';
import UserGuildSettings from '@app/features/user/state/UserGuildSettings';
import Users from '@app/features/user/state/Users';
import {LRUMap} from '@app/lib/list/ListLruMap';
import {MessageFlags, MessageStates, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
@@ -560,15 +559,23 @@ export class Message {
}
}
export const messageMentionsCurrentUser = (message: WireMessage): boolean => {
interface MentionSuppression {
suppressEveryone?: boolean;
suppressRoles?: boolean;
}
export const messageMentionsCurrentUser = (
message: WireMessage,
{suppressEveryone = false, suppressRoles = false}: MentionSuppression = {},
): boolean => {
const channel = Channels.getChannel(message.channel_id);
if (!channel) return false;
if (message.mention_everyone && !UserGuildSettings.isEveryoneMentionSuppressed(channel.guildId ?? null)) return true;
if (message.mention_everyone && !suppressEveryone) return true;
if (message.mentions?.some((user) => user.id === Authentication.currentUserId)) {
return true;
}
if (!channel.guildId) return false;
if (UserGuildSettings.isRoleMentionSuppressed(channel.guildId)) return false;
if (suppressRoles) return false;
const guild = Guilds.getGuild(channel.guildId);
if (!guild) return false;
const guildMember = GuildMembers.getMember(guild.id, Authentication.currentUserId);
@@ -56,6 +56,19 @@ class MessageReply {
delete this.replyingMessageIds[channelId];
}
handleMessageDelete(channelId: string, messageId: string): void {
if (this.replyingMessageIds[channelId]?.messageId === messageId) {
delete this.replyingMessageIds[channelId];
}
}
handleMessageDeleteBulk(channelId: string, messageIds: Array<string>): void {
const current = this.replyingMessageIds[channelId];
if (current && messageIds.includes(current.messageId)) {
delete this.replyingMessageIds[channelId];
}
}
highlightMessage(messageId: string): void {
this.highlightMessageId = messageId;
}
@@ -232,7 +232,12 @@ class Messages {
getLastEditableMessage(channelId: string): Message | undefined {
return this.getMessages(channelId).searchFromNewest((message) => {
return message.isCurrentUserAuthor() && message.state === MessageStates.SENT && message.isUserMessage();
return (
message.isCurrentUserAuthor() &&
message.state === MessageStates.SENT &&
message.isUserMessage() &&
!message.messageSnapshots
);
});
}
@@ -11,6 +11,7 @@ import {
} from '@app/features/notification/utils/MentionFeedFilters';
import Relationships from '@app/features/relationship/state/Relationships';
import {makeSyncedField} from '@app/features/user/state/SyncedField';
import UserGuildSettings from '@app/features/user/state/UserGuildSettings';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {MAX_MESSAGES_PER_CHANNEL} from '@fluxer/constants/src/LimitConstants';
import type {Channel as WireChannel} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
@@ -179,14 +180,19 @@ class MentionFeed {
}
handleMessageCreate(message: WireMessage): void {
if (!messageMentionsCurrentUser(message)) {
const channel = Channels.getChannel(message.channel_id);
if (!channel) return;
const guildId = channel.guildId ?? null;
const mentioned = messageMentionsCurrentUser(message, {
suppressEveryone: UserGuildSettings.isEveryoneMentionSuppressed(guildId),
suppressRoles: UserGuildSettings.isRoleMentionSuppressed(guildId),
});
if (!mentioned) {
return;
}
if (Relationships.isBlocked(message.author.id)) {
return;
}
const channel = Channels.getChannel(message.channel_id);
if (!channel) return;
if (!this.isMessageIncludedByFilters(message, channel)) {
return;
}
@@ -47,7 +47,7 @@
<image type="source" width="2560" height="2227">https://fluxer.app/static/img/screenshots-desktop-2560w.948e9d88f4c9807a.avif</image>
</screenshot>
</screenshots>
<url type="bugtracker">https://github.com/fluxerapp/fluxer/issues</url>
<url type="bugtracker">https://feedback.fluxer.com</url>
<url type="homepage">https://fluxer.app</url>
<url type="donation">https://fluxer.app/donate</url>
<url type="contact">https://fluxer.app/company-information#contact</url>
@@ -47,7 +47,7 @@
<image type="source" width="1920" height="1670">https://fluxer.app/static/img/screenshots-desktop-1920w.4976d2d1f2238c05.avif</image>
</screenshot>
</screenshots>
<url type="bugtracker">https://github.com/fluxerapp/fluxer/issues</url>
<url type="bugtracker">https://feedback.fluxer.com</url>
<url type="homepage">https://canary.fluxer.app</url>
<url type="donation">https://fluxer.app/donate</url>
<url type="vcs-browser">https://github.com/fluxerapp/fluxer.git</url>
+1 -1
View File
@@ -215,7 +215,7 @@ function buildTemplate(): Array<MenuItemConstructorOptions> {
{
label: t('desktop.appMenu.reportIssue'),
click: async () => {
await openExternalDeduped('https://github.com/fluxerapp/fluxer/issues');
await openExternalDeduped('https://feedback.fluxer.com');
},
},
{type: 'separator'},
@@ -2198,6 +2198,7 @@ async function verifyInstallerExecution(installerRoot: string): Promise<Array<st
env: {
...process.env,
PATH: `${stubBin}${path.delimiter}${process.env.PATH ?? ''}`,
FLUXER_INSTALLER_REFRESHED: '1',
...(cwd == null ? {} : {PWD: cwd}),
},
});
@@ -388,7 +388,7 @@ Default `fluxer`. The password. The literal `fluxer` is rejected in production.
#### `FLUXER_POSTGRES_SSL`
Default `false`. TLS to the database. Must be `true` or `false`. In production it must be `true` unless `FLUXER_SELF_HOSTED=true`. Compose forwards it from `.env`.
Default `false`. TLS to the database. Must be `true` or `false`. When `false`, an `sslmode` other than `prefer` in `FLUXER_POSTGRES_URL` still applies. In production it must be `true` unless `FLUXER_SELF_HOSTED=true`. Compose forwards it from `.env`.
#### `FLUXER_POSTGRES_SSL_CA`
@@ -928,6 +928,10 @@ Default `push.fluxer.com`. Hostnames, comma separated, of the Fluxer-run push re
Default empty. Hostnames, comma separated, of a push relay this instance runs itself. `push` delivers a browser push endpoint on one of them straight through its own APNs or FCM credentials, with no HTTP hop. Compose forwards it from `.env`.
#### `FLUXER_PUSH_SERVICE_PRIVATE_HOSTS`
Default empty. Hostnames, comma separated, that a push endpoint may use even when they resolve to private, loopback, or other non-public addresses, such as a ntfy server on your own network. `push` refuses every other such endpoint and logs `blocked_address`. Compose forwards it from `.env`.
`push` also runs as a relay under `--mode relay`, which the stack does not use. Only that mode reads `FLUXER_PUSH_RELAY_MAX_CONCURRENT`, default `1024`, `FLUXER_PUSH_RELAY_MAX_BODY_BYTES`, default `2816`, `FLUXER_PUSH_RELAY_TRUSTED_PROXY_HOPS`, default `1`, and `FLUXER_PUSH_RELAY_SOURCE_BUCKET_ENABLED`, default `false`. Compose does not forward them, and [Names the stack has no use for](#names-the-stack-has-no-use-for) lists them.
## Payments
@@ -240,7 +240,7 @@ Once email is on, the address goes through a DNS check before the account exists
Sign in to the admin dashboard at `https://chat.example.com/admin` with the account holding the wildcard ACL. The **Instance Config** page has everything the wizard asked, plus registration mode, approvals and integration keys. **Limit Config** holds the instance limits published to clients. **Voice Regions** and **Voice Servers** come seeded, so voice needs no setup there.
The desktop client opens the hosted web app for its release channel, so reach your own instance in a browser. [Issue #1088](https://github.com/fluxerapp/fluxer/issues/1088) tracks that, and is being worked on.
The desktop client opens the hosted web app for its release channel, so reach your own instance in a browser. [This feedback post](https://feedback.fluxer.com/p/641) tracks that, and is being worked on.
[Configuration](/operator/configuration/) lists every runtime setting and the environment variable each one overrides. [Deployment availability](/http-api/deployment-availability/) lists the routes that exist only on the hosted deployment.
@@ -128,6 +128,8 @@ Removing the line from `.env` also lets the upgrade run, and it is the wrong fix
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.
`--update` first compares the installer against the digest at `https://fluxer.dev/install.sh.sha256`. When the copy differs, it downloads the current one, checks it against that digest, and asks before it replaces the copy and runs it with the same options. Answering no, `--non-interactive`, or a run without a terminal stops before anything changes. On Windows the same check reads `https://fluxer.dev/install.ps1.sha256`.
See the plan first:
```bash
+75
View File
@@ -59,11 +59,17 @@ param(
[string[]]$Rest = @()
)
$FluxerScriptArguments = @{}
foreach ($entry in $PSBoundParameters.GetEnumerator()) {
$FluxerScriptArguments[$entry.Key] = $entry.Value
}
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$FluxerRawBase = 'https://raw.githubusercontent.com/fluxerapp/fluxer'
$FluxerInstallerUrl = 'https://fluxer.dev/install.ps1'
$FluxerStackPath = 'deploy/self-hosting'
$FluxerHealthPath = '/_health'
$FluxerInitService = 'seaweedfs-init'
@@ -1980,6 +1986,71 @@ function Assert-FluxerComposeFiles([string]$TargetDir, [string]$EnvPath) {
}
}
function Get-FluxerSha256([string]$Path) {
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
}
function Update-FluxerInstaller {
if ($env:FLUXER_INSTALLER_REFRESHED) {
return
}
$self = $PSCommandPath
if (-not $self) {
return
}
$selfDir = Split-Path -Parent $self
if ($DryRun) {
$staging = New-FluxerStagingDirectory ([System.IO.Path]::GetTempPath())
} else {
$staging = New-FluxerStagingDirectory $selfDir
}
try {
$digestPath = Join-Path $staging 'install.ps1.sha256'
try {
Invoke-WebRequest -Uri "$FluxerInstallerUrl.sha256" -OutFile $digestPath -UseBasicParsing -MaximumRedirection 5 -TimeoutSec 60
} catch {
Write-FluxerLine "Could not reach $FluxerInstallerUrl.sha256, so this run goes on with $self."
return
}
$published = ([System.IO.File]::ReadAllText($digestPath).Trim() -split '\s+')[0].ToLowerInvariant()
if ($published -notmatch '^[0-9a-f]{64}$') {
Stop-Fluxer "$FluxerInstallerUrl.sha256 holds no sha256 digest. Nothing was changed." $FluxerExitDownload
}
if ((Get-FluxerSha256 $self) -eq $published) {
return
}
$fresh = Join-Path $staging 'install.ps1'
try {
Invoke-WebRequest -Uri $FluxerInstallerUrl -OutFile $fresh -UseBasicParsing -MaximumRedirection 5 -TimeoutSec 120
} catch {
Stop-Fluxer "Download failed for $FluxerInstallerUrl. Nothing was changed." $FluxerExitDownload
}
if ((Get-FluxerSha256 $fresh) -ne $published) {
Stop-Fluxer "$FluxerInstallerUrl does not match the digest in $FluxerInstallerUrl.sha256. Nothing was changed." $FluxerExitDownload
}
Write-FluxerLine "$self differs from the installer $FluxerInstallerUrl serves. The stack files an upgrade downloads can require .env keys that only the current installer writes."
if ($DryRun) {
Write-FluxerLine 'The run asks to replace it with the current installer before it changes anything. The plan below is the one this copy would follow.'
return
}
if ($NonInteractive -or [Console]::IsInputRedirected) {
Stop-Fluxer "Nothing was changed. Download the current installer and run it:`n Invoke-WebRequest -Uri $FluxerInstallerUrl -OutFile install.ps1 -UseBasicParsing" $FluxerExitRefused
}
$answer = Read-Host -Prompt "Replace $self with the current installer and run that? [y/N]"
if ($null -eq $answer -or $answer.Trim() -notmatch '^(y|yes)$') {
Stop-Fluxer "Kept $self. Nothing was changed. Read the current installer at $FluxerInstallerUrl and run it once it is in place." $FluxerExitRefused
}
Move-Item -LiteralPath $fresh -Destination $self -Force
} finally {
Remove-FluxerStagingDirectory $staging
}
Write-FluxerLine "Replaced $self. Running it."
$env:FLUXER_INSTALLER_REFRESHED = '1'
$global:LASTEXITCODE = 0
& $self @FluxerScriptArguments
exit $LASTEXITCODE
}
function Invoke-FluxerInstall {
if ($Help) {
Show-FluxerUsage
@@ -2022,6 +2093,10 @@ function Invoke-FluxerInstall {
Invoke-FluxerPreflight
if ($Update) {
Update-FluxerInstaller
}
$targetPath = $Dir
$adoptedCwd = $false
$fellBack = $false
+85
View File
@@ -51,6 +51,7 @@ LC_ALL=C
export LC_ALL
FLUXER_RAW_BASE='https://raw.githubusercontent.com/fluxerapp/fluxer'
FLUXER_INSTALLER_URL='https://fluxer.dev/install.sh'
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
@@ -305,6 +306,18 @@ opt_no_volume_compression=0
opt_skip_backup=0
opt_allow_root=0
fluxer_self=''
if [ -f "$0" ]; then
case $0 in
/*) fluxer_self=$0 ;;
*) fluxer_self="$(pwd)/$0" ;;
esac
fi
fluxer_self_args=''
for fluxer_arg in "$@"; do
fluxer_self_args="$fluxer_self_args '$(printf '%s' "$fluxer_arg" | sed "s/'/'\\\\''/g")'"
done
while [ $# -gt 0 ]; do
case $1 in
--domain)
@@ -651,6 +664,74 @@ fluxer_prompt() {
return 1
}
fluxer_sha256() {
openssl dgst -sha256 < "$1" | awk '{print $NF}'
}
fluxer_drop_scratch() {
fluxer_cleanup
fluxer_scratch=''
}
fluxer_refresh_installer() {
[ -z "${FLUXER_INSTALLER_REFRESHED:-}" ] || return 0
[ -n "$fluxer_self" ] || return 0
fluxer_self_dir=$(dirname "$fluxer_self")
if [ "$opt_dry_run" -eq 1 ]; then
fluxer_open_scratch "${TMPDIR:-/tmp}"
elif [ -w "$fluxer_self_dir" ]; then
fluxer_open_scratch "$fluxer_self_dir"
else
fluxer_say "$fluxer_self_dir is not writable, so this run cannot check $fluxer_self against $FLUXER_INSTALLER_URL and goes on with it."
return 0
fi
if ! curl -fsSL --proto '=https' --tlsv1.2 -o "$fluxer_scratch/install.sh.sha256" "$FLUXER_INSTALLER_URL.sha256"; then
fluxer_say "Could not reach $FLUXER_INSTALLER_URL.sha256, so this run goes on with $fluxer_self."
fluxer_drop_scratch
return 0
fi
fluxer_published=$(awk 'NR == 1 {print $1}' "$fluxer_scratch/install.sh.sha256")
case $fluxer_published in
''|*[!0-9a-f]*) fluxer_fail 4 "$FLUXER_INSTALLER_URL.sha256 holds no sha256 digest. Nothing was changed." ;;
esac
if [ "$(fluxer_sha256 "$fluxer_self")" = "$fluxer_published" ]; then
fluxer_drop_scratch
return 0
fi
if ! curl -fsSL --proto '=https' --tlsv1.2 -o "$fluxer_scratch/install.sh" "$FLUXER_INSTALLER_URL"; then
fluxer_fail 4 "Download failed for $FLUXER_INSTALLER_URL. Nothing was changed."
fi
if [ "$(fluxer_sha256 "$fluxer_scratch/install.sh")" != "$fluxer_published" ]; then
fluxer_fail 4 "$FLUXER_INSTALLER_URL does not match the digest in $FLUXER_INSTALLER_URL.sha256. Nothing was changed."
fi
fluxer_say "$fluxer_self differs from the installer $FLUXER_INSTALLER_URL serves. The stack files an upgrade downloads can require .env keys that only the current installer writes."
if [ "$opt_dry_run" -eq 1 ]; then
fluxer_say 'The run asks to replace it with the current installer before it changes anything. The plan below is the one this copy would follow.'
fluxer_drop_scratch
return 0
fi
if [ "$opt_non_interactive" -eq 1 ] || [ ! -t 0 ]; then
fluxer_fail 3 "Nothing was changed. Download the current installer and run it:
curl -fsSLO $FLUXER_INSTALLER_URL"
fi
printf 'Replace %s with the current installer and run that? [y/N] ' "$fluxer_self" >&2
fluxer_answer=''
read -r fluxer_answer || true
case $fluxer_answer in
y|Y|yes|Yes|YES) ;;
*) fluxer_fail 3 "Kept $fluxer_self. Nothing was changed. Read the current installer at $FLUXER_INSTALLER_URL and run it once it is in place." ;;
esac
if [ -x "$fluxer_self" ]; then
chmod +x "$fluxer_scratch/install.sh"
fi
mv "$fluxer_scratch/install.sh" "$fluxer_self"
fluxer_drop_scratch
fluxer_say "Replaced $fluxer_self. Running it."
FLUXER_INSTALLER_REFRESHED=1
export FLUXER_INSTALLER_REFRESHED
eval "exec sh \"\$fluxer_self\" $fluxer_self_args"
}
fluxer_resolve_values() {
if [ "$opt_update" -eq 1 ] || [ "$opt_rollback" -eq 1 ]; then
return 0
@@ -2100,6 +2181,10 @@ fluxer_preflight
fluxer_validate_options
fluxer_resolve_values
if [ "$opt_update" -eq 1 ]; then
fluxer_refresh_installer
fi
if [ "$opt_update" -eq 1 ] || [ "$opt_rollback" -eq 1 ]; then
fluxer_require_instance
fluxer_resolve_ref
+5 -3
View File
@@ -185,6 +185,7 @@ pub struct DeliveryConfig {
pub apns: Option<ApnsConfig>,
pub fcm: Option<FcmConfig>,
pub own_relay_hosts: Vec<String>,
pub private_hosts: Vec<String>,
pub managed_relay_hosts: Vec<String>,
pub relay_consent_accepted: bool,
}
@@ -266,7 +267,8 @@ impl DeliveryConfig {
vapid: vapid_config(&env)?,
apns: apns_config(&env)?,
fcm: fcm_config(&env)?,
own_relay_hosts: own_relay_hosts(&env),
own_relay_hosts: host_list(&env, "FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS"),
private_hosts: host_list(&env, "FLUXER_PUSH_SERVICE_PRIVATE_HOSTS"),
managed_relay_hosts: managed_relay_hosts(&env),
relay_consent_accepted: parse_bool(
"FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED",
@@ -277,8 +279,8 @@ impl DeliveryConfig {
}
}
fn own_relay_hosts(env: &Env) -> Vec<String> {
env.get("FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS")
fn host_list(env: &Env, key: &str) -> Vec<String> {
env.get(key)
.unwrap_or_default()
.split(',')
.map(|host| host.trim().to_ascii_lowercase())
+5 -3
View File
@@ -87,7 +87,7 @@ struct Record {
}
fn seal(state: &AppState, sub: &Subscription, envelope: &Value) -> Result<Record, SendOutcome> {
if !endpoint_is_allowed(&sub.endpoint) {
if !endpoint_is_allowed(&sub.endpoint, &state.cfg.private_hosts) {
return Err(SendOutcome::permanent("endpoint_rejected"));
}
let (Some(p256dh), Some(auth)) = (sub.p256dh_key.as_deref(), sub.auth_key.as_deref()) else {
@@ -223,7 +223,7 @@ fn classify(status: u16) -> SendOutcome {
}
}
fn endpoint_is_allowed(endpoint: &str) -> bool {
fn endpoint_is_allowed(endpoint: &str, private_hosts: &[String]) -> bool {
let Ok(url) = Url::parse(endpoint) else {
return false;
};
@@ -237,7 +237,9 @@ fn endpoint_is_allowed(endpoint: &str) -> bool {
return false;
}
match url.host() {
Some(Host::Domain(host)) => is_public_hostname(host),
Some(Host::Domain(host)) => {
resolver::is_private_host(host, private_hosts) || is_public_hostname(host)
}
Some(Host::Ipv4(ip)) => !resolver::is_blocked(IpAddr::V4(ip)),
Some(Host::Ipv6(ip)) => !resolver::is_blocked(IpAddr::V6(ip)),
None => false,
+50 -8
View File
@@ -2,10 +2,13 @@
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::sync::Arc;
use tokio::net::lookup_host;
type ResolveError = Box<dyn std::error::Error + Send + Sync>;
pub const BLOCKED_ADDRESS_ERROR: &str = "host resolved into blocked address space";
const BLOCKED_V4: &[(Ipv4Addr, u32)] = &[
(Ipv4Addr::new(0, 0, 0, 0), 8),
(Ipv4Addr::new(10, 0, 0, 0), 8),
@@ -36,26 +39,42 @@ const BLOCKED_V6: &[(Ipv6Addr, u32)] = &[
const NAT64_PREFIX: [u8; 4] = [0x00, 0x64, 0xff, 0x9b];
const SIXTOFOUR_PREFIX: [u8; 2] = [0x20, 0x02];
pub struct PublicOnlyResolver;
pub struct PublicOnlyResolver {
private_hosts: Arc<[String]>,
}
impl PublicOnlyResolver {
pub fn new(private_hosts: &[String]) -> Self {
Self {
private_hosts: private_hosts.into(),
}
}
}
impl Resolve for PublicOnlyResolver {
fn resolve(&self, name: Name) -> Resolving {
let host = name.as_str().to_owned();
Box::pin(async move { resolve_public(&host).await })
let allow_private = is_private_host(&host, &self.private_hosts);
Box::pin(async move {
let resolved: Vec<SocketAddr> = lookup_host((host.as_str(), 0)).await?.collect();
screen(resolved, allow_private)
})
}
}
async fn resolve_public(host: &str) -> Result<Addrs, ResolveError> {
let resolved: Vec<SocketAddr> = lookup_host((host, 0)).await?.collect();
screen(resolved)
pub fn is_private_host(host: &str, private_hosts: &[String]) -> bool {
let host = host.strip_suffix('.').unwrap_or(host);
private_hosts
.iter()
.any(|private| private.eq_ignore_ascii_case(host))
}
fn screen(resolved: Vec<SocketAddr>) -> Result<Addrs, ResolveError> {
fn screen(resolved: Vec<SocketAddr>, allow_private: bool) -> Result<Addrs, ResolveError> {
if resolved.is_empty() {
return Err("host resolved to no addresses".into());
}
if resolved.iter().any(|addr| is_blocked(addr.ip())) {
return Err("host resolved into blocked address space".into());
if !allow_private && resolved.iter().any(|addr| is_blocked(addr.ip())) {
return Err(BLOCKED_ADDRESS_ERROR.into());
}
Ok(Box::new(resolved.into_iter()))
}
@@ -122,3 +141,26 @@ fn embedded_v4(ip: Ipv6Addr) -> Option<Ipv4Addr> {
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn lan_address() -> Vec<SocketAddr> {
vec![SocketAddr::from(([192, 168, 1, 20], 0))]
}
#[test]
fn a_private_address_is_refused_by_default() {
let error = screen(lan_address(), false).err().unwrap();
assert_eq!(error.to_string(), BLOCKED_ADDRESS_ERROR);
}
#[test]
fn a_listed_private_host_may_resolve_to_a_private_address() {
let hosts = vec!["ntfy.example.com".to_owned()];
assert!(is_private_host("NTFY.example.com.", &hosts));
assert!(!is_private_host("other.example.com", &hosts));
assert!(screen(lan_address(), true).is_ok());
}
}
+1 -1
View File
@@ -110,7 +110,7 @@ impl AppState {
rpc: RpcClient::new(&cfg.rpc, http.clone(), Arc::clone(&metrics)),
relay_consent,
sidecar: Arc::new(Sidecar::new(Arc::clone(&metrics))),
web_push_http: vendor::web_push_http_client()?,
web_push_http: vendor::web_push_http_client(&cfg.private_hosts)?,
apns_http: vendor::apns_http_client()?,
tokens: TokenCache::new(),
draining: watch::Sender::new(false),
+34 -7
View File
@@ -2,7 +2,7 @@
use crate::config::{ApnsConfig, FcmConfig, ProviderEnvironment};
use crate::metrics::Metrics;
use crate::resolver::PublicOnlyResolver;
use crate::resolver::{BLOCKED_ADDRESS_ERROR, PublicOnlyResolver};
use crate::tokens::{TokenCache, TokenError};
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
use reqwest::redirect::Policy;
@@ -30,10 +30,10 @@ pub fn http_client() -> reqwest::Result<reqwest::Client> {
.build()
}
pub fn web_push_http_client() -> reqwest::Result<reqwest::Client> {
pub fn web_push_http_client(private_hosts: &[String]) -> reqwest::Result<reqwest::Client> {
reqwest::Client::builder()
.redirect(Policy::none())
.dns_resolver(PublicOnlyResolver)
.dns_resolver(PublicOnlyResolver::new(private_hosts))
.connect_timeout(CONNECT_TIMEOUT)
.timeout(HTTP_TIMEOUT)
.build()
@@ -65,6 +65,7 @@ pub enum VendorOutcome {
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Unreachable {
BlockedAddress,
Dns,
Transport,
}
@@ -72,17 +73,20 @@ pub enum Unreachable {
impl Unreachable {
pub fn label(self) -> &'static str {
match self {
Self::BlockedAddress => "blocked_address",
Self::Dns => "dns",
Self::Transport => "transport",
}
}
pub fn is_permanent(self) -> bool {
matches!(self, Self::Dns)
matches!(self, Self::BlockedAddress | Self::Dns)
}
pub fn of(error: &reqwest::Error) -> Self {
if names_no_host(error) {
if source_mentions(error, BLOCKED_ADDRESS_ERROR) {
Self::BlockedAddress
} else if source_mentions(error, DNS_ERROR_MARKER) {
Self::Dns
} else {
Self::Transport
@@ -90,10 +94,10 @@ impl Unreachable {
}
}
fn names_no_host(error: &reqwest::Error) -> bool {
fn source_mentions(error: &reqwest::Error, marker: &str) -> bool {
let mut current = std::error::Error::source(error);
while let Some(error) = current {
if error.to_string().contains(DNS_ERROR_MARKER) {
if error.to_string().contains(marker) {
return true;
}
current = error.source();
@@ -320,6 +324,29 @@ mod tests {
);
}
#[tokio::test]
async fn a_host_in_blocked_address_space_is_named_as_such() {
let error = web_push_http_client(&[])
.expect("the http client builds")
.post("https://localhost/push")
.send()
.await
.expect_err("the request cannot complete");
assert_eq!(Unreachable::of(&error), Unreachable::BlockedAddress);
assert!(Unreachable::of(&error).is_permanent());
}
#[tokio::test]
async fn a_listed_private_host_gets_through_to_connect() {
let error = web_push_http_client(&["localhost".to_owned()])
.expect("the http client builds")
.post("https://localhost:1/push")
.send()
.await
.expect_err("the request cannot complete");
assert_eq!(Unreachable::of(&error), Unreachable::Transport);
}
#[tokio::test]
async fn a_refused_connection_stays_retryable() {
let error = error_for("http://127.0.0.1:1/").await;
+30 -6
View File
@@ -62,7 +62,6 @@ impl PostgresConfig {
}
pub async fn connect(config: &PostgresConfig) -> anyhow::Result<Pool> {
let has_url = config.url.is_some();
let mut pg = if let Some(url) = &config.url {
PgConfig::from_str(url).context("failed to parse FLUXER_POSTGRES_URL")?
} else {
@@ -77,11 +76,7 @@ pub async fn connect(config: &PostgresConfig) -> anyhow::Result<Pool> {
pg
};
if config.ssl {
pg.ssl_mode(SslMode::Require);
} else if !has_url {
pg.ssl_mode(SslMode::Disable);
}
apply_ssl_mode(&mut pg, config.ssl);
let tls = if pg.get_ssl_mode() == SslMode::Disable {
build_disabled_tls_connector()
@@ -111,6 +106,14 @@ pub async fn connect(config: &PostgresConfig) -> anyhow::Result<Pool> {
Ok(pool)
}
fn apply_ssl_mode(pg: &mut PgConfig, ssl: bool) {
if ssl {
pg.ssl_mode(SslMode::Require);
} else if pg.get_ssl_mode() == SslMode::Prefer {
pg.ssl_mode(SslMode::Disable);
}
}
fn build_tls_connector(ca_pem: Option<&str>) -> anyhow::Result<MakeRustlsConnect> {
let _ = rustls::crypto::ring::default_provider().install_default();
if let Some(ca_pem) = ca_pem.filter(|value| !value.trim().is_empty()) {
@@ -780,6 +783,27 @@ mod tests {
use super::*;
use serde_json::json;
fn ssl_mode_for(url: &str, ssl: bool) -> SslMode {
let mut pg = PgConfig::from_str(url).unwrap();
apply_ssl_mode(&mut pg, ssl);
pg.get_ssl_mode()
}
#[test]
fn ssl_flag_decides_tls_unless_url_names_a_mode() {
let socket = "postgres://fluxer@/fluxer?host=/run/postgresql";
assert_eq!(SslMode::Disable, ssl_mode_for(socket, false));
assert_eq!(SslMode::Require, ssl_mode_for(socket, true));
assert_eq!(
SslMode::Require,
ssl_mode_for("postgres://db.example.com/fluxer?sslmode=require", false)
);
assert_eq!(
SslMode::Require,
ssl_mode_for("postgres://db.example.com/fluxer?sslmode=disable", true)
);
}
#[test]
fn encodes_row_keys_like_postgres_kv_executor() {
assert_eq!(
+7
View File
@@ -102,6 +102,7 @@ function defaultConfig(): MasterConfig {
headers_timeout_ms: 30_000,
request_timeout_ms: 120_000,
max_inflight_requests: 512,
automated_message_deletion_delay_days: 7,
ip_ban_exempt_ips: [],
donation_proxy_key: '',
trusted_callers: [],
@@ -604,6 +605,12 @@ function normalizeConfig(config: MasterConfig): MasterConfig {
validateReplyToEmail(config.integrations.email.reply_to_email);
normalizeAppOriginAliases(config);
assertIntegerInRange(config.services.api.max_inflight_requests, 'FLUXER_API_MAX_INFLIGHT_REQUESTS', 1, 100_000);
assertIntegerInRange(
config.services.api.automated_message_deletion_delay_days,
'FLUXER_API_AUTOMATED_MESSAGE_DELETION_DELAY_DAYS',
1,
365,
);
assertIntegerInRange(config.services.api.headers_timeout_ms, 'FLUXER_API_HEADERS_TIMEOUT_MS', 1_000, 3_600_000);
assertIntegerInRange(config.services.api.request_timeout_ms, 'FLUXER_API_REQUEST_TIMEOUT_MS', 1_000, 3_600_000);
assertIntegerInRange(config.domain.public_port, 'FLUXER_PUBLIC_PORT', 1, 65_535);
+1
View File
@@ -88,6 +88,7 @@ export interface MasterConfig {
headers_timeout_ms: number;
request_timeout_ms: number;
max_inflight_requests: number;
automated_message_deletion_delay_days: number;
ip_ban_exempt_ips: Array<string>;
donation_proxy_key: string;
trusted_callers: Array<{
@@ -20,6 +20,7 @@ const API_SETTINGS_NOT_FORWARDED: Record<string, string> = {
FLUXER_TEST_MODE_ENABLED: 'test and development only',
FLUXER_TEST_HARNESS_TOKEN: 'test and development only',
FLUXER_VALIDATE_RESPONSES: 'test and development only',
FLUXER_API_AUTOMATED_MESSAGE_DELETION_DELAY_DAYS: 'automated account actions run only on the hosted service',
...Object.fromEntries(
['MONTHLY', 'YEARLY', 'GIFT_1_MONTH', 'GIFT_1_YEAR'].flatMap((slot) =>
['USD', 'EUR', 'BRL', 'DKK', 'INR', 'NOK', 'PLN', 'SEK', 'TRY'].map((currency) => [
@@ -71,6 +71,10 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
FLUXER_API_HEADERS_TIMEOUT_MS: {path: ['services', 'api', 'headers_timeout_ms'], parse: parseInteger},
FLUXER_API_REQUEST_TIMEOUT_MS: {path: ['services', 'api', 'request_timeout_ms'], parse: parseInteger},
FLUXER_API_MAX_INFLIGHT_REQUESTS: {path: ['services', 'api', 'max_inflight_requests'], parse: parseInteger},
FLUXER_API_AUTOMATED_MESSAGE_DELETION_DELAY_DAYS: {
path: ['services', 'api', 'automated_message_deletion_delay_days'],
parse: parseInteger,
},
FLUXER_API_IP_BAN_EXEMPT_IPS: {path: ['services', 'api', 'ip_ban_exempt_ips'], parse: parseCsv},
FLUXER_API_DONATION_PROXY_KEY: {path: ['services', 'api', 'donation_proxy_key']},
FLUXER_API_TRUSTED_CALLERS: {path: ['services', 'api', 'trusted_callers'], parse: parseJsonArray},