Skip to main content

cloudillo_ref/
handler.rs

1//! Reference (Ref) REST endpoints for managing shareable tokens and authentication workflows
2
3use axum::{
4	extract::{Path, Query, State},
5	http::StatusCode,
6	Json,
7};
8use serde::{Deserialize, Serialize};
9
10use crate::prelude::*;
11use cloudillo_core::extract::{OptionalAuth, OptionalRequestId};
12use cloudillo_types::meta_adapter::{CreateRefOptions, ListRefsOptions, RefData};
13use cloudillo_types::types::{serialize_timestamp_iso, serialize_timestamp_iso_opt, ApiResponse};
14use cloudillo_types::utils;
15
16/// Response structure for ref details (authenticated users get full data)
17#[serde_with::skip_serializing_none]
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct RefResponse {
20	#[serde(rename = "refId")]
21	pub ref_id: String,
22	pub r#type: String,
23	pub description: Option<String>,
24	#[serde(rename = "createdAt", serialize_with = "serialize_timestamp_iso")]
25	pub created_at: Timestamp,
26	#[serde(
27		rename = "expiresAt",
28		serialize_with = "serialize_timestamp_iso_opt",
29		skip_serializing_if = "Option::is_none"
30	)]
31	pub expires_at: Option<Timestamp>,
32	/// Usage count: None = unlimited, Some(n) = n uses remaining
33	pub count: Option<u32>,
34	/// Resource ID for share links (e.g., file_id for share.file type)
35	#[serde(rename = "resourceId")]
36	pub resource_id: Option<String>,
37	/// Access level for share links ("read" or "write")
38	#[serde(rename = "accessLevel")]
39	pub access_level: Option<String>,
40}
41
42/// Minimal response structure for unauthenticated requests (only refId and type)
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct RefResponseMinimal {
45	#[serde(rename = "refId")]
46	pub ref_id: String,
47	pub r#type: String,
48}
49
50impl From<RefData> for RefResponse {
51	fn from(ref_data: RefData) -> Self {
52		Self {
53			ref_id: ref_data.ref_id.to_string(),
54			r#type: ref_data.r#type.to_string(),
55			description: ref_data.description.map(|d| d.to_string()),
56			created_at: ref_data.created_at,
57			expires_at: ref_data.expires_at,
58			count: ref_data.count,
59			resource_id: ref_data.resource_id.map(|s| s.to_string()),
60			access_level: ref_data
61				.access_level
62				.map(|c| if c == 'W' { "write" } else { "read" }.to_string()),
63		}
64	}
65}
66
67impl From<RefData> for RefResponseMinimal {
68	fn from(ref_data: RefData) -> Self {
69		Self { ref_id: ref_data.ref_id.to_string(), r#type: ref_data.r#type.to_string() }
70	}
71}
72
73/// Request structure for creating a new ref
74#[derive(Debug, Deserialize)]
75pub struct CreateRefRequest {
76	/// Type of reference (e.g., "email-verify", "password-reset", "invite", "share.file")
77	pub r#type: String,
78	/// Human-readable description
79	pub description: Option<String>,
80	/// Optional expiration timestamp
81	pub expires_at: Option<i64>,
82	/// Number of times this ref can be used:
83	/// - Omit field: defaults to 1 (single use)
84	/// - null: unlimited uses
85	/// - number: that many uses
86	#[serde(default)]
87	pub count: Patch<u32>,
88	/// Resource ID for share links (e.g., file_id for share.file type)
89	#[serde(rename = "resourceId")]
90	pub resource_id: Option<String>,
91	/// Access level for share links ("read" or "write", default: "read")
92	#[serde(rename = "accessLevel")]
93	pub access_level: Option<String>,
94}
95
96/// Query parameters for listing refs
97#[derive(Debug, Deserialize, Default)]
98pub struct ListRefsQuery {
99	/// Filter by ref type
100	pub r#type: Option<String>,
101	/// Filter by status: 'active', 'used', 'expired', 'all' (default: 'active')
102	pub filter: Option<String>,
103	/// Filter by resource_id (for listing share links for a specific resource)
104	#[serde(rename = "resourceId")]
105	pub resource_id: Option<String>,
106}
107
108// Re-export service types for backward compatibility
109pub use crate::service::{create_ref_internal, CreateRefInternalParams};
110
111/// GET /api/refs - List refs for the current tenant
112#[axum::debug_handler]
113pub async fn list_refs(
114	State(app): State<App>,
115	tn_id: TnId,
116	Query(query_params): Query<ListRefsQuery>,
117	OptionalRequestId(req_id): OptionalRequestId,
118) -> ClResult<(StatusCode, Json<ApiResponse<Vec<RefResponse>>>)> {
119	info!(
120		tn_id = ?tn_id,
121		r#type = ?query_params.r#type,
122		filter = ?query_params.filter,
123		resource_id = ?query_params.resource_id,
124		"GET /api/refs - Listing refs"
125	);
126
127	let opts = ListRefsOptions {
128		typ: query_params.r#type,
129		filter: query_params.filter.or(Some("active".to_string())),
130		resource_id: query_params.resource_id,
131	};
132
133	let refs = app.meta_adapter.list_refs(tn_id, &opts).await?;
134
135	let response_data: Vec<RefResponse> = refs.into_iter().map(RefResponse::from).collect();
136
137	let total = response_data.len();
138	let mut response = ApiResponse::with_pagination(response_data, 0, total, total);
139	if let Some(id) = req_id {
140		response = response.with_req_id(id);
141	}
142
143	Ok((StatusCode::OK, Json(response)))
144}
145
146/// POST /api/refs - Create a new ref for authentication workflows
147#[axum::debug_handler]
148pub async fn create_ref(
149	State(app): State<App>,
150	tn_id: TnId,
151	OptionalRequestId(req_id): OptionalRequestId,
152	Json(create_req): Json<CreateRefRequest>,
153) -> ClResult<(StatusCode, Json<ApiResponse<RefResponse>>)> {
154	info!(
155		tn_id = ?tn_id,
156		ref_type = %create_req.r#type,
157		description = ?create_req.description,
158		resource_id = ?create_req.resource_id,
159		access_level = ?create_req.access_level,
160		"POST /api/refs - Creating new ref"
161	);
162
163	// Validate ref type is not empty
164	if create_req.r#type.is_empty() {
165		return Err(Error::ValidationError("ref type is required".to_string()));
166	}
167
168	// Validate expiration if provided
169	if let Some(expires_timestamp) = create_req.expires_at {
170		let expiration = Timestamp(expires_timestamp);
171		if expiration.0 <= Timestamp::now().0 {
172			return Err(Error::ValidationError(
173				"Expiration time must be in the future".to_string(),
174			));
175		}
176	}
177
178	// Parse and validate access_level
179	let access_level_char = match create_req.access_level.as_deref() {
180		Some("write" | "W") => Some('W'),
181		Some("read" | "R") | None => {
182			// Default to read if resource_id is present
183			if create_req.resource_id.is_some() {
184				Some('R')
185			} else {
186				None
187			}
188		}
189		Some(other) => {
190			return Err(Error::ValidationError(format!(
191				"Invalid access_level '{}': must be 'read' or 'write'",
192				other
193			)));
194		}
195	};
196
197	// Validate share.file type requires resource_id
198	if create_req.r#type == "share.file" && create_req.resource_id.is_none() {
199		return Err(Error::ValidationError(
200			"resource_id is required for share.file type".to_string(),
201		));
202	}
203
204	let ref_id = utils::random_id()?;
205
206	// Convert Patch<u32> to Option<u32>:
207	// - Undefined (field omitted): default to 1 (single use)
208	// - Null (explicit null): unlimited uses
209	// - Value(n): use that count
210	let count = match create_req.count {
211		Patch::Undefined => Some(1),
212		Patch::Null => None,
213		Patch::Value(n) => Some(n),
214	};
215
216	let opts = CreateRefOptions {
217		typ: create_req.r#type.clone(),
218		description: create_req.description.clone(),
219		expires_at: create_req.expires_at.map(Timestamp),
220		count,
221		resource_id: create_req.resource_id.clone(),
222		access_level: access_level_char,
223	};
224
225	let ref_data = app.meta_adapter.create_ref(tn_id, &ref_id, &opts).await.map_err(|e| {
226		warn!("Failed to create ref: {}", e);
227		e
228	})?;
229
230	let response_data = RefResponse::from(ref_data);
231	let mut response = ApiResponse::new(response_data);
232	if let Some(id) = req_id {
233		response = response.with_req_id(id);
234	}
235
236	Ok((StatusCode::CREATED, Json(response)))
237}
238
239/// GET /api/refs/{ref_id} - Get a specific ref by ID
240///
241/// Returns full ref details if authenticated, only refId and type if not authenticated.
242#[axum::debug_handler]
243pub async fn get_ref(
244	State(app): State<App>,
245	tn_id: TnId,
246	OptionalAuth(auth): OptionalAuth,
247	Path(ref_id): Path<String>,
248	OptionalRequestId(req_id): OptionalRequestId,
249) -> ClResult<(StatusCode, Json<serde_json::Value>)> {
250	let is_authenticated = auth.is_some();
251
252	info!(
253		tn_id = ?tn_id,
254		ref_id = %ref_id,
255		authenticated = is_authenticated,
256		"GET /api/refs/:id - Getting ref"
257	);
258
259	// Verify the ref exists first
260	app.meta_adapter.get_ref(tn_id, &ref_id).await?.ok_or(Error::NotFound)?;
261
262	// Reconstruct RefData from tuple (we have ref_type, ref_description)
263	// Note: The return type is Option<(Box<str>, Box<str>)> which contains (type, description)
264	// We need to use list_refs to get the full RefData with timestamps and count
265	let opts = ListRefsOptions { typ: None, filter: Some("all".to_string()), resource_id: None };
266
267	let refs = app.meta_adapter.list_refs(tn_id, &opts).await?;
268	let ref_data = refs
269		.into_iter()
270		.find(|r| r.ref_id.as_ref() == ref_id.as_str())
271		.ok_or(Error::NotFound)?;
272
273	// Return different response based on authentication
274	let response_value = if is_authenticated {
275		// Authenticated: return full details
276		let response_data = RefResponse::from(ref_data);
277		let mut response = ApiResponse::new(response_data);
278		if let Some(id) = req_id {
279			response = response.with_req_id(id);
280		}
281		serde_json::to_value(response)?
282	} else {
283		// Unauthenticated: return only refId and type
284		let response_data = RefResponseMinimal::from(ref_data);
285		let mut response = ApiResponse::new(response_data);
286		if let Some(id) = req_id {
287			response = response.with_req_id(id);
288		}
289		serde_json::to_value(response)?
290	};
291
292	Ok((StatusCode::OK, Json(response_value)))
293}
294
295/// DELETE /api/refs/{ref_id} - Delete/revoke a ref
296#[axum::debug_handler]
297pub async fn delete_ref(
298	State(app): State<App>,
299	tn_id: TnId,
300	Path(ref_id): Path<String>,
301	OptionalRequestId(req_id): OptionalRequestId,
302) -> ClResult<(StatusCode, Json<ApiResponse<()>>)> {
303	info!(
304		tn_id = ?tn_id,
305		ref_id = %ref_id,
306		"DELETE /api/refs/:id - Deleting ref"
307	);
308
309	// Verify the ref exists first
310	app.meta_adapter.get_ref(tn_id, &ref_id).await?.ok_or(Error::NotFound)?;
311
312	// Delete the ref
313	app.meta_adapter.delete_ref(tn_id, &ref_id).await.map_err(|e| {
314		warn!("Failed to delete ref: {}", e);
315		e
316	})?;
317
318	let mut response = ApiResponse::new(());
319	if let Some(id) = req_id {
320		response = response.with_req_id(id);
321	}
322
323	Ok((StatusCode::OK, Json(response)))
324}
325
326// vim: ts=4