Repository: simonw/datasette

Issue:
JSON API: Does not support `Content-Type: application/json; charset=utf-8`
The main branch of Datasette 1.0a does NOT support `Content-Type: application/json; charset=utf-8`:

Actual Result:
```sh
POST /db/messages/-/insert HTTP/1.1
Content-Type: application/json; charset=utf-8
Content-Length: 117
Host: 10.0.2.2:8001
Connection: Keep-Alive
Accept-Encoding: gzip
User-Agent: okhttp/4.12.0

{"row":{"body":"my feedback","body_format":"T","from_email":"bk@bk.com","from_name":"BK","title":"Contact Feedback"}}
-----
HTTP/1.1 400 Bad Request
date: Tue, 06 Aug 2024 04:24:45 GMT
server: uvicorn
content-type: application/json; charset=utf-8
Transfer-Encoding: chunked

{"ok": false, "errors": ["Invalid content-type, must be application/json"]}
```

Expected Result:
```sh
POST /db/messages/-/insert HTTP/1.1
Content-Type: application/json
Content-Length: 117
Host: 10.0.2.2:8001
Connection: Keep-Alive
Accept-Encoding: gzip
User-Agent: okhttp/4.12.0

{"row":{"body":"my feedback","body_format":"T","from_email":"bk@bk.com","from_name":"BK","title":"Contact Feedback"}}
------
HTTP/1.1 201 Created
date: Tue, 06 Aug 2024 04:32:57 GMT
server: uvicorn
content-type: application/json; charset=utf-8
Transfer-Encoding: chunked

{"ok": true, "rows": [{"id": 13, "from_user_id": null, "from_name": "BK", "from_email": "bk@bk.com", "to_user_id": null, "title": "Contact Feedback", "body": "my feedback", "body_format": "T", "sent_on_utc": "2024-08-06 04:32:58"}]}
```

The bug is on the line: https://github.com/simonw/datasette/blob/f6bd2bf8b025dcee49248ae7224e242b448f558c/datasette/views/table.py#L367

Discussion:

Patch:
diff --git a/datasette/app.py b/datasette/app.py
index 1f9e9d30..1363bc5c 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -1,3 +1,4 @@
+from asgi_csrf import Errors
 import asyncio
 from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union
 import asgi_csrf
@@ -1476,6 +1477,8 @@ class Datasette:
             routes.append((regex, view))
 
         add_route(IndexView.as_view(self), r"/(\.(?P<format>jsono?))?$")
+        add_route(IndexView.as_view(self), r"/-/(\.(?P<format>jsono?))?$")
+        add_route(permanent_redirect("/-/"), r"/-$")
         # TODO: /favicon.ico and /-/static/ deserve far-future cache expires
         add_route(favicon, "/favicon.ico")
 
@@ -1655,6 +1658,16 @@ class Datasette:
                 if not database.is_mutable:
                     await database.table_counts(limit=60 * 60 * 1000)
 
+        async def custom_csrf_error(scope, send, message_id):
+            await asgi_send(
+                send,
+                await self.render_template(
+                    "csrf_error.html",
+                    {"message_id": message_id, "message_name": Errors(message_id).name},
+                ),
+                403,
+            )
+
         asgi = asgi_csrf.asgi_csrf(
             DatasetteRouter(self, routes),
             signing_secret=self._secret,
@@ -1662,6 +1675,7 @@ class Datasette:
             skip_if_scope=lambda scope: any(
                 pm.hook.skip_csrf(datasette=self, scope=scope)
             ),
+            send_csrf_failed=custom_csrf_error,
         )
         if self.setting("trace_debug"):
             asgi = AsgiTracer(asgi)
diff --git a/datasette/templates/csrf_error.html b/datasette/templates/csrf_error.html
new file mode 100644
index 00000000..7cd4b42b
--- /dev/null
+++ b/datasette/templates/csrf_error.html
@@ -0,0 +1,13 @@
+{% extends "base.html" %}
+{% block title %}CSRF check failed){% endblock %}
+{% block content %}
+<h1>Form origin check failed</h1>
+
+<p>Your request's origin could not be validated. Please return to the form and submit it again.</p>
+
+<details><summary>Technical details</summary>
+  <p>Developers: consult Datasette's <a href="https://docs.datasette.io/en/latest/internals.html#csrf-protection">CSRF protection documentation</a>.</p>
+  <p>Error code is {{ message_name }}.</p>
+</details>
+
+{% endblock %}
diff --git a/datasette/templates/index.html b/datasette/templates/index.html
index 6e95126d..a3595a39 100644
--- a/datasette/templates/index.html
+++ b/datasette/templates/index.html
@@ -2,6 +2,10 @@
 
 {% block title %}{{ metadata.title or "Datasette" }}: {% for database in databases %}{{ database.name }}{% if not loop.last %}, {% endif %}{% endfor %}{% endblock %}
 
