Files
decision/crates/ld-server/src/config.rs
T

208 lines
8.4 KiB
Rust

//! Configuration, from the environment only. Secrets never have a default.
use std::path::PathBuf;
#[derive(Clone, Debug)]
pub struct Connect {
/// Base URL of the sso-connect instance (no trailing slash).
pub base_url: String,
pub client_id: String,
pub client_secret: String,
}
#[derive(Clone, Debug)]
pub struct Publish {
/// Remote of the texts repository, e.g. `gitea@git.open.us.org:yvv/decision-depot-textes-reference.git`.
pub remote: String,
pub branch: String,
/// Private SSH key allowed to push there (deploy key).
pub key: PathBuf,
pub author_name: String,
pub author_email: String,
/// Publish a document even when none of its articles is in force
/// (`LD_PUBLISH_WITHOUT_ARTICLES`). Off by default — Yvv, 2026-11-02: « Suspendre
/// jusqu'au 1er texte »: the queue waits, and the first text that enters sends it
/// (ADR 0011).
pub without_articles: bool,
/// Publish a document even while a text, an order, a title or a level of inertia
/// in effect entered by simulated votes does not hold on the proven votes alone
/// (`LD_PUBLISH_SIMULATED`). Off — Yvv, 2026-11-03: « Pas avant des votes
/// prouvés »: in force on the site, never published before the proven votes carry
/// it (ADR 0012, `publish::waits_for_proven_votes`).
pub simulated: bool,
}
/// The vote on the Ğ1 chain (ADR 0008): where the 0.1 Ğ1 of each vote go, how
/// much, and the nodes the page sends the transaction to. Without
/// `LD_VOTE_ACCOUNT`, voting through the chain is off, and the interface says so.
#[derive(Clone, Debug)]
pub struct VoteChain {
/// The vote account, as a Ğ1 address (`g1…`), whatever form it was given in.
pub account: String,
pub account_key: [u8; 32],
/// Cents of Ğ1 per vote (`LD_VOTE_AMOUNT`, 10 = 0.1 Ğ1 by default).
pub amount: u64,
/// WebSocket endpoints of Ğ1 nodes, for the page (`LD_VOTE_NODES`).
pub nodes: Vec<String>,
/// Tests only, with the dev login: the page sends to this fake node instead
/// of building the transaction with polkadot-api (`LD_VOTE_TEST_NODE`).
pub test_node: Option<String>,
}
impl VoteChain {
pub const DEFAULT_AMOUNT: u64 = 10;
/// From the account in any SS58 form; `None` if it is not an address.
pub fn new(account: &str, amount: u64, nodes: Vec<String>, test_node: Option<String>) -> Option<Self> {
let (_, key) = ld_engine::address::decode(account.trim())?;
Some(Self {
account: ld_engine::address::encode(ld_engine::address::G1_PREFIX, &key),
account_key: key,
amount: amount.max(1),
nodes,
test_node,
})
}
}
#[derive(Clone, Debug)]
pub struct Config {
pub bind: String,
pub data_dir: PathBuf,
pub web_dir: PathBuf,
pub seed_dir: PathBuf,
/// Public origin, e.g. `https://decision.librodrome.org` — callbacks and cookies.
pub public_url: String,
pub connect: Option<Connect>,
pub admin_g1_ids: Vec<String>,
pub dev_login: bool,
pub bootstrap_admin: bool,
pub publish: Option<Publish>,
pub rpc_endpoints: Vec<String>,
pub indexers: Vec<String>,
pub wot_fallback: u32,
pub build: String,
/// The vote on the chain; `None` = off (no `LD_VOTE_ACCOUNT`).
pub vote: Option<VoteChain>,
/// Whether a simulated vote counts for adoption (ADR 0012):
/// `ld_engine::buffer::SIMULATED_ADOPTS` — Yvv, 2026-11-02: « Il compte, puis
/// s'efface »: it counts until the act « supprime » sets it aside.
pub simulated_adopts: bool,
}
fn var(name: &str) -> Option<String> {
std::env::var(name).ok().map(|v| v.trim().to_owned()).filter(|v| !v.is_empty())
}
fn flag(name: &str) -> bool {
var(name).is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on"))
}
fn list(name: &str, default: &[&str]) -> Vec<String> {
match var(name) {
Some(v) => v.split(',').map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()).collect(),
None => default.iter().map(|s| (*s).to_owned()).collect(),
}
}
impl Config {
pub fn from_env() -> Self {
let data_dir = PathBuf::from(var("LD_DATA_DIR").unwrap_or_else(|| "data".into()));
let connect = match (var("LD_CONNECT_CLIENT_ID"), var("LD_CONNECT_CLIENT_SECRET")) {
(Some(client_id), Some(client_secret)) => Some(Connect {
base_url: var("LD_CONNECT_URL")
.unwrap_or_else(|| "https://connect.monnaie-libre.fr".into())
.trim_end_matches('/')
.to_owned(),
client_id,
client_secret,
}),
_ => None,
};
let publish = var("LD_PUBLISH_REMOTE").map(|remote| Publish {
remote,
branch: var("LD_PUBLISH_BRANCH").unwrap_or_else(|| "main".into()),
key: var("LD_PUBLISH_KEY").map(PathBuf::from).unwrap_or_else(|| data_dir.join("keys/publish_ed25519")),
author_name: var("LD_PUBLISH_AUTHOR").unwrap_or_else(|| "libreDecision".into()),
author_email: var("LD_PUBLISH_EMAIL").unwrap_or_else(|| "decision@librodrome.org".into()),
without_articles: flag("LD_PUBLISH_WITHOUT_ARTICLES"),
simulated: flag("LD_PUBLISH_SIMULATED"),
});
let dev_login = flag("LD_DEV_LOGIN");
let vote = var("LD_VOTE_ACCOUNT").and_then(|account| {
let amount = var("LD_VOTE_AMOUNT").and_then(|v| v.parse().ok()).unwrap_or(VoteChain::DEFAULT_AMOUNT);
let nodes = list("LD_VOTE_NODES", &["wss://rpc.duniter.org", "wss://g1.axiom-team.fr/ws"]);
// A fake node for the page, never outside the local dev login.
let test_node = var("LD_VOTE_TEST_NODE").filter(|_| dev_login);
let chain = VoteChain::new(&account, amount, nodes, test_node);
if chain.is_none() {
tracing::error!("LD_VOTE_ACCOUNT is not a Ğ1 address: the vote through the chain stays off");
}
chain
});
Self {
bind: var("LD_BIND").unwrap_or_else(|| "0.0.0.0:8002".into()),
web_dir: PathBuf::from(var("LD_WEB_DIR").unwrap_or_else(|| "web/build".into())),
seed_dir: PathBuf::from(var("LD_SEED_DIR").unwrap_or_else(|| "seeds".into())),
public_url: var("LD_PUBLIC_URL")
.unwrap_or_else(|| "http://127.0.0.1:3002".into())
.trim_end_matches('/')
.to_owned(),
connect,
admin_g1_ids: list("LD_ADMIN_G1_IDS", &[]),
dev_login,
bootstrap_admin: flag("LD_BOOTSTRAP_ADMIN"),
publish,
rpc_endpoints: list(
"LD_RPC_ENDPOINTS",
&[
"https://rpc.duniter.org",
"https://g1.axiom-team.fr/ws/",
"https://g1.asycn.io/ws/",
"https://g1.coinduf.eu",
],
),
indexers: list(
"LD_INDEXERS",
&["https://indexer.duniter.org/v1/graphql", "https://g1-squid.axiom-team.fr/v1/graphql"],
),
wot_fallback: var("LD_WOT_FALLBACK").and_then(|v| v.parse().ok()).unwrap_or(5745),
build: var("LD_BUILD").unwrap_or_else(|| "dev".into()),
vote,
simulated_adopts: ld_engine::buffer::SIMULATED_ADOPTS,
data_dir,
}
}
/// Cookies are `Secure` as soon as the public origin is https.
pub fn secure_cookies(&self) -> bool {
self.public_url.starts_with("https://")
}
pub fn connect_callback(&self) -> String {
format!("{}/api/auth/g1/callback", self.public_url)
}
/// For tests: an in-memory configuration with dev login.
pub fn for_tests() -> Self {
Self {
bind: "127.0.0.1:0".into(),
data_dir: std::env::temp_dir().join("ld-tests"),
web_dir: PathBuf::from("web/build"),
seed_dir: PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../../seeds")),
public_url: "http://127.0.0.1:3002".into(),
connect: None,
admin_g1_ids: vec!["idty:8352".into()],
dev_login: true,
bootstrap_admin: false,
publish: None,
rpc_endpoints: vec![],
indexers: vec![],
wot_fallback: 5745,
build: "test".into(),
vote: None,
simulated_adopts: ld_engine::buffer::SIMULATED_ADOPTS,
}
}
}