1use std::borrow::Cow;
28use std::collections::HashMap;
29use std::future::Future;
30use std::pin::Pin;
31use std::sync::{Arc, Mutex};
32
33use autumn_web::actuator::IndicatorGroup;
34use autumn_web::app::AppBuilder;
35use autumn_web::plugin::Plugin;
36use autumn_web::plugin_contract::PluginContract;
37use autumn_web::reexports::axum::Router;
38use autumn_web::reexports::axum::extract::FromRequestParts;
39use autumn_web::reexports::http::request::Parts;
40use autumn_web::route_listing::RouteInfo;
41use autumn_web::{AppState, AutumnError, AutumnResult};
42
43use crate::client::{RetryPolicy, ShopifyClient};
44use crate::config::ShopifyConfig;
45use crate::delivery::{DeliveryStore, InMemoryDeliveryStore};
46use crate::error::{Error, Result};
47use crate::tokens::{InMemoryTokenStore, ShopToken, TokenStore};
48use crate::types::{ApiVersion, ShopDomain};
49use crate::verify::SecretSet;
50use crate::webhooks::{IntoWebhookHandler, WebhookHandler, WebhookRegistry};
51
52const SUPPORTED_AUTUMN_WEB: &str = "0.10";
55
56pub const REFRESH_MARGIN_SECS: i64 = 60;
58
59pub const REFRESH_BACKOFF_SECS: i64 = 10;
62
63type ConfigureFn = Arc<dyn Fn(&mut ShopifyConfig) + Send + Sync + 'static>;
65
66pub type InstallFuture = Pin<Box<dyn Future<Output = AutumnResult<()>> + Send + 'static>>;
68
69pub type InstallHandler = Arc<dyn Fn(InstallContext) -> InstallFuture + Send + Sync + 'static>;
71
72#[derive(Clone)]
74#[non_exhaustive]
75pub struct InstallContext {
76 pub shop: ShopDomain,
78 pub scope: String,
80 pub state: AppState,
82 pub shopify: Shopify,
84}
85
86impl InstallContext {
87 #[must_use]
89 pub fn new(shop: ShopDomain, scope: impl Into<String>, shopify: Shopify) -> Self {
90 Self {
91 shop,
92 scope: scope.into(),
93 state: shopify.state().clone(),
94 shopify,
95 }
96 }
97}
98
99impl std::fmt::Debug for InstallContext {
100 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101 f.debug_struct("InstallContext")
102 .field("shop", &self.shop)
103 .field("scope", &self.scope)
104 .finish_non_exhaustive()
105 }
106}
107
108#[derive(Debug, Default)]
110struct RefreshState {
111 failed_at: Option<i64>,
112}
113
114pub(crate) struct Keeper {
118 http: reqwest::Client,
119 admin_base_url: Option<String>,
120 api_key: Option<String>,
121 secret: String,
122 tokens: Arc<dyn TokenStore>,
123 locks: Mutex<HashMap<ShopDomain, Arc<tokio::sync::Mutex<RefreshState>>>>,
124}
125
126impl Keeper {
127 pub(crate) const fn http(&self) -> &reqwest::Client {
129 &self.http
130 }
131
132 pub(crate) fn api_key(&self) -> Result<&str> {
134 self.api_key
135 .as_deref()
136 .ok_or_else(|| Error::Config("token calls need [shopify] api_key".to_owned()))
137 }
138
139 pub(crate) fn secret(&self) -> &str {
141 &self.secret
142 }
143
144 pub(crate) fn admin_origin(&self, shop: &ShopDomain) -> String {
146 self.admin_base_url.as_deref().map_or_else(
147 || format!("https://{shop}"),
148 |base| base.trim_end_matches('/').to_owned(),
149 )
150 }
151
152 fn lock_for(&self, shop: &ShopDomain) -> Result<Arc<tokio::sync::Mutex<RefreshState>>> {
154 let mut locks = self.lock_table()?;
155 Ok(Arc::clone(locks.entry(shop.clone()).or_default()))
156 }
157
158 fn lock_table(
159 &self,
160 ) -> Result<std::sync::MutexGuard<'_, HashMap<ShopDomain, Arc<tokio::sync::Mutex<RefreshState>>>>>
161 {
162 self.locks
163 .lock()
164 .map_err(|_| Error::Store("token lock table is poisoned".to_owned()))
165 }
166
167 #[allow(
169 clippy::significant_drop_tightening,
170 reason = "the per-shop lock must cover the read, the refresh, and the write"
171 )]
172 async fn fresh_token(&self, shop: &ShopDomain, now: i64) -> Result<ShopToken> {
173 let lock = self.lock_for(shop)?;
174 let mut state = lock.lock().await;
175 let token = self
176 .tokens
177 .get(shop)
178 .await?
179 .ok_or_else(|| Error::NotInstalled(shop.to_string()))?;
180 if !token.needs_refresh(now, REFRESH_MARGIN_SECS) {
181 return Ok(token);
182 }
183 let backing_off = state
184 .failed_at
185 .is_some_and(|at| now.saturating_sub(at) < REFRESH_BACKOFF_SECS);
186 if backing_off && !token.is_expired(now) {
187 return Ok(token);
188 }
189 let Some(refresh) = token
190 .refresh_token
191 .clone()
192 .filter(|_| token.can_refresh(now))
193 else {
194 self.delete_if_unchanged(shop, &token).await?;
195 return Err(Error::Reauthorize(shop.to_string()));
196 };
197 match crate::oauth::refresh_token(self, shop, &refresh, now).await {
198 Ok(mut fresh) => {
199 fresh.installed_at = token.installed_at;
200 self.tokens.put(shop, fresh.clone()).await?;
201 state.failed_at = None;
202 tracing::info!(shop = %shop, "shopify token refreshed");
203 Ok(fresh)
204 }
205 Err(Error::Reauthorize(_)) => {
206 if let Some(current) = self.tokens.get(shop).await?
209 && current != token
210 && !current.needs_refresh(now, REFRESH_MARGIN_SECS)
211 {
212 return Ok(current);
213 }
214 self.delete_if_unchanged(shop, &token).await?;
215 tracing::warn!(shop = %shop, "shopify refused the refresh token. The shop must install the app again");
216 Err(Error::Reauthorize(shop.to_string()))
217 }
218 Err(err) => {
219 state.failed_at = Some(now);
220 if token.is_expired(now) {
221 Err(err)
222 } else {
223 tracing::warn!(shop = %shop, error = %err, "shopify refresh failed; the old token is still valid");
224 Ok(token)
225 }
226 }
227 }
228 }
229
230 async fn delete_if_unchanged(&self, shop: &ShopDomain, seen: &ShopToken) -> Result<()> {
233 if self.tokens.get(shop).await?.as_ref() == Some(seen) {
234 self.tokens.delete(shop).await?;
235 }
236 Ok(())
237 }
238
239 #[allow(
241 clippy::significant_drop_tightening,
242 reason = "the per-shop lock must cover the write"
243 )]
244 pub(crate) async fn store_install(&self, shop: &ShopDomain, token: ShopToken) -> Result<()> {
245 let lock = self.lock_for(shop)?;
246 let mut state = lock.lock().await;
247 self.tokens.put(shop, token).await?;
248 state.failed_at = None;
249 Ok(())
250 }
251
252 #[allow(
254 clippy::significant_drop_tightening,
255 reason = "the per-shop lock must cover the read and the delete"
256 )]
257 pub(crate) async fn delete_for_event(
261 &self,
262 shop: &ShopDomain,
263 event_at: Option<i64>,
264 ) -> Result<bool> {
265 let lock = self.lock_for(shop)?;
268 let _guard = lock.lock().await;
269 let current = self.tokens.get(shop).await?;
270 let newer = matches!(
271 (current.as_ref().and_then(|t| t.installed_at), event_at),
272 (Some(installed), Some(event)) if installed > event
273 );
274 if newer {
275 return Ok(false);
276 }
277 self.tokens.delete(shop).await?;
278 Ok(true)
279 }
280}
281
282pub struct ShopifyRuntime {
286 config: ShopifyConfig,
287 secrets: SecretSet,
288 api_version: ApiVersion,
289 single_store: Option<(ShopDomain, String)>,
290 retry: RetryPolicy,
291 registry: WebhookRegistry,
292 deliveries: Arc<dyn DeliveryStore>,
293 on_install: Option<InstallHandler>,
294 keeper: Arc<Keeper>,
295}
296
297impl std::fmt::Debug for ShopifyRuntime {
299 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
300 f.debug_struct("ShopifyRuntime")
301 .field("config", &self.config)
302 .field(
303 "single_store",
304 &self.single_store.as_ref().map(|(shop, _)| shop),
305 )
306 .field("topics", &self.registry.topics())
307 .finish_non_exhaustive()
308 }
309}
310
311impl ShopifyRuntime {
312 #[must_use]
314 pub const fn config(&self) -> &ShopifyConfig {
315 &self.config
316 }
317
318 pub(crate) const fn secrets(&self) -> &SecretSet {
320 &self.secrets
321 }
322
323 #[must_use]
325 pub const fn registry(&self) -> &WebhookRegistry {
326 &self.registry
327 }
328
329 #[must_use]
334 pub fn token_store(&self) -> &Arc<dyn TokenStore> {
335 &self.keeper.tokens
336 }
337
338 #[must_use]
340 pub const fn delivery_store(&self) -> &Arc<dyn DeliveryStore> {
341 &self.deliveries
342 }
343
344 pub(crate) const fn on_install(&self) -> Option<&InstallHandler> {
346 self.on_install.as_ref()
347 }
348
349 pub(crate) fn keeper(&self) -> &Keeper {
351 &self.keeper
352 }
353
354 pub fn client(&self) -> Result<ShopifyClient> {
361 let (shop, token) = self.single_store.as_ref().ok_or_else(|| {
362 Error::Config(format!(
363 "no single-store client: set [shopify] shop and {}",
364 crate::config::ACCESS_TOKEN_ENV
365 ))
366 })?;
367 Ok(self.make_client(shop, token.clone()))
368 }
369
370 fn make_client(&self, shop: &ShopDomain, token: String) -> ShopifyClient {
372 ShopifyClient::from_parts(
373 self.keeper.http.clone(),
374 shop.clone(),
375 self.api_version.clone(),
376 token,
377 )
378 .with_base_url(self.keeper.admin_origin(shop))
379 .with_retry(self.retry)
380 }
381
382 pub async fn client_for(&self, shop: &ShopDomain, now: i64) -> Result<ShopifyClient> {
397 if let Some((single, token)) = &self.single_store
398 && single == shop
399 {
400 return Ok(self.make_client(shop, token.clone()));
401 }
402 let token = self
403 .keeper
404 .tokens
405 .get(shop)
406 .await?
407 .ok_or_else(|| Error::NotInstalled(shop.to_string()))?;
408 if !token.needs_refresh(now, REFRESH_MARGIN_SECS) {
409 return Ok(self.make_client(shop, token.access_token));
410 }
411 let keeper = Arc::clone(&self.keeper);
412 let owned = shop.clone();
413 let token = tokio::spawn(async move { keeper.fresh_token(&owned, now).await })
414 .await
415 .map_err(|_| Error::Store("the token refresh task stopped".to_owned()))??;
416 Ok(self.make_client(shop, token.access_token))
417 }
418}
419
420#[derive(Clone)]
437pub struct Shopify {
438 runtime: Arc<ShopifyRuntime>,
439 state: AppState,
440}
441
442impl std::fmt::Debug for Shopify {
443 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
444 f.debug_struct("Shopify")
445 .field("runtime", &self.runtime)
446 .finish_non_exhaustive()
447 }
448}
449
450impl Shopify {
451 pub fn from_state(state: &AppState) -> Result<Self> {
458 let runtime = state
459 .extension::<ShopifyRuntime>()
460 .ok_or(Error::MissingRuntime)?;
461 Ok(Self::from_runtime(runtime, state.clone()))
462 }
463
464 pub(crate) const fn from_runtime(runtime: Arc<ShopifyRuntime>, state: AppState) -> Self {
466 Self { runtime, state }
467 }
468
469 #[must_use]
471 pub const fn runtime(&self) -> &Arc<ShopifyRuntime> {
472 &self.runtime
473 }
474
475 #[must_use]
477 pub const fn state(&self) -> &AppState {
478 &self.state
479 }
480
481 pub fn client(&self) -> Result<ShopifyClient> {
487 self.runtime.client()
488 }
489
490 pub async fn client_for(&self, shop: &ShopDomain) -> Result<ShopifyClient> {
496 let now = self.state.clock().now().timestamp();
497 self.runtime.client_for(shop, now).await
498 }
499
500 #[must_use]
503 pub fn install_url(shop: &ShopDomain) -> String {
504 format!("{}?shop={shop}", crate::oauth::AUTH_PATH)
505 }
506}
507
508impl FromRequestParts<AppState> for Shopify {
509 type Rejection = AutumnError;
510
511 fn from_request_parts(
512 _parts: &mut Parts,
513 state: &AppState,
514 ) -> impl Future<Output = std::result::Result<Self, Self::Rejection>> {
515 std::future::ready(Self::from_state(state).map_err(|err| err.to_autumn_error()))
516 }
517}
518
519#[derive(Clone, Default)]
521pub struct ShopifyPlugin {
522 configure: Vec<ConfigureFn>,
523 webhooks: Vec<(String, WebhookHandler)>,
524 token_store: Option<Arc<dyn TokenStore>>,
525 delivery_store: Option<Arc<dyn DeliveryStore>>,
526 admin_base_url: Option<String>,
527 on_install: Option<InstallHandler>,
528}
529
530impl std::fmt::Debug for ShopifyPlugin {
531 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
532 let topics: Vec<&str> = self.webhooks.iter().map(|(t, _)| t.as_str()).collect();
533 f.debug_struct("ShopifyPlugin")
534 .field("topics", &topics)
535 .finish_non_exhaustive()
536 }
537}
538
539impl ShopifyPlugin {
540 #[must_use]
542 pub fn new() -> Self {
543 Self::default()
544 }
545
546 #[must_use]
549 pub fn configure(
550 mut self,
551 configure: impl Fn(&mut ShopifyConfig) + Send + Sync + 'static,
552 ) -> Self {
553 self.configure.push(Arc::new(configure));
554 self
555 }
556
557 #[must_use]
561 pub fn webhook(mut self, topic: impl Into<String>, handler: impl IntoWebhookHandler) -> Self {
562 self.webhooks.push((topic.into(), handler.into_handler()));
563 self
564 }
565
566 #[must_use]
568 pub fn token_store(mut self, store: Arc<dyn TokenStore>) -> Self {
569 self.token_store = Some(store);
570 self
571 }
572
573 #[must_use]
575 pub fn delivery_store(mut self, store: Arc<dyn DeliveryStore>) -> Self {
576 self.delivery_store = Some(store);
577 self
578 }
579
580 #[must_use]
584 pub fn on_install<F, Fut>(mut self, handler: F) -> Self
585 where
586 F: Fn(InstallContext) -> Fut + Send + Sync + 'static,
587 Fut: Future<Output = AutumnResult<()>> + Send + 'static,
588 {
589 self.on_install = Some(Arc::new(move |ctx| Box::pin(handler(ctx))));
590 self
591 }
592
593 #[must_use]
598 pub fn admin_base_url(mut self, base_url: impl Into<String>) -> Self {
599 self.admin_base_url = Some(base_url.into());
600 self
601 }
602
603 pub fn build_runtime(&self, config: ShopifyConfig) -> Result<ShopifyRuntime> {
614 let mut config = config;
615 for configure in &self.configure {
616 configure(&mut config);
617 }
618 config.validate()?;
619 let secrets = config.secrets()?;
620 let api_version = config.api_version()?;
621 let single_store = match (config.shop_domain()?, config.access_token()) {
622 (Some(shop), Some(token)) => Some((shop, token.to_owned())),
623 _ => None,
624 };
625 let mut registry = WebhookRegistry::new();
626 for (topic, handler) in &self.webhooks {
627 registry.register(topic, Arc::clone(handler))?;
628 }
629 if let Some(base) = &self.admin_base_url {
630 check_loopback(base)?;
631 }
632 let retry = RetryPolicy::new(config.max_throttle_retries, RetryPolicy::default().max_wait);
633 let keeper = Keeper {
634 http: crate::client::http_client()?,
635 admin_base_url: self.admin_base_url.clone(),
636 api_key: config.api_key.clone(),
637 secret: secrets.current().to_owned(),
638 tokens: self
639 .token_store
640 .clone()
641 .unwrap_or_else(|| Arc::new(InMemoryTokenStore::new())),
642 locks: Mutex::new(HashMap::new()),
643 };
644 Ok(ShopifyRuntime {
645 secrets,
646 api_version,
647 single_store,
648 retry,
649 registry,
650 deliveries: self
651 .delivery_store
652 .clone()
653 .unwrap_or_else(|| Arc::new(InMemoryDeliveryStore::default())),
654 on_install: self.on_install.clone(),
655 keeper: Arc::new(keeper),
656 config,
657 })
658 }
659}
660
661impl ShopifyPlugin {
662 fn start(&self, state: &AppState, config: ShopifyConfig) -> Result<()> {
665 let runtime = self.build_runtime(config)?;
666 for warning in startup_warnings(&runtime, &state.config_arc()) {
667 tracing::warn!("{warning}");
668 }
669 if let Ok(client) = runtime.client() {
670 let client = client.with_retry(RetryPolicy::new(0, std::time::Duration::ZERO));
674 let _ = state.health_indicator_registry().register(
677 "shopify",
678 IndicatorGroup::HealthOnly,
679 crate::health::indicator(&client),
680 );
681 }
682 tracing::info!(
683 topics = runtime.registry().topics().len(),
684 oauth = runtime.config().oauth_enabled(),
685 single_store = runtime.single_store.is_some(),
686 "shopify plugin ready"
687 );
688 state.extension_or_insert_with(|| runtime);
689 Ok(())
690 }
691}
692
693impl Plugin for ShopifyPlugin {
694 fn name(&self) -> Cow<'static, str> {
695 Cow::Borrowed("autumn-plugin-shopify")
696 }
697
698 fn contract(&self) -> Option<PluginContract> {
701 Some(
702 PluginContract::new(env!("CARGO_PKG_NAME"))
703 .plugin_version(env!("CARGO_PKG_VERSION"))
704 .autumn_web(SUPPORTED_AUTUMN_WEB),
705 )
706 }
707
708 fn build(self, app: AppBuilder) -> AppBuilder {
709 let (router, infos) = mount();
710 app.config_section(crate::config::CONFIG_SECTION)
711 .on_startup(move |state: AppState| {
712 let plugin = self.clone();
713 async move {
714 let config = ShopifyConfig::load().map_err(|err| err.to_autumn_error())?;
715 plugin
716 .start(&state, config)
717 .map_err(|err| err.to_autumn_error())
718 }
719 })
720 .nest("/shopify", router)
721 .declare_plugin_routes(infos)
722 }
723}
724
725pub(crate) fn startup_warnings(
728 runtime: &ShopifyRuntime,
729 app_config: &autumn_web::config::AutumnConfig,
730) -> Vec<String> {
731 let mut warnings = Vec::new();
732 if runtime.config().oauth_enabled() {
733 for topic in crate::types::Topic::COMPLIANCE {
734 if !runtime.registry().contains(topic) {
735 warnings.push(format!(
736 "shopify: no handler for the mandatory compliance topic {topic}; \
737 public apps must handle it"
738 ));
739 }
740 }
741 }
742 let config = runtime.config();
743 if config.shop.is_some() != config.access_token().is_some() {
744 warnings.push(format!(
745 "shopify: single-store mode is off; it needs both [shopify] shop and {}",
746 crate::config::ACCESS_TOKEN_ENV
747 ));
748 }
749 let csrf = &app_config.security.csrf;
750 if csrf.enabled
751 && !csrf
752 .exempt_paths
753 .iter()
754 .any(|path| covers(path, WEBHOOK_PATH))
755 {
756 warnings.push(format!(
757 "shopify: CSRF checks block {WEBHOOK_PATH}; add it to security.csrf.exempt_paths"
758 ));
759 }
760 warnings
761}
762
763pub(crate) fn mount() -> (Router<AppState>, Vec<RouteInfo>) {
769 let all_routes: Vec<autumn_web::Route> = crate::webhooks::webhook_routes()
770 .into_iter()
771 .chain(crate::oauth::oauth_routes())
772 .collect();
773 let mut infos = autumn_web::route_listing::collect_route_infos(&all_routes, &[], &[], &[])
775 .unwrap_or_default();
776 for info in &mut infos {
777 info.path = format!("/shopify{}", info.path);
778 }
779 let mut router = Router::<AppState>::new();
780 for route in all_routes {
781 router = router.route(route.path, route.handler);
782 }
783 (router, infos)
784}
785
786fn check_loopback(base: &str) -> Result<()> {
788 let url = url::Url::parse(base)
789 .map_err(|_| Error::Config(format!("admin_base_url {base:?} is not a valid URL")))?;
790 let loopback = matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"));
791 if loopback && matches!(url.scheme(), "http" | "https") {
792 Ok(())
793 } else {
794 Err(Error::Config(
795 "admin_base_url is for tests only and must use a loopback host".to_owned(),
796 ))
797 }
798}
799
800const WEBHOOK_PATH: &str = "/shopify/webhooks";
802
803fn covers(prefix: &str, path: &str) -> bool {
806 path == prefix
807 || (path.starts_with(prefix)
808 && (prefix.ends_with('/') || path.as_bytes().get(prefix.len()) == Some(&b'/')))
809}
810
811#[cfg(test)]
812mod tests;