Refactor DashboardCollectionList: Simplify search input layout and improve tag selection logic for better user experience.
This commit is contained in:
@@ -28,6 +28,7 @@ use tokio::fs;
|
||||
use super::auth;
|
||||
use super::error::ApiError;
|
||||
use super::handlers::AppState;
|
||||
use super::webhooks;
|
||||
|
||||
const ALLOWED_EXTENSIONS: &[(&str, &str)] = &[
|
||||
("jpg", "image/jpeg"),
|
||||
@@ -46,6 +47,13 @@ fn mime_for_ext(ext: &str) -> Option<&'static str> {
|
||||
.map(|(_, m)| *m)
|
||||
}
|
||||
|
||||
fn path_for_webhook(folder: &Option<String>, filename: &str) -> String {
|
||||
folder
|
||||
.as_deref()
|
||||
.map(|f| format!("{}/{}", f, filename))
|
||||
.unwrap_or_else(|| filename.to_string())
|
||||
}
|
||||
|
||||
/// Sanitize a single path segment: lowercase, alphanumeric + dash + underscore + dot.
|
||||
fn sanitize_segment(name: &str) -> Result<String, ApiError> {
|
||||
let name = name.trim();
|
||||
@@ -175,15 +183,18 @@ async fn read_images(
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListAssetsParams {
|
||||
folder: Option<String>,
|
||||
#[serde(rename = "_environment")]
|
||||
environment: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_assets(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<ListAssetsParams>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let base = &state.assets_dir;
|
||||
let env = state.effective_environment_from_param(params.environment.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()));
|
||||
let base = state.assets_dir_for(&env);
|
||||
if !base.exists() {
|
||||
fs::create_dir_all(base)
|
||||
fs::create_dir_all(&base)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Cannot create assets dir: {}", e)))?;
|
||||
}
|
||||
@@ -199,12 +210,12 @@ pub async fn list_assets(
|
||||
}
|
||||
// Root only (folder= with empty value)
|
||||
Some(_empty) => {
|
||||
all.extend(read_images(base, None).await?);
|
||||
all.extend(read_images(&base, None).await?);
|
||||
}
|
||||
// All: root + every subdirectory
|
||||
None => {
|
||||
all.extend(read_images(base, None).await?);
|
||||
let mut rd = fs::read_dir(base)
|
||||
all.extend(read_images(&base, None).await?);
|
||||
let mut rd = fs::read_dir(&base)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
while let Some(e) = rd
|
||||
@@ -243,18 +254,26 @@ pub async fn list_assets(
|
||||
// GET /api/assets/folders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct EnvironmentParam {
|
||||
#[serde(rename = "_environment")]
|
||||
pub environment: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_folders(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<EnvironmentParam>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let base = &state.assets_dir;
|
||||
let env = state.effective_environment_from_param(params.environment.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()));
|
||||
let base = state.assets_dir_for(&env);
|
||||
if !base.exists() {
|
||||
fs::create_dir_all(base)
|
||||
fs::create_dir_all(&base)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Cannot create assets dir: {}", e)))?;
|
||||
}
|
||||
|
||||
let mut folders: Vec<Value> = Vec::new();
|
||||
let mut rd = fs::read_dir(base)
|
||||
let mut rd = fs::read_dir(&base)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
while let Some(e) = rd
|
||||
@@ -303,14 +322,19 @@ pub async fn list_folders(
|
||||
pub async fn create_folder(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Query(env_param): Query<EnvironmentParam>,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ApiError> {
|
||||
auth::require_api_key(state.api_key.as_ref(), &headers)?;
|
||||
if let Some(ref keys) = state.api_keys {
|
||||
keys.require(&headers, auth::Permission::AssetsWrite)?;
|
||||
}
|
||||
let env = state.effective_environment_from_param(env_param.environment.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()));
|
||||
let assets_dir = state.assets_dir_for(&env);
|
||||
let name = body["name"]
|
||||
.as_str()
|
||||
.ok_or_else(|| ApiError::BadRequest("Missing 'name' field".into()))?;
|
||||
let name = validate_folder_name(name)?;
|
||||
let path = state.assets_dir.join(&name);
|
||||
let path = assets_dir.join(&name);
|
||||
if path.exists() {
|
||||
return Err(ApiError::Conflict(format!(
|
||||
"Folder '{}' already exists",
|
||||
@@ -330,11 +354,16 @@ pub async fn create_folder(
|
||||
pub async fn delete_folder(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Query(env_param): Query<EnvironmentParam>,
|
||||
AxumPath(name): AxumPath<String>,
|
||||
) -> Result<axum::response::Response, ApiError> {
|
||||
auth::require_api_key(state.api_key.as_ref(), &headers)?;
|
||||
if let Some(ref keys) = state.api_keys {
|
||||
keys.require(&headers, auth::Permission::AssetsWrite)?;
|
||||
}
|
||||
let env = state.effective_environment_from_param(env_param.environment.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()));
|
||||
let assets_dir = state.assets_dir_for(&env);
|
||||
let name = validate_folder_name(&name)?;
|
||||
let path = state.assets_dir.join(&name);
|
||||
let path = assets_dir.join(&name);
|
||||
if !path.exists() {
|
||||
return Err(ApiError::NotFound(format!("Folder '{}' not found", name)));
|
||||
}
|
||||
@@ -355,6 +384,8 @@ pub async fn delete_folder(
|
||||
#[derive(Deserialize)]
|
||||
pub struct UploadParams {
|
||||
folder: Option<String>,
|
||||
#[serde(rename = "_environment")]
|
||||
environment: Option<String>,
|
||||
}
|
||||
|
||||
/// Max upload size for asset uploads (50 MB).
|
||||
@@ -370,7 +401,12 @@ pub async fn upload_asset(
|
||||
Query(params): Query<UploadParams>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<axum::response::Response, ApiError> {
|
||||
auth::require_api_key(state.api_key.as_ref(), &headers)?;
|
||||
if let Some(ref keys) = state.api_keys {
|
||||
keys.require(&headers, auth::Permission::AssetsWrite)?;
|
||||
}
|
||||
|
||||
let env = state.effective_environment_from_param(params.environment.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()));
|
||||
let assets_dir = state.assets_dir_for(&env);
|
||||
|
||||
let folder = match params.folder.as_deref() {
|
||||
Some(f) if !f.is_empty() => Some(validate_folder_name(f)?),
|
||||
@@ -378,8 +414,8 @@ pub async fn upload_asset(
|
||||
};
|
||||
|
||||
let dir = match &folder {
|
||||
Some(f) => state.assets_dir.join(f),
|
||||
None => state.assets_dir.clone(),
|
||||
Some(f) => assets_dir.join(f),
|
||||
None => assets_dir.clone(),
|
||||
};
|
||||
if !dir.exists() {
|
||||
fs::create_dir_all(&dir)
|
||||
@@ -447,6 +483,18 @@ pub async fn upload_asset(
|
||||
.and_then(|d| DateTime::<Utc>::from_timestamp_secs(d.as_secs() as i64))
|
||||
.map(|dt: DateTime<Utc>| dt.to_rfc3339());
|
||||
|
||||
webhooks::fire(
|
||||
state.http_client.clone(),
|
||||
&state.webhook_urls,
|
||||
json!({
|
||||
"event": webhooks::EVENT_ASSET_UPLOADED,
|
||||
"path": path_for_webhook(&folder, &filename),
|
||||
"filename": filename,
|
||||
"folder": folder,
|
||||
"url": url,
|
||||
}),
|
||||
);
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(json!({
|
||||
@@ -468,12 +516,15 @@ pub async fn upload_asset(
|
||||
|
||||
pub async fn get_asset(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(env_param): Query<EnvironmentParam>,
|
||||
AxumPath(path): AxumPath<String>,
|
||||
) -> Result<axum::response::Response, ApiError> {
|
||||
let env = state.effective_environment_from_param(env_param.environment.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()));
|
||||
let assets_dir = state.assets_dir_for(&env);
|
||||
let (folder, filename) = parse_path(&path)?;
|
||||
let file_path = match &folder {
|
||||
Some(f) => state.assets_dir.join(f).join(&filename),
|
||||
None => state.assets_dir.join(&filename),
|
||||
Some(f) => assets_dir.join(f).join(&filename),
|
||||
None => assets_dir.join(&filename),
|
||||
};
|
||||
if !file_path.exists() {
|
||||
return Err(ApiError::NotFound(format!("Asset '{}' not found", path)));
|
||||
@@ -507,18 +558,23 @@ pub struct RenameBody {
|
||||
pub async fn rename_asset(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Query(env_param): Query<EnvironmentParam>,
|
||||
AxumPath(path): AxumPath<String>,
|
||||
Json(body): Json<RenameBody>,
|
||||
) -> Result<axum::response::Response, ApiError> {
|
||||
auth::require_api_key(state.api_key.as_ref(), &headers)?;
|
||||
if let Some(ref keys) = state.api_keys {
|
||||
keys.require(&headers, auth::Permission::AssetsWrite)?;
|
||||
}
|
||||
let env = state.effective_environment_from_param(env_param.environment.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()));
|
||||
let assets_dir = state.assets_dir_for(&env);
|
||||
let (folder, old_filename) = parse_path(&path)?;
|
||||
let new_filename = validate_filename(&body.filename)?;
|
||||
if new_filename == old_filename {
|
||||
return Err(ApiError::BadRequest("New filename is the same as current".into()));
|
||||
}
|
||||
let base = match &folder {
|
||||
Some(f) => state.assets_dir.join(f),
|
||||
None => state.assets_dir.clone(),
|
||||
Some(f) => assets_dir.join(f),
|
||||
None => assets_dir.clone(),
|
||||
};
|
||||
let old_path = base.join(&old_filename);
|
||||
let new_path = base.join(&new_filename);
|
||||
@@ -577,13 +633,18 @@ pub async fn rename_asset(
|
||||
pub async fn delete_asset(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Query(env_param): Query<EnvironmentParam>,
|
||||
AxumPath(path): AxumPath<String>,
|
||||
) -> Result<axum::response::Response, ApiError> {
|
||||
auth::require_api_key(state.api_key.as_ref(), &headers)?;
|
||||
if let Some(ref keys) = state.api_keys {
|
||||
keys.require(&headers, auth::Permission::AssetsWrite)?;
|
||||
}
|
||||
let env = state.effective_environment_from_param(env_param.environment.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()));
|
||||
let assets_dir = state.assets_dir_for(&env);
|
||||
let (folder, filename) = parse_path(&path)?;
|
||||
let file_path = match &folder {
|
||||
Some(f) => state.assets_dir.join(f).join(&filename),
|
||||
None => state.assets_dir.join(&filename),
|
||||
Some(f) => assets_dir.join(f).join(&filename),
|
||||
None => assets_dir.join(&filename),
|
||||
};
|
||||
if !file_path.exists() {
|
||||
return Err(ApiError::NotFound(format!("Asset '{}' not found", path)));
|
||||
@@ -591,5 +652,15 @@ pub async fn delete_asset(
|
||||
fs::remove_file(&file_path)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to delete asset: {}", e)))?;
|
||||
|
||||
webhooks::fire(
|
||||
state.http_client.clone(),
|
||||
&state.webhook_urls,
|
||||
json!({
|
||||
"event": webhooks::EVENT_ASSET_DELETED,
|
||||
"path": path,
|
||||
}),
|
||||
);
|
||||
|
||||
Ok(StatusCode::NO_CONTENT.into_response())
|
||||
}
|
||||
|
||||
144
src/api/auth.rs
144
src/api/auth.rs
@@ -1,9 +1,135 @@
|
||||
//! Optional API key auth via env RUSTYCMS_API_KEY. Protects POST/PUT/DELETE.
|
||||
//! Optional API key auth. Configure via RUSTYCMS_API_KEY (single key = full access)
|
||||
//! or RUSTYCMS_API_KEYS (key1:role1,key2:role2). Roles: read | read_write | admin.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
use super::error::ApiError;
|
||||
|
||||
/// Permission required for an action.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Permission {
|
||||
/// GET only (read content, list, assets). Used to decide draft visibility.
|
||||
Read,
|
||||
/// Write content (POST/PUT/DELETE entries).
|
||||
ContentWrite,
|
||||
/// Write assets (upload, delete, rename, folders).
|
||||
AssetsWrite,
|
||||
/// Write schemas (create/update/delete types).
|
||||
SchemasWrite,
|
||||
}
|
||||
|
||||
/// Role granted by an API key.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Role {
|
||||
/// Read-only: GET requests; can see drafts when using this key.
|
||||
Read,
|
||||
/// Read + write content and assets.
|
||||
ReadWrite,
|
||||
/// ReadWrite + schema management.
|
||||
Admin,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
fn from_str(s: &str) -> Option<Self> {
|
||||
match s.trim().to_lowercase().as_str() {
|
||||
"read" => Some(Role::Read),
|
||||
"read_write" | "readwrite" => Some(Role::ReadWrite),
|
||||
"admin" => Some(Role::Admin),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn allows(self, perm: Permission) -> bool {
|
||||
match perm {
|
||||
Permission::Read => true,
|
||||
Permission::ContentWrite | Permission::AssetsWrite => matches!(self, Role::ReadWrite | Role::Admin),
|
||||
Permission::SchemasWrite => matches!(self, Role::Admin),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed API keys with roles. Built from env in main.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ApiKeys {
|
||||
/// key (token) -> role
|
||||
keys: HashMap<String, Role>,
|
||||
}
|
||||
|
||||
impl ApiKeys {
|
||||
/// Parse from env: RUSTYCMS_API_KEYS=key1:read_write,key2:read or RUSTYCMS_API_KEY=single (admin).
|
||||
pub fn from_env() -> Option<Self> {
|
||||
if let Ok(s) = std::env::var("RUSTYCMS_API_KEYS") {
|
||||
let mut keys = HashMap::new();
|
||||
for part in s.split(',') {
|
||||
let part = part.trim();
|
||||
if part.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some((k, v)) = part.split_once(':') {
|
||||
let key = k.trim().to_string();
|
||||
let role = Role::from_str(v).unwrap_or(Role::Read);
|
||||
if !key.is_empty() {
|
||||
keys.insert(key, role);
|
||||
}
|
||||
} else {
|
||||
// key without role = full access (admin)
|
||||
let key = part.to_string();
|
||||
if !key.is_empty() {
|
||||
keys.insert(key, Role::Admin);
|
||||
}
|
||||
}
|
||||
}
|
||||
if keys.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some(ApiKeys { keys });
|
||||
}
|
||||
if let Ok(key) = std::env::var("RUSTYCMS_API_KEY") {
|
||||
let key = key.trim().to_string();
|
||||
if key.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut keys = HashMap::new();
|
||||
keys.insert(key, Role::Admin);
|
||||
return Some(ApiKeys { keys });
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Return the role for this token if it matches any key.
|
||||
pub fn role_for_token(&self, token: Option<&str>) -> Option<Role> {
|
||||
let t = token?;
|
||||
self.keys.get(t).copied()
|
||||
}
|
||||
|
||||
/// Require the given permission; the request must send a key that has it.
|
||||
pub fn require(&self, headers: &HeaderMap, permission: Permission) -> Result<(), ApiError> {
|
||||
let token = token_from_headers(headers);
|
||||
let role = self.role_for_token(token.as_deref());
|
||||
match role {
|
||||
Some(r) if r.allows(permission) => Ok(()),
|
||||
Some(_) => Err(ApiError::Unauthorized(
|
||||
"This API key does not have permission for this action.".to_string(),
|
||||
)),
|
||||
None => Err(ApiError::Unauthorized(
|
||||
"Missing or invalid API key. Use Authorization: Bearer <key> or X-API-Key: <key>."
|
||||
.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// True if any key is configured (so we hide drafts from unauthenticated requests).
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
!self.keys.is_empty()
|
||||
}
|
||||
|
||||
/// True if the request has a valid key (any role). Used for draft visibility.
|
||||
pub fn is_authenticated(&self, headers: &HeaderMap) -> bool {
|
||||
self.role_for_token(token_from_headers(headers).as_deref()).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Read token from `Authorization: Bearer <token>` or `X-API-Key: <token>`.
|
||||
pub fn token_from_headers(headers: &HeaderMap) -> Option<String> {
|
||||
if let Some(v) = headers.get("Authorization") {
|
||||
@@ -22,17 +148,11 @@ pub fn token_from_headers(headers: &HeaderMap) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// If `required_key` is Some, the request must send a matching token (Bearer or X-API-Key).
|
||||
pub fn require_api_key(required_key: Option<&String>, headers: &HeaderMap) -> Result<(), ApiError> {
|
||||
let Some(required) = required_key else {
|
||||
/// Legacy: require any valid API key (for call sites that only need “has key”).
|
||||
/// Prefer ApiKeys::require(permission) when possible.
|
||||
pub fn require_api_key(api_keys: Option<&ApiKeys>, headers: &HeaderMap) -> Result<(), ApiError> {
|
||||
let Some(keys) = api_keys else {
|
||||
return Ok(());
|
||||
};
|
||||
let provided = token_from_headers(headers);
|
||||
if provided.as_deref() != Some(required.as_str()) {
|
||||
return Err(ApiError::Unauthorized(
|
||||
"Missing or invalid API key. Use Authorization: Bearer <key> or X-API-Key: <key>."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
keys.require(headers, Permission::ContentWrite)
|
||||
}
|
||||
|
||||
@@ -57,11 +57,10 @@ impl ContentCache {
|
||||
);
|
||||
}
|
||||
|
||||
/// Removes all entries for the given collection (after create/update/delete).
|
||||
/// Invalidates all locales for this collection (e:collection:*, l:collection:*).
|
||||
pub async fn invalidate_collection(&self, collection: &str) {
|
||||
let prefix_e = format!("e:{}:", collection);
|
||||
let prefix_l = format!("l:{}:", collection);
|
||||
/// Removes all entries for the given collection in the given environment (after create/update/delete).
|
||||
pub async fn invalidate_collection(&self, env: &str, collection: &str) {
|
||||
let prefix_e = format!("e:{}:{}:", env, collection);
|
||||
let prefix_l = format!("l:{}:{}:", env, collection);
|
||||
let mut guard = self.data.write().await;
|
||||
guard.retain(|k, _| !k.starts_with(&prefix_e) && !k.starts_with(&prefix_l));
|
||||
}
|
||||
@@ -73,16 +72,16 @@ impl ContentCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache key for a single entry (incl. _resolve and optional _locale).
|
||||
pub fn entry_cache_key(collection: &str, slug: &str, resolve_key: &str, locale: Option<&str>) -> String {
|
||||
/// Cache key for a single entry (env + collection + slug + _resolve + optional _locale).
|
||||
pub fn entry_cache_key(env: &str, collection: &str, slug: &str, resolve_key: &str, locale: Option<&str>) -> String {
|
||||
let loc = locale.unwrap_or("");
|
||||
format!("e:{}:{}:{}:{}", collection, slug, resolve_key, loc)
|
||||
format!("e:{}:{}:{}:{}:{}", env, collection, slug, resolve_key, loc)
|
||||
}
|
||||
|
||||
/// Cache key for a list (collection + hash of query params + optional locale).
|
||||
pub fn list_cache_key(collection: &str, query_hash: u64, locale: Option<&str>) -> String {
|
||||
/// Cache key for a list (env + collection + query hash + optional locale).
|
||||
pub fn list_cache_key(env: &str, collection: &str, query_hash: u64, locale: Option<&str>) -> String {
|
||||
let loc = locale.unwrap_or("");
|
||||
format!("l:{}:{}:{}", collection, loc, query_hash)
|
||||
format!("l:{}:{}:{}:{}", env, collection, loc, query_hash)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -22,10 +22,11 @@ use super::auth;
|
||||
use super::cache::{self, ContentCache, TransformCache};
|
||||
use super::error::ApiError;
|
||||
use super::response::{collapse_asset_urls, expand_asset_urls, format_references, parse_resolve};
|
||||
use super::webhooks;
|
||||
|
||||
/// Shared application state. Registry and OpenAPI spec are behind RwLock for hot-reload.
|
||||
/// Store is selected via RUSTYCMS_STORE=file|sqlite.
|
||||
/// If api_key is set (RUSTYCMS_API_KEY), POST/PUT/DELETE require it (Bearer or X-API-Key).
|
||||
/// If api_keys is set (RUSTYCMS_API_KEY or RUSTYCMS_API_KEYS), write operations require a matching key with the right role.
|
||||
/// When locales is set (RUSTYCMS_LOCALES e.g. "de,en"), API accepts _locale query param.
|
||||
pub struct AppState {
|
||||
pub registry: Arc<RwLock<SchemaRegistry>>,
|
||||
@@ -33,7 +34,8 @@ pub struct AppState {
|
||||
pub openapi_spec: Arc<RwLock<serde_json::Value>>,
|
||||
/// Path to types directory (e.g. ./types) for schema file writes.
|
||||
pub types_dir: PathBuf,
|
||||
pub api_key: Option<String>,
|
||||
/// API keys with roles (from RUSTYCMS_API_KEYS or RUSTYCMS_API_KEY).
|
||||
pub api_keys: Option<super::auth::ApiKeys>,
|
||||
pub cache: Arc<ContentCache>,
|
||||
pub transform_cache: Arc<TransformCache>,
|
||||
pub http_client: reqwest::Client,
|
||||
@@ -43,6 +45,52 @@ pub struct AppState {
|
||||
pub assets_dir: PathBuf,
|
||||
/// Public base URL (e.g. https://api.example.com). Used to expand relative /api/assets/ paths.
|
||||
pub base_url: String,
|
||||
/// Webhook URLs to POST on content/asset/schema changes (from RUSTYCMS_WEBHOOKS).
|
||||
pub webhook_urls: Vec<String>,
|
||||
/// When set (RUSTYCMS_ENVIRONMENTS), content/assets are per-environment (e.g. production, staging).
|
||||
pub environments: Option<Vec<String>>,
|
||||
/// Store per environment when environments is set. Key = env name.
|
||||
pub stores: Option<HashMap<String, Arc<dyn ContentStore>>>,
|
||||
/// Assets dir per environment when environments is set. Key = env name.
|
||||
pub assets_dirs: Option<HashMap<String, PathBuf>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Resolve environment from query _environment. Default = first in list.
|
||||
pub fn effective_environment(&self, params: &HashMap<String, String>) -> String {
|
||||
let requested = params.get("_environment").map(|s| s.trim()).filter(|s| !s.is_empty());
|
||||
self.effective_environment_from_param(requested)
|
||||
}
|
||||
|
||||
/// Resolve environment from optional _environment value (e.g. from asset query params).
|
||||
pub fn effective_environment_from_param(&self, env_param: Option<&str>) -> String {
|
||||
let Some(ref list) = self.environments else {
|
||||
return "default".to_string();
|
||||
};
|
||||
if list.is_empty() {
|
||||
return "default".to_string();
|
||||
}
|
||||
match env_param {
|
||||
Some(env) if list.iter().any(|e| e == env) => env.to_string(),
|
||||
_ => list[0].clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Store for the given environment. When environments is off, returns the single store.
|
||||
pub fn store_for(&self, env: &str) -> Arc<dyn ContentStore> {
|
||||
self.stores
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(env).cloned())
|
||||
.unwrap_or_else(|| Arc::clone(&self.store))
|
||||
}
|
||||
|
||||
/// Assets directory for the given environment.
|
||||
pub fn assets_dir_for(&self, env: &str) -> PathBuf {
|
||||
self.assets_dirs
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(env).cloned())
|
||||
.unwrap_or_else(|| self.assets_dir.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve effective locale from query _locale and state.locales. Returns None when i18n is off.
|
||||
@@ -114,7 +162,9 @@ pub async fn create_schema(
|
||||
headers: HeaderMap,
|
||||
Json(schema): Json<SchemaDefinition>,
|
||||
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
||||
auth::require_api_key(state.api_key.as_ref(), &headers)?;
|
||||
if let Some(ref keys) = state.api_keys {
|
||||
keys.require(&headers, auth::Permission::SchemasWrite)?;
|
||||
}
|
||||
|
||||
if !is_valid_schema_name(&schema.name) {
|
||||
return Err(ApiError::BadRequest(
|
||||
@@ -156,6 +206,15 @@ pub async fn create_schema(
|
||||
|
||||
tracing::info!("Schema created: {} ({})", schema.name, path.display());
|
||||
|
||||
webhooks::fire(
|
||||
state.http_client.clone(),
|
||||
&state.webhook_urls,
|
||||
json!({
|
||||
"event": webhooks::EVENT_SCHEMA_CREATED,
|
||||
"name": schema.name,
|
||||
}),
|
||||
);
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(serde_json::to_value(&schema).unwrap()),
|
||||
@@ -172,7 +231,9 @@ pub async fn update_schema(
|
||||
headers: HeaderMap,
|
||||
Json(schema): Json<SchemaDefinition>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
auth::require_api_key(state.api_key.as_ref(), &headers)?;
|
||||
if let Some(ref keys) = state.api_keys {
|
||||
keys.require(&headers, auth::Permission::SchemasWrite)?;
|
||||
}
|
||||
|
||||
if name != schema.name {
|
||||
return Err(ApiError::BadRequest(
|
||||
@@ -215,6 +276,15 @@ pub async fn update_schema(
|
||||
|
||||
tracing::info!("Schema updated: {} ({})", name, path.display());
|
||||
|
||||
webhooks::fire(
|
||||
state.http_client.clone(),
|
||||
&state.webhook_urls,
|
||||
json!({
|
||||
"event": webhooks::EVENT_SCHEMA_UPDATED,
|
||||
"name": name,
|
||||
}),
|
||||
);
|
||||
|
||||
Ok(Json(serde_json::to_value(&schema).unwrap()))
|
||||
}
|
||||
|
||||
@@ -227,7 +297,9 @@ pub async fn delete_schema(
|
||||
Path(name): Path<String>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
auth::require_api_key(state.api_key.as_ref(), &headers)?;
|
||||
if let Some(ref keys) = state.api_keys {
|
||||
keys.require(&headers, auth::Permission::SchemasWrite)?;
|
||||
}
|
||||
|
||||
if !is_valid_schema_name(&name) {
|
||||
return Err(ApiError::BadRequest("Invalid schema name".to_string()));
|
||||
@@ -254,6 +326,15 @@ pub async fn delete_schema(
|
||||
return Err(ApiError::NotFound(format!("Schema '{}' not found", name)));
|
||||
}
|
||||
|
||||
webhooks::fire(
|
||||
state.http_client.clone(),
|
||||
&state.webhook_urls,
|
||||
json!({
|
||||
"event": webhooks::EVENT_SCHEMA_DELETED,
|
||||
"name": name,
|
||||
}),
|
||||
);
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -342,8 +423,9 @@ pub async fn slug_check(
|
||||
if exclude.as_deref() == Some(normalized.as_str()) {
|
||||
true
|
||||
} else {
|
||||
let exists = state
|
||||
.store
|
||||
let env = state.effective_environment(¶ms);
|
||||
let store = state.store_for(&env);
|
||||
let exists = store
|
||||
.get(&collection, &normalized, locale_ref)
|
||||
.await
|
||||
.map_err(ApiError::from)?
|
||||
@@ -381,6 +463,8 @@ pub async fn list_entries(
|
||||
)));
|
||||
}
|
||||
|
||||
let env = state.effective_environment(¶ms);
|
||||
let store = state.store_for(&env);
|
||||
let locale = effective_locale(¶ms, state.locales.as_deref());
|
||||
let locale_ref = locale.as_deref();
|
||||
|
||||
@@ -404,17 +488,20 @@ pub async fn list_entries(
|
||||
list_params.get(k.as_str()).unwrap().hash(&mut hasher);
|
||||
}
|
||||
let query_hash = hasher.finish();
|
||||
let cache_key = cache::list_cache_key(&collection, query_hash, locale_ref);
|
||||
let cache_key = cache::list_cache_key(&env, &collection, query_hash, locale_ref);
|
||||
if let Some(cached) = state.cache.get(&cache_key).await {
|
||||
return Ok(Json(cached));
|
||||
}
|
||||
|
||||
let entries = state.store.list(&collection, locale_ref).await.map_err(ApiError::from)?;
|
||||
let entries = store.list(&collection, locale_ref).await.map_err(ApiError::from)?;
|
||||
|
||||
let resolve = parse_resolve(list_params.get("_resolve").map(|s| s.as_str()));
|
||||
// When API key is required but not sent, show only published entries. Otherwise use _status param.
|
||||
let status_override = if state.api_key.as_ref().is_some()
|
||||
&& auth::token_from_headers(&headers).as_deref() != state.api_key.as_ref().map(String::as_str)
|
||||
// When API keys are enabled but request has no valid key, show only published entries.
|
||||
let status_override = if state
|
||||
.api_keys
|
||||
.as_ref()
|
||||
.map(|k| k.is_enabled() && !k.is_authenticated(&headers))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
Some(StatusFilter::Published)
|
||||
} else {
|
||||
@@ -427,7 +514,7 @@ pub async fn list_entries(
|
||||
*item = format_references(
|
||||
std::mem::take(item),
|
||||
schema,
|
||||
state.store.as_ref(),
|
||||
store.as_ref(),
|
||||
resolve.as_ref(),
|
||||
locale_ref,
|
||||
Some(&*registry),
|
||||
@@ -464,15 +551,20 @@ pub async fn get_entry(
|
||||
)));
|
||||
}
|
||||
|
||||
let env = state.effective_environment(¶ms);
|
||||
let store = state.store_for(&env);
|
||||
let locale = effective_locale(¶ms, state.locales.as_deref());
|
||||
let locale_ref = locale.as_deref();
|
||||
|
||||
let resolve_key = params.get("_resolve").map(|s| s.as_str()).unwrap_or("");
|
||||
let cache_key = cache::entry_cache_key(&collection, &slug, resolve_key, locale_ref);
|
||||
let cache_key = cache::entry_cache_key(&env, &collection, &slug, resolve_key, locale_ref);
|
||||
if let Some(ref cached) = state.cache.get(&cache_key).await {
|
||||
// Don't serve cached draft to unauthenticated requests.
|
||||
let is_authenticated = state.api_key.as_ref().is_none()
|
||||
|| auth::token_from_headers(&headers).as_deref() == state.api_key.as_ref().map(String::as_str);
|
||||
let is_authenticated = state
|
||||
.api_keys
|
||||
.as_ref()
|
||||
.map(|k| k.is_authenticated(&headers))
|
||||
.unwrap_or(true);
|
||||
if !is_authenticated && entry_is_draft(cached) {
|
||||
// Fall through to load from store (will 404 if draft).
|
||||
} else {
|
||||
@@ -502,8 +594,7 @@ pub async fn get_entry(
|
||||
}
|
||||
}
|
||||
|
||||
let entry = state
|
||||
.store
|
||||
let entry = store
|
||||
.get(&collection, &slug, locale_ref)
|
||||
.await
|
||||
.map_err(ApiError::from)?
|
||||
@@ -511,9 +602,12 @@ pub async fn get_entry(
|
||||
ApiError::NotFound(format!("Entry '{}' not found in '{}'", slug, collection))
|
||||
})?;
|
||||
|
||||
// When no API key is sent, hide draft entries (return 404).
|
||||
if state.api_key.as_ref().is_some()
|
||||
&& auth::token_from_headers(&headers).as_deref() != state.api_key.as_ref().map(String::as_str)
|
||||
// When API keys are enabled and request has no valid key, hide draft entries (return 404).
|
||||
if state
|
||||
.api_keys
|
||||
.as_ref()
|
||||
.map(|k| k.is_enabled() && !k.is_authenticated(&headers))
|
||||
.unwrap_or(false)
|
||||
&& entry_is_draft(&entry)
|
||||
{
|
||||
return Err(ApiError::NotFound(format!(
|
||||
@@ -526,7 +620,7 @@ pub async fn get_entry(
|
||||
let mut formatted = format_references(
|
||||
entry,
|
||||
schema,
|
||||
state.store.as_ref(),
|
||||
store.as_ref(),
|
||||
resolve.as_ref(),
|
||||
locale_ref,
|
||||
Some(&*registry),
|
||||
@@ -538,7 +632,7 @@ pub async fn get_entry(
|
||||
|
||||
// Only cache published entries so unauthenticated requests never see cached drafts.
|
||||
if !entry_is_draft(&formatted) {
|
||||
state.cache.set(cache_key, formatted.clone()).await;
|
||||
state.cache.set(cache_key.clone(), formatted.clone()).await;
|
||||
}
|
||||
|
||||
let json_str = serde_json::to_string(&formatted).unwrap_or_default();
|
||||
@@ -579,8 +673,12 @@ pub async fn create_entry(
|
||||
headers: HeaderMap,
|
||||
Json(mut body): Json<Value>,
|
||||
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
||||
auth::require_api_key(state.api_key.as_ref(), &headers)?;
|
||||
if let Some(ref keys) = state.api_keys {
|
||||
keys.require(&headers, auth::Permission::ContentWrite)?;
|
||||
}
|
||||
|
||||
let env = state.effective_environment(¶ms);
|
||||
let store = state.store_for(&env);
|
||||
let locale = effective_locale(¶ms, state.locales.as_deref());
|
||||
let locale_ref = locale.as_deref();
|
||||
|
||||
@@ -637,7 +735,7 @@ pub async fn create_entry(
|
||||
}
|
||||
|
||||
// Unique constraint check (within same locale)
|
||||
let entries = state.store.list(&collection, locale_ref).await.map_err(ApiError::from)?;
|
||||
let entries = store.list(&collection, locale_ref).await.map_err(ApiError::from)?;
|
||||
let unique_errors = validator::validate_unique(&schema, &body, None, &entries);
|
||||
if !unique_errors.is_empty() {
|
||||
let messages: Vec<String> = unique_errors.iter().map(|e| e.to_string()).collect();
|
||||
@@ -645,11 +743,11 @@ pub async fn create_entry(
|
||||
}
|
||||
|
||||
// Reference validation (blocking: we need sync closure; use tokio::task::block_in_place or spawn)
|
||||
let store = &state.store;
|
||||
let ref_errors = validator::validate_references(&schema, &body, &|coll, s| {
|
||||
let store_ref = Arc::clone(&store);
|
||||
tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(async move {
|
||||
store.get(coll, s, locale_ref).await.ok().flatten().is_some()
|
||||
store_ref.get(coll, s, locale_ref).await.ok().flatten().is_some()
|
||||
})
|
||||
})
|
||||
});
|
||||
@@ -662,16 +760,14 @@ pub async fn create_entry(
|
||||
collapse_asset_urls(&mut body, &state.base_url);
|
||||
|
||||
// Persist to filesystem
|
||||
state
|
||||
.store
|
||||
store
|
||||
.create(&collection, &slug, &body, locale_ref)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
state.cache.invalidate_collection(&collection).await;
|
||||
state.cache.invalidate_collection(&env, &collection).await;
|
||||
|
||||
// Return created entry (with reference format)
|
||||
let entry = state
|
||||
.store
|
||||
let entry = store
|
||||
.get(&collection, &slug, locale_ref)
|
||||
.await
|
||||
.map_err(ApiError::from)?
|
||||
@@ -679,13 +775,25 @@ pub async fn create_entry(
|
||||
let formatted = format_references(
|
||||
entry,
|
||||
&schema,
|
||||
state.store.as_ref(),
|
||||
store.as_ref(),
|
||||
None,
|
||||
locale_ref,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
webhooks::fire(
|
||||
state.http_client.clone(),
|
||||
&state.webhook_urls,
|
||||
json!({
|
||||
"event": webhooks::EVENT_CONTENT_CREATED,
|
||||
"collection": collection,
|
||||
"slug": slug,
|
||||
"locale": locale_ref,
|
||||
"environment": env,
|
||||
}),
|
||||
);
|
||||
|
||||
Ok((StatusCode::CREATED, Json(formatted)))
|
||||
}
|
||||
|
||||
@@ -700,8 +808,12 @@ pub async fn update_entry(
|
||||
headers: HeaderMap,
|
||||
Json(mut body): Json<Value>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
auth::require_api_key(state.api_key.as_ref(), &headers)?;
|
||||
if let Some(ref keys) = state.api_keys {
|
||||
keys.require(&headers, auth::Permission::ContentWrite)?;
|
||||
}
|
||||
|
||||
let env = state.effective_environment(¶ms);
|
||||
let store = state.store_for(&env);
|
||||
let locale = effective_locale(¶ms, state.locales.as_deref());
|
||||
let locale_ref = locale.as_deref();
|
||||
|
||||
@@ -736,8 +848,7 @@ pub async fn update_entry(
|
||||
validator::normalize_reference_arrays(&schema, &mut body);
|
||||
|
||||
// Load existing content for readonly check
|
||||
let existing = state
|
||||
.store
|
||||
let existing = store
|
||||
.get(&collection, &slug, locale_ref)
|
||||
.await
|
||||
.map_err(ApiError::from)?
|
||||
@@ -760,7 +871,7 @@ pub async fn update_entry(
|
||||
}
|
||||
|
||||
// Unique constraint check (exclude self, within same locale)
|
||||
let entries = state.store.list(&collection, locale_ref).await.map_err(ApiError::from)?;
|
||||
let entries = store.list(&collection, locale_ref).await.map_err(ApiError::from)?;
|
||||
let unique_errors = validator::validate_unique(&schema, &body, Some(&slug), &entries);
|
||||
if !unique_errors.is_empty() {
|
||||
let messages: Vec<String> = unique_errors.iter().map(|e| e.to_string()).collect();
|
||||
@@ -768,11 +879,11 @@ pub async fn update_entry(
|
||||
}
|
||||
|
||||
// Reference validation
|
||||
let store = &state.store;
|
||||
let ref_errors = validator::validate_references(&schema, &body, &|coll, s| {
|
||||
let store_ref = Arc::clone(&store);
|
||||
tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(async move {
|
||||
store.get(coll, s, locale_ref).await.ok().flatten().is_some()
|
||||
store_ref.get(coll, s, locale_ref).await.ok().flatten().is_some()
|
||||
})
|
||||
})
|
||||
});
|
||||
@@ -785,16 +896,14 @@ pub async fn update_entry(
|
||||
collapse_asset_urls(&mut body, &state.base_url);
|
||||
|
||||
// Persist to filesystem
|
||||
state
|
||||
.store
|
||||
store
|
||||
.update(&collection, &slug, &body, locale_ref)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
state.cache.invalidate_collection(&collection).await;
|
||||
state.cache.invalidate_collection(&env, &collection).await;
|
||||
|
||||
// Return updated entry (with reference format)
|
||||
let entry = state
|
||||
.store
|
||||
let entry = store
|
||||
.get(&collection, &slug, locale_ref)
|
||||
.await
|
||||
.map_err(ApiError::from)?
|
||||
@@ -802,13 +911,25 @@ pub async fn update_entry(
|
||||
let formatted = format_references(
|
||||
entry,
|
||||
&schema,
|
||||
state.store.as_ref(),
|
||||
store.as_ref(),
|
||||
None,
|
||||
locale_ref,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
webhooks::fire(
|
||||
state.http_client.clone(),
|
||||
&state.webhook_urls,
|
||||
json!({
|
||||
"event": webhooks::EVENT_CONTENT_UPDATED,
|
||||
"collection": collection,
|
||||
"slug": slug,
|
||||
"locale": locale_ref,
|
||||
"environment": env,
|
||||
}),
|
||||
);
|
||||
|
||||
Ok(Json(formatted))
|
||||
}
|
||||
|
||||
@@ -838,8 +959,12 @@ pub async fn delete_entry(
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
auth::require_api_key(state.api_key.as_ref(), &headers)?;
|
||||
if let Some(ref keys) = state.api_keys {
|
||||
keys.require(&headers, auth::Permission::ContentWrite)?;
|
||||
}
|
||||
|
||||
let env = state.effective_environment(¶ms);
|
||||
let store = state.store_for(&env);
|
||||
let locale = effective_locale(¶ms, state.locales.as_deref());
|
||||
let locale_ref = locale.as_deref();
|
||||
|
||||
@@ -857,12 +982,23 @@ pub async fn delete_entry(
|
||||
)));
|
||||
}
|
||||
|
||||
state
|
||||
.store
|
||||
store
|
||||
.delete(&collection, &slug, locale_ref)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
state.cache.invalidate_collection(&collection).await;
|
||||
state.cache.invalidate_collection(&env, &collection).await;
|
||||
|
||||
webhooks::fire(
|
||||
state.http_client.clone(),
|
||||
&state.webhook_urls,
|
||||
json!({
|
||||
"event": webhooks::EVENT_CONTENT_DELETED,
|
||||
"collection": collection,
|
||||
"slug": slug,
|
||||
"locale": locale_ref,
|
||||
"environment": env,
|
||||
}),
|
||||
);
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -7,3 +7,4 @@ pub mod openapi;
|
||||
pub mod response;
|
||||
pub mod routes;
|
||||
pub mod transform;
|
||||
pub mod webhooks;
|
||||
|
||||
62
src/api/webhooks.rs
Normal file
62
src/api/webhooks.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
//! Webhooks: POST to configured URLs when content or assets change.
|
||||
//! Configure via RUSTYCMS_WEBHOOKS (comma-separated URLs).
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Event types sent in the payload.
|
||||
pub const EVENT_CONTENT_CREATED: &str = "content.created";
|
||||
pub const EVENT_CONTENT_UPDATED: &str = "content.updated";
|
||||
pub const EVENT_CONTENT_DELETED: &str = "content.deleted";
|
||||
pub const EVENT_ASSET_UPLOADED: &str = "asset.uploaded";
|
||||
pub const EVENT_ASSET_DELETED: &str = "asset.deleted";
|
||||
pub const EVENT_SCHEMA_CREATED: &str = "schema.created";
|
||||
pub const EVENT_SCHEMA_UPDATED: &str = "schema.updated";
|
||||
pub const EVENT_SCHEMA_DELETED: &str = "schema.deleted";
|
||||
|
||||
/// Parse RUSTYCMS_WEBHOOKS env: comma-separated list of URLs.
|
||||
pub fn urls_from_env() -> Vec<String> {
|
||||
std::env::var("RUSTYCMS_WEBHOOKS")
|
||||
.ok()
|
||||
.map(|s| {
|
||||
s.split(',')
|
||||
.map(|u| u.trim().to_string())
|
||||
.filter(|u| !u.is_empty() && (u.starts_with("http://") || u.starts_with("https://")))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Fire webhooks in the background (spawns task, does not block).
|
||||
/// Payload should include at least "event" and relevant ids (collection, slug, etc.).
|
||||
pub fn fire(client: reqwest::Client, urls: &[String], payload: Value) {
|
||||
if urls.is_empty() {
|
||||
return;
|
||||
}
|
||||
let urls: Vec<String> = urls.to_vec();
|
||||
let client = client.clone();
|
||||
let body = match serde_json::to_vec(&payload) {
|
||||
Ok(b) => b,
|
||||
Err(_) => return,
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
for url in urls {
|
||||
let res = client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await;
|
||||
match res {
|
||||
Ok(r) if r.status().is_success() => {
|
||||
tracing::debug!("Webhook {} succeeded", url);
|
||||
}
|
||||
Ok(r) => {
|
||||
tracing::warn!("Webhook {} returned {}", url, r.status());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Webhook {} failed: {}", url, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user