Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 63 additions & 4 deletions app/utils/cloud/upstash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,62 @@ import { chunks } from "../format";
export type UpstashConfig = SyncStore["upstash"];
export type UpStashClient = ReturnType<typeof createUpstashClient>;

async function httpFetch(
url: string,
options?: RequestInit,
): Promise<Response> {
if (window.__TAURI__) {
// 转换 RequestInit 格式为 Tauri 期望的格式
const method = options?.method || "GET";
const headers: Record<string, string> = {};

// 处理 headers
if (options?.headers) {
if (options.headers instanceof Headers) {
options.headers.forEach((value, key) => {
headers[key] = value;
});
} else if (Array.isArray(options.headers)) {
options.headers.forEach(([key, value]) => {
headers[key] = value;
});
} else {
Object.assign(headers, options.headers);
}
}

// 处理 body
let body: number[] = [];
if (options?.body) {
if (typeof options.body === "string") {
body = Array.from(new TextEncoder().encode(options.body));
} else if (options.body instanceof ArrayBuffer) {
body = Array.from(new Uint8Array(options.body));
} else if (options.body instanceof Uint8Array) {
body = Array.from(options.body);
} else {
// 其他类型转换为字符串
body = Array.from(new TextEncoder().encode(String(options.body)));
}
}

const response = await window.__TAURI__.invoke("http_fetch", {
method,
url,
headers,
body,
});

// 将 Tauri 响应转换为 Response 对象格式
return new Response(new Uint8Array(response.body), {
status: response.status,
statusText: response.status_text,
headers: new Headers(response.headers),
});
}
return fetch(url, options);
}

