mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-12 06:33:47 +00:00
Required the API token for every runtime API route (#1045)
This commit is contained in:
1 parent
4f966eed86
commit
8badadb35f
24 files changed
+231
-79
No files matched your search
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)> {
|
||||
//
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -26,11 +26,4 @@ where
|
||||
None => Err(StatusCode::UNAUTHORIZED),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The API token error types.
|
||||
#[derive(Debug)]
|
||||
pub enum APITokenError {
|
||||
Missing,
|
||||
Invalid,
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user