]> jfr.im git - irc/quakenet/qwebirc.git/blame - qwebirc/engines/ajaxengine.py
Encode json more compactly.
[irc/quakenet/qwebirc.git] / qwebirc / engines / ajaxengine.py
CommitLineData
99844c15 1from twisted.web import resource, server, static, error as http_error
9e769c12 2from twisted.names import client
265f5ce3 3from twisted.internet import reactor, error
ace37679 4from authgateengine import login_optional, getSessionData
becfa850 5import md5, sys, os, time, config, qwebirc.config_options as config_options, traceback, socket
85f01e3f
CP
6import qwebirc.ircclient as ircclient
7from adminengine import AdminEngineAction
8from qwebirc.util import HitCounter
28c4ad01 9import qwebirc.dns as qdns
becfa850 10import qwebirc.util.qjson as json
9e769c12
CP
11Sessions = {}
12
13def get_session_id():
4e4bbf26 14 return md5.md5(os.urandom(16)).hexdigest()
8dc46dfa
CP
15
16class BufferOverflowException(Exception):
17 pass
18
f59585a7
CP
19class AJAXException(Exception):
20 pass
21
4094890f
CP
22class IDGenerationException(Exception):
23 pass
24
99844c15
CP
25class PassthruException(Exception):
26 pass
27
bdd008f9 28NOT_DONE_YET = None
becfa850 29EMPTY_JSON_LIST = json.dumps([])
bdd008f9 30
9e769c12
CP
31def jsondump(fn):
32 def decorator(*args, **kwargs):
f59585a7
CP
33 try:
34 x = fn(*args, **kwargs)
bdd008f9
CP
35 if x is None:
36 return server.NOT_DONE_YET
37 x = (True, x)
f59585a7 38 except AJAXException, e:
bdd008f9 39 x = (False, e[0])
99844c15
CP
40 except PassthruException, e:
41 return str(e)
f59585a7 42
becfa850 43 return json.dumps(x)
9e769c12
CP
44 return decorator
45
8dc46dfa
CP
46def cleanupSession(id):
47 try:
48 del Sessions[id]
49 except KeyError:
50 pass
51
9e769c12
CP
52class IRCSession:
53 def __init__(self, id):
54 self.id = id
55 self.subscriptions = []
56 self.buffer = []
8932790b 57 self.buflen = 0
9e769c12
CP
58 self.throttle = 0
59 self.schedule = None
8dc46dfa
CP
60 self.closed = False
61 self.cleanupschedule = None
62
265f5ce3
CP
63 def subscribe(self, channel, notifier):
64 timeout_entry = reactor.callLater(config.HTTP_AJAX_REQUEST_TIMEOUT, self.timeout, channel)
65 def cancel_timeout(result):
66 if channel in self.subscriptions:
67 self.subscriptions.remove(channel)
68 try:
69 timeout_entry.cancel()
70 except error.AlreadyCalled:
71 pass
72 notifier.addCallbacks(cancel_timeout, cancel_timeout)
73
0df6faa6 74 if len(self.subscriptions) >= config.MAXSUBSCRIPTIONS:
4e221566 75 self.subscriptions.pop(0).close()
0df6faa6 76
9e769c12
CP
77 self.subscriptions.append(channel)
78 self.flush()
79
265f5ce3
CP
80 def timeout(self, channel):
81 if self.schedule:
82 return
83
becfa850 84 channel.write(EMPTY_JSON_LIST)
265f5ce3
CP
85 if channel in self.subscriptions:
86 self.subscriptions.remove(channel)
87
9e769c12
CP
88 def flush(self, scheduled=False):
89 if scheduled:
90 self.schedule = None
91
92 if not self.buffer or not self.subscriptions:
93 return
94
95 t = time.time()
96
97 if t < self.throttle:
98 if not self.schedule:
99 self.schedule = reactor.callLater(self.throttle - t, self.flush, True)
100 return
101 else:
102 # process the rest of the packet
103 if not scheduled:
104 if not self.schedule:
105 self.schedule = reactor.callLater(0, self.flush, True)
106 return
107
108 self.throttle = t + config.UPDATE_FREQ
109
becfa850 110 encdata = json.dumps(self.buffer)
9e769c12 111 self.buffer = []
8932790b
CP
112 self.buflen = 0
113
9e769c12
CP
114 newsubs = []
115 for x in self.subscriptions:
116 if x.write(encdata):
117 newsubs.append(x)
118
119 self.subscriptions = newsubs
8dc46dfa
CP
120 if self.closed and not self.subscriptions:
121 cleanupSession(self.id)
122
9e769c12 123 def event(self, data):
8932790b
CP
124 newbuflen = self.buflen + len(data)
125 if newbuflen > config.MAXBUFLEN:
8dc46dfa 126 self.buffer = []
99844c15 127 self.client.error("Buffer overflow.")
8dc46dfa
CP
128 return
129
9e769c12 130 self.buffer.append(data)
8932790b 131 self.buflen = newbuflen
9e769c12
CP
132 self.flush()
133
134 def push(self, data):
8dc46dfa
CP
135 if not self.closed:
136 self.client.write(data)
137
138 def disconnect(self):
139 # keep the session hanging around for a few seconds so the
140 # client has a chance to see what the issue was
141 self.closed = True
142
143 reactor.callLater(5, cleanupSession, self.id)
144
28c4ad01
CP
145# DANGER! Breach of encapsulation!
146def connect_notice(line):
147 return "c", "NOTICE", "", ("AUTH", "*** (qwebirc) %s" % line)
148
9e769c12
CP
149class Channel:
150 def __init__(self, request):
151 self.request = request
152
153class SingleUseChannel(Channel):
154 def write(self, data):
155 self.request.write(data)
156 self.request.finish()
157 return False
158
4e221566
CP
159 def close(self):
160 self.request.finish()
161
9e769c12
CP
162class MultipleUseChannel(Channel):
163 def write(self, data):
164 self.request.write(data)
165 return True
166
167class AJAXEngine(resource.Resource):
168 isLeaf = True
169
170 def __init__(self, prefix):
171 self.prefix = prefix
85f01e3f
CP
172 self.__connect_hit = HitCounter()
173 self.__total_hit = HitCounter()
174
9e769c12 175 @jsondump
57ea572e 176 def render_POST(self, request):
9e769c12 177 path = request.path[len(self.prefix):]
f59585a7
CP
178 if path[0] == "/":
179 handler = self.COMMANDS.get(path[1:])
180 if handler is not None:
181 return handler(self, request)
99844c15
CP
182
183 raise PassthruException, http_error.NoResource().render(request)
f59585a7 184
f59585a7 185 def newConnection(self, request):
f065bc69
CP
186 ticket = login_optional(request)
187
23f85e9b 188 ip = request.getClientIP()
9e769c12 189
c70a7ff6 190 nick = request.args.get("nick")
f59585a7 191 if not nick:
99844c15 192 raise AJAXException, "Nickname not supplied."
c70a7ff6 193 nick = ircclient.irc_decode(nick[0])
57ea572e 194
6ce70043 195 password = request.args.get("password")
2f74dea9
CP
196 if password is not None:
197 password = ircclient.irc_decode(password[0])
198
f59585a7
CP
199 for i in xrange(10):
200 id = get_session_id()
201 if not Sessions.get(id):
202 break
203 else:
204 raise IDGenerationException()
9e769c12 205
f59585a7 206 session = IRCSession(id)
9e769c12 207
ace37679
CP
208 qticket = getSessionData(request).get("qticket")
209 if qticket is None:
210 perform = None
211 else:
348574ee
CP
212 service_mask = config.AUTH_SERVICE
213 msg_mask = service_mask.split("!")[0] + "@" + service_mask.split("@", 1)[1]
214 perform = ["PRIVMSG %s :TICKETAUTH %s" % (msg_mask, qticket)]
ace37679 215
b5c84380 216 ident, realname = config.IDENT, config.REALNAME
930be88a 217 if ident is config_options.IDENT_HEX or ident is None: # latter is legacy
b5c84380 218 ident = socket.inet_aton(ip).encode("hex")
930be88a
CP
219 elif ident is config_options.IDENT_NICKNAME:
220 ident = nick
b5c84380 221
85f01e3f 222 self.__connect_hit()
28c4ad01
CP
223
224 def proceed(hostname):
2f74dea9
CP
225 kwargs = dict(nick=nick, ident=ident, ip=ip, realname=realname, perform=perform, hostname=hostname)
226 if password is not None:
227 kwargs["password"] = password
228
229 client = ircclient.createIRC(session, **kwargs)
28c4ad01
CP
230 session.client = client
231
930be88a
CP
232 if not hasattr(config, "WEBIRC_MODE") or config.WEBIRC_MODE == "hmac":
233 proceed(None)
234 elif config.WEBIRC_MODE != "hmac":
28c4ad01
CP
235 notice = lambda x: session.event(connect_notice(x))
236 notice("Looking up your hostname...")
237 def callback(hostname):
238 notice("Found your hostname.")
239 proceed(hostname)
240 def errback(failure):
241 notice("Couldn't look up your hostname!")
242 proceed(ip)
243 qdns.lookupAndVerifyPTR(ip, timeout=[config.DNS_TIMEOUT]).addCallbacks(callback, errback)
28c4ad01 244
f59585a7
CP
245 Sessions[id] = session
246
247 return id
248
249 def getSession(self, request):
71afd444
CP
250 bad_session_message = "Invalid session, this most likely means the server has restarted; close this dialog and then try refreshing the page."
251
f59585a7
CP
252 sessionid = request.args.get("s")
253 if sessionid is None:
71afd444 254 raise AJAXException, bad_session_message
9e769c12 255
f59585a7
CP
256 session = Sessions.get(sessionid[0])
257 if not session:
71afd444 258 raise AJAXException, bad_session_message
f59585a7 259 return session
8dc46dfa 260
f59585a7 261 def subscribe(self, request):
1d924d97 262 request.channel.cancelTimeout()
265f5ce3 263 self.getSession(request).subscribe(SingleUseChannel(request), request.notifyFinish())
bdd008f9 264 return NOT_DONE_YET
9e769c12 265
f59585a7
CP
266 def push(self, request):
267 command = request.args.get("c")
268 if command is None:
99844c15 269 raise AJAXException, "No command specified."
85f01e3f
CP
270 self.__total_hit()
271
c70a7ff6 272 decoded = ircclient.irc_decode(command[0])
f59585a7
CP
273
274 session = self.getSession(request)
275
f59585a7
CP
276 if len(decoded) > config.MAXLINELEN:
277 session.disconnect()
99844c15 278 raise AJAXException, "Line too long."
f59585a7
CP
279
280 try:
281 session.push(decoded)
282 except AttributeError: # occurs when we haven't noticed an error
283 session.disconnect()
99844c15 284 raise AJAXException, "Connection closed by server; try reconnecting by reloading the page."
f59585a7
CP
285 except Exception, e: # catch all
286 session.disconnect()
287 traceback.print_exc(file=sys.stderr)
71afd444 288 raise AJAXException, "Unknown error."
f59585a7
CP
289
290 return True
291
85f01e3f
CP
292 def closeById(self, k):
293 s = Sessions.get(k)
294 if s is None:
295 return
296 s.client.client.error("Closed by admin interface")
297
298 @property
299 def adminEngine(self):
300 return {
301 "Sessions": [(str(v.client.client), AdminEngineAction("close", self.closeById, k)) for k, v in Sessions.iteritems() if not v.closed],
302 "Connections": [(self.__connect_hit,)],
303 "Total hits": [(self.__total_hit,)],
304 }
305
f59585a7 306 COMMANDS = dict(p=push, n=newConnection, s=subscribe)
b5c84380 307