Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,17 @@ POSTGRES_PORT=5432
# Lifetime (seconds) of a gated-document download link (default 7 days).
# TRUST_CENTER_DOWNLOAD_TTL=604800

# The front-end libraries (Bootstrap, htmx, Leaflet, the interface font...) are
# served from this instance, never from a CDN. A Docker image mirrors them at
# build time; a direct install downloads what is missing on its first launch.
# Set to False for an air-gapped install that puts the files in place itself.
# VENDOR_ASSETS_AUTO_DOWNLOAD=True
# Whether the About modal may ask GitHub whether a newer release exists. It is
# the only outbound call the interface makes, it fires only when someone opens
# that modal, and the answer is cached. Set to False where policy forbids the
# instance calling out.
# UPDATE_CHECK_ENABLED=True

# Optional – auto-create a super-admin on first startup
# DJANGO_SUPERUSER_EMAIL=admin@example.com
# DJANGO_SUPERUSER_PASSWORD=changeme
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ cover/
*.mo
*.pot

# Front-end libraries, mirrored from the pins in core/dependencies.py by
# `manage.py vendor_assets` (image build, or first launch of a direct install).
# Never committed: the registry is the source of truth, not a copy of the files.
static/vendor/

# Django stuff:
*.log
local_settings.py
Expand Down
10 changes: 8 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Changelog

All notable changes to Cairn (formerly Fairway) are documented in this file.
All notable changes to Cairn are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Expand All @@ -9,7 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- The About dialog lists the open source libraries the instance is built on, each linking to its official repository, also exposed at `GET /api/v1/dependencies` and through the `list_dependencies` MCP tool.
- The About dialog lists the open source libraries the instance is built on as `owner / library - version` in two columns, each linking to its official repository, also exposed at `GET /api/v1/dependencies` and through the `list_dependencies` MCP tool.
- Front-end libraries are served from the instance instead of a CDN : `manage.py vendor_assets` mirrors them into `static/vendor/` from the pins in `core/dependencies.py`, at Docker build time or on a direct install's first launch, verifying each download against its Subresource-Integrity digest.
- The About dialog reports whether a newer release is published, from the GitHub releases of the project, also exposed at `GET /api/v1/update-check` and through the `check_for_updates` MCP tool (`UPDATE_CHECK_ENABLED=False` to switch it off).

### Changed

- Front-end libraries are upgraded to their current releases : Bootstrap Icons 1.13.1, htmx 2.0.10, Tom Select 2.6.2, DOMPurify 3.4.14, Sortable 1.15.7 and ECharts 5.6.0.

## [0.35.1] - 2026-08-28

Expand Down
6 changes: 6 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ COPY . .

RUN echo "${APP_VERSION}" > /etc/app-version

# Mirror the front-end libraries into static/vendor/ so the running container
# never contacts a CDN. Unlike the two commands below this one must NOT be
# tolerated to fail: an image shipped without them renders an unstyled
# interface, and the build machine is the only place with a mirror to reach.
RUN python manage.py vendor_assets

RUN python manage.py compilemessages 2>/dev/null || true
RUN python manage.py collectstatic --noinput 2>/dev/null || true

Expand Down
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,18 @@ built from [`docs/`](docs/README.md).
Django 5.2 LTS, PostgreSQL 16, Django REST Framework, Channels and Redis,
Bootstrap 5.3 with HTMX and Apache ECharts. No JavaScript build step.

The full inventory of third-party components lives in
[`core/dependencies.py`](core/dependencies.py), and a running instance states it
in the About dialog, at `GET /api/v1/dependencies` and through the
`list_dependencies` MCP tool.
Every front-end library is served from the instance itself, never from a CDN :
an isolated deployment works, and no third party is told who browses it. They
are mirrored into `static/vendor/` from the pins in
[`core/dependencies.py`](core/dependencies.py) - by the Docker build, or on the
first launch of a direct install - and each download is checked against a
Subresource-Integrity digest. `python manage.py vendor_assets` does it by hand.

That same file is the full inventory of third-party components, and a running
instance states it in the About dialog, at `GET /api/v1/dependencies` and
through the `list_dependencies` MCP tool. The About dialog also says whether a
newer release is published (`GET /api/v1/update-check`, `check_for_updates`);
set `UPDATE_CHECK_ENABLED=False` to keep the instance from asking.

## Licence

Expand All @@ -91,8 +99,8 @@ available over a network, and you must offer those users the corresponding
source. Distributing a modified version triggers the same obligation.

