mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e28092cdc | ||
|
|
8b6910d505 | ||
|
|
d87e31efaf | ||
|
|
6618a6baf4 | ||
|
|
22b8f5454b | ||
|
|
801bd3f106 | ||
|
|
e26c8c870d | ||
|
|
f4e545e090 | ||
|
|
2006fc0d8d | ||
|
|
d456048e69 | ||
|
|
fd35b4da24 |
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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(',') ?? '',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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},
|
||||
|
||||
Reference in new issue
Block a user