feat: profile statuses

This commit is contained in:
zhom committed 2026-10-11 23:56:41 +04:00
1 parent 187788b309
commit 69d3c90a7c
41 files changed
+2279 -36

No files matched your search

+192
View File
@@ -49,6 +49,8 @@ pub struct ApiProfile {
/// The DNS server the profile resolves names with, through its own proxy
/// or VPN. `null` when the proxy resolves them.
pub dns_server: Option<String>,
/// The profile's status id (see `/v1/statuses`), or `null`.
pub status_id: Option<String>,
/// Extension group loaded into the browser at launch. Settable via
/// `PUT /v1/profiles/{id}`; exposed here so a caller can read back what it
/// set instead of having to go through the desktop app.
@@ -102,6 +104,7 @@ impl From<&crate::profile::types::BrowserProfile> for ApiProfile {
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
vpn_id: profile.vpn_id.clone(),
dns_server: profile.dns_server.clone(),
status_id: profile.status_id.clone(),
extension_group_id: profile.extension_group_id.clone(),
ephemeral: profile.ephemeral,
temporary: profile.temporary,
@@ -168,6 +171,9 @@ pub struct CreateProfileRequest {
/// proxy resolve names. Anything else is rejected with 400.
#[serde(default)]
pub dns_server: Option<String>,
/// Optional status id from `/v1/statuses`.
#[serde(default)]
pub status_id: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
@@ -198,6 +204,8 @@ pub struct UpdateProfileRequest {
/// clears it, so the proxy resolves names again. Same formats as on create.
/// Takes effect at the next launch.
pub dns_server: Option<String>,
/// Omitted or `null` leaves the status unchanged; an empty string clears it.
pub status_id: Option<String>,
}
#[derive(Clone)]
@@ -222,6 +230,34 @@ struct UpdateGroupRequest {
name: String,
}
/// A profile status: a named, colored label for where a profile's account
/// stands.
#[derive(Debug, Serialize, Deserialize, ToSchema)]
struct ApiStatusResponse {
id: String,
name: String,
/// `#rrggbb`.
color: String,
}
impl From<crate::profile_statuses::ProfileStatus> for ApiStatusResponse {
fn from(status: crate::profile_statuses::ProfileStatus) -> Self {
Self {
id: status.id,
name: status.name,
color: status.color,
}
}
}
#[derive(Debug, Deserialize, ToSchema)]
struct StatusRequest {
/// 1 to 40 characters, unique (case-insensitive).
name: String,
/// `#rrggbb`.
color: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
struct ApiProxyResponse {
id: String,
@@ -659,6 +695,10 @@ struct ImportProxiesResponse {
create_group,
update_group,
delete_group,
get_statuses,
create_status,
update_status,
delete_status,
get_tags,
get_proxies,
get_proxy,
@@ -704,6 +744,8 @@ struct ImportProxiesResponse {
ApiGroupResponse,
CreateGroupRequest,
UpdateGroupRequest,
ApiStatusResponse,
StatusRequest,
ApiProxyResponse,
CreateProxyRequest,
UpdateProxyRequest,
@@ -798,6 +840,7 @@ struct ImportProxiesResponse {
tags(
(name = "profiles", description = "Profile management endpoints"),
(name = "groups", description = "Group management endpoints"),
(name = "statuses", description = "Profile status management endpoints"),
(name = "tags", description = "Tag management endpoints"),
(name = "proxies", description = "Proxy management endpoints"),
(name = "vpns", description = "VPN management endpoints"),
@@ -990,6 +1033,8 @@ fn build_v1_router() -> Router<ApiServerState> {
.routes(routes!(import_profile_cookies))
.routes(routes!(get_groups, create_group))
.routes(routes!(get_group, update_group, delete_group))
.routes(routes!(get_statuses, create_status))
.routes(routes!(update_status, delete_status))
.routes(routes!(get_tags))
.routes(routes!(get_proxies, create_proxy))
.routes(routes!(import_proxies_api))
@@ -1585,6 +1630,14 @@ async fn create_profile(
});
}
if let Some(status_id) = request.status_id.as_deref().filter(|id| !id.is_empty()) {
if !crate::profile_statuses::exists(status_id) {
return Err(manager_error_response(crate::backend_error(
"PROFILE_STATUS_NOT_FOUND",
)));
}
}
// Create profile using the async create_profile_with_group method
match profile_manager
.create_profile_with_group(
@@ -1605,6 +1658,14 @@ async fn create_profile(
.await
{
Ok(mut profile) => {
if let Some(status_id) = request.status_id.clone().filter(|id| !id.is_empty()) {
if let Err(e) =
profile_manager.set_profiles_status(&[profile.id.to_string()], Some(status_id.clone()))
{
return Err(manager_error_response(e));
}
profile.status_id = Some(status_id);
}
if request.temporary.unwrap_or(false) {
match profile_manager.mark_profile_temporary(&profile.id.to_string()) {
Ok(updated) => profile = updated,
@@ -1695,6 +1756,13 @@ async fn update_profile(
crate::profile_dns::normalize_dns_server(Some(dns_server.clone()))
.map_err(manager_error_response)?;
}
if let Some(status_id) = request.status_id.as_deref().filter(|id| !id.is_empty()) {
if !crate::profile_statuses::exists(status_id) {
return Err(manager_error_response(crate::backend_error(
"PROFILE_STATUS_NOT_FOUND",
)));
}
}
// Update profile fields
if let Some(new_name) = request.name {
@@ -1822,6 +1890,13 @@ async fn update_profile(
}
}
if let Some(status_id) = request.status_id {
let status_id = Some(status_id).filter(|id| !id.is_empty());
if let Err(e) = profile_manager.set_profiles_status(std::slice::from_ref(&id), status_id) {
return Err(manager_error_response(e));
}
}
// Return updated profile
get_profile(Path(id), State(state))
.await
@@ -2042,6 +2117,99 @@ async fn delete_group(
}
}
// API Handlers - Statuses
#[utoipa::path(
get,
path = "/v1/statuses",
responses(
(status = 200, description = "Every profile status, in their order", body = Vec<ApiStatusResponse>),
(status = 401, description = "Unauthorized")
),
security(
("bearer_auth" = [])
),
tag = "statuses"
)]
async fn get_statuses() -> Json<Vec<ApiStatusResponse>> {
Json(
crate::profile_statuses::list()
.into_iter()
.map(ApiStatusResponse::from)
.collect(),
)
}
#[utoipa::path(
post,
path = "/v1/statuses",
request_body = StatusRequest,
responses(
(status = 200, description = "Status created", body = ApiStatusResponse),
(status = 400, description = "Empty, too long or duplicate name, invalid color, or 100 statuses already"),
(status = 401, description = "Unauthorized")
),
security(
("bearer_auth" = [])
),
tag = "statuses"
)]
async fn create_status(
Json(request): Json<StatusRequest>,
) -> Result<Json<ApiStatusResponse>, (StatusCode, String)> {
crate::profile_statuses::create(&request.name, &request.color)
.map(|status| Json(status.into()))
.map_err(manager_error_response)
}
#[utoipa::path(
put,
path = "/v1/statuses/{id}",
params(
("id" = String, Path, description = "Status ID")
),
request_body = StatusRequest,
responses(
(status = 200, description = "Status updated", body = ApiStatusResponse),
(status = 400, description = "Empty, too long or duplicate name, or invalid color"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Status not found")
),
security(
("bearer_auth" = [])
),
tag = "statuses"
)]
async fn update_status(
Path(id): Path<String>,
Json(request): Json<StatusRequest>,
) -> Result<Json<ApiStatusResponse>, (StatusCode, String)> {
crate::profile_statuses::update(&id, &request.name, &request.color)
.map(|status| Json(status.into()))
.map_err(manager_error_response)
}
#[utoipa::path(
delete,
path = "/v1/statuses/{id}",
params(
("id" = String, Path, description = "Status ID")
),
responses(
(status = 204, description = "Status deleted and taken off every profile that had it"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Status not found")
),
security(
("bearer_auth" = [])
),
tag = "statuses"
)]
async fn delete_status(Path(id): Path<String>) -> Result<StatusCode, (StatusCode, String)> {
crate::profile_statuses::delete(&id)
.map(|()| StatusCode::NO_CONTENT)
.map_err(manager_error_response)
}
// API Handlers - Tags
#[utoipa::path(
get,
@@ -5648,6 +5816,30 @@ mod tests {
}
}
#[test]
fn openapi_documents_profile_statuses() {
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
for path in ["/v1/statuses", "/v1/statuses/{id}"] {
assert!(
spec["paths"][path].is_object(),
"{path} must be in the spec"
);
}
for schema in ["CreateProfileRequest", "UpdateProfileRequest", "ApiProfile"] {
assert_eq!(
spec["components"]["schemas"][schema]["properties"]["status_id"]["type"],
serde_json::json!(["string", "null"]),
"{schema}.status_id must be a nullable string"
);
assert!(
!schema_required(&spec, schema)
.iter()
.any(|f| f == "status_id"),
"{schema}.status_id must be optional"
);
}
}
#[test]
fn openapi_documents_the_profile_dns_server() {
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");