Every source file carries an `SPDX-License-Identifier: AGPL-3.0-or-later`
header. Third-party files under `static/vendor/` keep their own upstream
licences and are not covered by this notice.
header. The third-party files mirrored into `static/vendor/` keep their own
upstream licences and are not covered by this notice.

### Trademarks

Expand Down
1 change: 1 addition & 0 deletions accounts/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

# Third-party components the instance is built on
path("dependencies/", views.DependenciesAPIView.as_view(), name="api-dependencies"),
path("update-check/", views.UpdateCheckAPIView.as_view(), name="api-update-check"),

# Resource endpoints
path("", include(router.urls)),
Expand Down
17 changes: 17 additions & 0 deletions accounts/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,23 @@ def get(self, request):
return Response({"status": "success", "count": len(data), "data": data})


class UpdateCheckAPIView(APIView):
"""Whether a newer Cairn release is published, and which one.

Same answer as the About modal (``core.updates``) : ``state`` is one of
``outdated``, ``current``, ``unknown`` (unreachable, or a build with no
release number) or ``disabled`` (the check is switched off on this
instance). Lets a fleet operator poll their estate for stale deployments.
"""

permission_classes = [IsAuthenticated]

def get(self, request):
from core.updates import update_status

return Response({"status": "success", "data": update_status()})


# ── Helpers ─────────────────────────────────────────────────

def _get_client_ip(request):
Expand Down
10 changes: 3 additions & 7 deletions accounts/templates/accounts/activate.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,8 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% trans "Activate your account - Cairn" %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB"
crossorigin="anonymous">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
rel="stylesheet">
<link href="{% static 'vendor/bootstrap/bootstrap.min.css' %}" rel="stylesheet">
<link href="{% static 'vendor/bootstrap-icons/bootstrap-icons.min.css' %}" rel="stylesheet">
<script>
(function(){
var root = document.documentElement;
Expand All @@ -36,7 +32,7 @@
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('https://cdn.jsdelivr.net/npm/@gitlab/fonts@1.3.1/gitlab-sans/GitLabSans.woff2') format('woff2');
src: url('{% static "vendor/gitlab-sans/GitLabSans.woff2" %}') format('woff2');
}
:root {
--accent: #1E3A8A;
Expand Down
16 changes: 5 additions & 11 deletions accounts/templates/accounts/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% trans "Sign in - Cairn" %}</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%236366f1'/%3E%3Cstop offset='100%25' stop-color='%237c3aed'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='7' fill='url(%23g)'/%3E%3Cpath d='M12.5 5.5L22 5.5A2.5 2.5 0 0124.5 8 2.5 2.5 0 0122 10.5L15 10.5 15 13.5 19.5 13.5A2.5 2.5 0 0122 16 2.5 2.5 0 0119.5 18.5L15 18.5 15 24A2.5 2.5 0 0112.5 26.5 2.5 2.5 0 0110 24L10 8A2.5 2.5 0 0112.5 5.5Z' fill='%23fff' opacity='.95'/%3E%3Ccircle cx='25' cy='8' r='1.8' fill='%23f0abfc' opacity='.85'/%3E%3Ccircle cx='22' cy='16' r='1.8' fill='%23a5b4fc' opacity='.85'/%3E%3C/svg%3E">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB"
crossorigin="anonymous">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
rel="stylesheet">
<link href="{% static 'vendor/bootstrap/bootstrap.min.css' %}" rel="stylesheet">
<link href="{% static 'vendor/bootstrap-icons/bootstrap-icons.min.css' %}" rel="stylesheet">
<script>
(function(){
var root = document.documentElement;
Expand Down Expand Up @@ -49,14 +45,14 @@
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('https://cdn.jsdelivr.net/npm/@gitlab/fonts@1.3.1/gitlab-sans/GitLabSans.woff2') format('woff2');
src: url('{% static "vendor/gitlab-sans/GitLabSans.woff2" %}') format('woff2');
}
@font-face {
font-family: 'GitLab Sans';
font-style: italic;
font-weight: 100 900;
font-display: swap;
src: url('https://cdn.jsdelivr.net/npm/@gitlab/fonts@1.3.1/gitlab-sans/GitLabSans-Italic.woff2') format('woff2');
src: url('{% static "vendor/gitlab-sans/GitLabSans-Italic.woff2" %}') format('woff2');
}
:root {
--accent: #1E3A8A;
Expand Down Expand Up @@ -331,9 +327,7 @@
</div>
</div>

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"
integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI"
crossorigin="anonymous"></script>
<script src="{% static 'vendor/bootstrap/bootstrap.bundle.min.js' %}"></script>
{% trans "Waiting for passkey..." as tr_waiting %}
{% trans "Authentication failed." as tr_auth_failed %}
{% trans "Sign in with a passkey" as tr_signin_passkey %}
Expand Down
10 changes: 5 additions & 5 deletions assets/templates/assets/dependency_graph.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% load i18n ui %}
{% load i18n ui static %}

{% block title %}{% trans "Dependency graph" %} - Cairn{% endblock %}

Expand Down Expand Up @@ -157,8 +157,8 @@
return;
}

// d3 (rendering) and dagre (layered layout) are loaded from a CDN. Under
// hx-boost navigation the external <script> is injected asynchronously, so
// d3 (rendering) and dagre (layered layout) are served from static/vendor/.
// Under hx-boost navigation the <script> is injected asynchronously, so
// the init code below would run before the libs are ready and the graph
// stays blank until a full page reload. Load both on demand and only build
// the graph once they are available.
Expand All @@ -179,8 +179,8 @@
}