+{% block extra_head %}
+{% if noindex %}<meta name="robots" content="noindex">{% endif %}
+{% endblock %}
+
 {% block body_class %}index{% endblock %}
 
 {% block content %}
diff --git a/datasette/views/index.py b/datasette/views/index.py
index a3178f53..63cc067d 100644
--- a/datasette/views/index.py
+++ b/datasette/views/index.py
@@ -152,8 +152,9 @@ class IndexView(BaseView):
                 extra_links = await await_me_maybe(hook)
                 if extra_links:
                     homepage_actions.extend(extra_links)
+            alternative_homepage = request.path == "/-/"
             return await self.render(
-                ["index.html"],
+                ["default:index.html" if alternative_homepage else "index.html"],
                 request=request,
                 context={
                     "databases": databases,
@@ -166,5 +167,6 @@ class IndexView(BaseView):
                         "top_homepage", self.ds, request
                     ),
                     "homepage_actions": homepage_actions,
+                    "noindex": request.path == "/-/",
                 },
             )
diff --git a/datasette/views/table.py b/datasette/views/table.py
index fa2c80de..ba0dd4f3 100644
--- a/datasette/views/table.py
+++ b/datasette/views/table.py
@@ -364,7 +364,7 @@ class TableInsertView(BaseView):
         def _errors(errors):
             return None, errors, {}
 
-        if request.headers.get("content-type") != "application/json":
+        if not request.headers.get("content-type").startswith("application/json"):
             # TODO: handle form-encoded data
             return _errors(["Invalid content-type, must be application/json"])
         body = await request.post_body()
diff --git a/docs/pages.rst b/docs/pages.rst
index 239c9f80..78d5520f 100644
--- a/docs/pages.rst
+++ b/docs/pages.rst
@@ -23,6 +23,8 @@ Add ``/.json`` to the end of the URL for the JSON version of the underlying data
 * `global-power-plants.datasettes.com/.json <https://global-power-plants.datasettes.com/.json>`_
 * `register-of-members-interests.datasettes.com/.json <https://register-of-members-interests.datasettes.com/.json>`_
 
+The index page can also be accessed at ``/-/``, useful for if the default index page has been replaced using an :ref:`index.html custom template <customization_custom_templates>`. The ``/-/`` page will always render the default Datasette ``index.html`` template.
+
 .. _DatabaseView:
 
 Database
diff --git a/setup.py b/setup.py
index c69404f8..923bc826 100644
--- a/setup.py
+++ b/setup.py
@@ -55,7 +55,7 @@ setup(
         "uvicorn>=0.11",
         "aiofiles>=0.4",
         "janus>=0.6.2",
-        "asgi-csrf>=0.9",
+        "asgi-csrf>=0.10",
         "PyYAML>=5.3",
         "mergedeep>=1.1.1",
         "itsdangerous>=1.1",


Test patch:
diff --git a/tests/test_html.py b/tests/test_html.py
index 5b60d2f5..c559f0c2 100644
--- a/tests/test_html.py
+++ b/tests/test_html.py
@@ -1,4 +1,6 @@
+from asgi_csrf import Errors
 from bs4 import BeautifulSoup as Soup
+from datasette.app import Datasette
 from datasette.utils import allowed_pragmas
 from .fixtures import (  # noqa
     app_client,
@@ -51,6 +53,27 @@ def test_homepage(app_client_two_attached_databases):
     ] == table_links
 
 
+@pytest.mark.asyncio
+@pytest.mark.parametrize("path", ("/", "/-/"))
+async def test_homepage_alternative_location(path, tmp_path_factory):
+    template_dir = tmp_path_factory.mktemp("templates")
+    (template_dir / "index.html").write_text("Custom homepage", "utf-8")
+    datasette = Datasette(template_dir=str(template_dir))
+    response = await datasette.client.get(path)
+    assert response.status_code == 200
+    html = response.text
+    if path == "/":
+        assert html == "Custom homepage"
+    else:
+        assert '<meta name="robots" content="noindex">' in html
+
+
+@pytest.mark.asyncio
+async def test_homepage_alternative_redirect(ds_client):
+    response = await ds_client.get("/-")
+    assert response.status_code == 301
+
+
 @pytest.mark.asyncio
 async def test_http_head(ds_client):
     response = await ds_client.head("/")
@@ -1136,3 +1159,16 @@ async def test_database_color(ds_client):
 
             pdb.set_trace()
         assert any(fragment in response.text for fragment in expected_fragments)
+
+
+@pytest.mark.asyncio
+async def test_custom_csrf_error(ds_client):
+    response = await ds_client.post(
+        "/-/messages",
+        data={
+            "message": "A message",
+        },
+        cookies={"csrftoken": "x"},
+    )
+    assert response.status_code == 403
+    assert "Error code is FORM_URLENCODED_MISMATCH." in response.text