mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
702 lines
22 KiB
Rust
702 lines
22 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
use fluxer_common::config::{parse_bool, parse_number};
|
|
|
|
use anyhow::Context as _;
|
|
use fluxer_common::secret::SecretString;
|
|
use serde::Deserialize;
|
|
use std::net::{IpAddr, SocketAddr};
|
|
|
|
pub const RPC_AUTH_HEADER: &str = "x-fluxer-rpc-auth";
|
|
pub const DEFAULT_APP_ID: &str = "stable";
|
|
|
|
const RPC_PATH: &str = "/internal/rpc";
|
|
const DEFAULT_HOST: &str = "0.0.0.0";
|
|
const DEFAULT_DELIVERY_PORT: u16 = 8126;
|
|
const DEFAULT_RELAY_PORT: u16 = 8127;
|
|
const DEFAULT_NATS_URL: &str = "nats://127.0.0.1:4222";
|
|
const DEFAULT_QUEUE_CAPACITY: usize = 10_000;
|
|
const DEFAULT_SEND_CONCURRENCY: usize = 256;
|
|
const DEFAULT_RELAY_MAX_CONCURRENT: usize = 1_024;
|
|
const DEFAULT_RELAY_MAX_BODY_BYTES: usize = 2_816;
|
|
const DEFAULT_TRUSTED_PROXY_HOPS: usize = 1;
|
|
const DEFAULT_DEVICE_TOKEN_BUCKET_ENTRIES: usize = 1_000_000;
|
|
const DEFAULT_DEVICE_TOKEN_BUCKET_PER_MINUTE: u32 = 60;
|
|
const DEFAULT_DEVICE_TOKEN_BUCKET_BURST: u32 = 20;
|
|
const DEFAULT_SOURCE_BUCKET_ENTRIES: usize = 100_000;
|
|
const DEFAULT_SOURCE_BUCKET_PER_MINUTE: u32 = 600;
|
|
const DEFAULT_SOURCE_BUCKET_BURST: u32 = 200;
|
|
const DEFAULT_FCM_TOKEN_URI: &str = "https://oauth2.googleapis.com/token";
|
|
const DEFAULT_FCM_BASE_URL: &str = "https://fcm.googleapis.com";
|
|
const DEFAULT_CLIENT_IP_HEADER_NAME: &str = "x-forwarded-for";
|
|
const APNS_PRODUCTION_BASE_URL: &str = "https://api.push.apple.com";
|
|
const APNS_DEVELOPMENT_BASE_URL: &str = "https://api.sandbox.push.apple.com";
|
|
const DEFAULT_MANAGED_RELAY_HOST: &str = "push.fluxer.com";
|
|
const VOIP_TOPIC_SUFFIX: &str = ".voip";
|
|
|
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, clap::ValueEnum)]
|
|
pub enum Mode {
|
|
#[default]
|
|
Delivery,
|
|
Relay,
|
|
}
|
|
|
|
impl Mode {
|
|
pub fn default_port(self) -> u16 {
|
|
match self {
|
|
Self::Delivery => DEFAULT_DELIVERY_PORT,
|
|
Self::Relay => DEFAULT_RELAY_PORT,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum ProviderEnvironment {
|
|
Production,
|
|
Development,
|
|
}
|
|
|
|
impl ProviderEnvironment {
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Production => "production",
|
|
Self::Development => "development",
|
|
}
|
|
}
|
|
|
|
pub fn from_label(raw: &str) -> Option<Self> {
|
|
match raw {
|
|
"production" => Some(Self::Production),
|
|
"development" => Some(Self::Development),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct ProviderApp {
|
|
pub app_id: String,
|
|
pub topic: Option<String>,
|
|
pub voip_topic: Option<String>,
|
|
pub environment: Option<ProviderEnvironment>,
|
|
pub project_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct NatsConfig {
|
|
pub url: String,
|
|
pub auth_token: Option<SecretString>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct RpcConfig {
|
|
pub url: String,
|
|
pub auth_token: SecretString,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct VapidConfig {
|
|
pub email: String,
|
|
pub public_key: String,
|
|
pub private_key: SecretString,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct ApnsConfig {
|
|
pub team_id: String,
|
|
pub key_id: String,
|
|
pub private_key: SecretString,
|
|
pub default_environment: ProviderEnvironment,
|
|
pub apps: Vec<ProviderApp>,
|
|
pub base_url_override: Option<String>,
|
|
}
|
|
|
|
impl ApnsConfig {
|
|
pub fn topic_for(&self, app_id: &str, environment: ProviderEnvironment) -> Option<&str> {
|
|
self.topic_by(app_id, environment, |app| app.topic.as_deref())
|
|
}
|
|
|
|
pub fn voip_topic_for(&self, app_id: &str, environment: ProviderEnvironment) -> Option<&str> {
|
|
self.topic_by(app_id, environment, |app| app.voip_topic.as_deref())
|
|
}
|
|
|
|
fn topic_by(
|
|
&self,
|
|
app_id: &str,
|
|
environment: ProviderEnvironment,
|
|
topic: fn(&ProviderApp) -> Option<&str>,
|
|
) -> Option<&str> {
|
|
let listed = |app: &&ProviderApp| app.app_id == app_id && topic(app).is_some();
|
|
self.apps
|
|
.iter()
|
|
.find(|app| listed(app) && app.environment == Some(environment))
|
|
.or_else(|| self.apps.iter().find(listed))
|
|
.and_then(topic)
|
|
}
|
|
|
|
pub fn base_url(&self, environment: ProviderEnvironment) -> &str {
|
|
if let Some(base_url) = self.base_url_override.as_deref() {
|
|
return base_url;
|
|
}
|
|
match environment {
|
|
ProviderEnvironment::Production => APNS_PRODUCTION_BASE_URL,
|
|
ProviderEnvironment::Development => APNS_DEVELOPMENT_BASE_URL,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct FcmConfig {
|
|
pub project_id: String,
|
|
pub client_email: String,
|
|
pub private_key: SecretString,
|
|
pub token_uri: String,
|
|
pub apps: Vec<ProviderApp>,
|
|
pub base_url: String,
|
|
}
|
|
|
|
impl FcmConfig {
|
|
pub fn project_id_for(&self, app_id: &str) -> &str {
|
|
self.apps
|
|
.iter()
|
|
.find(|app| app.app_id == app_id && app.project_id.is_some())
|
|
.and_then(|app| app.project_id.as_deref())
|
|
.unwrap_or(self.project_id.as_str())
|
|
}
|
|
|
|
pub fn listed_project_id(&self, app_id: &str) -> Option<&str> {
|
|
self.apps
|
|
.iter()
|
|
.find(|app| app.app_id == app_id)
|
|
.map(|app| {
|
|
app.project_id
|
|
.as_deref()
|
|
.unwrap_or(self.project_id.as_str())
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct DeliveryConfig {
|
|
pub bind_addr: SocketAddr,
|
|
pub nats: NatsConfig,
|
|
pub rpc: RpcConfig,
|
|
pub queue_capacity: usize,
|
|
pub send_concurrency: usize,
|
|
pub vapid: VapidConfig,
|
|
pub apns: Option<ApnsConfig>,
|
|
pub fcm: Option<FcmConfig>,
|
|
pub own_relay_hosts: Vec<String>,
|
|
pub private_hosts: Vec<String>,
|
|
pub managed_relay_hosts: Vec<String>,
|
|
pub relay_consent_accepted: bool,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct BucketConfig {
|
|
pub entries: usize,
|
|
pub per_minute: u32,
|
|
pub burst: u32,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct RelayConfig {
|
|
pub bind_addr: SocketAddr,
|
|
pub max_concurrent: usize,
|
|
pub max_body_bytes: usize,
|
|
pub trust_client_ip_header: bool,
|
|
pub client_ip_header_name: String,
|
|
pub trusted_proxy_hops: usize,
|
|
pub device_token_bucket: BucketConfig,
|
|
pub source_bucket: Option<BucketConfig>,
|
|
pub apns: Option<ApnsConfig>,
|
|
pub fcm: Option<FcmConfig>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum Config {
|
|
Delivery(Box<DeliveryConfig>),
|
|
Relay(Box<RelayConfig>),
|
|
}
|
|
|
|
impl Config {
|
|
pub fn load_from_iter<I, K, V>(mode: Mode, vars: I) -> anyhow::Result<Self>
|
|
where
|
|
I: IntoIterator<Item = (K, V)>,
|
|
K: Into<String>,
|
|
V: Into<String>,
|
|
{
|
|
Ok(match mode {
|
|
Mode::Delivery => Self::Delivery(Box::new(DeliveryConfig::load_from_iter(vars)?)),
|
|
Mode::Relay => Self::Relay(Box::new(RelayConfig::load_from_iter(vars)?)),
|
|
})
|
|
}
|
|
|
|
pub fn bind_addr_mut(&mut self) -> &mut SocketAddr {
|
|
match self {
|
|
Self::Delivery(cfg) => &mut cfg.bind_addr,
|
|
Self::Relay(cfg) => &mut cfg.bind_addr,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl DeliveryConfig {
|
|
pub fn load_from_iter<I, K, V>(vars: I) -> anyhow::Result<Self>
|
|
where
|
|
I: IntoIterator<Item = (K, V)>,
|
|
K: Into<String>,
|
|
V: Into<String>,
|
|
{
|
|
let env = Env::from_iter(vars);
|
|
Ok(Self {
|
|
bind_addr: bind_addr(&env, Mode::Delivery)?,
|
|
nats: nats_config(&env),
|
|
rpc: rpc_config(&env)?,
|
|
queue_capacity: parse_number(
|
|
"FLUXER_PUSH_SERVICE_QUEUE_CAPACITY",
|
|
env.get("FLUXER_PUSH_SERVICE_QUEUE_CAPACITY"),
|
|
DEFAULT_QUEUE_CAPACITY,
|
|
1,
|
|
1_000_000,
|
|
)?,
|
|
send_concurrency: parse_number(
|
|
"FLUXER_PUSH_SERVICE_SEND_CONCURRENCY",
|
|
env.get("FLUXER_PUSH_SERVICE_SEND_CONCURRENCY"),
|
|
DEFAULT_SEND_CONCURRENCY,
|
|
1,
|
|
65_536,
|
|
)?,
|
|
vapid: vapid_config(&env)?,
|
|
apns: apns_config(&env)?,
|
|
fcm: fcm_config(&env)?,
|
|
own_relay_hosts: host_list(&env, "FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS"),
|
|
private_hosts: host_list(&env, "FLUXER_PUSH_SERVICE_PRIVATE_HOSTS"),
|
|
managed_relay_hosts: managed_relay_hosts(&env),
|
|
relay_consent_accepted: parse_bool(
|
|
"FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED",
|
|
env.get("FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED"),
|
|
)?
|
|
.unwrap_or(false),
|
|
})
|
|
}
|
|
}
|
|
|
|
fn host_list(env: &Env, key: &str) -> Vec<String> {
|
|
env.get(key)
|
|
.unwrap_or_default()
|
|
.split(',')
|
|
.map(|host| host.trim().to_ascii_lowercase())
|
|
.filter(|host| !host.is_empty())
|
|
.collect()
|
|
}
|
|
|
|
fn managed_relay_hosts(env: &Env) -> Vec<String> {
|
|
let Some(raw) = env.get("FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS") else {
|
|
return vec![DEFAULT_MANAGED_RELAY_HOST.to_owned()];
|
|
};
|
|
raw.split(',')
|
|
.map(|host| host.trim().to_ascii_lowercase())
|
|
.filter(|host| !host.is_empty())
|
|
.collect()
|
|
}
|
|
|
|
impl RelayConfig {
|
|
pub fn load_from_iter<I, K, V>(vars: I) -> anyhow::Result<Self>
|
|
where
|
|
I: IntoIterator<Item = (K, V)>,
|
|
K: Into<String>,
|
|
V: Into<String>,
|
|
{
|
|
let env = Env::from_iter(vars);
|
|
let cfg = Self {
|
|
bind_addr: bind_addr(&env, Mode::Relay)?,
|
|
max_concurrent: parse_number(
|
|
"FLUXER_PUSH_RELAY_MAX_CONCURRENT",
|
|
env.get("FLUXER_PUSH_RELAY_MAX_CONCURRENT"),
|
|
DEFAULT_RELAY_MAX_CONCURRENT,
|
|
1,
|
|
1_000_000,
|
|
)?,
|
|
max_body_bytes: parse_number(
|
|
"FLUXER_PUSH_RELAY_MAX_BODY_BYTES",
|
|
env.get("FLUXER_PUSH_RELAY_MAX_BODY_BYTES"),
|
|
DEFAULT_RELAY_MAX_BODY_BYTES,
|
|
1,
|
|
1_048_576,
|
|
)?,
|
|
trust_client_ip_header: parse_bool(
|
|
"FLUXER_TRUST_CLIENT_IP_HEADER",
|
|
env.get("FLUXER_TRUST_CLIENT_IP_HEADER"),
|
|
)?
|
|
.unwrap_or(false),
|
|
client_ip_header_name: env
|
|
.get("FLUXER_CLIENT_IP_HEADER_NAME")
|
|
.unwrap_or(DEFAULT_CLIENT_IP_HEADER_NAME)
|
|
.to_ascii_lowercase(),
|
|
trusted_proxy_hops: parse_number(
|
|
"FLUXER_PUSH_RELAY_TRUSTED_PROXY_HOPS",
|
|
env.get("FLUXER_PUSH_RELAY_TRUSTED_PROXY_HOPS"),
|
|
DEFAULT_TRUSTED_PROXY_HOPS,
|
|
0,
|
|
8,
|
|
)?,
|
|
device_token_bucket: bucket_config(
|
|
&env,
|
|
"FLUXER_PUSH_RELAY_TOKEN_BUCKET",
|
|
BucketConfig {
|
|
entries: DEFAULT_DEVICE_TOKEN_BUCKET_ENTRIES,
|
|
per_minute: DEFAULT_DEVICE_TOKEN_BUCKET_PER_MINUTE,
|
|
burst: DEFAULT_DEVICE_TOKEN_BUCKET_BURST,
|
|
},
|
|
)?,
|
|
source_bucket: source_bucket_config(&env)?,
|
|
apns: apns_config(&env)?,
|
|
fcm: fcm_config(&env)?,
|
|
};
|
|
for (var_name, apps) in [
|
|
(
|
|
"FLUXER_PUSH_APNS_APPS",
|
|
cfg.apns.as_ref().map(|apns| &apns.apps),
|
|
),
|
|
(
|
|
"FLUXER_PUSH_FCM_APPS",
|
|
cfg.fcm.as_ref().map(|fcm| &fcm.apps),
|
|
),
|
|
] {
|
|
anyhow::ensure!(
|
|
apps.is_none_or(|apps| !apps.is_empty()),
|
|
"{var_name} must list at least one app"
|
|
);
|
|
}
|
|
Ok(cfg)
|
|
}
|
|
}
|
|
|
|
fn bind_addr(env: &Env, mode: Mode) -> anyhow::Result<SocketAddr> {
|
|
let host = env.get("FLUXER_PUSH_SERVICE_HOST").unwrap_or(DEFAULT_HOST);
|
|
let ip = host
|
|
.parse::<IpAddr>()
|
|
.with_context(|| format!("FLUXER_PUSH_SERVICE_HOST is not an IP address: {host}"))?;
|
|
let port = parse_number(
|
|
"FLUXER_PUSH_SERVICE_PORT",
|
|
env.get("FLUXER_PUSH_SERVICE_PORT"),
|
|
mode.default_port(),
|
|
1,
|
|
u16::MAX,
|
|
)?;
|
|
Ok(SocketAddr::new(ip, port))
|
|
}
|
|
|
|
fn nats_config(env: &Env) -> NatsConfig {
|
|
NatsConfig {
|
|
url: env
|
|
.get("FLUXER_SVC_NATS_URL")
|
|
.unwrap_or(DEFAULT_NATS_URL)
|
|
.to_owned(),
|
|
auth_token: env
|
|
.get("FLUXER_NATS_AUTH_TOKEN")
|
|
.map(|token| SecretString::new(token.to_owned())),
|
|
}
|
|
}
|
|
|
|
fn rpc_config(env: &Env) -> anyhow::Result<RpcConfig> {
|
|
let api_endpoint = env
|
|
.require("FLUXER_INTERNAL_API_ENDPOINT")?
|
|
.trim_end_matches('/')
|
|
.to_owned();
|
|
anyhow::ensure!(
|
|
!api_endpoint.is_empty(),
|
|
"FLUXER_INTERNAL_API_ENDPOINT must not be only slashes"
|
|
);
|
|
Ok(RpcConfig {
|
|
url: format!("{api_endpoint}{RPC_PATH}"),
|
|
auth_token: SecretString::new(env.require("FLUXER_GATEWAY_RPC_AUTH_TOKEN")?.to_owned()),
|
|
})
|
|
}
|
|
|
|
fn vapid_config(env: &Env) -> anyhow::Result<VapidConfig> {
|
|
Ok(VapidConfig {
|
|
email: env.require("FLUXER_VAPID_EMAIL")?.to_owned(),
|
|
public_key: env.require("FLUXER_VAPID_PUBLIC_KEY")?.to_owned(),
|
|
private_key: SecretString::new(env.require("FLUXER_VAPID_PRIVATE_KEY")?.to_owned()),
|
|
})
|
|
}
|
|
|
|
fn bucket_config(env: &Env, prefix: &str, defaults: BucketConfig) -> anyhow::Result<BucketConfig> {
|
|
let entries_var = format!("{prefix}_ENTRIES");
|
|
let per_minute_var = format!("{prefix}_PER_MINUTE");
|
|
let burst_var = format!("{prefix}_BURST");
|
|
Ok(BucketConfig {
|
|
entries: parse_number(
|
|
&entries_var,
|
|
env.get(&entries_var),
|
|
defaults.entries,
|
|
1,
|
|
100_000_000,
|
|
)?,
|
|
per_minute: parse_number(
|
|
&per_minute_var,
|
|
env.get(&per_minute_var),
|
|
defaults.per_minute,
|
|
1,
|
|
1_000_000,
|
|
)?,
|
|
burst: parse_number(
|
|
&burst_var,
|
|
env.get(&burst_var),
|
|
defaults.burst,
|
|
1,
|
|
1_000_000,
|
|
)?,
|
|
})
|
|
}
|
|
|
|
fn source_bucket_config(env: &Env) -> anyhow::Result<Option<BucketConfig>> {
|
|
if !parse_bool(
|
|
"FLUXER_PUSH_RELAY_SOURCE_BUCKET_ENABLED",
|
|
env.get("FLUXER_PUSH_RELAY_SOURCE_BUCKET_ENABLED"),
|
|
)?
|
|
.unwrap_or(false)
|
|
{
|
|
return Ok(None);
|
|
}
|
|
bucket_config(
|
|
env,
|
|
"FLUXER_PUSH_RELAY_SOURCE_BUCKET",
|
|
BucketConfig {
|
|
entries: DEFAULT_SOURCE_BUCKET_ENTRIES,
|
|
per_minute: DEFAULT_SOURCE_BUCKET_PER_MINUTE,
|
|
burst: DEFAULT_SOURCE_BUCKET_BURST,
|
|
},
|
|
)
|
|
.map(Some)
|
|
}
|
|
|
|
fn apns_config(env: &Env) -> anyhow::Result<Option<ApnsConfig>> {
|
|
if !parse_bool(
|
|
"FLUXER_PUSH_APNS_ENABLED",
|
|
env.get("FLUXER_PUSH_APNS_ENABLED"),
|
|
)?
|
|
.unwrap_or(false)
|
|
{
|
|
return Ok(None);
|
|
}
|
|
let default_environment = match env.get("FLUXER_PUSH_APNS_DEFAULT_ENVIRONMENT") {
|
|
Some(raw) => parse_environment("FLUXER_PUSH_APNS_DEFAULT_ENVIRONMENT", raw)?,
|
|
None => ProviderEnvironment::Production,
|
|
};
|
|
Ok(Some(ApnsConfig {
|
|
team_id: required_field(
|
|
env.get("FLUXER_PUSH_APNS_TEAM_ID"),
|
|
"FLUXER_PUSH_APNS_TEAM_ID",
|
|
"APNs push",
|
|
)?
|
|
.to_owned(),
|
|
key_id: required_field(
|
|
env.get("FLUXER_PUSH_APNS_KEY_ID"),
|
|
"FLUXER_PUSH_APNS_KEY_ID",
|
|
"APNs push",
|
|
)?
|
|
.to_owned(),
|
|
private_key: SecretString::new(read_key(
|
|
env,
|
|
"FLUXER_PUSH_APNS_PRIVATE_KEY",
|
|
"FLUXER_PUSH_APNS_PRIVATE_KEY_PATH",
|
|
"APNs push",
|
|
)?),
|
|
default_environment,
|
|
apps: parse_apps("FLUXER_PUSH_APNS_APPS", env.get("FLUXER_PUSH_APNS_APPS"))?,
|
|
base_url_override: env
|
|
.get("FLUXER_PUSH_SERVICE_APNS_BASE_URL")
|
|
.map(ToOwned::to_owned),
|
|
}))
|
|
}
|
|
|
|
fn fcm_config(env: &Env) -> anyhow::Result<Option<FcmConfig>> {
|
|
if !parse_bool(
|
|
"FLUXER_PUSH_FCM_ENABLED",
|
|
env.get("FLUXER_PUSH_FCM_ENABLED"),
|
|
)?
|
|
.unwrap_or(false)
|
|
{
|
|
return Ok(None);
|
|
}
|
|
let service_account = match env.get("FLUXER_PUSH_FCM_SERVICE_ACCOUNT_JSON_PATH") {
|
|
Some(path) => Some(read_service_account(path)?),
|
|
None => None,
|
|
};
|
|
let project_id = env
|
|
.get("FLUXER_PUSH_FCM_PROJECT_ID")
|
|
.map(ToOwned::to_owned)
|
|
.or_else(|| service_account.as_ref().and_then(|a| a.project_id.clone()));
|
|
let client_email = env
|
|
.get("FLUXER_PUSH_FCM_CLIENT_EMAIL")
|
|
.map(ToOwned::to_owned)
|
|
.or_else(|| {
|
|
service_account
|
|
.as_ref()
|
|
.and_then(|a| a.client_email.clone())
|
|
});
|
|
let private_key = match env.get("FLUXER_PUSH_FCM_PRIVATE_KEY") {
|
|
Some(key) => key.to_owned(),
|
|
None => match env.get("FLUXER_PUSH_FCM_PRIVATE_KEY_PATH") {
|
|
Some(path) => read_file("FLUXER_PUSH_FCM_PRIVATE_KEY_PATH", path)?,
|
|
None => service_account
|
|
.as_ref()
|
|
.and_then(|a| a.private_key.clone())
|
|
.ok_or_else(|| {
|
|
anyhow::anyhow!(
|
|
"FLUXER_PUSH_FCM_PRIVATE_KEY or FLUXER_PUSH_FCM_PRIVATE_KEY_PATH is required when FCM push is enabled"
|
|
)
|
|
})?,
|
|
},
|
|
};
|
|
|
|
Ok(Some(FcmConfig {
|
|
project_id: required_field(
|
|
project_id.as_deref(),
|
|
"FLUXER_PUSH_FCM_PROJECT_ID",
|
|
"FCM push",
|
|
)?
|
|
.to_owned(),
|
|
client_email: required_field(
|
|
client_email.as_deref(),
|
|
"FLUXER_PUSH_FCM_CLIENT_EMAIL",
|
|
"FCM push",
|
|
)?
|
|
.to_owned(),
|
|
private_key: SecretString::new(private_key),
|
|
token_uri: env
|
|
.get("FLUXER_PUSH_FCM_TOKEN_URI")
|
|
.unwrap_or(DEFAULT_FCM_TOKEN_URI)
|
|
.to_owned(),
|
|
apps: parse_apps("FLUXER_PUSH_FCM_APPS", env.get("FLUXER_PUSH_FCM_APPS"))?,
|
|
base_url: env
|
|
.get("FLUXER_PUSH_SERVICE_FCM_BASE_URL")
|
|
.unwrap_or(DEFAULT_FCM_BASE_URL)
|
|
.to_owned(),
|
|
}))
|
|
}
|
|
|
|
#[derive(Default, Deserialize)]
|
|
struct ServiceAccount {
|
|
project_id: Option<String>,
|
|
client_email: Option<String>,
|
|
private_key: Option<String>,
|
|
}
|
|
|
|
fn read_service_account(path: &str) -> anyhow::Result<ServiceAccount> {
|
|
let raw = read_file("FLUXER_PUSH_FCM_SERVICE_ACCOUNT_JSON_PATH", path)?;
|
|
serde_json::from_str(&raw).with_context(|| {
|
|
format!("FLUXER_PUSH_FCM_SERVICE_ACCOUNT_JSON_PATH is not a service account JSON: {path}")
|
|
})
|
|
}
|
|
|
|
fn read_key(env: &Env, key_var: &str, path_var: &str, provider: &str) -> anyhow::Result<String> {
|
|
if let Some(key) = env.get(key_var) {
|
|
return Ok(key.to_owned());
|
|
}
|
|
match env.get(path_var) {
|
|
Some(path) => read_file(path_var, path),
|
|
None => Err(anyhow::anyhow!(
|
|
"{key_var} or {path_var} is required when {provider} is enabled"
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn read_file(var_name: &str, path: &str) -> anyhow::Result<String> {
|
|
std::fs::read_to_string(path).with_context(|| format!("{var_name} could not be read: {path}"))
|
|
}
|
|
|
|
fn required_field<'a>(
|
|
value: Option<&'a str>,
|
|
var_name: &str,
|
|
provider: &str,
|
|
) -> anyhow::Result<&'a str> {
|
|
value.ok_or_else(|| anyhow::anyhow!("{var_name} is required when {provider} is configured"))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct RawProviderApp {
|
|
app_id: Option<String>,
|
|
topic: Option<String>,
|
|
environment: Option<String>,
|
|
project_id: Option<String>,
|
|
}
|
|
|
|
fn parse_apps(var_name: &str, raw: Option<&str>) -> anyhow::Result<Vec<ProviderApp>> {
|
|
let Some(raw) = raw else {
|
|
return Ok(Vec::new());
|
|
};
|
|
let entries: Vec<RawProviderApp> = serde_json::from_str(raw)
|
|
.with_context(|| format!("{var_name} must be a JSON array of push app objects"))?;
|
|
entries
|
|
.into_iter()
|
|
.map(|entry| {
|
|
let app_id = entry
|
|
.app_id
|
|
.filter(|app_id| !app_id.trim().is_empty())
|
|
.ok_or_else(|| anyhow::anyhow!("{var_name} contains an entry with no app_id"))?;
|
|
let environment = match entry.environment.as_deref() {
|
|
Some(value) => Some(parse_environment(var_name, value)?),
|
|
None => None,
|
|
};
|
|
let topic = entry
|
|
.topic
|
|
.map(|topic| topic.trim().to_owned())
|
|
.filter(|topic| !topic.is_empty());
|
|
if let Some(topic) = topic.as_deref() {
|
|
anyhow::ensure!(
|
|
!topic.ends_with(VOIP_TOPIC_SUFFIX),
|
|
"{var_name} lists a topic that already ends in {VOIP_TOPIC_SUFFIX}: {topic}"
|
|
);
|
|
}
|
|
Ok(ProviderApp {
|
|
app_id,
|
|
voip_topic: topic
|
|
.as_deref()
|
|
.map(|topic| format!("{topic}{VOIP_TOPIC_SUFFIX}")),
|
|
topic,
|
|
environment,
|
|
project_id: entry
|
|
.project_id
|
|
.filter(|project_id| !project_id.trim().is_empty()),
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn parse_environment(var_name: &str, raw: &str) -> anyhow::Result<ProviderEnvironment> {
|
|
ProviderEnvironment::from_label(&raw.trim().to_ascii_lowercase())
|
|
.ok_or_else(|| anyhow::anyhow!("{var_name} must be one of: production, development"))
|
|
}
|
|
|
|
struct Env(Vec<(String, String)>);
|
|
|
|
impl Env {
|
|
fn from_iter<I, K, V>(vars: I) -> Self
|
|
where
|
|
I: IntoIterator<Item = (K, V)>,
|
|
K: Into<String>,
|
|
V: Into<String>,
|
|
{
|
|
Self(
|
|
vars.into_iter()
|
|
.map(|(key, value)| (key.into(), value.into()))
|
|
.collect(),
|
|
)
|
|
}
|
|
|
|
fn get(&self, key: &str) -> Option<&str> {
|
|
self.0
|
|
.iter()
|
|
.find_map(|(name, value)| (name == key).then_some(value.trim()))
|
|
.filter(|value| !value.is_empty())
|
|
}
|
|
|
|
fn require(&self, key: &str) -> anyhow::Result<&str> {
|
|
self.get(key)
|
|
.ok_or_else(|| anyhow::anyhow!("{key} is required"))
|
|
}
|
|
}
|