Skip to content
Open
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
16 changes: 16 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@ jobs:
- name: Run formatter
run: mix format --check-formatted --dry-run

check_links:
name: 🔗 Check markdown links
runs-on: ubuntu-latest
container: ghcr.io/transportdatagouvfr/ops:elixir-1.19.4-erlang-27.3.4.1-ubuntu-noble-20251013-transport-tools-2.0.0
env:
LINK_CHECK_SKIP_HOSTS: www.researchgate.net,marketplace.visualstudio.com

steps:
- uses: actions/checkout@v6

- name: Checkout and compile
uses: ./.github/actions/checkout-compile

- name: Run link check
run: mix check_links

test:
name: ⚙️ Run the test suite
runs-on: ubuntu-latest
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Alternatively, you can use Docker, see the [Docker section](#docker-installation

### Creating a database

Make sure you have a postgres user with postgres password, and that the identification methode is set to md5 in your `pg_hba.conf` file.
Make sure you have a postgres user with postgres password, and that the identification method is set to scram-sha-256 in your `pg_hba.conf` file.

Create the database with the command `mix ecto.create`.

Expand Down Expand Up @@ -105,7 +105,7 @@ Run the server with `mix phx.server` and you can visit [`127.0.0.1:5000`](http:/

## Usage of the Elixir Proxy

[`apps/unlock`](https://github.com/etalab/transport-site/tree/master/apps/unlock) is a sub-part of the "umbrella app", which is served on its own subdomain (https://proxy.transport.data.gouv.fr for production, https://proxy.prochainement.transport.data.gouv.fr/ for staging).
[`apps/transport/lib/unlock`](https://github.com/etalab/transport-site/tree/master/apps/transport/lib/unlock) contains code related to the proxy, which is served on its own subdomain (https://proxy.transport.data.gouv.fr for production, https://proxy.prochainement.transport.data.gouv.fr/ for staging).

The proxy relies on this [yaml configuration](https://github.com/transportdatagouvfr/proxy-config/blob/master/proxy-config.yml) which is currently fetched at runtime once (but can be hot-reloaded via this [backoffice page](https://transport.data.gouv.fr/backoffice/proxy-config)).

Expand Down Expand Up @@ -155,7 +155,7 @@ config :oauth2, Datagouvfr.Authentication,

Run the tests with `mix test`

The application is an [umbrella app](https://elixir-lang.org/getting-started/mix-otp/dependencies-and-umbrella-projects.html). It means that it is split into several sub-projects (that you can see under `/apps`).
The application is an [umbrella app](https://mix.hexdocs.pm/Mix.Project.html#module-umbrella-projects). It means that it is split into several sub-projects (that you can see under `/apps`).

To run tests for a specific app, for example the `transport` or `unlock` app, use this command:

Expand Down Expand Up @@ -265,7 +265,7 @@ For the tests you also need to add an environment variable:
The Dockerfile needed to run the continuous integration is in the project:
https://github.com/etalab/transport-ops

Update it if needed (e.g. updating Elixir’s version) and then update `.circleci/config.yml`.
Update it if needed (e.g. updating Elixir’s version).

# Domain names

Expand All @@ -278,7 +278,7 @@ The following domain names are currently in use by the deployed Elixir app:
* Staging
* site: https://prochainement.transport.data.gouv.fr
* jobs: https://workers.prochainement.transport.data.gouv.fr
* proxy: https://proxy.prochainement.transport.gouv.fr
* proxy: https://proxy.prochainement.transport.data.gouv.fr

These names are [configured via a CNAME on Clever Cloud](https://www.clever-cloud.com/doc/administrate/domain-names/#using-personal-domain-names).

Expand Down
159 changes: 159 additions & 0 deletions apps/transport/lib/mix/tasks/check-links.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
defmodule Mix.Tasks.CheckLinks do
@shortdoc "Check all HTTP/HTTPS links in git-tracked markdown files"
@moduledoc """
Validates every HTTP/HTTPS URL found in markdown files tracked by git.

## Usage

mix check_links

## Environment variables

- `LINK_CHECK_TIMEOUT` — per-request timeout in seconds (default: `10`)
- `LINK_CHECK_RETRIES` — retry count on non-2xx/3xx (default: `2`)
- `LINK_CHECK_SKIP_HOSTS` — comma-separated list of hosts to skip entirely

Known-faulty markdown files are skipped by default (see `@skip_files`).
"""

@skip_files [
"docs/inventaire_donnees_geographiques_septembre_2023.md"
]

use Mix.Task
require Logger

def run(_argv) do
Application.ensure_started(:telemetry)

timeout = System.get_env("LINK_CHECK_TIMEOUT", "10") |> String.to_integer()
retries = System.get_env("LINK_CHECK_RETRIES", "2") |> String.to_integer()

{:ok, _pid} = Finch.start_link(name: CheckLinks.Finch)

markdown_files = git_markdown_files() |> skip_files()
Logger.info("Checking links in #{length(markdown_files)} markdown file(s)")

urls_with_files = extract_all_urls(markdown_files) |> deduplicate_urls()

results =
urls_with_files
|> Enum.map(fn {url, file} ->
Task.async(fn -> check_url(file, url, timeout, retries) end)
end)
|> await_many()

ok_count = Enum.count(results, fn {_file, _url, status} -> status == :ok end)
error_count = length(urls_with_files) - ok_count
errors = Enum.filter(results, fn {_file, _url, status} -> is_integer(status) end)

Logger.info("#{ok_count} ok, #{error_count} broken link(s)")

if error_count > 0 do
errors
|> Enum.sort_by(fn {_file, url, _status} -> url end)
|> Enum.each(fn {file, url, status} ->
Logger.error("ERROR #{file}: #{url} (HTTP #{status})")
end)

System.stop(1)
else
Logger.info("All links are valid")
end
end

defp await_many(tasks, timeout \\ 30_000),
do: Enum.map(tasks, &Task.await(&1, timeout))

# ---- git file discovery ---------------------------------------------------

defp git_markdown_files do
{stdout, _exit_code} = System.cmd("git", ["ls-files", "--", "*.md", "*.MD"])
String.split(stdout, "\n", trim: true)
end

defp skip_files(files) do
skipped = Enum.filter(files, &(&1 in @skip_files))

if Enum.empty?(skipped) do
files
else
filtered = Enum.reject(files, &(&1 in @skip_files))
Logger.info("Skipping #{length(skipped)} file(s): #{Enum.join(skipped, ", ")}")
filtered
end
end

# ---- URL extraction --------------------------------------------------------

defp extract_all_urls(files) do
files
|> Enum.flat_map(fn file ->
content = File.read!(file)

urls =
Regex.scan(~r/\[([^\]]*)\]\((https?:\/\/[^)]+)\)/, content) ++
Regex.scan(~r/<(https?:\/\/[^>]+)>/, content) ++
Regex.scan(~r/^\s*\[([^\]]+)\]:\s+(https?:\/\/.+)$/m, content)

urls
|> Enum.map(fn [_full | rest] -> List.last(rest) end)
|> Enum.map(&strip_trailing_punctuation/1)
|> Enum.reject(&localhost?/1)
|> Enum.reject(&private_repository?/1)
|> Enum.reject(&skipped_host?/1)
|> Enum.map(&{&1, file})
end)
end

defp deduplicate_urls(urls_with_files) do
urls_with_files |> Enum.uniq_by(fn {url, _file} -> url end)
end

# ---- URL parsing helpers ---------------------------------------------------

defp strip_trailing_punctuation(url),
do: Regex.replace(~r/[.,;!)>?]$/, url, "")

defp localhost?(url), do: url =~ ~r/^https?:\/\/(127\.|localhost)/

defp private_repository?(url), do: String.starts_with?(url, "https://github.com/transportdatagouvfr/proxy-config")

defp skipped_host?(url) do
host = parse_host(url)
hosts = System.get_env("LINK_CHECK_SKIP_HOSTS", "") |> String.split(",", trim: true)
Enum.any?(hosts, &(&1 == host))
end

defp parse_host(url),
do: url |> URI.parse() |> Map.get(:host, "")

# ---- HTTP check ------------------------------------------------------------

defp check_url(file, url, timeout, retries), do: {file, url, do_check(url, timeout, retries)}

defp do_check(_url, _timeout, 0), do: 0

defp do_check(url, timeout, remaining_retries) do
request = Finch.build(:head, url)

case Finch.request(request, CheckLinks.Finch, recv_timeout: timeout * 1000) do
{:ok, %{status: status}} when status in 200..399 -> :ok
{:ok, %{status: 405}} -> get_fallback(url, timeout, remaining_retries - 1)
{:ok, %{status: _status}} -> do_check(url, timeout, remaining_retries - 1)
{:error, _reason} -> do_check(url, timeout, remaining_retries - 1)
end
end

defp get_fallback(_url, _timeout, 0), do: 0

defp get_fallback(url, timeout, remaining_retries) do
request = Finch.build(:get, url)

case Finch.request(request, CheckLinks.Finch, recv_timeout: timeout * 1000) do
{:ok, %{status: status}} when status in 200..399 -> :ok
{:ok, %{status: _status}} -> get_fallback(url, timeout, remaining_retries - 1)
{:error, _reason} -> get_fallback(url, timeout, remaining_retries - 1)
end
end
end
72 changes: 55 additions & 17 deletions docs/contacts.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,62 +4,100 @@ Ce document décrit le fonctionnement des contacts dans notre application.

## Concepts métiers

Les contacts au sens de transport.data.gouv.fr sont des utilisateurs ou des listes de diffusion (`opendata@ma-collectivite.fr`).
Les contacts au sens de transport.data.gouv.fr sont des utilisateurs ou des
listes de diffusion (`opendata@ma-collectivite.fr`).

Ces contacts sont créés :

- par le biais du backoffice par des membres de l'équipe du PAN
- automatiquement lors de la connexion au PAN par le biais de l'OAuth avec data.gouv.fr

Ainsi les contacts peuvent être des :
- producteurs : agents de collectivités, prestataires techniques assistant les collectivités ou entreprises, salariés d'entreprises de mobilité

- producteurs : agents de collectivités, prestataires techniques assistant les
collectivités ou entreprises, salariés d'entreprises de mobilité
- listes de diffusion : services SIG, open data et mobilité principalement
- réutilisateurs de données
- curieux

## Champs obligatoires

Les champs obligatoires sont des informations sur :

- l'identité (nom et prénom OU titre pour une liste de diffusion)
- adresse e-mail

## Lien entre contact et utilisateur data.gouv.fr

Un contact créé depuis le backoffice n'a pas de `datagouv_user_id`. Si la personne se connecte plus tard sur le PAN avec l'adresse e-mail associée au contact les comptes sont "liés" et on reprend le `datagouv_user_id`.
Un contact créé depuis le backoffice n'a pas de `datagouv_user_id`. Si la
personne se connecte plus tard sur le PAN avec l'adresse e-mail associée au
contact les comptes sont "liés" et on reprend le `datagouv_user_id`.

## Protection des données

L'adresse e-mail (`email`) et le numéro de téléphone (`phone_number`) sont stockés de manière chiffrée en base de données.
L'adresse e-mail (`email`) et le numéro de téléphone (`phone_number`) sont
stockés de manière chiffrée en base de données.

La colonne `email_hash` permet de retrouver un contact par son adresse e-mail.

## Cycle de vie

### Créations

- Un contact créé depuis le backoffice est géré par [`TransportWeb.Backoffice.ContactController`](https://github.com/etalab/transport-site/blob/master/apps/transport/lib/transport_web/controllers/backoffice/contact_controller.ex).
- La création d'un contact lors de la connexion est effectuée dans [`TransportWeb.SessionController`](https://github.com/etalab/transport-site/blob/4698271861462b72ea2eed4c310c301562ad3eee/apps/transport/lib/transport_web/controllers/session_controller.ex#L57). Ce controller est également en charge de sauvegarder un `datagouv_user_id` dans le cas où un contact est déjà existant (comparaison avec l'adresse e-mail).
- Un contact créé depuis le backoffice est géré par [`TransportWeb.Backoffice.ContactController`].
- La création d'un contact lors de la connexion est effectuée dans [`TransportWeb.SessionController`].
Ce controller est également en charge de sauvegarder un `datagouv_user_id` dans
le cas où un contact est déjà existant (comparaison avec l'adresse e-mail).

### Mises à jour

- Un job ([`Transport.Jobs.UpdateContactsJob`](https://github.com/etalab/transport-site/blob/master/apps/transport/lib/jobs/update_contacts_job.ex)) est en charge de maintenir à jour les organisations associées à chaque contact pour lesquelles un `datagouv_user_id` est renseigné. Ce job est exécuté de manière quotidienne
- À chaque connexion sur le PAN ([une session dure 15 jours](https://github.com/etalab/transport-site/blob/4698271861462b72ea2eed4c310c301562ad3eee/apps/transport/lib/transport_web/endpoint.ex#L5-L12)), les informations de l'utilisateur data.gouv.fr sont synchronisées (prénom, nom, adresse e-mail). La dernière date de connexion est stockée dans `last_login_at`.
- Un job ([`Transport.Jobs.UpdateContactsJob`])
est en charge de maintenir à jour les organisations associées à chaque contact
pour lesquelles un `datagouv_user_id` est renseigné. Ce job est exécuté de
manière quotidienne
- À chaque connexion sur le PAN ([une session dure 15 jours]),
les informations de l'utilisateur data.gouv.fr sont synchronisées (prénom, nom,
adresse e-mail). La dernière date de connexion est stockée dans `last_login_at`.

### Suppression

La seule possibilité de supprimer un contact est pour le moment par le biais du backoffice.
La seule possibilité de supprimer un contact est pour le moment par le biais du
backoffice.

Un utilisateur ne peut pas supprimer son compte de manière autonome et un compte n'est pas supprimé en cas d'inactivité prolongée.
Un utilisateur ne peut pas supprimer son compte de manière autonome et un compte
n'est pas supprimé en cas d'inactivité prolongée.

## Rôles de producteurs et de réutilisateurs

On ne qualifie pas le rôle d'un contact directement dans la table `contact`. En effet ce rôle (producteur ou réutilisateur) peut être mixte et est propre à un contexte ou un jeu de données.
On ne qualifie pas le rôle d'un contact directement dans la table `contact`. En
effet ce rôle (producteur ou réutilisateur) peut être mixte et est propre à un
contexte ou un jeu de données.

Cette distinction est actuellement présente lors de l'abonnement aux notifications. Le contenu des notifications est adapté au rôle de l'utilisateur.
Cette distinction est actuellement présente lors de l'abonnement aux
notifications. Le contenu des notifications est adapté au rôle de l'utilisateur.

## Abonnements aux notifications

Un contact membre d'une organisation qui a un jeu de données sur le PAN pourra modifier des éléments de ce jeu de données sur son Espace Producteur et pourra s'abonner aux notifications en lien avec ce jeu de données en tant que producteur.

Un membre de l'équipe du PAN a la possibilité d'abonner des contacts à des notifications depuis le backoffice. Ceci est utilisé pour pouvoir abonner des contacts qui ne sont pas membres d'une organisation data.gouv.fr (prestataires techniques par exemple) mais qui ont un rôle de gestion d'un jeu de données. On considère que les abonnements aux notifications créés depuis le backoffice sont pour des contacts ayant un rôle de producteur et ceci est sauvegardé ainsi en base de données dans `notification_subscription.role`.

Si un contact était abonné en tant que réutilisateur à des notifications et qu'il devient membre de l'organisation produisant le jeu de données, son rôle sera mis à jour automatiquement par [`Transport.Jobs.NotificationSubscriptionProducerJob`](https://github.com/etalab/transport-site/blob/master/apps/transport/lib/jobs/notification_subscription_producer_job.ex), exécuté quotidiennement.
Un contact membre d'une organisation qui a un jeu de données sur le PAN pourra
modifier des éléments de ce jeu de données sur son Espace Producteur et pourra
s'abonner aux notifications en lien avec ce jeu de données en tant que
producteur.

Un membre de l'équipe du PAN a la possibilité d'abonner des contacts à des
notifications depuis le backoffice. Ceci est utilisé pour pouvoir abonner des
contacts qui ne sont pas membres d'une organisation data.gouv.fr (prestataires
techniques par exemple) mais qui ont un rôle de gestion d'un jeu de données. On
considère que les abonnements aux notifications créés depuis le backoffice sont
pour des contacts ayant un rôle de producteur et ceci est sauvegardé ainsi en
base de données dans `notification_subscription.role`.

Si un contact était abonné en tant que réutilisateur à des notifications et
qu'il devient membre de l'organisation produisant le jeu de données, son rôle
sera mis à jour automatiquement par [`Transport.Jobs.NotificationSubscriptionProducerJob`],
exécuté quotidiennement.

[`TransportWeb.Backoffice.ContactController`]: https://github.com/etalab/transport-site/blob/master/apps/transport/lib/transport_web/controllers/backoffice/contact_controller.ex
[`TransportWeb.SessionController`]: https://github.com/etalab/transport-site/blob/4698271861462b72ea2eed4c310c301562ad3eee/apps/transport/lib/transport_web/controllers/session_controller.ex#L57
[`Transport.Jobs.UpdateContactsJob`]: https://github.com/etalab/transport-site/blob/master/apps/transport/lib/jobs/update_contacts_job.ex
[une session dure 15 jours]: https://github.com/etalab/transport-site/blob/4698271861462b72ea2eed4c310c301562ad3eee/apps/transport/lib/transport_web/endpoint.ex#L5-L12
[`Transport.Jobs.NotificationSubscriptionProducerJob`]: https://github.com/etalab/transport-site/blob/master/apps/transport/lib/jobs/notification_subscription_producer_job.ex
Loading
Loading