1use std::path::Path;
4use std::sync::{OnceLock, RwLock};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use crate::config::{self, ServeConfig};
8
9use super::cache_key;
10
11#[cfg(feature = "redis")]
12use pharos_runtime_integrations::redis::{
13 RedisConnection, RedisConnectionFactory, RedisError, RedisRuntimeConfig, RedisStatus,
14};
15#[cfg(feature = "redis")]
16use std::cell::RefCell;
17
18#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]
19pub struct CachedSessionRecord {
20 pub user_id: i64,
21 pub expires_at_epoch: u64,
22}
23
24#[derive(Debug, Clone, Default)]
25struct RuntimeCacheState {
26 enabled: bool,
27 cache_sessions_enabled: bool,
28 cache_template_source_enabled: bool,
29 cache_route_match_enabled: bool,
30 cache_config_allowlist_enabled: bool,
31 cache_prefix: String,
32 status: String,
33 #[cfg(feature = "redis")]
34 next_connect_after_epoch: u64,
35 #[cfg(feature = "redis")]
36 current_backoff_seconds: u64,
37 route_match_generation: u64,
38 #[cfg(feature = "redis")]
39 factory: Option<RedisConnectionFactory>,
40}
41
42static RUNTIME_CACHE_STATE: OnceLock<RwLock<RuntimeCacheState>> = OnceLock::new();
43
44#[cfg(feature = "redis")]
45thread_local! {
46 static REDIS_CONNECTION: RefCell<Option<RedisConnection>> = const { RefCell::new(None) };
47}
48
49pub fn install_runtime_cache(config_record: &ServeConfig) {
50 let state_lock = RUNTIME_CACHE_STATE.get_or_init(|| RwLock::new(RuntimeCacheState::default()));
51 let mut state = match state_lock.write() {
52 Ok(state) => state,
53 Err(_) => return,
54 };
55
56 #[cfg(not(feature = "redis"))]
57 {
58 let _ = config_record;
59 *state = RuntimeCacheState {
60 enabled: false,
61 cache_sessions_enabled: false,
62 cache_template_source_enabled: false,
63 cache_route_match_enabled: false,
64 cache_config_allowlist_enabled: false,
65 cache_prefix: "pharos".to_owned(),
66 status: "disabled".to_owned(),
67 route_match_generation: 0,
68 };
69 }
70
71 #[cfg(feature = "redis")]
72 {
73 let runtime_config = runtime_config_from_serve_config(config_record);
74 let factory = RedisConnectionFactory::new(runtime_config.clone()).ok();
75 *state = RuntimeCacheState {
76 enabled: runtime_config.enabled && factory.is_some(),
77 cache_sessions_enabled: runtime_config.enabled && runtime_config.cache_sessions_enabled,
78 cache_template_source_enabled: runtime_config.enabled
79 && runtime_config.cache_template_source_enabled,
80 cache_route_match_enabled: runtime_config.enabled
81 && runtime_config.cache_route_match_enabled,
82 cache_config_allowlist_enabled: runtime_config.enabled
83 && runtime_config.cache_config_allowlist_enabled,
84 cache_prefix: runtime_config.cache_prefix.clone(),
85 status: if runtime_config.enabled && factory.is_some() {
86 RedisStatus::Connecting.as_str().to_owned()
87 } else {
88 RedisStatus::Disabled.as_str().to_owned()
89 },
90 next_connect_after_epoch: 0,
91 current_backoff_seconds: runtime_config.backoff_seconds.max(1),
92 route_match_generation: 0,
93 factory,
94 };
95 REDIS_CONNECTION.with(|cell| {
96 cell.borrow_mut().take();
97 });
98 }
99}
100
101pub fn redis_status() -> String {
102 cache_state_read(|state| state.status.clone()).unwrap_or_else(|| "disabled".to_owned())
103}
104
105pub fn probe_runtime_cache() {
106 #[cfg(feature = "redis")]
107 {
108 let _ = with_connection(|_, _| Ok(()));
109 }
110}
111
112pub fn session_cache_lookup(
113 app_id: &str,
114 session_token_hash: &str,
115 now_epoch_seconds: u64,
116) -> Option<CachedSessionRecord> {
117 if !session_cache_enabled() {
118 return None;
119 }
120 let key = prefix_runtime_key(cache_key::session_value(app_id, session_token_hash)?)?;
121 let text = get_value(&key)?;
122 let record = decode_session_record(&text)?;
123 (record.user_id > 0 && record.expires_at_epoch > now_epoch_seconds).then_some(record)
124}
125
126pub fn session_cache_store(
127 app_id: &str,
128 user_id: i64,
129 session_token_hash: &str,
130 expires_at_epoch: u64,
131) {
132 if !session_cache_enabled() || user_id <= 0 || expires_at_epoch <= now_epoch_seconds() {
133 return;
134 }
135 let Some(key) = cache_key::session_value(app_id, session_token_hash) else {
136 return;
137 };
138 let Some(index_key) = cache_key::session_user_index(app_id, user_id) else {
139 return;
140 };
141 let Some(key) = prefix_runtime_key(key) else {
142 return;
143 };
144 let Some(index_key) = prefix_runtime_key(index_key) else {
145 return;
146 };
147 let ttl = expires_at_epoch.saturating_sub(now_epoch_seconds()).max(1);
148 let value = encode_session_record(CachedSessionRecord {
149 user_id,
150 expires_at_epoch,
151 });
152 let _ = set_value(&key, &value, ttl);
153 let _ = set_add(&index_key, &key, ttl);
154}
155
156pub fn session_cache_revoke_token(app_id: &str, session_token_hash: &str) {
157 if !session_cache_enabled() {
158 return;
159 }
160 let Some(key) = cache_key::session_value(app_id, session_token_hash) else {
161 return;
162 };
163 let Some(key) = prefix_runtime_key(key) else {
164 return;
165 };
166 let _ = delete_value(&key);
167}
168
169pub fn session_cache_revoke_user(app_id: &str, user_id: i64) {
170 if !session_cache_enabled() || user_id <= 0 {
171 return;
172 }
173 let Some(index_key) = cache_key::session_user_index(app_id, user_id) else {
174 return;
175 };
176 let Some(index_key) = prefix_runtime_key(index_key) else {
177 return;
178 };
179 let keys = set_members(&index_key);
180 if !keys.is_empty() {
181 let borrowed = keys.iter().map(String::as_str).collect::<Vec<_>>();
182 let _ = delete_many(&borrowed);
183 }
184 let _ = delete_value(&index_key);
185}
186
187pub fn route_match_lookup(path: &str) -> Option<String> {
188 if !route_match_cache_enabled() {
189 return None;
190 }
191 let generation = cache_state_read(|state| state.route_match_generation).unwrap_or(0);
192 let key = prefix_runtime_key(cache_key::route_match(&format!("{generation}:{path}"))?)?;
193 get_value(&key)
194}
195
196pub fn route_match_store(path: &str, app_id: &str) {
197 if !route_match_cache_enabled() || app_id.is_empty() {
198 return;
199 }
200 let generation = cache_state_read(|state| state.route_match_generation).unwrap_or(0);
201 let Some(key) = cache_key::route_match(&format!("{generation}:{path}")) else {
202 return;
203 };
204 let Some(key) = prefix_runtime_key(key) else {
205 return;
206 };
207 let _ = set_value(&key, app_id, 300);
208}
209
210pub fn invalidate_route_match_namespace() {
211 let Some(state) = RUNTIME_CACHE_STATE.get() else {
212 return;
213 };
214 if let Ok(mut state) = state.write() {
215 state.route_match_generation = state.route_match_generation.saturating_add(1);
216 }
217}
218
219pub fn template_source_lookup(app_root: &Path, relative_path: &str) -> Option<String> {
220 if !template_source_cache_enabled() {
221 return None;
222 }
223 let app_root = app_root.to_string_lossy();
224 let key = prefix_runtime_key(cache_key::template_source(&app_root, relative_path)?)?;
225 get_value(&key)
226}
227
228pub fn template_source_store(app_root: &Path, relative_path: &str, template_text: &str) {
229 if !template_source_cache_enabled() || template_text.is_empty() {
230 return;
231 }
232 let app_root = app_root.to_string_lossy();
233 let Some(key) = cache_key::template_source(&app_root, relative_path) else {
234 return;
235 };
236 let Some(key) = prefix_runtime_key(key) else {
237 return;
238 };
239 let _ = set_value(&key, template_text, 60);
240}
241
242pub fn dynamic_config_allowlist_value(
243 app_root: &Path,
244 runtime_conf_path: Option<&Path>,
245 key: &str,
246) -> Option<String> {
247 if key.is_empty() {
248 return None;
249 }
250 let runtime_conf_str = runtime_conf_path.map(|path| path.to_string_lossy().into_owned());
251 if config_allowlist_cache_enabled() && config_key_allowlisted(key) {
252 let app_root_str = app_root.to_string_lossy();
253 let cache_key = prefix_runtime_key(cache_key::config_allowlist(
254 &app_root_str,
255 runtime_conf_str.as_deref(),
256 key,
257 )?)?;
258 if let Some(value) = get_value(&cache_key) {
259 return (!value.is_empty()).then_some(value);
260 }
261 let value = config::dynamic_config_effective_value(app_root, runtime_conf_path, key)?;
262 let _ = set_value(&cache_key, &value, 30);
263 return Some(value);
264 }
265 config::dynamic_config_effective_value(app_root, runtime_conf_path, key)
266}
267
268fn session_cache_enabled() -> bool {
269 cache_state_read(|state| state.enabled && state.cache_sessions_enabled).unwrap_or(false)
270}
271
272fn template_source_cache_enabled() -> bool {
273 cache_state_read(|state| state.enabled && state.cache_template_source_enabled).unwrap_or(false)
274}
275
276fn route_match_cache_enabled() -> bool {
277 cache_state_read(|state| state.enabled && state.cache_route_match_enabled).unwrap_or(false)
278}
279
280fn config_allowlist_cache_enabled() -> bool {
281 cache_state_read(|state| state.enabled && state.cache_config_allowlist_enabled).unwrap_or(false)
282}
283
284fn config_key_allowlisted(key: &str) -> bool {
285 matches!(
286 key,
287 "environment"
288 | "base_path"
289 | "base_url"
290 | "payment_providers"
291 | "stripe_client_id"
292 | "stripe_client_secret_env"
293 | "stripe_webhook_secret_env"
294 | "payment_provider_state_key_env"
295 | "payment_provider_secret_key_env"
296 | "payment_provider_secret_key_version"
297 | "payment_provider_secret_previous_key_version"
298 | "payment_provider_secret_previous_key_env"
299 | "payment_provider_allow_loopback_http"
300 | "payment_provider_allow_plaintext_local"
301 | "stripe_oauth_authorize_url"
302 | "stripe_oauth_token_url"
303 | "stripe_account_api_base_url"
304 | "stripe_oauth_deauthorize_url"
305 )
306}
307
308fn encode_session_record(record: CachedSessionRecord) -> String {
309 format!("{}|{}", record.user_id, record.expires_at_epoch)
310}
311
312fn decode_session_record(value: &str) -> Option<CachedSessionRecord> {
313 let mut fields = value.split('|');
314 let user_id = fields.next()?.parse::<i64>().ok()?;
315 let expires_at_epoch = fields.next()?.parse::<u64>().ok()?;
316 Some(CachedSessionRecord {
317 user_id,
318 expires_at_epoch,
319 })
320}
321
322fn now_epoch_seconds() -> u64 {
323 SystemTime::now()
324 .duration_since(UNIX_EPOCH)
325 .map(|value| value.as_secs())
326 .unwrap_or(0)
327}
328
329fn cache_state_read<T>(reader: impl FnOnce(&RuntimeCacheState) -> T) -> Option<T> {
330 let state = RUNTIME_CACHE_STATE.get()?;
331 let state = state.read().ok()?;
332 Some(reader(&state))
333}
334
335fn prefix_runtime_key(key: String) -> Option<String> {
336 let prefix = cache_state_read(|state| state.cache_prefix.clone()).unwrap_or_default();
337 (!prefix.is_empty() && !key.is_empty()).then(|| format!("{prefix}:{key}"))
338}
339
340#[cfg(feature = "redis")]
341fn runtime_config_from_serve_config(config_record: &ServeConfig) -> RedisRuntimeConfig {
342 RedisRuntimeConfig {
343 enabled: config_record.redis_enabled,
344 host: config_record
345 .redis_host
346 .clone()
347 .filter(|value| !value.is_empty())
348 .unwrap_or_else(|| "127.0.0.1".to_owned()),
349 port: config_record
350 .redis_port
351 .as_deref()
352 .and_then(|value| value.parse::<u16>().ok())
353 .unwrap_or(6379),
354 auth_user: config_record
355 .redis_auth_user
356 .clone()
357 .filter(|value| !value.is_empty())
358 .unwrap_or_else(|| "default".to_owned()),
359 auth_token_env: config_record
360 .redis_auth_token_env
361 .clone()
362 .unwrap_or_default(),
363 db_index: config_record
364 .redis_db_index
365 .as_deref()
366 .and_then(|value| value.parse::<i64>().ok())
367 .unwrap_or(0),
368 connect_timeout_ms: config_record
369 .redis_connect_timeout_ms
370 .as_deref()
371 .and_then(|value| value.parse::<u64>().ok())
372 .unwrap_or(500),
373 read_timeout_ms: config_record
374 .redis_read_timeout_ms
375 .as_deref()
376 .and_then(|value| value.parse::<u64>().ok())
377 .unwrap_or(300),
378 cache_prefix: config_record
379 .redis_cache_prefix
380 .clone()
381 .filter(|value| !value.is_empty())
382 .unwrap_or_else(|| "pharos".to_owned()),
383 backoff_seconds: config_record
384 .redis_backoff_seconds
385 .as_deref()
386 .and_then(|value| value.parse::<u64>().ok())
387 .unwrap_or(30),
388 backoff_max_seconds: config_record
389 .redis_backoff_max_seconds
390 .as_deref()
391 .and_then(|value| value.parse::<u64>().ok())
392 .unwrap_or(300),
393 tls_mode: config_record
394 .redis_tls_mode
395 .clone()
396 .filter(|value| !value.is_empty())
397 .unwrap_or_else(|| "disable".to_owned()),
398 tls_root_cert: config_record
399 .redis_tls_root_cert
400 .clone()
401 .unwrap_or_default(),
402 tls_cert: config_record.redis_tls_cert.clone().unwrap_or_default(),
403 tls_key: config_record.redis_tls_key.clone().unwrap_or_default(),
404 require_peer_name_match: config_record.redis_require_peer_name_match,
405 cache_sessions_enabled: config_record.redis_cache_sessions_enabled,
406 cache_template_source_enabled: config_record.redis_cache_template_source_enabled,
407 cache_route_match_enabled: config_record.redis_cache_route_match_enabled,
408 cache_config_allowlist_enabled: config_record.redis_cache_config_allowlist_enabled,
409 }
410}
411
412#[cfg(feature = "redis")]
413fn with_connection<T>(
414 action: impl FnOnce(&RedisConnectionFactory, &mut RedisConnection) -> Result<T, RedisError>,
415) -> Option<T> {
416 let factory = cache_state_read(|state| {
417 if !state.enabled || now_epoch_seconds() < state.next_connect_after_epoch {
418 None
419 } else {
420 state.factory.clone()
421 }
422 })
423 .flatten()?;
424
425 REDIS_CONNECTION.with(|cell| {
426 if cell.borrow().is_none() {
427 update_status(RedisStatus::Connecting, 0, None);
428 let mut connection = factory.connect().ok()?;
429 factory.ping(&mut connection).ok()?;
430 *cell.borrow_mut() = Some(connection);
431 update_status(
432 RedisStatus::Active,
433 factory.config().backoff_seconds.max(1),
434 Some(0),
435 );
436 }
437 let result = {
438 let mut borrow = cell.borrow_mut();
439 let connection = borrow.as_mut()?;
440 action(&factory, connection)
441 };
442 match result {
443 Ok(value) => Some(value),
444 Err(_) => {
445 cell.borrow_mut().take();
446 schedule_backoff(&factory);
447 None
448 }
449 }
450 })
451}
452
453#[cfg(not(feature = "redis"))]
454fn get_value(_key: &str) -> Option<String> {
455 None
456}
457
458#[cfg(feature = "redis")]
459fn get_value(key: &str) -> Option<String> {
460 with_connection(|factory, connection| factory.get_text(connection, key)).flatten()
461}
462
463#[cfg(not(feature = "redis"))]
464fn set_value(_key: &str, _value: &str, _ttl_seconds: u64) -> Option<()> {
465 None
466}
467
468#[cfg(feature = "redis")]
469fn set_value(key: &str, value: &str, ttl_seconds: u64) -> Option<()> {
470 with_connection(|factory, connection| {
471 factory.set_text_ttl(connection, key, value, ttl_seconds)?;
472 Ok(())
473 })
474}
475
476#[cfg(not(feature = "redis"))]
477fn delete_value(_key: &str) -> Option<()> {
478 None
479}
480
481#[cfg(feature = "redis")]
482fn delete_value(key: &str) -> Option<()> {
483 with_connection(|factory, connection| {
484 factory.delete(connection, key)?;
485 Ok(())
486 })
487}
488
489#[cfg(not(feature = "redis"))]
490fn delete_many(_keys: &[&str]) -> Option<()> {
491 None
492}
493
494#[cfg(feature = "redis")]
495fn delete_many(keys: &[&str]) -> Option<()> {
496 with_connection(|factory, connection| {
497 factory.delete_many(connection, keys.iter().copied())?;
498 Ok(())
499 })
500}
501
502#[cfg(not(feature = "redis"))]
503fn set_add(_key: &str, _member: &str, _ttl_seconds: u64) -> Option<()> {
504 None
505}
506
507#[cfg(feature = "redis")]
508fn set_add(key: &str, member: &str, ttl_seconds: u64) -> Option<()> {
509 with_connection(|factory, connection| {
510 factory.set_add(connection, key, member)?;
511 factory.set_expire(connection, key, ttl_seconds)?;
512 Ok(())
513 })
514}
515
516#[cfg(not(feature = "redis"))]
517fn set_members(_key: &str) -> Vec<String> {
518 Vec::new()
519}
520
521#[cfg(feature = "redis")]
522fn set_members(key: &str) -> Vec<String> {
523 with_connection(|factory, connection| factory.set_members(connection, key)).unwrap_or_default()
524}
525
526#[cfg(feature = "redis")]
527fn schedule_backoff(factory: &RedisConnectionFactory) {
528 let backoff = cache_state_read(|state| state.current_backoff_seconds)
529 .unwrap_or(factory.config().backoff_seconds.max(1));
530 let next_backoff = backoff
531 .saturating_mul(2)
532 .min(factory.config().backoff_max_seconds.max(backoff));
533 update_status(
534 RedisStatus::Backoff,
535 next_backoff,
536 Some(now_epoch_seconds().saturating_add(backoff)),
537 );
538}
539
540#[cfg(feature = "redis")]
541fn update_status(status: RedisStatus, backoff_seconds: u64, next_connect_after_epoch: Option<u64>) {
542 let Some(state) = RUNTIME_CACHE_STATE.get() else {
543 return;
544 };
545 let Ok(mut state) = state.write() else {
546 return;
547 };
548 state.status = status.as_str().to_owned();
549 state.current_backoff_seconds = backoff_seconds.max(1);
550 if let Some(next_connect_after_epoch) = next_connect_after_epoch {
551 state.next_connect_after_epoch = next_connect_after_epoch;
552 }
553}
554
555#[cfg(test)]
556mod tests {
557 use super::config_key_allowlisted;
558
559 #[test]
560 fn payment_provider_endpoint_config_keys_are_allowlisted_for_runtime_cache() {
561 for key in [
562 "payment_providers",
563 "stripe_client_id",
564 "stripe_client_secret_env",
565 "stripe_webhook_secret_env",
566 "payment_provider_state_key_env",
567 "payment_provider_secret_key_env",
568 "payment_provider_secret_key_version",
569 "payment_provider_secret_previous_key_version",
570 "payment_provider_secret_previous_key_env",
571 "payment_provider_allow_loopback_http",
572 "payment_provider_allow_plaintext_local",
573 "stripe_oauth_authorize_url",
574 "stripe_oauth_token_url",
575 "stripe_account_api_base_url",
576 "stripe_oauth_deauthorize_url",
577 ] {
578 assert!(config_key_allowlisted(key), "{key} must be allowlisted");
579 }
580 }
581}