1 | """ |
---|
2 | Ported to Python 3. |
---|
3 | """ |
---|
4 | from __future__ import annotations |
---|
5 | |
---|
6 | from six import ensure_str |
---|
7 | import sys |
---|
8 | if sys.version_info[:2] >= (3, 9): |
---|
9 | from importlib.resources import files as resource_files, as_file |
---|
10 | else: |
---|
11 | from importlib_resources import files as resource_files, as_file |
---|
12 | from contextlib import ExitStack |
---|
13 | import weakref |
---|
14 | from typing import Optional, Union, TypeVar, overload |
---|
15 | from typing_extensions import Literal |
---|
16 | |
---|
17 | import time |
---|
18 | import json |
---|
19 | from functools import wraps |
---|
20 | from base64 import urlsafe_b64decode |
---|
21 | |
---|
22 | from hyperlink import ( |
---|
23 | DecodedURL, |
---|
24 | ) |
---|
25 | |
---|
26 | from eliot import ( |
---|
27 | Message, |
---|
28 | start_action, |
---|
29 | ) |
---|
30 | from eliot.twisted import ( |
---|
31 | DeferredContext, |
---|
32 | ) |
---|
33 | |
---|
34 | from twisted.web import ( |
---|
35 | http, |
---|
36 | resource, |
---|
37 | template, |
---|
38 | static, |
---|
39 | ) |
---|
40 | from twisted.web.iweb import ( |
---|
41 | IRequest, |
---|
42 | ) |
---|
43 | from twisted.web.template import ( |
---|
44 | tags, |
---|
45 | ) |
---|
46 | from twisted.web.server import ( |
---|
47 | NOT_DONE_YET, |
---|
48 | ) |
---|
49 | from twisted.web.util import ( |
---|
50 | DeferredResource, |
---|
51 | FailureElement, |
---|
52 | redirectTo, |
---|
53 | ) |
---|
54 | from twisted.python.reflect import ( |
---|
55 | fullyQualifiedName, |
---|
56 | ) |
---|
57 | from twisted.python import log |
---|
58 | from twisted.python.failure import ( |
---|
59 | Failure, |
---|
60 | ) |
---|
61 | from twisted.internet.defer import ( |
---|
62 | CancelledError, |
---|
63 | maybeDeferred, |
---|
64 | ) |
---|
65 | from twisted.web.resource import ( |
---|
66 | IResource, |
---|
67 | ) |
---|
68 | |
---|
69 | from allmydata.dirnode import ONLY_FILES, _OnlyFiles |
---|
70 | from allmydata import blacklist |
---|
71 | from allmydata.interfaces import ( |
---|
72 | EmptyPathnameComponentError, |
---|
73 | ExistingChildError, |
---|
74 | FileTooLargeError, |
---|
75 | MustBeDeepImmutableError, |
---|
76 | MustBeReadonlyError, |
---|
77 | MustNotBeUnknownRWError, |
---|
78 | NoSharesError, |
---|
79 | NoSuchChildError, |
---|
80 | NotEnoughSharesError, |
---|
81 | MDMF_VERSION, |
---|
82 | SDMF_VERSION, |
---|
83 | ) |
---|
84 | from allmydata.mutable.common import UnrecoverableFileError |
---|
85 | from allmydata.util.time_format import ( |
---|
86 | format_delta, |
---|
87 | format_time, |
---|
88 | ) |
---|
89 | from allmydata.util.encodingutil import ( |
---|
90 | quote_output, |
---|
91 | quote_output_u, |
---|
92 | to_bytes, |
---|
93 | ) |
---|
94 | from allmydata.util import abbreviate |
---|
95 | from allmydata.crypto.rsa import PrivateKey, PublicKey, create_signing_keypair_from_string |
---|
96 | |
---|
97 | |
---|
98 | class WebError(Exception): |
---|
99 | def __init__(self, text, code=http.BAD_REQUEST): |
---|
100 | self.text = text |
---|
101 | self.code = code |
---|
102 | |
---|
103 | |
---|
104 | def get_filenode_metadata(filenode): |
---|
105 | metadata = {'mutable': filenode.is_mutable()} |
---|
106 | if metadata['mutable']: |
---|
107 | mutable_type = filenode.get_version() |
---|
108 | assert mutable_type in (SDMF_VERSION, MDMF_VERSION) |
---|
109 | if mutable_type == MDMF_VERSION: |
---|
110 | file_format = "MDMF" |
---|
111 | else: |
---|
112 | file_format = "SDMF" |
---|
113 | else: |
---|
114 | file_format = "CHK" |
---|
115 | metadata['format'] = file_format |
---|
116 | size = filenode.get_size() |
---|
117 | if size is not None: |
---|
118 | metadata['size'] = size |
---|
119 | return metadata |
---|
120 | |
---|
121 | def boolean_of_arg(arg): # type: (bytes) -> bool |
---|
122 | assert isinstance(arg, bytes) |
---|
123 | if arg.lower() not in (b"true", b"t", b"1", b"false", b"f", b"0", b"on", b"off"): |
---|
124 | raise WebError("invalid boolean argument: %r" % (arg,), http.BAD_REQUEST) |
---|
125 | return arg.lower() in (b"true", b"t", b"1", b"on") |
---|
126 | |
---|
127 | |
---|
128 | def parse_replace_arg(replace: bytes) -> Union[bool,_OnlyFiles]: |
---|
129 | assert isinstance(replace, bytes) |
---|
130 | if replace.lower() == b"only-files": |
---|
131 | return ONLY_FILES |
---|
132 | try: |
---|
133 | return boolean_of_arg(replace) |
---|
134 | except WebError: |
---|
135 | raise WebError("invalid replace= argument: %r" % (ensure_str(replace),), http.BAD_REQUEST) |
---|
136 | |
---|
137 | |
---|
138 | def get_format(req, default="CHK"): |
---|
139 | arg = get_arg(req, "format", None) |
---|
140 | if not arg: |
---|
141 | if boolean_of_arg(get_arg(req, "mutable", "false")): |
---|
142 | return "SDMF" |
---|
143 | return default |
---|
144 | if arg.upper() == b"CHK": |
---|
145 | return "CHK" |
---|
146 | elif arg.upper() == b"SDMF": |
---|
147 | return "SDMF" |
---|
148 | elif arg.upper() == b"MDMF": |
---|
149 | return "MDMF" |
---|
150 | else: |
---|
151 | raise WebError("Unknown format: %s, I know CHK, SDMF, MDMF" % str(arg, "ascii"), |
---|
152 | http.BAD_REQUEST) |
---|
153 | |
---|
154 | def get_mutable_type(file_format): # accepts result of get_format() |
---|
155 | if file_format == "SDMF": |
---|
156 | return SDMF_VERSION |
---|
157 | elif file_format == "MDMF": |
---|
158 | return MDMF_VERSION |
---|
159 | else: |
---|
160 | # this is also used to identify which formats are mutable. Use |
---|
161 | # if get_mutable_type(file_format) is not None: |
---|
162 | # do_mutable() |
---|
163 | # else: |
---|
164 | # do_immutable() |
---|
165 | return None |
---|
166 | |
---|
167 | |
---|
168 | def parse_offset_arg(offset): # type: (bytes) -> Union[int,None] |
---|
169 | # XXX: This will raise a ValueError when invoked on something that |
---|
170 | # is not an integer. Is that okay? Or do we want a better error |
---|
171 | # message? Since this call is going to be used by programmers and |
---|
172 | # their tools rather than users (through the wui), it is not |
---|
173 | # inconsistent to return that, I guess. |
---|
174 | if offset is not None: |
---|
175 | return int(offset) |
---|
176 | |
---|
177 | return offset |
---|
178 | |
---|
179 | |
---|
180 | def get_root(req): # type: (IRequest) -> str |
---|
181 | """ |
---|
182 | Get a relative path with parent directory segments that refers to the root |
---|
183 | location known to the given request. This seems a lot like the constant |
---|
184 | absolute path **/** but it will behave differently if the Tahoe-LAFS HTTP |
---|
185 | server is reverse-proxied and mounted somewhere other than at the root. |
---|
186 | |
---|
187 | :param twisted.web.iweb.IRequest req: The request to consider. |
---|
188 | |
---|
189 | :return: A string like ``../../..`` with the correct number of segments to |
---|
190 | reach the root. |
---|
191 | """ |
---|
192 | if not IRequest.providedBy(req): |
---|
193 | raise TypeError( |
---|
194 | "get_root requires IRequest provider, got {!r}".format(req), |
---|
195 | ) |
---|
196 | depth = len(req.prepath) + len(req.postpath) |
---|
197 | link = "/".join([".."] * depth) |
---|
198 | return link |
---|
199 | |
---|
200 | |
---|
201 | def convert_children_json(nodemaker, children_json): |
---|
202 | """I convert the JSON output of GET?t=json into the dict-of-nodes input |
---|
203 | to both dirnode.create_subdirectory() and |
---|
204 | client.create_directory(initial_children=). This is used by |
---|
205 | t=mkdir-with-children and t=mkdir-immutable""" |
---|
206 | children = {} |
---|
207 | if children_json: |
---|
208 | data = json.loads(children_json) |
---|
209 | for (namex, (ctype, propdict)) in list(data.items()): |
---|
210 | namex = str(namex) |
---|
211 | writecap = to_bytes(propdict.get("rw_uri")) |
---|
212 | readcap = to_bytes(propdict.get("ro_uri")) |
---|
213 | metadata = propdict.get("metadata", {}) |
---|
214 | # name= argument is just for error reporting |
---|
215 | childnode = nodemaker.create_from_cap(writecap, readcap, name=namex) |
---|
216 | children[namex] = (childnode, metadata) |
---|
217 | return children |
---|
218 | |
---|
219 | |
---|
220 | def compute_rate(bytes, seconds): |
---|
221 | if bytes is None: |
---|
222 | return None |
---|
223 | |
---|
224 | if seconds is None or seconds == 0: |
---|
225 | return None |
---|
226 | |
---|
227 | # negative values don't make sense here |
---|
228 | assert bytes > -1 |
---|
229 | assert seconds > 0 |
---|
230 | |
---|
231 | return bytes / seconds |
---|
232 | |
---|
233 | |
---|
234 | def abbreviate_rate(data): |
---|
235 | """ |
---|
236 | Convert number of bytes/second into human readable strings (unicode). |
---|
237 | |
---|
238 | Uses metric measures, so 1000 not 1024, e.g. 21.8kBps, 554.4kBps, 4.37MBps. |
---|
239 | |
---|
240 | :param data: Either ``None`` or integer. |
---|
241 | |
---|
242 | :return: Unicode string. |
---|
243 | """ |
---|
244 | if data is None: |
---|
245 | return u"" |
---|
246 | r = float(data) |
---|
247 | if r > 1000000: |
---|
248 | return u"%1.2fMBps" % (r/1000000) |
---|
249 | if r > 1000: |
---|
250 | return u"%.1fkBps" % (r/1000) |
---|
251 | return u"%.0fBps" % r |
---|
252 | |
---|
253 | |
---|
254 | def abbreviate_size(data): |
---|
255 | """ |
---|
256 | Convert number of bytes into human readable strings (unicode). |
---|
257 | |
---|
258 | Uses metric measures, so 1000 not 1024, e.g. 21.8kB, 554.4kB, 4.37MB. |
---|
259 | |
---|
260 | :param data: Either ``None`` or integer. |
---|
261 | |
---|
262 | :return: Unicode string. |
---|
263 | """ |
---|
264 | if data is None: |
---|
265 | return u"" |
---|
266 | r = float(data) |
---|
267 | if r > 1000000000: |
---|
268 | return u"%1.2fGB" % (r/1000000000) |
---|
269 | if r > 1000000: |
---|
270 | return u"%1.2fMB" % (r/1000000) |
---|
271 | if r > 1000: |
---|
272 | return u"%.1fkB" % (r/1000) |
---|
273 | return u"%.0fB" % r |
---|
274 | |
---|
275 | def plural(sequence_or_length): |
---|
276 | if isinstance(sequence_or_length, int): |
---|
277 | length = sequence_or_length |
---|
278 | else: |
---|
279 | length = len(sequence_or_length) |
---|
280 | if length == 1: |
---|
281 | return "" |
---|
282 | return "s" |
---|
283 | |
---|
284 | def text_plain(text, req): |
---|
285 | req.setHeader("content-type", "text/plain") |
---|
286 | req.setHeader("content-length", b"%d" % len(text)) |
---|
287 | return text |
---|
288 | |
---|
289 | def spaces_to_nbsp(text): |
---|
290 | return str(text).replace(u' ', u'\u00A0') |
---|
291 | |
---|
292 | def render_time_delta(time_1, time_2): |
---|
293 | return spaces_to_nbsp(format_delta(time_1, time_2)) |
---|
294 | |
---|
295 | def render_time(t): |
---|
296 | return spaces_to_nbsp(format_time(time.localtime(t))) |
---|
297 | |
---|
298 | def render_time_attr(t): |
---|
299 | return format_time(time.localtime(t)) |
---|
300 | |
---|
301 | |
---|
302 | # XXX: to make UnsupportedMethod return 501 NOT_IMPLEMENTED instead of 500 |
---|
303 | # Internal Server Error, we either need to do that ICanHandleException trick, |
---|
304 | # or make sure that childFactory returns a WebErrorResource (and never an |
---|
305 | # actual exception). The latter is growing increasingly annoying. |
---|
306 | |
---|
307 | def should_create_intermediate_directories(req): |
---|
308 | t = str(get_arg(req, "t", "").strip(), "ascii") |
---|
309 | return bool(req.method in (b"PUT", b"POST") and |
---|
310 | t not in ("delete", "rename", "rename-form", "check")) |
---|
311 | |
---|
312 | def humanize_exception(exc): |
---|
313 | """ |
---|
314 | Like ``humanize_failure`` but for an exception. |
---|
315 | |
---|
316 | :param Exception exc: The exception to describe. |
---|
317 | |
---|
318 | :return: See ``humanize_failure``. |
---|
319 | """ |
---|
320 | if isinstance(exc, EmptyPathnameComponentError): |
---|
321 | return ("The webapi does not allow empty pathname components, " |
---|
322 | "i.e. a double slash", http.BAD_REQUEST) |
---|
323 | if isinstance(exc, ExistingChildError): |
---|
324 | return ("There was already a child by that name, and you asked me " |
---|
325 | "to not replace it.", http.CONFLICT) |
---|
326 | if isinstance(exc, NoSuchChildError): |
---|
327 | quoted_name = quote_output_u(exc.args[0], quotemarks=False) |
---|
328 | return ("No such child: %s" % quoted_name, http.NOT_FOUND) |
---|
329 | if isinstance(exc, NotEnoughSharesError): |
---|
330 | t = ("NotEnoughSharesError: This indicates that some " |
---|
331 | "servers were unavailable, or that shares have been " |
---|
332 | "lost to server departure, hard drive failure, or disk " |
---|
333 | "corruption. You should perform a filecheck on " |
---|
334 | "this object to learn more.\n\nThe full error message is:\n" |
---|
335 | "%s") % str(exc) |
---|
336 | return (t, http.GONE) |
---|
337 | if isinstance(exc, NoSharesError): |
---|
338 | t = ("NoSharesError: no shares could be found. " |
---|
339 | "Zero shares usually indicates a corrupt URI, or that " |
---|
340 | "no servers were connected, but it might also indicate " |
---|
341 | "severe corruption. You should perform a filecheck on " |
---|
342 | "this object to learn more.\n\nThe full error message is:\n" |
---|
343 | "%s") % str(exc) |
---|
344 | return (t, http.GONE) |
---|
345 | if isinstance(exc, UnrecoverableFileError): |
---|
346 | t = ("UnrecoverableFileError: the directory (or mutable file) could " |
---|
347 | "not be retrieved, because there were insufficient good shares. " |
---|
348 | "This might indicate that no servers were connected, " |
---|
349 | "insufficient servers were connected, the URI was corrupt, or " |
---|
350 | "that shares have been lost due to server departure, hard drive " |
---|
351 | "failure, or disk corruption. You should perform a filecheck on " |
---|
352 | "this object to learn more.") |
---|
353 | return (t, http.GONE) |
---|
354 | if isinstance(exc, MustNotBeUnknownRWError): |
---|
355 | quoted_name = quote_output(exc.args[1], encoding="utf-8") |
---|
356 | immutable = exc.args[2] |
---|
357 | if immutable: |
---|
358 | t = ("MustNotBeUnknownRWError: an operation to add a child named " |
---|
359 | "%s to a directory was given an unknown cap in a write slot.\n" |
---|
360 | "If the cap is actually an immutable readcap, then using a " |
---|
361 | "webapi server that supports a later version of Tahoe may help.\n\n" |
---|
362 | "If you are using the webapi directly, then specifying an immutable " |
---|
363 | "readcap in the read slot (ro_uri) of the JSON PROPDICT, and " |
---|
364 | "omitting the write slot (rw_uri), would also work in this " |
---|
365 | "case.") % quoted_name |
---|
366 | else: |
---|
367 | t = ("MustNotBeUnknownRWError: an operation to add a child named " |
---|
368 | "%s to a directory was given an unknown cap in a write slot.\n" |
---|
369 | "Using a webapi server that supports a later version of Tahoe " |
---|
370 | "may help.\n\n" |
---|
371 | "If you are using the webapi directly, specifying a readcap in " |
---|
372 | "the read slot (ro_uri) of the JSON PROPDICT, as well as a " |
---|
373 | "writecap in the write slot if desired, would also work in this " |
---|
374 | "case.") % quoted_name |
---|
375 | return (t, http.BAD_REQUEST) |
---|
376 | if isinstance(exc, MustBeDeepImmutableError): |
---|
377 | quoted_name = quote_output(exc.args[1], encoding="utf-8") |
---|
378 | t = ("MustBeDeepImmutableError: a cap passed to this operation for " |
---|
379 | "the child named %s, needed to be immutable but was not. Either " |
---|
380 | "the cap is being added to an immutable directory, or it was " |
---|
381 | "originally retrieved from an immutable directory as an unknown " |
---|
382 | "cap.") % quoted_name |
---|
383 | return (t, http.BAD_REQUEST) |
---|
384 | if isinstance(exc, MustBeReadonlyError): |
---|
385 | quoted_name = quote_output(exc.args[1], encoding="utf-8") |
---|
386 | t = ("MustBeReadonlyError: a cap passed to this operation for " |
---|
387 | "the child named '%s', needed to be read-only but was not. " |
---|
388 | "The cap is being passed in a read slot (ro_uri), or was retrieved " |
---|
389 | "from a read slot as an unknown cap.") % quoted_name |
---|
390 | return (t, http.BAD_REQUEST) |
---|
391 | if isinstance(exc, blacklist.FileProhibited): |
---|
392 | t = "Access Prohibited: %s" % quote_output(exc.reason, encoding="utf-8", quotemarks=False) |
---|
393 | return (t, http.FORBIDDEN) |
---|
394 | if isinstance(exc, WebError): |
---|
395 | return (exc.text, exc.code) |
---|
396 | if isinstance(exc, FileTooLargeError): |
---|
397 | return ("FileTooLargeError: %s" % (exc,), http.REQUEST_ENTITY_TOO_LARGE) |
---|
398 | return (str(exc), None) |
---|
399 | |
---|
400 | |
---|
401 | def humanize_failure(f): |
---|
402 | """ |
---|
403 | Create an human-oriented description of a failure along with some HTTP |
---|
404 | metadata. |
---|
405 | |
---|
406 | :param Failure f: The failure to describe. |
---|
407 | |
---|
408 | :return (bytes, int): A tuple of some prose and an HTTP code describing |
---|
409 | the failure. |
---|
410 | """ |
---|
411 | return humanize_exception(f.value) |
---|
412 | |
---|
413 | |
---|
414 | class NeedOperationHandleError(WebError): |
---|
415 | pass |
---|
416 | |
---|
417 | |
---|
418 | class SlotsSequenceElement(template.Element): |
---|
419 | """ |
---|
420 | ``SlotsSequenceElement` is a minimal port of Nevow's sequence renderer for |
---|
421 | twisted.web.template. |
---|
422 | |
---|
423 | Tags passed in to be templated will have two renderers available: ``item`` |
---|
424 | and ``tag``. |
---|
425 | """ |
---|
426 | |
---|
427 | def __init__(self, tag, seq): |
---|
428 | self.loader = template.TagLoader(tag) |
---|
429 | self.seq = seq |
---|
430 | |
---|
431 | @template.renderer |
---|
432 | def header(self, request, tag): |
---|
433 | return tag |
---|
434 | |
---|
435 | @template.renderer |
---|
436 | def item(self, request, tag): |
---|
437 | """ |
---|
438 | A template renderer for each sequence item. |
---|
439 | |
---|
440 | ``tag`` will be cloned for each item in the sequence provided, and its |
---|
441 | slots filled from the sequence item. Each item must be dict-like enough |
---|
442 | for ``tag.fillSlots(**item)``. Each cloned tag will be siblings with no |
---|
443 | separator beween them. |
---|
444 | """ |
---|
445 | for item in self.seq: |
---|
446 | yield tag.clone(deep=False).fillSlots(**item) |
---|
447 | |
---|
448 | @template.renderer |
---|
449 | def empty(self, request, tag): |
---|
450 | """ |
---|
451 | A template renderer for empty sequences. |
---|
452 | |
---|
453 | This renderer will either return ``tag`` unmodified if the provided |
---|
454 | sequence has no items, or return the empty string if there are any |
---|
455 | items. |
---|
456 | """ |
---|
457 | if len(self.seq) > 0: |
---|
458 | return u'' |
---|
459 | else: |
---|
460 | return tag |
---|
461 | |
---|
462 | |
---|
463 | def exception_to_child(getChild): |
---|
464 | """ |
---|
465 | Decorate ``getChild`` method with exception handling behavior to render an |
---|
466 | error page reflecting the exception. |
---|
467 | """ |
---|
468 | @wraps(getChild) |
---|
469 | def g(self, name, req): |
---|
470 | # Bind the method to the instance so it has a better |
---|
471 | # fullyQualifiedName later on. This is not necessary on Python 3. |
---|
472 | bound_getChild = getChild.__get__(self, type(self)) |
---|
473 | |
---|
474 | action = start_action( |
---|
475 | action_type=u"allmydata:web:common-getChild", |
---|
476 | uri=req.uri, |
---|
477 | method=req.method, |
---|
478 | name=name, |
---|
479 | handler=fullyQualifiedName(bound_getChild), |
---|
480 | ) |
---|
481 | with action.context(): |
---|
482 | result = DeferredContext(maybeDeferred(bound_getChild, name, req)) |
---|
483 | result.addCallbacks( |
---|
484 | _getChild_done, |
---|
485 | _getChild_failed, |
---|
486 | callbackArgs=(self,), |
---|
487 | ) |
---|
488 | result = result.addActionFinish() |
---|
489 | return DeferredResource(result) |
---|
490 | return g |
---|
491 | |
---|
492 | |
---|
493 | def _getChild_done(child, parent): |
---|
494 | Message.log( |
---|
495 | message_type=u"allmydata:web:common-getChild:result", |
---|
496 | result=fullyQualifiedName(type(child)), |
---|
497 | ) |
---|
498 | if child is None: |
---|
499 | return resource.NoResource() |
---|
500 | return child |
---|
501 | |
---|
502 | |
---|
503 | def _getChild_failed(reason): |
---|
504 | text, code = humanize_failure(reason) |
---|
505 | return resource.ErrorPage(code, "Error", text) |
---|
506 | |
---|
507 | |
---|
508 | def render_exception(render): |
---|
509 | """ |
---|
510 | Decorate a ``render_*`` method with exception handling behavior to render |
---|
511 | an error page reflecting the exception. |
---|
512 | """ |
---|
513 | @wraps(render) |
---|
514 | def g(self, request): |
---|
515 | # Bind the method to the instance so it has a better |
---|
516 | # fullyQualifiedName later on. This is not necessary on Python 3. |
---|
517 | bound_render = render.__get__(self, type(self)) |
---|
518 | |
---|
519 | action = start_action( |
---|
520 | action_type=u"allmydata:web:common-render", |
---|
521 | uri=request.uri, |
---|
522 | method=request.method, |
---|
523 | handler=fullyQualifiedName(bound_render), |
---|
524 | ) |
---|
525 | if getattr(request, "dont_apply_extra_processing", False): |
---|
526 | with action: |
---|
527 | return bound_render(request) |
---|
528 | |
---|
529 | with action.context(): |
---|
530 | result = DeferredContext(maybeDeferred(bound_render, request)) |
---|
531 | # Apply `_finish` all of our result handling logic to whatever it |
---|
532 | # returned. |
---|
533 | result.addBoth(_finish, bound_render, request) |
---|
534 | d = result.addActionFinish() |
---|
535 | |
---|
536 | # If the connection is lost then there's no point running our _finish |
---|
537 | # logic because it has nowhere to send anything. There may also be no |
---|
538 | # point in finishing whatever operation was being performed because |
---|
539 | # the client cannot be informed of its result. Also, Twisted Web |
---|
540 | # raises exceptions from some Request methods if they're used after |
---|
541 | # the connection is lost. |
---|
542 | request.notifyFinish().addErrback( |
---|
543 | lambda ignored: d.cancel(), |
---|
544 | ) |
---|
545 | return NOT_DONE_YET |
---|
546 | |
---|
547 | return g |
---|
548 | |
---|
549 | |
---|
550 | def _finish(result, render, request): |
---|
551 | """ |
---|
552 | Try to finish rendering the response to a request. |
---|
553 | |
---|
554 | This implements extra convenience functionality not provided by Twisted |
---|
555 | Web. Various resources in Tahoe-LAFS made use of this functionality when |
---|
556 | it was provided by Nevow. Rather than making that application code do the |
---|
557 | more tedious thing itself, we duplicate the functionality here. |
---|
558 | |
---|
559 | :param result: Something returned by a render method which we can turn |
---|
560 | into a response. |
---|
561 | |
---|
562 | :param render: The original render method which produced the result. |
---|
563 | |
---|
564 | :param request: The request being responded to. |
---|
565 | |
---|
566 | :return: ``None`` |
---|
567 | """ |
---|
568 | if isinstance(result, Failure): |
---|
569 | if result.check(CancelledError): |
---|
570 | return |
---|
571 | Message.log( |
---|
572 | message_type=u"allmydata:web:common-render:failure", |
---|
573 | message=result.getErrorMessage(), |
---|
574 | ) |
---|
575 | _finish( |
---|
576 | _renderHTTP_exception(request, result), |
---|
577 | render, |
---|
578 | request, |
---|
579 | ) |
---|
580 | elif IResource.providedBy(result): |
---|
581 | # If result is also using @render_exception then we don't want to |
---|
582 | # double-apply the logic. This leads to an attempt to double-finish |
---|
583 | # the request. If it isn't using @render_exception then you should |
---|
584 | # fix it so it is. |
---|
585 | Message.log( |
---|
586 | message_type=u"allmydata:web:common-render:resource", |
---|
587 | resource=fullyQualifiedName(type(result)), |
---|
588 | ) |
---|
589 | result.render(request) |
---|
590 | elif isinstance(result, str): |
---|
591 | Message.log( |
---|
592 | message_type=u"allmydata:web:common-render:unicode", |
---|
593 | ) |
---|
594 | request.write(result.encode("utf-8")) |
---|
595 | request.finish() |
---|
596 | elif isinstance(result, bytes): |
---|
597 | Message.log( |
---|
598 | message_type=u"allmydata:web:common-render:bytes", |
---|
599 | ) |
---|
600 | request.write(result) |
---|
601 | request.finish() |
---|
602 | elif isinstance(result, DecodedURL): |
---|
603 | Message.log( |
---|
604 | message_type=u"allmydata:web:common-render:DecodedURL", |
---|
605 | ) |
---|
606 | _finish(redirectTo(result.to_text().encode("utf-8"), request), render, request) |
---|
607 | elif result is None: |
---|
608 | Message.log( |
---|
609 | message_type=u"allmydata:web:common-render:None", |
---|
610 | ) |
---|
611 | request.finish() |
---|
612 | elif result == NOT_DONE_YET: |
---|
613 | Message.log( |
---|
614 | message_type=u"allmydata:web:common-render:NOT_DONE_YET", |
---|
615 | ) |
---|
616 | pass |
---|
617 | else: |
---|
618 | Message.log( |
---|
619 | message_type=u"allmydata:web:common-render:unknown", |
---|
620 | ) |
---|
621 | log.err("Request for {!r} handled by {!r} returned unusable {!r}".format( |
---|
622 | request.uri, |
---|
623 | fullyQualifiedName(render), |
---|
624 | result, |
---|
625 | )) |
---|
626 | request.setResponseCode(http.INTERNAL_SERVER_ERROR) |
---|
627 | _finish(b"Internal Server Error", render, request) |
---|
628 | |
---|
629 | |
---|
630 | def _renderHTTP_exception(request, failure): |
---|
631 | try: |
---|
632 | text, code = humanize_failure(failure) |
---|
633 | except: |
---|
634 | log.msg("exception in humanize_failure") |
---|
635 | log.msg("argument was %s" % (failure,)) |
---|
636 | log.err() |
---|
637 | text = str(failure) |
---|
638 | code = None |
---|
639 | |
---|
640 | if code is not None: |
---|
641 | return _renderHTTP_exception_simple(request, text, code) |
---|
642 | |
---|
643 | accept = request.getHeader("accept") |
---|
644 | if not accept: |
---|
645 | accept = "*/*" |
---|
646 | if "*/*" in accept or "text/*" in accept or "text/html" in accept: |
---|
647 | request.setResponseCode(http.INTERNAL_SERVER_ERROR) |
---|
648 | return template.renderElement( |
---|
649 | request, |
---|
650 | tags.html( |
---|
651 | tags.head( |
---|
652 | tags.title(u"Exception"), |
---|
653 | ), |
---|
654 | tags.body( |
---|
655 | FailureElement(failure), |
---|
656 | ), |
---|
657 | ), |
---|
658 | ) |
---|
659 | |
---|
660 | # use plain text |
---|
661 | traceback = failure.getTraceback() |
---|
662 | return _renderHTTP_exception_simple( |
---|
663 | request, |
---|
664 | traceback, |
---|
665 | http.INTERNAL_SERVER_ERROR, |
---|
666 | ) |
---|
667 | |
---|
668 | |
---|
669 | def _renderHTTP_exception_simple(request, text, code): |
---|
670 | request.setResponseCode(code) |
---|
671 | request.setHeader("content-type", "text/plain;charset=utf-8") |
---|
672 | if isinstance(text, str): |
---|
673 | text = text.encode("utf-8") |
---|
674 | request.setHeader("content-length", b"%d" % len(text)) |
---|
675 | return text |
---|
676 | |
---|
677 | |
---|
678 | def handle_when_done(req, d): |
---|
679 | when_done = get_arg(req, "when_done", None) |
---|
680 | if when_done: |
---|
681 | d.addCallback(lambda res: DecodedURL.from_text(when_done.decode("utf-8"))) |
---|
682 | return d |
---|
683 | |
---|
684 | |
---|
685 | def url_for_string(req, url_string): |
---|
686 | """ |
---|
687 | Construct a universal URL using the given URL string. |
---|
688 | |
---|
689 | :param IRequest req: The request being served. If ``redir_to`` is not |
---|
690 | absolute then this is used to determine the net location of this |
---|
691 | server and the resulting URL is made to point at it. |
---|
692 | |
---|
693 | :param bytes url_string: A byte string giving a universal or absolute URL. |
---|
694 | |
---|
695 | :return DecodedURL: An absolute URL based on this server's net location |
---|
696 | and the given URL string. |
---|
697 | """ |
---|
698 | url = DecodedURL.from_text(url_string.decode("utf-8")) |
---|
699 | if not url.host: |
---|
700 | root = req.URLPath() |
---|
701 | netloc = root.netloc.split(b":", 1) |
---|
702 | if len(netloc) == 1: |
---|
703 | host = netloc |
---|
704 | port = None |
---|
705 | else: |
---|
706 | host = netloc[0] |
---|
707 | port = int(netloc[1]) |
---|
708 | url = url.replace( |
---|
709 | scheme=root.scheme.decode("ascii"), |
---|
710 | host=host.decode("ascii"), |
---|
711 | port=port, |
---|
712 | ) |
---|
713 | return url |
---|
714 | |
---|
715 | T = TypeVar("T") |
---|
716 | |
---|
717 | @overload |
---|
718 | def get_arg(req: IRequest, argname: str | bytes, default: Optional[T] = None, *, multiple: Literal[False] = False) -> T | bytes: ... |
---|
719 | |
---|
720 | @overload |
---|
721 | def get_arg(req: IRequest, argname: str | bytes, default: Optional[T] = None, *, multiple: Literal[True]) -> T | tuple[bytes, ...]: ... |
---|
722 | |
---|
723 | def get_arg(req: IRequest, argname: str | bytes, default: Optional[T] = None, *, multiple: bool = False) -> None | T | bytes | tuple[bytes, ...]: |
---|
724 | """Extract an argument from either the query args (req.args) or the form |
---|
725 | body fields (req.fields). If multiple=False, this returns a single value |
---|
726 | (or the default, which defaults to None), and the query args take |
---|
727 | precedence. If multiple=True, this returns a tuple of arguments (possibly |
---|
728 | empty), starting with all those in the query args. |
---|
729 | |
---|
730 | :param TahoeLAFSRequest req: The request to consider. |
---|
731 | |
---|
732 | :return: Either bytes or tuple of bytes. |
---|
733 | """ |
---|
734 | # Need to import here to prevent circular import: |
---|
735 | from ..webish import TahoeLAFSRequest |
---|
736 | |
---|
737 | if isinstance(argname, str): |
---|
738 | argname_bytes = argname.encode("utf-8") |
---|
739 | else: |
---|
740 | argname_bytes = argname |
---|
741 | |
---|
742 | results : list[bytes] = [] |
---|
743 | if req.args is not None and argname_bytes in req.args: |
---|
744 | results.extend(req.args[argname_bytes]) |
---|
745 | argname_unicode = str(argname_bytes, "utf-8") |
---|
746 | if isinstance(req, TahoeLAFSRequest) and req.fields and argname_unicode in req.fields: |
---|
747 | # In all but one or two unit tests, the request will be a |
---|
748 | # TahoeLAFSRequest. |
---|
749 | value = req.fields[argname_unicode].value |
---|
750 | if isinstance(value, str): |
---|
751 | value = value.encode("utf-8") |
---|
752 | results.append(value) |
---|
753 | if multiple: |
---|
754 | return tuple(results) |
---|
755 | if results: |
---|
756 | return results[0] |
---|
757 | |
---|
758 | if isinstance(default, str): |
---|
759 | return default.encode("utf-8") |
---|
760 | return default |
---|
761 | |
---|
762 | |
---|
763 | class MultiFormatResource(resource.Resource, object): |
---|
764 | """ |
---|
765 | ``MultiFormatResource`` is a ``resource.Resource`` that can be rendered in |
---|
766 | a number of different formats. |
---|
767 | |
---|
768 | Rendered format is controlled by a query argument (given by |
---|
769 | ``self.formatArgument``). Different resources may support different |
---|
770 | formats but ``json`` is a pretty common one. ``html`` is the default |
---|
771 | format if nothing else is given as the ``formatDefault``. |
---|
772 | """ |
---|
773 | formatArgument = "t" |
---|
774 | formatDefault = None # type: Optional[str] |
---|
775 | |
---|
776 | def render(self, req): |
---|
777 | """ |
---|
778 | Dispatch to a renderer for a particular format, as selected by a query |
---|
779 | argument. |
---|
780 | |
---|
781 | A renderer for the format given by the query argument matching |
---|
782 | ``formatArgument`` will be selected and invoked. render_HTML will be |
---|
783 | used as a default if no format is selected (either by query arguments |
---|
784 | or by ``formatDefault``). |
---|
785 | |
---|
786 | :return: The result of the selected renderer. |
---|
787 | """ |
---|
788 | t = get_arg(req, self.formatArgument, self.formatDefault) |
---|
789 | # It's either bytes or None. |
---|
790 | if isinstance(t, bytes): |
---|
791 | t = str(t, "ascii") |
---|
792 | renderer = self._get_renderer(t) |
---|
793 | result = renderer(req) |
---|
794 | # On Python 3, json.dumps() returns Unicode for example, but |
---|
795 | # twisted.web expects bytes. Instead of updating every single render |
---|
796 | # method, just handle Unicode one time here. |
---|
797 | if isinstance(result, str): |
---|
798 | result = result.encode("utf-8") |
---|
799 | return result |
---|
800 | |
---|
801 | def _get_renderer(self, fmt): |
---|
802 | """ |
---|
803 | Get the renderer for the indicated format. |
---|
804 | |
---|
805 | :param str fmt: The format. If a method with a prefix of ``render_`` |
---|
806 | and a suffix of this format (upper-cased) is found, it will be |
---|
807 | used. |
---|
808 | |
---|
809 | :return: A callable which takes a twisted.web Request and renders a |
---|
810 | response. |
---|
811 | """ |
---|
812 | renderer = None |
---|
813 | |
---|
814 | if fmt is not None: |
---|
815 | try: |
---|
816 | renderer = getattr(self, "render_{}".format(fmt.upper())) |
---|
817 | except AttributeError: |
---|
818 | return resource.ErrorPage( |
---|
819 | http.BAD_REQUEST, |
---|
820 | "Bad Format", |
---|
821 | "Unknown {} value: {!r}".format(self.formatArgument, fmt), |
---|
822 | ).render |
---|
823 | |
---|
824 | if renderer is None: |
---|
825 | renderer = self.render_HTML |
---|
826 | |
---|
827 | return renderer |
---|
828 | |
---|
829 | |
---|
830 | def abbreviate_time(data): |
---|
831 | """ |
---|
832 | Convert number of seconds into human readable string. |
---|
833 | |
---|
834 | :param data: Either ``None`` or integer or float, seconds. |
---|
835 | |
---|
836 | :return: Unicode string. |
---|
837 | """ |
---|
838 | # 1.23s, 790ms, 132us |
---|
839 | if data is None: |
---|
840 | return u"" |
---|
841 | s = float(data) |
---|
842 | if s >= 10: |
---|
843 | return abbreviate.abbreviate_time(data) |
---|
844 | if s >= 1.0: |
---|
845 | return u"%.2fs" % s |
---|
846 | if s >= 0.01: |
---|
847 | return u"%.0fms" % (1000*s) |
---|
848 | if s >= 0.001: |
---|
849 | return u"%.1fms" % (1000*s) |
---|
850 | return u"%.0fus" % (1000000*s) |
---|
851 | |
---|
852 | def get_keypair(request: IRequest) -> tuple[PublicKey, PrivateKey] | None: |
---|
853 | """ |
---|
854 | Load a keypair from a urlsafe-base64-encoded RSA private key in the |
---|
855 | **private-key** argument of the given request, if there is one. |
---|
856 | """ |
---|
857 | privkey_der = get_arg(request, "private-key", default=None, multiple=False) |
---|
858 | if privkey_der is None: |
---|
859 | return None |
---|
860 | privkey, pubkey = create_signing_keypair_from_string(urlsafe_b64decode(privkey_der)) |
---|
861 | return pubkey, privkey |
---|
862 | |
---|
863 | |
---|
864 | def add_static_children(root: IResource): |
---|
865 | """ |
---|
866 | Add static files from C{allmydata.web} to the given resource. |
---|
867 | |
---|
868 | Package resources may be on the filesystem, or they may be in a zip |
---|
869 | or something, so we need to do a bit more work to serve them as |
---|
870 | static files. |
---|
871 | """ |
---|
872 | temporary_file_manager = ExitStack() |
---|
873 | static_dir = resource_files("allmydata.web") / "static" |
---|
874 | for child in static_dir.iterdir(): |
---|
875 | child_path = child.name.encode("utf-8") |
---|
876 | root.putChild(child_path, static.File( |
---|
877 | str(temporary_file_manager.enter_context(as_file(child))) |
---|
878 | )) |
---|
879 | weakref.finalize(root, temporary_file_manager.close) |
---|