1use super::*;
3use pharos_runtime_integrations::browser::{Browser, BrowserError};
4use pharos_runtime_social::browser as sessions;
5use std::sync::{Arc, Mutex, OnceLock};
6
7struct PendingBrowser {
8 scope: ConnectionScope,
9 provider: SocialProvider,
10 session: String,
11 browser: Browser,
12 target: String,
13 previous: Option<ConnectionRecord>,
14 created: std::time::Instant,
15}
16type PendingMap = std::collections::BTreeMap<String, Arc<Mutex<PendingBrowser>>>;
17fn registry() -> &'static Mutex<PendingMap> {
19 static REGISTRY: OnceLock<Mutex<PendingMap>> = OnceLock::new();
20 REGISTRY.get_or_init(|| Mutex::new(PendingMap::new()))
21}
22fn operation_lock(
24 scope: &ConnectionScope,
25 provider: SocialProvider,
26) -> Result<Arc<Mutex<()>>, SocialError> {
27 static LOCKS: OnceLock<Mutex<std::collections::BTreeMap<String, Arc<Mutex<()>>>>> =
28 OnceLock::new();
29 let mut locks = LOCKS
30 .get_or_init(|| Mutex::new(std::collections::BTreeMap::new()))
31 .lock()
32 .map_err(|_| SocialError::Busy)?;
33 locks.retain(|_, value| Arc::strong_count(value) > 1);
34 if locks.len() >= 256 {
35 return Err(SocialError::Busy);
36 }
37 Ok(locks
38 .entry(reference(scope, provider))
39 .or_insert_with(|| Arc::new(Mutex::new(())))
40 .clone())
41}
42fn reference(scope: &ConnectionScope, provider: SocialProvider) -> String {
44 dynamic::hex_digest(&Sha256::digest(
45 format!(
46 "{}\0{}\0{}\0{}",
47 scope.application(),
48 scope.namespace(),
49 scope.owner(),
50 provider.key()
51 )
52 .as_bytes(),
53 ))
54}
55fn executable(app: &AppRuntime) -> Result<PathBuf, SocialError> {
57 let conf = if app.is_editor_preview {
58 OPERATOR_CONFIG
59 .read()
60 .map_err(|_| SocialError::Configuration)?
61 .clone()
62 } else {
63 app.runtime_conf_path.clone()
64 }
65 .ok_or(SocialError::Configuration)?;
66 let conf = Path::new(&conf);
67 if config::dynamic_config_lookup_raw(conf, "SOCIAL_BROWSER_MODE").as_deref() != Some("local") {
68 return Err(SocialError::UnsupportedProvider);
69 }
70 let path = config::dynamic_config_lookup_raw(conf, "SOCIAL_BROWSER_EXECUTABLE")
71 .filter(|value| !value.is_empty())
72 .map(PathBuf::from)
73 .or_else(|| {
74 [
75 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
76 "/usr/bin/chromium",
77 "/usr/bin/google-chrome",
78 ]
79 .iter()
80 .map(PathBuf::from)
81 .find(|path| path.is_file())
82 })
83 .ok_or(SocialError::Configuration)?;
84 if !path.is_absolute() || !path.is_file() {
85 return Err(SocialError::Configuration);
86 }
87 Ok(path)
88}
89fn profile(
91 app: &AppRuntime,
92 scope: &ConnectionScope,
93 provider: SocialProvider,
94) -> Result<PathBuf, SocialError> {
95 let root = app
96 .state_path
97 .as_deref()
98 .and_then(|path| Path::new(path).parent())
99 .ok_or(SocialError::Configuration)?;
100 for installed in [app.app_root.as_deref(), app.artifact_dir.as_deref()]
101 .into_iter()
102 .flatten()
103 {
104 if root.starts_with(installed) {
105 return Err(SocialError::Configuration);
106 }
107 }
108 let root = root.join("browser-sessions");
109 stdfs::create_dir_all(&root).map_err(|_| SocialError::Transport)?;
110 use std::os::unix::fs::PermissionsExt;
111 if stdfs::symlink_metadata(&root)
112 .map_err(|_| SocialError::Transport)?
113 .file_type()
114 .is_symlink()
115 {
116 return Err(SocialError::Forbidden);
117 }
118 stdfs::set_permissions(&root, stdfs::Permissions::from_mode(0o700))
119 .map_err(|_| SocialError::Transport)?;
120 Ok(root.join(reference(scope, provider)))
121}
122fn error(error: BrowserError) -> SocialError {
124 match error {
125 BrowserError::Blocked => SocialError::Forbidden,
126 BrowserError::Capacity => SocialError::Capacity,
127 BrowserError::Closed | BrowserError::Unavailable => SocialError::Transport,
128 BrowserError::InvalidResponse => SocialError::InvalidResponse,
129 }
130}
131fn pending(
133 scope: &ConnectionScope,
134 provider: SocialProvider,
135 session: &str,
136) -> Result<Option<Arc<Mutex<PendingBrowser>>>, SocialError> {
137 let handle = registry()
138 .lock()
139 .map_err(|_| SocialError::Busy)?
140 .get(&reference(scope, provider))
141 .cloned();
142 if let Some(handle) = &handle {
143 let value = handle.lock().map_err(|_| SocialError::Busy)?;
144 if value.scope != *scope || value.provider != provider || value.session != session {
145 return Err(SocialError::Busy);
146 }
147 }
148 Ok(handle)
149}
150pub(super) fn maintenance() {
152 let handles = match registry().lock() {
153 Ok(map) => map
154 .iter()
155 .map(|(key, value)| (key.clone(), value.clone()))
156 .collect::<Vec<_>>(),
157 Err(_) => return,
158 };
159 for (key, handle) in handles {
160 let expired = handle.try_lock().is_ok_and(|mut value| {
161 value.created.elapsed() > Duration::from_secs(1200) || !value.browser.is_open()
162 });
163 if expired {
164 let removed = registry().lock().ok().and_then(|mut map| map.remove(&key));
165 drop(removed);
166 }
167 }
168}
169pub(super) fn shutdown() {
171 let handles = registry()
172 .lock()
173 .map(|mut values| std::mem::take(&mut *values))
174 .unwrap_or_default();
175 drop(handles);
176}
177#[allow(clippy::too_many_arguments)]
179pub(super) fn handle(
180 app: &AppRuntime,
181 request: &http::HttpRequest,
182 declaration: &Value,
183 provider: SocialProvider,
184 operation: &str,
185 scope: &ConnectionScope,
186 store: &database::LogicalAppStore,
187 session: &str,
188 capability: &str,
189) -> Result<Vec<u8>, SocialError> {
190 let operation_lock = (operation != "status")
192 .then(|| operation_lock(scope, provider))
193 .transpose()?;
194 let _operation_guard = operation_lock
195 .as_ref()
196 .map(|lock| lock.lock().map_err(|_| SocialError::Busy))
197 .transpose()?;
198 maintenance();
199 let destination = return_destination(declaration)?;
200 let available = executable(app).is_ok();
201 let vault = vault(app)?;
202 let guards = authority_guards(app, store, scope.owner(), capability, Some(session))?;
203 let repository = ConnectionRepository {
204 adapter: store.adapter.as_ref(),
205 config: &store.config,
206 schema: &store.schema,
207 vault: &vault,
208 guards: &guards,
209 };
210 let row = repository.browser(scope, provider)?;
211 if operation == "status" {
212 let pending = pending(scope, provider, session)?;
213 if request.target.contains("format=json") || request.target.contains("format%3Djson") {
214 let page_ready = if let Some(handle) = &pending {
215 let mut value = handle.lock().map_err(|_| SocialError::Busy)?;
216 let target = value.target.clone();
217 value.browser.page_ready(&target).unwrap_or(false)
218 } else {
219 false
220 };
221 return Ok(http_json_response(200,"OK",&json!({"provider":provider.key(),"mode":"browser","page_ready":page_ready,"configured":available,"connection_scope":"owner","namespace":scope.namespace(),"authorization_pending":pending.is_some(),"connection":row}).to_string(),&request.method));
222 }
223 return status_response(
224 app,
225 request,
226 declaration,
227 None,
228 provider,
229 scope,
230 row,
231 session,
232 None,
233 Some(BrowserStatus {
234 available,
235 pending: pending.is_some(),
236 }),
237 );
238 }
239 match operation {
240 "connect" => {
241 if super::super::runtime_shutdown_requested() {
242 return Err(SocialError::Busy);
243 }
244 if pending(scope, provider, session)?.is_some() {
245 return Err(SocialError::Busy);
246 }
247 let executable = executable(app)?;
248 let profile = profile(app, scope, provider)?;
249 let mut sign_in_hosts =
250 if provider == SocialProvider::X || provider.needs_account_page() {
251 pharos_runtime_integrations::browser::GOOGLE_SIGN_IN_HOSTS.to_vec()
252 } else {
253 Vec::new()
254 };
255 if provider.needs_account_page() {
256 sign_in_hosts
257 .extend_from_slice(pharos_runtime_integrations::browser::ACCOUNT_SIGN_IN_HOSTS);
258 }
259 let mut browser = Browser::launch_with_sign_in_hosts(
260 &executable,
261 &profile,
262 sessions::domains(provider),
263 true,
264 &sign_in_hosts,
265 )
266 .map_err(error)?;
267 if let Some(row) = row.as_ref().filter(|row| !row.credential_ref.is_empty()) {
268 let cookies = checkpoint_for_retained_profile(&repository, scope, provider, row)?;
269 match restore_retained_session(
270 &mut browser,
271 provider,
272 &row.account_id,
273 cookies.as_deref().map(String::as_str).unwrap_or_default(),
274 ) {
275 Ok(()) | Err(SocialError::AuthorizationRequired) => {}
276 Err(problem) => return Err(problem),
277 }
278 }
279 let target = browser.open(sessions::home(provider)).map_err(error)?;
280 let handle = Arc::new(Mutex::new(PendingBrowser {
281 scope: scope.clone(),
282 provider,
283 session: session.into(),
284 browser,
285 target,
286 previous: row,
287 created: std::time::Instant::now(),
288 }));
289 let mut map = registry().lock().map_err(|_| SocialError::Busy)?;
290 if map.len() >= 4 || map.contains_key(&reference(scope, provider)) {
291 return Err(SocialError::Busy);
292 }
293 map.insert(reference(scope, provider), handle);
294 }
295 "finish" => {
296 let Some(handle) = pending(scope, provider, session)? else {
297 return status_response(
298 app,
299 request,
300 declaration,
301 None,
302 provider,
303 scope,
304 row,
305 session,
306 None,
307 Some(BrowserStatus {
308 available,
309 pending: false,
310 }),
311 );
312 };
313 let mut value = handle.lock().map_err(|_| SocialError::Busy)?;
314 let target = value.target.clone();
315 let ready = match value.browser.authentication_ready(&target) {
316 Ok(ready) => ready,
317 Err(problem) => {
318 eprintln!(
319 "browser connection navigation check unavailable: {}",
320 error(problem).code()
321 );
322 false
323 }
324 };
325 if !ready {
326 return status_response(
327 app,
328 request,
329 declaration,
330 None,
331 provider,
332 scope,
333 row,
334 session,
335 None,
336 Some(BrowserStatus {
337 available,
338 pending: true,
339 }),
340 );
341 }
342 let cookies = match value.browser.cookies() {
343 Ok(cookies) => cookies,
344 Err(problem) => {
345 eprintln!(
346 "browser connection cookie checkpoint unavailable: {}",
347 error(problem).code()
348 );
349 consent_ui::record(scope, provider, session, Some(consent_ui::Outcome::Failed));
350 return status_response(
351 app,
352 request,
353 declaration,
354 None,
355 provider,
356 scope,
357 row,
358 session,
359 None,
360 Some(BrowserStatus {
361 available,
362 pending: true,
363 }),
364 );
365 }
366 };
367 let identity = if provider.needs_account_page() {
368 value
369 .browser
370 .account_identity(&target, provider.key())
371 .map_err(error)?
372 .map(|identity| {
373 dynamic::hex_digest(&Sha256::digest(
374 format!("{}\0{identity}", provider.key()).as_bytes(),
375 ))
376 })
377 } else {
378 sessions::account(provider, &cookies).ok()
379 };
380 if identity.as_deref().is_none_or(|identity| {
381 sessions::account_with_identity(provider, &cookies, Some(identity)).is_err()
382 }) {
383 return status_response(
384 app,
385 request,
386 declaration,
387 None,
388 provider,
389 scope,
390 row,
391 session,
392 None,
393 Some(BrowserStatus {
394 available,
395 pending: true,
396 }),
397 );
398 }
399 let identity = identity.ok_or(SocialError::AuthorizationRequired)?;
400 require_session(store, session, scope.owner())?;
401 let saved = match repository.save_browser_identity(
402 scope,
403 provider,
404 value.previous.as_ref(),
405 &cookies,
406 &identity,
407 ) {
408 Ok(row) => row,
409 Err(error) => {
410 eprintln!("browser connection checkpoint failed: {}", error.code());
411 consent_ui::record(scope, provider, session, Some(consent_ui::Outcome::Failed));
412 return status_response(
413 app,
414 request,
415 declaration,
416 None,
417 provider,
418 scope,
419 row,
420 session,
421 None,
422 Some(BrowserStatus {
423 available,
424 pending: true,
425 }),
426 );
427 }
428 };
429 consent_ui::record(scope, provider, session, None);
430 value.browser.close();
431 drop(value);
432 let removed = registry()
433 .lock()
434 .map_err(|_| SocialError::Busy)?
435 .remove(&reference(scope, provider));
436 drop(removed);
437 use pharos_runtime_database::operations::{Mutation, Predicate};
438 let mut completion_guards = guards.clone();
439 completion_guards.push(Mutation::RequireCount {
440 entity: "social_connection".into(),
441 filter: Predicate::All(vec![
442 Predicate::Equal("application".into(), json!(scope.application())),
443 Predicate::Equal("namespace".into(), json!(scope.namespace())),
444 Predicate::Equal("owner".into(), json!(scope.owner())),
445 Predicate::Equal("provider".into(), json!(saved.provider)),
446 Predicate::Equal("revision".into(), json!(saved.revision)),
447 Predicate::Equal("status".into(), json!("connected")),
448 ]),
449 minimum: 1,
450 maximum: Some(1),
451 });
452 if super::super::event_discovery::social_connection_available(
453 app,
454 provider,
455 &completion_guards,
456 )
457 .is_err()
458 {
459 eprintln!("event sources could not be rescheduled after browser connection");
460 }
461 return status_response(
462 app,
463 request,
464 declaration,
465 None,
466 provider,
467 scope,
468 Some(saved),
469 session,
470 None,
471 Some(BrowserStatus {
472 available,
473 pending: false,
474 }),
475 );
476 }
477 "cancel" => {
478 let _ = pending(scope, provider, session)?;
479 let removed = registry()
480 .lock()
481 .map_err(|_| SocialError::Busy)?
482 .remove(&reference(scope, provider));
483 drop(removed);
484 }
485 "disconnect" => {
486 let _ = pending(scope, provider, session)?;
487 let revoked = row
488 .as_ref()
489 .filter(|row| row.status != "disconnected" || !row.credential_ref.is_empty())
490 .map(|row| repository.disconnect(row))
491 .transpose()?;
492 let removed = registry()
493 .lock()
494 .map_err(|_| SocialError::Busy)?
495 .remove(&reference(scope, provider));
496 drop(removed);
497 let profile = profile(app, scope, provider)?;
498 if profile.exists() {
499 if stdfs::symlink_metadata(&profile)
500 .map_err(|_| SocialError::Transport)?
501 .file_type()
502 .is_symlink()
503 {
504 return Err(SocialError::Forbidden);
505 }
506 stdfs::remove_dir_all(profile).map_err(|_| SocialError::Transport)?;
507 }
508 if let Some(row) = revoked {
509 repository.finish_disconnect(&row)?;
510 }
511 }
512 _ => return Err(SocialError::Forbidden),
513 }
514 Ok(http_route_redirect_for_request(
515 request,
516 &app_scope_path(app, destination),
517 ))
518}
519
520fn selected_owner(app: &AppRuntime, provider: SocialProvider) -> Result<Option<i64>, SocialError> {
522 use pharos_runtime_database::operations::{Predicate, Query};
523 let declaration = dynamic::dynamic_contract_doc(app, "social_connections_contract")
524 .ok_or(SocialError::Configuration)?;
525 if declaration.get("collection_owner").and_then(Value::as_str) != Some("connected") {
526 return Ok(None);
527 }
528 let scope = scope(app, 1)?;
529 let store = app_database_operations(app).ok_or(SocialError::Configuration)?;
530 let rows = store
531 .adapter
532 .query(
533 &store.config,
534 &store.schema,
535 &Query {
536 entity: "social_connection".into(),
537 fields: vec!["owner".into()],
538 filter: Some(Predicate::All(vec![
539 Predicate::Equal("application".into(), json!(scope.application())),
540 Predicate::Equal("namespace".into(), json!(scope.namespace())),
541 Predicate::Equal("provider".into(), json!(sessions::key(provider))),
542 Predicate::Equal("status".into(), json!("connected")),
543 ])),
544 order: vec![],
545 limit: 2,
546 offset: 0,
547 },
548 )
549 .map_err(|_| SocialError::Transport)?;
550 if rows.len() != 1 {
551 return Err(SocialError::AuthorizationRequired);
552 }
553 Ok(Some(
554 rows[0]
555 .get("owner")
556 .and_then(Value::as_i64)
557 .filter(|owner| *owner > 0)
558 .ok_or(SocialError::Configuration)?,
559 ))
560}
561fn checkpoint_for_retained_profile(
563 repository: &ConnectionRepository<'_>,
564 scope: &ConnectionScope,
565 provider: SocialProvider,
566 row: &ConnectionRecord,
567) -> Result<Option<zeroize::Zeroizing<String>>, SocialError> {
568 match repository.browser_reconnect_cookies(scope, provider, row) {
569 Ok(cookies) => Ok(Some(cookies)),
570 Err(SocialError::AuthorizationRequired)
571 if row.application == scope.application()
572 && row.namespace == scope.namespace()
573 && row.owner == scope.owner()
574 && row.provider == sessions::key(provider)
575 && !row.account_id.is_empty()
576 && matches!(
577 row.status.as_str(),
578 "connected" | "reauthorization_required" | "disconnected"
579 ) =>
580 {
581 Ok(None)
582 }
583 Err(problem) => Err(problem),
584 }
585}
586
587fn restore_retained_session(
589 browser: &mut Browser,
590 provider: SocialProvider,
591 account_id: &str,
592 checkpoint: &str,
593) -> Result<(), SocialError> {
594 let retained = browser.cookies().map_err(error)?;
595 match sessions::account_with_identity(provider, &retained, Some(account_id)) {
597 Ok(account) if account == account_id => Ok(()),
598 Ok(_) => Err(SocialError::Forbidden),
599 Err(_) if checkpoint.is_empty() => Err(SocialError::AuthorizationRequired),
600 Err(_) => browser.restore(checkpoint).map_err(error),
601 }
602}
603
604fn provider_home_ready(
606 browser: &mut Browser,
607 provider: SocialProvider,
608) -> Result<bool, SocialError> {
609 let target = browser.open(sessions::home(provider)).map_err(error)?;
610 let started = std::time::Instant::now();
611 loop {
612 if started.elapsed() >= Duration::from_secs(2)
613 && browser.page_ready(&target).map_err(error)?
614 && browser.authentication_ready(&target).map_err(error)?
615 {
616 return Ok(true);
617 }
618 if started.elapsed() >= Duration::from_secs(5) {
619 return Ok(false);
620 }
621 std::thread::sleep(Duration::from_millis(250));
622 }
623}
624
625fn settle_blocked_resource(
627 browser: &mut Browser,
628 repository: &ConnectionRepository<'_>,
629 scope: &ConnectionScope,
630 provider: SocialProvider,
631 leased: &ConnectionRecord,
632) -> Result<(), SocialError> {
633 if provider_home_ready(browser, provider)? {
634 let retained = browser.cookies().map_err(error)?;
635 if sessions::account(provider, &retained).ok().as_deref()
636 == Some(leased.account_id.as_str())
637 {
638 repository.save_browser(scope, provider, Some(leased), &retained)?;
639 return Ok(());
640 }
641 }
642 repository.browser_needs_login(leased)
643}
644
645pub(super) fn observations(
647 app: &AppRuntime,
648 profile_url: &str,
649) -> Result<ObservationBatch, SocialError> {
650 use pharos_runtime_database::operations::{Mutation, Predicate, Transaction, WriteValue};
651 let parsed = Url::parse(profile_url).map_err(|_| SocialError::InvalidResponse)?;
652 let provider = SocialProvider::from_url(&parsed).ok_or(SocialError::UnsupportedProvider)?;
653 let selected = selected_owner(app, provider).inspect_err(|problem| {
654 eprintln!(
655 "social collection owner unavailable: provider={} code={}",
656 provider.key(),
657 problem.code()
658 );
659 })?;
660 let (scope, store) = collection_authority(app, selected).inspect_err(|problem| {
661 eprintln!(
662 "social collection authority unavailable: provider={} code={}",
663 provider.key(),
664 problem.code()
665 );
666 })?;
667 let operation_lock = operation_lock(&scope, provider)?;
668 let _operation_guard = operation_lock.lock().map_err(|_| SocialError::Busy)?;
669 if registry()
670 .lock()
671 .map_err(|_| SocialError::Busy)?
672 .contains_key(&reference(&scope, provider))
673 {
674 return Err(SocialError::Busy);
675 }
676 let declaration = dynamic::dynamic_contract_doc(app, "social_connections_contract")
677 .ok_or(SocialError::Configuration)?;
678 let capability = declaration
679 .get("owner_capability")
680 .and_then(Value::as_str)
681 .ok_or(SocialError::Configuration)?;
682 let guards = authority_guards(app, &store, scope.owner(), capability, None)?;
683 let vault = vault(app)?;
684 let repository = ConnectionRepository {
685 adapter: store.adapter.as_ref(),
686 config: &store.config,
687 schema: &store.schema,
688 vault: &vault,
689 guards: &guards,
690 };
691 let row = repository
692 .browser(&scope, provider)?
693 .ok_or(SocialError::AuthorizationRequired)?;
694 let now = SystemTime::now()
695 .duration_since(UNIX_EPOCH)
696 .map_err(|_| SocialError::Configuration)?
697 .as_secs() as i64;
698 let cookies = checkpoint_for_retained_profile(&repository, &scope, provider, &row)?;
699 let leased = repository.lease_refresh(&row, now)?;
700 let mut checkpoint_saved = false;
701 let result = (|| {
702 let mut browser = Browser::launch(
703 &executable(app)?,
704 &profile(app, &scope, provider)?,
705 sessions::domains(provider),
706 false,
707 )
708 .map_err(error)?;
709 if let Err(problem) = restore_retained_session(
710 &mut browser,
711 provider,
712 &row.account_id,
713 cookies.as_deref().map(String::as_str).unwrap_or_default(),
714 ) {
715 if problem == SocialError::AuthorizationRequired {
716 repository.browser_needs_login(&leased)?;
717 }
718 return Err(problem);
719 }
720 let restored = browser.cookies().map_err(error)?;
721 if sessions::account(provider, &restored).ok().as_deref() != Some(row.account_id.as_str()) {
722 repository.browser_needs_login(&leased)?;
723 return Err(SocialError::AuthorizationRequired);
724 }
725 let target = browser.open(profile_url).map_err(error)?;
726 let collection_started = std::time::Instant::now();
727 let mut poster_budget = 2;
728 let mut date_budget = 8;
729 let snapshot = collection_snapshot(
730 app,
731 &mut browser,
732 &target,
733 profile_url,
734 profile_url,
735 &mut poster_budget,
736 &mut date_budget,
737 );
738 let mut snapshot = match snapshot {
739 Ok(snapshot) => snapshot,
740 Err(SocialError::AuthorizationRequired) => {
741 eprintln!(
742 "social artist resource redirected: provider={}",
743 provider.key()
744 );
745 settle_blocked_resource(&mut browser, &repository, &scope, provider, &leased)?;
746 return Err(SocialError::AuthorizationRequired);
747 }
748 Err(error) => return Err(error),
749 };
750 let resources = snapshot
751 .get("post_resources")
752 .and_then(Value::as_array)
753 .cloned()
754 .unwrap_or_default();
755 let mut resource_count = 0;
756 let mut detail_dates = Vec::new();
757 let mut detail_diagnostics = Vec::new();
758 for resource in resources.iter().filter_map(Value::as_str).take(4) {
759 if collection_started.elapsed() >= Duration::from_secs(20) {
760 break;
761 }
762 resource_count += 1;
763 let post_target = match browser.open(resource) {
764 Ok(target) => target,
765 Err(problem) => {
766 eprintln!(
767 "browser optional detail unavailable: {}",
768 error(problem).code()
769 );
770 continue;
771 }
772 };
773 let page = match collection_snapshot(
774 app,
775 &mut browser,
776 &post_target,
777 resource,
778 profile_url,
779 &mut poster_budget,
780 &mut date_budget,
781 ) {
782 Ok(page) => page,
783 Err(problem) => {
784 eprintln!("browser optional detail unavailable: {}", problem.code());
785 continue;
786 }
787 };
788 let diagnostics = page.get("diagnostics").unwrap_or(&Value::Null);
789 detail_diagnostics.push(json!({"headings":diagnostics.get("heading_count"),"messages":diagnostics.get("message_nodes"),"attributed":diagnostics.get("attributed"),"profile_links":diagnostics.get("profile_links"),"main_links":diagnostics.get("main_link_count"),"privacy":diagnostics.get("instagram_privacy")}));
790 detail_dates.extend(
791 page.pointer("/diagnostics/posting_dates")
792 .and_then(Value::as_array)
793 .into_iter()
794 .flatten()
795 .take(4)
796 .cloned(),
797 );
798 for key in ["posts", "links", "structured"] {
799 let additions = page
800 .get(key)
801 .and_then(Value::as_array)
802 .cloned()
803 .unwrap_or_default();
804 if let Some(values) = snapshot.get_mut(key).and_then(Value::as_array_mut) {
805 for value in additions.into_iter().take(20) {
806 if values.len() < 60 && !values.contains(&value) {
807 values.push(value);
808 }
809 }
810 }
811 }
812 }
813 snapshot["diagnostics"]["detail_pages"] = json!(resource_count);
814 snapshot["diagnostics"]["detail_posting_dates"] = json!(detail_dates);
815 snapshot["diagnostics"]["detail_views"] = json!(detail_diagnostics);
816 snapshot["text"] = json!(
817 snapshot
818 .get("posts")
819 .and_then(Value::as_array)
820 .map(|posts| posts
821 .iter()
822 .filter_map(|post| post.get("text").and_then(Value::as_str))
823 .take(20)
824 .collect::<Vec<_>>()
825 .join("\n"))
826 .unwrap_or_default()
827 );
828 let current = snapshot
829 .get("url")
830 .and_then(Value::as_str)
831 .ok_or(SocialError::InvalidResponse)?;
832 let refreshed = browser.cookies().map_err(error)?;
833 if sessions::account(provider, &refreshed).ok().as_deref() != Some(row.account_id.as_str())
834 {
835 repository.browser_needs_login(&leased)?;
836 return Err(SocialError::AuthorizationRequired);
837 }
838 if !browser.allowed_url(current)
839 || snapshot.get("login").and_then(Value::as_bool) == Some(true)
840 {
841 eprintln!(
842 "social artist resource login barrier: provider={}",
843 provider.key()
844 );
845 settle_blocked_resource(&mut browser, &repository, &scope, provider, &leased)?;
846 return Err(SocialError::AuthorizationRequired);
847 }
848 let row = repository.save_browser(&scope, provider, Some(&leased), &refreshed)?;
849 checkpoint_saved = true;
850 let mut evidence = snapshot
851 .get("structured")
852 .and_then(Value::as_array)
853 .map(|values| values.iter().take(10).cloned().collect::<Vec<_>>())
854 .unwrap_or_default();
855 for post in snapshot
857 .get("posts")
858 .and_then(Value::as_array)
859 .into_iter()
860 .flatten()
861 .take(10)
862 {
863 let text = post.get("text").and_then(Value::as_str).unwrap_or("");
864 let text = &text[..text.floor_char_boundary(3500)];
865 if evidence.len() < 40
866 && (!text.trim().is_empty()
867 || post
868 .get("image_text")
869 .and_then(Value::as_str)
870 .is_some_and(|text| !text.trim().is_empty()))
871 {
872 evidence.push(json!({"url":post.get("permalink").and_then(Value::as_str).unwrap_or(profile_url),"description":text,"source_profile":profile_url,"evidence_kind":"rendered_post","audience":post.get("audience"),"datePublished":post.get("published_at"),"publication_labels":post.get("publication_labels"),"image_text":post.get("image_text"),"image_status":post.get("image_status"),"author_url":post.get("author_url")}));
873 }
874 for link in post
875 .get("links")
876 .and_then(Value::as_array)
877 .into_iter()
878 .flatten()
879 .filter_map(Value::as_str)
880 .take(20)
881 {
882 if facebook_event_url(link).is_some() && evidence.len() < 40 {
883 evidence.push(json!({"evidence_kind":"rendered_post","url":link,"description":text,"source_profile":profile_url,"datePublished":post.get("published_at"),"publication_labels":post.get("publication_labels"),"image_text":post.get("image_text")}));
884 break;
885 }
886 }
887 }
888 let event_links = snapshot
889 .get("links")
890 .and_then(Value::as_array)
891 .into_iter()
892 .flatten()
893 .filter_map(|link| link.get("url").and_then(Value::as_str))
894 .filter_map(facebook_event_url)
895 .collect::<std::collections::BTreeSet<_>>();
896 for link in event_links
897 .iter()
898 .take(2usize.saturating_sub(resource_count))
899 {
900 let target = match browser.open(link) {
901 Ok(target) => target,
902 Err(problem) => {
903 eprintln!(
904 "browser optional detail unavailable: {}",
905 error(problem).code()
906 );
907 continue;
908 }
909 };
910 let page = match collection_snapshot(
911 app,
912 &mut browser,
913 &target,
914 link,
915 profile_url,
916 &mut poster_budget,
917 &mut date_budget,
918 ) {
919 Ok(page) => page,
920 Err(problem) => {
921 eprintln!("browser optional detail unavailable: {}", problem.code());
922 continue;
923 }
924 };
925 if page.get("login").and_then(Value::as_bool) == Some(true) {
926 continue;
927 }
928 for value in page
929 .get("structured")
930 .and_then(Value::as_array)
931 .into_iter()
932 .flatten()
933 .take(3)
934 {
935 if evidence.len() < 40 {
936 evidence.push(value.clone());
937 }
938 }
939 }
940 browser.close();
941 let mut links = std::collections::BTreeSet::new();
942 for item in snapshot
943 .get("links")
944 .and_then(Value::as_array)
945 .into_iter()
946 .flatten()
947 .take(1000)
948 {
949 let Some(value) = item.get("url").and_then(Value::as_str) else {
950 continue;
951 };
952 let Ok(mut url) = Url::parse(value) else {
953 continue;
954 };
955 if matches!(
956 url.host_str(),
957 Some("l.facebook.com" | "lm.facebook.com" | "l.instagram.com")
958 ) && let Some(value) = url
959 .query_pairs()
960 .find(|(key, _)| key == "u")
961 .map(|(_, value)| value.into_owned())
962 && let Ok(decoded) = Url::parse(&value)
963 {
964 url = decoded;
965 }
966 if url.scheme() == "https"
967 && url.username().is_empty()
968 && url.password().is_none()
969 && url.as_str().len() <= 2048
970 {
971 if evidence.len() < 40 && SocialProvider::from_url(&url).is_none() {
972 let context = item.get("context").and_then(Value::as_str).unwrap_or("");
973 let context = &context[..context.floor_char_boundary(3500)];
974 evidence.push(json!({"evidence_kind":"rendered_link","url":url.as_str(),"description":context,"source_profile":profile_url,"datePublished":item.get("published_at"),"publication_labels":item.get("publication_labels"),"author_url":item.get("author_url")}));
975 }
976 links.insert(url.to_string());
977 }
978 }
979 let mut guards = guards.clone();
980 guards.push(Mutation::RequireCount {
981 entity: "social_connection".into(),
982 filter: Predicate::All(vec![
983 Predicate::Equal("application".into(), json!(scope.application())),
984 Predicate::Equal("namespace".into(), json!(scope.namespace())),
985 Predicate::Equal("owner".into(), json!(scope.owner())),
986 Predicate::Equal("provider".into(), json!(sessions::key(provider))),
987 Predicate::Equal("revision".into(), json!(row.revision)),
988 Predicate::Equal("status".into(), json!("connected")),
989 ]),
990 minimum: 1,
991 maximum: Some(1),
992 });
993 let text = snapshot.get("text").and_then(Value::as_str).unwrap_or("");
994 let text = format!(
995 "Attributed browser posts v3; public evidence required before publication.\n{}\nCapture diagnostics: {}",
996 &text[..text.floor_char_boundary(16000)],
997 snapshot.get("diagnostics").cloned().unwrap_or(Value::Null)
998 );
999 let target = pharos_runtime_database::operations::operation_fingerprint(&profile_url)
1000 .map_err(|_| SocialError::InvalidResponse)?;
1001 let post = pharos_runtime_database::operations::operation_fingerprint(&(
1002 profile_url,
1003 &text,
1004 &links,
1005 ))
1006 .map_err(|_| SocialError::InvalidResponse)?;
1007 let observation = json!({"application":scope.application(),"namespace":scope.namespace(),"owner":scope.owner(),"provider":sessions::key(provider),"target":target,"post_id":post,"published_at":"","permalink":profile_url,"text":text,"links":serde_json::to_string(&links).map_err(|_|SocialError::InvalidResponse)?});
1008 let values = observation
1009 .as_object()
1010 .ok_or(SocialError::InvalidResponse)?
1011 .iter()
1012 .map(|(key, value)| (key.clone(), WriteValue::Literal(value.clone())))
1013 .collect();
1014 let mut steps = guards.clone();
1015 steps.extend(legacy_observation_redactions(&store, &scope, provider)?);
1016 steps.push(Mutation::InsertIfAbsent {
1017 entity: "social_observation".into(),
1018 unique_key: vec![
1019 "application".into(),
1020 "namespace".into(),
1021 "owner".into(),
1022 "provider".into(),
1023 "target".into(),
1024 "post_id".into(),
1025 ],
1026 values,
1027 });
1028 store
1029 .adapter
1030 .transact(
1031 &store.config,
1032 &store.schema,
1033 &Transaction {
1034 steps,
1035 idempotency_key: None,
1036 },
1037 )
1038 .map_err(|_| SocialError::Busy)?;
1039 let posts = snapshot.get("posts").and_then(Value::as_array);
1040 let unverified_posts = match posts {
1041 Some(posts) if !posts.is_empty() => posts
1042 .iter()
1043 .filter(|post| {
1044 !super::super::event_discovery::social_evidence::posting_date_known(post)
1045 })
1046 .count(),
1047 _ => snapshot
1048 .pointer("/diagnostics/message_nodes")
1049 .and_then(Value::as_u64)
1050 .unwrap_or(0) as usize,
1051 };
1052 let verified_posts = posts
1053 .map(|posts| {
1054 posts
1055 .iter()
1056 .filter(|post| {
1057 super::super::event_discovery::social_evidence::posting_date_known(post)
1058 })
1059 .count()
1060 })
1061 .unwrap_or(0);
1062 Ok(ObservationBatch {
1063 links: links.into_iter().collect(),
1064 evidence,
1065 unverified_posts,
1066 verified_posts,
1067 guards,
1068 })
1069 })();
1070 if result.is_err() && !checkpoint_saved {
1071 match repository.release_browser_lease(&leased) {
1072 Ok(()) | Err(SocialError::Busy | SocialError::Forbidden) => {}
1073 Err(problem) => eprintln!(
1074 "browser collection lease cleanup failed: {}",
1075 problem.code()
1076 ),
1077 }
1078 }
1079 result
1080}
1081
1082fn legacy_observation_redactions(
1084 store: &super::super::database::LogicalAppStore,
1085 scope: &ConnectionScope,
1086 provider: SocialProvider,
1087) -> Result<Vec<pharos_runtime_database::operations::Mutation>, SocialError> {
1088 use pharos_runtime_database::operations::{Mutation, Predicate, Query, WriteValue};
1089 let prefixes = [
1090 "Partial browser view; public evidence required before publication.",
1091 "Scoped browser content v2; public evidence required before publication.",
1092 ];
1093 let scope_filter = vec![
1094 Predicate::Equal("application".into(), json!(scope.application())),
1095 Predicate::Equal("namespace".into(), json!(scope.namespace())),
1096 Predicate::Equal("owner".into(), json!(scope.owner())),
1097 Predicate::Equal("provider".into(), json!(sessions::key(provider))),
1098 ];
1099 let mut filter = scope_filter.clone();
1100 filter.push(Predicate::Any(
1101 prefixes
1102 .iter()
1103 .map(|prefix| Predicate::Contains("text".into(), (*prefix).into()))
1104 .collect(),
1105 ));
1106 let rows = store
1107 .adapter
1108 .query(
1109 &store.config,
1110 &store.schema,
1111 &Query {
1112 entity: "social_observation".into(),
1113 fields: vec![
1114 "target".into(),
1115 "post_id".into(),
1116 "text".into(),
1117 "links".into(),
1118 ],
1119 filter: Some(Predicate::All(filter)),
1120 order: vec![],
1121 limit: 100,
1122 offset: 0,
1123 },
1124 )
1125 .map_err(|_| SocialError::Transport)?;
1126 let mut steps = Vec::new();
1127 for row in rows {
1128 if !row
1129 .get("text")
1130 .and_then(Value::as_str)
1131 .is_some_and(|text| prefixes.iter().any(|prefix| text.starts_with(prefix)))
1132 {
1133 continue;
1134 }
1135 let mut filter = scope_filter.clone();
1136 for field in ["target", "post_id", "text", "links"] {
1137 let value = row
1138 .get(field)
1139 .filter(|value| value.is_string())
1140 .ok_or(SocialError::InvalidResponse)?;
1141 filter.push(Predicate::Equal(field.into(), value.clone()));
1142 }
1143 steps.push(Mutation::Update {
1144 entity: "social_observation".into(),
1145 filter: Predicate::All(filter),
1146 values: std::collections::BTreeMap::from([
1147 (
1148 "text".into(),
1149 WriteValue::Literal(json!(
1150 "[redacted: legacy browser snapshot lacked verified post attribution]"
1151 )),
1152 ),
1153 ("links".into(), WriteValue::Literal(json!("[]"))),
1154 ]),
1155 expected_rows: Some(1),
1156 });
1157 }
1158 Ok(steps)
1159}
1160
1161fn facebook_event_url(value: &str) -> Option<String> {
1163 let mut url = Url::parse(value).ok()?;
1164 if url.scheme() != "https"
1165 || url.port_or_known_default() != Some(443)
1166 || !matches!(
1167 url.host_str(),
1168 Some("facebook.com" | "www.facebook.com" | "m.facebook.com")
1169 )
1170 || !url.username().is_empty()
1171 || url.password().is_some()
1172 {
1173 return None;
1174 }
1175 let segments = url
1176 .path_segments()?
1177 .filter(|segment| !segment.is_empty())
1178 .collect::<Vec<_>>();
1179 if segments.len() != 2
1180 || segments[0] != "events"
1181 || segments[1].is_empty()
1182 || !segments[1].bytes().all(|byte| byte.is_ascii_digit())
1183 {
1184 return None;
1185 }
1186 url.set_query(None);
1187 url.set_fragment(None);
1188 Some(url.to_string())
1189}
1190
1191fn instagram_post_key(url: &Url) -> Option<(&str, &str)> {
1193 let path = url.path().trim_end_matches('/');
1194 let count = path.split('/').filter(|part| !part.is_empty()).count();
1195 if !(2..=3).contains(&count) {
1196 return None;
1197 }
1198 let (prefix, id) = path.rsplit_once('/')?;
1199 let kind = prefix.rsplit('/').next()?;
1200 (matches!(kind, "p" | "reel")
1201 && !id.is_empty()
1202 && id.len() <= 128
1203 && id
1204 .bytes()
1205 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, b'_' | b'-')))
1206 .then_some((kind, id))
1207}
1208
1209fn same_profile_location(expected: &str, current: &str) -> bool {
1211 let (Ok(expected), Ok(current)) = (Url::parse(expected), Url::parse(current)) else {
1212 return false;
1213 };
1214 let host = |url: &Url| {
1215 url.host_str()
1216 .unwrap_or("")
1217 .trim_start_matches("www.")
1218 .trim_start_matches("m.")
1219 .trim_start_matches("mbasic.")
1220 .to_ascii_lowercase()
1221 };
1222 expected.scheme() == "https"
1223 && current.scheme() == "https"
1224 && host(&expected) == host(¤t)
1225 && (expected.path().trim_end_matches('/') == current.path().trim_end_matches('/')
1226 || host(&expected) == "instagram.com"
1227 && instagram_post_key(&expected).is_some()
1228 && instagram_post_key(&expected) == instagram_post_key(¤t))
1229 && ["id", "story_fbid", "fbid"].iter().all(|key| {
1230 expected
1231 .query_pairs()
1232 .find(|(name, _)| name == key)
1233 .map(|(_, value)| value)
1234 == current
1235 .query_pairs()
1236 .find(|(name, _)| name == key)
1237 .map(|(_, value)| value)
1238 })
1239}
1240
1241fn extract_posters(
1243 app: &AppRuntime,
1244 browser: &mut Browser,
1245 target: &str,
1246 snapshot: &mut Value,
1247 budget: &mut usize,
1248) {
1249 use base64::Engine;
1250 use std::io::Write;
1251 use std::os::unix::fs::OpenOptionsExt;
1252 let Some(posts) = snapshot.get_mut("posts").and_then(Value::as_array_mut) else {
1253 return;
1254 };
1255 for post in posts {
1256 if post.get("image_status").is_some() {
1257 continue;
1258 }
1259 let now = super::super::runtime_timestamp_seconds(&super::super::current_timestamp_utc())
1260 .unwrap_or(u64::MAX);
1261 if !super::super::event_discovery::social_evidence::dated_announcement(post, now) {
1262 post["image_status"] = json!("posting_date_unverified_or_stale");
1263 continue;
1264 }
1265 let images = post
1266 .get("images")
1267 .and_then(Value::as_array)
1268 .cloned()
1269 .unwrap_or_default();
1270 if images.is_empty() {
1271 continue;
1272 }
1273 post["image_status"] = json!("not_read");
1274 for image in images
1275 .into_iter()
1276 .filter(|image| image.get("visible").and_then(Value::as_bool) == Some(true))
1277 .take(1)
1278 {
1279 if *budget == 0 {
1280 post["image_status"] = json!("budget_exceeded");
1281 break;
1282 }
1283 *budget -= 1;
1284 let result = (|| -> Result<String, SocialError> {
1285 let encoded = browser
1286 .capture_poster(target, &image["clip"])
1287 .map_err(error)?;
1288 let bytes = base64::engine::general_purpose::STANDARD
1289 .decode(encoded)
1290 .map_err(|_| SocialError::InvalidResponse)?;
1291 if bytes.len() > 2 * 1024 * 1024 {
1292 return Err(SocialError::Capacity);
1293 }
1294 let root = app
1295 .state_path
1296 .as_deref()
1297 .and_then(|path| Path::new(path).parent())
1298 .ok_or(SocialError::Configuration)?;
1299 let name = SystemTime::now()
1300 .duration_since(UNIX_EPOCH)
1301 .map_err(|_| SocialError::Configuration)?
1302 .as_nanos();
1303 let path = root.join(format!("social-poster-{name}.png"));
1304 let mut file = stdfs::OpenOptions::new()
1305 .create_new(true)
1306 .write(true)
1307 .mode(0o600)
1308 .open(&path)
1309 .map_err(|_| SocialError::Transport)?;
1310 let extraction = (|| {
1311 file.write_all(&bytes).map_err(|_| SocialError::Transport)?;
1312 drop(file);
1313 let conf = if app.is_editor_preview {
1314 OPERATOR_CONFIG
1315 .read()
1316 .map_err(|_| SocialError::Configuration)?
1317 .clone()
1318 } else {
1319 app.runtime_conf_path.clone()
1320 }
1321 .ok_or(SocialError::Configuration)?;
1322 super::super::agent_media::extract_poster_with_platform_helper(
1323 &path,
1324 root,
1325 Path::new(&conf),
1326 )
1327 .map_err(|_| SocialError::InvalidResponse)
1328 })();
1329 stdfs::remove_file(&path).map_err(|_| SocialError::Transport)?;
1330 extraction
1331 })();
1332 match result {
1333 Ok(text) => {
1334 post["image_text"] = json!(text);
1335 post["image_status"] = json!("read");
1336 }
1337 Err(_) => post["image_status"] = json!("unavailable"),
1338 }
1339 }
1340 }
1341}
1342
1343fn resolve_publications(
1345 browser: &mut Browser,
1346 session: &str,
1347 snapshot: &mut Value,
1348 budget: &mut usize,
1349) {
1350 let targets = snapshot
1351 .get("date_targets")
1352 .and_then(Value::as_array)
1353 .cloned()
1354 .unwrap_or_default();
1355 for target in targets.iter().take(4) {
1356 if *budget == 0 {
1357 break;
1358 }
1359 *budget -= 1;
1360 let Ok(labels) = browser.publication_labels(session, target) else {
1361 continue;
1362 };
1363 if labels.as_array().is_none_or(Vec::is_empty) {
1364 continue;
1365 }
1366 if let Some(posts) = snapshot.get_mut("posts").and_then(Value::as_array_mut) {
1367 for (index, post) in posts.iter_mut().enumerate() {
1368 if target.get("post_index").and_then(Value::as_u64) == Some(index as u64)
1369 && post.get("permalink") == target.get("permalink")
1370 {
1371 post["publication_labels"] = labels.clone();
1372 }
1373 }
1374 }
1375 }
1376 if let Some(posts) = snapshot.get("posts").and_then(Value::as_array) {
1377 let dates=posts.iter().take(20).map(|post|json!({"published_at":post.get("published_at"),"publication_labels":post.get("publication_labels"),"audience":post.get("audience")})).collect::<Vec<_>>();
1378 snapshot["diagnostics"]["posting_dates"] = json!(dates);
1379 }
1380}
1381
1382fn collection_snapshot(
1384 app: &AppRuntime,
1385 browser: &mut Browser,
1386 target: &str,
1387 expected: &str,
1388 source_profile: &str,
1389 poster_budget: &mut usize,
1390 date_budget: &mut usize,
1391) -> Result<Value, SocialError> {
1392 let started = std::time::Instant::now();
1393 let mut snapshot = loop {
1394 let value = match browser.snapshot_for_profile(target, Some(source_profile)) {
1395 Ok(value) => value,
1396 Err(BrowserError::InvalidResponse) if started.elapsed() < Duration::from_secs(15) => {
1397 std::thread::sleep(Duration::from_millis(500));
1398 continue;
1399 }
1400 Err(problem) => return Err(error(problem)),
1401 };
1402 if value
1403 .get("navigation_error")
1404 .and_then(Value::as_str)
1405 .is_some()
1406 {
1407 return Err(SocialError::Transport);
1408 }
1409 if started.elapsed() >= Duration::from_secs(3)
1410 && value.get("ready").and_then(Value::as_bool) == Some(true)
1411 {
1412 if !value
1413 .get("url")
1414 .and_then(Value::as_str)
1415 .is_some_and(|current| same_profile_location(expected, current))
1416 {
1417 let path = value
1418 .get("url")
1419 .and_then(Value::as_str)
1420 .and_then(|url| Url::parse(url).ok())
1421 .map(|url| url.path().to_owned())
1422 .unwrap_or_default();
1423 eprintln!(
1424 "social resource navigation rejected: path={} login={}",
1425 path,
1426 value.get("login").and_then(Value::as_bool).unwrap_or(false)
1427 );
1428 return Err(SocialError::AuthorizationRequired);
1429 }
1430 break value;
1431 }
1432 if started.elapsed() >= Duration::from_secs(15) {
1433 eprintln!(
1434 "social browser content not ready: diagnostics={}",
1435 value.get("diagnostics").unwrap_or(&Value::Null)
1436 );
1437 return Err(SocialError::Transport);
1438 }
1439 std::thread::sleep(Duration::from_millis(500));
1440 };
1441 resolve_publications(browser, target, &mut snapshot, date_budget);
1442 extract_posters(app, browser, target, &mut snapshot, poster_budget);
1443 if expected != source_profile {
1444 return Ok(snapshot);
1445 }
1446 for _ in 0..2 {
1447 if snapshot.get("login").and_then(Value::as_bool) == Some(true) {
1448 break;
1449 }
1450 browser.scroll_collection_view(target).map_err(error)?;
1451 std::thread::sleep(Duration::from_secs(2));
1452 let mut next = browser
1453 .snapshot_for_profile(target, Some(source_profile))
1454 .map_err(error)?;
1455 if !next
1456 .get("url")
1457 .and_then(Value::as_str)
1458 .is_some_and(|current| same_profile_location(expected, current))
1459 {
1460 return Err(SocialError::AuthorizationRequired);
1461 }
1462 if let Some(posts) = next.get_mut("posts").and_then(Value::as_array_mut) {
1463 for post in posts {
1464 if let Some(previous) =
1465 snapshot
1466 .get("posts")
1467 .and_then(Value::as_array)
1468 .and_then(|posts| {
1469 posts.iter().find(|previous| {
1470 previous["text"] == post["text"]
1471 && previous["published_at"] == post["published_at"]
1472 && previous["author_url"] == post["author_url"]
1473 })
1474 })
1475 {
1476 *post = previous.clone();
1477 }
1478 }
1479 }
1480 resolve_publications(browser, target, &mut next, date_budget);
1481 extract_posters(app, browser, target, &mut next, poster_budget);
1482 for (key, limit) in [
1483 ("links", 1000),
1484 ("structured", 30),
1485 ("posts", 40),
1486 ("post_resources", 4),
1487 ] {
1488 let additions = next
1489 .get(key)
1490 .and_then(Value::as_array)
1491 .cloned()
1492 .unwrap_or_default();
1493 if let Some(values) = snapshot.get_mut(key).and_then(Value::as_array_mut) {
1494 for value in additions {
1495 if values.len() >= limit {
1496 break;
1497 }
1498 if !values.contains(&value) {
1499 values.push(value);
1500 }
1501 }
1502 }
1503 }
1504 }
1505 Ok(snapshot)
1506}
1507
1508#[cfg(test)]
1509mod tests {
1510 use super::*;
1511 #[test]
1513 #[cfg(feature = "sqlite")]
1514 fn legacy_snapshots_are_redacted_without_crossing_scope() {
1515 use pharos_runtime_database::{
1516 contract::{AppDataContract, migration_checksum},
1517 operations::{
1518 AppSchema, Migration, Mutation, Predicate, Query, SchemaChange, Transaction,
1519 WriteValue,
1520 },
1521 };
1522 let (_directory, app) = super::super::tests::application();
1523 let app_id = "generic_consumer";
1524 let schema = AppSchema {
1525 app_id: app_id.into(),
1526 entities: pharos_runtime_social::schema::entities(),
1527 };
1528 let changes = schema
1529 .entities
1530 .iter()
1531 .cloned()
1532 .map(SchemaChange::CreateEntity)
1533 .collect::<Vec<_>>();
1534 let contract = AppDataContract {
1535 schema,
1536 migrations: vec![Migration {
1537 app_id: app_id.into(),
1538 id: "create_social".into(),
1539 checksum: migration_checksum(app_id, "create_social", &changes).unwrap(),
1540 changes,
1541 }],
1542 legacy_adoption: None,
1543 };
1544 let config = pharos_runtime_database::DbBackendConfig {
1545 backend: "sqlite".into(),
1546 sqlite_path: Some(
1547 Path::new(app.app_root.as_ref().unwrap())
1548 .parent()
1549 .unwrap()
1550 .join("observations.sqlite3"),
1551 ),
1552 ..Default::default()
1553 };
1554 let adapter = pharos_runtime_database_adapters::resolve(&config).unwrap();
1555 adapter.ensure_database(&config, &contract.schema).unwrap();
1556 adapter.apply_contract(&config, &contract).unwrap();
1557 let store = super::super::super::database::LogicalAppStore {
1558 config,
1559 schema: contract.schema,
1560 adapter,
1561 };
1562 let legacy = "Partial browser view; public evidence required before publication. private-account-panel";
1563 let mut inserts = Vec::new();
1564 for (post, owner, namespace, provider, text) in [
1565 ("old", 7, "deployment", "browser_facebook", legacy),
1566 (
1567 "region-only",
1568 7,
1569 "deployment",
1570 "browser_facebook",
1571 "Scoped browser content v2; public evidence required before publication. private-account-panel",
1572 ),
1573 ("other-owner", 8, "deployment", "browser_facebook", legacy),
1574 ("other-namespace", 7, "another", "browser_facebook", legacy),
1575 ("api", 7, "deployment", "facebook", legacy),
1576 (
1577 "new",
1578 7,
1579 "deployment",
1580 "browser_facebook",
1581 "Attributed browser posts v3; Public event",
1582 ),
1583 ] {
1584 let row = json!({"application":app_id,"namespace":namespace,"owner":owner,"provider":provider,"target":"profile","post_id":post,"published_at":"","permalink":"https://facebook.com/artist","text":text,"links":"[]"});
1585 inserts.push(Mutation::Insert {
1586 entity: "social_observation".into(),
1587 values: row
1588 .as_object()
1589 .unwrap()
1590 .iter()
1591 .map(|(key, value)| (key.clone(), WriteValue::Literal(value.clone())))
1592 .collect(),
1593 });
1594 }
1595 store
1596 .adapter
1597 .transact(
1598 &store.config,
1599 &store.schema,
1600 &Transaction {
1601 steps: inserts,
1602 idempotency_key: None,
1603 },
1604 )
1605 .unwrap();
1606 let scope = ConnectionScope::new(app_id, "deployment", 7).unwrap();
1607 let steps =
1608 legacy_observation_redactions(&store, &scope, SocialProvider::Facebook).unwrap();
1609 assert_eq!(steps.len(), 2);
1610 store
1611 .adapter
1612 .transact(
1613 &store.config,
1614 &store.schema,
1615 &Transaction {
1616 steps,
1617 idempotency_key: None,
1618 },
1619 )
1620 .unwrap();
1621 let rows = store
1622 .adapter
1623 .query(
1624 &store.config,
1625 &store.schema,
1626 &Query {
1627 entity: "social_observation".into(),
1628 fields: vec!["post_id".into(), "text".into()],
1629 filter: Some(Predicate::Contains(
1630 "text".into(),
1631 "private-account-panel".into(),
1632 )),
1633 order: vec![],
1634 limit: 10,
1635 offset: 0,
1636 },
1637 )
1638 .unwrap();
1639 assert_eq!(rows.len(), 3);
1640 assert!(rows.iter().all(|row| row["post_id"] != "old"));
1641 assert!(
1642 legacy_observation_redactions(&store, &scope, SocialProvider::Facebook)
1643 .unwrap()
1644 .is_empty()
1645 );
1646 }
1647
1648 #[test]
1650 #[ignore = "requires PHAROS_TEST_POSTER_INPUT, PHAROS_TEST_RUNTIME_CONFIG and installed OCR helper/models"]
1651 fn poster_dates_use_configured_platform_helper() {
1652 let input = PathBuf::from(std::env::var("PHAROS_TEST_POSTER_INPUT").unwrap());
1653 let conf = PathBuf::from(std::env::var("PHAROS_TEST_RUNTIME_CONFIG").unwrap());
1654 let text = super::super::super::agent_media::extract_poster_with_platform_helper(
1655 &input,
1656 input.parent().unwrap(),
1657 &conf,
1658 )
1659 .unwrap();
1660 assert!(text.contains("OCTOBER 30 2026"));
1661 let candidate = json!({"evidence_kind":"rendered_post","author_url":"https://facebook.com/artist","source_profile":"https://facebook.com/artist","datePublished":"2026-10-22T10:00:00Z","description":"Festival details on poster","image_text":text});
1662 assert!(
1663 super::super::super::event_discovery::social_evidence::eligible(
1664 &candidate,
1665 super::super::super::runtime_timestamp_seconds("2026-10-23T12:00:00Z").unwrap()
1666 )
1667 );
1668 }
1669
1670 #[test]
1672 fn profile_redirects_do_not_change_evidence_identity() {
1673 assert!(same_profile_location(
1674 "https://www.instagram.com/p/AbC123/",
1675 "https://www.instagram.com/madmaxxmusic/p/AbC123/"
1676 ));
1677 assert!(!same_profile_location(
1678 "https://www.instagram.com/p/AbC123/",
1679 "https://www.instagram.com/madmaxxmusic/p/Other123/"
1680 ));
1681 assert!(!same_profile_location(
1682 "https://www.instagram.com/p/AbC123/",
1683 "https://www.instagram.com/"
1684 ));
1685 assert!(!same_profile_location(
1686 "https://www.instagram.com/madmaxxmusic/",
1687 "https://www.instagram.com/p/AbC123/"
1688 ));
1689
1690 assert!(same_profile_location(
1691 "https://www.facebook.com/artist/",
1692 "https://m.facebook.com/artist"
1693 ));
1694 assert!(same_profile_location(
1695 "https://facebook.com/story.php?id=1&story_fbid=123",
1696 "https://www.facebook.com/story.php?id=1&story_fbid=123"
1697 ));
1698 assert!(!same_profile_location(
1699 "https://facebook.com/story.php?id=1&story_fbid=123",
1700 "https://facebook.com/story.php?id=1&story_fbid=456"
1701 ));
1702 assert!(!same_profile_location(
1703 "https://facebook.com/artist",
1704 "https://facebook.com/"
1705 ));
1706 assert!(!same_profile_location(
1707 "https://facebook.com/artist",
1708 "https://facebook.com/owner"
1709 ));
1710 assert!(!same_profile_location(
1711 "https://facebook.com/profile.php?id=1",
1712 "https://facebook.com/profile.php?id=2"
1713 ));
1714 assert!(!same_profile_location(
1715 "https://facebook.com/artist",
1716 "https://facebook.com.evil/artist"
1717 ));
1718 }
1719
1720 #[test]
1722 fn observed_facebook_events_are_bounded() {
1723 assert_eq!(
1724 facebook_event_url("https://www.facebook.com/events/123/?ref=profile").as_deref(),
1725 Some("https://www.facebook.com/events/123/")
1726 );
1727 for value in [
1728 "https://www.facebook.com/other-profile",
1729 "https://www.facebook.com/events/123/edit/",
1730 "https://www.facebook.com/events/not-an-id/",
1731 "https://facebook.com.evil.test/events/123/",
1732 "https://user:secret@www.facebook.com/events/123/",
1733 "http://www.facebook.com/events/123/",
1734 ] {
1735 assert!(facebook_event_url(value).is_none());
1736 }
1737 }
1738 #[test]
1740 fn profiles_persist_and_remain_scope_bound() {
1741 let (_directory, app) = super::super::tests::application();
1742 let scope = ConnectionScope::new("generic_consumer", "deployment", 7).unwrap();
1743 let first = profile(&app, &scope, SocialProvider::Facebook).unwrap();
1744 stdfs::create_dir_all(&first).unwrap();
1745 stdfs::write(first.join("retention-marker"), "controlled-test-marker").unwrap();
1746 assert_eq!(
1747 profile(&app, &scope, SocialProvider::Facebook).unwrap(),
1748 first
1749 );
1750 assert!(first.join("retention-marker").is_file());
1751 for other in [
1752 ConnectionScope::new("generic_consumer", "deployment", 8).unwrap(),
1753 ConnectionScope::new("generic_consumer", "other", 7).unwrap(),
1754 ConnectionScope::new("another_app", "deployment", 7).unwrap(),
1755 ] {
1756 assert_ne!(
1757 profile(&app, &other, SocialProvider::Facebook).unwrap(),
1758 first
1759 );
1760 }
1761 assert_ne!(profile(&app, &scope, SocialProvider::X).unwrap(), first);
1762 assert_eq!(executable(&app), Err(SocialError::UnsupportedProvider));
1763 }
1764 #[test]
1766 #[ignore = "requires explicitly selected installed Chromium"]
1767 fn reconnect_preserves_newer_profile_cookies() {
1768 let (_directory, app) = super::super::tests::application();
1769 let executable = std::env::var("PHAROS_BROWSER_TEST_EXECUTABLE").unwrap();
1770 let profile = Path::new(app.app_root.as_ref().unwrap()).join("test-browser-profile");
1771 let mut browser = Browser::launch(
1772 Path::new(&executable),
1773 &profile,
1774 sessions::domains(SocialProvider::Facebook),
1775 false,
1776 )
1777 .unwrap();
1778 assert_eq!(
1779 restore_retained_session(&mut browser, SocialProvider::Facebook, "123", ""),
1780 Err(SocialError::AuthorizationRequired)
1781 );
1782 let older = r#"[{"name":"c_user","value":"123","domain":".facebook.com","path":"/","secure":true},{"name":"xs","value":"older-test-session","domain":".facebook.com","path":"/","secure":true}]"#;
1783 restore_retained_session(&mut browser, SocialProvider::Facebook, "123", older).unwrap();
1784 let newer = older.replace("older-test-session", "rotated-test-session");
1785 browser.restore(&newer).unwrap();
1786 restore_retained_session(&mut browser, SocialProvider::Facebook, "123", older).unwrap();
1787 assert!(browser.cookies().unwrap().contains("rotated-test-session"));
1788 restore_retained_session(&mut browser, SocialProvider::Facebook, "123", "").unwrap();
1789 assert_eq!(
1790 restore_retained_session(&mut browser, SocialProvider::Facebook, "456", older),
1791 Err(SocialError::Forbidden)
1792 );
1793 assert!(browser.cookies().unwrap().contains("rotated-test-session"));
1794 drop(browser);
1795 }
1796}