source: trunk/src/allmydata/web/root.py @ 65b6daf9

Last change on this file since 65b6daf9 was 65b6daf9, checked in by Sajith Sasidharan <sajith@…>, at 2020-04-27T20:44:06Z

Rewrite incident button using twisted tags

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