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