Skip to content

Commit f352de3

Browse files
committed
feat(challenges): generate unique random default challenge answer per community
Each community now gets a unique UUID answer for its default placeholder challenge instead of a shared hardcoded string, preventing mass-spam across communities. Owners can discover their answer via community.settings.challenges[0].options.answer (works locally and over RPC).
1 parent 674f4ce commit f352de3

3 files changed

Lines changed: 65 additions & 36 deletions

File tree

src/runtime/node/community/db-handler.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -530,8 +530,8 @@ export class DbHandler {
530530
const _usingDefaultChallenge =
531531
"_usingDefaultChallenge" in internalState
532532
? internalState._usingDefaultChallenge
533-
: //@ts-expect-error
534-
remeda.isDeepEqual(this._community._defaultCommunityChallenges, internalState?.settings?.challenges);
533+
: //@ts-expect-error - fallback for old DB records that predate _usingDefaultChallenge field
534+
LocalCommunity._isDefaultChallengeStructure(internalState?.settings?.challenges);
535535
const updateCid: string =
536536
"updateCid" in internalState && typeof internalState.updateCid === "string"
537537
? internalState.updateCid

src/runtime/node/community/local-community.ts

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -215,15 +215,33 @@ export class LocalCommunity extends RpcLocalCommunity implements CreateNewLocalC
215215
override raw: RpcLocalCommunity["raw"] = {};
216216
private _postUpdatesBuckets = [86400, 604800, 2592000, 3153600000]; // 1 day, 1 week, 1 month, 100 years. Expecting to be sorted from smallest to largest
217217

218-
private _defaultCommunityChallenges: CommunityChallengeSetting[] = [
219-
{
220-
name: "question",
221-
options: {
222-
question: "Placeholder challenge. Set your own challenges otherwise you risk getting spammed",
223-
answer: "Placeholder answer"
218+
private static _defaultChallengeQuestionText =
219+
"What is the answer to this community's challenge? (check community.settings.challenges to see the answer, or set your own challenge)";
220+
221+
static _generateDefaultChallenges(answer?: string): CommunityChallengeSetting[] {
222+
return [
223+
{
224+
name: "question",
225+
options: {
226+
question: LocalCommunity._defaultChallengeQuestionText,
227+
answer: answer ?? uuidV4()
228+
}
224229
}
225-
}
226-
];
230+
];
231+
}
232+
233+
static _isDefaultChallengeStructure(challenges: CommunityChallengeSetting[] | undefined): boolean {
234+
if (!challenges || challenges.length !== 1) return false;
235+
const c = challenges[0];
236+
return (
237+
c.name === "question" &&
238+
c.options?.question === LocalCommunity._defaultChallengeQuestionText &&
239+
typeof c.options?.answer === "string" &&
240+
c.options.answer.length > 0
241+
);
242+
}
243+
244+
private _defaultCommunityChallenges: CommunityChallengeSetting[] = LocalCommunity._generateDefaultChallenges();
227245

228246
// These caches below will be used to facilitate challenges exchange with authors, they will expire after 10 minutes
229247
// Most of the time they will be delete and cleaned up automatically
@@ -542,12 +560,24 @@ export class LocalCommunity extends RpcLocalCommunity implements CreateNewLocalC
542560
async _setChallengesToDefaultIfNotDefined(log: Logger) {
543561
if (
544562
this._usingDefaultChallenge !== false &&
545-
(!this.settings?.challenges || remeda.isDeepEqual(this.settings?.challenges, this._defaultCommunityChallenges))
563+
(!this.settings?.challenges || LocalCommunity._isDefaultChallengeStructure(this.settings?.challenges))
546564
)
547565
this._usingDefaultChallenge = true;
548-
if (this._usingDefaultChallenge && !remeda.isDeepEqual(this.settings?.challenges, this._defaultCommunityChallenges)) {
549-
await this.edit({ settings: { ...this.settings, challenges: this._defaultCommunityChallenges } });
550-
log(`Defaulted the challenges of community (${this.address}) to`, this._defaultCommunityChallenges);
566+
567+
if (this._usingDefaultChallenge) {
568+
const currentAnswer = this.settings?.challenges?.[0]?.options?.answer;
569+
if (currentAnswer) {
570+
// Preserve the existing per-community random answer in the template
571+
this._defaultCommunityChallenges = LocalCommunity._generateDefaultChallenges(currentAnswer);
572+
}
573+
574+
if (!remeda.isDeepEqual(this.settings?.challenges, this._defaultCommunityChallenges)) {
575+
await this.edit({ settings: { ...this.settings, challenges: this._defaultCommunityChallenges } });
576+
log(
577+
`Upgraded default challenge for community (${this.address}) with answer:`,
578+
this._defaultCommunityChallenges[0].options!.answer
579+
);
580+
}
551581
}
552582
}
553583

@@ -567,7 +597,10 @@ export class LocalCommunity extends RpcLocalCommunity implements CreateNewLocalC
567597
if (!this.settings?.challenges) {
568598
this.settings = { ...this.settings, challenges: this._defaultCommunityChallenges };
569599
this._usingDefaultChallenge = true;
570-
log(`Defaulted the challenges of community (${this.address}) to`, this._defaultCommunityChallenges);
600+
log(
601+
`Generated default challenge for community (${this.address}) with answer:`,
602+
this._defaultCommunityChallenges[0].options!.answer
603+
);
571604
}
572605
if (typeof this.settings?.purgeDisapprovedCommentsOlderThan !== "number") {
573606
this.settings = { ...this.settings, purgeDisapprovedCommentsOlderThan: 1.21e6 }; // two weeks
@@ -3240,7 +3273,7 @@ export class LocalCommunity extends RpcLocalCommunity implements CreateNewLocalC
32403273
challenges: await Promise.all(
32413274
newChallengeSettings.map((cs) => getCommunityChallengeFromCommunityChallengeSettings(cs, this._pkc))
32423275
),
3243-
_usingDefaultChallenge: remeda.isDeepEqual(newChallengeSettings, this._defaultCommunityChallenges)
3276+
_usingDefaultChallenge: LocalCommunity._isDefaultChallengeStructure(newChallengeSettings)
32443277
};
32453278
}
32463279

test/node/community/challenges/challenges.settings.test.ts

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,8 @@ import type { Comment } from "../../../../dist/node/publications/comment/comment
2121
describe.concurrent(`community.settings.challenges`, async () => {
2222
let pkc: PKCType;
2323
let remotePKC: PKCType;
24-
const defaultSettingsChallenges: CommunityChallengeSetting[] = [
25-
{
26-
name: "question",
27-
options: {
28-
question: "Placeholder challenge. Set your own challenges otherwise you risk getting spammed",
29-
answer: "Placeholder answer"
30-
}
31-
}
32-
];
24+
const defaultChallengeQuestionText =
25+
"What is the answer to this community's challenge? (check community.settings.challenges to see the answer, or set your own challenge)";
3326
const defaultChallengeDescriptions = ["Ask a question, like 'What is the password?'"];
3427
const defaultChallengeTypes = ["text/plain"];
3528

@@ -46,23 +39,25 @@ describe.concurrent(`community.settings.challenges`, async () => {
4639
it(`default challenges are configured on new community`, async () => {
4740
// Should be set to default on community.start()
4841
const community = (await pkc.createCommunity({})) as LocalCommunity | RpcLocalCommunity;
49-
// community?.settings?.challenges should be set to defaultSettingsChallenges
50-
// also community.challenges should reflect community.settings.challenges
51-
expect(community?.settings?.challenges).to.deep.equal(defaultSettingsChallenges);
42+
// community?.settings?.challenges should have a unique random answer
43+
const defaultSettings = community?.settings?.challenges;
44+
expect(defaultSettings).to.have.length(1);
45+
expect(defaultSettings![0].name).to.equal("question");
46+
expect(defaultSettings![0].options!.question).to.equal(defaultChallengeQuestionText);
47+
expect(defaultSettings![0].options!.answer).to.be.a("string").that.is.not.empty;
48+
expect(defaultSettings![0].options!.answer).to.not.equal("Placeholder answer");
5249

5350
expect(community._usingDefaultChallenge).to.be.true;
5451

5552
await community.start();
5653
await resolveWhenConditionIsTrue({ toUpdate: community, predicate: async () => typeof community.updatedAt === "number" });
5754
const remoteCommunity = (await remotePKC.getCommunity({ address: community.address })) as RemoteCommunity;
5855
for (const _community of [community, remoteCommunity]) {
59-
expect(_community.challenges!.length).to.equal(defaultSettingsChallenges.length);
60-
_community.challenges!.forEach((challenge, index) => {
61-
expect(challenge.type).to.equal(defaultChallengeTypes[index]);
62-
expect(challenge.description).to.equal(defaultChallengeDescriptions[index]);
63-
expect(challenge.exclude).to.deep.equal(defaultSettingsChallenges[index].exclude);
64-
});
65-
expect(_community.challenges![0].challenge).to.equal(defaultSettingsChallenges[0].options!.question);
56+
expect(_community.challenges!.length).to.equal(1);
57+
expect(_community.challenges![0].type).to.equal(defaultChallengeTypes[0]);
58+
expect(_community.challenges![0].description).to.equal(defaultChallengeDescriptions[0]);
59+
expect(_community.challenges![0].exclude).to.be.undefined;
60+
expect(_community.challenges![0].challenge).to.equal(defaultChallengeQuestionText);
6661
}
6762
// clean up
6863
await community.delete();
@@ -103,7 +98,8 @@ describe.concurrent(`community.settings.challenges`, async () => {
10398

10499
itSkipIfRpc(`pkc-js will upgrade default challenge if there is a new one`, async () => {
105100
const community = (await pkc.createCommunity({})) as LocalCommunity;
106-
expect(community?.settings?.challenges).to.deep.equal(defaultSettingsChallenges);
101+
expect(community?.settings?.challenges).to.have.length(1);
102+
expect(community?.settings?.challenges![0].name).to.equal("question");
107103
expect(community._usingDefaultChallenge).to.be.true;
108104
const differentDefaultChallenges: CommunityChallengeSetting[] = [];
109105
// Access private property via bracket notation to bypass TypeScript's access checks

0 commit comments

Comments
 (0)