Required the API token for every runtime API route (#1045)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-11 10:07:07 +02:00
1 parent 4f966eed86
commit 8badadb35f
24 files changed
+231 -79

No files matched your search

+2
View File
@@ -4299,6 +4299,7 @@ dependencies = [
"serde_json",
"sha2 0.11.0",
"strum_macros",
"subtle",
"symphonia",
"sys-locale",
"sysinfo",
@@ -4316,6 +4317,7 @@ dependencies = [
"tokio",
"tokio-stream",
"toml 1.1.4+spec-1.1.0",
"tower",
"webkit2gtk",
"webm-iterable",
"whoami",
+5
View File
@@ -33,6 +33,7 @@ axum-server = { version = "0.8.0", features = ["tls-rustls"] }
rustls = { version = "0.23.45", default-features = false, features = ["aws_lc_rs"] }
rand = "0.10.2"
rand_chacha = "0.10.0"
subtle = "2.6.1"
base64 = "0.22.1"
aes = "0.9.1"
cbc = "0.2.1"
@@ -79,6 +80,10 @@ toml = "1.1.4"
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp"] }
tokenizers = "0.23.1"
[dev-dependencies]
# Sends requests through the runtime API router in tests, without starting a server.
tower = { version = "0.5.2", features = ["util"] }
[patch.crates-io]
# Issue: This repo was not updated since 2020. The rand crate was outdated. We patched it to use a newer version of rand.
# State: There is a PR for a long time, but it was not merged. We use the git version for now. Qdrant Edge still depends
+31 -1
View File
@@ -2,6 +2,7 @@ use log::error;
use rand::rngs::SysRng;
use rand::{Rng, SeedableRng};
use rand_chacha::ChaChaRng;
use subtle::ConstantTimeEq;
/// The API token data structure used to authenticate requests.
pub struct APIToken {
@@ -31,8 +32,12 @@ impl APIToken {
}
/// Validates the received token against the valid token.
///
/// The comparison takes the same time no matter where the two tokens differ. A comparison
/// which stops at the first difference would let the time of each refusal tell how much of a
/// guess was right.
pub fn validate(&self, received_token: &Self) -> bool {
received_token.to_hex_text() == self.to_hex_text()
received_token.to_hex_text().as_bytes().ct_eq(self.to_hex_text().as_bytes()).into()
}
}
@@ -50,4 +55,29 @@ pub fn generate_api_token() -> APIToken {
});
rng.fill_bytes(&mut token);
APIToken::from_bytes(token.to_vec())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_accepts_only_the_identical_token() {
let valid_token = APIToken::from_hex_text("0123456789abcdef");
assert!(valid_token.validate(&APIToken::from_hex_text("0123456789abcdef")));
assert!(!valid_token.validate(&APIToken::from_hex_text("0123456789abcdee")));
assert!(!valid_token.validate(&APIToken::from_hex_text("1123456789abcdef")));
assert!(!valid_token.validate(&APIToken::from_hex_text("0123456789abcde")));
assert!(!valid_token.validate(&APIToken::from_hex_text("0123456789abcdef0")));
assert!(!valid_token.validate(&APIToken::from_hex_text("")));
}
#[test]
fn a_generated_token_validates_against_its_own_text() {
let token = generate_api_token();
assert_eq!(token.to_hex_text().len(), 64);
assert!(token.validate(&APIToken::from_hex_text(token.to_hex_text())));
}
}
+8 -9
View File
@@ -20,7 +20,6 @@ use tauri_plugin_updater::{UpdaterExt, Update};
use tauri_plugin_opener::OpenerExt;
use tokio::sync::broadcast;
use tokio::time;
use crate::api_token::APIToken;
use crate::clipboard::shutdown_clipboard;
use crate::dotnet::{cleanup_dotnet_server, start_dotnet_server, stop_dotnet_server};
use crate::environment::{
@@ -379,7 +378,7 @@ fn should_open_in_system_browser<R: tauri::Runtime>(webview: &tauri::Webview<R>,
/// When the client disconnects, the stream is closed. But we try to not lose events in between.
/// The client is expected to reconnect automatically when the connection is closed and continue
/// listening for events.
pub async fn get_event_stream(_token: APIToken) -> Response {
pub async fn get_event_stream() -> Response {
// Get the lock to the event broadcast sender:
let event_broadcast_lock = EVENT_BROADCAST.lock().unwrap();
@@ -652,7 +651,7 @@ pub async fn change_location_to(url: &str) {
}
/// Checks for updates.
pub async fn check_for_update(_token: APIToken) -> Json<CheckUpdateResponse> {
pub async fn check_for_update() -> Json<CheckUpdateResponse> {
if let Some(reason) = self_update_blocked_reason(is_flatpak(), installation_kind()) {
warn!(Source = "Updater"; "Skipping update check because {reason}.");
return Json(CheckUpdateResponse {
@@ -737,7 +736,7 @@ pub struct CheckUpdateResponse {
}
/// Installs the update.
pub async fn install_update(_token: APIToken) {
pub async fn install_update() {
if let Some(reason) = self_update_blocked_reason(is_flatpak(), installation_kind()) {
warn!(Source = "Updater"; "Skipping update installation because {reason}.");
return;
@@ -819,7 +818,7 @@ pub struct AppExitResponse {
}
/// Requests a controlled shutdown of the entire desktop application.
pub async fn exit_app(_token: APIToken) -> Json<AppExitResponse> {
pub async fn exit_app() -> Json<AppExitResponse> {
let app_handle = {
let main_window_lock = MAIN_WINDOW.lock().unwrap();
match main_window_lock.as_ref() {
@@ -848,7 +847,7 @@ pub async fn exit_app(_token: APIToken) -> Json<AppExitResponse> {
/// Registers or updates a global shortcut. If the shortcut string is empty,
/// the existing shortcut for that name will be unregistered.
pub async fn register_shortcut(_token: APIToken, payload: Json<RegisterShortcutRequest>) -> Json<ShortcutResponse> {
pub async fn register_shortcut(payload: Json<RegisterShortcutRequest>) -> Json<ShortcutResponse> {
let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().map(|window| window.app_handle().clone());
let event_sender = EVENT_BROADCAST.lock().unwrap().clone();
Json(crate::global_shortcuts::register(app_handle, event_sender, payload.0).await)
@@ -873,7 +872,7 @@ pub struct ShortcutValidationResponse {
/// Validates a shortcut string without registering it.
/// Checks if the shortcut syntax is valid and if it
/// conflicts with existing shortcuts.
pub async fn validate_shortcut(_token: APIToken, payload: Json<ValidateShortcutRequest>) -> Json<ShortcutValidationResponse> {
pub async fn validate_shortcut(payload: Json<ValidateShortcutRequest>) -> Json<ShortcutValidationResponse> {
let shortcut = payload.shortcut.clone();
// Empty shortcuts are always valid (means "disabled"):
@@ -924,13 +923,13 @@ pub async fn validate_shortcut(_token: APIToken, payload: Json<ValidateShortcutR
/// Tauri shortcuts are temporarily unregistered and restored on resume.
/// This is useful when opening a dialog to configure shortcuts, so the user can
/// press the current shortcut to re-enter it without triggering the action.
pub async fn suspend_shortcuts(_token: APIToken) -> Json<ShortcutResponse> {
pub async fn suspend_shortcuts() -> Json<ShortcutResponse> {
let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().map(|window| window.app_handle().clone());
Json(crate::global_shortcuts::suspend(app_handle).await)
}
/// Resumes shortcut processing by re-registering all shortcuts with the OS.
pub async fn resume_shortcuts(_token: APIToken) -> Json<ShortcutResponse> {
pub async fn resume_shortcuts() -> Json<ShortcutResponse> {
let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().map(|window| window.app_handle().clone());
let event_sender = EVENT_BROADCAST.lock().unwrap().clone();
Json(crate::global_shortcuts::resume(app_handle, event_sender).await)
+1 -2
View File
@@ -5,7 +5,6 @@ use axum::Json;
use log::{debug, error, warn};
use once_cell::sync::Lazy;
use serde::Serialize;
use crate::api_token::APIToken;
use crate::encryption::{EncryptedText, ENCRYPTION};
/// The process-wide clipboard instance. On Linux, retaining this instance keeps the app's
@@ -76,7 +75,7 @@ fn release_clipboard<B>(clipboard: &mut Option<B>) -> bool {
}
/// Sets the clipboard text to the provided encrypted text.
pub async fn set_clipboard(_token: APIToken, encrypted_text: String) -> Json<SetClipboardResponse> {
pub async fn set_clipboard(encrypted_text: String) -> Json<SetClipboardResponse> {
//
// The clipboard backend of the operating system may take a moment to answer, a failed write is
// retried with a fresh backend, and the lock waits for a write still running. None of this
+2 -3
View File
@@ -8,7 +8,6 @@ use once_cell::sync::Lazy;
use tauri::{Manager, Url};
use tauri_plugin_shell::process::{CommandChild, CommandEvent, TerminatedPayload};
use tauri_plugin_shell::ShellExt;
use crate::api_token::APIToken;
use crate::runtime_api_token::API_TOKEN;
use crate::app_window::change_location_to;
use crate::runtime_certificate::CERTIFICATE_FINGERPRINT;
@@ -109,7 +108,7 @@ fn describe_termination(payload: &TerminatedPayload) -> String {
/// Returns the desired port of the .NET server. Our .NET app calls this endpoint to get
/// the port where the .NET server should listen to.
pub async fn dotnet_port(_token: APIToken) -> String {
pub async fn dotnet_port() -> String {
let dotnet_server_port = *DOTNET_SERVER_PORT;
format!("{dotnet_server_port}")
}
@@ -287,7 +286,7 @@ pub fn start_dotnet_server<R: tauri::Runtime>(app_handle: tauri::AppHandle<R>) {
}
/// This endpoint is called by the .NET server to signal that the server is ready.
pub async fn dotnet_ready(_token: APIToken) {
pub async fn dotnet_ready() {
// We create a manual scope for the lock to be released as soon as possible.
// This is necessary because we cannot await any function while the lock is
+7 -8
View File
@@ -1,4 +1,3 @@
use crate::api_token::APIToken;
use axum::http::StatusCode;
use axum::Json;
use log::{debug, error, info, warn};
@@ -58,7 +57,7 @@ static USER_LANGUAGE: OnceLock<String> = OnceLock::new();
static INSTALLATION_KIND: OnceLock<InstallationKind> = OnceLock::new();
/// Returns the config directory.
pub async fn get_config_directory(_token: APIToken) -> String {
pub async fn get_config_directory() -> String {
match CONFIG_DIRECTORY.get() {
Some(config_directory) => config_directory.clone(),
None => String::from(""),
@@ -66,7 +65,7 @@ pub async fn get_config_directory(_token: APIToken) -> String {
}
/// Returns the data directory.
pub async fn get_data_directory(_token: APIToken) -> String {
pub async fn get_data_directory() -> String {
match DATA_DIRECTORY.get() {
Some(data_directory) => data_directory.clone(),
None => String::from(""),
@@ -74,7 +73,7 @@ pub async fn get_data_directory(_token: APIToken) -> String {
}
/// Returns the current user's username.
pub async fn read_user_name(_token: APIToken) -> String {
pub async fn read_user_name() -> String {
whoami::username().unwrap_or_else(|e| {
error!("Failed to read the current OS username: {e}.");
String::new()
@@ -129,7 +128,7 @@ pub struct RuntimeInfo {
pub installation_kind: InstallationKind,
}
pub async fn get_runtime_info(_token: APIToken) -> Json<RuntimeInfo> {
pub async fn get_runtime_info() -> Json<RuntimeInfo> {
Json(RuntimeInfo {
working_directory: env::current_dir()
.map(|path| path.to_string_lossy().into_owned())
@@ -545,7 +544,7 @@ fn detect_user_language() -> (String, LanguageDetectionSource) {
)
}
pub async fn read_user_language(_token: APIToken) -> String {
pub async fn read_user_language() -> String {
USER_LANGUAGE
.get_or_init(|| {
let (user_language, source) = detect_user_language();
@@ -642,13 +641,13 @@ struct EnterpriseSourceData {
encryption_secret: String,
}
pub async fn read_enterprise_env_config_encryption_secret(_token: APIToken) -> Result<String, (StatusCode, String)> {
pub async fn read_enterprise_env_config_encryption_secret() -> Result<String, (StatusCode, String)> {
debug!("Trying to read the effective enterprise configuration encryption secret.");
read_enterprise_sources(|| resolve_effective_enterprise_secret_source().encryption_secret).await
}
/// Returns all enterprise configurations from the effective source.
pub async fn read_enterprise_configs(_token: APIToken) -> Result<Json<Vec<EnterpriseConfig>>, (StatusCode, String)> {
pub async fn read_enterprise_configs() -> Result<Json<Vec<EnterpriseConfig>>, (StatusCode, String)> {
info!("Trying to read the effective enterprise configurations.");
read_enterprise_sources(|| Json(resolve_effective_enterprise_config_source().configs)).await
}
+1 -7
View File
@@ -5,7 +5,6 @@ use file_format::FileFormat;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tauri_plugin_dialog::{DialogExt, FileDialogBuilder};
use crate::api_token::APIToken;
use crate::app_window::MAIN_WINDOW;
use crate::file_data::is_executable_content;
@@ -119,7 +118,6 @@ pub struct PreviousFile {
/// Let the user select a directory.
pub async fn select_directory(
_token: APIToken,
Query(query): Query<SelectDirectoryQuery>,
previous_directory: Option<Json<PreviousDirectory>>,
) -> Json<DirectorySelectionResponse> {
@@ -174,7 +172,6 @@ pub async fn select_directory(
/// Let the user select a file.
pub async fn select_file(
_token: APIToken,
payload: Json<SelectFileOptions>,
) -> Json<FileSelectionResponse> {
// Create a new file dialog builder:
@@ -233,7 +230,6 @@ pub async fn select_file(
/// Let the user select some files.
pub async fn select_files(
_token: APIToken,
payload: Json<SelectFileOptions>,
) -> Json<FilesSelectionResponse> {
// Create a new file dialog builder:
@@ -282,7 +278,7 @@ pub async fn select_files(
}
}
pub async fn save_file(_token: APIToken, payload: Json<SaveFileOptions>) -> Json<FileSaveResponse> {
pub async fn save_file(payload: Json<SaveFileOptions>) -> Json<FileSaveResponse> {
// Create a new file dialog builder:
let file_dialog = MAIN_WINDOW
.lock()
@@ -343,7 +339,6 @@ pub async fn save_file(_token: APIToken, payload: Json<SaveFileOptions>) -> Json
}
pub async fn open_path_in_file_manager(
_token: APIToken,
payload: Json<OpenPathOptions>,
) -> Json<OpenPathResponse> {
let requested_path = PathBuf::from(payload.path.trim());
@@ -417,7 +412,6 @@ async fn open_file_manager_target(requested_path: &Path) -> Result<(), String> {
/// The page is best effort and never decides whether this succeeded: a viewer which cannot be told
/// a page still shows the document, which is what the user asked for by clicking a source.
pub async fn open_document(
_token: APIToken,
payload: Json<OpenDocumentOptions>,
) -> Json<OpenDocumentResponse> {
let requested_path = PathBuf::from(payload.path.trim());
-2
View File
@@ -1,7 +1,6 @@
use std::cmp::min;
use std::collections::VecDeque;
use std::convert::Infallible;
use crate::api_token::APIToken;
use crate::pandoc::PandocProcessBuilder;
use crate::pdfium::{with_pdfium_access, PdfiumInit};
use crate::prompt_injection::{Finding as PromptInjectionFinding, Sanitizer};
@@ -504,7 +503,6 @@ fn filter_failed_error() -> ExtractionError {
}
pub async fn extract_data(
_token: APIToken,
query: std::result::Result<Query<ExtractDataQuery>, QueryRejection>,
) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {
let query = match query {
+2 -3
View File
@@ -13,7 +13,6 @@ use log::{kv, Level};
use log::kv::{Key, Value, VisitSource};
use axum::Json;
use serde::{Deserialize, Serialize};
use crate::api_token::APIToken;
use crate::environment::{is_dev, is_flatpak};
const FLATPAK_PERSISTENT_DATA_DIRECTORY: &str = "/var/data";
@@ -406,7 +405,7 @@ fn file_logger_format(
write!(w, "{}", record.args())
}
pub async fn get_log_paths(_token: APIToken) -> Json<LogPathsResponse> {
pub async fn get_log_paths() -> Json<LogPathsResponse> {
Json(LogPathsResponse {
log_startup_path: LOG_STARTUP_PATH.get().expect("No startup log path was set").clone(),
log_app_path: LOG_APP_PATH.get().expect("No app log path was set").clone(),
@@ -461,7 +460,7 @@ fn log_with_level(
}
/// Logs an event from the .NET server.
pub async fn log_event(_token: APIToken, Json(event): Json<LogEvent>) -> Json<LogEventResponse> {
pub async fn log_event(Json(event): Json<LogEvent>) -> Json<LogEventResponse> {
let level = parse_dotnet_log_level(&event.level);
let message = event.message.as_str();
let category = event.category.as_str();
+1 -5
View File
@@ -36,8 +36,6 @@ use tokio_stream::StreamExt;
use webm_iterable::matroska_spec::{Master, MatroskaSpec, SimpleBlock};
use webm_iterable::{WebmIterator, WebmWriter, WriteOptions};
use crate::api_token::APIToken;
/// Sample rate required by the normalized WebM/Opus output contract.
const OUTPUT_SAMPLE_RATE: u32 = 48_000;
@@ -333,7 +331,6 @@ impl MediaJob {
/// Registers and immediately schedules a media normalization job.
pub async fn create_job(
_token: APIToken,
Json(request): Json<CreateMediaJobRequest>,
) -> Result<Json<CreateMediaJobResponse>, (StatusCode, Json<MediaError>)> {
let input_path = PathBuf::from(&request.input_path);
@@ -415,7 +412,6 @@ async fn retain_terminal_job(job_id: String) {
/// Streams the current snapshot followed by live media job events.
pub async fn get_job_events(
_token: APIToken,
Path(job_id): Path<String>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, StatusCode> {
let job = JOBS.read().unwrap().get(&job_id).cloned().ok_or(StatusCode::NOT_FOUND)?;
@@ -431,7 +427,7 @@ pub async fn get_job_events(
}
/// Requests cooperative cancellation of a running media job.
pub async fn cancel_job(_token: APIToken, Path(job_id): Path<String>) -> impl IntoResponse {
pub async fn cancel_job(Path(job_id): Path<String>) -> impl IntoResponse {
match JOBS.read().unwrap().get(&job_id) {
Some(job) => {
job.cancelled.store(true, Ordering::Relaxed);
-3
View File
@@ -9,7 +9,6 @@
//! pages, each with its own content, title, description, and authors. Those go through the
//! batch endpoint, which filters them in one request instead of one round trip per field.
use crate::api_token::APIToken;
use axum::http::StatusCode;
use axum::Json;
use serde::{Deserialize, Serialize};
@@ -46,7 +45,6 @@ pub struct SanitizeBatchResponse {
}
pub async fn sanitize(
_token: APIToken,
Json(request): Json<SanitizeRequest>,
) -> Result<Json<SanitizeResponse>, (StatusCode, String)> {
//
@@ -73,7 +71,6 @@ pub async fn sanitize(
}
pub async fn sanitize_batch(
_token: APIToken,
Json(request): Json<SanitizeBatchRequest>,
) -> Result<Json<SanitizeBatchResponse>, (StatusCode, String)> {
//
+8 -10
View File
@@ -18,7 +18,6 @@ use qdrant_edge::{
use serde::{Deserialize, Serialize};
use tauri::Manager;
use crate::api_token::APIToken;
use crate::environment::DATA_DIRECTORY;
use crate::metadata::META_DATA;
@@ -472,7 +471,7 @@ fn qdrant_edge_base_path() -> QdrantEdgeResult<PathBuf> {
.join("vector_database"))
}
pub async fn qdrant_edge_info(_token: APIToken) -> Json<QdrantEdgeServiceInfo> {
pub async fn qdrant_edge_info() -> Json<QdrantEdgeServiceInfo> {
let (current_status, mut unavailable_reason) = {
let status = QDRANT_EDGE_STATUS.lock().unwrap();
(status.status, status.unavailable_reason.clone())
@@ -513,37 +512,37 @@ fn read_qdrant_edge_info() -> Option<QdrantEdgeInfo> {
.and_then(|database| database.info().ok())
}
pub async fn ensure_qdrant_edge_store(_token: APIToken, Json(request): Json<EnsureQdrantEdgeStoreRequest>) -> Json<QdrantEdgeResponse<QdrantEdgeEnsureStoreResult>> {
pub async fn ensure_qdrant_edge_store(Json(request): Json<EnsureQdrantEdgeStoreRequest>) -> Json<QdrantEdgeResponse<QdrantEdgeEnsureStoreResult>> {
run_qdrant_edge_request(move |database| {
database.ensure_store_exists(&request.store_name, &request.data_source_name, request.vector_size)
}).await
}
pub async fn insert_qdrant_edge_embedding(_token: APIToken, Json(request): Json<InsertQdrantEdgeEmbeddingRequest>) -> Json<QdrantEdgeResponse<()>> {
pub async fn insert_qdrant_edge_embedding(Json(request): Json<InsertQdrantEdgeEmbeddingRequest>) -> Json<QdrantEdgeResponse<()>> {
run_qdrant_edge_request(move |database| {
database.insert_embedding(&request.store_name, request.points)
}).await
}
pub async fn search_qdrant_edge_embeddings(_token: APIToken, Json(request): Json<SearchQdrantEdgeEmbeddingRequest>) -> Json<QdrantEdgeResponse<Vec<QdrantEdgeSearchResult>>> {
pub async fn search_qdrant_edge_embeddings(Json(request): Json<SearchQdrantEdgeEmbeddingRequest>) -> Json<QdrantEdgeResponse<Vec<QdrantEdgeSearchResult>>> {
run_qdrant_edge_request(move |database| {
database.search_embedding(&request.store_name, request.vector, request.max_matches, request.point_ids)
}).await
}
pub async fn delete_qdrant_edge_embedding_by_file(_token: APIToken, Json(request): Json<DeleteQdrantEdgeEmbeddingByFileRequest>) -> Json<QdrantEdgeResponse<()>> {
pub async fn delete_qdrant_edge_embedding_by_file(Json(request): Json<DeleteQdrantEdgeEmbeddingByFileRequest>) -> Json<QdrantEdgeResponse<()>> {
run_qdrant_edge_request(move |database| {
database.delete_embedding_by_file(&request.store_name, &request.file_path)
}).await
}
pub async fn optimize_qdrant_edge_store(_token: APIToken, Json(request): Json<OptimizeQdrantEdgeStoreRequest>) -> Json<QdrantEdgeResponse<()>> {
pub async fn optimize_qdrant_edge_store(Json(request): Json<OptimizeQdrantEdgeStoreRequest>) -> Json<QdrantEdgeResponse<()>> {
run_qdrant_edge_request(move |database| {
database.optimize_store(&request.store_name)
}).await
}
pub async fn delete_qdrant_edge_store(_token: APIToken, Json(request): Json<DeleteQdrantEdgeStoreRequest>) -> Json<QdrantEdgeResponse<()>> {
pub async fn delete_qdrant_edge_store(Json(request): Json<DeleteQdrantEdgeStoreRequest>) -> Json<QdrantEdgeResponse<()>> {
run_qdrant_edge_request(move |database| {
database.delete_store(&request.store_name)
}).await
@@ -1302,7 +1301,6 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn a_request_waiting_for_the_database_leaves_the_runtime_free() {
let request = optimize_qdrant_edge_store(
APIToken::from_hex_text("test"),
Json(OptimizeQdrantEdgeStoreRequest { store_name: SEARCH_STORE.to_string() }),
);
@@ -1314,7 +1312,7 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn the_info_waiting_for_the_database_leaves_the_runtime_free() {
let request = qdrant_edge_info(APIToken::from_hex_text("test"));
let request = qdrant_edge_info();
let info = assert_runtime_stays_free(&QDRANT_EDGE_DATABASE, request).await;
assert!(!info.is_available);
+137 -7
View File
@@ -1,11 +1,13 @@
use log::info;
use once_cell::sync::Lazy;
use axum::extract::DefaultBodyLimit;
use axum::middleware;
use axum::routing::{delete, get, post};
use axum::Router;
use axum_server::tls_rustls::RustlsConfig;
use std::net::SocketAddr;
use std::sync::Once;
use crate::api_token::APIToken;
use crate::runtime_certificate::{CERTIFICATE, CERTIFICATE_PRIVATE_KEY};
use crate::environment::is_dev;
use crate::network::get_available_port;
@@ -28,13 +30,9 @@ pub static API_SERVER_PORT: Lazy<u16> = Lazy::new(|| {
}
});
/// Starts the runtime API server. The server is used to communicate with the .NET server and
/// to provide additional functionality to the Tauri app.
pub fn start_runtime_api() {
let api_port = *API_SERVER_PORT;
info!("Try to start the API server on 'http://localhost:{api_port}'...");
let app = Router::new()
/// Creates the router with every route of the runtime API, each of which requires the API token.
fn create_router() -> Router {
let router = Router::new()
.route("/system/dotnet/port", get(crate::dotnet::dotnet_port))
.route("/system/dotnet/ready", get(crate::dotnet::dotnet_ready))
.route("/system/qdrant-edge/info", get(crate::qdrant_edge_database::qdrant_edge_info))
@@ -91,6 +89,29 @@ pub fn start_runtime_api() {
.route("/shortcuts/suspend", post(crate::app_window::suspend_shortcuts))
.route("/shortcuts/resume", post(crate::app_window::resume_shortcuts));
require_api_token(router)
}
/// Requires a valid API token for every route of the given router.
///
/// This layer is the only place where the runtime API checks the token, so the handlers take no
/// `APIToken` argument. Asking each handler for it left a route open to every process on this
/// machine as soon as one handler forgot it.
///
/// `route_layer` protects only the routes registered before it, so call this function after the
/// last route. Unknown paths still answer 404 without checking the token.
fn require_api_token(router: Router) -> Router {
router.route_layer(middleware::from_extractor::<APIToken>())
}
/// Starts the runtime API server. The server is used to communicate with the .NET server and
/// to provide additional functionality to the Tauri app.
pub fn start_runtime_api() {
let api_port = *API_SERVER_PORT;
info!("Try to start the API server on 'http://localhost:{api_port}'...");
let app = create_router();
tauri::async_runtime::spawn(async move {
install_rustls_crypto_provider();
@@ -159,3 +180,112 @@ pub(crate) mod test_support {
response
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use regex::Regex;
use tokio::time::timeout;
use tower::ServiceExt;
use crate::runtime_api_token::API_TOKEN;
/// How long a route may take to refuse a request without the token. The refusal needs no work,
/// so a route which takes longer has passed the request on to its handler. In a test, some
/// handlers then never answer, for example one which waits for the main window, and others
/// panic because the app around them is missing.
const REFUSAL_LIMIT: Duration = Duration::from_secs(1);
/// One route which `create_router` registers.
struct RegisteredRoute {
method: String,
path: String,
}
/// Reads every route from the source of `create_router`, so that a new route is checked
/// without anybody having to add it to a test.
fn registered_routes() -> Vec<RegisteredRoute> {
let source = include_str!("runtime_api.rs");
let start = source.find("fn create_router()").unwrap();
let end = start + source[start..].find("\n}").unwrap();
let create_router_source = &source[start..end];
let route_pattern = Regex::new(r#"\.route\("([^"]+)",\s*(get|post|put|patch|delete)\("#).unwrap();
let path_parameter_pattern = Regex::new(r"\{[^}]+\}").unwrap();
let routes: Vec<RegisteredRoute> = route_pattern
.captures_iter(create_router_source)
.map(|route| RegisteredRoute {
method: route[2].to_uppercase(),
path: path_parameter_pattern.replace_all(&route[1], "x").into_owned(),
})
.collect();
//
// A route which the pattern cannot read would silently drop out of the check, for example
// one whose methods are not a plain get, post, put, patch, or delete call. Extend the
// pattern then.
//
assert_eq!(
routes.len(),
create_router_source.matches(".route(").count(),
"The test could not read every route of create_router.",
);
assert!(!routes.is_empty(), "The test found no route in create_router.");
routes
}
/// Sends one request through the router, without a server, and returns the status of the response.
async fn send(router: Router, request: Request<Body>) -> StatusCode {
router.oneshot(request).await.unwrap().status()
}
/// Builds a request with the given token header, or without one.
fn request(method: &str, uri: &str, token: Option<&str>, body: &'static str) -> Request<Body> {
let mut builder = Request::builder()
.method(method)
.uri(uri)
.header("content-type", "application/json");
if let Some(token) = token {
builder = builder.header("token", token);
}
builder.body(Body::from(body)).unwrap()
}
#[tokio::test]
async fn require_api_token_lets_only_the_valid_token_through() {
let router = require_api_token(Router::new().route("/probe", get(|| async {})));
assert_eq!(send(router.clone(), request("GET", "/probe", None, "")).await, StatusCode::UNAUTHORIZED);
assert_eq!(send(router.clone(), request("GET", "/probe", Some("0123abcd"), "")).await, StatusCode::UNAUTHORIZED);
assert_eq!(send(router, request("GET", "/probe", Some(API_TOKEN.to_hex_text()), "")).await, StatusCode::OK);
}
#[tokio::test]
async fn every_route_requires_the_api_token() {
let mut unprotected_routes = Vec::new();
for route in registered_routes() {
// A task of its own turns a panicking handler into a finding instead of ending the test.
let answer = tokio::spawn(send(create_router(), request(&route.method, &route.path, None, "")));
match timeout(REFUSAL_LIMIT, answer).await {
Ok(Ok(StatusCode::UNAUTHORIZED)) => {},
Ok(Ok(status)) => unprotected_routes.push(format!("{} {} answered {status}", route.method, route.path)),
Ok(Err(_)) => unprotected_routes.push(format!("{} {} panicked in its handler", route.method, route.path)),
Err(_) => unprotected_routes.push(format!("{} {} did not refuse within {REFUSAL_LIMIT:?}", route.method, route.path)),
}
}
assert!(unprotected_routes.is_empty(), "These routes answer without the API token: {unprotected_routes:#?}");
}
#[tokio::test]
async fn a_valid_token_gets_through_to_the_handler() {
// The handler fails on the incomplete body, before it touches any file.
let with_token = request("POST", "/image/prepare", Some(API_TOKEN.to_hex_text()), "{}");
assert_eq!(send(create_router(), with_token).await, StatusCode::UNPROCESSABLE_ENTITY);
}
}
-7
View File
@@ -26,11 +26,4 @@ where
None => Err(StatusCode::UNAUTHORIZED),
}
}
}
/// The API token error types.
#[derive(Debug)]
pub enum APITokenError {
Missing,
Invalid,
}
+3 -4
View File
@@ -2,7 +2,6 @@ use axum::Json;
use keyring_core::{Entry, Error as KeyringError};
use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize};
use crate::api_token::APIToken;
use crate::encryption::{EncryptedText, ENCRYPTION};
/// A structured issue reported by the native credential store.
@@ -96,7 +95,7 @@ where
}
/// Stores a secret in the secret store using the operating system's keyring.
pub async fn store_secret(_token: APIToken, request: Json<StoreSecret>) -> Json<StoreSecretResponse> {
pub async fn store_secret(request: Json<StoreSecret>) -> Json<StoreSecretResponse> {
match run_secret_store_request(move || store_secret_sync(request)).await {
Ok(response) => response,
Err(issue) => Json(StoreSecretResponse {
@@ -173,7 +172,7 @@ pub struct StoreSecretResponse {
}
/// Retrieves a secret from the secret store using the operating system's keyring.
pub async fn get_secret(_token: APIToken, request: Json<RequestSecret>) -> Json<RequestedSecret> {
pub async fn get_secret(request: Json<RequestSecret>) -> Json<RequestedSecret> {
match run_secret_store_request(move || get_secret_sync(request)).await {
Ok(response) => response,
Err(issue) => Json(RequestedSecret {
@@ -268,7 +267,7 @@ pub struct RequestedSecret {
}
/// Deletes a secret from the secret store using the operating system's keyring.
pub async fn delete_secret(_token: APIToken, request: Json<RequestSecret>) -> Json<DeleteSecretResponse> {
pub async fn delete_secret(request: Json<RequestSecret>) -> Json<DeleteSecretResponse> {
match run_secret_store_request(move || delete_secret_sync(request)).await {
Ok(response) => response,
Err(issue) => Json(DeleteSecretResponse {
+1 -2
View File
@@ -2,7 +2,6 @@ use axum::Json;
use log::{error, info};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::api_token::APIToken;
/// The directory the app creates its shareable plugin archives in. Keep in sync with
/// PluginShareService.TEMPORARY_ARCHIVE_DIRECTORY on the .NET side.
@@ -23,7 +22,7 @@ pub struct ShareFileResponse {
issue: String,
}
pub async fn share_file(_token: APIToken, Json(request): Json<ShareFileRequest>) -> Json<ShareFileResponse> {
pub async fn share_file(Json(request): Json<ShareFileRequest>) -> Json<ShareFileResponse> {
let path = PathBuf::from(request.file_path.trim());
if path.as_os_str().is_empty() {
return failure("The file path is empty.");
+4 -6
View File
@@ -10,7 +10,6 @@ use tauri::path::BaseDirectory;
use tauri::Manager;
use tokenizers::tokenizer::Tokenizer;
use crate::api_token::APIToken;
use crate::environment::DATA_DIRECTORY;
const DEFAULT_TOKENIZER_RESOURCE_PATH: &str = "resources/tokenizers/tokenizer.json";
@@ -103,28 +102,28 @@ pub fn set_default_tokenizer_path(app_handle: tauri::AppHandle) {
}
}
pub async fn token_count(_token: APIToken, req: Json<SetTokenText>) -> Json<TokenizerResponse> {
pub async fn token_count(req: Json<SetTokenText>) -> Json<TokenizerResponse> {
match run_tokenizer_work(move || get_token_count(&req.tokenizer_path, &req.text)).await {
Ok(count) => Json(TokenizerResponse::available(count)),
Err(e) => Json(TokenizerResponse::unavailable(e)),
}
}
pub async fn validate_tokenizer(_token: APIToken, payload: Json<TokenizerPath>) -> Json<TokenizerResponse> {
pub async fn validate_tokenizer(payload: Json<TokenizerPath>) -> Json<TokenizerResponse> {
match run_tokenizer_work(move || handle_tokenizer_validate(&PathBuf::from(payload.file_path.clone()))).await {
Ok(count) => Json(TokenizerResponse::available(count)),
Err(e) => Json(TokenizerResponse::unavailable(e)),
}
}
pub async fn store_tokenizer(_token: APIToken, payload: Json<TokenizerStorage>) -> Json<TokenizerResponse> {
pub async fn store_tokenizer(payload: Json<TokenizerStorage>) -> Json<TokenizerResponse> {
match run_tokenizer_work(move || handle_tokenizer_store(&payload).map_err(|e| e.to_string())).await {
Ok(dest_path) => Json(TokenizerResponse::stored(dest_path)),
Err(e) => Json(TokenizerResponse::unavailable(e)),
}
}
pub async fn delete_tokenizer(_token: APIToken, payload: Json<TokenizerDelete>) -> Json<TokenizerResponse> {
pub async fn delete_tokenizer(payload: Json<TokenizerDelete>) -> Json<TokenizerResponse> {
match run_tokenizer_work(move || handle_tokenizer_delete(&payload).map_err(|e| e.to_string())).await {
Ok(_) => Json(TokenizerResponse::stored(String::new())),
Err(e) => Json(TokenizerResponse::unavailable(e)),
@@ -344,7 +343,6 @@ mod tests {
fs::write(&tokenizer_path, "This is not a tokenizer.").unwrap();
let request = token_count(
APIToken::from_hex_text("test"),
Json(SetTokenText {
text: "Hello, world!".to_string(),
tokenizer_path: tokenizer_path.to_string_lossy().to_string(),