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