1 module bancho.ratelimit;
2 @safe:
3 
4 import core.time;
5 
6 import vibe.core.core;
7 import vibe.core.sync;
8 
9 /// Params:
10 /// 	messages = Maximum allowed messages in the given timespan
11 /// 	timespan = Timespan to check messages in
12 struct ChatRatelimiter(int maxMessages, Duration timespan)
13 {
14 	/// Delay to always apply to avoid large gaps.
15 	Duration baseDelay = timespan / maxMessages / 2; // default to half an average cycle so if messages are going to be spammed, half of timespan is the maximum idle time
16 	/// Log of all sent message times.
17 	MonoTime[maxMessages] times;
18 	/// Index where to insert in times array right now.
19 	int cycle;
20 	InterruptibleTaskMutex mutex;
21 
22 	void load()
23 	{
24 		mutex = new InterruptibleTaskMutex();
25 	}
26 
27 	/// Throws: InterruptException if task got interrupted
28 	void putWait(bool put)
29 	{
30 		if (!putWaitNothrow(put))
31 			throw new InterruptException();
32 	}
33 
34 	/// Returns: false if it was interrupted
35 	bool putWaitNothrow(bool put) nothrow
36 	{
37 		try
38 		{
39 			mutex.lock();
40 		}
41 		catch (InterruptException)
42 		{
43 			return false;
44 		}
45 		catch (Exception e)
46 		{
47 			import core.exception : AssertError;
48 
49 			throw new AssertError("InterruptibleTaskMutex.lock is not expected to throw anything else than InterruptException", __FILE__, __LINE__, e);
50 		}
51 
52 		scope (exit)
53 			mutex.unlock();
54 
55 		auto sinceFirst = MonoTime.currTime - times[cycle];
56 		auto sinceLast = MonoTime.currTime - times[(cycle + $ - 1) % $];
57 		if (sinceLast >= baseDelay && (sinceFirst >= timespan || times[cycle] == MonoTime.zero))
58 		{
59 			// do nothing
60 		}
61 		else if (sinceFirst >= timespan || times[cycle] == MonoTime.zero)
62 		{
63 			// sleep remaining of baseDelay
64 			if (waitForInterrupt(baseDelay - sinceLast))
65 				return false;
66 		}
67 		else
68 		{
69 			if (waitForInterrupt(baseDelay + (timespan - sinceFirst)))
70 				return false;
71 		}
72 
73 		if (put)
74 		{
75 			times[cycle] = MonoTime.currTime;
76 			cycle = (cycle + 1) % times.length;
77 		}
78 
79 		return true;
80 	}
81 
82 	/// Returns true if a message can be sent instantly.
83 	bool peekInstant() nothrow
84 	{
85 		if (!mutex.tryLock())
86 			return false;
87 		mutex.unlock();
88 		auto sinceFirst = MonoTime.currTime - times[cycle];
89 		auto sinceLast = MonoTime.currTime - times[(cycle + $ - 1) % $];
90 		return sinceLast >= baseDelay && (sinceFirst >= timespan || times[cycle] == MonoTime.zero);
91 	}
92 
93 	/// Returns true if a message can be after waiting at most the baseDelay.
94 	bool peekBase() nothrow
95 	{
96 		if (!mutex.tryLock())
97 			return false;
98 		mutex.unlock();
99 		auto sinceFirst = MonoTime.currTime - times[cycle];
100 		return sinceFirst >= timespan || times[cycle] == MonoTime.zero;
101 	}
102 }
103 
104 unittest
105 {
106 	import std.conv;
107 	import vibe.core.log;
108 
109 	auto start = MonoTime.currTime;
110 	ChatRatelimiter!(5, 1.seconds) ratelimit; // 6 messages in 3 seconds
111 	ratelimit.load();
112 
113 	assert(ratelimit.baseDelay == 100.msecs);
114 
115 	foreach (i; 0 .. 4)
116 	{
117 		logInfo("waiting at %s", MonoTime.currTime - start);
118 		assert(ratelimit.peekBase, i.to!string);
119 		ratelimit.putWait(true);
120 		assert(ratelimit.peekBase, i.to!string);
121 		assert(!ratelimit.peekInstant, i.to!string);
122 		sleep(ratelimit.baseDelay);
123 		assert(ratelimit.peekInstant, i.to!string);
124 	}
125 	ratelimit.putWait(true);
126 	assert(!ratelimit.peekInstant);
127 	sleep(ratelimit.baseDelay);
128 	assert(!ratelimit.peekInstant);
129 	assert(!ratelimit.peekBase);
130 
131 	logInfo("done at %s", MonoTime.currTime - start);
132 	assert(MonoTime.currTime - start > 450.msecs && MonoTime.currTime - start < 550.msecs,
133 			"took " ~ (MonoTime.currTime - start).toString ~ " instead of expected 0.5s");
134 	logInfo("waiting at %s", MonoTime.currTime - start);
135 	ratelimit.putWait(true);
136 	assert(MonoTime.currTime - start > 1050.msecs && MonoTime.currTime - start < 1150.msecs,
137 			"took " ~ (MonoTime.currTime - start).toString ~ " instead of expected 1s");
138 }
139 
140 // public rooms: 60 messages / minute
141 // private channels: 300 messages / minute
142 struct BanchoRatelimiter(int publicMax = 6, Duration publicTime = 6.seconds,
143 		int privateMax = 5, Duration privateTime = 1.seconds)
144 {
145 	ChatRatelimiter!(publicMax, publicTime) publicLimit;
146 	ChatRatelimiter!(privateMax, privateTime) privateLimit;
147 
148 	void load()
149 	{
150 		publicLimit.load();
151 		privateLimit.load();
152 	}
153 
154 	void putWait(string channel, bool put)
155 	{
156 		isPublic(channel) ? publicLimit.putWait(put) : privateLimit.putWait(put);
157 	}
158 
159 	bool putWaitNothrow(string channel, bool put) nothrow
160 	{
161 		return isPublic(channel) ? publicLimit.putWaitNothrow(put) : privateLimit.putWaitNothrow(put);
162 	}
163 
164 	bool peekInstant(string channel) nothrow
165 	{
166 		return isPublic(channel) ? publicLimit.peekInstant() : privateLimit.peekInstant();
167 	}
168 
169 	bool peekBase(string channel) nothrow
170 	{
171 		return isPublic(channel) ? publicLimit.peekBase() : privateLimit.peekBase();
172 	}
173 
174 }
175 
176 private bool isPublic(string channel) nothrow
177 {
178 	if (channel.length && channel[0] == '#')
179 	{
180 		// channel, so public
181 		return true;
182 	}
183 	else
184 	{
185 		// doesn't start with #, must be a player, so private
186 		return false;
187 	}
188 }
189 
190 package bool waitForInterrupt(Duration d) nothrow
191 {
192 	try
193 	{
194 		sleep(d);
195 		return false;
196 	}
197 	catch (InterruptException)
198 	{
199 		return true;
200 	}
201 	catch (Exception e)
202 	{
203 		import core.exception : AssertError;
204 
205 		throw new AssertError("sleep is not expected to throw anything else than InterruptException", __FILE__, __LINE__, e);
206 	}
207 }