function withLibs(cb) {
var needD3 = window.d3 ? Promise.resolve() : loadScript('d3-cdn', 'https://d3js.org/d3.v7.min.js');
var needDagre = window.dagre ? Promise.resolve() : loadScript('dagre-cdn', 'https://cdn.jsdelivr.net/npm/dagre@0.8.5/dist/dagre.min.js');
var needD3 = window.d3 ? Promise.resolve() : loadScript('d3-lib', '{% static "vendor/d3/d3.min.js" %}');
var needDagre = window.dagre ? Promise.resolve() : loadScript('dagre-lib', '{% static "vendor/dagre/dagre.min.js" %}');
Promise.all([needD3, needDagre]).then(cb);
}

Expand Down
3 changes: 2 additions & 1 deletion assets/tests/test_supplier_address.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ def test_map_rendered_with_coords(self, client):
html = client.get(reverse("assets:supplier-detail", kwargs={"pk": s.pk})).content.decode()
assert 'id="supplier-map"' in html
assert 'data-lat="48.87"' in html and 'data-lon="2.32"' in html
assert "leaflet@1.9.4/dist/leaflet.js" in html
# Leaflet is served from this instance, not from a CDN.
assert "/static/vendor/leaflet/leaflet.js" in html
urls = re.findall(r'https?://[^\s"\'<>]+', html)
# Leaflet's {s} subdomain placeholder makes the parsed hostname
# "{s}.basemaps.cartocdn.com", so accept the CARTO domain or any subdomain.
Expand Down
47 changes: 47 additions & 0 deletions core/apps.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,34 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# SPDX-FileCopyrightText: 2026 François Rousselet
import sys
from pathlib import Path

from django.apps import AppConfig
from django.conf import settings

#: Management commands that serve or collect the interface, and therefore need
#: the front-end libraries on disk. Every other command (migrate, shell, tests…)
#: must stay offline-safe, so it never triggers a download.
COMMANDS_NEEDING_ASSETS = {"runserver", "collectstatic"}


def _is_serving(argv):
"""Whether this process is about to serve or collect the interface.

A direct install is started either through ``manage.py runserver`` or
through an ASGI/WSGI runner (uvicorn, gunicorn, daphne) that imports
``core.asgi`` : the runner has no management command to inspect, so anything
that is not ``manage.py`` counts as serving.
"""
if not argv:
return False
entry = Path(argv[0]).name
if entry in {"manage.py", "django-admin", "django-admin.py"}:
return len(argv) > 1 and argv[1] in COMMANDS_NEEDING_ASSETS
# A test runner drives the app without serving it, and must not hit the network.
if entry.startswith("pytest") or entry == "py.test":
return False
return True


class CoreConfig(AppConfig):
Expand All @@ -10,3 +38,22 @@ class CoreConfig(AppConfig):
def ready(self):
import core.lifecycle_seed # noqa: F401 (post_migrate lifecycle sync)
import core.signals # noqa: F401

self._mirror_frontend_libraries()

def _mirror_frontend_libraries(self):
"""Fetch the front-end libraries on the first launch of a direct install.

Docker images carry them already (the build runs ``vendor_assets``), and
every later start finds the files in place, so this is a no-op cost of
one stat per file. Never fatal : an unreachable mirror degrades the
interface, it does not stop the instance from booting.
"""
if not getattr(settings, "VENDOR_ASSETS_AUTO_DOWNLOAD", True):
return
if not _is_serving(sys.argv):
return

from core.vendoring import ensure_present

ensure_present(log=lambda message: print(message, file=sys.stderr))
Loading