mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 14:21:25 +02:00
fix: enforce operated paid mode contracts
This commit is contained in:
@@ -51,6 +51,7 @@ OPERATOR_NAME=Such Software
|
||||
OPERATOR_CONTACT_URL=mailto:apps@such.software
|
||||
OPERATOR_CONTACT_LABEL=apps@such.software
|
||||
FREE_PLAY_ENABLED=true # offer free play as a choice (Pleb board)
|
||||
GAME_MODE=PAID_CREDITS # exact operated-profile legacy/socket identity
|
||||
PAYMENT_MODES=credits # only purchased-credit prestige is operated here
|
||||
DIRECT_PAYMENT_ENABLED=false # per-run payment is outside the operated scope
|
||||
CREDITS_ENABLED=true
|
||||
@@ -74,7 +75,8 @@ service while the wallet/preflight and fleet activation gates remain open:
|
||||
```bash
|
||||
sudo systemctl restart wownerogue.service
|
||||
journalctl -u wownerogue.service -n 50 --no-pager # verify: "Payment system: ENABLED", payouts disabled
|
||||
curl -s localhost:3000/api/game-modes | jq '{freePlayEnabled, PAID_SINGLE, PAID_CREDITS}'
|
||||
curl -fsS localhost:3000/api/game-modes \
|
||||
| jq -e '.FREE.enabled == true and .PAID_CREDITS.enabled == true and (has("PAID_SINGLE") | not)'
|
||||
```
|
||||
|
||||
**MAINNET WALLET NO-GO:** this application release does not authorize changing, copying, stopping,
|
||||
@@ -150,6 +152,7 @@ DIFFICULTY_PRESET=casino
|
||||
|
||||
# --- payments + free play ---
|
||||
PAYMENTS_ENABLED=true
|
||||
GAME_MODE=PAID_SINGLE
|
||||
PAYMENT_MODES=direct
|
||||
FREE_PLAY_ENABLED=true # free and direct solo boards available
|
||||
|
||||
@@ -244,6 +247,9 @@ domains, websocket upgrades, and local readiness probes still work.
|
||||
## Step 6 — Verify end to end
|
||||
|
||||
- `monerogue.app` loads; `play.wowne.ro` still healthy.
|
||||
- Each public modes document contains only its operated paid solo product:
|
||||
Wownero has `PAID_CREDITS` and no `PAID_SINGLE`; Monerogue has `PAID_SINGLE` and no
|
||||
`PAID_CREDITS`.
|
||||
- After wallet promotion and the separately approved bounded canary: buy credits / pay entry with a
|
||||
stagenet wallet → game starts → qualifying solo outcome → 2×/3× test-coin payout arrives.
|
||||
- After the corresponding gates clear, both boards populate: free game → Pleb, paid game → Hall of
|
||||
|
||||
@@ -39,6 +39,7 @@ PAID_ACKNOWLEDGEMENT_REQUIRED=true
|
||||
# Free runs rank on the Pleb board; purchased credits rank on the paid prestige board.
|
||||
PAYMENTS_ENABLED=true
|
||||
FREE_PLAY_ENABLED=true
|
||||
GAME_MODE=PAID_CREDITS
|
||||
PAYMENT_MODES=credits
|
||||
ALLOW_MIXED_MODE=false
|
||||
PREFER_CREDITS_FIRST=true
|
||||
|
||||
@@ -40,6 +40,7 @@ PAID_ACKNOWLEDGEMENT_REQUIRED=true
|
||||
# Purchased-credit entry is outside this operated profile and stays fail-closed.
|
||||
PAYMENTS_ENABLED=true
|
||||
FREE_PLAY_ENABLED=true
|
||||
GAME_MODE=PAID_SINGLE
|
||||
PAYMENT_MODES=direct
|
||||
ALLOW_MIXED_MODE=false
|
||||
PREFER_CREDITS_FIRST=false
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
const { isSupported, familyFor } = require('../chain/chainProfile');
|
||||
const { resolveMatchRuleset } = require('../game/rulesets');
|
||||
const money = require('../money/atomic');
|
||||
const { validateOperatedProductProfile } = require('./operatedProductProfiles');
|
||||
const {
|
||||
getOperatedProductProfile,
|
||||
validateOperatedProductProfile
|
||||
} = require('./operatedProductProfiles');
|
||||
const {
|
||||
PG_BIGINT_MAX,
|
||||
cryptoRulesetSupported,
|
||||
@@ -133,7 +136,10 @@ class EnvironmentValidator {
|
||||
const financialSinkUrl = String(env.FINANCIAL_EVENT_SINK_URL || '').trim();
|
||||
const financialSinkToken = String(env.FINANCIAL_EVENT_SINK_TOKEN || '').trim();
|
||||
|
||||
if (env.GAME_MODE && env.PAYMENT_MODES) {
|
||||
const operatedProfile = getOperatedProductProfile(env);
|
||||
const exactOperatedLegacyMode = operatedProfile
|
||||
&& String(env.GAME_MODE || '').trim() === operatedProfile.gameMode;
|
||||
if (env.GAME_MODE && env.PAYMENT_MODES && !exactOperatedLegacyMode) {
|
||||
warnings.push('Both GAME_MODE and PAYMENT_MODES are set. PAYMENT_MODES takes precedence.');
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ const SOURCE_SOFTWARE = Object.freeze({
|
||||
const PROFILES = Object.freeze({
|
||||
[OPERATED_PRODUCT_PROFILE_IDS.WOW_PRESTIGE]: Object.freeze({
|
||||
id: OPERATED_PRODUCT_PROFILE_IDS.WOW_PRESTIGE,
|
||||
gameMode: 'PAID_CREDITS',
|
||||
hostname: 'play.wowne.ro',
|
||||
publicUrl: 'https://play.wowne.ro',
|
||||
operatorName: 'Such Software',
|
||||
@@ -41,6 +42,7 @@ const PROFILES = Object.freeze({
|
||||
}),
|
||||
[OPERATED_PRODUCT_PROFILE_IDS.XMR_STAGENET]: Object.freeze({
|
||||
id: OPERATED_PRODUCT_PROFILE_IDS.XMR_STAGENET,
|
||||
gameMode: 'PAID_SINGLE',
|
||||
hostname: 'monerogue.app',
|
||||
publicUrl: 'https://monerogue.app',
|
||||
operatorName: 'Such Software',
|
||||
@@ -73,6 +75,16 @@ function getOperatedProductProfile(env = process.env) {
|
||||
return PROFILES[selectedProfileId(env)] || null;
|
||||
}
|
||||
|
||||
function selectPublicPaidModeDescriptors(env = process.env, descriptors = {}) {
|
||||
const profile = getOperatedProductProfile(env);
|
||||
if (!profile) {
|
||||
return { ...descriptors };
|
||||
}
|
||||
|
||||
const descriptor = descriptors[profile.gameMode];
|
||||
return descriptor === undefined ? {} : { [profile.gameMode]: descriptor };
|
||||
}
|
||||
|
||||
function isExactPublicRoot(value, expected) {
|
||||
try {
|
||||
const actual = new URL(String(value || ''));
|
||||
@@ -134,6 +146,8 @@ function validateOperatedProductProfile(env = process.env, config = {}) {
|
||||
`TERMS_EFFECTIVE_DATE=${profile.termsEffectiveDate}`);
|
||||
requireContract(isExplicitTrue(env.PAYMENTS_ENABLED) && config?.paymentsEnabled === true,
|
||||
'PAYMENTS_ENABLED=true');
|
||||
requireContract(String(env.GAME_MODE || '').trim() === profile.gameMode,
|
||||
`GAME_MODE=${profile.gameMode}`);
|
||||
requireContract(isExplicitTrue(env.PAID_ACKNOWLEDGEMENT_REQUIRED),
|
||||
'PAID_ACKNOWLEDGEMENT_REQUIRED=true');
|
||||
requireContract(isExplicitTrue(env.FREE_PLAY_ENABLED), 'FREE_PLAY_ENABLED=true');
|
||||
@@ -190,6 +204,7 @@ module.exports = {
|
||||
OPERATED_PRODUCT_PROFILE_IDS,
|
||||
SOURCE_SOFTWARE,
|
||||
getOperatedProductProfile,
|
||||
selectPublicPaidModeDescriptors,
|
||||
selectedProfileId,
|
||||
validateOperatedProductProfile
|
||||
};
|
||||
|
||||
@@ -386,11 +386,18 @@ class PaymentConfigManager {
|
||||
if (!this.config.paymentsEnabled) {
|
||||
return 'FREE';
|
||||
}
|
||||
if (this.legacyOverride) {
|
||||
return this.legacyOverride.toUpperCase();
|
||||
}
|
||||
const directEnabled = !!this.config.modes.direct.enabled;
|
||||
const creditsEnabled = !!this.config.modes.credits.enabled;
|
||||
const legacyOverride = String(this.legacyOverride || '').trim().toUpperCase();
|
||||
// GAME_MODE is legacy compatibility input, not authority over the parsed payment
|
||||
// policy. Honor it only when the selected mode is actually enabled; contradictory
|
||||
// PAYMENT_MODES/per-mode switches must never be misreported to sockets or health.
|
||||
if (legacyOverride === 'PAID_SINGLE' && directEnabled) {
|
||||
return 'PAID_SINGLE';
|
||||
}
|
||||
if (legacyOverride === 'PAID_CREDITS' && creditsEnabled) {
|
||||
return 'PAID_CREDITS';
|
||||
}
|
||||
if (directEnabled && !creditsEnabled) {
|
||||
return 'PAID_SINGLE';
|
||||
}
|
||||
|
||||
@@ -1258,6 +1258,27 @@ class GameModeManager {
|
||||
};
|
||||
}
|
||||
|
||||
getPublicPreferredPaymentMode(user) {
|
||||
if (!this.paymentsEnabled) {
|
||||
return 'free';
|
||||
}
|
||||
|
||||
const preferred = String(user?.preferred_payment_mode || '').trim().toLowerCase();
|
||||
if (preferred === 'direct' && this.directModeEnabled) {
|
||||
return 'direct';
|
||||
}
|
||||
if (preferred === 'credits' && this.creditsModeEnabled) {
|
||||
return 'credits';
|
||||
}
|
||||
if (this.creditsModeEnabled) {
|
||||
return 'credits';
|
||||
}
|
||||
if (this.directModeEnabled) {
|
||||
return 'direct';
|
||||
}
|
||||
return 'free';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available payment options for a user
|
||||
* @param {string} socketId - Socket ID
|
||||
|
||||
+19
-14
@@ -51,6 +51,7 @@ const { attachDungeonVerification } = require('./game/fairnessVerifier');
|
||||
const { renderVerifyPage } = require('./views/verifyPage');
|
||||
const { renderPrivacy, renderResponsiblePlay, renderTerms } = require('./views/legalPages');
|
||||
const { buildCommerceDisclosure } = require('./config/commerceDisclosurePolicy');
|
||||
const { selectPublicPaidModeDescriptors } = require('./config/operatedProductProfiles');
|
||||
const createAdminRoutes = require('./routes/admin');
|
||||
const createAuthRoutes = require('./routes/auth');
|
||||
const { createLeaderboardHandler } = require('./routes/leaderboard');
|
||||
@@ -399,7 +400,7 @@ app.get('/api/user/:socketId/mode', requireSessionOwnership(401), asyncHandler(a
|
||||
}
|
||||
res.json({
|
||||
socketId,
|
||||
preferredPaymentMode: user.preferred_payment_mode || 'direct',
|
||||
preferredPaymentMode: gameModeManager.getPublicPreferredPaymentMode(user),
|
||||
hasPayoutAddress: !!user.payout_address,
|
||||
paymentsEnabled: gameModeManager.paymentsEnabled,
|
||||
directModeEnabled: gameModeManager.directModeEnabled,
|
||||
@@ -789,19 +790,7 @@ app.get('/api/game-modes', (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
operatedProductProfileId: disclosure.operatedProduct?.id || null,
|
||||
cryptoMatchPayoutsEnabled: disclosure.service.cryptoMatchPayoutsEnabled,
|
||||
soloEnabled: publicModeInfo.modes?.solo === true,
|
||||
// Free play is available when the instance is free-only OR when free play is offered
|
||||
// as a choice alongside paid options (FREE_PLAY_ENABLED).
|
||||
freePlayEnabled: !!gameModeManager.freePlayEnabled,
|
||||
FREE: {
|
||||
name: 'Free Play',
|
||||
cost: 0,
|
||||
payoutMultiplier: 0,
|
||||
enabled: !!gameModeManager.freePlayEnabled
|
||||
},
|
||||
const paidModeDescriptors = selectPublicPaidModeDescriptors(process.env, {
|
||||
PAID_SINGLE: {
|
||||
name: 'Paid Single Game',
|
||||
cost: toDisplay(directMode.price),
|
||||
@@ -818,7 +807,23 @@ app.get('/api/game-modes', (req, res) => {
|
||||
payoutMultiplier: isPayoutProcessingEnabled() && creditsMode.enabled && config.payouts.rules.credits.enabled
|
||||
? gameModeManager.getImplementedPayoutMultipliersForMode('PAID_CREDITS')
|
||||
: 0
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
operatedProductProfileId: disclosure.operatedProduct?.id || null,
|
||||
cryptoMatchPayoutsEnabled: disclosure.service.cryptoMatchPayoutsEnabled,
|
||||
soloEnabled: publicModeInfo.modes?.solo === true,
|
||||
// Free play is available when the instance is free-only OR when free play is offered
|
||||
// as a choice alongside paid options (FREE_PLAY_ENABLED).
|
||||
freePlayEnabled: !!gameModeManager.freePlayEnabled,
|
||||
FREE: {
|
||||
name: 'Free Play',
|
||||
cost: 0,
|
||||
payoutMultiplier: 0,
|
||||
enabled: !!gameModeManager.freePlayEnabled
|
||||
},
|
||||
...paidModeDescriptors,
|
||||
match: {
|
||||
enabled: publicModeInfo.modes?.match?.enabled === true,
|
||||
economies: publicModeInfo.modes?.match?.economies || {}
|
||||
|
||||
@@ -236,9 +236,8 @@ function assertPublicModeContract(modes) {
|
||||
assert(modes?.soloEnabled === true && modes?.FREE?.enabled === true,
|
||||
'the original solo mode and free solo entry must be enabled');
|
||||
assert(modes?.PAID_SINGLE?.enabled === true
|
||||
&& modes?.PAID_CREDITS?.enabled === false
|
||||
&& Number(modes?.PAID_CREDITS?.payoutMultiplier) === 0,
|
||||
'direct solo must be enabled and purchased-credit solo must be disabled');
|
||||
&& !Object.prototype.hasOwnProperty.call(modes || {}, 'PAID_CREDITS'),
|
||||
'direct solo must be enabled and purchased-credit solo must be omitted');
|
||||
assert(modes?.match?.enabled === true
|
||||
&& JSON.stringify(enabledEconomyIds(modes?.match?.economies))
|
||||
=== JSON.stringify(['credits_prestige', 'free']),
|
||||
|
||||
@@ -192,6 +192,7 @@ describe('EnvironmentValidator production launch gate', () => {
|
||||
CRYPTO_TYPE: 'WOW',
|
||||
MONERO_NETWORK: 'mainnet',
|
||||
FREE_PLAY_ENABLED: 'true',
|
||||
GAME_MODE: 'PAID_CREDITS',
|
||||
PAYMENT_MODES: 'credits',
|
||||
DIRECT_PAYMENT_ENABLED: 'false',
|
||||
CREDITS_ENABLED: 'true',
|
||||
@@ -219,6 +220,16 @@ describe('EnvironmentValidator production launch gate', () => {
|
||||
expect(new EnvironmentValidator({ env, logger: silentLogger })
|
||||
.assertValid(operatedConfig).errors).toEqual([]);
|
||||
|
||||
for (const gameMode of [undefined, 'PAID_SINGLE']) {
|
||||
const driftedEnv = { ...env };
|
||||
if (gameMode === undefined) delete driftedEnv.GAME_MODE;
|
||||
else driftedEnv.GAME_MODE = gameMode;
|
||||
expect(new EnvironmentValidator({ env: driftedEnv, logger: silentLogger })
|
||||
.validate(operatedConfig).errors).toContain(
|
||||
'OPERATED_PRODUCT_PROFILE=such-play-wow-prestige requires GAME_MODE=PAID_CREDITS.'
|
||||
);
|
||||
}
|
||||
|
||||
expect(new EnvironmentValidator({ env, logger: silentLogger }).validate({
|
||||
...operatedConfig,
|
||||
products: { cosmetic: [{ id: 'unrelated-pack', price: 1n }] }
|
||||
@@ -237,6 +248,7 @@ describe('EnvironmentValidator production launch gate', () => {
|
||||
LEGAL_POLICY_VERSION: '2026-07-23-v2',
|
||||
TERMS_EFFECTIVE_DATE: '2026-07-23',
|
||||
FREE_PLAY_ENABLED: 'true',
|
||||
GAME_MODE: 'PAID_SINGLE',
|
||||
PAYMENT_MODES: 'direct',
|
||||
DIRECT_PAYMENT_ENABLED: 'true',
|
||||
CREDITS_ENABLED: 'false',
|
||||
@@ -312,6 +324,7 @@ describe('EnvironmentValidator production launch gate', () => {
|
||||
CRYPTO_TYPE: 'WOW',
|
||||
MONERO_NETWORK: 'mainnet',
|
||||
FREE_PLAY_ENABLED: 'true',
|
||||
GAME_MODE: 'PAID_CREDITS',
|
||||
PAYMENT_MODES: 'direct,credits',
|
||||
DIRECT_PAYMENT_ENABLED: 'true',
|
||||
CREDITS_ENABLED: 'true',
|
||||
@@ -345,6 +358,7 @@ describe('EnvironmentValidator production launch gate', () => {
|
||||
CRYPTO_TYPE: 'XMR',
|
||||
MONERO_NETWORK: 'mainnet',
|
||||
FREE_PLAY_ENABLED: 'true',
|
||||
GAME_MODE: 'PAID_CREDITS',
|
||||
PAYMENT_MODES: 'direct,credits',
|
||||
DIRECT_PAYMENT_ENABLED: 'true',
|
||||
CREDITS_ENABLED: 'true',
|
||||
@@ -376,6 +390,7 @@ describe('EnvironmentValidator production launch gate', () => {
|
||||
expect.stringContaining('requires LEGAL_POLICY_VERSION=2026-07-23-v2'),
|
||||
expect.stringContaining('requires TERMS_EFFECTIVE_DATE=2026-07-23'),
|
||||
expect.stringContaining('requires MONERO_NETWORK=stagenet'),
|
||||
expect.stringContaining('requires GAME_MODE=PAID_SINGLE'),
|
||||
expect.stringContaining('requires PAYMENT_MODES=direct'),
|
||||
expect.stringContaining('requires CREDITS_ENABLED=false'),
|
||||
expect.stringContaining('requires CREDITS_PAYOUTS_ENABLED=false'),
|
||||
|
||||
@@ -47,6 +47,7 @@ describe('operated product environment templates', () => {
|
||||
CRYPTO_TYPE: 'WOW',
|
||||
MONERO_NETWORK: 'mainnet',
|
||||
FREE_PLAY_ENABLED: 'true',
|
||||
GAME_MODE: 'PAID_CREDITS',
|
||||
PAYMENT_MODES: 'credits',
|
||||
DIRECT_PAYMENT_ENABLED: 'false',
|
||||
CREDITS_ENABLED: 'true',
|
||||
@@ -78,6 +79,7 @@ describe('operated product environment templates', () => {
|
||||
CRYPTO_TYPE: 'XMR',
|
||||
MONERO_NETWORK: 'stagenet',
|
||||
FREE_PLAY_ENABLED: 'true',
|
||||
GAME_MODE: 'PAID_SINGLE',
|
||||
PAYMENT_MODES: 'direct',
|
||||
DIRECT_PAYMENT_ENABLED: 'true',
|
||||
CREDITS_ENABLED: 'false',
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
const {
|
||||
selectPublicPaidModeDescriptors
|
||||
} = require('../src/config/operatedProductProfiles');
|
||||
|
||||
const descriptors = Object.freeze({
|
||||
PAID_SINGLE: Object.freeze({ enabled: true, name: 'Paid Single Game' }),
|
||||
PAID_CREDITS: Object.freeze({ enabled: true, name: 'Credits Package' })
|
||||
});
|
||||
|
||||
describe('operated-product public paid-mode contract', () => {
|
||||
test('play.wowne.ro publishes credits and omits direct entry', () => {
|
||||
const result = selectPublicPaidModeDescriptors({
|
||||
OPERATED_PRODUCT_PROFILE: 'such-play-wow-prestige'
|
||||
}, descriptors);
|
||||
|
||||
expect(result).toEqual({ PAID_CREDITS: descriptors.PAID_CREDITS });
|
||||
expect(result).not.toHaveProperty('PAID_SINGLE');
|
||||
});
|
||||
|
||||
test('monerogue.app publishes direct entry and omits purchased credits', () => {
|
||||
const result = selectPublicPaidModeDescriptors({
|
||||
OPERATED_PRODUCT_PROFILE: 'such-monerogue-stagenet'
|
||||
}, descriptors);
|
||||
|
||||
expect(result).toEqual({ PAID_SINGLE: descriptors.PAID_SINGLE });
|
||||
expect(result).not.toHaveProperty('PAID_CREDITS');
|
||||
});
|
||||
|
||||
test('generic MIT deployments retain both legacy API descriptors', () => {
|
||||
const result = selectPublicPaidModeDescriptors({}, descriptors);
|
||||
|
||||
expect(result).toEqual(descriptors);
|
||||
expect(result).not.toBe(descriptors);
|
||||
});
|
||||
});
|
||||
@@ -105,6 +105,38 @@ describe('production payment policy wiring', () => {
|
||||
expect(wallet.createPaymentRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a contradictory legacy GAME_MODE cannot misreport the enabled payment policy', () => {
|
||||
process.env.PAYMENTS_ENABLED = 'true';
|
||||
process.env.GAME_MODE = 'PAID_SINGLE';
|
||||
process.env.PAYMENT_MODES = 'credits';
|
||||
process.env.DIRECT_PAYMENT_ENABLED = 'false';
|
||||
process.env.CREDITS_ENABLED = 'true';
|
||||
process.env.PAYOUTS_ENABLED = 'false';
|
||||
process.env.DIRECT_PAYOUTS_ENABLED = 'false';
|
||||
process.env.CREDITS_PAYOUTS_ENABLED = 'false';
|
||||
|
||||
const manager = new PaymentConfigManager({ logger: { warn() {}, info() {} } });
|
||||
const gameModes = new GameModeManager(
|
||||
{ query: jest.fn(), withTransaction: jest.fn() },
|
||||
{ validateAddress: jest.fn() },
|
||||
{ CONSOLE_LOGGING: false },
|
||||
manager
|
||||
);
|
||||
|
||||
expect(manager.getConfig().modes.direct.enabled).toBe(false);
|
||||
expect(manager.getConfig().modes.credits.enabled).toBe(true);
|
||||
expect(manager.getLegacyGameMode()).toBe('PAID_CREDITS');
|
||||
expect(gameModes.getGameModeInfo()).toEqual(expect.objectContaining({
|
||||
mode: 'PAID_CREDITS',
|
||||
directModeEnabled: false,
|
||||
creditsModeEnabled: true,
|
||||
payoutsEnabled: false
|
||||
}));
|
||||
expect(gameModes.getPublicPreferredPaymentMode({
|
||||
preferred_payment_mode: 'direct'
|
||||
})).toBe('credits');
|
||||
});
|
||||
|
||||
test('global per-game payout cap is an outer bound for direct and credit wins', () => {
|
||||
process.env.PAYMENT_MODES = 'direct,credits';
|
||||
process.env.PAYOUT_MAX_PER_GAME = '123456';
|
||||
|
||||
@@ -185,10 +185,6 @@ describe('XMR stagenet financial canary safety contract', () => {
|
||||
enabled: true,
|
||||
payoutMultiplier: { escape: 2, escapeWithTreasure: 3 }
|
||||
},
|
||||
PAID_CREDITS: {
|
||||
enabled: false,
|
||||
payoutMultiplier: 0
|
||||
},
|
||||
match: {
|
||||
enabled: true,
|
||||
economies: {
|
||||
|
||||
Reference in New Issue
Block a user