Coverage for datasette/app.py : 95%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1import asyncio
2import asgi_csrf
3import collections
4import datetime
5import hashlib
6import itertools
7import json
8import os
9import re
10import secrets
11import sys
12import threading
13import traceback
14import urllib.parse
15from concurrent import futures
16from pathlib import Path
18import click
19from markupsafe import Markup
20from itsdangerous import URLSafeSerializer
21import jinja2
22from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader, escape
23from jinja2.environment import Template
24from jinja2.exceptions import TemplateNotFound
25import uvicorn
27from .views.base import DatasetteError, ureg, AsgiRouter
28from .views.database import DatabaseDownload, DatabaseView
29from .views.index import IndexView
30from .views.special import (
31 JsonDataView,
32 PatternPortfolioView,
33 AuthTokenView,
34 PermissionsDebugView,
35 MessagesDebugView,
36)
37from .views.table import RowView, TableView
38from .renderer import json_renderer
39from .database import Database, QueryInterrupted
41from .utils import (
42 async_call_with_supported_arguments,
43 escape_css_string,
44 escape_sqlite,
45 format_bytes,
46 module_from_path,
47 parse_metadata,
48 resolve_env_secrets,
49 sqlite3,
50 to_css_class,
51)
52from .utils.asgi import (
53 AsgiLifespan,
54 Forbidden,
55 NotFound,
56 Request,
57 Response,
58 asgi_static,
59 asgi_send,
60 asgi_send_html,
61 asgi_send_json,
62 asgi_send_redirect,
63)
64from .tracer import AsgiTracer
65from .plugins import pm, DEFAULT_PLUGINS, get_plugins
66from .version import __version__
68app_root = Path(__file__).parent.parent
70MEMORY = object()
72ConfigOption = collections.namedtuple("ConfigOption", ("name", "default", "help"))
73CONFIG_OPTIONS = (
74 ConfigOption("default_page_size", 100, "Default page size for the table view"),
75 ConfigOption(
76 "max_returned_rows",
77 1000,
78 "Maximum rows that can be returned from a table or custom query",
79 ),
80 ConfigOption(
81 "num_sql_threads",
82 3,
83 "Number of threads in the thread pool for executing SQLite queries",
84 ),
85 ConfigOption(
86 "sql_time_limit_ms", 1000, "Time limit for a SQL query in milliseconds"
87 ),
88 ConfigOption(
89 "default_facet_size", 30, "Number of values to return for requested facets"
90 ),
91 ConfigOption(
92 "facet_time_limit_ms", 200, "Time limit for calculating a requested facet"
93 ),
94 ConfigOption(
95 "facet_suggest_time_limit_ms",
96 50,
97 "Time limit for calculating a suggested facet",
98 ),
99 ConfigOption(
100 "hash_urls",
101 False,
102 "Include DB file contents hash in URLs, for far-future caching",
103 ),
104 ConfigOption(
105 "allow_facet",
106 True,
107 "Allow users to specify columns to facet using ?_facet= parameter",
108 ),
109 ConfigOption(
110 "allow_download",
111 True,
112 "Allow users to download the original SQLite database files",
113 ),
114 ConfigOption("suggest_facets", True, "Calculate and display suggested facets"),
115 ConfigOption(
116 "default_cache_ttl",
117 5,
118 "Default HTTP cache TTL (used in Cache-Control: max-age= header)",
119 ),
120 ConfigOption(
121 "default_cache_ttl_hashed",
122 365 * 24 * 60 * 60,
123 "Default HTTP cache TTL for hashed URL pages",
124 ),
125 ConfigOption(
126 "cache_size_kb", 0, "SQLite cache size in KB (0 == use SQLite default)"
127 ),
128 ConfigOption(
129 "allow_csv_stream",
130 True,
131 "Allow .csv?_stream=1 to download all rows (ignoring max_returned_rows)",
132 ),
133 ConfigOption(
134 "max_csv_mb",
135 100,
136 "Maximum size allowed for CSV export in MB - set 0 to disable this limit",
137 ),
138 ConfigOption(
139 "truncate_cells_html",
140 2048,
141 "Truncate cells longer than this in HTML table view - set 0 to disable",
142 ),
143 ConfigOption(
144 "force_https_urls",
145 False,
146 "Force URLs in API output to always use https:// protocol",
147 ),
148 ConfigOption(
149 "template_debug",
150 False,
151 "Allow display of template debug information with ?_context=1",
152 ),
153 ConfigOption("base_url", "/", "Datasette URLs should use this base"),
154)
156DEFAULT_CONFIG = {option.name: option.default for option in CONFIG_OPTIONS}
159async def favicon(scope, receive, send):
160 await asgi_send(send, "", 200)
163class Datasette:
164 # Message constants:
165 INFO = 1
166 WARNING = 2
167 ERROR = 3
169 def __init__(
170 self,
171 files,
172 immutables=None,
173 cache_headers=True,
174 cors=False,
175 inspect_data=None,
176 metadata=None,
177 sqlite_extensions=None,
178 template_dir=None,
179 plugins_dir=None,
180 static_mounts=None,
181 memory=False,
182 config=None,
183 secret=None,
184 version_note=None,
185 config_dir=None,
186 ):
187 assert config_dir is None or isinstance(
188 config_dir, Path
189 ), "config_dir= should be a pathlib.Path"
190 self._secret = secret or secrets.token_hex(32)
191 self.files = tuple(files) + tuple(immutables or [])
192 if config_dir:
193 self.files += tuple([str(p) for p in config_dir.glob("*.db")])
194 if (
195 config_dir
196 and (config_dir / "inspect-data.json").exists()
197 and not inspect_data
198 ):
199 inspect_data = json.load((config_dir / "inspect-data.json").open())
200 if immutables is None:
201 immutable_filenames = [i["file"] for i in inspect_data.values()]
202 immutables = [
203 f for f in self.files if Path(f).name in immutable_filenames
204 ]
205 self.inspect_data = inspect_data
206 self.immutables = set(immutables or [])
207 if not self.files:
208 self.files = [MEMORY]
209 elif memory:
210 self.files = (MEMORY,) + self.files
211 self.databases = collections.OrderedDict()
212 for file in self.files:
213 path = file
214 is_memory = False
215 if file is MEMORY:
216 path = None
217 is_memory = True
218 is_mutable = path not in self.immutables
219 db = Database(self, path, is_mutable=is_mutable, is_memory=is_memory)
220 if db.name in self.databases:
221 raise Exception("Multiple files with same stem: {}".format(db.name))
222 self.add_database(db.name, db)
223 self.cache_headers = cache_headers
224 self.cors = cors
225 metadata_files = []
226 if config_dir:
227 metadata_files = [
228 config_dir / filename
229 for filename in ("metadata.json", "metadata.yaml", "metadata.yml")
230 if (config_dir / filename).exists()
231 ]
232 if config_dir and metadata_files and not metadata:
233 with metadata_files[0].open() as fp:
234 metadata = parse_metadata(fp.read())
235 self._metadata = metadata or {}
236 self.sqlite_functions = []
237 self.sqlite_extensions = sqlite_extensions or []
238 if config_dir and (config_dir / "templates").is_dir() and not template_dir:
239 template_dir = str((config_dir / "templates").resolve())
240 self.template_dir = template_dir
241 if config_dir and (config_dir / "plugins").is_dir() and not plugins_dir:
242 plugins_dir = str((config_dir / "plugins").resolve())
243 self.plugins_dir = plugins_dir
244 if config_dir and (config_dir / "static").is_dir() and not static_mounts:
245 static_mounts = [("static", str((config_dir / "static").resolve()))]
246 self.static_mounts = static_mounts or []
247 if config_dir and (config_dir / "config.json").exists() and not config:
248 config = json.load((config_dir / "config.json").open())
249 self._config = dict(DEFAULT_CONFIG, **(config or {}))
250 self.renderers = {} # File extension -> (renderer, can_render) functions
251 self.version_note = version_note
252 self.executor = futures.ThreadPoolExecutor(
253 max_workers=self.config("num_sql_threads")
254 )
255 self.max_returned_rows = self.config("max_returned_rows")
256 self.sql_time_limit_ms = self.config("sql_time_limit_ms")
257 self.page_size = self.config("default_page_size")
258 # Execute plugins in constructor, to ensure they are available
259 # when the rest of `datasette inspect` executes
260 if self.plugins_dir:
261 for filename in os.listdir(self.plugins_dir):
262 filepath = os.path.join(self.plugins_dir, filename)
263 mod = module_from_path(filepath, name=filename)
264 try:
265 pm.register(mod)
266 except ValueError:
267 # Plugin already registered
268 pass
270 # Configure Jinja
271 default_templates = str(app_root / "datasette" / "templates")
272 template_paths = []
273 if self.template_dir:
274 template_paths.append(self.template_dir)
275 plugin_template_paths = [
276 plugin["templates_path"]
277 for plugin in get_plugins()
278 if plugin["templates_path"]
279 ]
280 template_paths.extend(plugin_template_paths)
281 template_paths.append(default_templates)
282 template_loader = ChoiceLoader(
283 [
284 FileSystemLoader(template_paths),
285 # Support {% extends "default:table.html" %}:
286 PrefixLoader(
287 {"default": FileSystemLoader(default_templates)}, delimiter=":"
288 ),
289 ]
290 )
291 self.jinja_env = Environment(
292 loader=template_loader, autoescape=True, enable_async=True
293 )
294 self.jinja_env.filters["escape_css_string"] = escape_css_string
295 self.jinja_env.filters["quote_plus"] = lambda u: urllib.parse.quote_plus(u)
296 self.jinja_env.filters["escape_sqlite"] = escape_sqlite
297 self.jinja_env.filters["to_css_class"] = to_css_class
298 # pylint: disable=no-member
299 pm.hook.prepare_jinja2_environment(env=self.jinja_env)
301 self._register_renderers()
302 self._permission_checks = collections.deque(maxlen=200)
303 self._root_token = secrets.token_hex(32)
305 async def invoke_startup(self):
306 for hook in pm.hook.startup(datasette=self):
307 if callable(hook):
308 hook = hook()
309 if asyncio.iscoroutine(hook):
310 hook = await hook
312 def sign(self, value, namespace="default"):
313 return URLSafeSerializer(self._secret, namespace).dumps(value)
315 def unsign(self, signed, namespace="default"):
316 return URLSafeSerializer(self._secret, namespace).loads(signed)
318 def get_database(self, name=None):
319 if name is None:
320 return next(iter(self.databases.values()))
321 return self.databases[name]
323 def add_database(self, name, db):
324 self.databases[name] = db
326 def remove_database(self, name):
327 self.databases.pop(name)
329 def config(self, key):
330 return self._config.get(key, None)
332 def config_dict(self):
333 # Returns a fully resolved config dictionary, useful for templates
334 return {option.name: self.config(option.name) for option in CONFIG_OPTIONS}
336 def metadata(self, key=None, database=None, table=None, fallback=True):
337 """
338 Looks up metadata, cascading backwards from specified level.
339 Returns None if metadata value is not found.
340 """
341 assert not (
342 database is None and table is not None
343 ), "Cannot call metadata() with table= specified but not database="
344 databases = self._metadata.get("databases") or {}
345 search_list = []
346 if database is not None:
347 search_list.append(databases.get(database) or {})
348 if table is not None:
349 table_metadata = ((databases.get(database) or {}).get("tables") or {}).get(
350 table
351 ) or {}
352 search_list.insert(0, table_metadata)
353 search_list.append(self._metadata)
354 if not fallback:
355 # No fallback allowed, so just use the first one in the list
356 search_list = search_list[:1]
357 if key is not None:
358 for item in search_list:
359 if key in item:
360 return item[key]
361 return None
362 else:
363 # Return the merged list
364 m = {}
365 for item in search_list:
366 m.update(item)
367 return m
369 def plugin_config(self, plugin_name, database=None, table=None, fallback=True):
370 "Return config for plugin, falling back from specified database/table"
371 plugins = self.metadata(
372 "plugins", database=database, table=table, fallback=fallback
373 )
374 if plugins is None:
375 return None
376 plugin_config = plugins.get(plugin_name)
377 # Resolve any $file and $env keys
378 plugin_config = resolve_env_secrets(plugin_config, os.environ)
379 return plugin_config
381 def app_css_hash(self):
382 if not hasattr(self, "_app_css_hash"):
383 self._app_css_hash = hashlib.sha1(
384 open(os.path.join(str(app_root), "datasette/static/app.css"))
385 .read()
386 .encode("utf8")
387 ).hexdigest()[:6]
388 return self._app_css_hash
390 def get_canned_queries(self, database_name):
391 queries = self.metadata("queries", database=database_name, fallback=False) or {}
392 names = queries.keys()
393 return [self.get_canned_query(database_name, name) for name in names]
395 def get_canned_query(self, database_name, query_name):
396 queries = self.metadata("queries", database=database_name, fallback=False) or {}
397 query = queries.get(query_name)
398 if query:
399 if not isinstance(query, dict):
400 query = {"sql": query}
401 query["name"] = query_name
402 return query
404 def update_with_inherited_metadata(self, metadata):
405 # Fills in source/license with defaults, if available
406 metadata.update(
407 {
408 "source": metadata.get("source") or self.metadata("source"),
409 "source_url": metadata.get("source_url") or self.metadata("source_url"),
410 "license": metadata.get("license") or self.metadata("license"),
411 "license_url": metadata.get("license_url")
412 or self.metadata("license_url"),
413 "about": metadata.get("about") or self.metadata("about"),
414 "about_url": metadata.get("about_url") or self.metadata("about_url"),
415 }
416 )
418 def _prepare_connection(self, conn, database):
419 conn.row_factory = sqlite3.Row
420 conn.text_factory = lambda x: str(x, "utf-8", "replace")
421 for name, num_args, func in self.sqlite_functions:
422 conn.create_function(name, num_args, func)
423 if self.sqlite_extensions:
424 conn.enable_load_extension(True)
425 for extension in self.sqlite_extensions:
426 conn.execute("SELECT load_extension('{}')".format(extension))
427 if self.config("cache_size_kb"):
428 conn.execute("PRAGMA cache_size=-{}".format(self.config("cache_size_kb")))
429 # pylint: disable=no-member
430 pm.hook.prepare_connection(conn=conn, database=database, datasette=self)
432 def add_message(self, request, message, type=INFO):
433 if not hasattr(request, "_messages"):
434 request._messages = []
435 request._messages_should_clear = False
436 request._messages.append((message, type))
438 def _write_messages_to_response(self, request, response):
439 if getattr(request, "_messages", None):
440 # Set those messages
441 response.set_cookie("ds_messages", self.sign(request._messages, "messages"))
442 elif getattr(request, "_messages_should_clear", False):
443 response.set_cookie("ds_messages", "", expires=0, max_age=0)
445 def _show_messages(self, request):
446 if getattr(request, "_messages", None):
447 request._messages_should_clear = True
448 messages = request._messages
449 request._messages = []
450 return messages
451 else:
452 return []
454 async def permission_allowed(self, actor, action, resource=None, default=False):
455 "Check permissions using the permissions_allowed plugin hook"
456 result = None
457 for check in pm.hook.permission_allowed(
458 datasette=self, actor=actor, action=action, resource=resource,
459 ):
460 if callable(check):
461 check = check()
462 if asyncio.iscoroutine(check):
463 check = await check
464 if check is not None:
465 result = check
466 used_default = False
467 if result is None:
468 result = default
469 used_default = True
470 self._permission_checks.append(
471 {
472 "when": datetime.datetime.utcnow().isoformat(),
473 "actor": actor,
474 "action": action,
475 "resource": resource,
476 "used_default": used_default,
477 "result": result,
478 }
479 )
480 return result
482 async def execute(
483 self,
484 db_name,
485 sql,
486 params=None,
487 truncate=False,
488 custom_time_limit=None,
489 page_size=None,
490 log_sql_errors=True,
491 ):
492 return await self.databases[db_name].execute(
493 sql,
494 params=params,
495 truncate=truncate,
496 custom_time_limit=custom_time_limit,
497 page_size=page_size,
498 log_sql_errors=log_sql_errors,
499 )
501 async def expand_foreign_keys(self, database, table, column, values):
502 "Returns dict mapping (column, value) -> label"
503 labeled_fks = {}
504 db = self.databases[database]
505 foreign_keys = await db.foreign_keys_for_table(table)
506 # Find the foreign_key for this column
507 try:
508 fk = [
509 foreign_key
510 for foreign_key in foreign_keys
511 if foreign_key["column"] == column
512 ][0]
513 except IndexError:
514 return {}
515 label_column = await db.label_column_for_table(fk["other_table"])
516 if not label_column:
517 return {(fk["column"], value): str(value) for value in values}
518 labeled_fks = {}
519 sql = """
520 select {other_column}, {label_column}
521 from {other_table}
522 where {other_column} in ({placeholders})
523 """.format(
524 other_column=escape_sqlite(fk["other_column"]),
525 label_column=escape_sqlite(label_column),
526 other_table=escape_sqlite(fk["other_table"]),
527 placeholders=", ".join(["?"] * len(set(values))),
528 )
529 try:
530 results = await self.execute(database, sql, list(set(values)))
531 except QueryInterrupted:
532 pass
533 else:
534 for id, value in results:
535 labeled_fks[(fk["column"], id)] = value
536 return labeled_fks
538 def absolute_url(self, request, path):
539 url = urllib.parse.urljoin(request.url, path)
540 if url.startswith("http://") and self.config("force_https_urls"):
541 url = "https://" + url[len("http://") :]
542 return url
544 def _register_custom_units(self):
545 "Register any custom units defined in the metadata.json with Pint"
546 for unit in self.metadata("custom_units") or []:
547 ureg.define(unit)
549 def _connected_databases(self):
550 return [
551 {
552 "name": d.name,
553 "path": d.path,
554 "size": d.size,
555 "is_mutable": d.is_mutable,
556 "is_memory": d.is_memory,
557 "hash": d.hash,
558 }
559 for d in sorted(self.databases.values(), key=lambda d: d.name)
560 ]
562 def _versions(self):
563 conn = sqlite3.connect(":memory:")
564 self._prepare_connection(conn, ":memory:")
565 sqlite_version = conn.execute("select sqlite_version()").fetchone()[0]
566 sqlite_extensions = {}
567 for extension, testsql, hasversion in (
568 ("json1", "SELECT json('{}')", False),
569 ("spatialite", "SELECT spatialite_version()", True),
570 ):
571 try:
572 result = conn.execute(testsql)
573 if hasversion:
574 sqlite_extensions[extension] = result.fetchone()[0]
575 else:
576 sqlite_extensions[extension] = None
577 except Exception:
578 pass
579 # Figure out supported FTS versions
580 fts_versions = []
581 for fts in ("FTS5", "FTS4", "FTS3"):
582 try:
583 conn.execute(
584 "CREATE VIRTUAL TABLE v{fts} USING {fts} (data)".format(fts=fts)
585 )
586 fts_versions.append(fts)
587 except sqlite3.OperationalError:
588 continue
589 datasette_version = {"version": __version__}
590 if self.version_note:
591 datasette_version["note"] = self.version_note
592 return {
593 "python": {
594 "version": ".".join(map(str, sys.version_info[:3])),
595 "full": sys.version,
596 },
597 "datasette": datasette_version,
598 "asgi": "3.0",
599 "uvicorn": uvicorn.__version__,
600 "sqlite": {
601 "version": sqlite_version,
602 "fts_versions": fts_versions,
603 "extensions": sqlite_extensions,
604 "compile_options": [
605 r[0] for r in conn.execute("pragma compile_options;").fetchall()
606 ],
607 },
608 }
610 def _plugins(self, request=None, all=False):
611 ps = list(get_plugins())
612 should_show_all = False
613 if request is not None:
614 should_show_all = request.args.get("all")
615 else:
616 should_show_all = all
617 if not should_show_all:
618 ps = [p for p in ps if p["name"] not in DEFAULT_PLUGINS]
619 return [
620 {
621 "name": p["name"],
622 "static": p["static_path"] is not None,
623 "templates": p["templates_path"] is not None,
624 "version": p.get("version"),
625 "hooks": p["hooks"],
626 }
627 for p in ps
628 ]
630 def _threads(self):
631 threads = list(threading.enumerate())
632 d = {
633 "num_threads": len(threads),
634 "threads": [
635 {"name": t.name, "ident": t.ident, "daemon": t.daemon} for t in threads
636 ],
637 }
638 # Only available in Python 3.7+
639 if hasattr(asyncio, "all_tasks"):
640 tasks = asyncio.all_tasks()
641 d.update(
642 {
643 "num_tasks": len(tasks),
644 "tasks": [_cleaner_task_str(t) for t in tasks],
645 }
646 )
647 return d
649 def _actor(self, request):
650 return {"actor": request.actor}
652 def table_metadata(self, database, table):
653 "Fetch table-specific metadata."
654 return (
655 (self.metadata("databases") or {})
656 .get(database, {})
657 .get("tables", {})
658 .get(table, {})
659 )
661 def _register_renderers(self):
662 """ Register output renderers which output data in custom formats. """
663 # Built-in renderers
664 self.renderers["json"] = (json_renderer, lambda: True)
666 # Hooks
667 hook_renderers = []
668 # pylint: disable=no-member
669 for hook in pm.hook.register_output_renderer(datasette=self):
670 if type(hook) == list:
671 hook_renderers += hook
672 else:
673 hook_renderers.append(hook)
675 for renderer in hook_renderers:
676 self.renderers[renderer["extension"]] = (
677 # It used to be called "callback" - remove this in Datasette 1.0
678 renderer.get("render") or renderer["callback"],
679 renderer.get("can_render") or (lambda: True),
680 )
682 async def render_template(
683 self, templates, context=None, request=None, view_name=None
684 ):
685 context = context or {}
686 if isinstance(templates, Template):
687 template = templates
688 else:
689 if isinstance(templates, str):
690 templates = [templates]
691 template = self.jinja_env.select_template(templates)
692 body_scripts = []
693 # pylint: disable=no-member
694 for script in pm.hook.extra_body_script(
695 template=template.name,
696 database=context.get("database"),
697 table=context.get("table"),
698 view_name=view_name,
699 datasette=self,
700 ):
701 body_scripts.append(Markup(script))
703 extra_template_vars = {}
704 # pylint: disable=no-member
705 for extra_vars in pm.hook.extra_template_vars(
706 template=template.name,
707 database=context.get("database"),
708 table=context.get("table"),
709 view_name=view_name,
710 request=request,
711 datasette=self,
712 ):
713 if callable(extra_vars):
714 extra_vars = extra_vars()
715 if asyncio.iscoroutine(extra_vars):
716 extra_vars = await extra_vars
717 assert isinstance(extra_vars, dict), "extra_vars is of type {}".format(
718 type(extra_vars)
719 )
720 extra_template_vars.update(extra_vars)
722 template_context = {
723 **context,
724 **{
725 "app_css_hash": self.app_css_hash(),
726 "zip": zip,
727 "body_scripts": body_scripts,
728 "format_bytes": format_bytes,
729 "extra_css_urls": self._asset_urls("extra_css_urls", template, context),
730 "extra_js_urls": self._asset_urls("extra_js_urls", template, context),
731 "base_url": self.config("base_url"),
732 },
733 **extra_template_vars,
734 }
735 if request and request.args.get("_context") and self.config("template_debug"):
736 return "<pre>{}</pre>".format(
737 jinja2.escape(json.dumps(template_context, default=repr, indent=4))
738 )
740 return await template.render_async(template_context)
742 def _asset_urls(self, key, template, context):
743 # Flatten list-of-lists from plugins:
744 seen_urls = set()
745 for url_or_dict in itertools.chain(
746 itertools.chain.from_iterable(
747 getattr(pm.hook, key)(
748 template=template.name,
749 database=context.get("database"),
750 table=context.get("table"),
751 datasette=self,
752 )
753 ),
754 (self.metadata(key) or []),
755 ):
756 if isinstance(url_or_dict, dict):
757 url = url_or_dict["url"]
758 sri = url_or_dict.get("sri")
759 else:
760 url = url_or_dict
761 sri = None
762 if url in seen_urls:
763 continue
764 seen_urls.add(url)
765 if sri:
766 yield {"url": url, "sri": sri}
767 else:
768 yield {"url": url}
770 def app(self):
771 "Returns an ASGI app function that serves the whole of Datasette"
772 routes = []
774 for routes_to_add in pm.hook.register_routes():
775 for regex, view_fn in routes_to_add:
776 routes.append((regex, wrap_view(view_fn, self)))
778 def add_route(view, regex):
779 routes.append((regex, view))
781 # Generate a regex snippet to match all registered renderer file extensions
782 renderer_regex = "|".join(r"\." + key for key in self.renderers.keys())
784 add_route(IndexView.as_asgi(self), r"/(?P<as_format>(\.jsono?)?$)")
785 # TODO: /favicon.ico and /-/static/ deserve far-future cache expires
786 add_route(favicon, "/favicon.ico")
788 add_route(
789 asgi_static(app_root / "datasette" / "static"), r"/-/static/(?P<path>.*)$"
790 )
791 for path, dirname in self.static_mounts:
792 add_route(asgi_static(dirname), r"/" + path + "/(?P<path>.*)$")
794 # Mount any plugin static/ directories
795 for plugin in get_plugins():
796 if plugin["static_path"]:
797 add_route(
798 asgi_static(plugin["static_path"]),
799 "/-/static-plugins/{}/(?P<path>.*)$".format(plugin["name"]),
800 )
801 # Support underscores in name in addition to hyphens, see https://github.com/simonw/datasette/issues/611
802 add_route(
803 asgi_static(plugin["static_path"]),
804 "/-/static-plugins/{}/(?P<path>.*)$".format(
805 plugin["name"].replace("-", "_")
806 ),
807 )
808 add_route(
809 JsonDataView.as_asgi(self, "metadata.json", lambda: self._metadata),
810 r"/-/metadata(?P<as_format>(\.json)?)$",
811 )
812 add_route(
813 JsonDataView.as_asgi(self, "versions.json", self._versions),
814 r"/-/versions(?P<as_format>(\.json)?)$",
815 )
816 add_route(
817 JsonDataView.as_asgi(
818 self, "plugins.json", self._plugins, needs_request=True
819 ),
820 r"/-/plugins(?P<as_format>(\.json)?)$",
821 )
822 add_route(
823 JsonDataView.as_asgi(self, "config.json", lambda: self._config),
824 r"/-/config(?P<as_format>(\.json)?)$",
825 )
826 add_route(
827 JsonDataView.as_asgi(self, "threads.json", self._threads),
828 r"/-/threads(?P<as_format>(\.json)?)$",
829 )
830 add_route(
831 JsonDataView.as_asgi(self, "databases.json", self._connected_databases),
832 r"/-/databases(?P<as_format>(\.json)?)$",
833 )
834 add_route(
835 JsonDataView.as_asgi(self, "actor.json", self._actor, needs_request=True),
836 r"/-/actor(?P<as_format>(\.json)?)$",
837 )
838 add_route(
839 AuthTokenView.as_asgi(self), r"/-/auth-token$",
840 )
841 add_route(
842 PermissionsDebugView.as_asgi(self), r"/-/permissions$",
843 )
844 add_route(
845 MessagesDebugView.as_asgi(self), r"/-/messages$",
846 )
847 add_route(
848 PatternPortfolioView.as_asgi(self), r"/-/patterns$",
849 )
850 add_route(
851 DatabaseDownload.as_asgi(self), r"/(?P<db_name>[^/]+?)(?P<as_db>\.db)$"
852 )
853 add_route(
854 DatabaseView.as_asgi(self),
855 r"/(?P<db_name>[^/]+?)(?P<as_format>"
856 + renderer_regex
857 + r"|.jsono|\.csv)?$",
858 )
859 add_route(
860 TableView.as_asgi(self),
861 r"/(?P<db_name>[^/]+)/(?P<table_and_format>[^/]+?$)",
862 )
863 add_route(
864 RowView.as_asgi(self),
865 r"/(?P<db_name>[^/]+)/(?P<table>[^/]+?)/(?P<pk_path>[^/]+?)(?P<as_format>"
866 + renderer_regex
867 + r")?$",
868 )
869 self._register_custom_units()
871 async def setup_db():
872 # First time server starts up, calculate table counts for immutable databases
873 for dbname, database in self.databases.items():
874 if not database.is_mutable:
875 await database.table_counts(limit=60 * 60 * 1000)
877 asgi = AsgiLifespan(
878 AsgiTracer(
879 asgi_csrf.asgi_csrf(
880 DatasetteRouter(self, routes),
881 signing_secret=self._secret,
882 cookie_name="ds_csrftoken",
883 )
884 ),
885 on_startup=setup_db,
886 )
887 for wrapper in pm.hook.asgi_wrapper(datasette=self):
888 asgi = wrapper(asgi)
889 return asgi
892class DatasetteRouter(AsgiRouter):
893 def __init__(self, datasette, routes):
894 self.ds = datasette
895 super().__init__(routes)
897 async def route_path(self, scope, receive, send, path):
898 # Strip off base_url if present before routing
899 base_url = self.ds.config("base_url")
900 if base_url != "/" and path.startswith(base_url):
901 path = "/" + path[len(base_url) :]
902 scope_modifications = {}
903 # Apply force_https_urls, if set
904 if (
905 self.ds.config("force_https_urls")
906 and scope["type"] == "http"
907 and scope.get("scheme") != "https"
908 ):
909 scope_modifications["scheme"] = "https"
910 # Handle authentication
911 actor = None
912 for actor in pm.hook.actor_from_request(
913 datasette=self.ds, request=Request(scope, receive)
914 ):
915 if callable(actor):
916 actor = actor()
917 if asyncio.iscoroutine(actor):
918 actor = await actor
919 if actor:
920 break
921 scope_modifications["actor"] = actor
922 return await super().route_path(
923 dict(scope, **scope_modifications), receive, send, path
924 )
926 async def handle_404(self, scope, receive, send, exception=None):
927 # If URL has a trailing slash, redirect to URL without it
928 path = scope.get("raw_path", scope["path"].encode("utf8"))
929 if path.endswith(b"/"):
930 path = path.rstrip(b"/")
931 if scope["query_string"]:
932 path += b"?" + scope["query_string"]
933 await asgi_send_redirect(send, path.decode("latin1"))
934 else:
935 # Is there a pages/* template matching this path?
936 template_path = os.path.join("pages", *scope["path"].split("/")) + ".html"
937 try:
938 template = self.ds.jinja_env.select_template([template_path])
939 except TemplateNotFound:
940 template = None
941 if template:
942 headers = {}
943 status = [200]
945 def custom_header(name, value):
946 headers[name] = value
947 return ""
949 def custom_status(code):
950 status[0] = code
951 return ""
953 def custom_redirect(location, code=302):
954 status[0] = code
955 headers["Location"] = location
956 return ""
958 body = await self.ds.render_template(
959 template,
960 {
961 "custom_header": custom_header,
962 "custom_status": custom_status,
963 "custom_redirect": custom_redirect,
964 },
965 request=Request(scope, receive),
966 view_name="page",
967 )
968 # Pull content-type out into separate parameter
969 content_type = "text/html; charset=utf-8"
970 matches = [k for k in headers if k.lower() == "content-type"]
971 if matches:
972 content_type = headers[matches[0]]
973 await asgi_send(
974 send,
975 body,
976 status=status[0],
977 headers=headers,
978 content_type=content_type,
979 )
980 else:
981 await self.handle_500(
982 scope, receive, send, exception or NotFound("404")
983 )
985 async def handle_500(self, scope, receive, send, exception):
986 title = None
987 if isinstance(exception, NotFound):
988 status = 404
989 info = {}
990 message = exception.args[0]
991 elif isinstance(exception, Forbidden):
992 status = 403
993 info = {}
994 message = exception.args[0]
995 elif isinstance(exception, DatasetteError):
996 status = exception.status
997 info = exception.error_dict
998 message = exception.message
999 if exception.messagge_is_html:
1000 message = Markup(message)
1001 title = exception.title
1002 else:
1003 status = 500
1004 info = {}
1005 message = str(exception)
1006 traceback.print_exc()
1007 templates = ["500.html"]
1008 if status != 500:
1009 templates = ["{}.html".format(status)] + templates
1010 info.update(
1011 {"ok": False, "error": message, "status": status, "title": title,}
1012 )
1013 headers = {}
1014 if self.ds.cors:
1015 headers["Access-Control-Allow-Origin"] = "*"
1016 if scope["path"].split("?")[0].endswith(".json"):
1017 await asgi_send_json(send, info, status=status, headers=headers)
1018 else:
1019 template = self.ds.jinja_env.select_template(templates)
1020 await asgi_send_html(
1021 send,
1022 await template.render_async(
1023 dict(
1024 info,
1025 base_url=self.ds.config("base_url"),
1026 app_css_hash=self.ds.app_css_hash(),
1027 )
1028 ),
1029 status=status,
1030 headers=headers,
1031 )
1034_cleaner_task_str_re = re.compile(r"\S*site-packages/")
1037def _cleaner_task_str(task):
1038 s = str(task)
1039 # This has something like the following in it:
1040 # running at /Users/simonw/Dropbox/Development/datasette/venv-3.7.5/lib/python3.7/site-packages/uvicorn/main.py:361>
1041 # Clean up everything up to and including site-packages
1042 return _cleaner_task_str_re.sub("", s)
1045def wrap_view(view_fn, datasette):
1046 async def asgi_view_fn(scope, receive, send):
1047 response = await async_call_with_supported_arguments(
1048 view_fn,
1049 scope=scope,
1050 receive=receive,
1051 send=send,
1052 request=Request(scope, receive),
1053 datasette=datasette,
1054 )
1055 if response is not None:
1056 await response.asgi_send(send)
1058 return asgi_view_fn