source: trunk/src/allmydata/web/root.py

Last change on this file was e6630b5, checked in by Itamar Turner-Trauring <itamar@…>, at 2023-11-17T16:22:06Z

Fix mypy complaints, simplifying code while we're at it

  • Property mode set to 100644
File size: 19.7 KB
Line 
1"""
2Ported to Python 3.
3"""
4import time
5from urllib.parse import quote as urlquote
6
7from hyperlink import DecodedURL, URL
8from twisted.web import (
9    http,
10    resource,
11)
12from twisted.web.util import redirectTo, Redirect
13from twisted.python.filepath import FilePath
14from twisted.web.template import (
15    Element,
16    XMLFile,
17    renderer,
18    renderElement,
19    tags,
20)
21
22import allmydata # to display import path
23from allmydata.util import log, jsonbytes as json
24from allmydata.interfaces import IFileNode
25from allmydata.web import (
26    filenode,
27    directory,
28    unlinked,
29    status,
30)
31from allmydata.web import storage
32from allmydata.web.common import (
33    abbreviate_size,
34    WebError,
35    exception_to_child,
36    get_arg,
37    MultiFormatResource,
38    SlotsSequenceElement,
39    get_format,
40    get_mutable_type,
41    render_exception,
42    render_time_delta,
43    render_time,
44    render_time_attr,
45    add_static_children,
46)
47from allmydata.web.private import (
48    create_private_tree,
49)
50from allmydata import uri
51
52class URIHandler(resource.Resource, object):
53    """
54    I live at /uri . There are several operations defined on /uri itself,
55    mostly involved with creation of unlinked files and directories.
56    """
57
58    def __init__(self, client):
59        super(URIHandler, self).__init__()
60        self.client = client
61
62    @render_exception
63    def render_GET(self, req):
64        """
65        Historically, accessing this via "GET /uri?uri=<capabilitiy>"
66        was/is a feature -- which simply redirects to the more-common
67        "GET /uri/<capability>" with any other query args
68        preserved. New code should use "/uri/<cap>"
69        """
70        uri_arg = req.args.get(b"uri", [None])[0]
71        if uri_arg is None:
72            raise WebError("GET /uri requires uri=")
73
74        # shennanigans like putting "%2F" or just "/" itself, or ../
75        # etc in the <cap> might be a vector for weirdness so we
76        # validate that this is a valid capability before proceeding.
77        cap = uri.from_string(uri_arg)
78        if isinstance(cap, uri.UnknownURI):
79            raise WebError("Invalid capability")
80
81        # so, using URL.from_text(req.uri) isn't going to work because
82        # it seems Nevow was creating absolute URLs including
83        # host/port whereas req.uri is absolute (but lacks host/port)
84        redir_uri = URL.from_text(req.prePathURL().decode('utf8'))
85        redir_uri = redir_uri.child(urlquote(uri_arg))
86        # add back all the query args that AREN'T "?uri="
87        for k, values in req.args.items():
88            if k != b"uri":
89                for v in values:
90                    redir_uri = redir_uri.add(k.decode('utf8'), v.decode('utf8'))
91        return redirectTo(redir_uri.to_text().encode('utf8'), req)
92
93    @render_exception
94    def render_PUT(self, req):
95        """
96        either "PUT /uri" to create an unlinked file, or
97        "PUT /uri?t=mkdir" to create an unlinked directory
98        """
99        t = str(get_arg(req, "t", "").strip(), "utf-8")
100        if t == "":
101            file_format = get_format(req, "CHK")
102            mutable_type = get_mutable_type(file_format)
103            if mutable_type is not None:
104                return unlinked.PUTUnlinkedSSK(req, self.client, mutable_type)
105            else:
106                return unlinked.PUTUnlinkedCHK(req, self.client)
107        if t == "mkdir":
108            return unlinked.PUTUnlinkedCreateDirectory(req, self.client)
109        errmsg = (
110            "/uri accepts only PUT, PUT?t=mkdir, POST?t=upload, "
111            "and POST?t=mkdir"
112        )
113        raise WebError(errmsg, http.BAD_REQUEST)
114
115    @render_exception
116    def render_POST(self, req):
117        """
118        "POST /uri?t=upload&file=newfile" to upload an
119        unlinked file or "POST /uri?t=mkdir" to create a
120        new directory
121        """
122        t = str(get_arg(req, "t", "").strip(), "ascii")
123        if t in ("", "upload"):
124            file_format = get_format(req)
125            mutable_type = get_mutable_type(file_format)
126            if mutable_type is not None:
127                return unlinked.POSTUnlinkedSSK(req, self.client, mutable_type)
128            else:
129                return unlinked.POSTUnlinkedCHK(req, self.client)
130        if t == "mkdir":
131            return unlinked.POSTUnlinkedCreateDirectory(req, self.client)
132        elif t == "mkdir-with-children":
133            return unlinked.POSTUnlinkedCreateDirectoryWithChildren(req,
134                                                                    self.client)
135        elif t == "mkdir-immutable":
136            return unlinked.POSTUnlinkedCreateImmutableDirectory(req,
137                                                                 self.client)
138        errmsg = ("/uri accepts only PUT, PUT?t=mkdir, POST?t=upload, "
139                  "and POST?t=mkdir")
140        raise WebError(errmsg, http.BAD_REQUEST)
141
142    @exception_to_child
143    def getChild(self, name, req):
144        """
145        Most requests look like /uri/<cap> so this fetches the capability
146        and creates and appropriate handler (depending on the kind of
147        capability it was passed).
148        """
149        # this is in case a URI like "/uri/?uri=<valid capability>" is
150        # passed -- we re-direct to the non-trailing-slash version so
151        # that there is just one valid URI for "uri" resource.
152        if not name:
153            u = DecodedURL.from_text(req.uri.decode('utf8'))
154            u = u.replace(
155                path=(s for s in u.path if s),  # remove empty segments
156            )
157            return Redirect(u.to_uri().to_text().encode('utf8'))
158        try:
159            node = self.client.create_node_from_uri(name)
160            return directory.make_handler_for(node, self.client)
161        except (TypeError, AssertionError) as e:
162            log.msg(format="Failed to parse cap, perhaps due to bug: %(e)s",
163                    e=e, level=log.WEIRD)
164            raise WebError(
165                "'{}' is not a valid file- or directory- cap".format(name)
166            )
167
168
169class FileHandler(resource.Resource, object):
170    # I handle /file/$FILECAP[/IGNORED] , which provides a URL from which a
171    # file can be downloaded correctly by tools like "wget".
172
173    def __init__(self, client):
174        super(FileHandler, self).__init__()
175        self.client = client
176
177    @exception_to_child
178    def getChild(self, name, req):
179        if req.method not in (b"GET", b"HEAD"):
180            raise WebError("/file can only be used with GET or HEAD")
181        # 'name' must be a file URI
182        try:
183            node = self.client.create_node_from_uri(name)
184        except (TypeError, AssertionError):
185            # I think this can no longer be reached
186            raise WebError("%r is not a valid file- or directory- cap"
187                           % name)
188        if not IFileNode.providedBy(node):
189            raise WebError("%r is not a file-cap" % name)
190        return filenode.FileNodeDownloadHandler(self.client, node)
191
192    @render_exception
193    def render_GET(self, req):
194        raise WebError("/file must be followed by a file-cap and a name",
195                       http.NOT_FOUND)
196
197class IncidentReporter(MultiFormatResource):
198    """Handler for /report_incident POST request"""
199
200    @render_exception
201    def render(self, req):
202        if req.method != b"POST":
203            raise WebError("/report_incident can only be used with POST")
204
205        log.msg(format="User reports incident through web page: %(details)s",
206                details=get_arg(req, "details", ""),
207                level=log.WEIRD, umid="LkD9Pw")
208        req.setHeader("content-type", "text/plain; charset=UTF-8")
209        return b"An incident report has been saved to logs/incidents/ in the node directory."
210
211SPACE = u"\u00A0"*2
212
213
214class Root(MultiFormatResource):
215
216    addSlash = True
217
218    def __init__(self, client, clock=None, now_fn=None):
219        """
220        Render root page ("/") of the URI.
221
222        :client allmydata.client._Client: a stats provider.
223        :clock: unused here.
224        :now_fn: a function that returns current time.
225
226        """
227        super(Root, self).__init__()
228        self._client = client
229        self._now_fn = now_fn
230
231        self.putChild(b"uri", URIHandler(client))
232        self.putChild(b"cap", URIHandler(client))
233
234        # Handler for everything beneath "/private", an area of the resource
235        # hierarchy which is only accessible with the private per-node API
236        # auth token.
237        self.putChild(b"private", create_private_tree(client.get_auth_token))
238
239        self.putChild(b"file", FileHandler(client))
240        self.putChild(b"named", FileHandler(client))
241        self.putChild(b"status", status.Status(client.get_history()))
242        self.putChild(b"statistics", status.Statistics(client.stats_provider))
243        self.putChild(b"report_incident", IncidentReporter())
244
245        add_static_children(self)
246
247    @exception_to_child
248    def getChild(self, path, request):
249        if not path:
250            # Render "/" path.
251            return self
252        if path == b"helper_status":
253            # the Helper isn't attached until after the Tub starts, so this child
254            # needs to created on each request
255            return status.HelperStatus(self._client.helper)
256        if path == b"storage":
257            # Storage isn't initialized until after the web hierarchy is
258            # constructed so this child needs to be created later than
259            # `__init__`.
260            try:
261                storage_server = self._client.getServiceNamed("storage")
262            except KeyError:
263                storage_server = None
264            return storage.StorageStatus(storage_server, self._client.nickname)
265
266    @render_exception
267    def render_HTML(self, req):
268        return renderElement(req, RootElement(self._client, self._now_fn))
269
270    @render_exception
271    def render_JSON(self, req):
272        req.setHeader("content-type", "application/json; charset=utf-8")
273        intro_summaries = [s.summary for s in self._client.introducer_connection_statuses()]
274        sb = self._client.get_storage_broker()
275        servers = self._describe_known_servers(sb)
276        result = {
277            "introducers": {
278                "statuses": intro_summaries,
279            },
280            "servers": servers
281        }
282        return json.dumps(result, indent=1) + "\n"
283
284    def _describe_known_servers(self, broker):
285        return list(
286            self._describe_server(server)
287            for server
288            in broker.get_known_servers()
289        )
290
291    def _describe_server(self, server):
292        status = server.get_connection_status()
293        description = {
294            u"nodeid": server.get_serverid(),
295            u"connection_status": status.summary,
296            u"available_space": server.get_available_space(),
297            u"nickname": server.get_nickname(),
298            u"version": None,
299            u"last_received_data": status.last_received_time,
300        }
301        version = server.get_version()
302        if version is not None:
303            description[u"version"] = version[b"application-version"]
304
305        return description
306
307class RootElement(Element):
308
309    loader = XMLFile(FilePath(__file__).sibling("welcome.xhtml"))
310
311    def __init__(self, client, now_fn):
312        super(RootElement, self).__init__()
313        self._client = client
314        self._now_fn = now_fn
315
316    _connectedalts = {
317        "not-configured": "Not Configured",
318        "yes": "Connected",
319        "no": "Disconnected",
320        }
321
322    @renderer
323    def my_nodeid(self, req, tag):
324        tubid_s = "TubID: "+self._client.get_long_tubid()
325        return tags.td(self._client.get_long_nodeid(), title=tubid_s)
326
327    @renderer
328    def my_nickname(self, req, tag):
329        return tag(self._client.nickname)
330
331    def _connected_introducers(self):
332        return len([1 for cs in self._client.introducer_connection_statuses()
333                    if cs.connected])
334
335    @renderer
336    def connected_introducers(self, req, tag):
337        return tag(str(self._connected_introducers()))
338
339    @renderer
340    def connected_to_at_least_one_introducer(self, req, tag):
341        if self._connected_introducers():
342            return "yes"
343        return "no"
344
345    @renderer
346    def connected_to_at_least_one_introducer_alt(self, req, tag):
347        state = self.connected_to_at_least_one_introducer(req, tag)
348        return self._connectedalts.get(state)
349
350    @renderer
351    def services(self, req, tag):
352        ul = tags.ul()
353        try:
354            ss = self._client.getServiceNamed("storage")
355            stats = ss.get_stats()
356            if stats["storage_server.accepting_immutable_shares"]:
357                msg = "accepting new shares"
358            else:
359                msg = "not accepting new shares (read-only)"
360            available = stats.get("storage_server.disk_avail")
361            if available is not None:
362                msg += ", %s available" % abbreviate_size(available)
363            ul(tags.li(tags.a("Storage Server", href="storage"), ": ", msg))
364        except KeyError:
365            ul(tags.li("Not running storage server"))
366
367        if self._client.helper:
368            stats = self._client.helper.get_stats()
369            active_uploads = stats["chk_upload_helper.active_uploads"]
370            ul(tags.li("Helper: %d active uploads" % (active_uploads,)))
371        else:
372            ul(tags.li("Not running helper"))
373
374        return tag(ul)
375
376    @renderer
377    def introducer_description(self, req, tag):
378        connected_count = self._connected_introducers()
379        if connected_count == 0:
380            return tag("No introducers connected")
381        elif connected_count == 1:
382            return tag("1 introducer connected")
383        else:
384            return tag("%s introducers connected" % (connected_count,))
385
386    @renderer
387    def total_introducers(self, req, tag):
388        return tag(str(len(self._get_introducers())))
389
390    # In case we configure multiple introducers
391    @renderer
392    def introducers(self, req, tag):
393        ix = self._get_introducers()
394        if not ix:
395            return tag("No introducers")
396        return tag
397
398    def _get_introducers(self):
399        return self._client.introducer_connection_statuses()
400
401    @renderer
402    def helper_furl_prefix(self, req, tag):
403        try:
404            uploader = self._client.getServiceNamed("uploader")
405        except KeyError:
406            return tag("None")
407        furl, connected = uploader.get_helper_info()
408        if not furl:
409            return tag("None")
410        # trim off the secret swissnum
411        (prefix, _, swissnum) = furl.rpartition("/")
412        return tag("%s/[censored]" % (prefix,))
413
414    def _connected_to_helper(self):
415        try:
416            uploader = self._client.getServiceNamed("uploader")
417        except KeyError:
418            return "no" # we don't even have an Uploader
419        furl, connected = uploader.get_helper_info()
420
421        if furl is None:
422            return "not-configured"
423        if connected:
424            return "yes"
425        return "no"
426
427    @renderer
428    def helper_description(self, req, tag):
429        if self._connected_to_helper() == "no":
430            return tag("Helper not connected")
431        return tag("Helper")
432
433    @renderer
434    def connected_to_helper(self, req, tag):
435        return tag(self._connected_to_helper())
436
437    @renderer
438    def connected_to_helper_alt(self, req, tag):
439        return tag(self._connectedalts.get(self._connected_to_helper()))
440
441    @renderer
442    def known_storage_servers(self, req, tag):
443        sb = self._client.get_storage_broker()
444        return tag(str(len(sb.get_all_serverids())))
445
446    @renderer
447    def connected_storage_servers(self, req, tag):
448        sb = self._client.get_storage_broker()
449        return tag(str(len(sb.get_connected_servers())))
450
451    @renderer
452    def services_table(self, req, tag):
453        rows = [ self._describe_server_and_connection(server)
454                 for server in self._services() ]
455        return SlotsSequenceElement(tag, rows)
456
457    @renderer
458    def introducers_table(self, req, tag):
459        rows = [ self._describe_connection_status(cs)
460                 for cs in self._get_introducers() ]
461        return SlotsSequenceElement(tag, rows)
462
463    def _services(self):
464        sb = self._client.get_storage_broker()
465        return sorted(sb.get_known_servers(), key=lambda s: s.get_serverid())
466
467    @staticmethod
468    def _describe_server(server):
469        """Return a dict containing server stats."""
470        peerid = server.get_longname()
471        nickname =  server.get_nickname()
472        version = server.get_announcement().get("my-version", "")
473
474        space = server.get_available_space()
475        if space is not None:
476            available_space = abbreviate_size(space)
477        else:
478            available_space = "N/A"
479
480        return {
481            "peerid": peerid,
482            "nickname": nickname,
483            "version": version,
484            "available_space": available_space,
485        }
486
487    def _describe_server_and_connection(self, server):
488        """Return a dict containing both server and connection stats."""
489        srvstat = self._describe_server(server)
490        cs = server.get_connection_status()
491        constat = self._describe_connection_status(cs)
492        return dict(list(srvstat.items()) + list(constat.items()))
493
494    def _describe_connection_status(self, cs):
495        """Return a dict containing some connection stats."""
496        others = cs.non_connected_statuses
497
498        if cs.connected:
499            summary = cs.summary
500            if others:
501                hints = "\n".join(["* %s: %s\n" % (which, others[which])
502                                for which in sorted(others)])
503                details = "Other hints:\n" + hints
504            else:
505                details = "(no other hints)"
506        else:
507            details = tags.ul()
508            for which in sorted(others):
509                details(tags.li("%s: %s" % (which, others[which])))
510            summary = [cs.summary, details]
511
512        connected = "yes" if cs.connected else "no"
513        connected_alt = self._connectedalts[connected]
514
515        since = cs.last_connection_time
516
517        if since is not None:
518            service_connection_status_rel_time = render_time_delta(since, self._now_fn())
519            service_connection_status_abs_time = render_time_attr(since)
520        else:
521            service_connection_status_rel_time = "N/A"
522            service_connection_status_abs_time = "N/A"
523
524        last_received_data_time = cs.last_received_time
525
526        if last_received_data_time is not None:
527            last_received_data_abs_time = render_time_attr(last_received_data_time)
528            last_received_data_rel_time = render_time_delta(last_received_data_time, self._now_fn())
529        else:
530            last_received_data_abs_time = "N/A"
531            last_received_data_rel_time = "N/A"
532
533        return {
534            "summary": summary,
535            "details": details,
536            "service_connection_status": connected,
537            "service_connection_status_alt": connected_alt,
538            "service_connection_status_abs_time": service_connection_status_abs_time,
539            "service_connection_status_rel_time": service_connection_status_rel_time,
540            "last_received_data_abs_time": last_received_data_abs_time,
541            "last_received_data_rel_time": last_received_data_rel_time,
542        }
543
544    @renderer
545    def incident_button(self, req, tag):
546        # this button triggers a foolscap-logging "incident"
547        form = tags.form(
548            tags.fieldset(
549                tags.input(type="hidden", name="t", value="report-incident"),
550                "What went wrong?"+SPACE,
551                tags.input(type="text", name="details"), SPACE,
552                tags.input(type="submit", value=u"Save \u00BB"),
553            ),
554            action="report_incident",
555            method="post",
556            enctype="multipart/form-data"
557        )
558        return tags.div(form)
559
560    @renderer
561    def rendered_at(self, req, tag):
562        return tag(render_time(time.time()))
563
564    @renderer
565    def version(self, req, tag):
566        return tag(allmydata.__full_version__)
567
568    @renderer
569    def import_path(self, req, tag):
570        return tag(str(allmydata))
Note: See TracBrowser for help on using the repository browser.