mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-03 17:58:42 +02:00
4851 lines
162 KiB
Rust
4851 lines
162 KiB
Rust
use crate::browser::ProxySettings;
|
|
use crate::events;
|
|
use crate::group_manager::GROUP_MANAGER;
|
|
use crate::profile::manager::ProfileManager;
|
|
use crate::proxy_manager::PROXY_MANAGER;
|
|
use crate::tag_manager::TAG_MANAGER;
|
|
use axum::{
|
|
extract::{
|
|
ws::{Message as WsMessage, WebSocket, WebSocketUpgrade},
|
|
Path, Query, State,
|
|
},
|
|
http::{header, HeaderMap, Method, StatusCode},
|
|
middleware::{self, Next},
|
|
response::{IntoResponse, Json, Response},
|
|
routing::get,
|
|
Router,
|
|
};
|
|
use lazy_static::lazy_static;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::sync::Arc;
|
|
use tokio::net::TcpListener;
|
|
use tokio::sync::{mpsc, Mutex};
|
|
use tower_http::cors::CorsLayer;
|
|
use utoipa::{OpenApi, ToSchema};
|
|
use utoipa_axum::{router::OpenApiRouter, routes};
|
|
|
|
// API Types
|
|
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
|
|
pub struct ApiProfile {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub browser: String,
|
|
pub version: String,
|
|
pub proxy_id: Option<String>,
|
|
pub launch_hook: Option<String>,
|
|
pub process_id: Option<u32>,
|
|
pub last_launch: Option<u64>,
|
|
pub release_type: String,
|
|
pub group_id: Option<String>,
|
|
pub tags: Vec<String>,
|
|
pub is_running: bool,
|
|
pub proxy_bypass_rules: Vec<String>,
|
|
pub vpn_id: Option<String>,
|
|
pub clear_on_close: bool,
|
|
/// Cloud sync mode: `"Disabled"`, `"Regular"` or `"Encrypted"`.
|
|
/// Settable via `PUT /v1/profiles/{id}`; exposed here so a caller can read
|
|
/// back what it set, and so a remote-launch caller can tell whether the
|
|
/// profile is actually available in cloud storage.
|
|
pub sync_mode: String,
|
|
/// Convenience form of `sync_mode` — true for Regular or Encrypted.
|
|
pub cloud_sync_enabled: bool,
|
|
/// OS the profile was created on (`"macos"`, `"windows"`, `"linux"`).
|
|
/// `null` when neither `host_os` nor the browser config records one.
|
|
pub host_os: Option<String>,
|
|
/// True when the profile belongs to a different OS than this machine.
|
|
/// Such a profile cannot be launched locally, and must only ever run on a
|
|
/// remote host of its own OS — Chromium profile state is OS-specific.
|
|
pub is_cross_os: bool,
|
|
}
|
|
|
|
impl From<&crate::profile::types::BrowserProfile> for ApiProfile {
|
|
/// Single conversion for every profile-returning route. Previously open-coded
|
|
/// at three call sites, which is how `sync_mode` came to be settable but not
|
|
/// readable: a field added to the struct had to be remembered three times.
|
|
fn from(profile: &crate::profile::types::BrowserProfile) -> Self {
|
|
Self {
|
|
id: profile.id.to_string(),
|
|
name: profile.name.clone(),
|
|
browser: profile.browser.clone(),
|
|
version: profile.version.clone(),
|
|
proxy_id: profile.proxy_id.clone(),
|
|
launch_hook: profile.launch_hook.clone(),
|
|
process_id: profile.process_id,
|
|
last_launch: profile.last_launch,
|
|
release_type: profile.release_type.clone(),
|
|
group_id: profile.group_id.clone(),
|
|
tags: profile.tags.clone(),
|
|
is_running: profile.process_id.is_some(),
|
|
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
|
|
vpn_id: profile.vpn_id.clone(),
|
|
clear_on_close: profile.clear_on_close,
|
|
sync_mode: format!("{:?}", profile.sync_mode),
|
|
cloud_sync_enabled: profile.is_sync_enabled(),
|
|
host_os: profile.resolved_os().map(|os| os.to_string()),
|
|
is_cross_os: profile.is_cross_os(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
|
pub struct ApiProfilesResponse {
|
|
pub profiles: Vec<ApiProfile>,
|
|
pub total: usize,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
|
pub struct ApiProfileResponse {
|
|
pub profile: ApiProfile,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
|
pub struct CreateProfileRequest {
|
|
pub name: String,
|
|
/// Browser engine. Must be `"wayfern"` (anti-detect Chromium). Any other
|
|
/// value (e.g. `"chromium"`) is rejected with 400.
|
|
pub browser: String,
|
|
/// Optional. Omit (or pass `"latest"`) to use the newest already-downloaded
|
|
/// version of the chosen browser. A concrete version must already be
|
|
/// downloaded; the create path does not fetch new versions.
|
|
#[serde(default)]
|
|
pub version: Option<String>,
|
|
pub proxy_id: Option<String>,
|
|
pub vpn_id: Option<String>,
|
|
pub launch_hook: Option<String>,
|
|
pub release_type: Option<String>,
|
|
/// Wayfern fingerprint/config. Send only when `browser` is `"wayfern"`.
|
|
/// Omit it, or pass an empty object `{}`, to have a fresh fingerprint
|
|
/// generated automatically at creation. Provide a `fingerprint` field to
|
|
/// pin a specific one.
|
|
#[schema(value_type = Option<Object>)]
|
|
pub wayfern_config: Option<serde_json::Value>,
|
|
pub group_id: Option<String>,
|
|
pub tags: Option<Vec<String>>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
|
pub struct UpdateProfileRequest {
|
|
pub name: Option<String>,
|
|
// No `browser` field: a profile's engine is fixed at creation (changing it
|
|
// would invalidate the generated fingerprint and on-disk profile dir).
|
|
// Accepting it here only to silently ignore it misled API clients.
|
|
pub version: Option<String>,
|
|
pub proxy_id: Option<String>,
|
|
pub vpn_id: Option<String>,
|
|
pub launch_hook: Option<String>,
|
|
pub release_type: Option<String>,
|
|
pub group_id: Option<String>,
|
|
pub tags: Option<Vec<String>>,
|
|
pub extension_group_id: Option<String>,
|
|
pub proxy_bypass_rules: Option<Vec<String>>,
|
|
/// One of "Disabled", "Regular", "Encrypted".
|
|
pub sync_mode: Option<String>,
|
|
/// Wipe browsing data (keeping extensions and bookmarks) when the browser
|
|
/// exits. Rejected (400) for ephemeral or password-protected profiles.
|
|
pub clear_on_close: Option<bool>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct ApiServerState {
|
|
app_handle: tauri::AppHandle,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
|
struct ApiGroupResponse {
|
|
id: String,
|
|
name: String,
|
|
profile_count: usize,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
struct CreateGroupRequest {
|
|
name: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
struct UpdateGroupRequest {
|
|
name: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
|
struct ApiProxyResponse {
|
|
id: String,
|
|
name: String,
|
|
#[schema(value_type = Object)]
|
|
proxy_settings: ProxySettings,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
struct CreateProxyRequest {
|
|
name: String,
|
|
#[schema(value_type = Object)]
|
|
proxy_settings: ProxySettings,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
struct UpdateProxyRequest {
|
|
name: Option<String>,
|
|
#[schema(value_type = Option<Object>)]
|
|
proxy_settings: Option<ProxySettings>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
|
struct ApiVpnResponse {
|
|
id: String,
|
|
name: String,
|
|
/// Always "WireGuard"
|
|
vpn_type: String,
|
|
created_at: i64,
|
|
last_used: Option<i64>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
struct ApiVpnExportResponse {
|
|
id: String,
|
|
name: String,
|
|
/// Always "WireGuard"
|
|
vpn_type: String,
|
|
/// Raw `.conf` file content (decrypted)
|
|
config_data: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
struct ImportVpnRequest {
|
|
/// Raw WireGuard `.conf` file content
|
|
content: String,
|
|
/// Original filename
|
|
filename: String,
|
|
/// Optional display name; defaults to filename-based name
|
|
name: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
struct CreateVpnRequest {
|
|
name: String,
|
|
/// Must be "WireGuard"
|
|
vpn_type: String,
|
|
config_data: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
struct UpdateVpnRequest {
|
|
name: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
struct DownloadBrowserRequest {
|
|
browser: String,
|
|
version: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
struct DownloadBrowserResponse {
|
|
browser: String,
|
|
version: String,
|
|
status: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
|
pub struct ToastPayload {
|
|
pub message: String,
|
|
pub variant: String,
|
|
pub title: String,
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
struct RunProfileResponse {
|
|
profile_id: String,
|
|
remote_debugging_port: u16,
|
|
headless: bool,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
|
pub struct RunRemoteRequest {
|
|
/// Optional URL to open once the remote browser is up.
|
|
pub url: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
|
pub struct SetCloudSyncRequest {
|
|
/// `Disabled`, `Regular`, or `Encrypted`.
|
|
///
|
|
/// `Encrypted` derives its key from a passphrase that never leaves this
|
|
/// machine, so a profile in that mode can be synced but NOT run remotely —
|
|
/// a remote host would download ciphertext it cannot decrypt.
|
|
pub mode: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
|
pub struct SetCloudSyncResponse {
|
|
pub profile_id: String,
|
|
pub mode: String,
|
|
/// Whether the profile can now be launched on a remote host.
|
|
pub remote_launchable: bool,
|
|
/// Why not, when `remote_launchable` is false.
|
|
pub remote_blocked_reason: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
|
pub struct RunRemoteResponse {
|
|
pub profile_id: String,
|
|
/// Remote session id, for polling or closing the session.
|
|
pub session_id: String,
|
|
/// Operating system the session was scheduled onto — always the profile's own.
|
|
pub platform: String,
|
|
pub status: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
|
pub struct StopRemoteResponse {
|
|
pub session_id: String,
|
|
pub status: String,
|
|
/// What the session actually cost, in seconds.
|
|
pub billed_seconds: u64,
|
|
}
|
|
|
|
/// Every remote session the signed-in account currently owns.
|
|
///
|
|
/// `run-remote` hands back a session id and the literal string `provisioning`;
|
|
/// without a way to read the real state back, an automation client can only
|
|
/// discover that a session became usable by trying to drive it.
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
struct ApiRemoteSessionsResponse {
|
|
sessions: Vec<crate::remote_session::RemoteSessionState>,
|
|
}
|
|
|
|
/// Enrol a profile in the nightly cookie bot, or replace its enrolment.
|
|
///
|
|
/// `platform` and `profile_name` are optional because this machine already
|
|
/// knows both: the platform is the profile's own operating system, and a
|
|
/// caller-supplied one that disagrees is a mistake, not a choice.
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
struct SetCookieBotScheduleRequest {
|
|
/// Defaults to the profile's local name.
|
|
profile_name: Option<String>,
|
|
/// `windows` or `macos`. Defaults to the profile's own operating system, and
|
|
/// must match it when supplied.
|
|
platform: Option<String>,
|
|
/// Whether the nightly run is armed. A disabled schedule keeps its settings.
|
|
enabled: bool,
|
|
/// Minutes past local midnight the run is anchored to (0..1439).
|
|
run_at_minute: u16,
|
|
/// Bitmask of local weekdays, bit 0 = Monday (1..127).
|
|
days_mask: u8,
|
|
/// IANA zone the run time is expressed in.
|
|
timezone: String,
|
|
/// Server-issued preset id from `GET /v1/cookie-bot/presets`.
|
|
preset: String,
|
|
/// Upper bound on one run, in minutes.
|
|
max_minutes: u32,
|
|
/// Absolute http(s) URLs to browse. The bot visits only these.
|
|
#[serde(default)]
|
|
sites: Vec<String>,
|
|
/// Random spread around the anchor time, in seconds.
|
|
jitter_seconds: Option<u32>,
|
|
/// Write anyway when a teammate already enrols this profile. Without it, a
|
|
/// colliding write is refused with 409 and the teammate's details.
|
|
#[serde(default)]
|
|
acknowledge_conflict: bool,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
struct StartCookieBotRunRequest {
|
|
/// Profile to warm. It must already have a schedule: the preset and the site
|
|
/// list live there, so a run never carries a behaviour of its own.
|
|
profile_id: String,
|
|
/// Overrides the schedule's own cap for this run only.
|
|
max_minutes: Option<u32>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct CookieBotScopeQuery {
|
|
scope: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct CookieBotConflictsQuery {
|
|
profile_id: String,
|
|
run_at_minute: Option<u16>,
|
|
timezone: Option<String>,
|
|
days_mask: Option<u8>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct CookieBotRunsQuery {
|
|
profile_id: Option<String>,
|
|
scope: Option<String>,
|
|
limit: Option<u32>,
|
|
before: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct CookieBotUsageQuery {
|
|
period: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
struct RunProfileRequest {
|
|
url: Option<String>,
|
|
headless: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
struct OpenUrlRequest {
|
|
url: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
struct ImportCookiesRequest {
|
|
/// Raw cookie file content. Format is auto-detected: a JSON array
|
|
/// (Puppeteer / EditThisCookie style) or a Netscape `cookies.txt`.
|
|
content: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
struct ImportCookiesResponse {
|
|
cookies_imported: usize,
|
|
cookies_replaced: usize,
|
|
errors: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
struct BatchRunRequest {
|
|
/// Profile IDs to launch.
|
|
profile_ids: Vec<String>,
|
|
/// Optional URL to open in every launched profile.
|
|
url: Option<String>,
|
|
/// Launch headless. Defaults to false.
|
|
headless: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
struct BatchRunResult {
|
|
profile_id: String,
|
|
/// Whether this profile launched successfully.
|
|
ok: bool,
|
|
/// Remote debugging port if launched, otherwise null.
|
|
remote_debugging_port: Option<u16>,
|
|
/// Failure reason if not launched, otherwise null.
|
|
error: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
struct BatchRunResponse {
|
|
results: Vec<BatchRunResult>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
struct BatchStopRequest {
|
|
/// Profile IDs to stop.
|
|
profile_ids: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
struct BatchStopResult {
|
|
profile_id: String,
|
|
/// Whether this profile was stopped successfully.
|
|
ok: bool,
|
|
/// Failure reason if not stopped, otherwise null.
|
|
error: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
struct BatchStopResponse {
|
|
results: Vec<BatchStopResult>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
struct DetectedProfilesResponse {
|
|
profiles: Vec<crate::profile_importer::DetectedProfile>,
|
|
total: usize,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct DetectImportQuery {
|
|
/// Optional folder to scan instead of the default browser locations.
|
|
folder: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
struct ImportProfilesRequest {
|
|
/// Profiles to import. Each item is isolated — one failure doesn't stop the rest.
|
|
items: Vec<crate::profile_importer::ImportProfileItem>,
|
|
/// Optional group to assign every imported profile to.
|
|
group_id: Option<String>,
|
|
/// How to handle an already-taken profile name: "skip" or "rename"
|
|
/// (auto-suffix). Defaults to "rename".
|
|
duplicate_strategy: Option<crate::profile_importer::DuplicateStrategy>,
|
|
/// Wayfern fingerprint/config applied to every imported profile. Omit to
|
|
/// have fresh fingerprints generated automatically.
|
|
#[schema(value_type = Option<Object>)]
|
|
wayfern_config: Option<serde_json::Value>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
struct ImportProxiesRequest {
|
|
/// "txt" — one proxy per line (`host:port`, `host:port:user:pass`, or URL
|
|
/// forms like `http://user:pass@host:port`). "json" — a Donut proxy export.
|
|
format: String,
|
|
/// Raw proxy list / export content.
|
|
content: String,
|
|
/// Name prefix for txt imports; proxies are named "{prefix} Proxy {n}".
|
|
name_prefix: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
struct ImportProxiesResponse {
|
|
imported_count: usize,
|
|
skipped_count: usize,
|
|
errors: Vec<String>,
|
|
proxies: Vec<ApiProxyResponse>,
|
|
}
|
|
|
|
#[derive(OpenApi)]
|
|
#[openapi(
|
|
paths(
|
|
get_profiles,
|
|
get_profile,
|
|
create_profile,
|
|
update_profile,
|
|
delete_profile,
|
|
run_profile,
|
|
run_profile_remote,
|
|
stop_remote_session,
|
|
remote_session_cdp,
|
|
list_remote_sessions_api,
|
|
get_remote_session_api,
|
|
get_remote_hours,
|
|
set_profile_cloud_sync,
|
|
list_cookie_bot_schedules,
|
|
get_cookie_bot_schedule,
|
|
set_cookie_bot_schedule,
|
|
delete_cookie_bot_schedule,
|
|
get_cookie_bot_conflicts,
|
|
list_cookie_bot_runs,
|
|
start_cookie_bot_run,
|
|
cancel_cookie_bot_run,
|
|
list_cookie_bot_presets,
|
|
get_cookie_bot_usage,
|
|
open_url_in_profile,
|
|
kill_profile,
|
|
batch_run_profiles,
|
|
batch_stop_profiles,
|
|
detect_import_profiles,
|
|
import_profiles_api,
|
|
import_profile_cookies,
|
|
get_groups,
|
|
get_group,
|
|
create_group,
|
|
update_group,
|
|
delete_group,
|
|
get_tags,
|
|
get_proxies,
|
|
get_proxy,
|
|
create_proxy,
|
|
import_proxies_api,
|
|
update_proxy,
|
|
delete_proxy,
|
|
get_vpns,
|
|
get_vpn,
|
|
export_vpn,
|
|
import_vpn,
|
|
create_vpn,
|
|
update_vpn,
|
|
delete_vpn,
|
|
get_extensions,
|
|
get_extension_groups,
|
|
delete_extension_api,
|
|
delete_extension_group_api,
|
|
download_browser_api,
|
|
get_browser_versions,
|
|
check_browser_downloaded,
|
|
),
|
|
components(schemas(
|
|
ApiProfile,
|
|
ApiProfilesResponse,
|
|
ApiProfileResponse,
|
|
CreateProfileRequest,
|
|
UpdateProfileRequest,
|
|
ApiGroupResponse,
|
|
CreateGroupRequest,
|
|
UpdateGroupRequest,
|
|
ApiProxyResponse,
|
|
CreateProxyRequest,
|
|
UpdateProxyRequest,
|
|
ApiVpnResponse,
|
|
ApiVpnExportResponse,
|
|
ImportVpnRequest,
|
|
CreateVpnRequest,
|
|
UpdateVpnRequest,
|
|
DownloadBrowserRequest,
|
|
DownloadBrowserResponse,
|
|
RunProfileResponse,
|
|
RunRemoteRequest,
|
|
RunRemoteResponse,
|
|
StopRemoteResponse,
|
|
SetCloudSyncRequest,
|
|
SetCloudSyncResponse,
|
|
ApiRemoteSessionsResponse,
|
|
SetCookieBotScheduleRequest,
|
|
StartCookieBotRunRequest,
|
|
crate::remote_session::RemoteSessionState,
|
|
crate::cookie_bot::CookieBotSchedule,
|
|
crate::cookie_bot::CookieBotScheduleList,
|
|
crate::cookie_bot::CookieBotScheduleSaved,
|
|
crate::cookie_bot::CookieBotScheduleDeleted,
|
|
crate::cookie_bot::CookieBotConflict,
|
|
crate::cookie_bot::CookieBotConflictCheck,
|
|
crate::cookie_bot::CookieBotRun,
|
|
crate::cookie_bot::CookieBotRunPage,
|
|
crate::cookie_bot::CookieBotRunStarted,
|
|
crate::cookie_bot::CookieBotPreset,
|
|
crate::cookie_bot::CookieBotPresetList,
|
|
crate::cookie_bot::CookieBotUsage,
|
|
crate::cookie_bot::CookieBotUsageMember,
|
|
crate::cookie_bot::CookieBotUsageProfile,
|
|
crate::cookie_bot::RemoteHoursQuota,
|
|
crate::cookie_bot::RemoteHoursMember,
|
|
crate::cookie_bot::RemoteHoursBreakdown,
|
|
RunProfileRequest,
|
|
BatchRunRequest,
|
|
BatchRunResult,
|
|
BatchRunResponse,
|
|
BatchStopRequest,
|
|
BatchStopResult,
|
|
BatchStopResponse,
|
|
OpenUrlRequest,
|
|
ImportCookiesRequest,
|
|
ImportCookiesResponse,
|
|
ProxySettings,
|
|
DetectedProfilesResponse,
|
|
ImportProfilesRequest,
|
|
ImportProxiesRequest,
|
|
ImportProxiesResponse,
|
|
crate::profile_importer::DetectedProfile,
|
|
crate::profile_importer::ImportProfileItem,
|
|
crate::profile_importer::DuplicateStrategy,
|
|
crate::profile_importer::ProfileImportItemResult,
|
|
crate::profile_importer::ProfileImportBatchResult,
|
|
)),
|
|
tags(
|
|
(name = "profiles", description = "Profile management endpoints"),
|
|
(name = "groups", description = "Group management endpoints"),
|
|
(name = "tags", description = "Tag management endpoints"),
|
|
(name = "proxies", description = "Proxy management endpoints"),
|
|
(name = "vpns", description = "VPN management endpoints"),
|
|
(name = "extensions", description = "Extension management endpoints"),
|
|
(name = "browsers", description = "Browser management endpoints"),
|
|
(name = "cookies", description = "Cookie management endpoints"),
|
|
(name = "remote-sessions", description = "Sessions running on the leased remote fleet"),
|
|
(name = "cookie-bot", description = "Scheduled cookie-warming runs on the remote fleet"),
|
|
),
|
|
modifiers(&SecurityAddon),
|
|
)]
|
|
struct ApiDoc;
|
|
|
|
struct SecurityAddon;
|
|
|
|
impl utoipa::Modify for SecurityAddon {
|
|
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
|
if let Some(components) = openapi.components.as_mut() {
|
|
components.add_security_scheme(
|
|
"bearer_auth",
|
|
utoipa::openapi::security::SecurityScheme::Http(
|
|
utoipa::openapi::security::HttpBuilder::new()
|
|
.scheme(utoipa::openapi::security::HttpAuthScheme::Bearer)
|
|
.bearer_format("JWT")
|
|
.build(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct ApiServer {
|
|
port: Option<u16>,
|
|
shutdown_tx: Option<mpsc::Sender<()>>,
|
|
task_handle: Option<tokio::task::JoinHandle<()>>,
|
|
}
|
|
|
|
impl ApiServer {
|
|
fn new() -> Self {
|
|
Self {
|
|
port: None,
|
|
shutdown_tx: None,
|
|
task_handle: None,
|
|
}
|
|
}
|
|
|
|
fn get_port(&self) -> Option<u16> {
|
|
self.port
|
|
}
|
|
|
|
async fn start(
|
|
&mut self,
|
|
app_handle: tauri::AppHandle,
|
|
preferred_port: u16,
|
|
) -> Result<u16, String> {
|
|
// Stop existing server if running
|
|
self.stop().await.ok();
|
|
|
|
let (shutdown_tx, mut shutdown_rx) = mpsc::channel(1);
|
|
let state = ApiServerState {
|
|
app_handle: app_handle.clone(),
|
|
};
|
|
|
|
// Try preferred port first, then random port
|
|
let listener = match TcpListener::bind(format!("127.0.0.1:{preferred_port}")).await {
|
|
Ok(listener) => listener,
|
|
Err(_) => {
|
|
// Port conflict, try random port
|
|
let random_port = rand::random::<u16>().saturating_add(10000);
|
|
match TcpListener::bind(format!("127.0.0.1:{random_port}")).await {
|
|
Ok(listener) => {
|
|
let _ = events::emit(
|
|
"api-port-conflict",
|
|
format!("API server using fallback port {random_port}"),
|
|
);
|
|
listener
|
|
}
|
|
Err(e) => {
|
|
return Err(crate::backend_error_with_detail("API_PORT_UNAVAILABLE", e));
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
let actual_port = listener
|
|
.local_addr()
|
|
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?
|
|
.port();
|
|
|
|
let v1_routes = build_v1_router();
|
|
|
|
let api = ApiDoc::openapi();
|
|
|
|
let v1_routes = v1_routes
|
|
// Innermost so only authenticated automation requests consume quota.
|
|
.layer(middleware::from_fn(rate_limit_middleware))
|
|
.layer(middleware::from_fn_with_state(
|
|
state.clone(),
|
|
auth_middleware,
|
|
))
|
|
.layer(middleware::from_fn(terms_check_middleware));
|
|
|
|
let api_for_v1 = api.clone();
|
|
let app = Router::new()
|
|
.merge(v1_routes)
|
|
.route("/openapi.json", get(move || async move { Json(api) }))
|
|
.route(
|
|
"/v1/openapi.json",
|
|
get(move || async move { Json(api_for_v1) }),
|
|
)
|
|
// Outermost layer: logs every request so customer reports show what
|
|
// their automation is actually calling, what the response status was,
|
|
// and how long it took. Never logs request bodies or auth headers.
|
|
.layer(middleware::from_fn(request_logging_middleware))
|
|
.layer(CorsLayer::permissive())
|
|
.with_state(state);
|
|
|
|
// Start server task
|
|
let task_handle = tokio::spawn(async move {
|
|
let server = axum::serve(listener, app);
|
|
tokio::select! {
|
|
_ = server => {},
|
|
_ = shutdown_rx.recv() => {},
|
|
}
|
|
});
|
|
|
|
self.port = Some(actual_port);
|
|
self.shutdown_tx = Some(shutdown_tx);
|
|
self.task_handle = Some(task_handle);
|
|
|
|
Ok(actual_port)
|
|
}
|
|
|
|
async fn stop(&mut self) -> Result<(), String> {
|
|
if let Some(shutdown_tx) = self.shutdown_tx.take() {
|
|
let _ = shutdown_tx.send(()).await;
|
|
}
|
|
|
|
if let Some(handle) = self.task_handle.take() {
|
|
handle.abort();
|
|
}
|
|
|
|
self.port = None;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Register every `/v1` handler.
|
|
///
|
|
/// Pulled out of `start` so a test can build it. Axum panics when two handlers
|
|
/// claim the same path, and until this was callable the only thing that
|
|
/// exercised it was starting the real server — a conflict introduced here
|
|
/// would have shipped as an app that dies the moment the API is switched on.
|
|
///
|
|
/// The OpenAPI half of `split_for_parts` is discarded on purpose: the served
|
|
/// spec comes from the hand-maintained `ApiDoc`, which is why
|
|
/// `openapi_spec_covers_registered_routes` exists.
|
|
fn build_v1_router() -> Router<ApiServerState> {
|
|
let (routes, _) = OpenApiRouter::new()
|
|
.routes(routes!(get_profiles, create_profile))
|
|
.routes(routes!(get_profile, update_profile, delete_profile))
|
|
.routes(routes!(run_profile))
|
|
.routes(routes!(run_profile_remote))
|
|
// One `routes!` per PATH, not per handler: the GET and the DELETE share
|
|
// `/v1/remote-sessions/{id}`, and registering them separately would have
|
|
// the second overwrite the first.
|
|
.routes(routes!(get_remote_session_api, stop_remote_session))
|
|
.routes(routes!(remote_session_cdp))
|
|
.routes(routes!(list_remote_sessions_api))
|
|
.routes(routes!(get_remote_hours))
|
|
.routes(routes!(set_profile_cloud_sync))
|
|
.routes(routes!(list_cookie_bot_schedules))
|
|
.routes(routes!(
|
|
get_cookie_bot_schedule,
|
|
set_cookie_bot_schedule,
|
|
delete_cookie_bot_schedule
|
|
))
|
|
.routes(routes!(get_cookie_bot_conflicts))
|
|
.routes(routes!(list_cookie_bot_runs, start_cookie_bot_run))
|
|
.routes(routes!(cancel_cookie_bot_run))
|
|
.routes(routes!(list_cookie_bot_presets))
|
|
.routes(routes!(get_cookie_bot_usage))
|
|
.routes(routes!(open_url_in_profile))
|
|
.routes(routes!(kill_profile))
|
|
.routes(routes!(batch_run_profiles))
|
|
.routes(routes!(batch_stop_profiles))
|
|
.routes(routes!(detect_import_profiles))
|
|
.routes(routes!(import_profiles_api))
|
|
.routes(routes!(import_profile_cookies))
|
|
.routes(routes!(get_groups, create_group))
|
|
.routes(routes!(get_group, update_group, delete_group))
|
|
.routes(routes!(get_tags))
|
|
.routes(routes!(get_proxies, create_proxy))
|
|
.routes(routes!(import_proxies_api))
|
|
.routes(routes!(get_proxy, update_proxy, delete_proxy))
|
|
.routes(routes!(get_vpns, create_vpn))
|
|
.routes(routes!(import_vpn))
|
|
.routes(routes!(export_vpn))
|
|
.routes(routes!(get_vpn, update_vpn, delete_vpn))
|
|
.routes(routes!(get_extensions))
|
|
.routes(routes!(delete_extension_api))
|
|
.routes(routes!(get_extension_groups))
|
|
.routes(routes!(delete_extension_group_api))
|
|
.routes(routes!(download_browser_api))
|
|
.routes(routes!(get_browser_versions))
|
|
.routes(routes!(check_browser_downloaded))
|
|
.split_for_parts();
|
|
routes
|
|
}
|
|
|
|
// Terms and Conditions check middleware
|
|
async fn terms_check_middleware(
|
|
request: axum::extract::Request,
|
|
next: Next,
|
|
) -> Result<Response, StatusCode> {
|
|
// Check if Wayfern terms have been accepted
|
|
if !crate::wayfern_terms::WayfernTermsManager::instance().is_terms_accepted() {
|
|
return Err(StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
Ok(next.run(request).await)
|
|
}
|
|
|
|
// Authentication middleware
|
|
async fn auth_middleware(
|
|
State(state): State<ApiServerState>,
|
|
headers: HeaderMap,
|
|
request: axum::extract::Request,
|
|
next: Next,
|
|
) -> Result<Response, StatusCode> {
|
|
let path = request.uri().path().to_string();
|
|
|
|
// Get the Authorization header
|
|
let auth_header = headers
|
|
.get("Authorization")
|
|
.and_then(|h| h.to_str().ok())
|
|
.and_then(|h| h.strip_prefix("Bearer "));
|
|
|
|
let token = match auth_header {
|
|
Some(token) => token,
|
|
None => {
|
|
log::warn!("[api] Rejected {path}: missing Authorization header");
|
|
return Err(StatusCode::UNAUTHORIZED);
|
|
}
|
|
};
|
|
|
|
// Get the stored token
|
|
let settings_manager = crate::settings_manager::SettingsManager::instance();
|
|
let stored_token = match settings_manager.get_api_token(&state.app_handle).await {
|
|
Ok(Some(stored_token)) => stored_token,
|
|
Ok(None) => {
|
|
log::warn!(
|
|
"[api] Rejected {path}: API server has no stored token (was the API toggled off?)"
|
|
);
|
|
return Err(StatusCode::UNAUTHORIZED);
|
|
}
|
|
Err(e) => {
|
|
log::error!("[api] Failed to read stored API token: {e}");
|
|
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
|
}
|
|
};
|
|
|
|
// Constant-time comparison so the auth check doesn't leak the shared-prefix
|
|
// length via timing. `ConstantTimeEq` on equal-length byte slices; differing
|
|
// lengths simply compare unequal.
|
|
use subtle::ConstantTimeEq;
|
|
let token_bytes = token.as_bytes();
|
|
let stored_bytes = stored_token.as_bytes();
|
|
let matches = token_bytes.len() == stored_bytes.len() && token_bytes.ct_eq(stored_bytes).into();
|
|
if !matches {
|
|
log::warn!("[api] Rejected {path}: token mismatch");
|
|
return Err(StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
// Token is valid, continue with the request
|
|
Ok(next.run(request).await)
|
|
}
|
|
|
|
/// Logs every request: method, path, query, response status, duration.
|
|
/// Skips Authorization header and request bodies entirely.
|
|
async fn request_logging_middleware(request: axum::extract::Request, next: Next) -> Response {
|
|
let method = request.method().clone();
|
|
let path = request.uri().path().to_string();
|
|
let query = request.uri().query().map(|q| q.to_string());
|
|
let started = std::time::Instant::now();
|
|
|
|
let response = next.run(request).await;
|
|
|
|
let status = response.status();
|
|
let elapsed_ms = started.elapsed().as_millis();
|
|
|
|
let level = if status.is_server_error() {
|
|
log::Level::Error
|
|
} else if status.is_client_error() {
|
|
log::Level::Warn
|
|
} else {
|
|
log::Level::Info
|
|
};
|
|
|
|
match query {
|
|
Some(q) => log::log!(
|
|
level,
|
|
"[api] {method} {path}?{q} -> {status} ({elapsed_ms} ms)"
|
|
),
|
|
None => log::log!(level, "[api] {method} {path} -> {status} ({elapsed_ms} ms)"),
|
|
}
|
|
|
|
response
|
|
}
|
|
|
|
fn is_automation_request(method: &Method, path: &str) -> bool {
|
|
// Ending a remote session is the one automation action that is not a POST.
|
|
// Its handler declares a 429, which could never fire while this function
|
|
// returned early for every non-POST method.
|
|
//
|
|
// Cancelling a cookie-bot run joins it: both reach across to the fleet, and
|
|
// treating one stop as metered and the other as free would be arbitrary.
|
|
// Note that the desktop's own stop button goes through a Tauri command, not
|
|
// this server, so a human can always stop a run the limiter has cut off.
|
|
if method == Method::DELETE {
|
|
let mut segments = match path
|
|
.strip_prefix("/v1/remote-sessions/")
|
|
.or_else(|| path.strip_prefix("/v1/cookie-bot/runs/"))
|
|
{
|
|
Some(rest) => rest.split('/'),
|
|
None => return false,
|
|
};
|
|
return matches!((segments.next(), segments.next()), (Some(id), None) if !id.is_empty());
|
|
}
|
|
|
|
if method != Method::POST {
|
|
return false;
|
|
}
|
|
|
|
// Starting a bot run leases a host for up to two hours and spends the
|
|
// account's pooled remote-hour budget, which makes it the single most
|
|
// expensive thing this API can be asked to do.
|
|
//
|
|
// Deliberately NOT here: the cookie-bot schedule writes (PUT and DELETE on
|
|
// /v1/cookie-bot/schedules/{profile_id}). They are configuration — a small
|
|
// row in donutbrowser-infra — and lease nothing. Metering them would 429 a
|
|
// client enrolling a fleet of profiles at start-up, while the thing that
|
|
// actually protects the hardware, the pooled hour budget, is enforced
|
|
// server-side on every run whether or not it was scheduled from here.
|
|
if matches!(
|
|
path,
|
|
"/v1/profiles/batch/run" | "/v1/profiles/batch/stop" | "/v1/cookie-bot/runs"
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
let Some(profile_action) = path.strip_prefix("/v1/profiles/") else {
|
|
return false;
|
|
};
|
|
let mut segments = profile_action.split('/');
|
|
matches!(
|
|
(segments.next(), segments.next(), segments.next()),
|
|
// `run-remote` is a separate segment from `run`, so it matched nothing here
|
|
// and every remote launch bypassed the quota it declares a 429 for.
|
|
(
|
|
Some(_),
|
|
Some("run" | "open-url" | "kill" | "run-remote"),
|
|
None
|
|
)
|
|
)
|
|
}
|
|
|
|
async fn rate_limit_middleware(request: axum::extract::Request, next: Next) -> Response {
|
|
if !is_automation_request(request.method(), request.uri().path()) {
|
|
return next.run(request).await;
|
|
}
|
|
|
|
match crate::automation_rate_limiter::check_automation_rate_limit().await {
|
|
crate::automation_rate_limiter::RateLimitOutcome::Limited { retry_after_secs } => {
|
|
log::warn!(
|
|
"[api] Rejected {}: automation rate limit exceeded; retry in {}s",
|
|
request.uri().path(),
|
|
retry_after_secs
|
|
);
|
|
(
|
|
StatusCode::TOO_MANY_REQUESTS,
|
|
[(header::RETRY_AFTER, retry_after_secs.to_string())],
|
|
"automation request rate limit exceeded",
|
|
)
|
|
.into_response()
|
|
}
|
|
crate::automation_rate_limiter::RateLimitOutcome::Unlimited
|
|
| crate::automation_rate_limiter::RateLimitOutcome::Allowed { .. } => next.run(request).await,
|
|
}
|
|
}
|
|
|
|
// Global API server instance
|
|
lazy_static! {
|
|
pub static ref API_SERVER: Arc<Mutex<ApiServer>> = Arc::new(Mutex::new(ApiServer::new()));
|
|
}
|
|
|
|
// Tauri commands
|
|
#[tauri::command]
|
|
pub async fn start_api_server_internal(
|
|
port: u16,
|
|
app_handle: &tauri::AppHandle,
|
|
) -> Result<u16, String> {
|
|
let mut server_guard = API_SERVER.lock().await;
|
|
server_guard.start(app_handle.clone(), port).await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn stop_api_server() -> Result<(), String> {
|
|
let mut server_guard = API_SERVER.lock().await;
|
|
server_guard.stop().await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn start_api_server(
|
|
port: Option<u16>,
|
|
app_handle: tauri::AppHandle,
|
|
) -> Result<u16, String> {
|
|
let actual_port = port.unwrap_or(10108);
|
|
start_api_server_internal(actual_port, &app_handle).await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_api_server_status() -> Result<Option<u16>, String> {
|
|
let server_guard = API_SERVER.lock().await;
|
|
Ok(server_guard.get_port())
|
|
}
|
|
|
|
// API Handlers - Profiles
|
|
/// Maps a manager-layer error onto a consistent HTTP status: 404 for missing
|
|
/// entities, 400 for validation/duplicate/client-input errors, 500 for
|
|
/// everything else (IO and other internal failures). The error text passes
|
|
/// through as the response body so API clients get a diagnostic instead of a
|
|
/// bare status code. Matching is on message content because the managers
|
|
/// return plain strings (some are the JSON `{"code": ...}` strings shared
|
|
/// with the Tauri commands).
|
|
/// Codes meaning "this profile is held by someone else right now".
|
|
///
|
|
/// Kept as one list so the REST layer, which has no other way to tell a refusal
|
|
/// apart from a validation failure, cannot drift from the guards that produce
|
|
/// them. `PROFILE_REMOTE_SYNC_PENDING` in particular is temporary by nature: the
|
|
/// pull that clears it is already running.
|
|
const LAUNCH_CONFLICT_CODES: [&str; 5] = [
|
|
"PROFILE_RUNNING",
|
|
"PROFILE_RUNNING_REMOTELY",
|
|
"PROFILE_REMOTE_SYNC_PENDING",
|
|
"PROFILE_LOCKED_BY_MEMBER",
|
|
"PROFILE_LOCKED_ELSEWHERE",
|
|
];
|
|
|
|
fn manager_error_response(err: impl std::fmt::Display) -> (StatusCode, String) {
|
|
let msg = err.to_string();
|
|
|
|
// Structured {"code": ...} errors from the shared managers classify exactly.
|
|
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&msg) {
|
|
if let Some(code) = value.get("code").and_then(|c| c.as_str()) {
|
|
let status = if code.ends_with("_NOT_FOUND") {
|
|
StatusCode::NOT_FOUND
|
|
} else if LAUNCH_CONFLICT_CODES.contains(&code) {
|
|
// Someone or something else holds this profile: another team member, a
|
|
// browser already open, or a remote session whose work has not been
|
|
// pulled back yet. All of them are "try again later", not "your request
|
|
// was malformed", and 400 would tell an automation client to give up.
|
|
StatusCode::CONFLICT
|
|
} else if code == "INTERNAL_ERROR" {
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
} else if code == "PROFILE_LOCK_UNAVAILABLE" {
|
|
// The lock service could not be reached. The launch is refused because
|
|
// it cannot be proven safe, which is an upstream failure, not the
|
|
// caller's fault.
|
|
StatusCode::SERVICE_UNAVAILABLE
|
|
} else if code.ends_with("_REQUIRES_PRO") || code.ends_with("_PAYMENT_REQUIRED") {
|
|
// Paid-feature gates (FINGERPRINT_REQUIRES_PRO, PROXY_PAYMENT_REQUIRED).
|
|
// Mapping them here lets the gate live in the shared manager instead of
|
|
// being re-implemented in each handler to get the status right.
|
|
StatusCode::PAYMENT_REQUIRED
|
|
} else {
|
|
// Validation-style codes (NAME_CANNOT_BE_EMPTY, GROUP_ALREADY_EXISTS,
|
|
// WAYFERN_VERSION_NOT_AVAILABLE, ...).
|
|
StatusCode::BAD_REQUEST
|
|
};
|
|
return (status, msg);
|
|
}
|
|
}
|
|
|
|
// Plain-text manager messages: match the known phrases narrowly so raw
|
|
// OS/serde/network error text (e.g. "invalid type: ..." from a corrupt
|
|
// store) falls through to 500 instead of masquerading as a client error.
|
|
let lower = msg.to_lowercase();
|
|
let status = if lower.contains("not found") {
|
|
StatusCode::NOT_FOUND
|
|
} else if lower.contains("already exists")
|
|
|| lower.contains("cannot set both")
|
|
|| lower.contains("cannot edit")
|
|
|| lower.contains("cannot delete")
|
|
|| lower.contains("cannot open url")
|
|
|| lower.contains("invalid browser")
|
|
|| lower.contains("invalid profile id")
|
|
|| lower.contains("unsupported browser")
|
|
|| lower.contains("not supported on your platform")
|
|
|| lower.contains("is not downloaded")
|
|
|| lower.contains("terms and conditions")
|
|
{
|
|
StatusCode::BAD_REQUEST
|
|
} else {
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
};
|
|
(status, msg)
|
|
}
|
|
|
|
/// Real per-group profile counts, computed from the profile list (the same
|
|
/// source of truth the GUI uses).
|
|
fn group_profile_counts() -> std::collections::HashMap<String, usize> {
|
|
let mut counts = std::collections::HashMap::new();
|
|
if let Ok(profiles) = ProfileManager::instance().list_profiles() {
|
|
for profile in profiles {
|
|
if let Some(group_id) = profile.group_id {
|
|
*counts.entry(group_id).or_insert(0) += 1;
|
|
}
|
|
}
|
|
}
|
|
counts
|
|
}
|
|
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/v1/profiles",
|
|
responses(
|
|
(status = 200, description = "List of all profiles", body = ApiProfilesResponse),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "profiles"
|
|
)]
|
|
async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
|
|
let profile_manager = ProfileManager::instance();
|
|
match profile_manager.list_profiles() {
|
|
Ok(profiles) => {
|
|
let api_profiles: Vec<ApiProfile> = profiles.iter().map(ApiProfile::from).collect();
|
|
|
|
Ok(Json(ApiProfilesResponse {
|
|
profiles: api_profiles,
|
|
total: profiles.len(),
|
|
}))
|
|
}
|
|
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
|
}
|
|
}
|
|
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/v1/profiles/{id}",
|
|
params(
|
|
("id" = String, Path, description = "Profile ID")
|
|
),
|
|
responses(
|
|
(status = 200, description = "Profile details", body = ApiProfileResponse),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 404, description = "Profile not found"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "profiles"
|
|
)]
|
|
async fn get_profile(
|
|
Path(id): Path<String>,
|
|
State(_state): State<ApiServerState>,
|
|
) -> Result<Json<ApiProfileResponse>, StatusCode> {
|
|
let profile_manager = ProfileManager::instance();
|
|
match profile_manager.list_profiles() {
|
|
Ok(profiles) => {
|
|
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == id) {
|
|
Ok(Json(ApiProfileResponse {
|
|
profile: ApiProfile::from(profile),
|
|
}))
|
|
} else {
|
|
Err(StatusCode::NOT_FOUND)
|
|
}
|
|
}
|
|
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
|
}
|
|
}
|
|
|
|
/// Create a profile.
|
|
///
|
|
/// - `browser` must be `"wayfern"`; any other value is rejected
|
|
/// with 400.
|
|
/// - `version` is optional: omit it or pass `"latest"` to use the newest
|
|
/// already-downloaded version of that browser. The version must be present
|
|
/// locally (this endpoint does not download new versions); 400 if none is.
|
|
/// - Omitting the matching `wayfern_config`, or passing an
|
|
/// empty object `{}`, generates a fresh fingerprint automatically.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/v1/profiles",
|
|
request_body = CreateProfileRequest,
|
|
responses(
|
|
(status = 200, description = "Profile created successfully", body = ApiProfileResponse),
|
|
(status = 400, description = "Invalid browser, or no downloaded version available"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 402, description = "Selected proxy requires payment"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "profiles"
|
|
)]
|
|
async fn create_profile(
|
|
State(state): State<ApiServerState>,
|
|
Json(request): Json<CreateProfileRequest>,
|
|
) -> Result<Json<ApiProfileResponse>, (StatusCode, String)> {
|
|
let profile_manager = ProfileManager::instance();
|
|
|
|
// Only Wayfern profiles are launchable; the rest of the system
|
|
// (fingerprint generation, launch, run) supports nothing else. Reject anything
|
|
// else up front — otherwise the profile is created with no fingerprint and an
|
|
// unrecognized browser, then crashes with a 500 on /run. Mirrors the MCP
|
|
// create_profile validation.
|
|
if request.browser != "wayfern" {
|
|
return Err((
|
|
StatusCode::BAD_REQUEST,
|
|
format!(
|
|
"Invalid browser \"{}\". Must be \"wayfern\" (anti-detect Chromium).",
|
|
request.browser
|
|
),
|
|
));
|
|
}
|
|
|
|
// Resolve the version. Omitted, empty, or "latest" means "newest version
|
|
// already downloaded for this browser". The create path generates the
|
|
// fingerprint by launching that binary, so the version must be present
|
|
// locally — we don't fetch new versions here. 400 if none is downloaded.
|
|
let version = match request.version.as_deref() {
|
|
Some(v) if !v.is_empty() && v != "latest" => v.to_string(),
|
|
_ => {
|
|
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
|
|
let mut versions = registry.get_downloaded_versions(&request.browser);
|
|
// browsers is a HashMap, so keys are unordered — sort newest-first by
|
|
// semver before taking the latest.
|
|
versions.sort_by(|a, b| crate::api_client::compare_versions(b, a));
|
|
match versions.into_iter().next() {
|
|
Some(v) => v,
|
|
None => {
|
|
return Err((
|
|
StatusCode::BAD_REQUEST,
|
|
format!(
|
|
"No downloaded version of \"{}\" is available. Download the browser in Donut Browser first — this endpoint does not download browsers.",
|
|
request.browser
|
|
),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Parse wayfern config if provided
|
|
let wayfern_config = if let Some(config) = &request.wayfern_config {
|
|
serde_json::from_value(config.clone()).ok()
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Reject a dead/unreachable proxy or VPN before creating the profile. A 402
|
|
// (expired proxy subscription) maps to 402; anything else is a 400.
|
|
if let Err(err) =
|
|
crate::validate_profile_network(request.proxy_id.as_deref(), request.vpn_id.as_deref()).await
|
|
{
|
|
return Err(if err.contains("PROXY_PAYMENT_REQUIRED") {
|
|
(
|
|
StatusCode::PAYMENT_REQUIRED,
|
|
"The selected proxy requires an active subscription.".to_string(),
|
|
)
|
|
} else {
|
|
(
|
|
StatusCode::BAD_REQUEST,
|
|
format!("Profile network validation failed: {err}"),
|
|
)
|
|
});
|
|
}
|
|
|
|
// Create profile using the async create_profile_with_group method
|
|
match profile_manager
|
|
.create_profile_with_group(
|
|
&state.app_handle,
|
|
&request.name,
|
|
&request.browser,
|
|
&version,
|
|
request.release_type.as_deref().unwrap_or("stable"),
|
|
request.proxy_id.clone(),
|
|
request.vpn_id.clone(),
|
|
wayfern_config,
|
|
request.group_id.clone(),
|
|
false,
|
|
None,
|
|
request.launch_hook.clone(),
|
|
)
|
|
.await
|
|
{
|
|
Ok(mut profile) => {
|
|
// Apply tags if provided
|
|
if let Some(tags) = &request.tags {
|
|
if profile_manager
|
|
.update_profile_tags(&state.app_handle, &profile.name, tags.clone())
|
|
.is_err()
|
|
{
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"Profile created but failed to apply tags.".to_string(),
|
|
));
|
|
}
|
|
profile.tags = tags.clone();
|
|
}
|
|
|
|
// Update tag manager with new tags
|
|
if let Ok(profiles) = profile_manager.list_profiles() {
|
|
let _ = crate::tag_manager::TAG_MANAGER
|
|
.lock()
|
|
.map(|manager| manager.rebuild_from_profiles(&profiles));
|
|
}
|
|
|
|
Ok(Json(ApiProfileResponse {
|
|
profile: ApiProfile::from(&profile),
|
|
}))
|
|
}
|
|
Err(e) => Err((
|
|
StatusCode::BAD_REQUEST,
|
|
format!("Failed to create profile: {e}"),
|
|
)),
|
|
}
|
|
}
|
|
|
|
#[utoipa::path(
|
|
put,
|
|
path = "/v1/profiles/{id}",
|
|
params(
|
|
("id" = String, Path, description = "Profile ID")
|
|
),
|
|
request_body = UpdateProfileRequest,
|
|
responses(
|
|
(status = 200, description = "Profile updated successfully", body = ApiProfileResponse),
|
|
(status = 400, description = "Bad request"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 404, description = "Profile not found"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "profiles"
|
|
)]
|
|
async fn update_profile(
|
|
Path(id): Path<String>,
|
|
State(state): State<ApiServerState>,
|
|
Json(request): Json<UpdateProfileRequest>,
|
|
) -> Result<Json<ApiProfileResponse>, (StatusCode, String)> {
|
|
let profile_manager = ProfileManager::instance();
|
|
|
|
if request.proxy_id.as_deref().is_some_and(|s| !s.is_empty())
|
|
&& request.vpn_id.as_deref().is_some_and(|s| !s.is_empty())
|
|
{
|
|
return Err((
|
|
StatusCode::BAD_REQUEST,
|
|
"Cannot set both proxy_id and vpn_id".to_string(),
|
|
));
|
|
}
|
|
|
|
// Update profile fields
|
|
if let Some(new_name) = request.name {
|
|
if let Err(e) = profile_manager.rename_profile(&state.app_handle, &id, &new_name) {
|
|
return Err(manager_error_response(e));
|
|
}
|
|
}
|
|
|
|
if let Some(version) = request.version {
|
|
if let Err(e) = profile_manager.update_profile_version(&state.app_handle, &id, &version) {
|
|
return Err(manager_error_response(e));
|
|
}
|
|
}
|
|
|
|
if let Some(proxy_id) = request.proxy_id {
|
|
if let Err(e) = profile_manager
|
|
.update_profile_proxy(state.app_handle.clone(), &id, Some(proxy_id))
|
|
.await
|
|
{
|
|
return Err(manager_error_response(e));
|
|
}
|
|
}
|
|
|
|
if let Some(vpn_id) = request.vpn_id {
|
|
let normalized = if vpn_id.is_empty() {
|
|
None
|
|
} else {
|
|
Some(vpn_id)
|
|
};
|
|
if let Err(e) = profile_manager
|
|
.update_profile_vpn(state.app_handle.clone(), &id, normalized)
|
|
.await
|
|
{
|
|
return Err(manager_error_response(e));
|
|
}
|
|
}
|
|
|
|
if let Some(launch_hook) = request.launch_hook {
|
|
let normalized = if launch_hook.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(launch_hook)
|
|
};
|
|
|
|
if let Err(e) = profile_manager.update_profile_launch_hook(&state.app_handle, &id, normalized) {
|
|
return Err(manager_error_response(e));
|
|
}
|
|
}
|
|
|
|
if let Some(group_id) = request.group_id {
|
|
if let Err(e) =
|
|
profile_manager.assign_profiles_to_group(&state.app_handle, vec![id.clone()], Some(group_id))
|
|
{
|
|
return Err(manager_error_response(e));
|
|
}
|
|
}
|
|
|
|
if let Some(tags) = request.tags {
|
|
if let Err(e) = profile_manager.update_profile_tags(&state.app_handle, &id, tags) {
|
|
return Err(manager_error_response(e));
|
|
}
|
|
|
|
// Update tag manager with new tags from all profiles
|
|
if let Ok(profiles) = profile_manager.list_profiles() {
|
|
let _ = crate::tag_manager::TAG_MANAGER
|
|
.lock()
|
|
.map(|manager| manager.rebuild_from_profiles(&profiles));
|
|
}
|
|
}
|
|
|
|
if let Some(extension_group_id) = request.extension_group_id {
|
|
let ext_group = if extension_group_id.is_empty() {
|
|
None
|
|
} else {
|
|
Some(extension_group_id)
|
|
};
|
|
if let Err(e) = profile_manager.update_profile_extension_group(&id, ext_group) {
|
|
return Err(manager_error_response(e));
|
|
}
|
|
}
|
|
|
|
if let Some(proxy_bypass_rules) = request.proxy_bypass_rules {
|
|
if let Err(e) =
|
|
profile_manager.update_profile_proxy_bypass_rules(&state.app_handle, &id, proxy_bypass_rules)
|
|
{
|
|
return Err(manager_error_response(e));
|
|
}
|
|
}
|
|
|
|
if let Some(sync_mode) = request.sync_mode {
|
|
if let Err(e) =
|
|
crate::sync::set_profile_sync_mode(state.app_handle.clone(), id.clone(), sync_mode).await
|
|
{
|
|
return Err(manager_error_response(e));
|
|
}
|
|
}
|
|
|
|
if let Some(clear_on_close) = request.clear_on_close {
|
|
if let Err(e) =
|
|
profile_manager.update_profile_clear_on_close(&state.app_handle, &id, clear_on_close)
|
|
{
|
|
return Err(manager_error_response(e));
|
|
}
|
|
}
|
|
|
|
// Return updated profile
|
|
get_profile(Path(id), State(state))
|
|
.await
|
|
.map_err(|status| (status, String::new()))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
delete,
|
|
path = "/v1/profiles/{id}",
|
|
params(
|
|
("id" = String, Path, description = "Profile ID")
|
|
),
|
|
responses(
|
|
(status = 204, description = "Profile deleted successfully"),
|
|
(status = 400, description = "Bad request"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 404, description = "Profile not found"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "profiles"
|
|
)]
|
|
async fn delete_profile(
|
|
Path(id): Path<String>,
|
|
State(state): State<ApiServerState>,
|
|
) -> Result<StatusCode, (StatusCode, String)> {
|
|
let profile_manager = ProfileManager::instance();
|
|
match profile_manager.delete_profile(&state.app_handle, &id) {
|
|
Ok(_) => Ok(StatusCode::NO_CONTENT),
|
|
Err(e) => Err(manager_error_response(e)),
|
|
}
|
|
}
|
|
|
|
// API Handlers - Groups
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/v1/groups",
|
|
responses(
|
|
(status = 200, description = "List of all groups", body = Vec<ApiGroupResponse>),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "groups"
|
|
)]
|
|
async fn get_groups(
|
|
State(_state): State<ApiServerState>,
|
|
) -> Result<Json<Vec<ApiGroupResponse>>, StatusCode> {
|
|
match GROUP_MANAGER.lock() {
|
|
Ok(manager) => match manager.get_all_groups() {
|
|
Ok(groups) => {
|
|
let counts = group_profile_counts();
|
|
let api_groups = groups
|
|
.into_iter()
|
|
.map(|group| ApiGroupResponse {
|
|
profile_count: counts.get(&group.id).copied().unwrap_or(0),
|
|
id: group.id,
|
|
name: group.name,
|
|
})
|
|
.collect();
|
|
Ok(Json(api_groups))
|
|
}
|
|
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
|
},
|
|
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
|
}
|
|
}
|
|
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/v1/groups/{id}",
|
|
params(
|
|
("id" = String, Path, description = "Group ID")
|
|
),
|
|
responses(
|
|
(status = 200, description = "Group details", body = ApiGroupResponse),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 404, description = "Group not found"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "groups"
|
|
)]
|
|
async fn get_group(
|
|
Path(id): Path<String>,
|
|
State(_state): State<ApiServerState>,
|
|
) -> Result<Json<ApiGroupResponse>, StatusCode> {
|
|
match GROUP_MANAGER.lock() {
|
|
Ok(manager) => match manager.get_all_groups() {
|
|
Ok(groups) => {
|
|
if let Some(group) = groups.into_iter().find(|g| g.id == id) {
|
|
Ok(Json(ApiGroupResponse {
|
|
profile_count: group_profile_counts().get(&group.id).copied().unwrap_or(0),
|
|
id: group.id,
|
|
name: group.name,
|
|
}))
|
|
} else {
|
|
Err(StatusCode::NOT_FOUND)
|
|
}
|
|
}
|
|
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
|
},
|
|
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
|
}
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/v1/groups",
|
|
request_body = CreateGroupRequest,
|
|
responses(
|
|
(status = 200, description = "Group created successfully", body = ApiGroupResponse),
|
|
(status = 400, description = "Bad request"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "groups"
|
|
)]
|
|
async fn create_group(
|
|
State(state): State<ApiServerState>,
|
|
Json(request): Json<CreateGroupRequest>,
|
|
) -> Result<Json<ApiGroupResponse>, (StatusCode, String)> {
|
|
match GROUP_MANAGER.lock() {
|
|
Ok(manager) => match manager.create_group(&state.app_handle, request.name) {
|
|
Ok(group) => Ok(Json(ApiGroupResponse {
|
|
id: group.id,
|
|
name: group.name,
|
|
profile_count: 0,
|
|
})),
|
|
Err(e) => Err(manager_error_response(e)),
|
|
},
|
|
Err(_) => Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"group manager unavailable".to_string(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
#[utoipa::path(
|
|
put,
|
|
path = "/v1/groups/{id}",
|
|
params(
|
|
("id" = String, Path, description = "Group ID")
|
|
),
|
|
request_body = UpdateGroupRequest,
|
|
responses(
|
|
(status = 200, description = "Group updated successfully", body = ApiGroupResponse),
|
|
(status = 400, description = "Bad request"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 404, description = "Group not found"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "groups"
|
|
)]
|
|
async fn update_group(
|
|
Path(id): Path<String>,
|
|
State(state): State<ApiServerState>,
|
|
Json(request): Json<UpdateGroupRequest>,
|
|
) -> Result<Json<ApiGroupResponse>, (StatusCode, String)> {
|
|
match GROUP_MANAGER.lock() {
|
|
Ok(manager) => match manager.update_group(&state.app_handle, id.clone(), request.name) {
|
|
Ok(group) => Ok(Json(ApiGroupResponse {
|
|
profile_count: group_profile_counts().get(&group.id).copied().unwrap_or(0),
|
|
id: group.id,
|
|
name: group.name,
|
|
})),
|
|
Err(e) => Err(manager_error_response(e)),
|
|
},
|
|
Err(_) => Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"group manager unavailable".to_string(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
#[utoipa::path(
|
|
delete,
|
|
path = "/v1/groups/{id}",
|
|
params(
|
|
("id" = String, Path, description = "Group ID")
|
|
),
|
|
responses(
|
|
(status = 204, description = "Group deleted successfully"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 404, description = "Group not found"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "groups"
|
|
)]
|
|
async fn delete_group(
|
|
Path(id): Path<String>,
|
|
State(state): State<ApiServerState>,
|
|
) -> Result<StatusCode, (StatusCode, String)> {
|
|
match GROUP_MANAGER.lock() {
|
|
Ok(manager) => match manager.delete_group(&state.app_handle, id.clone()) {
|
|
Ok(_) => Ok(StatusCode::NO_CONTENT),
|
|
Err(e) => Err(manager_error_response(e)),
|
|
},
|
|
Err(_) => Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"group manager unavailable".to_string(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
// API Handlers - Tags
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/v1/tags",
|
|
responses(
|
|
(status = 200, description = "List of all tags", body = Vec<String>),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "tags"
|
|
)]
|
|
async fn get_tags(State(_state): State<ApiServerState>) -> Result<Json<Vec<String>>, StatusCode> {
|
|
match TAG_MANAGER.lock() {
|
|
Ok(manager) => match manager.get_all_tags() {
|
|
Ok(tags) => Ok(Json(tags)),
|
|
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
|
},
|
|
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
|
}
|
|
}
|
|
|
|
// API Handlers - Proxies
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/v1/proxies",
|
|
responses(
|
|
(status = 200, description = "List of all proxies", body = Vec<ApiProxyResponse>),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "proxies"
|
|
)]
|
|
async fn get_proxies(
|
|
State(_state): State<ApiServerState>,
|
|
) -> Result<Json<Vec<ApiProxyResponse>>, StatusCode> {
|
|
let proxies = PROXY_MANAGER.get_stored_proxies();
|
|
Ok(Json(
|
|
proxies
|
|
.into_iter()
|
|
.map(|p| ApiProxyResponse {
|
|
id: p.id,
|
|
name: p.name,
|
|
proxy_settings: p.proxy_settings,
|
|
})
|
|
.collect(),
|
|
))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/v1/proxies/{id}",
|
|
params(
|
|
("id" = String, Path, description = "Proxy ID")
|
|
),
|
|
responses(
|
|
(status = 200, description = "Proxy details", body = ApiProxyResponse),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 404, description = "Proxy not found"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "proxies"
|
|
)]
|
|
async fn get_proxy(
|
|
Path(id): Path<String>,
|
|
State(_state): State<ApiServerState>,
|
|
) -> Result<Json<ApiProxyResponse>, StatusCode> {
|
|
let proxies = PROXY_MANAGER.get_stored_proxies();
|
|
if let Some(proxy) = proxies.into_iter().find(|p| p.id == id) {
|
|
Ok(Json(ApiProxyResponse {
|
|
id: proxy.id,
|
|
name: proxy.name,
|
|
proxy_settings: proxy.proxy_settings,
|
|
}))
|
|
} else {
|
|
Err(StatusCode::NOT_FOUND)
|
|
}
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/v1/proxies",
|
|
request_body = CreateProxyRequest,
|
|
responses(
|
|
(status = 200, description = "Proxy created successfully", body = ApiProxyResponse),
|
|
(status = 400, description = "Bad request"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "proxies"
|
|
)]
|
|
async fn create_proxy(
|
|
State(state): State<ApiServerState>,
|
|
Json(request): Json<CreateProxyRequest>,
|
|
) -> Result<Json<ApiProxyResponse>, (StatusCode, String)> {
|
|
let result = PROXY_MANAGER.create_stored_proxy(
|
|
&state.app_handle,
|
|
request.name.clone(),
|
|
request.proxy_settings,
|
|
);
|
|
|
|
match result {
|
|
Ok(proxy) => Ok(Json(ApiProxyResponse {
|
|
id: proxy.id,
|
|
name: proxy.name,
|
|
proxy_settings: proxy.proxy_settings,
|
|
})),
|
|
Err(e) => Err(manager_error_response(e)),
|
|
}
|
|
}
|
|
|
|
// API Handler - Bulk-import proxies from a txt list or a Donut JSON export.
|
|
// Mirrors the MCP `import_proxies` tool.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/v1/proxies/import",
|
|
request_body = ImportProxiesRequest,
|
|
responses(
|
|
(status = 200, description = "Import completed; inspect counts and per-proxy errors", body = ImportProxiesResponse),
|
|
(status = 400, description = "Invalid format or no valid proxies in content"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "proxies"
|
|
)]
|
|
async fn import_proxies_api(
|
|
State(state): State<ApiServerState>,
|
|
Json(request): Json<ImportProxiesRequest>,
|
|
) -> Result<Json<ImportProxiesResponse>, (StatusCode, String)> {
|
|
let result = match request.format.as_str() {
|
|
"json" => PROXY_MANAGER
|
|
.import_proxies_json(&state.app_handle, &request.content)
|
|
.map_err(manager_error_response)?,
|
|
"txt" => {
|
|
use crate::proxy_manager::{ProxyManager, ProxyParseResult};
|
|
|
|
let parsed: Vec<_> = ProxyManager::parse_txt_proxies(&request.content)
|
|
.into_iter()
|
|
.filter_map(|r| match r {
|
|
ProxyParseResult::Parsed(p) => Some(p),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
|
|
if parsed.is_empty() {
|
|
return Err((
|
|
StatusCode::BAD_REQUEST,
|
|
"No valid proxies found in content".to_string(),
|
|
));
|
|
}
|
|
|
|
PROXY_MANAGER
|
|
.import_proxies_from_parsed(&state.app_handle, parsed, request.name_prefix)
|
|
.map_err(manager_error_response)?
|
|
}
|
|
other => {
|
|
return Err((
|
|
StatusCode::BAD_REQUEST,
|
|
format!("Invalid format \"{other}\", must be \"json\" or \"txt\""),
|
|
))
|
|
}
|
|
};
|
|
|
|
Ok(Json(ImportProxiesResponse {
|
|
imported_count: result.imported_count,
|
|
skipped_count: result.skipped_count,
|
|
errors: result.errors,
|
|
proxies: result
|
|
.proxies
|
|
.into_iter()
|
|
.map(|p| ApiProxyResponse {
|
|
id: p.id,
|
|
name: p.name,
|
|
proxy_settings: p.proxy_settings,
|
|
})
|
|
.collect(),
|
|
}))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
put,
|
|
path = "/v1/proxies/{id}",
|
|
params(
|
|
("id" = String, Path, description = "Proxy ID")
|
|
),
|
|
request_body = UpdateProxyRequest,
|
|
responses(
|
|
(status = 200, description = "Proxy updated successfully", body = ApiProxyResponse),
|
|
(status = 400, description = "Bad request"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 404, description = "Proxy not found"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "proxies"
|
|
)]
|
|
async fn update_proxy(
|
|
Path(id): Path<String>,
|
|
State(state): State<ApiServerState>,
|
|
Json(request): Json<UpdateProxyRequest>,
|
|
) -> Result<Json<ApiProxyResponse>, (StatusCode, String)> {
|
|
let result =
|
|
PROXY_MANAGER.update_stored_proxy(&state.app_handle, &id, request.name, request.proxy_settings);
|
|
|
|
match result {
|
|
Ok(proxy) => Ok(Json(ApiProxyResponse {
|
|
id: proxy.id,
|
|
name: proxy.name,
|
|
proxy_settings: proxy.proxy_settings,
|
|
})),
|
|
Err(e) => Err(manager_error_response(e)),
|
|
}
|
|
}
|
|
|
|
#[utoipa::path(
|
|
delete,
|
|
path = "/v1/proxies/{id}",
|
|
params(
|
|
("id" = String, Path, description = "Proxy ID")
|
|
),
|
|
responses(
|
|
(status = 204, description = "Proxy deleted successfully"),
|
|
(status = 400, description = "Bad request (e.g. cloud-managed proxy)"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 404, description = "Proxy not found"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "proxies"
|
|
)]
|
|
async fn delete_proxy(
|
|
Path(id): Path<String>,
|
|
State(state): State<ApiServerState>,
|
|
) -> Result<StatusCode, (StatusCode, String)> {
|
|
match PROXY_MANAGER.delete_stored_proxy(&state.app_handle, &id) {
|
|
Ok(_) => Ok(StatusCode::NO_CONTENT),
|
|
Err(e) => Err(manager_error_response(e)),
|
|
}
|
|
}
|
|
|
|
// API Handlers - VPNs
|
|
|
|
fn vpn_to_api_response(c: &crate::vpn::VpnConfig) -> ApiVpnResponse {
|
|
ApiVpnResponse {
|
|
id: c.id.clone(),
|
|
name: c.name.clone(),
|
|
vpn_type: c.vpn_type.to_string(),
|
|
created_at: c.created_at,
|
|
last_used: c.last_used,
|
|
}
|
|
}
|
|
|
|
fn parse_vpn_type(s: &str) -> Option<crate::vpn::VpnType> {
|
|
match s.to_ascii_lowercase().as_str() {
|
|
"wireguard" | "wg" => Some(crate::vpn::VpnType::WireGuard),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/v1/vpns",
|
|
responses(
|
|
(status = 200, description = "List of all VPN configurations", body = Vec<ApiVpnResponse>),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(("bearer_auth" = [])),
|
|
tag = "vpns"
|
|
)]
|
|
async fn get_vpns(
|
|
State(_state): State<ApiServerState>,
|
|
) -> Result<Json<Vec<ApiVpnResponse>>, StatusCode> {
|
|
let storage = crate::vpn::VPN_STORAGE
|
|
.lock()
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
let configs = storage
|
|
.list_configs()
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
Ok(Json(configs.iter().map(vpn_to_api_response).collect()))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/v1/vpns/{id}",
|
|
params(("id" = String, Path, description = "VPN configuration ID")),
|
|
responses(
|
|
(status = 200, description = "VPN configuration details", body = ApiVpnResponse),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 404, description = "VPN configuration not found"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(("bearer_auth" = [])),
|
|
tag = "vpns"
|
|
)]
|
|
async fn get_vpn(
|
|
Path(id): Path<String>,
|
|
State(_state): State<ApiServerState>,
|
|
) -> Result<Json<ApiVpnResponse>, StatusCode> {
|
|
let storage = crate::vpn::VPN_STORAGE
|
|
.lock()
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
let configs = storage
|
|
.list_configs()
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
configs
|
|
.iter()
|
|
.find(|c| c.id == id)
|
|
.map(|c| Json(vpn_to_api_response(c)))
|
|
.ok_or(StatusCode::NOT_FOUND)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/v1/vpns/{id}/export",
|
|
params(("id" = String, Path, description = "VPN configuration ID")),
|
|
responses(
|
|
(status = 200, description = "Decrypted VPN configuration", body = ApiVpnExportResponse),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 404, description = "VPN configuration not found"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(("bearer_auth" = [])),
|
|
tag = "vpns"
|
|
)]
|
|
async fn export_vpn(
|
|
Path(id): Path<String>,
|
|
State(_state): State<ApiServerState>,
|
|
) -> Result<Json<ApiVpnExportResponse>, StatusCode> {
|
|
let storage = crate::vpn::VPN_STORAGE
|
|
.lock()
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
match storage.load_config(&id) {
|
|
Ok(config) => Ok(Json(ApiVpnExportResponse {
|
|
id: config.id,
|
|
name: config.name,
|
|
vpn_type: config.vpn_type.to_string(),
|
|
config_data: config.config_data,
|
|
})),
|
|
Err(_) => Err(StatusCode::NOT_FOUND),
|
|
}
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/v1/vpns/import",
|
|
request_body = ImportVpnRequest,
|
|
responses(
|
|
(status = 200, description = "VPN configuration imported successfully", body = ApiVpnResponse),
|
|
(status = 400, description = "Invalid or unrecognized VPN config"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(("bearer_auth" = [])),
|
|
tag = "vpns"
|
|
)]
|
|
async fn import_vpn(
|
|
State(_state): State<ApiServerState>,
|
|
Json(request): Json<ImportVpnRequest>,
|
|
) -> Result<Json<ApiVpnResponse>, StatusCode> {
|
|
let result = {
|
|
let storage = crate::vpn::VPN_STORAGE
|
|
.lock()
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
storage.import_config(&request.content, &request.filename, request.name)
|
|
};
|
|
match result {
|
|
Ok(config) => {
|
|
let _ = events::emit("vpn-configs-changed", ());
|
|
Ok(Json(vpn_to_api_response(&config)))
|
|
}
|
|
Err(_) => Err(StatusCode::BAD_REQUEST),
|
|
}
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/v1/vpns",
|
|
request_body = CreateVpnRequest,
|
|
responses(
|
|
(status = 200, description = "VPN configuration created successfully", body = ApiVpnResponse),
|
|
(status = 400, description = "Invalid VPN config or unknown vpn_type"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(("bearer_auth" = [])),
|
|
tag = "vpns"
|
|
)]
|
|
async fn create_vpn(
|
|
State(_state): State<ApiServerState>,
|
|
Json(request): Json<CreateVpnRequest>,
|
|
) -> Result<Json<ApiVpnResponse>, StatusCode> {
|
|
let vpn_type = parse_vpn_type(&request.vpn_type).ok_or(StatusCode::BAD_REQUEST)?;
|
|
let result = {
|
|
let storage = crate::vpn::VPN_STORAGE
|
|
.lock()
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
storage.create_config_manual(&request.name, vpn_type, &request.config_data)
|
|
};
|
|
match result {
|
|
Ok(config) => {
|
|
let _ = events::emit("vpn-configs-changed", ());
|
|
Ok(Json(vpn_to_api_response(&config)))
|
|
}
|
|
Err(_) => Err(StatusCode::BAD_REQUEST),
|
|
}
|
|
}
|
|
|
|
#[utoipa::path(
|
|
put,
|
|
path = "/v1/vpns/{id}",
|
|
params(("id" = String, Path, description = "VPN configuration ID")),
|
|
request_body = UpdateVpnRequest,
|
|
responses(
|
|
(status = 200, description = "VPN configuration updated successfully", body = ApiVpnResponse),
|
|
(status = 400, description = "Bad request"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 404, description = "VPN configuration not found"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(("bearer_auth" = [])),
|
|
tag = "vpns"
|
|
)]
|
|
async fn update_vpn(
|
|
Path(id): Path<String>,
|
|
State(_state): State<ApiServerState>,
|
|
Json(request): Json<UpdateVpnRequest>,
|
|
) -> Result<Json<ApiVpnResponse>, StatusCode> {
|
|
let result = {
|
|
let storage = crate::vpn::VPN_STORAGE
|
|
.lock()
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
storage.update_config_name(&id, &request.name)
|
|
};
|
|
match result {
|
|
Ok(config) => {
|
|
let _ = events::emit("vpn-configs-changed", ());
|
|
Ok(Json(vpn_to_api_response(&config)))
|
|
}
|
|
Err(_) => Err(StatusCode::NOT_FOUND),
|
|
}
|
|
}
|
|
|
|
#[utoipa::path(
|
|
delete,
|
|
path = "/v1/vpns/{id}",
|
|
params(("id" = String, Path, description = "VPN configuration ID")),
|
|
responses(
|
|
(status = 204, description = "VPN configuration deleted successfully"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 404, description = "VPN configuration not found"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(("bearer_auth" = [])),
|
|
tag = "vpns"
|
|
)]
|
|
async fn delete_vpn(
|
|
Path(id): Path<String>,
|
|
State(_state): State<ApiServerState>,
|
|
) -> Result<StatusCode, StatusCode> {
|
|
let _ = crate::vpn_worker_runner::stop_vpn_worker_by_vpn_id(&id).await;
|
|
|
|
let result = {
|
|
let storage = crate::vpn::VPN_STORAGE
|
|
.lock()
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
storage.delete_config(&id)
|
|
};
|
|
match result {
|
|
Ok(_) => {
|
|
let _ = events::emit("vpn-configs-changed", ());
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
Err(_) => Err(StatusCode::NOT_FOUND),
|
|
}
|
|
}
|
|
|
|
// Extension API endpoints
|
|
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/v1/extensions",
|
|
responses(
|
|
(status = 200, description = "List of extensions"),
|
|
(status = 401, description = "Unauthorized"),
|
|
),
|
|
security(("bearer_auth" = [])),
|
|
tag = "extensions"
|
|
)]
|
|
async fn get_extensions(
|
|
State(_state): State<ApiServerState>,
|
|
) -> Result<Json<Vec<crate::extension_manager::Extension>>, StatusCode> {
|
|
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
|
mgr
|
|
.list_extensions()
|
|
.map(Json)
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/v1/extension-groups",
|
|
responses(
|
|
(status = 200, description = "List of extension groups"),
|
|
(status = 401, description = "Unauthorized"),
|
|
),
|
|
security(("bearer_auth" = [])),
|
|
tag = "extensions"
|
|
)]
|
|
async fn get_extension_groups(
|
|
State(_state): State<ApiServerState>,
|
|
) -> Result<Json<Vec<crate::extension_manager::ExtensionGroup>>, StatusCode> {
|
|
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
|
mgr
|
|
.list_groups()
|
|
.map(Json)
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
delete,
|
|
path = "/v1/extensions/{id}",
|
|
params(("id" = String, Path, description = "Extension ID")),
|
|
responses(
|
|
(status = 204, description = "Extension deleted"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 404, description = "Extension not found"),
|
|
(status = 500, description = "Internal server error"),
|
|
),
|
|
security(("bearer_auth" = [])),
|
|
tag = "extensions"
|
|
)]
|
|
async fn delete_extension_api(
|
|
Path(id): Path<String>,
|
|
State(state): State<ApiServerState>,
|
|
) -> Result<StatusCode, (StatusCode, String)> {
|
|
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
|
mgr
|
|
.delete_extension(&state.app_handle, &id)
|
|
.map(|_| StatusCode::NO_CONTENT)
|
|
.map_err(manager_error_response)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
delete,
|
|
path = "/v1/extension-groups/{id}",
|
|
params(("id" = String, Path, description = "Extension Group ID")),
|
|
responses(
|
|
(status = 204, description = "Extension group deleted"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 404, description = "Extension group not found"),
|
|
(status = 500, description = "Internal server error"),
|
|
),
|
|
security(("bearer_auth" = [])),
|
|
tag = "extensions"
|
|
)]
|
|
async fn delete_extension_group_api(
|
|
Path(id): Path<String>,
|
|
State(state): State<ApiServerState>,
|
|
) -> Result<StatusCode, (StatusCode, String)> {
|
|
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
|
mgr
|
|
.delete_group(&state.app_handle, &id)
|
|
.map(|_| StatusCode::NO_CONTENT)
|
|
.map_err(manager_error_response)
|
|
}
|
|
|
|
// API Handler - Run Profile with Remote Debugging
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/v1/profiles/{id}/run",
|
|
params(
|
|
("id" = String, Path, description = "Profile ID")
|
|
),
|
|
request_body = RunProfileRequest,
|
|
responses(
|
|
(status = 200, description = "Profile launched successfully", body = RunProfileResponse),
|
|
(status = 400, description = "Cannot launch cross-OS profile"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 402, description = "Active paid plan with browser automation required"),
|
|
(status = 404, description = "Profile not found"),
|
|
(status = 409, description = "Profile is locked by another team member, running on the remote fleet, or waiting for a finished remote session to be pulled back"),
|
|
(status = 429, description = "Automation request rate limit exceeded"),
|
|
(status = 503, description = "The profile lock service could not be reached"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "profiles"
|
|
)]
|
|
async fn run_profile(
|
|
Path(id): Path<String>,
|
|
State(state): State<ApiServerState>,
|
|
Json(request): Json<RunProfileRequest>,
|
|
) -> Result<Json<RunProfileResponse>, (StatusCode, String)> {
|
|
if !crate::cloud_auth::CLOUD_AUTH
|
|
.can_use_browser_automation()
|
|
.await
|
|
{
|
|
return Err((StatusCode::PAYMENT_REQUIRED, String::new()));
|
|
}
|
|
|
|
let headless = request.headless.unwrap_or(false);
|
|
let url = request.url;
|
|
|
|
let profile_manager = ProfileManager::instance();
|
|
let profiles = profile_manager
|
|
.list_profiles()
|
|
.map_err(manager_error_response)?;
|
|
|
|
let profile = profiles
|
|
.iter()
|
|
.find(|p| p.id.to_string() == id)
|
|
.ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?;
|
|
|
|
if profile.is_cross_os() {
|
|
return Err((
|
|
StatusCode::BAD_REQUEST,
|
|
"cannot launch a cross-OS profile locally; use /run-remote".to_string(),
|
|
));
|
|
}
|
|
|
|
// Team lock check. Routed through the shared mapper so a profile held by the
|
|
// user's OWN remote session is a 409 that says so, rather than a bare status
|
|
// with no body, which is what an automation client had to guess from.
|
|
crate::team_lock::acquire_team_lock_if_needed(profile)
|
|
.await
|
|
.map_err(manager_error_response)?;
|
|
|
|
let remote_debugging_port = {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.map_err(manager_error_response)?;
|
|
let port = listener
|
|
.local_addr()
|
|
.map_err(manager_error_response)?
|
|
.port();
|
|
drop(listener);
|
|
port
|
|
};
|
|
|
|
// Use the same launch path as the main app, but force a fresh instance with
|
|
// remote debugging enabled so the returned port is the one the browser binds.
|
|
let updated_profile = crate::browser_runner::launch_browser_profile_impl(
|
|
state.app_handle.clone(),
|
|
profile.clone(),
|
|
url,
|
|
Some(remote_debugging_port),
|
|
headless,
|
|
true,
|
|
)
|
|
.await
|
|
.map_err(manager_error_response)?;
|
|
|
|
Ok(Json(RunProfileResponse {
|
|
profile_id: updated_profile.id.to_string(),
|
|
remote_debugging_port,
|
|
headless,
|
|
}))
|
|
}
|
|
|
|
// API Handler - Launch this profile on a REMOTE VM of its own operating system
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/v1/profiles/{id}/run-remote",
|
|
params(
|
|
("id" = String, Path, description = "Profile ID")
|
|
),
|
|
request_body = RunRemoteRequest,
|
|
responses(
|
|
(status = 200, description = "Remote session started", body = RunRemoteResponse),
|
|
(status = 400, description = "Profile does not have cloud sync enabled"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 402, description = "Active paid plan with browser automation required"),
|
|
(status = 404, description = "Profile not found"),
|
|
(status = 409, description = "Profile is locked by another session"),
|
|
(status = 429, description = "Automation request rate limit exceeded"),
|
|
(status = 503, description = "No remote capacity for this operating system"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "profiles"
|
|
)]
|
|
async fn run_profile_remote(
|
|
Path(id): Path<String>,
|
|
State(state): State<ApiServerState>,
|
|
Json(request): Json<RunRemoteRequest>,
|
|
) -> Result<Json<RunRemoteResponse>, (StatusCode, String)> {
|
|
if !crate::cloud_auth::CLOUD_AUTH
|
|
.can_use_browser_automation()
|
|
.await
|
|
{
|
|
return Err((StatusCode::PAYMENT_REQUIRED, String::new()));
|
|
}
|
|
|
|
let profile_manager = ProfileManager::instance();
|
|
let profiles = profile_manager
|
|
.list_profiles()
|
|
.map_err(manager_error_response)?;
|
|
let profile = profiles
|
|
.iter()
|
|
.find(|p| p.id.to_string() == id)
|
|
.ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?;
|
|
|
|
// The profile must exist in cloud storage before a remote host can open it —
|
|
// the VM pulls it from donut-sync, and a profile that has never synced would
|
|
// launch an empty browser and then push that emptiness back over the real one.
|
|
if let Err(reason) = remote_launch_precondition(profile).await {
|
|
return Err((StatusCode::BAD_REQUEST, reason));
|
|
}
|
|
|
|
// Deliberately NO is_cross_os() guard here. Local /run refuses a foreign
|
|
// profile because this machine is the wrong OS; running it remotely on a host
|
|
// of its OWN OS is precisely what this endpoint exists for.
|
|
let outcome =
|
|
crate::remote_session::start_remote_session(state.app_handle.clone(), profile, request.url)
|
|
.await
|
|
.map_err(remote_session_error_response)?;
|
|
|
|
Ok(Json(RunRemoteResponse {
|
|
profile_id: profile.id.to_string(),
|
|
session_id: outcome.session_id,
|
|
platform: outcome.platform,
|
|
status: outcome.status,
|
|
}))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/v1/profiles/{id}/cloud-sync",
|
|
params(
|
|
("id" = String, Path, description = "Profile ID")
|
|
),
|
|
request_body = SetCloudSyncRequest,
|
|
responses(
|
|
(status = 200, description = "Cloud sync mode updated", body = SetCloudSyncResponse),
|
|
(status = 400, description = "Invalid mode, or the profile cannot be synced"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 402, description = "Active paid plan with cloud backup required"),
|
|
(status = 404, description = "Profile not found"),
|
|
(status = 409, description = "Profile is running — stop it before enabling sync"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "profiles"
|
|
)]
|
|
async fn set_profile_cloud_sync(
|
|
Path(id): Path<String>,
|
|
State(state): State<ApiServerState>,
|
|
Json(request): Json<SetCloudSyncRequest>,
|
|
) -> Result<Json<SetCloudSyncResponse>, (StatusCode, String)> {
|
|
// Remote launch requires cloud sync, and until now sync could only be turned
|
|
// on from the GUI — so an automation-only caller could never reach the state
|
|
// that makes /run-remote work.
|
|
let mode = match request.mode.as_str() {
|
|
"Disabled" | "Regular" | "Encrypted" => request.mode.clone(),
|
|
other => {
|
|
return Err((
|
|
StatusCode::BAD_REQUEST,
|
|
format!("invalid sync mode {other:?}; expected Disabled, Regular or Encrypted"),
|
|
));
|
|
}
|
|
};
|
|
|
|
crate::sync::set_profile_sync_mode(state.app_handle.clone(), id.clone(), mode.clone())
|
|
.await
|
|
.map_err(sync_mode_error_response)?;
|
|
|
|
let profile_manager = ProfileManager::instance();
|
|
let profiles = profile_manager
|
|
.list_profiles()
|
|
.map_err(manager_error_response)?;
|
|
let profile = profiles
|
|
.iter()
|
|
.find(|p| p.id.to_string() == id)
|
|
.ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?;
|
|
|
|
// Reported rather than left for the caller to discover at launch time: the
|
|
// most common reason a caller enables sync is to run the profile remotely,
|
|
// and Encrypted mode silently makes that impossible.
|
|
let blocked = remote_launch_precondition(profile).await.err();
|
|
Ok(Json(SetCloudSyncResponse {
|
|
profile_id: profile.id.to_string(),
|
|
mode,
|
|
remote_launchable: blocked.is_none(),
|
|
remote_blocked_reason: blocked,
|
|
}))
|
|
}
|
|
|
|
/// Map a sync-mode failure onto the status the caller can act on.
|
|
///
|
|
/// `set_profile_sync_mode` reports a running profile as a JSON body rather than
|
|
/// a plain message, because enabling sync under a live browser would race the
|
|
/// browser's own writes.
|
|
fn sync_mode_error_response(err: String) -> (StatusCode, String) {
|
|
if err.contains("PROFILE_RUNNING") {
|
|
return (
|
|
StatusCode::CONFLICT,
|
|
"profile is running; stop it before changing cloud sync".to_string(),
|
|
);
|
|
}
|
|
if err.contains("cross-OS") || err.contains("ephemeral") {
|
|
return (StatusCode::BAD_REQUEST, err);
|
|
}
|
|
(StatusCode::INTERNAL_SERVER_ERROR, err)
|
|
}
|
|
|
|
/// Whether a profile may be launched on a remote host.
|
|
///
|
|
/// The one gate between "the user asked" and "a browser opens somewhere else
|
|
/// holding their cookies". Adds the live check the pure rules cannot make: a
|
|
/// launch that races this profile's own upload hands the host a torn snapshot.
|
|
pub async fn remote_launch_precondition(
|
|
profile: &crate::profile::types::BrowserProfile,
|
|
) -> Result<(), String> {
|
|
remote_launch_profile_rules(profile)?;
|
|
|
|
// The manifest is written last, so a host pulling mid-upload gets files
|
|
// that are about to be replaced and a manifest that does not describe them.
|
|
// The browser then comes up on a profile that never existed on this machine
|
|
// and pushes it back over the real one.
|
|
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
|
if scheduler
|
|
.is_profile_sync_in_progress(&profile.id.to_string())
|
|
.await
|
|
{
|
|
return Err(serde_json::json!({ "code": "REMOTE_SYNC_IN_PROGRESS" }).to_string());
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// The parts of the rule that depend only on the profile itself.
|
|
///
|
|
/// Split out so the rules stay unit-testable without a running app or a sync
|
|
/// scheduler, and so the live check above cannot be reached without them.
|
|
pub fn remote_launch_profile_rules(
|
|
profile: &crate::profile::types::BrowserProfile,
|
|
) -> Result<(), String> {
|
|
if !profile.is_sync_enabled() {
|
|
return Err(
|
|
"profile does not have cloud sync enabled; a remote host has no way to \
|
|
obtain it"
|
|
.to_string(),
|
|
);
|
|
}
|
|
if profile.is_encrypted_sync() {
|
|
// The key is derived from a passphrase that never leaves this machine, so
|
|
// the host would download ciphertext, launch Chromium on it, and push the
|
|
// corruption back over the user's real profile.
|
|
return Err(
|
|
"profile uses end-to-end encrypted sync; a remote host cannot decrypt \
|
|
it. Switch the profile to Regular sync to run it remotely."
|
|
.to_string(),
|
|
);
|
|
}
|
|
if profile.resolved_os().is_none() {
|
|
return Err(
|
|
"profile has no recorded operating system, so it cannot be scheduled \
|
|
onto a matching host"
|
|
.to_string(),
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// API Handler - Stop a REMOTE session started by run-remote
|
|
#[utoipa::path(
|
|
delete,
|
|
path = "/v1/remote-sessions/{id}",
|
|
params(
|
|
("id" = String, Path, description = "Remote session ID from run-remote")
|
|
),
|
|
responses(
|
|
(status = 200, description = "Remote session stopped", body = StopRemoteResponse),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 404, description = "No such remote session"),
|
|
(status = 429, description = "Automation request rate limit exceeded"),
|
|
(status = 503, description = "The fleet could not be reached; the session is still running"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "profiles"
|
|
)]
|
|
async fn stop_remote_session(
|
|
Path(id): Path<String>,
|
|
) -> Result<Json<StopRemoteResponse>, (StatusCode, String)> {
|
|
// Without this route, `run-remote` hands back a session id nothing can act
|
|
// on: the only thing that ends a session is the fleet's own two-hour cap, so
|
|
// every launch bills 7200s no matter how briefly it ran.
|
|
let outcome = crate::remote_session::end_remote_session(&id)
|
|
.await
|
|
.map_err(remote_session_error_response)?;
|
|
|
|
Ok(Json(StopRemoteResponse {
|
|
session_id: outcome.session_id,
|
|
status: outcome.status,
|
|
billed_seconds: outcome.billed_seconds,
|
|
}))
|
|
}
|
|
|
|
fn remote_session_error_response(
|
|
err: crate::remote_session::RemoteSessionError,
|
|
) -> (StatusCode, String) {
|
|
use crate::remote_session::RemoteSessionError;
|
|
match err {
|
|
RemoteSessionError::NoCapacity(m) => (StatusCode::SERVICE_UNAVAILABLE, m),
|
|
RemoteSessionError::Conflict(m) => (StatusCode::CONFLICT, m),
|
|
RemoteSessionError::NotAuthorised(m) => (StatusCode::PAYMENT_REQUIRED, m),
|
|
RemoteSessionError::Other(m) => (StatusCode::INTERNAL_SERVER_ERROR, m),
|
|
}
|
|
}
|
|
|
|
/// Map a read of remote state onto a status, and answer with a machine code.
|
|
///
|
|
/// Separate from `remote_session_error_response` on purpose. That one serves
|
|
/// the LAUNCH path, whose documented contract is a plain-English diagnostic and
|
|
/// whose only interesting failures are "busy", "already open" and "not on your
|
|
/// plan". A read has a different failure set — chiefly "no such session", which
|
|
/// the launch mapping would report as a 500 — and it is new, so it can answer
|
|
/// with the `{"code":…}` envelope from the start instead of English a client
|
|
/// would have to pattern-match.
|
|
fn remote_session_read_response(
|
|
err: crate::remote_session::RemoteSessionError,
|
|
) -> (StatusCode, String) {
|
|
use crate::remote_session::RemoteSessionError;
|
|
let upstream = match &err {
|
|
RemoteSessionError::NoCapacity(_) => 503,
|
|
RemoteSessionError::Conflict(_) => 409,
|
|
RemoteSessionError::NotAuthorised(_) => 403,
|
|
// The status was consumed on the way in; the code is recovered from the
|
|
// backend's own envelope instead.
|
|
RemoteSessionError::Other(_) => 0,
|
|
};
|
|
let body = err.to_error_json();
|
|
let status = cloud_failure_status(upstream, &error_code_of(&body));
|
|
(status, body)
|
|
}
|
|
|
|
fn cookie_bot_error_response(err: crate::cookie_bot::CookieBotError) -> (StatusCode, String) {
|
|
let status = cloud_failure_status(err.status(), err.code());
|
|
(status, err.to_error_json())
|
|
}
|
|
|
|
/// Read the machine code out of a `{"code":…}` body.
|
|
fn error_code_of(body: &str) -> String {
|
|
serde_json::from_str::<serde_json::Value>(body)
|
|
.ok()
|
|
.and_then(|value| {
|
|
value
|
|
.get("code")
|
|
.and_then(serde_json::Value::as_str)
|
|
.map(str::to_string)
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Turn a donutbrowser-infra failure into the status a local client can act on.
|
|
///
|
|
/// The upstream status is not echoed blindly. A 401 up there means THIS desktop
|
|
/// has no cloud session, which has nothing to do with the caller's own bearer
|
|
/// token — answering 401 would send an automation client off to rotate a token
|
|
/// that is perfectly good. Likewise the cloud's 403 covers two unrelated
|
|
/// things: "your plan does not include this", which is the 402 this API uses
|
|
/// everywhere else, and "you are not a member of that team", which no payment
|
|
/// fixes.
|
|
fn cloud_failure_status(upstream: u16, code: &str) -> StatusCode {
|
|
// The disambiguations first, because no status can express them.
|
|
if code == crate::cloud_errors::NOT_SIGNED_IN {
|
|
return StatusCode::FORBIDDEN;
|
|
}
|
|
if code.ends_with("NOT_ENTITLED") || code == "REMOTE_HOURS_EXHAUSTED" {
|
|
return StatusCode::PAYMENT_REQUIRED;
|
|
}
|
|
if code == crate::cloud_errors::RATE_LIMITED {
|
|
return StatusCode::TOO_MANY_REQUESTS;
|
|
}
|
|
if code == crate::cloud_errors::UNREACHABLE || code == crate::cloud_errors::NO_CAPACITY {
|
|
return StatusCode::SERVICE_UNAVAILABLE;
|
|
}
|
|
|
|
match upstream {
|
|
400 | 422 => StatusCode::BAD_REQUEST,
|
|
402 => StatusCode::PAYMENT_REQUIRED,
|
|
403 => StatusCode::FORBIDDEN,
|
|
404 => StatusCode::NOT_FOUND,
|
|
409 => StatusCode::CONFLICT,
|
|
429 => StatusCode::TOO_MANY_REQUESTS,
|
|
503 => StatusCode::SERVICE_UNAVAILABLE,
|
|
// Some transports keep only the body, so the status is gone by the time
|
|
// it gets here. Falling straight through to 500 would report every one of
|
|
// those as our fault, including "no such run".
|
|
_ => status_for_code(code),
|
|
}
|
|
}
|
|
|
|
/// The status a machine code implies when the HTTP status did not survive.
|
|
fn status_for_code(code: &str) -> StatusCode {
|
|
if code.ends_with("NOT_FOUND") || code == "COOKIE_BOT_NOT_ENROLLED" {
|
|
StatusCode::NOT_FOUND
|
|
} else if code.ends_with("CONFLICT")
|
|
|| code == "COOKIE_BOT_RUN_IN_PROGRESS"
|
|
|| code == "REMOTE_SYNC_IN_PROGRESS"
|
|
{
|
|
StatusCode::CONFLICT
|
|
} else if code.starts_with("COOKIE_BOT_INVALID")
|
|
|| code == "COOKIE_BOT_SITE_LIMIT"
|
|
|| code == "REMOTE_SESSION_REFUSED"
|
|
{
|
|
StatusCode::BAD_REQUEST
|
|
} else if code == "NOT_TEAM_MEMBER" {
|
|
StatusCode::FORBIDDEN
|
|
} else {
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
}
|
|
}
|
|
|
|
// API Handler - Attach a CDP client (Playwright, Puppeteer, chrome-remote-interface)
|
|
// to a remote session.
|
|
//
|
|
// This is what makes `run-remote` usable. Without it the endpoint hands back a
|
|
// session id that nothing outside this app can do anything with: the fleet's
|
|
// relay only accepts the user's Donut cloud credential, an automation client
|
|
// does not have one, and it must not be given one — an API token is scoped to
|
|
// "drive my browsers", not "act as my account".
|
|
//
|
|
// So the socket is opened here with the credential this process already holds
|
|
// and the frames are pumped verbatim in both directions. The caller presents
|
|
// the ordinary API bearer token and gets a browser-level CDP endpoint at
|
|
// `ws://127.0.0.1:<api port>/v1/remote-sessions/{id}/cdp`:
|
|
//
|
|
// const browser = await chromium.connectOverCDP({
|
|
// endpointURL: `ws://127.0.0.1:10108/v1/remote-sessions/${id}/cdp`,
|
|
// headers: { Authorization: `Bearer ${API_TOKEN}` },
|
|
// });
|
|
//
|
|
// Nothing is attached to a page first, deliberately: Playwright drives
|
|
// `Target.setAutoAttach` and builds its own session map, and a socket already
|
|
// bound to one page would hide every other target from it.
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/v1/remote-sessions/{id}/cdp",
|
|
params(
|
|
("id" = String, Path, description = "Remote session ID from run-remote")
|
|
),
|
|
responses(
|
|
(status = 101, description = "Switching Protocols; a browser-level CDP WebSocket follows"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 402, description = "Active paid plan with browser automation required"),
|
|
(status = 404, description = "No such remote session, or it is not attachable yet"),
|
|
(status = 502, description = "The relay could not be reached"),
|
|
(status = 426, description = "Not a WebSocket upgrade request")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "remote-sessions"
|
|
)]
|
|
async fn remote_session_cdp(
|
|
Path(id): Path<String>,
|
|
upgrade: WebSocketUpgrade,
|
|
) -> Result<Response, (StatusCode, String)> {
|
|
if !crate::cloud_auth::CLOUD_AUTH
|
|
.can_use_browser_automation()
|
|
.await
|
|
{
|
|
return Err((StatusCode::PAYMENT_REQUIRED, String::new()));
|
|
}
|
|
|
|
// Dialled BEFORE the upgrade is accepted, so a session that is not attachable
|
|
// is an HTTP status the client can read. Accepting the upgrade first would
|
|
// turn every such failure into a socket that opens and immediately closes,
|
|
// which is what a CDP client reports as "browser closed unexpectedly".
|
|
let upstream = crate::cdp_target::open_relay_socket(&id)
|
|
.await
|
|
.map_err(cdp_error_response)?;
|
|
|
|
Ok(
|
|
upgrade
|
|
.max_message_size(crate::cdp_target::MAX_RELAY_MESSAGE_BYTES)
|
|
.max_frame_size(crate::cdp_target::MAX_RELAY_MESSAGE_BYTES)
|
|
.on_upgrade(move |client| pump_cdp(id, client, upstream)),
|
|
)
|
|
}
|
|
|
|
fn cdp_error_response(err: crate::cdp_target::CdpError) -> (StatusCode, String) {
|
|
use crate::cdp_target::CdpError;
|
|
let status = match err {
|
|
CdpError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
|
|
// "Not drivable" covers a session that is still provisioning and one that
|
|
// is not the caller's. Both are 404 to a CDP client: there is no browser at
|
|
// this address right now.
|
|
CdpError::NotDrivable(_) => StatusCode::NOT_FOUND,
|
|
CdpError::Unreachable(_) => StatusCode::BAD_GATEWAY,
|
|
CdpError::Transport(_) | CdpError::Protocol(_) => StatusCode::BAD_GATEWAY,
|
|
};
|
|
(status, err.to_string())
|
|
}
|
|
|
|
/// Copy CDP frames between the local client and the fleet relay until either
|
|
/// side hangs up.
|
|
///
|
|
/// Verbatim in both directions. This proxy deliberately understands nothing
|
|
/// about CDP: a client that speaks a newer protocol, or a target type this
|
|
/// build has never heard of, must keep working without a Donut release.
|
|
async fn pump_cdp(session_id: String, client: WebSocket, upstream: crate::cdp_target::RelaySocket) {
|
|
use futures_util::{SinkExt, StreamExt};
|
|
use tokio_tungstenite::tungstenite::Message as RelayMessage;
|
|
|
|
let (mut client_tx, mut client_rx) = client.split();
|
|
let (mut relay_tx, mut relay_rx) = upstream.split();
|
|
|
|
let to_relay = async {
|
|
while let Some(Ok(message)) = client_rx.next().await {
|
|
let forwarded = match message {
|
|
WsMessage::Text(text) => RelayMessage::Text(text.as_str().into()),
|
|
WsMessage::Binary(bytes) => RelayMessage::Binary(bytes),
|
|
WsMessage::Ping(bytes) => RelayMessage::Ping(bytes),
|
|
WsMessage::Pong(bytes) => RelayMessage::Pong(bytes),
|
|
WsMessage::Close(_) => break,
|
|
};
|
|
if relay_tx.send(forwarded).await.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
let _ = relay_tx.close().await;
|
|
};
|
|
|
|
let to_client = async {
|
|
while let Some(Ok(message)) = relay_rx.next().await {
|
|
let forwarded = match message {
|
|
RelayMessage::Text(text) => WsMessage::Text(text.as_str().into()),
|
|
RelayMessage::Binary(bytes) => WsMessage::Binary(bytes),
|
|
RelayMessage::Ping(bytes) => WsMessage::Ping(bytes),
|
|
RelayMessage::Pong(bytes) => WsMessage::Pong(bytes),
|
|
// A relay close carries the only diagnosis the server gives (1008 is a
|
|
// rejected credential, 1013 is "not up yet"), so it is passed through
|
|
// rather than swallowed into a bare disconnect.
|
|
RelayMessage::Close(frame) => {
|
|
let _ = client_tx
|
|
.send(WsMessage::Close(frame.map(|f| {
|
|
axum::extract::ws::CloseFrame {
|
|
code: u16::from(f.code),
|
|
reason: f.reason.as_str().into(),
|
|
}
|
|
})))
|
|
.await;
|
|
return;
|
|
}
|
|
RelayMessage::Frame(_) => continue,
|
|
};
|
|
if client_tx.send(forwarded).await.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
let _ = client_tx.close().await;
|
|
};
|
|
|
|
// Either direction ending means the conversation is over. Waiting for both
|
|
// would hold a relay socket open — and one of the session's four allowed
|
|
// attachments with it — after the client had gone.
|
|
tokio::select! {
|
|
() = to_relay => {}
|
|
() = to_client => {}
|
|
}
|
|
log::info!("CDP proxy for remote session {session_id} closed");
|
|
}
|
|
|
|
// API Handler - Every remote session this account currently owns
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/v1/remote-sessions",
|
|
responses(
|
|
(status = 200, description = "Sessions owned by the signed-in account", body = ApiRemoteSessionsResponse),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 402, description = "Active paid plan with browser automation required"),
|
|
(status = 403, description = "This desktop is not signed in to Donut cloud"),
|
|
(status = 503, description = "Donut cloud could not be reached"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "remote-sessions"
|
|
)]
|
|
async fn list_remote_sessions_api() -> Result<Json<ApiRemoteSessionsResponse>, (StatusCode, String)>
|
|
{
|
|
let sessions = crate::remote_session::list_remote_sessions()
|
|
.await
|
|
.map_err(remote_session_read_response)?;
|
|
Ok(Json(ApiRemoteSessionsResponse { sessions }))
|
|
}
|
|
|
|
// API Handler - One remote session's real state
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/v1/remote-sessions/{id}",
|
|
params(
|
|
("id" = String, Path, description = "Remote session ID from run-remote")
|
|
),
|
|
responses(
|
|
(status = 200, description = "Current session state", body = crate::remote_session::RemoteSessionState),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 402, description = "Active paid plan with browser automation required"),
|
|
(status = 403, description = "This desktop is not signed in to Donut cloud"),
|
|
(status = 404, description = "No such remote session"),
|
|
(status = 503, description = "Donut cloud could not be reached"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "remote-sessions"
|
|
)]
|
|
async fn get_remote_session_api(
|
|
Path(id): Path<String>,
|
|
) -> Result<Json<crate::remote_session::RemoteSessionState>, (StatusCode, String)> {
|
|
// `run-remote` answers `provisioning` and nothing more. Until this route
|
|
// existed, an automation client had no way to learn a session had become
|
|
// usable other than repeatedly trying to drive it.
|
|
crate::remote_session::get_remote_session(&id)
|
|
.await
|
|
.map(Json)
|
|
.map_err(remote_session_read_response)
|
|
}
|
|
|
|
// API Handler - The pooled remote-hour budget
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/v1/remote-hours",
|
|
responses(
|
|
(status = 200, description = "Pooled remote-hour budget and its breakdown", body = crate::cookie_bot::RemoteHoursQuota),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 403, description = "This desktop is not signed in to Donut cloud"),
|
|
(status = 503, description = "Donut cloud could not be reached"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "remote-sessions"
|
|
)]
|
|
async fn get_remote_hours(
|
|
) -> Result<Json<crate::cookie_bot::RemoteHoursQuota>, (StatusCode, String)> {
|
|
// Bot runs and interactive remote sessions spend one pool. Being refused a
|
|
// launch should not be the only way to find out how much of it is left.
|
|
crate::cookie_bot::remote_hours_quota()
|
|
.await
|
|
.map(Json)
|
|
.map_err(cookie_bot_error_response)
|
|
}
|
|
|
|
// --- Cookie bot -------------------------------------------------------------
|
|
//
|
|
// Thin proxies onto donutbrowser-infra, which owns the schedule, the calendar
|
|
// arithmetic, the browsing model and the pooled hour budget. Nothing here
|
|
// decides when a run happens or what it does. What this file DOES decide is
|
|
// which profiles may be offered to it at all.
|
|
|
|
/// Resolve a profile the cookie bot is allowed to touch.
|
|
///
|
|
/// The bot exists only on the leased fleet: a run materialises the profile on a
|
|
/// remote host from cloud sync, warms it, and pushes it back. A profile that
|
|
/// cannot make that round trip — never synced, encrypted with a key that never
|
|
/// leaves this machine, no recorded OS, an OS the fleet cannot lease, or no
|
|
/// proxy or VPN to egress through — has no path to a run and must never reach
|
|
/// an enrolment, a quota check or a leased host.
|
|
///
|
|
/// Every cookie-bot WRITE on this server goes through here, so there is no
|
|
/// surface on which a local-only profile can be pointed at the bot. The server
|
|
/// re-checks all of it; this exists so the refusal happens at the moment the
|
|
/// caller asks rather than silently at 02:00.
|
|
fn cookie_bot_eligible_profile(
|
|
profile_id: &str,
|
|
) -> Result<crate::profile::types::BrowserProfile, (StatusCode, String)> {
|
|
let profiles = ProfileManager::instance()
|
|
.list_profiles()
|
|
.map_err(manager_error_response)?;
|
|
let profile = profiles
|
|
.into_iter()
|
|
.find(|p| p.id.to_string() == profile_id)
|
|
.ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?;
|
|
|
|
crate::cookie_bot::bot_precondition(&profile)
|
|
.map_err(|reason| (StatusCode::BAD_REQUEST, reason))?;
|
|
Ok(profile)
|
|
}
|
|
|
|
// API Handler - Every cookie-bot enrolment the caller can see
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/v1/cookie-bot/schedules",
|
|
params(
|
|
("scope" = Option<String>, Query, description = "`mine` (default) or `team`")
|
|
),
|
|
responses(
|
|
(status = 200, description = "Enrolled profiles", body = crate::cookie_bot::CookieBotScheduleList),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 402, description = "Plan does not include the cookie bot"),
|
|
(status = 403, description = "Not signed in, or scope=team from a non-member"),
|
|
(status = 503, description = "Donut cloud could not be reached"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "cookie-bot"
|
|
)]
|
|
async fn list_cookie_bot_schedules(
|
|
Query(query): Query<CookieBotScopeQuery>,
|
|
) -> Result<Json<crate::cookie_bot::CookieBotScheduleList>, (StatusCode, String)> {
|
|
crate::cookie_bot::list_schedules(query.scope.as_deref())
|
|
.await
|
|
.map(Json)
|
|
.map_err(cookie_bot_error_response)
|
|
}
|
|
|
|
// API Handler - One profile's enrolment
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/v1/cookie-bot/schedules/{profile_id}",
|
|
params(
|
|
("profile_id" = String, Path, description = "Profile ID")
|
|
),
|
|
responses(
|
|
(status = 200, description = "The profile's enrolment", body = crate::cookie_bot::CookieBotSchedule),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 403, description = "This desktop is not signed in to Donut cloud"),
|
|
(status = 404, description = "This profile is not enrolled"),
|
|
(status = 503, description = "Donut cloud could not be reached"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "cookie-bot"
|
|
)]
|
|
async fn get_cookie_bot_schedule(
|
|
Path(profile_id): Path<String>,
|
|
) -> Result<Json<crate::cookie_bot::CookieBotSchedule>, (StatusCode, String)> {
|
|
// Deliberately NOT gated on eligibility: a profile whose sync was turned off
|
|
// after it was enrolled must still be able to show what it is enrolled as,
|
|
// otherwise the only way to see the schedule is to be allowed to run it.
|
|
match crate::cookie_bot::get_schedule(&profile_id)
|
|
.await
|
|
.map_err(cookie_bot_error_response)?
|
|
{
|
|
Some(schedule) => Ok(Json(schedule)),
|
|
None => Err((
|
|
StatusCode::NOT_FOUND,
|
|
serde_json::json!({ "code": "COOKIE_BOT_NOT_ENROLLED" }).to_string(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
// API Handler - Enrol a profile, or replace its enrolment
|
|
#[utoipa::path(
|
|
put,
|
|
path = "/v1/cookie-bot/schedules/{profile_id}",
|
|
params(
|
|
("profile_id" = String, Path, description = "Profile ID")
|
|
),
|
|
request_body = SetCookieBotScheduleRequest,
|
|
responses(
|
|
(status = 200, description = "Enrolment saved", body = crate::cookie_bot::CookieBotScheduleSaved),
|
|
(status = 400, description = "Invalid schedule, or a profile the bot cannot run"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 402, description = "Plan does not include the cookie bot"),
|
|
(status = 403, description = "This desktop is not signed in to Donut cloud"),
|
|
(status = 404, description = "Profile not found"),
|
|
(status = 409, description = "A teammate already enrols this profile; retry with acknowledge_conflict"),
|
|
(status = 503, description = "Donut cloud could not be reached"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "cookie-bot"
|
|
)]
|
|
async fn set_cookie_bot_schedule(
|
|
Path(profile_id): Path<String>,
|
|
Json(request): Json<SetCookieBotScheduleRequest>,
|
|
) -> Result<Json<crate::cookie_bot::CookieBotScheduleSaved>, (StatusCode, String)> {
|
|
let profile = cookie_bot_eligible_profile(&profile_id)?;
|
|
// `bot_precondition` already proved the profile has a resolvable OS the
|
|
// fleet can lease, so this cannot fail; taking it from the profile rather
|
|
// than the request is what stops a caller enrolling a macOS profile onto a
|
|
// Windows host.
|
|
let platform = profile
|
|
.resolved_os()
|
|
.ok_or((
|
|
StatusCode::BAD_REQUEST,
|
|
serde_json::json!({ "code": "COOKIE_BOT_UNKNOWN_PLATFORM" }).to_string(),
|
|
))?
|
|
.to_string();
|
|
|
|
if let Some(requested) = request.platform.as_deref() {
|
|
if requested != platform {
|
|
return Err((
|
|
StatusCode::BAD_REQUEST,
|
|
serde_json::json!({
|
|
"code": "COOKIE_BOT_UNSUPPORTED_PLATFORM",
|
|
"params": { "platform": requested }
|
|
})
|
|
.to_string(),
|
|
));
|
|
}
|
|
}
|
|
|
|
let input = crate::cookie_bot::CookieBotScheduleInput {
|
|
profile_name: request.profile_name.unwrap_or_else(|| profile.name.clone()),
|
|
platform,
|
|
enabled: request.enabled,
|
|
run_at_minute: request.run_at_minute,
|
|
days_mask: request.days_mask,
|
|
timezone: request.timezone,
|
|
preset: request.preset,
|
|
max_minutes: request.max_minutes,
|
|
sites: request.sites,
|
|
jitter_seconds: request.jitter_seconds,
|
|
..Default::default()
|
|
}
|
|
// The server requires these and cannot read them itself — the profile lives
|
|
// in the user's sync namespace, not its database.
|
|
.with_profile_state(crate::cookie_bot::profile_state(&profile));
|
|
|
|
crate::cookie_bot::save_schedule(&profile_id, &input, request.acknowledge_conflict)
|
|
.await
|
|
.map(Json)
|
|
.map_err(cookie_bot_error_response)
|
|
}
|
|
|
|
// API Handler - Turn the bot off for a profile
|
|
#[utoipa::path(
|
|
delete,
|
|
path = "/v1/cookie-bot/schedules/{profile_id}",
|
|
params(
|
|
("profile_id" = String, Path, description = "Profile ID")
|
|
),
|
|
responses(
|
|
(status = 200, description = "Enrolment removed, or there was none", body = crate::cookie_bot::CookieBotScheduleDeleted),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 403, description = "This desktop is not signed in to Donut cloud"),
|
|
(status = 503, description = "Donut cloud could not be reached"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "cookie-bot"
|
|
)]
|
|
async fn delete_cookie_bot_schedule(
|
|
Path(profile_id): Path<String>,
|
|
) -> Result<Json<crate::cookie_bot::CookieBotScheduleDeleted>, (StatusCode, String)> {
|
|
// No eligibility gate and no 404: "turn the bot off" must be safe to repeat,
|
|
// and a profile that has since become ineligible is exactly the one a caller
|
|
// most needs to be able to unenrol.
|
|
crate::cookie_bot::delete_schedule(&profile_id)
|
|
.await
|
|
.map(Json)
|
|
.map_err(cookie_bot_error_response)
|
|
}
|
|
|
|
// API Handler - Who else already warms this profile
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/v1/cookie-bot/conflicts",
|
|
params(
|
|
("profile_id" = String, Query, description = "Profile ID"),
|
|
("run_at_minute" = Option<u16>, Query, description = "Proposed minute past local midnight"),
|
|
("timezone" = Option<String>, Query, description = "Proposed IANA zone"),
|
|
("days_mask" = Option<u8>, Query, description = "Proposed weekday bitmask, bit 0 = Monday")
|
|
),
|
|
responses(
|
|
(status = 200, description = "Teammates enrolling the same profile", body = crate::cookie_bot::CookieBotConflictCheck),
|
|
(status = 400, description = "profile_id missing"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 403, description = "This desktop is not signed in to Donut cloud"),
|
|
(status = 503, description = "Donut cloud could not be reached"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "cookie-bot"
|
|
)]
|
|
async fn get_cookie_bot_conflicts(
|
|
Query(query): Query<CookieBotConflictsQuery>,
|
|
) -> Result<Json<crate::cookie_bot::CookieBotConflictCheck>, (StatusCode, String)> {
|
|
// A dry run that writes nothing, so an automation client can find a
|
|
// collision before it makes one instead of after two operators have quietly
|
|
// scheduled the same profile against itself.
|
|
crate::cookie_bot::check_conflicts(
|
|
&query.profile_id,
|
|
query.run_at_minute,
|
|
query.timezone.as_deref(),
|
|
query.days_mask,
|
|
)
|
|
.await
|
|
.map(Json)
|
|
.map_err(cookie_bot_error_response)
|
|
}
|
|
|
|
// API Handler - Run history
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/v1/cookie-bot/runs",
|
|
params(
|
|
("profile_id" = Option<String>, Query, description = "Restrict to one profile"),
|
|
("scope" = Option<String>, Query, description = "`mine` (default) or `team`"),
|
|
("limit" = Option<u32>, Query, description = "Page size, 1..100 (default 30)"),
|
|
("before" = Option<String>, Query, description = "Keyset cursor from a previous page's next_before")
|
|
),
|
|
responses(
|
|
(status = 200, description = "One page of runs, newest first", body = crate::cookie_bot::CookieBotRunPage),
|
|
(status = 400, description = "limit out of range or malformed cursor"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 403, description = "Not signed in, or scope=team from a non-member"),
|
|
(status = 503, description = "Donut cloud could not be reached"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "cookie-bot"
|
|
)]
|
|
async fn list_cookie_bot_runs(
|
|
Query(query): Query<CookieBotRunsQuery>,
|
|
) -> Result<Json<crate::cookie_bot::CookieBotRunPage>, (StatusCode, String)> {
|
|
crate::cookie_bot::list_runs(
|
|
query.profile_id.as_deref(),
|
|
query.scope.as_deref(),
|
|
query.limit,
|
|
query.before.as_deref(),
|
|
)
|
|
.await
|
|
.map(Json)
|
|
.map_err(cookie_bot_error_response)
|
|
}
|
|
|
|
// API Handler - Warm a profile now instead of waiting for tonight
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/v1/cookie-bot/runs",
|
|
request_body = StartCookieBotRunRequest,
|
|
responses(
|
|
(status = 202, description = "Run accepted; it keeps executing for minutes after this response", body = crate::cookie_bot::CookieBotRunStarted),
|
|
(status = 400, description = "A profile the bot cannot run"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 402, description = "Plan does not include the cookie bot, or the pooled hours are spent"),
|
|
(status = 403, description = "This desktop is not signed in to Donut cloud"),
|
|
(status = 404, description = "Profile not found, or not enrolled"),
|
|
(status = 409, description = "A run or remote session already holds this profile"),
|
|
(status = 429, description = "Automation request rate limit exceeded"),
|
|
(status = 503, description = "No host of that operating system has a free slot"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "cookie-bot"
|
|
)]
|
|
async fn start_cookie_bot_run(
|
|
Json(request): Json<StartCookieBotRunRequest>,
|
|
) -> Result<(StatusCode, Json<crate::cookie_bot::CookieBotRunStarted>), (StatusCode, String)> {
|
|
if !crate::cloud_auth::CLOUD_AUTH
|
|
.can_use_browser_automation()
|
|
.await
|
|
{
|
|
return Err((StatusCode::PAYMENT_REQUIRED, String::new()));
|
|
}
|
|
|
|
cookie_bot_eligible_profile(&request.profile_id)?;
|
|
|
|
let started = crate::cookie_bot::run_now(&request.profile_id, request.max_minutes)
|
|
.await
|
|
.map_err(cookie_bot_error_response)?;
|
|
|
|
// 202, not 200: the fleet is still browsing when this returns. Answering 200
|
|
// would tell a client the work is done when it has barely started.
|
|
Ok((StatusCode::ACCEPTED, Json(started)))
|
|
}
|
|
|
|
// API Handler - Stop a run that is still going
|
|
#[utoipa::path(
|
|
delete,
|
|
path = "/v1/cookie-bot/runs/{run_id}",
|
|
params(
|
|
("run_id" = String, Path, description = "Run ID")
|
|
),
|
|
responses(
|
|
(status = 200, description = "The run, cancelled (or unchanged if it had already finished)", body = crate::cookie_bot::CookieBotRun),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 403, description = "This desktop is not signed in to Donut cloud"),
|
|
(status = 404, description = "No such run for this account"),
|
|
(status = 429, description = "Automation request rate limit exceeded"),
|
|
(status = 503, description = "The fleet could not be reached; the run is still live"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "cookie-bot"
|
|
)]
|
|
async fn cancel_cookie_bot_run(
|
|
Path(run_id): Path<String>,
|
|
) -> Result<Json<crate::cookie_bot::CookieBotRun>, (StatusCode, String)> {
|
|
// No entitlement gate. A lapsed plan must never be the reason a user cannot
|
|
// stop something that is spending their hours.
|
|
crate::cookie_bot::cancel_run(&run_id)
|
|
.await
|
|
.map(Json)
|
|
.map_err(cookie_bot_error_response)
|
|
}
|
|
|
|
// API Handler - The intensities the server offers
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/v1/cookie-bot/presets",
|
|
responses(
|
|
(status = 200, description = "Selectable presets", body = crate::cookie_bot::CookieBotPresetList),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 403, description = "This desktop is not signed in to Donut cloud"),
|
|
(status = 503, description = "Donut cloud could not be reached"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "cookie-bot"
|
|
)]
|
|
async fn list_cookie_bot_presets(
|
|
) -> Result<Json<crate::cookie_bot::CookieBotPresetList>, (StatusCode, String)> {
|
|
// Ids and a rough duration only. What a preset expands to — the site
|
|
// ordering, the dwell model, the scroll and click programme — is the
|
|
// server's, and stays there.
|
|
crate::cookie_bot::list_presets()
|
|
.await
|
|
.map(Json)
|
|
.map_err(cookie_bot_error_response)
|
|
}
|
|
|
|
// API Handler - Who spent what, for a calendar month
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/v1/cookie-bot/usage",
|
|
params(
|
|
("period" = Option<String>, Query, description = "`YYYY-MM`; defaults to the current UTC month")
|
|
),
|
|
responses(
|
|
(status = 200, description = "Per-member and per-profile spend", body = crate::cookie_bot::CookieBotUsage),
|
|
(status = 400, description = "Malformed period"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 403, description = "Not signed in, or not a member of that team"),
|
|
(status = 503, description = "Donut cloud could not be reached"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "cookie-bot"
|
|
)]
|
|
async fn get_cookie_bot_usage(
|
|
Query(query): Query<CookieBotUsageQuery>,
|
|
) -> Result<Json<crate::cookie_bot::CookieBotUsage>, (StatusCode, String)> {
|
|
// Reporting, never enforcement: the pooled budget is spent against by the
|
|
// server, and this is how an owner finds out where it went.
|
|
crate::cookie_bot::team_usage(query.period.as_deref())
|
|
.await
|
|
.map(Json)
|
|
.map_err(cookie_bot_error_response)
|
|
}
|
|
|
|
// API Handler - Open URL in existing browser
|
|
//
|
|
// Works against a profile running here OR one running on the leased fleet: a
|
|
// remote session is navigated over the same CDP path the automation tools use,
|
|
// so a caller does not have to know where the browser is. The cross-OS refusal
|
|
// therefore only applies to a profile that would have to be launched locally.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/v1/profiles/{id}/open-url",
|
|
params(
|
|
("id" = String, Path, description = "Profile ID")
|
|
),
|
|
request_body = OpenUrlRequest,
|
|
responses(
|
|
(status = 200, description = "URL opened successfully, locally or on the profile's remote session"),
|
|
(status = 400, description = "Cannot open URL with a cross-OS profile that is not running remotely"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 402, description = "Active paid plan with browser automation required"),
|
|
(status = 404, description = "Profile not found"),
|
|
(status = 409, description = "Profile is locked by another team member, or waiting for a finished remote session to be pulled back"),
|
|
(status = 429, description = "Automation request rate limit exceeded"),
|
|
(status = 503, description = "The profile lock service could not be reached"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "profiles"
|
|
)]
|
|
async fn open_url_in_profile(
|
|
Path(id): Path<String>,
|
|
State(state): State<ApiServerState>,
|
|
Json(request): Json<OpenUrlRequest>,
|
|
) -> Result<StatusCode, (StatusCode, String)> {
|
|
if !crate::cloud_auth::CLOUD_AUTH
|
|
.can_use_browser_automation()
|
|
.await
|
|
{
|
|
return Err((StatusCode::PAYMENT_REQUIRED, String::new()));
|
|
}
|
|
|
|
let browser_runner = crate::browser_runner::BrowserRunner::instance();
|
|
|
|
browser_runner
|
|
.open_url_with_profile(state.app_handle.clone(), id, request.url)
|
|
.await
|
|
.map_err(manager_error_response)?;
|
|
|
|
Ok(StatusCode::OK)
|
|
}
|
|
|
|
// API Handler - Kill browser process
|
|
//
|
|
// Stops the browser wherever it is. A profile open on the leased fleet is ended
|
|
// through the backend, which is what makes this endpoint mean "stop this
|
|
// profile" rather than "stop this profile if it happens to be on this machine" —
|
|
// the latter reported success, killed nothing, and left the session billing to
|
|
// its two-hour cap.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/v1/profiles/{id}/kill",
|
|
params(
|
|
("id" = String, Path, description = "Profile ID")
|
|
),
|
|
responses(
|
|
(status = 204, description = "Browser stopped, locally or on the profile's remote session"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 402, description = "Active paid plan required"),
|
|
(status = 404, description = "Profile not found"),
|
|
(status = 429, description = "Automation request rate limit exceeded"),
|
|
(status = 503, description = "The fleet could not be reached; the remote browser is still running"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "profiles"
|
|
)]
|
|
async fn kill_profile(
|
|
Path(id): Path<String>,
|
|
State(state): State<ApiServerState>,
|
|
) -> Result<StatusCode, (StatusCode, String)> {
|
|
// Programmatically launching and stopping profiles is a paid feature; the
|
|
// run/open-url handlers gate the same way.
|
|
if !crate::cloud_auth::CLOUD_AUTH
|
|
.can_use_browser_automation()
|
|
.await
|
|
{
|
|
return Err((StatusCode::PAYMENT_REQUIRED, String::new()));
|
|
}
|
|
|
|
let profile_manager = ProfileManager::instance();
|
|
let profiles = profile_manager
|
|
.list_profiles()
|
|
.map_err(manager_error_response)?;
|
|
|
|
let profile = profiles
|
|
.iter()
|
|
.find(|p| p.id.to_string() == id)
|
|
.ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?;
|
|
|
|
let browser_runner = crate::browser_runner::BrowserRunner::instance();
|
|
browser_runner
|
|
.kill_browser_process(state.app_handle.clone(), profile)
|
|
.await
|
|
.map_err(|e| {
|
|
let message = e.to_string();
|
|
// The backend refuses to retire a session it could not stop on the fleet.
|
|
// Reporting that as a 500 invites a retry loop against a browser that is
|
|
// still running; 503 says "it is still up, try again".
|
|
if message.contains("REMOTE_") {
|
|
(StatusCode::SERVICE_UNAVAILABLE, message)
|
|
} else {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, message)
|
|
}
|
|
})?;
|
|
|
|
crate::team_lock::release_team_lock_if_needed(profile).await;
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
// API Handler - Batch run profiles (paid: browser automation). Mirrors the
|
|
// single `/run` gate; never breaks the batch on a single profile's failure —
|
|
// each profile gets its own result entry.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/v1/profiles/batch/run",
|
|
request_body = BatchRunRequest,
|
|
responses(
|
|
(status = 200, description = "Batch launch completed; inspect per-profile results", body = BatchRunResponse),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 402, description = "Active paid plan with browser automation required"),
|
|
(status = 429, description = "Automation request rate limit exceeded"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "profiles"
|
|
)]
|
|
async fn batch_run_profiles(
|
|
State(state): State<ApiServerState>,
|
|
Json(request): Json<BatchRunRequest>,
|
|
) -> Result<Json<BatchRunResponse>, StatusCode> {
|
|
if !crate::cloud_auth::CLOUD_AUTH
|
|
.can_use_browser_automation()
|
|
.await
|
|
{
|
|
return Err(StatusCode::PAYMENT_REQUIRED);
|
|
}
|
|
|
|
let headless = request.headless.unwrap_or(false);
|
|
let profile_manager = ProfileManager::instance();
|
|
let profiles = profile_manager
|
|
.list_profiles()
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
|
|
let mut results = Vec::with_capacity(request.profile_ids.len());
|
|
for profile_id in &request.profile_ids {
|
|
let fail = |error: &str| BatchRunResult {
|
|
profile_id: profile_id.clone(),
|
|
ok: false,
|
|
remote_debugging_port: None,
|
|
error: Some(error.to_string()),
|
|
};
|
|
|
|
let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else {
|
|
results.push(fail("profile not found"));
|
|
continue;
|
|
};
|
|
if profile.is_cross_os() {
|
|
results.push(fail("cross-OS profiles cannot be launched"));
|
|
continue;
|
|
}
|
|
if crate::team_lock::acquire_team_lock_if_needed(profile)
|
|
.await
|
|
.is_err()
|
|
{
|
|
results.push(fail("profile is locked by another team member"));
|
|
continue;
|
|
}
|
|
|
|
let port = match tokio::net::TcpListener::bind("127.0.0.1:0").await {
|
|
Ok(listener) => match listener.local_addr() {
|
|
Ok(addr) => addr.port(),
|
|
Err(_) => {
|
|
results.push(fail("failed to allocate debugging port"));
|
|
continue;
|
|
}
|
|
},
|
|
Err(_) => {
|
|
results.push(fail("failed to allocate debugging port"));
|
|
continue;
|
|
}
|
|
};
|
|
|
|
match crate::browser_runner::launch_browser_profile_impl(
|
|
state.app_handle.clone(),
|
|
profile.clone(),
|
|
request.url.clone(),
|
|
Some(port),
|
|
headless,
|
|
true,
|
|
)
|
|
.await
|
|
{
|
|
Ok(_) => results.push(BatchRunResult {
|
|
profile_id: profile_id.clone(),
|
|
ok: true,
|
|
remote_debugging_port: Some(port),
|
|
error: None,
|
|
}),
|
|
Err(e) => results.push(fail(&format!("launch failed: {e}"))),
|
|
}
|
|
}
|
|
|
|
Ok(Json(BatchRunResponse { results }))
|
|
}
|
|
|
|
// API Handler - Batch stop profiles (paid: browser automation).
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/v1/profiles/batch/stop",
|
|
request_body = BatchStopRequest,
|
|
responses(
|
|
(status = 200, description = "Batch stop completed; inspect per-profile results", body = BatchStopResponse),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 402, description = "Active paid plan with browser automation required"),
|
|
(status = 429, description = "Automation request rate limit exceeded"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "profiles"
|
|
)]
|
|
async fn batch_stop_profiles(
|
|
State(state): State<ApiServerState>,
|
|
Json(request): Json<BatchStopRequest>,
|
|
) -> Result<Json<BatchStopResponse>, StatusCode> {
|
|
if !crate::cloud_auth::CLOUD_AUTH
|
|
.can_use_browser_automation()
|
|
.await
|
|
{
|
|
return Err(StatusCode::PAYMENT_REQUIRED);
|
|
}
|
|
|
|
let profile_manager = ProfileManager::instance();
|
|
let profiles = profile_manager
|
|
.list_profiles()
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
let browser_runner = crate::browser_runner::BrowserRunner::instance();
|
|
|
|
let mut results = Vec::with_capacity(request.profile_ids.len());
|
|
for profile_id in &request.profile_ids {
|
|
let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else {
|
|
results.push(BatchStopResult {
|
|
profile_id: profile_id.clone(),
|
|
ok: false,
|
|
error: Some("profile not found".to_string()),
|
|
});
|
|
continue;
|
|
};
|
|
|
|
match browser_runner
|
|
.kill_browser_process(state.app_handle.clone(), profile)
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
crate::team_lock::release_team_lock_if_needed(profile).await;
|
|
results.push(BatchStopResult {
|
|
profile_id: profile_id.clone(),
|
|
ok: true,
|
|
error: None,
|
|
});
|
|
}
|
|
Err(e) => results.push(BatchStopResult {
|
|
profile_id: profile_id.clone(),
|
|
ok: false,
|
|
error: Some(format!("stop failed: {e}")),
|
|
}),
|
|
}
|
|
}
|
|
|
|
Ok(Json(BatchStopResponse { results }))
|
|
}
|
|
|
|
// API Handler - Detect importable browser profiles on this machine, or scan a
|
|
// custom folder. Free: importing is not gated behind browser automation.
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/v1/profiles/import/detect",
|
|
params(
|
|
("folder" = Option<String>, Query, description = "Optional folder to scan instead of the default browser locations. Accepts a single profile dir, a Chromium user-data dir, or a folder holding one profile dir per child.")
|
|
),
|
|
responses(
|
|
(status = 200, description = "Detected importable profiles", body = DetectedProfilesResponse),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 404, description = "Folder not found"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "profiles"
|
|
)]
|
|
async fn detect_import_profiles(
|
|
Query(query): Query<DetectImportQuery>,
|
|
State(_state): State<ApiServerState>,
|
|
) -> Result<Json<DetectedProfilesResponse>, (StatusCode, String)> {
|
|
let importer = crate::profile_importer::ProfileImporter::instance();
|
|
let profiles = match query.folder.as_deref() {
|
|
Some(folder) => importer.scan_folder(std::path::Path::new(folder)),
|
|
None => importer.detect_existing_profiles(),
|
|
}
|
|
.map_err(manager_error_response)?;
|
|
let total = profiles.len();
|
|
Ok(Json(DetectedProfilesResponse { profiles, total }))
|
|
}
|
|
|
|
// API Handler - Bulk-import browser profiles from on-disk profile folders.
|
|
// Free (parity with create_profile); only fingerprint OS spoofing is Pro.
|
|
// Items are isolated — one failure doesn't stop the rest.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/v1/profiles/import",
|
|
request_body = ImportProfilesRequest,
|
|
responses(
|
|
(status = 200, description = "Batch import completed; inspect per-item results", body = crate::profile_importer::ProfileImportBatchResult),
|
|
(status = 400, description = "No items, or invalid input"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 402, description = "Fingerprint OS spoofing requires an active Pro subscription"),
|
|
(status = 404, description = "Group not found"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "profiles"
|
|
)]
|
|
async fn import_profiles_api(
|
|
State(state): State<ApiServerState>,
|
|
Json(request): Json<ImportProfilesRequest>,
|
|
) -> Result<Json<crate::profile_importer::ProfileImportBatchResult>, (StatusCode, String)> {
|
|
let wayfern_config: Option<crate::wayfern_manager::WayfernConfig> = request
|
|
.wayfern_config
|
|
.as_ref()
|
|
.and_then(|config| serde_json::from_value(config.clone()).ok());
|
|
|
|
// The Pro gate for fingerprint OS spoofing lives inside import_profiles, so
|
|
// every surface inherits it; manager_error_response maps the code to 402.
|
|
let importer = crate::profile_importer::ProfileImporter::instance();
|
|
importer
|
|
.import_profiles(
|
|
&state.app_handle,
|
|
request.items,
|
|
request.group_id,
|
|
request.duplicate_strategy.unwrap_or_default(),
|
|
wayfern_config,
|
|
)
|
|
.await
|
|
.map(Json)
|
|
.map_err(manager_error_response)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/v1/profiles/{id}/cookies/import",
|
|
params(
|
|
("id" = String, Path, description = "Profile ID")
|
|
),
|
|
request_body = ImportCookiesRequest,
|
|
responses(
|
|
(status = 200, description = "Cookies imported successfully", body = ImportCookiesResponse),
|
|
(status = 400, description = "Invalid cookie file or unsupported browser"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 404, description = "Profile not found"),
|
|
(status = 409, description = "Browser is currently running"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "cookies"
|
|
)]
|
|
async fn import_profile_cookies(
|
|
Path(id): Path<String>,
|
|
State(state): State<ApiServerState>,
|
|
Json(request): Json<ImportCookiesRequest>,
|
|
) -> Result<Json<ImportCookiesResponse>, StatusCode> {
|
|
let profile_manager = ProfileManager::instance();
|
|
let profiles = profile_manager
|
|
.list_profiles()
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
|
|
if !profiles.iter().any(|p| p.id.to_string() == id) {
|
|
return Err(StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
match crate::cookie_manager::CookieManager::import_cookies(
|
|
&state.app_handle,
|
|
&id,
|
|
&request.content,
|
|
)
|
|
.await
|
|
{
|
|
Ok(result) => {
|
|
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
|
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == id) {
|
|
if profile.is_sync_enabled() {
|
|
let pid = id.clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
scheduler.queue_profile_sync(pid).await;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
Ok(Json(ImportCookiesResponse {
|
|
cookies_imported: result.cookies_imported,
|
|
cookies_replaced: result.cookies_replaced,
|
|
errors: result.errors,
|
|
}))
|
|
}
|
|
Err(e) => {
|
|
let msg = e.to_lowercase();
|
|
if msg.contains("running") {
|
|
Err(StatusCode::CONFLICT)
|
|
} else if msg.contains("no valid cookies") || msg.contains("unsupported browser") {
|
|
Err(StatusCode::BAD_REQUEST)
|
|
} else {
|
|
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// API Handler - Download Browser
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/v1/browsers/download",
|
|
request_body = DownloadBrowserRequest,
|
|
responses(
|
|
(status = 200, description = "Browser downloaded (or already present)", body = DownloadBrowserResponse),
|
|
(status = 400, description = "Invalid browser or version not available for download"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 409, description = "This browser version is already being downloaded"),
|
|
(status = 500, description = "Internal server error (e.g. network failure)")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "browsers"
|
|
)]
|
|
async fn download_browser_api(
|
|
State(state): State<ApiServerState>,
|
|
Json(request): Json<DownloadBrowserRequest>,
|
|
) -> Result<Json<DownloadBrowserResponse>, (StatusCode, String)> {
|
|
match crate::downloader::download_browser(
|
|
state.app_handle.clone(),
|
|
request.browser.clone(),
|
|
request.version,
|
|
)
|
|
.await
|
|
{
|
|
// Echo the version the downloader actually installed, not the requested one.
|
|
Ok(version) => Ok(Json(DownloadBrowserResponse {
|
|
browser: request.browser,
|
|
version,
|
|
status: "downloaded".to_string(),
|
|
})),
|
|
Err(e) => {
|
|
if e.contains("already being downloaded") {
|
|
Err((StatusCode::CONFLICT, e))
|
|
} else {
|
|
Err(manager_error_response(e))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// API Handler - Get Browser Versions
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/v1/browsers/{browser}/versions",
|
|
params(
|
|
("browser" = String, Path, description = "Browser name")
|
|
),
|
|
responses(
|
|
(status = 200, description = "List of available browser versions", body = Vec<String>),
|
|
(status = 400, description = "Unsupported browser"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "browsers"
|
|
)]
|
|
async fn get_browser_versions(
|
|
Path(browser): Path<String>,
|
|
State(_state): State<ApiServerState>,
|
|
) -> Result<Json<Vec<String>>, (StatusCode, String)> {
|
|
let version_manager = crate::browser_version_manager::BrowserVersionManager::instance();
|
|
|
|
match version_manager
|
|
.fetch_browser_versions_with_count(&browser, false)
|
|
.await
|
|
{
|
|
Ok(result) => Ok(Json(result.versions)),
|
|
Err(e) => Err(manager_error_response(e)),
|
|
}
|
|
}
|
|
|
|
// API Handler - Check if Browser is Downloaded
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/v1/browsers/{browser}/versions/{version}/downloaded",
|
|
params(
|
|
("browser" = String, Path, description = "Browser name"),
|
|
("version" = String, Path, description = "Browser version")
|
|
),
|
|
responses(
|
|
(status = 200, description = "Browser download status", body = bool),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
),
|
|
tag = "browsers"
|
|
)]
|
|
async fn check_browser_downloaded(
|
|
Path((browser, version)): Path<(String, String)>,
|
|
State(_state): State<ApiServerState>,
|
|
) -> Result<Json<bool>, StatusCode> {
|
|
let is_downloaded = crate::downloaded_browsers_registry::is_browser_downloaded(browser, version);
|
|
Ok(Json(is_downloaded))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::profile::types::{BrowserProfile, SyncMode};
|
|
|
|
fn profile_with(sync_mode: SyncMode, host_os: Option<&str>) -> BrowserProfile {
|
|
BrowserProfile {
|
|
id: uuid::Uuid::nil(),
|
|
name: "p".to_string(),
|
|
browser: "wayfern".to_string(),
|
|
version: "latest".to_string(),
|
|
sync_mode,
|
|
host_os: host_os.map(|s| s.to_string()),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
// Cloud sync has been settable through PUT /v1/profiles/{id} but was absent
|
|
// from every profile RESPONSE, so a caller could turn it on and never
|
|
// confirm it. A remote-launch caller must be able to see this before it can
|
|
// decide whether the profile exists in cloud storage at all.
|
|
// /run-remote exists precisely so a profile can run on a host of ITS OWN OS
|
|
// when this machine is the wrong one. The gate is cloud sync: a remote host
|
|
// obtains the profile from donut-sync, so a profile that has never synced
|
|
// would launch an empty browser and push that emptiness over the real one.
|
|
#[test]
|
|
fn remote_launch_requires_cloud_sync() {
|
|
let err = remote_launch_profile_rules(&profile_with(SyncMode::Disabled, Some("macos")))
|
|
.expect_err("a non-synced profile must be refused");
|
|
assert!(err.contains("cloud sync"), "unhelpful message: {err}");
|
|
|
|
assert!(
|
|
remote_launch_profile_rules(&profile_with(SyncMode::Regular, Some("macos"))).is_ok(),
|
|
"a synced profile must be allowed"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn remote_launch_refuses_an_end_to_end_encrypted_profile() {
|
|
// The key is derived from a passphrase that never leaves this machine, so
|
|
// a remote host downloads ciphertext, launches Chromium on it, and pushes
|
|
// the corruption back over the user's real profile. Refusing here also
|
|
// saves taking the profile lock and a slot on leased hardware for a
|
|
// session that cannot possibly work.
|
|
let err = remote_launch_profile_rules(&profile_with(SyncMode::Encrypted, Some("macos")))
|
|
.expect_err("an encrypted profile must be refused");
|
|
assert!(
|
|
err.contains("encrypted") && err.contains("Regular"),
|
|
"the message must say what to change: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn remote_launch_requires_a_known_operating_system() {
|
|
// Without one there is no way to pick a matching host, and guessing would
|
|
// be the cross-OS mismatch this whole design exists to prevent.
|
|
assert!(remote_launch_profile_rules(&profile_with(SyncMode::Regular, None)).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn remote_launch_allows_a_cross_os_profile() {
|
|
let host = crate::profile::types::get_host_os();
|
|
let other = if host == "windows" {
|
|
"macos"
|
|
} else {
|
|
"windows"
|
|
};
|
|
let foreign = profile_with(SyncMode::Regular, Some(other));
|
|
|
|
assert!(
|
|
foreign.is_cross_os(),
|
|
"test setup: profile should be foreign"
|
|
);
|
|
// Local /run refuses this; running it remotely on a host of its own OS is
|
|
// exactly what /run-remote is for.
|
|
assert!(remote_launch_profile_rules(&foreign).is_ok());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn remote_launch_is_refused_while_the_profile_is_mid_upload() {
|
|
// The manifest is written last. A host that pulls during the upload gets
|
|
// files that are about to be replaced described by a manifest that does
|
|
// not match them, launches Chromium on that, and pushes the result back
|
|
// over the real profile.
|
|
let scheduler = std::sync::Arc::new(crate::sync::SyncScheduler::new());
|
|
crate::sync::set_global_scheduler(scheduler.clone());
|
|
|
|
let mut profile = profile_with(SyncMode::Regular, Some("macos"));
|
|
profile.id = uuid::Uuid::new_v4();
|
|
assert!(
|
|
remote_launch_precondition(&profile).await.is_ok(),
|
|
"an idle profile must be launchable"
|
|
);
|
|
|
|
scheduler.queue_profile_sync(profile.id.to_string()).await;
|
|
let err = remote_launch_precondition(&profile)
|
|
.await
|
|
.expect_err("a profile mid-upload must be refused");
|
|
assert!(
|
|
err.contains("REMOTE_SYNC_IN_PROGRESS"),
|
|
"the refusal must be a code the frontend can translate: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn api_profile_exposes_cloud_sync_state() {
|
|
let disabled = ApiProfile::from(&profile_with(SyncMode::Disabled, None));
|
|
assert_eq!(disabled.sync_mode, "Disabled");
|
|
assert!(!disabled.cloud_sync_enabled);
|
|
|
|
let regular = ApiProfile::from(&profile_with(SyncMode::Regular, None));
|
|
assert_eq!(regular.sync_mode, "Regular");
|
|
assert!(regular.cloud_sync_enabled);
|
|
|
|
let encrypted = ApiProfile::from(&profile_with(SyncMode::Encrypted, None));
|
|
assert_eq!(encrypted.sync_mode, "Encrypted");
|
|
assert!(encrypted.cloud_sync_enabled);
|
|
}
|
|
|
|
// A profile must only ever run on its own operating system: Chromium's
|
|
// on-disk state is OS-specific, so replaying a macOS profile on Windows is a
|
|
// mismatch no amount of user-agent spoofing repairs.
|
|
#[test]
|
|
fn api_profile_reports_its_operating_system() {
|
|
let host = crate::profile::types::get_host_os();
|
|
let same = ApiProfile::from(&profile_with(SyncMode::Regular, Some(&host)));
|
|
assert_eq!(same.host_os.as_deref(), Some(host.as_str()));
|
|
assert!(!same.is_cross_os);
|
|
|
|
let other = if host == "windows" {
|
|
"macos"
|
|
} else {
|
|
"windows"
|
|
};
|
|
let foreign = ApiProfile::from(&profile_with(SyncMode::Regular, Some(other)));
|
|
assert_eq!(foreign.host_os.as_deref(), Some(other));
|
|
assert!(foreign.is_cross_os);
|
|
}
|
|
|
|
#[test]
|
|
fn api_profile_without_a_recorded_os_is_not_cross_os() {
|
|
// An older profile that predates host_os must stay locally launchable
|
|
// rather than being treated as foreign.
|
|
let unknown = ApiProfile::from(&profile_with(SyncMode::Disabled, None));
|
|
assert_eq!(unknown.host_os, None);
|
|
assert!(!unknown.is_cross_os);
|
|
}
|
|
|
|
// Removing `browser` from UpdateProfileRequest, and rejecting invalid
|
|
// `browser` values on create, must NOT make the API reject requests that
|
|
// carry extra/unknown fields — old clients still send them. serde ignores
|
|
// unknown fields by default; these tests lock that in so a future
|
|
// `#[serde(deny_unknown_fields)]` can't silently break compatibility.
|
|
#[test]
|
|
fn update_profile_request_ignores_unknown_fields() {
|
|
// `browser` is no longer a field, plus a wholly unknown field. Both must
|
|
// be accepted and ignored, not rejected.
|
|
let json = r#"{"name": "p", "browser": "wayfern", "totally_unknown": 123}"#;
|
|
let parsed: UpdateProfileRequest =
|
|
serde_json::from_str(json).expect("unknown fields must be ignored, not rejected");
|
|
assert_eq!(parsed.name.as_deref(), Some("p"));
|
|
}
|
|
|
|
#[test]
|
|
fn create_profile_request_ignores_unknown_fields() {
|
|
let json = r#"{"name": "p", "browser": "wayfern", "version": "latest", "future_field": true}"#;
|
|
let parsed: CreateProfileRequest =
|
|
serde_json::from_str(json).expect("unknown fields must be ignored, not rejected");
|
|
assert_eq!(parsed.browser, "wayfern");
|
|
}
|
|
|
|
#[test]
|
|
fn create_profile_request_allows_omitting_version_and_configs() {
|
|
// Minimal body: no version, no wayfern_config. Must
|
|
// deserialize (version resolves to latest-downloaded at the handler; an
|
|
// absent config triggers fresh-fingerprint generation).
|
|
let json = r#"{"name": "p", "browser": "wayfern"}"#;
|
|
let parsed: CreateProfileRequest =
|
|
serde_json::from_str(json).expect("version and configs are optional");
|
|
assert_eq!(parsed.browser, "wayfern");
|
|
assert!(parsed.version.is_none());
|
|
assert!(parsed.wayfern_config.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn create_profile_browser_validation_matches_supported_engines() {
|
|
// The handler rejects anything that isn't a launchable engine; this is the
|
|
// same predicate it uses, kept in lockstep with MCP's create_profile.
|
|
let is_valid = |b: &str| b == "wayfern";
|
|
assert!(is_valid("wayfern"));
|
|
assert!(!is_valid("chromium"));
|
|
assert!(!is_valid(""));
|
|
}
|
|
|
|
#[test]
|
|
fn rate_limit_only_classifies_browser_automation_routes() {
|
|
for path in [
|
|
"/v1/profiles/profile-id/run",
|
|
"/v1/profiles/profile-id/open-url",
|
|
"/v1/profiles/profile-id/kill",
|
|
// Launching on leased remote hardware is the most expensive automation
|
|
// action there is; it went unmetered because `run-remote` is its own
|
|
// path segment and never matched `run`.
|
|
"/v1/profiles/profile-id/run-remote",
|
|
"/v1/profiles/batch/run",
|
|
"/v1/profiles/batch/stop",
|
|
// Starting a bot run leases a host for up to two hours and spends the
|
|
// account's pooled remote-hour budget.
|
|
"/v1/cookie-bot/runs",
|
|
] {
|
|
assert!(
|
|
is_automation_request(&Method::POST, path),
|
|
"automation route was not limited: {path}"
|
|
);
|
|
}
|
|
|
|
// Stopping a remote session is a DELETE, and its handler declares a 429.
|
|
// Cancelling a bot run reaches the same fleet and is metered the same way.
|
|
for path in [
|
|
"/v1/remote-sessions/session-id",
|
|
"/v1/cookie-bot/runs/run-id",
|
|
] {
|
|
assert!(
|
|
is_automation_request(&Method::DELETE, path),
|
|
"metered stop was not limited: {path}"
|
|
);
|
|
}
|
|
|
|
for (method, path) in [
|
|
(Method::GET, "/v1/profiles/profile-id/run"),
|
|
(Method::POST, "/v1/profiles"),
|
|
(Method::POST, "/v1/profiles/import"),
|
|
(Method::GET, "/v1/profiles"),
|
|
(Method::GET, "/openapi.json"),
|
|
// Only the single-session DELETE is automation; the collection is not a
|
|
// route, and a GET of one never launches anything.
|
|
(Method::DELETE, "/v1/remote-sessions/"),
|
|
(Method::GET, "/v1/remote-sessions/session-id"),
|
|
(Method::GET, "/v1/remote-sessions"),
|
|
// Enrolling a profile writes one row on the server and leases nothing.
|
|
// Metering it would 429 a client setting up a fleet of profiles, while
|
|
// the budget that actually protects the hardware is spent per RUN and
|
|
// enforced server-side however the run was scheduled.
|
|
(Method::PUT, "/v1/cookie-bot/schedules/profile-id"),
|
|
(Method::DELETE, "/v1/cookie-bot/schedules/profile-id"),
|
|
(Method::GET, "/v1/cookie-bot/schedules"),
|
|
(Method::GET, "/v1/cookie-bot/runs"),
|
|
(Method::GET, "/v1/cookie-bot/usage"),
|
|
(Method::GET, "/v1/remote-hours"),
|
|
// A run id is required; the collection DELETE is not a route.
|
|
(Method::DELETE, "/v1/cookie-bot/runs/"),
|
|
] {
|
|
assert!(
|
|
!is_automation_request(&method, path),
|
|
"free or non-mutating route was limited: {method} {path}"
|
|
);
|
|
}
|
|
}
|
|
|
|
// The bot exists only on the leased fleet. Every write surface resolves the
|
|
// profile through `bot_precondition` first, so there is no request shape on
|
|
// this server that points it at a profile which could never make the round
|
|
// trip to a remote host and back.
|
|
#[test]
|
|
fn a_profile_the_bot_could_never_run_is_refused_before_the_cloud_is_asked() {
|
|
let mut local_only = profile_with(SyncMode::Disabled, Some("macos"));
|
|
local_only.proxy_id = Some("proxy-1".to_string());
|
|
assert!(
|
|
crate::cookie_bot::bot_precondition(&local_only).is_err(),
|
|
"a profile with no cloud copy has nothing for a host to open"
|
|
);
|
|
|
|
let mut encrypted = profile_with(SyncMode::Encrypted, Some("macos"));
|
|
encrypted.proxy_id = Some("proxy-1".to_string());
|
|
assert!(
|
|
crate::cookie_bot::bot_precondition(&encrypted).is_err(),
|
|
"a host cannot decrypt a profile whose key never leaves this machine"
|
|
);
|
|
|
|
let mut datacenter_egress = profile_with(SyncMode::Regular, Some("macos"));
|
|
datacenter_egress.proxy_id = None;
|
|
datacenter_egress.vpn_id = None;
|
|
assert!(
|
|
crate::cookie_bot::bot_precondition(&datacenter_egress).is_err(),
|
|
"hours of traffic from a hosting ASN damages the identity being warmed"
|
|
);
|
|
|
|
let mut eligible = profile_with(SyncMode::Regular, Some("macos"));
|
|
eligible.proxy_id = Some("proxy-1".to_string());
|
|
assert!(crate::cookie_bot::bot_precondition(&eligible).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn a_cloud_401_is_not_reported_as_the_callers_own_token_being_wrong() {
|
|
// The caller's bearer token was accepted — the auth middleware ran. It is
|
|
// THIS desktop that has no cloud session, and answering 401 would send an
|
|
// automation client off to rotate a token that is perfectly good.
|
|
assert_eq!(
|
|
cloud_failure_status(401, crate::cloud_errors::NOT_SIGNED_IN),
|
|
StatusCode::FORBIDDEN
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_clouds_403_splits_into_the_two_things_it_means() {
|
|
// "Your plan does not include this" is the 402 this API uses everywhere
|
|
// else; "you are not in that team" is not something a payment fixes.
|
|
assert_eq!(
|
|
cloud_failure_status(403, "COOKIE_BOT_NOT_ENTITLED"),
|
|
StatusCode::PAYMENT_REQUIRED
|
|
);
|
|
assert_eq!(
|
|
cloud_failure_status(403, "NOT_TEAM_MEMBER"),
|
|
StatusCode::FORBIDDEN
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn spending_the_pooled_hours_is_a_payment_problem_not_a_server_fault() {
|
|
// It arrives as a 403 with a code. Reporting it as a plain forbidden would
|
|
// hide the one thing the user can act on.
|
|
assert_eq!(
|
|
cloud_failure_status(403, "REMOTE_HOURS_EXHAUSTED"),
|
|
StatusCode::PAYMENT_REQUIRED
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_busy_or_unreachable_fleet_is_never_reported_as_broken() {
|
|
// 503 means "try again shortly". Turning it into a 500 tells the user
|
|
// their automation is broken when nothing is.
|
|
assert_eq!(
|
|
cloud_failure_status(503, crate::cloud_errors::NO_CAPACITY),
|
|
StatusCode::SERVICE_UNAVAILABLE
|
|
);
|
|
assert_eq!(
|
|
cloud_failure_status(0, crate::cloud_errors::UNREACHABLE),
|
|
StatusCode::SERVICE_UNAVAILABLE
|
|
);
|
|
assert_eq!(
|
|
cloud_failure_status(429, crate::cloud_errors::RATE_LIMITED),
|
|
StatusCode::TOO_MANY_REQUESTS
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_schedule_and_a_missing_run_both_stay_a_404() {
|
|
assert_eq!(
|
|
cloud_failure_status(404, "COOKIE_BOT_NOT_ENROLLED"),
|
|
StatusCode::NOT_FOUND
|
|
);
|
|
assert_eq!(
|
|
cloud_failure_status(404, "COOKIE_BOT_RUN_NOT_FOUND"),
|
|
StatusCode::NOT_FOUND
|
|
);
|
|
assert_eq!(
|
|
cloud_failure_status(409, "COOKIE_BOT_SCHEDULE_CONFLICT"),
|
|
StatusCode::CONFLICT
|
|
);
|
|
assert_eq!(
|
|
cloud_failure_status(400, "COOKIE_BOT_INVALID_SCHEDULE"),
|
|
StatusCode::BAD_REQUEST
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_read_of_a_session_that_does_not_exist_is_a_404_not_a_500() {
|
|
// The launch mapping folds every unrecognised status into 500, which for a
|
|
// read means "no such session" is indistinguishable from "our backend
|
|
// fell over".
|
|
let missing =
|
|
crate::remote_session::classify_backend_status(404, r#"{"code":"REMOTE_SESSION_NOT_FOUND"}"#);
|
|
let (status, body) = remote_session_read_response(missing);
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
assert_eq!(error_code_of(&body), "REMOTE_SESSION_NOT_FOUND");
|
|
}
|
|
|
|
#[test]
|
|
fn a_read_answers_with_a_code_rather_than_the_backends_english() {
|
|
let busy = crate::remote_session::classify_backend_status(503, "no macos host has a free slot");
|
|
let (status, body) = remote_session_read_response(busy);
|
|
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
|
|
assert_eq!(error_code_of(&body), crate::cloud_errors::NO_CAPACITY);
|
|
}
|
|
|
|
// Axum panics when two handlers claim one path, and the router is only built
|
|
// when the API server is switched on — so a conflict introduced here would
|
|
// ship as an app that dies the first time a user enables the API. Both
|
|
// `/v1/remote-sessions/{id}` and `/v1/cookie-bot/schedules/{profile_id}` now
|
|
// carry several methods, which is exactly the shape that trips it.
|
|
#[test]
|
|
fn every_v1_route_can_be_registered_together() {
|
|
let _router: Router<ApiServerState> = build_v1_router();
|
|
}
|
|
|
|
fn schema_required(spec: &serde_json::Value, schema: &str) -> Vec<String> {
|
|
spec["components"]["schemas"][schema]["required"]
|
|
.as_array()
|
|
.map(|a| {
|
|
a.iter()
|
|
.filter_map(|v| v.as_str().map(str::to_string))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
// `#[schema(value_type = Object)]` on an `Option<T>` erases the optionality
|
|
// and marks the field required in the served spec; these fields must stay
|
|
// optional so generated clients aren't forced to send them.
|
|
#[test]
|
|
fn openapi_optional_fields_are_not_required() {
|
|
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
|
|
|
|
let create_profile = schema_required(&spec, "CreateProfileRequest");
|
|
assert!(
|
|
!create_profile.iter().any(|f| f == "wayfern_config"),
|
|
"wayfern_config must be optional, required list: {create_profile:?}"
|
|
);
|
|
|
|
// `ApiProfile` is the response body of every profile-returning route, so a
|
|
// wrongly-required `group_id` makes generated clients assume a group is
|
|
// always present on an ungrouped profile.
|
|
let api_profile = schema_required(&spec, "ApiProfile");
|
|
assert!(
|
|
!api_profile.iter().any(|f| f == "group_id"),
|
|
"group_id must be optional on ApiProfile, required list: {api_profile:?}"
|
|
);
|
|
assert_eq!(
|
|
spec["components"]["schemas"]["ApiProfile"]["properties"]["group_id"]["type"],
|
|
serde_json::json!(["string", "null"]),
|
|
"group_id must be a nullable string, not a free-form object"
|
|
);
|
|
|
|
let update_profile = schema_required(&spec, "UpdateProfileRequest");
|
|
assert!(
|
|
!update_profile.iter().any(|f| f == "group_id"),
|
|
"group_id must be optional, required list: {update_profile:?}"
|
|
);
|
|
|
|
let update_proxy = schema_required(&spec, "UpdateProxyRequest");
|
|
assert!(
|
|
!update_proxy.iter().any(|f| f == "proxy_settings"),
|
|
"proxy_settings must be optional on update, required list: {update_proxy:?}"
|
|
);
|
|
|
|
let proxy_settings = schema_required(&spec, "ProxySettings");
|
|
for field in ["username", "password", "vless_uri"] {
|
|
assert!(
|
|
!proxy_settings.iter().any(|candidate| candidate == field),
|
|
"{field} must be optional in proxy settings, required list: {proxy_settings:?}"
|
|
);
|
|
assert!(
|
|
spec["components"]["schemas"]["ProxySettings"]["properties"][field].is_object(),
|
|
"{field} must be present in the served ProxySettings schema"
|
|
);
|
|
}
|
|
|
|
let import_profiles = schema_required(&spec, "ImportProfilesRequest");
|
|
for field in ["group_id", "duplicate_strategy", "wayfern_config"] {
|
|
assert!(
|
|
!import_profiles.iter().any(|f| f == field),
|
|
"{field} must be optional on profile import, required list: {import_profiles:?}"
|
|
);
|
|
}
|
|
|
|
let import_item = schema_required(&spec, "ImportProfileItem");
|
|
for field in ["proxy_id", "vpn_id", "browser_type"] {
|
|
assert!(
|
|
!import_item.iter().any(|f| f == field),
|
|
"{field} must be optional on import items, required list: {import_item:?}"
|
|
);
|
|
}
|
|
|
|
// A remote launch with no URL just opens the browser; forcing generated
|
|
// clients to send one would make the common case the awkward one.
|
|
let run_remote = schema_required(&spec, "RunRemoteRequest");
|
|
assert!(
|
|
!run_remote.iter().any(|f| f == "url"),
|
|
"url must be optional on a remote launch, required list: {run_remote:?}"
|
|
);
|
|
|
|
// A run-now with no cap inherits the schedule's own.
|
|
let start_run = schema_required(&spec, "StartCookieBotRunRequest");
|
|
assert!(
|
|
start_run.iter().any(|f| f == "profile_id"),
|
|
"profile_id is the one thing a run cannot infer, required list: {start_run:?}"
|
|
);
|
|
assert!(
|
|
!start_run.iter().any(|f| f == "max_minutes"),
|
|
"max_minutes must be optional, required list: {start_run:?}"
|
|
);
|
|
|
|
// This machine already knows the profile's name and operating system, and
|
|
// a caller-supplied platform that disagrees is refused rather than
|
|
// honoured — so neither may be marked required.
|
|
let set_schedule = schema_required(&spec, "SetCookieBotScheduleRequest");
|
|
for field in [
|
|
"profile_name",
|
|
"platform",
|
|
"jitter_seconds",
|
|
"sites",
|
|
"acknowledge_conflict",
|
|
] {
|
|
assert!(
|
|
!set_schedule.iter().any(|f| f == field),
|
|
"{field} must be optional when enrolling, required list: {set_schedule:?}"
|
|
);
|
|
}
|
|
|
|
// A freshly created enrolment has never run, so every one of these is
|
|
// absent on the first read. Marking them required would make a generated
|
|
// client reject the response it gets immediately after enrolling.
|
|
let schedule = schema_required(&spec, "CookieBotSchedule");
|
|
for field in ["next_run_at", "last_run_at", "last_run_id", "updated_at"] {
|
|
assert!(
|
|
!schedule.iter().any(|f| f == field),
|
|
"{field} must be optional on a schedule, required list: {schedule:?}"
|
|
);
|
|
}
|
|
|
|
let run = schema_required(&spec, "CookieBotRun");
|
|
for field in ["started_at", "ended_at", "outcome_code", "session_id"] {
|
|
assert!(
|
|
!run.iter().any(|f| f == field),
|
|
"{field} must be optional on a run, required list: {run:?}"
|
|
);
|
|
}
|
|
|
|
// Only `session_id` and `status` are guaranteed while a session is still
|
|
// provisioning; everything else arrives as the session progresses.
|
|
let session = schema_required(&spec, "RemoteSessionState");
|
|
for field in [
|
|
"profile_id",
|
|
"platform",
|
|
"kind",
|
|
"run_id",
|
|
"started_at",
|
|
"ready_at",
|
|
"closed_at",
|
|
"close_reason",
|
|
"billed_seconds",
|
|
] {
|
|
assert!(
|
|
!session.iter().any(|f| f == field),
|
|
"{field} must be optional on a session, required list: {session:?}"
|
|
);
|
|
}
|
|
|
|
// The route predates the pooled budget and returned only two keys. A
|
|
// deployment that has not rolled forward must still satisfy the spec.
|
|
let quota = schema_required(&spec, "RemoteHoursQuota");
|
|
for field in ["members", "breakdown", "scope", "team_id", "seats"] {
|
|
assert!(
|
|
!quota.iter().any(|f| f == field),
|
|
"{field} must be optional on the quota, required list: {quota:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn import_profiles_request_allows_minimal_body() {
|
|
// Only items with source_path + new_profile_name are required; everything
|
|
// else has defaults.
|
|
let json = r#"{"items": [{"source_path": "/tmp/p", "new_profile_name": "Imported"}]}"#;
|
|
let parsed: ImportProfilesRequest =
|
|
serde_json::from_str(json).expect("minimal import body must deserialize");
|
|
assert_eq!(parsed.items.len(), 1);
|
|
assert!(parsed.group_id.is_none());
|
|
assert!(parsed.duplicate_strategy.is_none());
|
|
assert_eq!(parsed.items[0].browser_type, "chromium");
|
|
}
|
|
|
|
// The served /openapi.json comes from the hand-maintained ApiDoc `paths(...)`
|
|
// list, not from the router — endpoints registered on the router but missing
|
|
// from ApiDoc silently disappear from the spec. Lock in the ones that were
|
|
// once dropped, and that removed endpoints stay gone.
|
|
#[test]
|
|
fn a_profile_held_elsewhere_is_a_conflict_not_a_bad_request() {
|
|
// These four refusals all mean "come back in a moment". Answering 400 tells
|
|
// an automation client its request was malformed and to stop retrying, and
|
|
// that is what every one of them did before they had codes at all.
|
|
for code in [
|
|
"PROFILE_RUNNING_REMOTELY",
|
|
"PROFILE_REMOTE_SYNC_PENDING",
|
|
"PROFILE_LOCKED_BY_MEMBER",
|
|
"PROFILE_LOCKED_ELSEWHERE",
|
|
] {
|
|
let (status, body) = manager_error_response(serde_json::json!({ "code": code }).to_string());
|
|
assert_eq!(status, StatusCode::CONFLICT, "{code} must be a 409");
|
|
assert!(body.contains(code), "{code} must reach the caller");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_unreachable_lock_service_is_not_the_callers_fault() {
|
|
let (status, _) =
|
|
manager_error_response(serde_json::json!({ "code": "PROFILE_LOCK_UNAVAILABLE" }).to_string());
|
|
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
|
|
}
|
|
|
|
#[test]
|
|
fn a_remote_session_exposes_a_cdp_endpoint_an_external_client_can_attach_to() {
|
|
// Without this route `run-remote` hands back a session id that nothing
|
|
// outside the app can use: the fleet relay accepts only the user's cloud
|
|
// credential, which an API consumer does not have and must not be given.
|
|
// A Playwright user reads the spec to find this, so it has to be in it.
|
|
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
|
|
let operation = &spec["paths"]["/v1/remote-sessions/{id}/cdp"]["get"];
|
|
assert!(
|
|
operation.is_object(),
|
|
"the CDP attach endpoint must be in the served spec"
|
|
);
|
|
assert!(
|
|
operation["responses"].get("101").is_some(),
|
|
"a WebSocket endpoint must document its upgrade"
|
|
);
|
|
assert_eq!(operation["tags"][0], "remote-sessions");
|
|
}
|
|
|
|
#[test]
|
|
fn a_cdp_attach_failure_is_not_reported_as_a_broken_relay() {
|
|
// A CDP client retries a 502 and gives up on a 404. Reporting "this session
|
|
// is not up yet" as a gateway failure sends it into a loop against a
|
|
// session that is doing exactly what it should.
|
|
use crate::cdp_target::CdpError;
|
|
assert_eq!(
|
|
cdp_error_response(CdpError::NotDrivable("provisioning".into())).0,
|
|
StatusCode::NOT_FOUND
|
|
);
|
|
assert_eq!(
|
|
cdp_error_response(CdpError::Unauthorized("no token".into())).0,
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
assert_eq!(
|
|
cdp_error_response(CdpError::Unreachable("dns".into())).0,
|
|
StatusCode::BAD_GATEWAY
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_kill_route_documents_that_it_can_fail_to_stop_a_remote_browser() {
|
|
// The backend refuses to retire a session it could not stop on the fleet, so
|
|
// stopping can genuinely fail with the browser still running. A spec that
|
|
// only lists 204 tells a client that never happens.
|
|
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
|
|
let responses = &spec["paths"]["/v1/profiles/{id}/kill"]["post"]["responses"];
|
|
assert!(
|
|
responses.get("503").is_some(),
|
|
"kill must document that the fleet may be unreachable"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_local_launch_routes_document_their_conflict() {
|
|
// A profile waiting on a finished remote session refuses a local launch.
|
|
// Undocumented, that reaches an integrator as an unexplained 409.
|
|
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
|
|
for path in ["/v1/profiles/{id}/run", "/v1/profiles/{id}/open-url"] {
|
|
let responses = &spec["paths"][path]["post"]["responses"];
|
|
assert!(
|
|
responses.get("409").is_some(),
|
|
"{path} must document its conflict"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn openapi_spec_covers_registered_routes() {
|
|
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
|
|
let paths = spec["paths"].as_object().expect("paths object");
|
|
|
|
for path in [
|
|
"/v1/vpns/{id}/export",
|
|
"/v1/extensions",
|
|
"/v1/extension-groups",
|
|
"/v1/extensions/{id}",
|
|
"/v1/extension-groups/{id}",
|
|
"/v1/profiles/import",
|
|
"/v1/profiles/import/detect",
|
|
"/v1/proxies/import",
|
|
// The whole remote-execution surface was registered on the router but
|
|
// absent from ApiDoc, so it never appeared in the served spec. This list
|
|
// is a hand-maintained allowlist, which is exactly why that drift went
|
|
// unnoticed — every route added here must also be added below.
|
|
"/v1/profiles/{id}/run-remote",
|
|
"/v1/profiles/{id}/cloud-sync",
|
|
"/v1/remote-sessions/{id}",
|
|
// Remote-session observability and the whole cookie-bot surface. Same
|
|
// hazard, so the same guard: registered on the router is not registered
|
|
// in the spec, and the spec is what an automation client is written from.
|
|
"/v1/remote-sessions",
|
|
"/v1/remote-hours",
|
|
"/v1/cookie-bot/schedules",
|
|
"/v1/cookie-bot/schedules/{profile_id}",
|
|
"/v1/cookie-bot/conflicts",
|
|
"/v1/cookie-bot/runs",
|
|
"/v1/cookie-bot/runs/{run_id}",
|
|
"/v1/cookie-bot/presets",
|
|
"/v1/cookie-bot/usage",
|
|
] {
|
|
assert!(paths.contains_key(path), "missing from ApiDoc: {path}");
|
|
}
|
|
|
|
// Every method of every shared path must survive. Registering two handlers
|
|
// on one path in separate `routes!` calls silently drops one of them, and
|
|
// the spec is where that shows up.
|
|
for (path, method) in [
|
|
("/v1/remote-sessions/{id}", "get"),
|
|
("/v1/remote-sessions/{id}", "delete"),
|
|
("/v1/cookie-bot/schedules/{profile_id}", "get"),
|
|
("/v1/cookie-bot/schedules/{profile_id}", "put"),
|
|
("/v1/cookie-bot/schedules/{profile_id}", "delete"),
|
|
("/v1/cookie-bot/runs", "get"),
|
|
("/v1/cookie-bot/runs", "post"),
|
|
("/v1/cookie-bot/runs/{run_id}", "delete"),
|
|
] {
|
|
assert!(
|
|
paths[path].get(method).is_some(),
|
|
"missing from ApiDoc: {method} {path}"
|
|
);
|
|
}
|
|
|
|
// Every cookie-bot operation must be findable by tag, or it is invisible in
|
|
// a generated client's grouping even though the path exists.
|
|
for (path, method) in [
|
|
("/v1/cookie-bot/schedules", "get"),
|
|
("/v1/cookie-bot/schedules/{profile_id}", "put"),
|
|
("/v1/cookie-bot/runs", "post"),
|
|
("/v1/cookie-bot/usage", "get"),
|
|
] {
|
|
let tags = paths[path][method]["tags"]
|
|
.as_array()
|
|
.unwrap_or_else(|| panic!("{method} {path} has no tags"));
|
|
assert!(
|
|
tags.iter().any(|tag| tag == "cookie-bot"),
|
|
"{method} {path} is not tagged cookie-bot: {tags:?}"
|
|
);
|
|
}
|
|
|
|
// A bot run is accepted, not completed: the fleet browses for minutes
|
|
// after the response. A 200 here would be a lie the client acts on.
|
|
assert!(
|
|
paths["/v1/cookie-bot/runs"]["post"]["responses"]
|
|
.get("202")
|
|
.is_some(),
|
|
"starting a bot run must declare 202 Accepted"
|
|
);
|
|
|
|
assert!(
|
|
!paths.keys().any(|p| p.contains("wayfern-token")),
|
|
"wayfern-token endpoints were removed and must stay out of the spec"
|
|
);
|
|
|
|
// A path with a body that resolves to nothing is worse than a missing
|
|
// path: a generator emits a client for it and the response type is empty.
|
|
// These live in other modules, so `components(schemas(...))` is the only
|
|
// thing pulling them in.
|
|
for schema in [
|
|
"RemoteSessionState",
|
|
"ApiRemoteSessionsResponse",
|
|
"SetCookieBotScheduleRequest",
|
|
"StartCookieBotRunRequest",
|
|
"CookieBotSchedule",
|
|
"CookieBotScheduleList",
|
|
"CookieBotScheduleSaved",
|
|
"CookieBotScheduleDeleted",
|
|
"CookieBotConflict",
|
|
"CookieBotConflictCheck",
|
|
"CookieBotRun",
|
|
"CookieBotRunPage",
|
|
"CookieBotRunStarted",
|
|
"CookieBotPreset",
|
|
"CookieBotPresetList",
|
|
"CookieBotUsage",
|
|
"CookieBotUsageMember",
|
|
"CookieBotUsageProfile",
|
|
"RemoteHoursQuota",
|
|
"RemoteHoursMember",
|
|
"RemoteHoursBreakdown",
|
|
] {
|
|
assert!(
|
|
spec["components"]["schemas"][schema]["properties"].is_object(),
|
|
"schema is missing from the served spec: {schema}"
|
|
);
|
|
}
|
|
|
|
// A response body declared as a path outside this module must resolve to
|
|
// the component that path registered, not to a dangling or inlined name.
|
|
for (path, method, status, schema) in [
|
|
(
|
|
"/v1/cookie-bot/schedules",
|
|
"get",
|
|
"200",
|
|
"CookieBotScheduleList",
|
|
),
|
|
("/v1/cookie-bot/runs", "post", "202", "CookieBotRunStarted"),
|
|
(
|
|
"/v1/cookie-bot/runs/{run_id}",
|
|
"delete",
|
|
"200",
|
|
"CookieBotRun",
|
|
),
|
|
(
|
|
"/v1/remote-sessions/{id}",
|
|
"get",
|
|
"200",
|
|
"RemoteSessionState",
|
|
),
|
|
("/v1/remote-hours", "get", "200", "RemoteHoursQuota"),
|
|
] {
|
|
let reference =
|
|
&paths[path][method]["responses"][status]["content"]["application/json"]["schema"]["$ref"];
|
|
assert_eq!(
|
|
reference.as_str(),
|
|
Some(format!("#/components/schemas/{schema}").as_str()),
|
|
"{method} {path} {status} does not reference {schema}: {reference:?}"
|
|
);
|
|
}
|
|
|
|
// The presets a client may choose from must never carry the behaviour they
|
|
// expand to. A site list, a dwell range or a step programme appearing here
|
|
// would mean the browsing model had leaked out of the server.
|
|
let preset_properties = spec["components"]["schemas"]["CookieBotPreset"]["properties"]
|
|
.as_object()
|
|
.expect("preset properties");
|
|
for leaked in [
|
|
"sites",
|
|
"dwell",
|
|
"dwell_seconds",
|
|
"steps",
|
|
"actions",
|
|
"corpus",
|
|
] {
|
|
assert!(
|
|
!preset_properties.contains_key(leaked),
|
|
"the browsing model leaked into the client contract: {leaked}"
|
|
);
|
|
}
|
|
|
|
for path in [
|
|
"/v1/profiles/{id}/run",
|
|
"/v1/profiles/{id}/open-url",
|
|
"/v1/profiles/{id}/kill",
|
|
"/v1/profiles/{id}/run-remote",
|
|
"/v1/profiles/batch/run",
|
|
"/v1/profiles/batch/stop",
|
|
] {
|
|
assert!(
|
|
paths[path]["post"]["responses"].get("429").is_some(),
|
|
"automation route is missing its 429 response: {path}"
|
|
);
|
|
}
|
|
|
|
assert!(
|
|
paths["/v1/cookie-bot/runs"]["post"]["responses"]
|
|
.get("429")
|
|
.is_some(),
|
|
"starting a bot run is metered and must declare its 429"
|
|
);
|
|
|
|
// The automation routes that are not POSTs. Both declared a 429 that
|
|
// `is_automation_request` could never produce, because that function
|
|
// returned early for every non-POST method.
|
|
for path in ["/v1/remote-sessions/{id}", "/v1/cookie-bot/runs/{run_id}"] {
|
|
assert!(
|
|
paths[path]["delete"]["responses"].get("429").is_some(),
|
|
"metered stop route is missing its 429 response: {path}"
|
|
);
|
|
}
|
|
|
|
// Schedule writes are configuration, not automation. Declaring a 429 they
|
|
// can never return would send a client building retry logic for a status
|
|
// it will never see.
|
|
for method in ["put", "delete"] {
|
|
assert!(
|
|
paths["/v1/cookie-bot/schedules/{profile_id}"][method]["responses"]
|
|
.get("429")
|
|
.is_none(),
|
|
"a schedule write must not declare a 429: {method}"
|
|
);
|
|
}
|
|
}
|
|
}
|