Skip to main content

cloudillo_profile/
community.rs

1//! Community profile creation handler
2
3use axum::{
4	extract::{Path, State},
5	http::StatusCode,
6	Json,
7};
8
9use crate::prelude::*;
10use cloudillo_core::{
11	bootstrap_types::CreateCompleteTenantOptions, extract::Auth, CreateActionFn,
12	CreateCompleteTenantFn,
13};
14use cloudillo_idp::registration::{IdpRegContent, IdpRegResponse};
15use cloudillo_types::{
16	action_types::CreateAction,
17	meta_adapter::{
18		Profile, ProfileConnectionStatus, ProfileType, UpdateProfileData, UpdateTenantData,
19	},
20	types::{ApiResponse, CommunityProfileResponse, CreateCommunityRequest},
21	utils::derive_name_from_id_tag,
22};
23
24/// PUT /api/profiles/{id_tag} - Create a new community profile
25pub async fn put_community_profile(
26	State(app): State<App>,
27	Auth(auth): Auth,
28	Path(id_tag): Path<String>,
29	Json(req): Json<CreateCommunityRequest>,
30) -> ClResult<(StatusCode, Json<ApiResponse<CommunityProfileResponse>>)> {
31	let id_tag_lower = id_tag.to_lowercase();
32	let creator_id_tag = &auth.id_tag;
33	let creator_tn_id = auth.tn_id;
34
35	// Community creation requires an invite (unless user has SADM role)
36	let is_admin = auth.roles.iter().any(|r| r.as_ref() == "SADM");
37	let invite_ref = req.invite_ref.as_deref();
38	if !is_admin {
39		let ref_code = invite_ref.ok_or_else(|| {
40			Error::ValidationError("Community creation requires an invite".into())
41		})?;
42		// Validate ref exists, is correct type, not expired, has remaining uses
43		app.meta_adapter.validate_ref(ref_code, &["profile.invite"]).await?;
44	}
45
46	info!(
47		creator = %creator_id_tag,
48		community = %id_tag_lower,
49		typ = %req.typ,
50		"Creating community profile"
51	);
52
53	// 1. Validate identity type
54	if req.typ != "idp" && req.typ != "domain" {
55		return Err(Error::ValidationError("Invalid identity type".into()));
56	}
57
58	// 3. Validate id_tag availability
59	let providers = crate::register::get_identity_providers(&app, TnId(1)).await;
60	let validation = crate::register::verify_register_data(
61		&app,
62		&req.typ,
63		&id_tag_lower,
64		req.app_domain.as_deref(),
65		providers,
66	)
67	.await?;
68
69	if !validation.id_tag_error.is_empty() {
70		warn!(
71			community = %id_tag_lower,
72			error = %validation.id_tag_error,
73			"Community id_tag validation failed"
74		);
75		return Err(Error::ValidationError(validation.id_tag_error));
76	}
77
78	// 3a. For IDP type, register with identity provider first
79	let idp_api_key: Option<String> = if req.typ == "idp" {
80		// Extract IDP domain from id_tag (e.g., "csapat.home.w9.hu" -> "home.w9.hu")
81		let idp_domain = match id_tag_lower.find('.') {
82			Some(pos) => &id_tag_lower[pos + 1..],
83			None => return Err(Error::ValidationError("Invalid IDP id_tag format".into())),
84		};
85
86		// Build IDP:REG action content
87		let address = if app.opts.local_address.is_empty() {
88			None
89		} else {
90			Some(app.opts.local_address.iter().map(|s| s.as_ref()).collect::<Vec<_>>().join(","))
91		};
92
93		let reg_content = IdpRegContent {
94			id_tag: id_tag_lower.clone(),
95			email: None,                                    // Communities don't have email
96			owner_id_tag: Some(creator_id_tag.to_string()), // Creator owns the community
97			issuer: None,
98			address,
99			lang: None, // Communities don't have language preference
100		};
101
102		// Create IDP:REG action
103		let action = CreateAction {
104			typ: "IDP:REG".into(),
105			sub_typ: None,
106			parent_id: None,
107			audience_tag: Some(idp_domain.to_string().into()),
108			content: Some(serde_json::to_value(&reg_content)?),
109			attachments: None,
110			subject: None,
111			expires_at: Some(Timestamp::now().add_seconds(86400 * 30)),
112			visibility: None,
113			flags: None,
114			x: None,
115		};
116
117		// Generate and send token to IDP
118		let action_token = app.auth_adapter.create_action_token(TnId(1), action).await?;
119
120		#[derive(serde::Serialize)]
121		struct InboxRequest {
122			token: String,
123		}
124
125		info!(
126			community = %id_tag_lower,
127			idp_domain = %idp_domain,
128			"Registering community with identity provider"
129		);
130
131		let idp_response: cloudillo_types::types::ApiResponse<serde_json::Value> = app
132			.request
133			.post_public(
134				idp_domain,
135				"/inbox/sync",
136				&InboxRequest { token: action_token.to_string() },
137			)
138			.await
139			.map_err(|e| {
140				warn!(error = %e, idp_domain = %idp_domain, "Failed to register community with IDP");
141				Error::ValidationError("IDP registration failed".into())
142			})?;
143
144		// Parse response
145		let idp_reg_result: IdpRegResponse = serde_json::from_value(idp_response.data)
146			.map_err(|e| Error::Internal(format!("IDP response parsing failed: {}", e)))?;
147
148		if !idp_reg_result.success {
149			warn!(
150				community = %id_tag_lower,
151				message = %idp_reg_result.message,
152				"IDP registration failed"
153			);
154			return Err(Error::ValidationError(idp_reg_result.message));
155		}
156
157		info!(
158			community = %id_tag_lower,
159			"Community registered with identity provider"
160		);
161
162		idp_reg_result.api_key
163	} else {
164		None
165	};
166
167	// 4. Create community tenant via extension function
168	let display_name = req.name.clone().unwrap_or_else(|| derive_name_from_id_tag(&id_tag_lower));
169	let create_tenant = app.ext::<CreateCompleteTenantFn>()?;
170	let community_tn_id = create_tenant(
171		&app,
172		CreateCompleteTenantOptions {
173			id_tag: &id_tag_lower,
174			email: None,
175			password: None,
176			roles: None,
177			display_name: Some(&display_name),
178			create_acme_cert: app.opts.acme_email.is_some(),
179			acme_email: app.opts.acme_email.as_deref(),
180			app_domain: req.app_domain.as_deref(),
181		},
182	)
183	.await?;
184
185	info!(
186		community = %id_tag_lower,
187		tn_id = ?community_tn_id,
188		"Community tenant created"
189	);
190
191	// 4a. Store IDP API key if we got one from registration
192	if let Some(api_key) = &idp_api_key {
193		info!(
194			community = %id_tag_lower,
195			"Storing IDP API key for community"
196		);
197		if let Err(e) = app.auth_adapter.update_idp_api_key(&id_tag_lower, api_key).await {
198			warn!(error = %e, community = %id_tag_lower, "Failed to store IDP API key");
199			// Continue - not critical for basic functionality
200		}
201	}
202
203	// 5. Update tenant to Community type (and set profile_pic if provided)
204	// Note: create_tenant already created a basic profile, update_tenant syncs changes
205	app.meta_adapter
206		.update_tenant(
207			community_tn_id,
208			&UpdateTenantData {
209				typ: Patch::Value(ProfileType::Community),
210				profile_pic: match &req.profile_pic {
211					Some(pic) => Patch::Value(pic.clone()),
212					None => Patch::Undefined,
213				},
214				..Default::default()
215			},
216		)
217		.await?;
218
219	// 5a. Enable auto-approve for incoming posts from connected users
220	app.meta_adapter
221		.update_setting(
222			community_tn_id,
223			"federation.auto_approve",
224			Some(serde_json::Value::Bool(true)),
225		)
226		.await?;
227
228	// 6. Create CONN: creator → community (in creator's tenant) via extension function
229	info!(
230		creator = %creator_id_tag,
231		community = %id_tag_lower,
232		"Creating CONN action from creator to community"
233	);
234	let create_action = app.ext::<CreateActionFn>()?;
235	create_action(
236		&app,
237		creator_tn_id,
238		creator_id_tag,
239		CreateAction {
240			typ: "CONN".into(),
241			audience_tag: Some(id_tag_lower.clone().into()),
242			..Default::default()
243		},
244	)
245	.await?;
246
247	// 7. Create CONN: community → creator (in community's tenant)
248	// This triggers mutual detection and auto-accept
249	info!(
250		creator = %creator_id_tag,
251		community = %id_tag_lower,
252		"Creating CONN action from community to creator"
253	);
254	let create_action = app.ext::<CreateActionFn>()?;
255	create_action(
256		&app,
257		community_tn_id,
258		&id_tag_lower,
259		CreateAction {
260			typ: "CONN".into(),
261			audience_tag: Some(creator_id_tag.to_string().into()),
262			..Default::default()
263		},
264	)
265	.await?;
266
267	// 7b. Directly set community profile in creator's tenant to Connected
268	// (Don't rely on async CONN delivery — the on_receive mutual detection is fragile
269	// for same-server communities because the community's signing key may not be ready)
270	app.meta_adapter
271		.update_profile(
272			creator_tn_id,
273			&id_tag_lower,
274			&UpdateProfileData {
275				connected: Patch::Value(ProfileConnectionStatus::Connected),
276				..Default::default()
277			},
278		)
279		.await?;
280
281	// 8. Get creator's profile name for the community's profile record
282	let creator_name = match app.meta_adapter.get_profile_info(creator_tn_id, creator_id_tag).await
283	{
284		Ok(profile) => profile.name.to_string(),
285		Err(_) => derive_name_from_id_tag(creator_id_tag),
286	};
287
288	// 9. Create creator's profile in community tenant with "leader" role
289	let creator_profile = Profile {
290		id_tag: creator_id_tag.as_ref(),
291		name: creator_name.as_str(),
292		typ: ProfileType::Person,
293		profile_pic: None,
294		following: false,
295		connected: ProfileConnectionStatus::Connected,
296		roles: None,
297	};
298	app.meta_adapter.create_profile(community_tn_id, &creator_profile, "").await?;
299
300	// Set leader role
301	app.meta_adapter
302		.update_profile(
303			community_tn_id,
304			creator_id_tag,
305			&UpdateProfileData {
306				roles: Patch::Value(Some(vec!["leader".to_string().into()])),
307				connected: Patch::Value(ProfileConnectionStatus::Connected),
308				following: Patch::Undefined,
309				..Default::default()
310			},
311		)
312		.await?;
313
314	info!(
315		creator = %creator_id_tag,
316		community = %id_tag_lower,
317		"Creator assigned leader role in community"
318	);
319
320	// 10. Consume the invite ref (if used)
321	if let Some(ref_code) = invite_ref {
322		if let Err(e) = app.meta_adapter.use_ref(ref_code, &["profile.invite"]).await {
323			warn!(error = %e, "Failed to consume invite ref after community creation");
324		}
325	}
326
327	// 11. Return response
328	let response = CommunityProfileResponse {
329		id_tag: id_tag_lower,
330		name: display_name,
331		r#type: "community".to_string(),
332		profile_pic: req.profile_pic,
333		created_at: Timestamp::now(),
334	};
335
336	Ok((StatusCode::CREATED, Json(ApiResponse::new(response))))
337}
338
339// vim: ts=4