##// END OF EJS Templates
httppeer: move a variable to avoid a bad reference before assignment warning...
Matt Harbison -
r52632:78f789a4 default
parent child Browse files
Show More
@@ -1,668 +1,669
1 1 # httppeer.py - HTTP repository proxy classes for mercurial
2 2 #
3 3 # Copyright 2005, 2006 Olivia Mackall <olivia@selenic.com>
4 4 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
5 5 #
6 6 # This software may be used and distributed according to the terms of the
7 7 # GNU General Public License version 2 or any later version.
8 8
9 9
10 10 import errno
11 11 import io
12 12 import os
13 13 import socket
14 14 import struct
15 15
16 16 from concurrent import futures
17 17 from .i18n import _
18 18 from . import (
19 19 bundle2,
20 20 error,
21 21 httpconnection,
22 22 pycompat,
23 23 statichttprepo,
24 24 url as urlmod,
25 25 util,
26 26 wireprotov1peer,
27 27 )
28 28 from .utils import urlutil
29 29
30 30 httplib = util.httplib
31 31 urlerr = util.urlerr
32 32 urlreq = util.urlreq
33 33
34 34
35 35 def encodevalueinheaders(value, header, limit):
36 36 """Encode a string value into multiple HTTP headers.
37 37
38 38 ``value`` will be encoded into 1 or more HTTP headers with the names
39 39 ``header-<N>`` where ``<N>`` is an integer starting at 1. Each header
40 40 name + value will be at most ``limit`` bytes long.
41 41
42 42 Returns an iterable of 2-tuples consisting of header names and
43 43 values as native strings.
44 44 """
45 45 # HTTP Headers are ASCII. Python 3 requires them to be unicodes,
46 46 # not bytes. This function always takes bytes in as arguments.
47 47 fmt = pycompat.strurl(header) + r'-%s'
48 48 # Note: it is *NOT* a bug that the last bit here is a bytestring
49 49 # and not a unicode: we're just getting the encoded length anyway,
50 50 # and using an r-string to make it portable between Python 2 and 3
51 51 # doesn't work because then the \r is a literal backslash-r
52 52 # instead of a carriage return.
53 53 valuelen = limit - len(fmt % '000') - len(b': \r\n')
54 54 result = []
55 55
56 56 n = 0
57 57 for i in range(0, len(value), valuelen):
58 58 n += 1
59 59 result.append((fmt % str(n), pycompat.strurl(value[i : i + valuelen])))
60 60
61 61 return result
62 62
63 63
64 64 class _multifile:
65 65 def __init__(self, *fileobjs):
66 66 for f in fileobjs:
67 67 if not hasattr(f, 'length'):
68 68 raise ValueError(
69 69 b'_multifile only supports file objects that '
70 70 b'have a length but this one does not:',
71 71 type(f),
72 72 f,
73 73 )
74 74 self._fileobjs = fileobjs
75 75 self._index = 0
76 76
77 77 @property
78 78 def length(self):
79 79 return sum(f.length for f in self._fileobjs)
80 80
81 81 def read(self, amt=None):
82 82 if amt <= 0:
83 83 return b''.join(f.read() for f in self._fileobjs)
84 84 parts = []
85 85 while amt and self._index < len(self._fileobjs):
86 86 parts.append(self._fileobjs[self._index].read(amt))
87 87 got = len(parts[-1])
88 88 if got < amt:
89 89 self._index += 1
90 90 amt -= got
91 91 return b''.join(parts)
92 92
93 93 def seek(self, offset, whence=os.SEEK_SET):
94 94 if whence != os.SEEK_SET:
95 95 raise NotImplementedError(
96 96 b'_multifile does not support anything other'
97 97 b' than os.SEEK_SET for whence on seek()'
98 98 )
99 99 if offset != 0:
100 100 raise NotImplementedError(
101 101 b'_multifile only supports seeking to start, but that '
102 102 b'could be fixed if you need it'
103 103 )
104 104 for f in self._fileobjs:
105 105 f.seek(0)
106 106 self._index = 0
107 107
108 108
109 109 def makev1commandrequest(
110 110 ui,
111 111 requestbuilder,
112 112 caps,
113 113 capablefn,
114 114 repobaseurl,
115 115 cmd,
116 116 args,
117 117 remotehidden=False,
118 118 ):
119 119 """Make an HTTP request to run a command for a version 1 client.
120 120
121 121 ``caps`` is a set of known server capabilities. The value may be
122 122 None if capabilities are not yet known.
123 123
124 124 ``capablefn`` is a function to evaluate a capability.
125 125
126 126 ``cmd``, ``args``, and ``data`` define the command, its arguments, and
127 127 raw data to pass to it.
128 128 """
129 129 if cmd == b'pushkey':
130 130 args[b'data'] = b''
131 131 data = args.pop(b'data', None)
132 132 headers = args.pop(b'headers', {})
133 133
134 134 ui.debug(b"sending %s command\n" % cmd)
135 135 q = [(b'cmd', cmd)]
136 136 if remotehidden:
137 137 q.append(('access-hidden', '1'))
138 138 headersize = 0
139 139 # Important: don't use self.capable() here or else you end up
140 140 # with infinite recursion when trying to look up capabilities
141 141 # for the first time.
142 142 postargsok = caps is not None and b'httppostargs' in caps
143 143
144 144 # Send arguments via POST.
145 145 if postargsok and args:
146 146 strargs = urlreq.urlencode(sorted(args.items()))
147 147 if not data:
148 148 data = strargs
149 149 else:
150 150 if isinstance(data, bytes):
151 151 i = io.BytesIO(data)
152 152 i.length = len(data)
153 153 data = i
154 154 argsio = io.BytesIO(strargs)
155 155 argsio.length = len(strargs)
156 156 data = _multifile(argsio, data)
157 157 headers['X-HgArgs-Post'] = len(strargs)
158 158 elif args:
159 159 # Calling self.capable() can infinite loop if we are calling
160 160 # "capabilities". But that command should never accept wire
161 161 # protocol arguments. So this should never happen.
162 162 assert cmd != b'capabilities'
163 163 httpheader = capablefn(b'httpheader')
164 164 if httpheader:
165 165 headersize = int(httpheader.split(b',', 1)[0])
166 166
167 167 # Send arguments via HTTP headers.
168 168 if headersize > 0:
169 169 # The headers can typically carry more data than the URL.
170 170 encoded_args = urlreq.urlencode(sorted(args.items()))
171 171 for header, value in encodevalueinheaders(
172 172 encoded_args, b'X-HgArg', headersize
173 173 ):
174 174 headers[header] = value
175 175 # Send arguments via query string (Mercurial <1.9).
176 176 else:
177 177 q += sorted(args.items())
178 178
179 179 qs = b'?%s' % urlreq.urlencode(q)
180 180 cu = b"%s%s" % (repobaseurl, qs)
181 181 size = 0
182 182 if hasattr(data, 'length'):
183 183 size = data.length
184 184 elif data is not None:
185 185 size = len(data)
186 186 if data is not None and 'Content-Type' not in headers:
187 187 headers['Content-Type'] = 'application/mercurial-0.1'
188 188
189 189 # Tell the server we accept application/mercurial-0.2 and multiple
190 190 # compression formats if the server is capable of emitting those
191 191 # payloads.
192 192 # Note: Keep this set empty by default, as client advertisement of
193 193 # protocol parameters should only occur after the handshake.
194 194 protoparams = set()
195 195
196 196 mediatypes = set()
197 197 if caps is not None:
198 198 mt = capablefn(b'httpmediatype')
199 199 if mt:
200 200 protoparams.add(b'0.1')
201 201 mediatypes = set(mt.split(b','))
202 202
203 203 protoparams.add(b'partial-pull')
204 204
205 205 if b'0.2tx' in mediatypes:
206 206 protoparams.add(b'0.2')
207 207
208 208 if b'0.2tx' in mediatypes and capablefn(b'compression'):
209 209 # We /could/ compare supported compression formats and prune
210 210 # non-mutually supported or error if nothing is mutually supported.
211 211 # For now, send the full list to the server and have it error.
212 212 comps = [
213 213 e.wireprotosupport().name
214 214 for e in util.compengines.supportedwireengines(util.CLIENTROLE)
215 215 ]
216 216 protoparams.add(b'comp=%s' % b','.join(comps))
217 217
218 218 if protoparams:
219 219 protoheaders = encodevalueinheaders(
220 220 b' '.join(sorted(protoparams)), b'X-HgProto', headersize or 1024
221 221 )
222 222 for header, value in protoheaders:
223 223 headers[header] = value
224 224
225 225 varyheaders = []
226 226 for header in headers:
227 227 if header.lower().startswith('x-hg'):
228 228 varyheaders.append(header)
229 229
230 230 if varyheaders:
231 231 headers['Vary'] = ','.join(sorted(varyheaders))
232 232
233 233 req = requestbuilder(pycompat.strurl(cu), data, headers)
234 234
235 235 if data is not None:
236 236 ui.debug(b"sending %d bytes\n" % size)
237 237 req.add_unredirected_header('Content-Length', '%d' % size)
238 238
239 239 return req, cu, qs
240 240
241 241
242 242 def sendrequest(ui, opener, req):
243 243 """Send a prepared HTTP request.
244 244
245 245 Returns the response object.
246 246 """
247 247 dbg = ui.debug
248 line = b'devel-peer-request: %s\n'
249
248 250 if ui.debugflag and ui.configbool(b'devel', b'debug.peer-request'):
249 line = b'devel-peer-request: %s\n'
250 251 dbg(
251 252 line
252 253 % b'%s %s'
253 254 % (
254 255 pycompat.bytesurl(req.get_method()),
255 256 pycompat.bytesurl(req.get_full_url()),
256 257 )
257 258 )
258 259 hgargssize = None
259 260
260 261 for header, value in sorted(req.header_items()):
261 262 header = pycompat.bytesurl(header)
262 263 value = pycompat.bytesurl(value)
263 264 if header.startswith(b'X-hgarg-'):
264 265 if hgargssize is None:
265 266 hgargssize = 0
266 267 hgargssize += len(value)
267 268 else:
268 269 dbg(line % b' %s %s' % (header, value))
269 270
270 271 if hgargssize is not None:
271 272 dbg(
272 273 line
273 274 % b' %d bytes of commands arguments in headers'
274 275 % hgargssize
275 276 )
276 277 data = req.data
277 278 if data is not None:
278 279 length = getattr(data, 'length', None)
279 280 if length is None:
280 281 length = len(data)
281 282 dbg(line % b' %d bytes of data' % length)
282 283
283 284 start = util.timer()
284 285
285 286 res = None
286 287 try:
287 288 res = opener.open(req)
288 289 except urlerr.httperror as inst:
289 290 if inst.code == 401:
290 291 raise error.Abort(_(b'authorization failed'))
291 292 raise
292 293 except httplib.HTTPException as inst:
293 294 ui.debug(
294 295 b'http error requesting %s\n'
295 296 % urlutil.hidepassword(req.get_full_url())
296 297 )
297 298 ui.traceback()
298 299 raise IOError(None, inst)
299 300 finally:
300 301 if ui.debugflag and ui.configbool(b'devel', b'debug.peer-request'):
301 302 code = res.code if res else -1
302 303 dbg(
303 304 line
304 305 % b' finished in %.4f seconds (%d)'
305 306 % (util.timer() - start, code)
306 307 )
307 308
308 309 # Insert error handlers for common I/O failures.
309 310 urlmod.wrapresponse(res)
310 311
311 312 return res
312 313
313 314
314 315 class RedirectedRepoError(error.RepoError):
315 316 def __init__(self, msg, respurl):
316 317 super(RedirectedRepoError, self).__init__(msg)
317 318 self.respurl = respurl
318 319
319 320
320 321 def parsev1commandresponse(ui, baseurl, requrl, qs, resp, compressible):
321 322 # record the url we got redirected to
322 323 redirected = False
323 324 respurl = pycompat.bytesurl(resp.geturl())
324 325 if respurl.endswith(qs):
325 326 respurl = respurl[: -len(qs)]
326 327 qsdropped = False
327 328 else:
328 329 qsdropped = True
329 330
330 331 if baseurl.rstrip(b'/') != respurl.rstrip(b'/'):
331 332 redirected = True
332 333 if not ui.quiet:
333 334 ui.warn(_(b'real URL is %s\n') % respurl)
334 335
335 336 try:
336 337 proto = pycompat.bytesurl(resp.getheader('content-type', ''))
337 338 except AttributeError:
338 339 proto = pycompat.bytesurl(resp.headers.get('content-type', ''))
339 340
340 341 safeurl = urlutil.hidepassword(baseurl)
341 342 if proto.startswith(b'application/hg-error'):
342 343 raise error.OutOfBandError(resp.read())
343 344
344 345 # Pre 1.0 versions of Mercurial used text/plain and
345 346 # application/hg-changegroup. We don't support such old servers.
346 347 if not proto.startswith(b'application/mercurial-'):
347 348 ui.debug(b"requested URL: '%s'\n" % urlutil.hidepassword(requrl))
348 349 msg = _(
349 350 b"'%s' does not appear to be an hg repository:\n"
350 351 b"---%%<--- (%s)\n%s\n---%%<---\n"
351 352 ) % (safeurl, proto or b'no content-type', resp.read(1024))
352 353
353 354 # Some servers may strip the query string from the redirect. We
354 355 # raise a special error type so callers can react to this specially.
355 356 if redirected and qsdropped:
356 357 raise RedirectedRepoError(msg, respurl)
357 358 else:
358 359 raise error.RepoError(msg)
359 360
360 361 try:
361 362 subtype = proto.split(b'-', 1)[1]
362 363
363 364 version_info = tuple([int(n) for n in subtype.split(b'.')])
364 365 except ValueError:
365 366 raise error.RepoError(
366 367 _(b"'%s' sent a broken Content-Type header (%s)") % (safeurl, proto)
367 368 )
368 369
369 370 # TODO consider switching to a decompression reader that uses
370 371 # generators.
371 372 if version_info == (0, 1):
372 373 if compressible:
373 374 resp = util.compengines[b'zlib'].decompressorreader(resp)
374 375
375 376 elif version_info == (0, 2):
376 377 # application/mercurial-0.2 always identifies the compression
377 378 # engine in the payload header.
378 379 elen = struct.unpack(b'B', util.readexactly(resp, 1))[0]
379 380 ename = util.readexactly(resp, elen)
380 381 engine = util.compengines.forwiretype(ename)
381 382
382 383 resp = engine.decompressorreader(resp)
383 384 else:
384 385 raise error.RepoError(
385 386 _(b"'%s' uses newer protocol %s") % (safeurl, subtype)
386 387 )
387 388
388 389 return respurl, proto, resp
389 390
390 391
391 392 class httppeer(wireprotov1peer.wirepeer):
392 393 def __init__(
393 394 self, ui, path, url, opener, requestbuilder, caps, remotehidden=False
394 395 ):
395 396 super().__init__(ui, path=path, remotehidden=remotehidden)
396 397 self._url = url
397 398 self._caps = caps
398 399 self.limitedarguments = caps is not None and b'httppostargs' not in caps
399 400 self._urlopener = opener
400 401 self._requestbuilder = requestbuilder
401 402 self._remotehidden = remotehidden
402 403
403 404 def __del__(self):
404 405 for h in self._urlopener.handlers:
405 406 h.close()
406 407 getattr(h, "close_all", lambda: None)()
407 408
408 409 # Begin of ipeerconnection interface.
409 410
410 411 def url(self):
411 412 return self.path.loc
412 413
413 414 def local(self):
414 415 return None
415 416
416 417 def canpush(self):
417 418 return True
418 419
419 420 def close(self):
420 421 try:
421 422 reqs, sent, recv = (
422 423 self._urlopener.requestscount,
423 424 self._urlopener.sentbytescount,
424 425 self._urlopener.receivedbytescount,
425 426 )
426 427 except AttributeError:
427 428 return
428 429 self.ui.note(
429 430 _(
430 431 b'(sent %d HTTP requests and %d bytes; '
431 432 b'received %d bytes in responses)\n'
432 433 )
433 434 % (reqs, sent, recv)
434 435 )
435 436
436 437 # End of ipeerconnection interface.
437 438
438 439 # Begin of ipeercommands interface.
439 440
440 441 def capabilities(self):
441 442 return self._caps
442 443
443 444 def _finish_inline_clone_bundle(self, stream):
444 445 # HTTP streams must hit the end to process the last empty
445 446 # chunk of Chunked-Encoding so the connection can be reused.
446 447 chunk = stream.read(1)
447 448 if chunk:
448 449 self._abort(error.ResponseError(_(b"unexpected response:"), chunk))
449 450
450 451 # End of ipeercommands interface.
451 452
452 453 def _callstream(self, cmd, _compressible=False, **args):
453 454 args = pycompat.byteskwargs(args)
454 455
455 456 req, cu, qs = makev1commandrequest(
456 457 self.ui,
457 458 self._requestbuilder,
458 459 self._caps,
459 460 self.capable,
460 461 self._url,
461 462 cmd,
462 463 args,
463 464 self._remotehidden,
464 465 )
465 466
466 467 resp = sendrequest(self.ui, self._urlopener, req)
467 468
468 469 self._url, ct, resp = parsev1commandresponse(
469 470 self.ui, self._url, cu, qs, resp, _compressible
470 471 )
471 472
472 473 return resp
473 474
474 475 def _call(self, cmd, **args):
475 476 fp = self._callstream(cmd, **args)
476 477 try:
477 478 return fp.read()
478 479 finally:
479 480 # if using keepalive, allow connection to be reused
480 481 fp.close()
481 482
482 483 def _callpush(self, cmd, cg, **args):
483 484 # have to stream bundle to a temp file because we do not have
484 485 # http 1.1 chunked transfer.
485 486
486 487 types = self.capable(b'unbundle')
487 488 try:
488 489 types = types.split(b',')
489 490 except AttributeError:
490 491 # servers older than d1b16a746db6 will send 'unbundle' as a
491 492 # boolean capability. They only support headerless/uncompressed
492 493 # bundles.
493 494 types = [b""]
494 495 for x in types:
495 496 if x in bundle2.bundletypes:
496 497 type = x
497 498 break
498 499
499 500 tempname = bundle2.writebundle(self.ui, cg, None, type)
500 501 fp = httpconnection.httpsendfile(self.ui, tempname, b"rb")
501 502 headers = {'Content-Type': 'application/mercurial-0.1'}
502 503
503 504 try:
504 505 r = self._call(cmd, data=fp, headers=headers, **args)
505 506 vals = r.split(b'\n', 1)
506 507 if len(vals) < 2:
507 508 raise error.ResponseError(_(b"unexpected response:"), r)
508 509 return vals
509 510 except urlerr.httperror:
510 511 # Catch and re-raise these so we don't try and treat them
511 512 # like generic socket errors. They lack any values in
512 513 # .args on Python 3 which breaks our socket.error block.
513 514 raise
514 515 except socket.error as err:
515 516 if err.args[0] in (errno.ECONNRESET, errno.EPIPE):
516 517 raise error.Abort(_(b'push failed: %s') % err.args[1])
517 518 raise error.Abort(err.args[1])
518 519 finally:
519 520 fp.close()
520 521 os.unlink(tempname)
521 522
522 523 def _calltwowaystream(self, cmd, fp, **args):
523 524 # dump bundle to disk
524 525 fd, filename = pycompat.mkstemp(prefix=b"hg-bundle-", suffix=b".hg")
525 526 try:
526 527 with os.fdopen(fd, "wb") as fh:
527 528 d = fp.read(4096)
528 529 while d:
529 530 fh.write(d)
530 531 d = fp.read(4096)
531 532 # start http push
532 533 with httpconnection.httpsendfile(self.ui, filename, b"rb") as fp_:
533 534 headers = {'Content-Type': 'application/mercurial-0.1'}
534 535 return self._callstream(cmd, data=fp_, headers=headers, **args)
535 536 finally:
536 537 os.unlink(filename)
537 538
538 539 def _callcompressable(self, cmd, **args):
539 540 return self._callstream(cmd, _compressible=True, **args)
540 541
541 542 def _abort(self, exception):
542 543 raise exception
543 544
544 545
545 546 class queuedcommandfuture(futures.Future):
546 547 """Wraps result() on command futures to trigger submission on call."""
547 548
548 549 def result(self, timeout=None):
549 550 if self.done():
550 551 return futures.Future.result(self, timeout)
551 552
552 553 self._peerexecutor.sendcommands()
553 554
554 555 # sendcommands() will restore the original __class__ and self.result
555 556 # will resolve to Future.result.
556 557 return self.result(timeout)
557 558
558 559
559 560 def performhandshake(ui, url, opener, requestbuilder):
560 561 # The handshake is a request to the capabilities command.
561 562
562 563 caps = None
563 564
564 565 def capable(x):
565 566 raise error.ProgrammingError(b'should not be called')
566 567
567 568 args = {}
568 569
569 570 req, requrl, qs = makev1commandrequest(
570 571 ui, requestbuilder, caps, capable, url, b'capabilities', args
571 572 )
572 573 resp = sendrequest(ui, opener, req)
573 574
574 575 # The server may redirect us to the repo root, stripping the
575 576 # ?cmd=capabilities query string from the URL. The server would likely
576 577 # return HTML in this case and ``parsev1commandresponse()`` would raise.
577 578 # We catch this special case and re-issue the capabilities request against
578 579 # the new URL.
579 580 #
580 581 # We should ideally not do this, as a redirect that drops the query
581 582 # string from the URL is arguably a server bug. (Garbage in, garbage out).
582 583 # However, Mercurial clients for several years appeared to handle this
583 584 # issue without behavior degradation. And according to issue 5860, it may
584 585 # be a longstanding bug in some server implementations. So we allow a
585 586 # redirect that drops the query string to "just work."
586 587 try:
587 588 respurl, ct, resp = parsev1commandresponse(
588 589 ui, url, requrl, qs, resp, compressible=False
589 590 )
590 591 except RedirectedRepoError as e:
591 592 req, requrl, qs = makev1commandrequest(
592 593 ui, requestbuilder, caps, capable, e.respurl, b'capabilities', args
593 594 )
594 595 resp = sendrequest(ui, opener, req)
595 596 respurl, ct, resp = parsev1commandresponse(
596 597 ui, url, requrl, qs, resp, compressible=False
597 598 )
598 599
599 600 try:
600 601 rawdata = resp.read()
601 602 finally:
602 603 resp.close()
603 604
604 605 if not ct.startswith(b'application/mercurial-'):
605 606 raise error.ProgrammingError(b'unexpected content-type: %s' % ct)
606 607
607 608 info = {b'v1capabilities': set(rawdata.split())}
608 609
609 610 return respurl, info
610 611
611 612
612 613 def _make_peer(
613 614 ui, path, opener=None, requestbuilder=urlreq.request, remotehidden=False
614 615 ):
615 616 """Construct an appropriate HTTP peer instance.
616 617
617 618 ``opener`` is an ``url.opener`` that should be used to establish
618 619 connections, perform HTTP requests.
619 620
620 621 ``requestbuilder`` is the type used for constructing HTTP requests.
621 622 It exists as an argument so extensions can override the default.
622 623 """
623 624 if path.url.query or path.url.fragment:
624 625 msg = _(b'unsupported URL component: "%s"')
625 626 msg %= path.url.query or path.url.fragment
626 627 raise error.Abort(msg)
627 628
628 629 # urllib cannot handle URLs with embedded user or passwd.
629 630 url, authinfo = path.url.authinfo()
630 631 ui.debug(b'using %s\n' % url)
631 632
632 633 opener = opener or urlmod.opener(ui, authinfo)
633 634
634 635 respurl, info = performhandshake(ui, url, opener, requestbuilder)
635 636
636 637 return httppeer(
637 638 ui,
638 639 path,
639 640 respurl,
640 641 opener,
641 642 requestbuilder,
642 643 info[b'v1capabilities'],
643 644 remotehidden=remotehidden,
644 645 )
645 646
646 647
647 648 def make_peer(
648 649 ui, path, create, intents=None, createopts=None, remotehidden=False
649 650 ):
650 651 if create:
651 652 raise error.Abort(_(b'cannot create new http repository'))
652 653 try:
653 654 if path.url.scheme == b'https' and not urlmod.has_https:
654 655 raise error.Abort(
655 656 _(b'Python support for SSL and HTTPS is not installed')
656 657 )
657 658
658 659 inst = _make_peer(ui, path, remotehidden=remotehidden)
659 660
660 661 return inst
661 662 except error.RepoError as httpexception:
662 663 try:
663 664 path = path.copy(new_raw_location=b"static-" + path.rawloc)
664 665 r = statichttprepo.make_peer(ui, path, create)
665 666 ui.note(_(b'(falling back to static-http)\n'))
666 667 return r
667 668 except error.RepoError:
668 669 raise httpexception # use the original http RepoError instead
General Comments 0
You need to be logged in to leave comments. Login now