Skip to content

Commit 8d521f5

Browse files
authored
fix: generate new pins if old expired (#26)
fixes #18
1 parent a28ce1c commit 8d521f5

File tree

6 files changed

+193
-115
lines changed

6 files changed

+193
-115
lines changed

common/src/main/java/me/confuser/banmanager/webenhancer/common/commands/PinCommand.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ public boolean onCommand(CommonSender sender, CommandParser parser) {
2727
if (sender.isConsole()) return false;
2828
if (parser.getArgs().length != 0) return false;
2929

30+
// Check rate limit before async work
31+
if (plugin.getPlayerPinStorage().isRateLimited(sender.getData().getUUID())) {
32+
long remaining = plugin.getPlayerPinStorage().getRateLimitRemaining(sender.getData().getUUID());
33+
Message.get("pin.rateLimited")
34+
.set("seconds", String.valueOf(remaining))
35+
.sendTo(sender);
36+
return true;
37+
}
38+
3039
getPlugin().getScheduler().runAsync(() -> {
3140
PlayerData player = null;
3241

common/src/main/java/me/confuser/banmanager/webenhancer/common/storage/PlayerPinStorage.java

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
import me.confuser.banmanager.webenhancer.common.google.guava.cache.Cache;
44
import me.confuser.banmanager.webenhancer.common.google.guava.cache.CacheBuilder;
5-
import me.confuser.banmanager.common.data.PlayerWarnData;
65
import me.confuser.banmanager.common.ormlite.dao.BaseDaoImpl;
6+
import me.confuser.banmanager.common.ormlite.stmt.DeleteBuilder;
77
import me.confuser.banmanager.common.ormlite.support.ConnectionSource;
88
import me.confuser.banmanager.common.ormlite.table.DatabaseTableConfig;
99
import me.confuser.banmanager.common.ormlite.table.TableUtils;
@@ -17,9 +17,10 @@
1717
import java.util.concurrent.TimeUnit;
1818

1919
public class PlayerPinStorage extends BaseDaoImpl<PlayerPinData, Integer> {
20-
private Cache<UUID, PlayerPinData> pins = CacheBuilder.newBuilder()
21-
.expireAfterWrite(5, TimeUnit.MINUTES)
22-
.concurrencyLevel(2)
20+
private static final int RATE_LIMIT_SECONDS = 30;
21+
22+
private Cache<UUID, Long> rateLimitCache = CacheBuilder.newBuilder()
23+
.expireAfterWrite(RATE_LIMIT_SECONDS, TimeUnit.SECONDS)
2324
.maximumSize(200)
2425
.build();
2526

@@ -38,14 +39,37 @@ public PlayerPinStorage(ConnectionSource connection) throws SQLException {
3839
}
3940
}
4041

41-
public PlayerPinData generate(PlayerData player) {
42+
/**
43+
* Checks if a player is rate limited from generating a new pin.
44+
*
45+
* @param playerId the player's UUID
46+
* @return true if the player must wait before generating a new pin
47+
*/
48+
public boolean isRateLimited(UUID playerId) {
49+
return rateLimitCache.getIfPresent(playerId) != null;
50+
}
51+
52+
/**
53+
* Gets the number of seconds remaining until rate limit expires.
54+
*
55+
* @param playerId the player's UUID
56+
* @return seconds remaining, or 0 if not rate limited
57+
*/
58+
public long getRateLimitRemaining(UUID playerId) {
59+
Long lastGenerated = rateLimitCache.getIfPresent(playerId);
60+
if (lastGenerated == null) {
61+
return 0;
62+
}
63+
long elapsed = (System.currentTimeMillis() - lastGenerated) / 1000;
64+
return Math.max(0, RATE_LIMIT_SECONDS - elapsed);
65+
}
66+
67+
private PlayerPinData generate(PlayerData player) {
4268
PlayerPinData pin = null;
4369
try {
4470
pin = new PlayerPinData(player);
4571
if (create(pin) != 1) {
4672
pin = null;
47-
} else {
48-
pins.put(player.getUUID(), pin);
4973
}
5074
} catch (NoSuchAlgorithmException | SQLException e) {
5175
e.printStackTrace();
@@ -54,11 +78,29 @@ public PlayerPinData generate(PlayerData player) {
5478
return pin;
5579
}
5680

81+
/**
82+
* Gets a valid pin for the player, always generating a fresh one.
83+
* Any existing pins for this player are deleted first to prevent stale pins.
84+
* Rate limiting should be checked via isRateLimited() before calling this method.
85+
*
86+
* @param player the player to generate a pin for
87+
* @return the newly generated pin, or null if generation failed
88+
*/
5789
public PlayerPinData getValidPin(PlayerData player) {
58-
PlayerPinData pin = pins.getIfPresent(player.getUUID());
90+
try {
91+
DeleteBuilder<PlayerPinData, Integer> deleteBuilder = deleteBuilder();
92+
deleteBuilder.where().eq("player_id", player.getId());
93+
deleteBuilder.delete();
94+
} catch (SQLException e) {
95+
e.printStackTrace();
96+
}
97+
98+
// Generate fresh pin
99+
PlayerPinData pin = generate(player);
59100

60-
if (pin == null) {
61-
pin = generate(player);
101+
// Update rate limit cache on successful generation
102+
if (pin != null) {
103+
rateLimitCache.put(player.getUUID(), System.currentTimeMillis());
62104
}
63105

64106
return pin;

common/src/main/resources/messages.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ messages:
22
pin:
33
notify: '&6Your pin expires in [expires]'
44
pin: '[pin]'
5-
5+
rateLimited: '&cPlease wait [seconds] seconds before generating a new pin'

e2e/tests/src/pin-cache.test.ts

Lines changed: 53 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -5,45 +5,36 @@ import {
55
opPlayer,
66
sleep,
77
getPlayerPinCount,
8-
getAllPlayerPins,
98
clearPlayerPins,
109
closeDatabase
1110
} from './helpers'
1211

13-
describe('Pin Cache Behavior', () => {
12+
describe('Pin Rate Limiting Behavior', () => {
1413
let playerBot: TestBot
1514

16-
beforeAll(async () => {
17-
playerBot = await createBot('PinCachePlayer')
18-
await sleep(2000)
19-
await opPlayer('PinCachePlayer')
20-
await sleep(500)
21-
await clearPlayerPins()
22-
})
23-
2415
afterAll(async () => {
2516
await playerBot?.disconnect()
2617
await disconnectRcon()
2718
await closeDatabase()
2819
})
2920

30-
beforeEach(async () => {
31-
await clearPlayerPins()
21+
test('rate limit prevents rapid pin generation', async () => {
22+
playerBot = await createBot('RateLimitTest')
23+
await sleep(2000)
24+
await opPlayer('RateLimitTest')
3225
await sleep(500)
33-
})
3426

35-
test('same pin is returned within 5-minute cache window', async () => {
36-
// First /bmpin call
27+
// First /bmpin call - should succeed
3728
playerBot.clearSystemMessages()
3829
await playerBot.sendChat('/bmpin')
3930

40-
// Wait for the expiry message first
31+
// Wait for the expiry message (indicates success)
4132
const firstResponse = await playerBot.waitForSystemMessage('expires', 10000)
4233
console.log(`First call - Received: ${firstResponse.message}`)
4334

4435
await sleep(500)
4536

46-
// Get all system messages and find the 6-digit pin
37+
// Get the first pin
4738
const firstMessages = playerBot.getSystemMessages()
4839
let actualFirstPin: string | undefined
4940
for (const msg of firstMessages) {
@@ -54,84 +45,83 @@ describe('Pin Cache Behavior', () => {
5445
}
5546
}
5647
console.log(`First call - Actual pin: ${actualFirstPin ?? 'not found'}`)
48+
expect(actualFirstPin).toBeDefined()
5749

5850
await sleep(1000)
5951

60-
// Second /bmpin call (should return cached pin)
52+
// Second /bmpin call immediately - should be rate limited
6153
playerBot.clearSystemMessages()
6254
await playerBot.sendChat('/bmpin')
6355

64-
const secondResponse = await playerBot.waitForSystemMessage('expires', 10000)
65-
console.log(`Second call - Received: ${secondResponse.message}`)
56+
// Should receive rate limit message
57+
const rateLimitResponse = await playerBot.waitForSystemMessage('wait', 10000)
58+
console.log(`Second call - Rate limit message: ${rateLimitResponse.message}`)
6659

67-
await sleep(500)
60+
expect(rateLimitResponse.message).toContain('wait')
61+
expect(rateLimitResponse.message).toContain('seconds')
6862

69-
// Get all system messages and find the 6-digit pin
70-
const secondMessages = playerBot.getSystemMessages()
71-
let actualSecondPin: string | undefined
72-
for (const msg of secondMessages) {
73-
const match = msg.message.match(/^\d{6}$/)
74-
if (match != null) {
75-
actualSecondPin = match[0]
76-
break
77-
}
78-
}
79-
console.log(`Second call - Actual pin: ${actualSecondPin ?? 'not found'}`)
80-
81-
// The pins should be the same (cached)
82-
if (actualFirstPin != null && actualSecondPin != null) {
83-
expect(actualFirstPin).toBe(actualSecondPin)
84-
console.log(`Pins match as expected (cache working): ${actualFirstPin}`)
85-
}
63+
await playerBot.disconnect()
8664
}, 30000)
8765

8866
test('pin is stored in database on first request', async () => {
89-
const initialCount = await getPlayerPinCount('PinCachePlayer')
67+
// Use a unique player for this test (max 16 chars)
68+
playerBot = await createBot('PinDbTest')
69+
await sleep(2000)
70+
await opPlayer('PinDbTest')
71+
await sleep(500)
72+
await clearPlayerPins()
73+
await sleep(500)
74+
75+
const initialCount = await getPlayerPinCount('PinDbTest')
9076
console.log(`Initial pin count: ${initialCount}`)
9177

9278
playerBot.clearSystemMessages()
9379
await playerBot.sendChat('/bmpin')
9480
await playerBot.waitForSystemMessage('expires', 10000)
9581
await sleep(1000)
9682

97-
const newCount = await getPlayerPinCount('PinCachePlayer')
98-
// Pin count should increase by at least 1 (could be more if previous test created one)
99-
expect(newCount).toBeGreaterThanOrEqual(initialCount)
83+
const newCount = await getPlayerPinCount('PinDbTest')
84+
// Pin count should be exactly 1 (old pins deleted before new one created)
85+
expect(newCount).toBe(1)
10086
console.log(`Pin count after /bmpin: ${newCount}`)
101-
}, 20000)
10287

103-
test('only one pin is stored for multiple cached requests', async () => {
104-
// Clear and wait for cache to be empty
88+
await playerBot.disconnect()
89+
}, 30000)
90+
91+
test('new pin generation deletes old pins for same player', async () => {
92+
// Use a unique player for this test (max 16 chars)
93+
playerBot = await createBot('PinDelTest')
94+
await sleep(2000)
95+
await opPlayer('PinDelTest')
96+
await sleep(500)
10597
await clearPlayerPins()
10698
await sleep(500)
10799

108-
const initialCount = await getPlayerPinCount('PinCachePlayer')
109-
console.log(`Initial pin count after clear: ${initialCount}`)
110-
111100
// First call creates a pin
112101
playerBot.clearSystemMessages()
113102
await playerBot.sendChat('/bmpin')
114103
await playerBot.waitForSystemMessage('expires', 10000)
115104
await sleep(500)
116105

117-
const afterFirstCall = await getPlayerPinCount('PinCachePlayer')
106+
const afterFirstCall = await getPlayerPinCount('PinDelTest')
118107
console.log(`Pin count after first call: ${afterFirstCall}`)
108+
expect(afterFirstCall).toBe(1)
119109

120-
// Make two more /bmpin calls (should use cache)
121-
for (let i = 0; i < 2; i++) {
122-
playerBot.clearSystemMessages()
123-
await playerBot.sendChat('/bmpin')
124-
await playerBot.waitForSystemMessage('expires', 10000)
125-
await sleep(500)
126-
}
110+
// Wait for rate limit to expire (30 seconds)
111+
console.log('Waiting 31 seconds for rate limit to expire...')
112+
await sleep(31000)
127113

128-
await sleep(1000)
114+
// Second call should delete old pin and create new one
115+
playerBot.clearSystemMessages()
116+
await playerBot.sendChat('/bmpin')
117+
await playerBot.waitForSystemMessage('expires', 10000)
118+
await sleep(500)
129119

130-
// Should still have the same count (cached, not generating new ones)
131-
const finalCount = await getPlayerPinCount('PinCachePlayer')
132-
console.log(`Final pin count: ${finalCount}`)
120+
// Should still have only 1 pin (old one was deleted)
121+
const finalCount = await getPlayerPinCount('PinDelTest')
122+
console.log(`Pin count after second call: ${finalCount}`)
123+
expect(finalCount).toBe(1)
133124

134-
// Final count should equal the count after first call (no new pins generated)
135-
expect(finalCount).toBe(afterFirstCall)
136-
}, 30000)
125+
await playerBot.disconnect()
126+
}, 70000)
137127
})

0 commit comments

Comments
 (0)