Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/api-reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Request and response models
Pydantic models for the HTTP layer. These are what generate the OpenAPI document served
at ``/api/doc/openapi.json``, so they and the schema endpoint never disagree.

.. automodule:: goodmap.api_models
.. automodule:: goodmap.api.api_models
:members:
:show-inheritance:

Expand Down
5 changes: 4 additions & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@
_ANY_PY_ROLE = "py:.*"
nitpick_ignore_regex = [
(_ANY_PY_ROLE, r"ConfigDict|callable"),
(_ANY_PY_ROLE, r"(annotated_types|pymongo)\..*"),
(_ANY_PY_ROLE, r"(annotated_types|pymongo|spectree)\..*"),
# RootModel generics: pydantic's inventory has RootModel but not RootModel[...]
# or RootModelRootType, so the parametrised bases autodoc prints cannot resolve.
(_ANY_PY_ROLE, r"pydantic\.root_model\..*"),
(_ANY_PY_ROLE, r"[gl]e=-?\d+"),
(
_ANY_PY_ROLE,
Expand Down
8 changes: 4 additions & 4 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -187,10 +187,10 @@ frontend to decide what to render. Both are set the same way.
suggest form has no fields — see the note below.
* - ``CATEGORIES_HELP``
- both
- Enables the help-tooltip data in ``/api/categories``, ``/api/categories-full`` and
``/api/category/<name>``, and makes the frontend render the tooltips. Without it
the ``categories_help`` and ``categories_options_help`` keys in your data are
ignored. See :ref:`data-source-help`.
- Enables the help-tooltip data in ``/api/categories-full``, and makes the frontend
render the tooltips. Without it the ``categories_help`` and
``categories_options_help`` keys in your data are ignored. See
:ref:`data-source-help`.
* - ``USE_SERVER_SIDE_CLUSTERING``
- both
- The frontend fetches ``/api/locations-clustered`` instead of ``/api/locations``,
Expand Down
148 changes: 61 additions & 87 deletions docs/http-api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ A running instance also serves its own generated OpenAPI schema:
* - ``/api/doc/openapi.json``
- raw OpenAPI document

Use this page for the semantics and the schema endpoint for the exact shapes of the
release you are running.
The schema is generated from the code, so it always describes the release you are
running: every endpoint's parameters, status codes and response shapes. **Use it as the
reference.** This page covers the parts a schema cannot state — what the endpoints mean,
how filtering behaves, and which parameters depend on your own data.

Conventions
-----------
Expand All @@ -29,8 +31,10 @@ Conventions
on the ``categories`` in your data source (:doc:`data-source`).

**Writes need a CSRF token.** CSRF protection is on for the whole app, so ``POST``,
``PUT`` and ``DELETE`` without a token get ``400 The CSRF token is missing``. Send it as
an ``X-CSRFToken`` header. Server-rendered pages expose one in a meta tag:
``PUT``, ``PATCH`` and ``DELETE`` without a token get ``400 {"message": "The CSRF token
is missing."}``. Send it as an ``X-CSRFToken`` header, from the same session the token was
minted in — the token is bound to the session cookie, so the pair travels together.
Server-rendered pages expose one in a meta tag:

.. code-block:: html

Expand All @@ -45,6 +49,28 @@ an ``X-CSRFToken`` header. Server-rendered pages expose one in a meta tag:
body: JSON.stringify({ id: locationUuid, description: 'has a hole' }),
});

A scripted client has no meta tag to read, so it needs both pieces the browser gets for
free: the token, and the session cookie it is bound to. **A bare token is not enough** —
without the matching cookie the request fails with a different error,
``400 {"message": "The CSRF session token is missing."}``. Fetch a page first to get
both, keeping cookies in a jar to reuse on the write:

.. code-block:: bash

JAR=$(mktemp)
TOKEN=$(curl -s -c "$JAR" http://localhost:5000/ | grep -oP 'name="csrf-token" content="\K[^"]+')
curl -X POST http://localhost:5000/api/report-location \
-b "$JAR" \
-H "Content-Type: application/json" \
-H "X-CSRFToken: $TOKEN" \
-d '{"id": "9264286a-5d33-4e38-ab11-c8e179a7754a", "description": "has a hole"}'

Over https, a matching ``Referer`` header is required too — same-origin defense in
depth, on top of the token. Browsers send this automatically for a same-origin request,
so it is invisible in normal use; a scripted client (``curl``, a backend job) must set it
explicitly, e.g. ``-H "Referer: https://your-host/"``, or the request gets
``400 {"message": "The referrer header is missing."}``.

**Errors are ``{"message": "..."}``**, occasionally with an extra ``error`` field.
Messages are deliberately generic — the details go to the server log, not the response.

Expand Down Expand Up @@ -84,17 +110,8 @@ Query parameters:

curl 'http://localhost:5000/api/locations?accessible_by=bikes&lat=51.10&lon=17.05&limit=5'

.. code-block:: json

[
{
"uuid": "7c3d5e7f-9a1b-4c3d-8e5f-7a9b1c3d5e7f",
"position": [50.0397, 19.906],
"remark": false
}
]

``remark`` is a **boolean** — whether the point has a remark, not the remark itself.
Each point comes back as ``uuid``, ``position`` and ``has_remark`` — a **boolean**, whether
the point has a remark, not its text.

Invalid or unknown query parameters are ignored rather than rejected.

Expand All @@ -108,27 +125,8 @@ level. This is what the frontend calls instead of ``/api/locations`` when
Takes every parameter of :ref:`api-locations`, plus ``zoom`` (integer, **0–16**, default
``7``). A ``zoom`` outside that range is a ``400``.

.. code-block:: json

[
{
"type": "cluster",
"position": [50.1026, 19.8240],
"uuid": null,
"cluster_uuid": "34515392-7913-47be-a5b4-0c4b5247ad4c",
"cluster_count": 2
},
{
"type": "point",
"position": [50.833, 15.917],
"uuid": "9b1c3d5e-7f9a-4b1c-8d5e-9f1a3b5c7d9e",
"cluster_uuid": null,
"cluster_count": null
}
]

Both kinds come back in one list, told apart by ``type``. A ``"point"`` carries a real
``uuid`` you can pass to :ref:`api-location-detail`; a ``"cluster"`` carries a
Points and clusters come back in one list, told apart by ``type``. A ``"point"`` carries
a real ``uuid`` you can pass to :ref:`api-location-detail`; a ``"cluster"`` carries a
freshly-generated ``cluster_uuid`` (not stable across requests — it is a render key, not
an identifier) and the number of points it stands for. ``position`` is
``[latitude, longitude]``, as everywhere else.
Expand Down Expand Up @@ -169,20 +167,6 @@ well-formed UUID that does not exist also gives ``404 {"message": "Location not
Every category with its options, defaults and filter mode — everything needed to render
the filter panel in one request.

.. code-block:: json

{
"categories": [
{
"key": "accessible_by",
"name": "accessible_by",
"options": [["bikes", "bikes"], ["cars", "cars"]],
"default_checked": [],
"filter_mode": "or"
}
]
}

``key`` is the query-parameter name to filter by; ``name`` is its translated label.
``options`` are ``[value, translated label]`` pairs — send the *value*.
``filter_mode`` tells you which control to draw: checkboxes for ``or``/``and``, radio
Expand All @@ -192,41 +176,27 @@ buttons for ``exclusive``/``threshold``, a single checkbox for ``boolean``
With ``CATEGORIES_HELP`` on, each category also carries ``options_help``, and the response
gains a top-level ``categories_help`` — both lists of ``{option: help text}`` objects.

Prefer this endpoint over the two below, which exist for older clients and cost one
request per category.

``GET /api/categories``
~~~~~~~~~~~~~~~~~~~~~~~

Category names only, as ``[key, translated name]`` pairs. With ``CATEGORIES_HELP`` on,
returns ``{"categories": [...], "categories_help": [...]}`` instead — note the response
*type* changes with the flag.
.. _api-location-schema:

``GET /api/category/<name>``
``GET /api/location-schema``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The options for one category, as ``[value, translated label]`` pairs. With
``CATEGORIES_HELP`` on, returns
``{"categories_options": [...], "categories_options_help": [...]}``.

``GET /api/languages``
~~~~~~~~~~~~~~~~~~~~~~

The configured interface languages, exactly as given in ``LANGUAGES``:

.. code-block:: json

{"en": {"name": "English", "flag": "gb", "country": "GB"}}
What *this* instance accepts for a new point: the fields of its location model (minus the
server-assigned ``uuid`` — ``position`` is required and client-supplied, same as
``/api/suggest-new-point``), the allowed values per category, the reportable issue types,
and the photo limits. Since the accepted fields are configured per deployment, this is how
a client discovers them rather than assuming — it is the same schema the built-in suggest
form is generated from.

``GET /api/version``
Other read endpoints
~~~~~~~~~~~~~~~~~~~~

.. code-block:: json

{"backend": "<installed-version>"}
``GET /api/languages`` returns the configured languages keyed by language code, exactly as
given in ``LANGUAGES``.

The installed package version, normalised to PEP 440 — a release published as
``2.0.0-alpha.5`` reports as ``2.0.0a5``. Useful as a health check.
``GET /api/version`` returns the installed package version normalised to PEP 440, so a
release published as ``2.0.0-alpha.5`` reports as ``2.0.0a5``. It needs no data source,
which makes it the endpoint to point a load balancer at.

Submissions
-----------
Expand All @@ -244,15 +214,27 @@ the map data.

**The request must be ``multipart/form-data``.** The point goes in a single ``location``
form field as a JSON object — not as one form field per property — and the optional photo
goes in a ``photo`` file part. Send the point without a ``uuid``; the server assigns one:
goes in a ``photo`` file part. Send the point without a ``uuid``; the server assigns one.

That envelope is the same everywhere. **What goes inside the JSON object is not** — the
accepted fields are whatever *your* data source declares in ``location_obligatory_fields``
and ``categories`` (:doc:`data-source`), so there is no universal payload to copy. The
fields below are the ones the :doc:`quickstart` map happens to declare; substitute your
own:

.. code-block:: bash

curl -X POST http://localhost:5000/api/suggest-new-point \
-b "$JAR" \
-H "X-CSRFToken: $TOKEN" \
-F 'location={"name": "Nowy", "position": [51.11, 17.03], "type_of_place": "small bridge", "accessible_by": ["bikes"], "is_free": "true"}' \
-F 'photo=@bridge.jpg'

(``$JAR`` and ``$TOKEN`` as obtained above.)

To find the fields a given instance wants, call :ref:`api-location-schema` — the same
schema the built-in suggest form is generated from.

.. note::

Sending the point as a JSON request body used to work and no longer does — a
Expand Down Expand Up @@ -320,11 +302,3 @@ description that satisfies neither rule gives ``400``.

The report is stored with ``"status": "pending"`` and ``"priority": "medium"`` in the
data source, for triage.

``GET /api/generate-csrf-token``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

.. deprecated:: 1.1.8

Deprecated since 1.1.8 and kept only for backward compatibility. Read the token from
the ``csrf-token`` meta tag instead. CSRF protection itself is unaffected.
8 changes: 4 additions & 4 deletions frontend/src/components/MarkerPopup/MarkerPopup.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ const asteriskIcon = new Icon({
* @param {Object} props - Component props
* @param {Object} props.place - Location data object
* @param {number[]} props.place.position - Coordinates [latitude, longitude]
* @param {boolean} [props.place.remark] - Whether this location has a remark (uses asterisk icon if true)
* @param {boolean} [props.place.has_remark] - Whether this location has a remark (uses asterisk icon if true)
* @returns {React.ReactElement} Leaflet Marker component with click-to-show-details functionality
*/
export const MarkerPopup = ({ place }) => {
Expand Down Expand Up @@ -113,12 +113,12 @@ export const MarkerPopup = ({ place }) => {
eventHandlers: {
click: handleMarkerClick,
},
alt: place.remark ? 'Marker-Asterisk' : 'Marker',
alt: place.has_remark ? 'Marker-Asterisk' : 'Marker',
};

// Only add icon prop if we have a custom icon (for remarks)
// This prevents passing undefined which can cause issues with MarkerClusterGroup
if (place.remark) {
if (place.has_remark) {
markerProps.icon = asteriskIcon;
}

Expand All @@ -132,7 +132,7 @@ export const MarkerPopup = ({ place }) => {
MarkerPopup.propTypes = {
place: PropTypes.shape({
position: PropTypes.arrayOf(PropTypes.number).isRequired,
remark: PropTypes.bool,
has_remark: PropTypes.bool, // eslint-disable-line camelcase -- matches backend API schema property name
uuid: PropTypes.string.isRequired,
}).isRequired,
};
50 changes: 11 additions & 39 deletions frontend/src/utils/csrf.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,60 +9,32 @@
*/

/**
* Gets the CSRF token from the page's meta tag, with fallback to legacy API endpoint.
* Gets the CSRF token from the page's meta tag.
*
* Preferred method: The backend sets a meta tag like:
* The backend sets a meta tag like:
* <meta name="csrf-token" content="TOKEN_VALUE">
*
* Fallback (DEPRECATED): Fetches token from /api/generate-csrf-token endpoint.
* This fallback exists for backward compatibility but will be removed in a future version.
*
* This token must be included in the X-CSRFToken header for all
* state-changing requests (POST, PUT, PATCH, DELETE).
*
* @returns {Promise<string>} The CSRF token
* @throws {Error} If CSRF token cannot be obtained from either source
* @returns {string} The CSRF token
* @throws {Error} If the CSRF token meta tag is missing or empty
*
* @example
* const csrfToken = await getCsrfToken();
* const csrfToken = getCsrfToken();
* axios.post('/api/suggest-new-point', data, {
* headers: { 'X-CSRFToken': csrfToken }
* });
*/
export const getCsrfToken = async () => {
export const getCsrfToken = () => {
const metaTag = document.querySelector('meta[name="csrf-token"]');
const token = metaTag?.getAttribute('content');

// Try to get token from meta tag first (preferred method)
if (metaTag) {
const token = metaTag.getAttribute('content');
if (token) {
return token;
}
}

// Fallback to legacy API endpoint (DEPRECATED)
console.warn(
'⚠️ DEPRECATION WARNING: CSRF token meta tag not found. ' +
'Falling back to /api/generate-csrf-token endpoint. ' +
'This fallback is DEPRECATED and will be removed in a future version. ' +
'Please ensure the backend includes <meta name="csrf-token" content="..."> in the page HTML.',
);

try {
const response = await fetch('/api/generate-csrf-token');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (!data.csrf_token) {
throw new Error('API response missing csrf_token field');
}
return data.csrf_token;
} catch (error) {
console.error('Failed to fetch CSRF token from legacy endpoint:', error);
if (!token) {
throw new Error(
'CSRF token not found. Neither meta tag nor /api/generate-csrf-token endpoint provided a valid token.',
'CSRF token not found. Ensure the backend includes <meta name="csrf-token" content="..."> in the page HTML.',
);
}
};

return token;
};
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,17 @@ describe('MarkerPopup integration with MarkerClusterGroup', () => {
{
position: [51.1095, 17.0525],
uuid: 'location-1',
remark: false,
has_remark: false, // eslint-disable-line camelcase
},
{
position: [51.10655, 17.0555],
uuid: 'location-2',
remark: true,
has_remark: true, // eslint-disable-line camelcase
},
{
position: [51.1085, 17.0535],
uuid: 'location-3',
remark: false,
has_remark: false, // eslint-disable-line camelcase
},
];

Expand Down
Loading
Loading