1use super::*;
4use pharos_runtime_oauth::{CallbackParameters, Cancellation, SecretValue, Url, wire};
5use pharos_runtime_security::credentials::EncryptedFileCredentialBackend;
6use pharos_runtime_social::{
7 SocialError,
8 connection::{ConnectionScope, PendingConnection, SocialClient},
9 provider::SocialProvider,
10 repository::{ConnectionRecord, ConnectionRepository},
11};
12use serde::{Deserialize, Serialize};
13use serde_json::json;
14
15const PREFIX: &str = "/__pharos/social/";
16#[cfg(unix)]
17mod browser;
18mod consent_ui;
19mod settings;
20
21static OPERATOR_CONFIG: std::sync::RwLock<Option<String>> = std::sync::RwLock::new(None);
22
23pub(super) fn install_operator_config(config: &ServeConfig) -> Result<(), &'static str> {
25 *OPERATOR_CONFIG
26 .write()
27 .map_err(|_| "social operator configuration lock failed")? = config.conf_path.clone();
28 Ok(())
29}
30
31#[derive(Deserialize, Serialize, PartialEq, Eq)]
32#[serde(deny_unknown_fields)]
33struct Registration {
34 application: String,
35 namespace: String,
36 provider: String,
37 client_id: String,
38 callback: String,
39 revision: String,
40 client_secret_env: Option<String>,
41 #[serde(default)]
42 client_secret_ref: Option<String>,
43 #[serde(default)]
44 instagram_account_id: Option<String>,
45}
46
47struct Pending {
48 attempt: Option<PendingConnection>,
49 scope: ConnectionScope,
50 session: String,
51 previous: Option<ConnectionRecord>,
52 created: std::time::Instant,
53 provider: SocialProvider,
54}
55
56fn pending_registry() -> &'static std::sync::Mutex<std::collections::BTreeMap<String, Pending>> {
58 static PENDING: std::sync::OnceLock<
59 std::sync::Mutex<std::collections::BTreeMap<String, Pending>>,
60 > = std::sync::OnceLock::new();
61 PENDING.get_or_init(|| std::sync::Mutex::new(std::collections::BTreeMap::new()))
62}
63
64fn take_pending(
66 key: &str,
67 scope: &ConnectionScope,
68 provider: SocialProvider,
69 session: &str,
70) -> Result<Pending, SocialError> {
71 let mut registry = pending_registry().lock().map_err(|_| SocialError::Busy)?;
72 let pending = registry
73 .get(key)
74 .ok_or(SocialError::AuthorizationRequired)?;
75 if &pending.scope != scope
76 || pending.provider != provider
77 || pending.session != session
78 || pending.created.elapsed() >= Duration::from_secs(300)
79 {
80 return Err(SocialError::Forbidden);
81 }
82 if pending.attempt.is_none() {
83 return Err(SocialError::AuthorizationRequired);
84 }
85 let pending = registry
86 .remove(key)
87 .ok_or(SocialError::AuthorizationRequired)?;
88 registry.insert(
89 key.into(),
90 Pending {
91 attempt: None,
92 scope: pending.scope.clone(),
93 session: pending.session.clone(),
94 previous: None,
95 created: pending.created,
96 provider: pending.provider,
97 },
98 );
99 Ok(pending)
100}
101
102fn registration(
104 app: &AppRuntime,
105 scope: &ConnectionScope,
106 provider: SocialProvider,
107) -> Result<Option<Registration>, SocialError> {
108 if let Some(value) = settings::load(app, scope, provider)? {
109 return Ok(Some(value));
110 }
111 let operator_conf = if app.is_editor_preview {
112 OPERATOR_CONFIG
113 .read()
114 .map_err(|_| SocialError::Configuration)?
115 .clone()
116 } else {
117 app.runtime_conf_path.clone()
118 };
119 let Some(conf) = operator_conf.as_deref() else {
120 return Ok(None);
121 };
122 let Some(path) =
123 config::dynamic_config_lookup_raw(Path::new(conf), "SOCIAL_CONNECTIONS_CONFIG")
124 .filter(|path| !path.is_empty())
125 else {
126 return Ok(None);
127 };
128 let path = config::resolve_conf_relative_path(Path::new(conf), &path)
129 .ok_or(SocialError::Configuration)?;
130 let metadata = stdfs::symlink_metadata(&path).map_err(|_| SocialError::Configuration)?;
131 if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() > 65536 {
132 return Err(SocialError::Configuration);
133 }
134 #[cfg(unix)]
135 {
136 use std::os::unix::fs::PermissionsExt;
137 if metadata.permissions().mode() & 0o077 != 0 {
138 return Err(SocialError::Configuration);
139 }
140 }
141 let bytes = stdfs::read(&path).map_err(|_| SocialError::Configuration)?;
142 let registrations: Vec<Registration> =
143 serde_json::from_slice(&bytes).map_err(|_| SocialError::Configuration)?;
144 if registrations.len() > 32 {
145 return Err(SocialError::Configuration);
146 }
147 let mut selected = registrations.into_iter().filter(|row| {
148 row.application == scope.application()
149 && row.namespace == scope.namespace()
150 && row.provider == provider.key()
151 });
152 let value = selected.next();
153 if selected.next().is_some() {
154 return Err(SocialError::Configuration);
155 }
156 Ok(value)
157}
158
159fn client(app: &AppRuntime, registration: &Registration) -> Result<SocialClient, SocialError> {
161 let secret = registration
162 .client_secret_env
163 .as_ref()
164 .map(|name| {
165 if name.is_empty()
166 || name.len() > 128
167 || !name
168 .bytes()
169 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
170 {
171 return Err(SocialError::Configuration);
172 }
173 SecretValue::new(std::env::var(name).map_err(|_| SocialError::Configuration)?)
174 .map_err(|_| SocialError::Configuration)
175 })
176 .transpose()?;
177 let stored = registration
178 .client_secret_ref
179 .as_ref()
180 .map(|key| {
181 use pharos_runtime_security::credentials::CredentialBackend;
182 let bytes = zeroize::Zeroizing::new(
183 vault(app)?
184 .get_secret(key)
185 .map_err(|_| SocialError::Configuration)?,
186 );
187 SecretValue::new(
188 std::str::from_utf8(&bytes)
189 .map_err(|_| SocialError::Configuration)?
190 .to_owned(),
191 )
192 .map_err(|_| SocialError::Configuration)
193 })
194 .transpose()?;
195 let mut client = SocialClient::new(
196 SocialProvider::parse(®istration.provider)?,
197 ®istration.client_id,
198 ®istration.callback,
199 ®istration.revision,
200 stored.as_ref().or(secret.as_ref()),
201 )?;
202 if let Some(id) = ®istration.instagram_account_id {
203 client = client.with_instagram_account(id)?;
204 }
205 Ok(client)
206}
207
208fn collection_client(
210 app: &AppRuntime,
211 registration: &Registration,
212 scope: &ConnectionScope,
213) -> Result<SocialClient, SocialError> {
214 let client = client(app, registration)?;
215 if client.provider() != SocialProvider::Instagram {
216 return Ok(client);
217 }
218 let store = app_database_operations(app).ok_or(SocialError::Configuration)?;
219 let vault = vault(app)?;
220 let repository = ConnectionRepository {
221 adapter: store.adapter.as_ref(),
222 config: &store.config,
223 schema: &store.schema,
224 vault: &vault,
225 guards: &[],
226 };
227 let row = repository
228 .load(scope, SocialProvider::Instagram)?
229 .ok_or(SocialError::AuthorizationRequired)?;
230 if registration
231 .instagram_account_id
232 .as_ref()
233 .is_some_and(|id| id != &row.account_id)
234 {
235 return Err(SocialError::Forbidden);
236 }
237 client.with_instagram_account(&row.account_id)
238}
239
240fn connection_namespace(app: &AppRuntime, authority: &str) -> Result<String, SocialError> {
242 let epoch = match app.connection_namespace.as_deref() {
243 Some(epoch) => epoch,
244 None if app.is_editor_preview => return Err(SocialError::Configuration),
245 None => return Ok(authority.to_owned()),
246 };
247 ConnectionScope::new("social", epoch, 1)?;
248 let mut digest = Sha256::new();
249 digest.update(b"pharos.social.namespace.v1\0");
250 digest.update(authority.as_bytes());
251 digest.update(b"\0");
252 digest.update(epoch.as_bytes());
253 Ok(dynamic::hex_digest(&digest.finalize()))
254}
255
256pub(super) fn scope(app: &AppRuntime, owner: i64) -> Result<ConnectionScope, SocialError> {
258 let authority = app_security_cache_scope(app).map_err(|_| SocialError::Configuration)?;
259 ConnectionScope::new(
260 app.app_id.as_deref().ok_or(SocialError::Configuration)?,
261 &connection_namespace(app, &authority)?,
262 owner,
263 )
264}
265
266fn vault(app: &AppRuntime) -> Result<EncryptedFileCredentialBackend, SocialError> {
268 let parent = app
269 .state_path
270 .as_deref()
271 .and_then(|path| Path::new(path).parent())
272 .ok_or(SocialError::Configuration)?;
273 for root in [app.app_root.as_deref(), app.artifact_dir.as_deref()]
274 .into_iter()
275 .flatten()
276 {
277 if parent.starts_with(root) {
278 return Err(SocialError::Configuration);
279 }
280 }
281 let directory = parent.join("vault");
282 EncryptedFileCredentialBackend::open_or_create(
283 directory.join("social-connections.enc"),
284 &directory.join("social-root.key"),
285 )
286 .map_err(|_| SocialError::Transport)
287}
288
289fn provider_transport(
291 client: &SocialClient,
292 endpoint: Option<String>,
293) -> Result<pharos_runtime_integrations::oauth_http::NativeProviderHttp, SocialError> {
294 let mut endpoints = client.protocol_endpoints();
295 if let Some(endpoint) = endpoint {
296 if client.provider() == SocialProvider::Facebook {
297 let url = Url::parse(&endpoint).map_err(|_| SocialError::Configuration)?;
298 if let Some(target) = url
299 .path()
300 .strip_prefix("/v25.0/")
301 .and_then(|path| path.strip_suffix("/posts"))
302 {
303 endpoints.push((wire::Method::GET, client.page_token_endpoint(target)?));
304 }
305 }
306 endpoints.push((wire::Method::GET, endpoint));
307 }
308 pharos_runtime_integrations::oauth_http::NativeProviderHttp::for_endpoints(
309 &endpoints,
310 512 * 1024,
311 )
312 .map_err(|_| SocialError::Configuration)
313}
314
315fn require_session(
317 store: &database::LogicalAppStore,
318 session: &str,
319 owner: i64,
320) -> Result<(), SocialError> {
321 let active = store
322 .adapter
323 .active_session(
324 &store.config,
325 &store.schema,
326 session,
327 SystemTime::now()
328 .duration_since(UNIX_EPOCH)
329 .map_err(|_| SocialError::Configuration)?
330 .as_secs(),
331 )
332 .map_err(|_| SocialError::Transport)?;
333 if active.is_none_or(|session| session.user_id != owner) {
334 return Err(SocialError::AuthorizationRequired);
335 }
336 let users = store
337 .adapter
338 .query(
339 &store.config,
340 &store.schema,
341 &pharos_runtime_database::operations::Query {
342 entity: "user_account".into(),
343 fields: vec!["id".into()],
344 filter: Some(pharos_runtime_database::operations::Predicate::All(vec![
345 pharos_runtime_database::operations::Predicate::Equal(
346 "id".into(),
347 json!(owner),
348 ),
349 pharos_runtime_database::operations::Predicate::Equal(
350 "is_active".into(),
351 json!(true),
352 ),
353 ])),
354 order: vec![],
355 limit: 1,
356 offset: 0,
357 },
358 )
359 .map_err(|_| SocialError::Transport)?;
360 if users.is_empty() {
361 return Err(SocialError::AuthorizationRequired);
362 }
363 Ok(())
364}
365
366fn authority_guards(
368 app: &AppRuntime,
369 store: &database::LogicalAppStore,
370 owner: i64,
371 capability: &str,
372 session: Option<&str>,
373) -> Result<Vec<pharos_runtime_database::operations::Mutation>, SocialError> {
374 use pharos_runtime_database::operations::{Mutation, Predicate, Query};
375 let mut guards = vec![Mutation::RequireCount {
376 entity: "user_account".into(),
377 filter: Predicate::All(vec![
378 Predicate::Equal("id".into(), json!(owner)),
379 Predicate::Equal("is_active".into(), json!(true)),
380 ]),
381 minimum: 1,
382 maximum: Some(1),
383 }];
384 let ir = app
385 .application_ir
386 .as_ref()
387 .ok_or(SocialError::Configuration)?;
388 let roles = ir
389 .role_capability_edges
390 .iter()
391 .filter(|(_, granted)| granted.as_str() == capability)
392 .map(|(role, _)| json!(role.as_str()))
393 .collect::<Vec<_>>();
394 let mut granted = false;
395 for (entity, field) in [
396 ("user_profile", "account_type"),
397 ("store_membership", "role"),
398 ] {
399 if !store
400 .schema
401 .entities
402 .iter()
403 .any(|value| value.name == entity)
404 {
405 continue;
406 }
407 let filter = Predicate::All(vec![
408 Predicate::Equal("user_id".into(), json!(owner)),
409 Predicate::In(field.into(), roles.clone()),
410 ]);
411 let rows = store
412 .adapter
413 .query(
414 &store.config,
415 &store.schema,
416 &Query {
417 entity: entity.into(),
418 fields: vec!["user_id".into()],
419 filter: Some(filter.clone()),
420 order: vec![],
421 limit: 1,
422 offset: 0,
423 },
424 )
425 .map_err(|_| SocialError::Transport)?;
426 if !rows.is_empty() {
427 guards.push(Mutation::RequireCount {
428 entity: entity.into(),
429 filter,
430 minimum: 1,
431 maximum: None,
432 });
433 granted = true;
434 break;
435 }
436 }
437 if !granted {
438 return Err(SocialError::Forbidden);
439 }
440 if let Some(session) = session {
441 guards.push(Mutation::RequireCount {
442 entity: "auth_session".into(),
443 filter: Predicate::All(vec![
444 Predicate::Equal("user_id".into(), json!(owner)),
445 Predicate::Equal("session_token_hash".into(), json!(session)),
446 Predicate::IsNull("revoked_at".into()),
447 Predicate::GreaterThan("expires_at".into(), json!(current_timestamp_utc())),
448 ]),
449 minimum: 1,
450 maximum: Some(1),
451 });
452 }
453 Ok(guards)
454}
455
456pub(super) fn response(
458 app: &AppRuntime,
459 request: &http::HttpRequest,
460 relative: &str,
461) -> Option<Vec<u8>> {
462 response_at(app, request, relative, None)
463}
464
465pub(super) fn response_at(
467 app: &AppRuntime,
468 request: &http::HttpRequest,
469 relative: &str,
470 callback_path: Option<&str>,
471) -> Option<Vec<u8>> {
472 let path = relative.split('?').next().unwrap_or("");
473 if !path.starts_with(PREFIX) {
474 return None;
475 }
476 Some(match handle(app, request, relative, path, callback_path) {
477 Ok(response) => response,
478 Err(error) => failure(error, &request.method),
479 })
480}
481
482fn failure(error: SocialError, method: &str) -> Vec<u8> {
484 let status = match error {
485 SocialError::Forbidden => 403,
486 SocialError::AuthorizationRequired => 401,
487 SocialError::Busy => 409,
488 SocialError::RateLimited => 429,
489 SocialError::Capacity => 413,
490 SocialError::Configuration | SocialError::Transport => 503,
491 _ => 422,
492 };
493 http_json_response(
494 status,
495 http_reason_phrase(status),
496 &json!({"error":error.code(),"message":error.to_string()}).to_string(),
497 method,
498 )
499}
500
501struct BrowserStatus {
503 available: bool,
504 pending: bool,
505}
506
507#[allow(clippy::too_many_arguments)]
509fn status_response(
510 app: &AppRuntime,
511 request: &http::HttpRequest,
512 declaration: &Value,
513 registered: Option<&Registration>,
514 provider: SocialProvider,
515 scope: &ConnectionScope,
516 row: Option<ConnectionRecord>,
517 session: &str,
518 callback_path: Option<&str>,
519 browser: Option<BrowserStatus>,
520) -> Result<Vec<u8>, SocialError> {
521 if let Some(template) = declaration.get("status_template").and_then(Value::as_str) {
522 if template.len() > 512
523 || !template.starts_with("templates/")
524 || !template.ends_with(".html")
525 || Path::new(template)
526 .components()
527 .any(|component| !matches!(component, std::path::Component::Normal(_)))
528 {
529 return Err(SocialError::Configuration);
530 }
531 let is_browser = browser.is_some();
532 let configured = browser
533 .as_ref()
534 .map_or(registered.is_some(), |state| state.available);
535 let connected = row.as_ref().is_some_and(|row| row.status == "connected");
536 let saved_browser = is_browser
537 && row
538 .as_ref()
539 .is_some_and(|row| !row.credential_ref.is_empty());
540 let pending = browser.as_ref().map_or_else(
541 || consent_ui::pending(scope, provider, session),
542 |state| state.pending,
543 );
544 let disconnectable = connected
545 || row.as_ref().is_some_and(|row| {
546 matches!(row.status.as_str(), "revoking" | "reauthorization_required")
547 })
548 || is_browser && (saved_browser || pending);
549 let feedback = consent_ui::outcome(scope, provider, session);
550 let setup_allowed = !is_browser && settings::can_setup(app, declaration, scope.owner())?;
551 let callback = if setup_allowed && !configured {
552 Some(settings::callback_url(
553 app,
554 request,
555 provider,
556 callback_path,
557 )?)
558 } else {
559 None
560 };
561 let mut params = json!({
562 "social_api_hidden":if is_browser{"hidden"}else{""},
563 "social_browser_unavailable_hidden":if is_browser && !configured{""}else{"hidden"},
564 "social_browser_finish_hidden":"hidden",
565 "social_auto_finish_hidden":if is_browser && pending{""}else{"hidden"},
566 "social_auto_trigger":if is_browser && pending{"every 2s"}else{"none"},
567 "social_disconnect_hidden":if disconnectable {""}else{"hidden"},
568 "social_connect_hidden":if pending || disconnectable {"hidden"}else{""},
569 "social_secret_required":if provider==SocialProvider::X {""}else{"required"},
570 "social_setup_hidden":if setup_allowed && !configured {""}else{"hidden"},
571 "social_setup_url":app_scope_path(app,&format!("/__pharos/social/{}/setup",provider.key())),
572 "social_callback_url":callback,
573 "social_connection_mode":if is_browser{"browser"}else{"oauth"}, "social_extension_hidden":"hidden", "social_browser_hidden":if is_browser{""}else{"hidden"}, "social_browser_failed_hidden":if is_browser && feedback==Some(consent_ui::Outcome::Failed){""}else{"hidden"}, "social_finish_hidden":"hidden", "social_browser_pending_hidden":"hidden", "social_connect_target":"_self", "social_finish_url":app_scope_path(app,&format!("/__pharos/social/{}/finish",provider.key())),
574 "social_pending_hidden":if pending{""}else{"hidden"},
575 "social_cancelled_hidden":if feedback==Some(consent_ui::Outcome::Cancelled){""}else{"hidden"},
576 "social_failed_hidden":if feedback==Some(consent_ui::Outcome::Failed){""}else{"hidden"},
577 "social_selection_hidden":if feedback==Some(consent_ui::Outcome::SelectionRequired){""}else{"hidden"},
578 "social_facebook_hidden":if provider==SocialProvider::Facebook{""}else{"hidden"},
579 "social_instagram_hidden":if provider==SocialProvider::Instagram{""}else{"hidden"},
580 "social_x_hidden":if provider==SocialProvider::X{""}else{"hidden"},
581 "social_runtime_status_hidden":if configured && !pending{""}else{"hidden"},
582 "social_cancel_url":app_scope_path(app,&format!("/__pharos/social/{}/cancel",provider.key())),
583 "social_status":row.as_ref().map(|row|row.status.as_str()).unwrap_or("disconnected"),
584 "social_username":row.as_ref().map(|row|row.username.as_str()).unwrap_or(""),
585 "social_namespace":scope.namespace(),
586 "social_provider":provider.key(), "social_provider_label":provider.label(),
587 "social_unavailable_hidden":if configured || is_browser {"hidden"}else{""},
588 "social_available_hidden":if configured && !is_browser {""}else{"hidden"},
589 "social_connect_url":app_scope_path(app,&format!("/__pharos/social/{}/connect",provider.key())),
590 "social_disconnect_url":app_scope_path(app,&format!("/__pharos/social/{}/disconnect",provider.key())),
591 "social_connect_disabled":if configured && !pending {""} else {"disabled"},
592 "social_disconnect_disabled":if configured && disconnectable {""} else {"disabled"},
593 "csrf_token":dynamic::csrf_token_for_response(request.cookie.as_deref()),
594 });
595 for (key, candidate) in [
596 ("youtube", SocialProvider::Youtube),
597 ("soundcloud", SocialProvider::Soundcloud),
598 ("spotify", SocialProvider::Spotify),
599 ] {
600 params[format!("social_{key}_hidden")] =
601 json!(if provider == candidate { "" } else { "hidden" });
602 }
603 params["social_reconnect_hidden"] = json!(if is_browser { "" } else { "hidden" });
604 params["social_reconnect_disabled"] = json!(if saved_browser && !pending && configured {
605 ""
606 } else {
607 "disabled"
608 });
609 return Ok(app_fragment_response(
610 app,
611 template,
612 params,
613 &request.method,
614 ));
615 }
616 Ok(http_json_response(200,"OK",&json!({"provider":provider.key(),"namespace":scope.namespace(),"configured":registered.is_some(),"connection":row}).to_string(),&request.method))
617}
618
619fn return_destination(declaration: &Value) -> Result<&str, SocialError> {
621 let destination = declaration
622 .get("return_path")
623 .and_then(Value::as_str)
624 .unwrap_or("/__pharos/social/x/status");
625 if destination.len() > 2048
626 || !destination.starts_with('/')
627 || destination.starts_with("//")
628 || destination.contains(['\\', '?', '#', ' '])
629 || destination.chars().any(char::is_control)
630 {
631 return Err(SocialError::Configuration);
632 }
633 Ok(destination)
634}
635
636fn browser_mode(declaration: &Value) -> Result<bool, SocialError> {
638 match declaration.get("connection_mode") {
639 None => Ok(false),
640 Some(Value::String(mode)) if mode == "api" => Ok(false),
641 Some(Value::String(mode)) if mode == "browser" => Ok(true),
642 _ => Err(SocialError::Configuration),
643 }
644}
645
646fn handle(
648 app: &AppRuntime,
649 request: &http::HttpRequest,
650 relative: &str,
651 path: &str,
652 callback_path: Option<&str>,
653) -> Result<Vec<u8>, SocialError> {
654 let declaration = dynamic::dynamic_contract_doc(app, "social_connections_contract")
655 .ok_or(SocialError::Forbidden)?;
656 if declaration.get("enabled").and_then(Value::as_bool) != Some(true) {
657 return Err(SocialError::Forbidden);
658 }
659 let owner = active_session_user_id(app, request.cookie.as_deref())
660 .map_err(|_| SocialError::Transport)?;
661 if owner <= 0 {
662 return Err(SocialError::AuthorizationRequired);
663 }
664 let capability = declaration
665 .get("owner_capability")
666 .and_then(Value::as_str)
667 .filter(|value| {
668 !matches!(
669 *value,
670 "public" | "customer" | "account_login" | "account_logout" | "authenticated_user"
671 )
672 })
673 .ok_or(SocialError::Configuration)?;
674 if !actor_has_route_policy(app, owner, capability).map_err(|_| SocialError::Transport)? {
675 return Err(SocialError::Forbidden);
676 }
677 let (provider, operation) = path
678 .strip_prefix(PREFIX)
679 .and_then(|p| p.split_once('/'))
680 .ok_or(SocialError::Configuration)?;
681 let provider = SocialProvider::parse(provider)?;
682 if !matches!(
683 (request.method.as_str(), operation),
684 ("GET", "status" | "callback")
685 | (
686 "POST",
687 "connect" | "cancel" | "disconnect" | "setup" | "finish"
688 )
689 ) {
690 return Err(SocialError::Forbidden);
691 }
692 if request.method == "POST"
693 && (!trusted_browser_origin(request)
694 || !dynamic::csrf_request_valid(
695 request,
696 dynamic::request_body_text(request).as_deref(),
697 ))
698 {
699 return Err(SocialError::Forbidden);
700 }
701 let scope = scope(app, owner)?;
702 let store = app_database_operations(app).ok_or(SocialError::Configuration)?;
703 let session = request
704 .cookie
705 .as_deref()
706 .and_then(|cookie| dynamic::dynamic_cookie_value(cookie, "pharos_session"))
707 .and_then(|token| native_session_token_hash(app, &token))
708 .ok_or(SocialError::AuthorizationRequired)?;
709 require_session(&store, &session, owner)?;
710 if browser_mode(&declaration)? {
711 #[cfg(unix)]
712 return browser::handle(
713 app,
714 request,
715 &declaration,
716 provider,
717 operation,
718 &scope,
719 &store,
720 &session,
721 capability,
722 );
723 #[cfg(not(unix))]
724 return Err(SocialError::UnsupportedProvider);
725 }
726 if operation == "finish" {
727 return Err(SocialError::Forbidden);
728 }
729 if operation == "setup" {
730 pharos_runtime_security::local_role_cache_revoke_actor(scope.namespace(), owner);
731 if !settings::can_setup(app, &declaration, owner)? {
732 return Err(SocialError::Forbidden);
733 }
734 if request.body_len > 16384
735 || request.body.len() > 16384
736 || request.body_file_path.is_some()
737 {
738 return Err(SocialError::Capacity);
739 }
740 let client_id = dynamic::request_form_value(request, "client_id").unwrap_or_default();
741 let secret = zeroize::Zeroizing::new(
742 dynamic::request_form_value(request, "client_secret").unwrap_or_default(),
743 );
744 let callback = settings::callback_url(app, request, provider, callback_path)?;
745 let destination = return_destination(&declaration)?;
746 settings::save(app, &scope, provider, client_id.trim(), &secret, &callback)?;
747 return Ok(http_route_redirect_for_request(
748 request,
749 &app_scope_path(app, destination),
750 ));
751 }
752 if operation == "cancel" {
753 consent_ui::cancel(&scope, provider, &session)?;
754 return Ok(http_route_redirect_for_request(
755 request,
756 &app_scope_path(app, return_destination(&declaration)?),
757 ));
758 }
759 let registered = registration(app, &scope, provider)?;
760 if operation == "status" {
761 let row = if store
762 .schema
763 .entities
764 .iter()
765 .any(|entity| entity.name == "social_connection")
766 {
767 let vault = vault(app)?;
768 ConnectionRepository {
769 adapter: store.adapter.as_ref(),
770 config: &store.config,
771 schema: &store.schema,
772 vault: &vault,
773 guards: &[],
774 }
775 .load(&scope, provider)?
776 } else {
777 None
778 };
779 if relative
780 .split_once('?')
781 .and_then(|(_, query)| json_commands::request_form_value(query, "format"))
782 .as_deref()
783 == Some("json")
784 {
785 return Ok(http_json_response(200,"OK",&json!({"provider":provider.key(),"namespace":scope.namespace(),"mode":"oauth","setup_scope":"application_deployment","connection_scope":"owner","setup_allowed":settings::can_setup(app,&declaration,owner)?,"configured":registered.is_some(),"authorization_pending":consent_ui::pending(&scope,provider,&session),"connection":row}).to_string(),&request.method));
786 }
787 return status_response(
788 app,
789 request,
790 &declaration,
791 registered.as_ref(),
792 provider,
793 &scope,
794 row,
795 &session,
796 callback_path,
797 None,
798 );
799 }
800 let destination = return_destination(&declaration)?;
801 let registered = registered.ok_or(SocialError::Configuration)?;
802 let client = client(app, ®istered)?;
803 let vault = vault(app)?;
804 let guards = authority_guards(app, &store, owner, capability, Some(&session))?;
805 let repository = ConnectionRepository {
806 adapter: store.adapter.as_ref(),
807 config: &store.config,
808 schema: &store.schema,
809 vault: &vault,
810 guards: &guards,
811 };
812 if operation == "connect" {
813 let pending = client.begin(scope.clone())?;
814 let url =
815 Url::parse(pending.authorization_url()).map_err(|_| SocialError::Configuration)?;
816 let state = url
817 .query_pairs()
818 .find(|(key, _)| key == "state")
819 .ok_or(SocialError::Configuration)?
820 .1
821 .into_owned();
822 let destination = pending.authorization_url().to_owned();
823 let previous = repository.load(&scope, provider)?;
824 let mut registry = pending_registry().lock().map_err(|_| SocialError::Busy)?;
825 registry.retain(|_, pending| {
826 pending.created.elapsed() < Duration::from_secs(300)
827 && (pending.scope != scope || pending.provider != provider)
828 });
829 if registry.len() >= 128 {
830 return Err(SocialError::Capacity);
831 }
832 consent_ui::record(&scope, provider, &session, None);
833 registry.insert(
834 dynamic::hex_digest(&Sha256::digest(state.as_bytes())),
835 Pending {
836 attempt: Some(pending),
837 scope,
838 session,
839 previous,
840 provider,
841 created: std::time::Instant::now(),
842 },
843 );
844 return Ok(http_route_redirect_for_request(request, &destination));
845 }
846 let runtime = tokio::runtime::Builder::new_current_thread()
847 .enable_all()
848 .build()
849 .map_err(|_| SocialError::Transport)?;
850 let http = provider_transport(&client, None)?;
851 if operation == "callback" {
852 let query = relative
853 .split_once('?')
854 .map(|(_, query)| query)
855 .ok_or(SocialError::AuthorizationRequired)?;
856 let callback =
857 CallbackParameters::parse(query).map_err(|_| SocialError::AuthorizationRequired)?;
858 let state = json_commands::request_form_value(query, "state")
859 .ok_or(SocialError::AuthorizationRequired)?;
860 let key = dynamic::hex_digest(&Sha256::digest(state.as_bytes()));
861 let pending = take_pending(&key, &scope, provider, &session)?;
862 let result = runtime.block_on(client.finish(
863 &http,
864 &Cancellation::default(),
865 &scope,
866 pending.attempt.ok_or(SocialError::AuthorizationRequired)?,
867 callback,
868 ));
869 let account = match result {
870 Ok(account) => account,
871 Err(error) => {
872 let mut claimed = consent_ui::claim_guard(&key, &scope, provider, &session)?;
873 claimed.remove(&key);
874 drop(claimed);
875 let outcome = if error == SocialError::ResourceSelectionRequired {
876 consent_ui::Outcome::SelectionRequired
877 } else if json_commands::request_form_value(query, "error").as_deref()
878 == Some("access_denied")
879 {
880 consent_ui::Outcome::Cancelled
881 } else {
882 consent_ui::Outcome::Failed
883 };
884 consent_ui::record(&scope, provider, &session, Some(outcome));
885 return Ok(http_route_redirect_for_request(
886 request,
887 &app_scope_path(app, destination),
888 ));
889 }
890 };
891 require_session(&store, &session, owner)?;
892 pharos_runtime_security::local_role_cache_revoke_actor(scope.namespace(), owner);
893 if !actor_has_route_policy(app, owner, capability).map_err(|_| SocialError::Transport)? {
894 return Err(SocialError::Forbidden);
895 }
896 if registration(app, &scope, provider)?.as_ref() != Some(®istered) {
897 return Err(SocialError::Forbidden);
898 }
899 let mut claimed = consent_ui::claim_guard(&key, &scope, provider, &session)?;
900 let promoted = repository.promote(
901 &client,
902 &scope,
903 provider,
904 ®istered.revision,
905 pending.previous.as_ref(),
906 &account,
907 );
908 claimed.remove(&key);
909 drop(claimed);
910 if promoted.is_err() {
911 consent_ui::record(
912 &scope,
913 provider,
914 &session,
915 Some(consent_ui::Outcome::Failed),
916 );
917 return Ok(http_route_redirect_for_request(
918 request,
919 &app_scope_path(app, destination),
920 ));
921 }
922 consent_ui::record(&scope, provider, &session, None);
923 let now = SystemTime::now()
924 .duration_since(UNIX_EPOCH)
925 .map_err(|_| SocialError::Configuration)?
926 .as_secs() as i64;
927 repository.resume_collections(&scope, provider, now)?;
928 } else {
929 let row = repository
930 .load(&scope, provider)?
931 .ok_or(SocialError::AuthorizationRequired)?;
932 let account = if row.status == "revoking" {
933 use pharos_runtime_security::credentials::CredentialBackend;
934 let bytes = zeroize::Zeroizing::new(
935 vault
936 .get_secret(&row.credential_ref)
937 .map_err(|_| SocialError::Transport)?,
938 );
939 client.restore_from_vault(&scope, &bytes)?
940 } else {
941 repository.account(&client, &scope, &row, ®istered.revision)?
942 };
943 let revoked = if row.status == "revoking" {
944 row
945 } else {
946 repository.disconnect(&row)?
947 };
948 repository.cancel_collections(&scope, provider)?;
949 runtime.block_on(client.revoke(&http, &Cancellation::default(), &scope, account))?;
950 repository.finish_disconnect(&revoked)?;
951 consent_ui::record(&scope, provider, &session, None);
952 pending_registry()
953 .lock()
954 .map_err(|_| SocialError::Busy)?
955 .retain(|_, pending| pending.scope != scope || pending.provider != provider);
956 }
957 Ok(http_route_redirect_for_request(
958 request,
959 &app_scope_path(app, destination),
960 ))
961}
962
963#[cfg(test)]
964mod tests;
965
966fn collection_context(
968 app: &AppRuntime,
969 provider: SocialProvider,
970) -> Result<(ConnectionScope, database::LogicalAppStore, Registration), SocialError> {
971 let (scope, store) = collection_authority(app, None)?;
972 let registration =
973 registration(app, &scope, provider)?.ok_or(SocialError::AuthorizationRequired)?;
974 Ok((scope, store, registration))
975}
976
977fn collection_authority(
979 app: &AppRuntime,
980 selected_owner: Option<i64>,
981) -> Result<(ConnectionScope, database::LogicalAppStore), SocialError> {
982 use pharos_runtime_database::operations::{Predicate, Query};
983 let declaration = dynamic::dynamic_contract_doc(app, "social_connections_contract")
984 .ok_or(SocialError::AuthorizationRequired)?;
985 if declaration.get("enabled").and_then(Value::as_bool) != Some(true) {
986 return Err(SocialError::AuthorizationRequired);
987 }
988 let owner = selected_owner
989 .or_else(|| declaration.get("collection_owner").and_then(Value::as_i64))
990 .ok_or(SocialError::AuthorizationRequired)?;
991 let scope = scope(app, owner)?;
992 let capability = declaration
993 .get("owner_capability")
994 .and_then(Value::as_str)
995 .filter(|value| {
996 !matches!(
997 *value,
998 "public" | "customer" | "account_login" | "account_logout" | "authenticated_user"
999 )
1000 })
1001 .ok_or(SocialError::Configuration)?;
1002 pharos_runtime_security::local_role_cache_revoke_actor(scope.namespace(), owner);
1003 if !actor_has_route_policy(app, owner, capability).map_err(|_| SocialError::Transport)? {
1004 return Err(SocialError::Forbidden);
1005 }
1006 let store = app_database_operations(app).ok_or(SocialError::Configuration)?;
1007 let users = store
1008 .adapter
1009 .query(
1010 &store.config,
1011 &store.schema,
1012 &Query {
1013 entity: "user_account".into(),
1014 fields: vec!["id".into()],
1015 filter: Some(Predicate::All(vec![
1016 Predicate::Equal("id".into(), json!(owner)),
1017 Predicate::Equal("is_active".into(), json!(true)),
1018 ])),
1019 order: vec![],
1020 limit: 1,
1021 offset: 0,
1022 },
1023 )
1024 .map_err(|_| SocialError::Transport)?;
1025 if users.is_empty() {
1026 return Err(SocialError::Forbidden);
1027 }
1028 Ok((scope, store))
1029}
1030
1031fn current_account(
1033 repository: &ConnectionRepository<'_>,
1034 client: &SocialClient,
1035 scope: &ConnectionScope,
1036 registration: &Registration,
1037) -> Result<
1038 (
1039 ConnectionRecord,
1040 pharos_runtime_social::connection::ConnectedAccount,
1041 ),
1042 SocialError,
1043> {
1044 let row = repository
1045 .load(scope, client.provider())?
1046 .ok_or(SocialError::AuthorizationRequired)?;
1047 let account = repository.account(client, scope, &row, ®istration.revision)?;
1048 let now = SystemTime::now()
1049 .duration_since(UNIX_EPOCH)
1050 .map_err(|_| SocialError::Configuration)?
1051 .as_secs() as i64;
1052 if row.expires > now + 60 {
1053 return Ok((row, account));
1054 }
1055 let leased = repository.lease_refresh(&row, now)?;
1056 let runtime = tokio::runtime::Builder::new_current_thread()
1057 .enable_all()
1058 .build()
1059 .map_err(|_| SocialError::Transport)?;
1060 let account = runtime.block_on(client.refresh(
1061 &provider_transport(client, None)?,
1062 &Cancellation::default(),
1063 scope,
1064 &account,
1065 ))?;
1066 let updated = repository.promote(
1067 client,
1068 scope,
1069 client.provider(),
1070 ®istration.revision,
1071 Some(&leased),
1072 &account,
1073 )?;
1074 Ok((updated, account))
1075}
1076
1077pub(super) fn process_one(app: &AppRuntime) {
1079 #[cfg(unix)]
1080 browser::maintenance();
1081 if dynamic::dynamic_contract_doc(app, "social_connections_contract")
1082 .as_ref()
1083 .map(browser_mode)
1084 .transpose()
1085 .unwrap_or(Some(true))
1086 .unwrap_or(false)
1087 {
1088 return;
1089 }
1090
1091 for provider in [
1092 SocialProvider::Facebook,
1093 SocialProvider::Instagram,
1094 SocialProvider::X,
1095 ] {
1096 process_provider(app, provider);
1097 }
1098}
1099
1100fn process_provider(app: &AppRuntime, provider: SocialProvider) {
1102 if super::runtime_shutdown_requested() {
1103 return;
1104 }
1105 let Ok((scope, store, registration)) = collection_context(app, provider) else {
1106 return;
1107 };
1108 let Ok(vault) = vault(app) else {
1109 return;
1110 };
1111 let Some(capability) = dynamic::dynamic_contract_doc(app, "social_connections_contract")
1112 .and_then(|value| {
1113 value
1114 .get("owner_capability")
1115 .and_then(Value::as_str)
1116 .map(str::to_owned)
1117 })
1118 else {
1119 return;
1120 };
1121 let Ok(guards) = authority_guards(app, &store, scope.owner(), &capability, None) else {
1122 return;
1123 };
1124 let repository = ConnectionRepository {
1125 adapter: store.adapter.as_ref(),
1126 config: &store.config,
1127 schema: &store.schema,
1128 vault: &vault,
1129 guards: &guards,
1130 };
1131 let Ok(client) = collection_client(app, ®istration, &scope) else {
1132 return;
1133 };
1134 let now = SystemTime::now()
1135 .duration_since(UNIX_EPOCH)
1136 .map(|value| value.as_secs() as i64)
1137 .unwrap_or(0);
1138 let Ok(Some(lease)) = repository.claim_collection(&scope, provider, now) else {
1139 return;
1140 };
1141 let result = (|| {
1142 let (row, account) = current_account(&repository, &client, &scope, ®istration)?;
1143 let endpoint =
1144 client.collection_endpoint(&pharos_runtime_social::connection::PostQuery {
1145 account_id: &lease.target,
1146 since_id: (!lease.since_id.is_empty()).then_some(lease.since_id.as_str()),
1147 page_token: (!lease.page_token.is_empty()).then_some(lease.page_token.as_str()),
1148 })?;
1149 let http = provider_transport(&client, Some(endpoint))?;
1150 let runtime = tokio::runtime::Builder::new_current_thread()
1151 .enable_all()
1152 .build()
1153 .map_err(|_| SocialError::Transport)?;
1154 let page = runtime.block_on(repository.collect_page(
1155 &client,
1156 &http,
1157 &Cancellation::default(),
1158 &scope,
1159 &account,
1160 &lease,
1161 ))?;
1162 if super::runtime_shutdown_requested() {
1163 return Err(SocialError::Busy);
1164 }
1165 collection_context(app, provider)?;
1166 repository.commit_page(
1167 &scope,
1168 provider,
1169 &row,
1170 &lease,
1171 &page,
1172 SystemTime::now()
1173 .duration_since(UNIX_EPOCH)
1174 .map_err(|_| SocialError::Configuration)?
1175 .as_secs() as i64,
1176 )
1177 })();
1178 if let Err(error) = result {
1179 let _ = repository.fail_collection(&lease, error, now);
1180 }
1181}
1182
1183pub(super) struct ObservationBatch {
1185 pub links: Vec<String>,
1186 pub evidence: Vec<Value>,
1187 pub unverified_posts: usize,
1188 pub verified_posts: usize,
1189 pub guards: Vec<pharos_runtime_database::operations::Mutation>,
1190}
1191
1192pub(super) fn observations_for_profile(
1194 app: &AppRuntime,
1195 profile: &str,
1196 interval: i64,
1197) -> Result<ObservationBatch, SocialError> {
1198 if dynamic::dynamic_contract_doc(app, "social_connections_contract")
1199 .as_ref()
1200 .map(browser_mode)
1201 .transpose()?
1202 .unwrap_or(false)
1203 {
1204 #[cfg(unix)]
1205 return browser::observations(app, profile);
1206 #[cfg(not(unix))]
1207 return Err(SocialError::UnsupportedProvider);
1208 }
1209 let parsed = Url::parse(profile).map_err(|_| SocialError::Configuration)?;
1210 let provider = SocialProvider::from_url(&parsed).ok_or(SocialError::UnsupportedProvider)?;
1211 let (scope, store, registration) = collection_context(app, provider)?;
1212 let client = collection_client(app, ®istration, &scope)?;
1213 let endpoint = client.resource_endpoint(profile)?;
1214 let vault = vault(app)?;
1215 let capability = dynamic::dynamic_contract_doc(app, "social_connections_contract")
1216 .and_then(|value| {
1217 value
1218 .get("owner_capability")
1219 .and_then(Value::as_str)
1220 .map(str::to_owned)
1221 })
1222 .ok_or(SocialError::Configuration)?;
1223 let guards = authority_guards(app, &store, scope.owner(), &capability, None)?;
1224 let repository = ConnectionRepository {
1225 adapter: store.adapter.as_ref(),
1226 config: &store.config,
1227 schema: &store.schema,
1228 vault: &vault,
1229 guards: &guards,
1230 };
1231
1232 let (connection, account) = current_account(&repository, &client, &scope, ®istration)?;
1233 let runtime = tokio::runtime::Builder::new_current_thread()
1234 .enable_all()
1235 .build()
1236 .map_err(|_| SocialError::Transport)?;
1237 let target = runtime.block_on(client.resolve_profile(
1238 &provider_transport(&client, Some(endpoint))?,
1239 &Cancellation::default(),
1240 &scope,
1241 &account,
1242 profile,
1243 ))?;
1244 let now = SystemTime::now()
1245 .duration_since(UNIX_EPOCH)
1246 .map_err(|_| SocialError::Configuration)?
1247 .as_secs() as i64;
1248 repository.schedule(&scope, provider, &target, interval, now)?;
1249 repository.collection_outcome(&scope, provider, &target)?;
1250 if !repository.collection_complete(&scope, provider, &target, now)? {
1251 return Err(SocialError::Busy);
1252 }
1253 let mut links = std::collections::BTreeSet::new();
1254 let mut complete = false;
1255 for offset in (0..10000).step_by(100) {
1256 let observations = repository.observations(&scope, provider, &target, offset)?;
1257 complete = observations.len() < 100;
1258 for observation in observations {
1259 let encoded = observation
1260 .get("links")
1261 .and_then(Value::as_str)
1262 .ok_or(SocialError::InvalidResponse)?;
1263 let urls: Vec<String> =
1264 serde_json::from_str(encoded).map_err(|_| SocialError::InvalidResponse)?;
1265 for url in urls {
1266 links.insert(url);
1267 if links.len() > 1000 {
1268 return Err(SocialError::Capacity);
1269 }
1270 }
1271 }
1272 if complete {
1273 break;
1274 }
1275 }
1276 if !complete {
1277 return Err(SocialError::Capacity);
1278 }
1279 let mut guards = guards.clone();
1280 use pharos_runtime_database::operations::{Mutation, Predicate};
1281 guards.push(Mutation::RequireCount {
1282 entity: "social_connection".into(),
1283 filter: Predicate::All(vec![
1284 pharos_runtime_social::repository::scope_filter(&scope, provider),
1285 Predicate::Equal("revision".into(), json!(connection.revision)),
1286 Predicate::Equal("status".into(), json!("connected")),
1287 ]),
1288 minimum: 1,
1289 maximum: Some(1),
1290 });
1291 Ok(ObservationBatch {
1292 links: links.into_iter().collect(),
1293 evidence: Vec::new(),
1294 unverified_posts: 0,
1295 verified_posts: 0,
1296 guards,
1297 })
1298}
1299
1300#[cfg(unix)]
1302pub(super) fn shutdown_browsers() {
1303 browser::shutdown();
1304}