export function createUpstashClient(store: SyncStore) {
const config = store.upstash;
const storeKey = config.username.length === 0 ? STORAGE_KEY : config.username;
Expand All @@ -17,7 +73,7 @@ export function createUpstashClient(store: SyncStore) {
return {
async check() {
try {
const res = await fetch(this.path(`get/${storeKey}`, proxyUrl), {
const res = await httpFetch(this.path(`get/${storeKey}`, proxyUrl), {
method: "GET",
headers: this.headers(),
});
Expand All @@ -30,7 +86,7 @@ export function createUpstashClient(store: SyncStore) {
},

async redisGet(key: string) {
const res = await fetch(this.path(`get/${key}`, proxyUrl), {
const res = await httpFetch(this.path(`get/${key}`, proxyUrl), {
method: "GET",
headers: this.headers(),
});
Expand All @@ -42,7 +98,7 @@ export function createUpstashClient(store: SyncStore) {
},

async redisSet(key: string, value: string) {
const res = await fetch(this.path(`set/${key}`, proxyUrl), {
const res = await httpFetch(this.path(`set/${key}`, proxyUrl), {
method: "POST",
headers: this.headers(),
body: value,
Expand Down Expand Up @@ -81,6 +137,9 @@ export function createUpstashClient(store: SyncStore) {
};
},
path(path: string, proxyUrl: string = "") {
if (window.__TAURI__) {
return config.endpoint + "/" + path;
}
if (!path.endsWith("/")) {
path += "/";
}
Expand All @@ -96,7 +155,7 @@ export function createUpstashClient(store: SyncStore) {
const pathPrefix = "/api/upstash/";

try {
let u = new URL(proxyUrl + pathPrefix + path);
let u = new URL(proxyUrl + pathPrefix + path, window.location.origin);
// add query params
u.searchParams.append("endpoint", config.endpoint);
url = u.toString();
Expand Down
169 changes: 169 additions & 0 deletions src-tauri/src/fetch.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
//
// HTTP request handler module
//

use std::time::Duration;
use std::error::Error;
use std::sync::atomic::{AtomicU32, Ordering};
use std::collections::HashMap;
use reqwest::Client;
use reqwest::header::{HeaderName, HeaderMap};

static REQUEST_COUNTER: AtomicU32 = AtomicU32::new(0);

#[derive(Debug, Clone, serde::Serialize)]
pub struct FetchResponse {
request_id: u32,
status: u16,
status_text: String,
headers: HashMap<String, String>,
body: Vec<u8>,
}

#[tauri::command]
pub async fn http_fetch(
method: String,
url: String,
headers: HashMap<String, String>,
body: Vec<u8>,
) -> Result<FetchResponse, String> {

let request_id = REQUEST_COUNTER.fetch_add(1, Ordering::SeqCst);

let mut _headers = HeaderMap::new();
for (key, value) in &headers {
match key.parse::<HeaderName>() {
Ok(header_name) => {
match value.parse() {
Ok(header_value) => {
_headers.insert(header_name, header_value);
}
Err(err) => {
return Err(format!("failed to parse header value '{}': {}", value, err));
}
}
}
Err(err) => {
return Err(format!("failed to parse header name '{}': {}", key, err));
}
}
}

// Parse HTTP method
let method = method.parse::<reqwest::Method>()
.map_err(|err| format!("failed to parse method: {}", err))?;

// Create client
let client = Client::builder()
.default_headers(_headers)
.redirect(reqwest::redirect::Policy::limited(3))
.connect_timeout(Duration::new(10, 0))
.timeout(Duration::new(30, 0))
.build()
.map_err(|err| format!("failed to create client: {}", err))?;
Comment on lines +57 to +63
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Headers should be added to the request, not as default headers.

Setting headers as default headers on the client affects all requests made by this client instance. Since we're only making one request, headers should be set on the request builder instead.

  // Create client
  let client = Client::builder()
-    .default_headers(_headers)
    .redirect(reqwest::redirect::Policy::limited(3))
    .connect_timeout(Duration::new(10, 0))
    .timeout(Duration::new(30, 0))
    .build()
    .map_err(|err| format!("failed to create client: {}", err))?;

  // Build request
  let mut request = client.request(
    method.clone(),
    url.parse::<reqwest::Url>()
      .map_err(|err| format!("failed to parse url: {}", err))?
-  );
+  ).headers(_headers);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let client = Client::builder()
.default_headers(_headers)
.redirect(reqwest::redirect::Policy::limited(3))
.connect_timeout(Duration::new(10, 0))
.timeout(Duration::new(30, 0))
.build()
.map_err(|err| format!("failed to create client: {}", err))?;
// Create client
let client = Client::builder()
.redirect(reqwest::redirect::Policy::limited(3))
.connect_timeout(Duration::new(10, 0))
.timeout(Duration::new(30, 0))
.build()
.map_err(|err| format!("failed to create client: {}", err))?;
// Build request
let mut request = client.request(
method.clone(),
url.parse::<reqwest::Url>()
.map_err(|err| format!("failed to parse url: {}", err))?
).headers(_headers);
🤖 Prompt for AI Agents
In src-tauri/src/fetch.rs around lines 57 to 63, the code sets headers as
default headers on the Client which affects all requests; instead remove the
.default_headers(_headers) call from the Client builder and apply the headers to
the specific request before sending (use the request builder's
.headers(_headers) method or .header(key, value) calls). Ensure the headers
value is available/moved into the request builder (clone if needed) and keep the
client build without default headers, then attach the headers to the individual
GET/POST request prior to .send().


// Build request
let mut request = client.request(
method.clone(),
url.parse::<reqwest::Url>()
.map_err(|err| format!("failed to parse url: {}", err))?
);

// For request methods that need a body, add the request body
if method == reqwest::Method::POST
|| method == reqwest::Method::PUT
|| method == reqwest::Method::PATCH
|| method == reqwest::Method::DELETE {
if !body.is_empty() {
let body_bytes = bytes::Bytes::from(body);
request = request.body(body_bytes);
}
}
Comment on lines +73 to +81
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

DELETE requests typically don't have a body.

According to HTTP specifications, DELETE requests should not have a request body. While some servers may accept it, many will reject DELETE requests with bodies. Consider removing DELETE from this list or making it configurable.

  // For request methods that need a body, add the request body
  if method == reqwest::Method::POST 
    || method == reqwest::Method::PUT 
-    || method == reqwest::Method::PATCH 
-    || method == reqwest::Method::DELETE {
+    || method == reqwest::Method::PATCH {
    if !body.is_empty() {
      let body_bytes = bytes::Bytes::from(body);
      request = request.body(body_bytes);
    }
  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if method == reqwest::Method::POST
|| method == reqwest::Method::PUT
|| method == reqwest::Method::PATCH
|| method == reqwest::Method::DELETE {
if !body.is_empty() {
let body_bytes = bytes::Bytes::from(body);
request = request.body(body_bytes);
}
}
// For request methods that need a body, add the request body
if method == reqwest::Method::POST
|| method == reqwest::Method::PUT
|| method == reqwest::Method::PATCH {
if !body.is_empty() {
let body_bytes = bytes::Bytes::from(body);
request = request.body(body_bytes);
}
}
🤖 Prompt for AI Agents
In src-tauri/src/fetch.rs around lines 73 to 81, the conditional that attaches a
body to requests currently includes reqwest::Method::DELETE which is
non-conformant; remove DELETE from the list (or make it configurable) so DELETE
requests do not get a request body by default. Update the condition to only
include POST, PUT, PATCH (or check an explicit allow_body flag passed into the
function/config), and adjust any callers/tests to reflect that DELETE no longer
carries a body unless explicitly permitted via the new flag.


// Send request
let response = request.send().await
.map_err(|err| {
let error_msg = err.source()
.map(|e| e.to_string())
.unwrap_or_else(|| err.to_string());
format!("request failed: {}", error_msg)
})?;

// Get response status and headers
let status = response.status().as_u16();
let status_text = response.status().canonical_reason()
.unwrap_or("Unknown")
.to_string();

let mut response_headers = HashMap::new();
for (name, value) in response.headers() {
response_headers.insert(
name.as_str().to_string(),
std::str::from_utf8(value.as_bytes())
.unwrap_or("<invalid utf8>")
.to_string()
);
}

// Read response body
let response_body = response.bytes().await
.map_err(|err| format!("failed to read response body: {}", err))?;

Ok(FetchResponse {
request_id,
status,
status_text,
headers: response_headers,
body: response_body.to_vec(),
})
}

#[tauri::command]
pub async fn http_fetch_text(
method: String,
url: String,
headers: HashMap<String, String>,
body: String,
) -> Result<String, String> {

// Convert string body to bytes
let body_bytes = body.into_bytes();

// Call the main fetch method
let response = http_fetch(method, url, headers, body_bytes).await?;

// Convert response body to string
let response_text = String::from_utf8(response.body)
.map_err(|err| format!("failed to convert response to text: {}", err))?;

Ok(response_text)
}

#[tauri::command]
pub async fn http_fetch_json(
method: String,
url: String,
headers: HashMap<String, String>,
body: serde_json::Value,
) -> Result<serde_json::Value, String> {

// Convert JSON to string and then to bytes
let body_string = serde_json::to_string(&body)
.map_err(|err| format!("failed to serialize JSON body: {}", err))?;
let body_bytes = body_string.into_bytes();

// Ensure the correct Content-Type is set
let mut json_headers = headers;
if !json_headers.contains_key("content-type") && !json_headers.contains_key("Content-Type") {
json_headers.insert("Content-Type".to_string(), "application/json".to_string());
}
Comment on lines +155 to +159
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Use case-insensitive header lookup.

HTTP headers are case-insensitive, but the current check only handles two specific cases. Consider using a case-insensitive comparison or normalizing header names.

  // Ensure the correct Content-Type is set
  let mut json_headers = headers;
-  if !json_headers.contains_key("content-type") && !json_headers.contains_key("Content-Type") {
+  let has_content_type = json_headers.keys()
+    .any(|k| k.to_lowercase() == "content-type");
+  if !has_content_type {
    json_headers.insert("Content-Type".to_string(), "application/json".to_string());
  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Ensure the correct Content-Type is set
let mut json_headers = headers;
if !json_headers.contains_key("content-type") && !json_headers.contains_key("Content-Type") {
json_headers.insert("Content-Type".to_string(), "application/json".to_string());
}
// Ensure the correct Content-Type is set
let mut json_headers = headers;
let has_content_type = json_headers.keys()
.any(|k| k.to_lowercase() == "content-type");
if !has_content_type {
json_headers.insert("Content-Type".to_string(), "application/json".to_string());
}
🤖 Prompt for AI Agents
In src-tauri/src/fetch.rs around lines 155 to 159, the code checks for
"content-type" and "Content-Type" explicitly which is brittle; change the check
to be case-insensitive by either normalizing header names to lowercase before
checking/inserting or by scanning the existing header keys and comparing
key.to_lowercase() == "content-type"; if not found insert the header using the
canonical "Content-Type" name and value "application/json".


// Call the main fetch method
let response = http_fetch(method, url, json_headers, body_bytes).await?;

// Parse response body as JSON
let response_json: serde_json::Value = serde_json::from_slice(&response.body)
.map_err(|err| format!("failed to parse response as JSON: {}", err))?;

Ok(response_json)
}
8 changes: 7 additions & 1 deletion src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

mod stream;
mod fetch;

fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![stream::stream_fetch])
.invoke_handler(tauri::generate_handler![
stream::stream_fetch,
fetch::http_fetch,
fetch::http_fetch_text,
fetch::http_fetch_json
])
.plugin(tauri_plugin_window_state::Builder::default().build())
.run(tauri::generate_context!())
.expect("error while running tauri application");
Expand Down