mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-01 15:03:11 +02:00
Url parameters done
This commit is contained in:
12
src-tauri/.sqlx/query-4a5fd6c81401ccafac64b05cb476da92cc30919d5bdb0a0226ea5e30d5b30c0f.json
generated
Normal file
12
src-tauri/.sqlx/query-4a5fd6c81401ccafac64b05cb476da92cc30919d5bdb0a0226ea5e30d5b30c0f.json
generated
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "\n INSERT INTO http_requests (\n id,\n workspace_id,\n folder_id,\n name,\n url,\n url_parameters,\n method,\n body,\n body_type,\n authentication,\n authentication_type,\n headers,\n sort_priority\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n folder_id = excluded.folder_id,\n method = excluded.method,\n headers = excluded.headers,\n body = excluded.body,\n body_type = excluded.body_type,\n authentication = excluded.authentication,\n authentication_type = excluded.authentication_type,\n url = excluded.url,\n url_parameters = excluded.url_parameters,\n sort_priority = excluded.sort_priority\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 13
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "4a5fd6c81401ccafac64b05cb476da92cc30919d5bdb0a0226ea5e30d5b30c0f"
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "\n SELECT\n id,\n model,\n workspace_id,\n folder_id,\n created_at,\n updated_at,\n name,\n url,\n method,\n body AS \"body!: Json<HashMap<String, JsonValue>>\",\n body_type,\n authentication AS \"authentication!: Json<HashMap<String, JsonValue>>\",\n authentication_type,\n sort_priority,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE workspace_id = ?\n ",
|
"query": "\n SELECT\n id,\n model,\n workspace_id,\n folder_id,\n created_at,\n updated_at,\n name,\n url,\n url_parameters AS \"url_parameters!: sqlx::types::Json<Vec<HttpUrlParameter>>\",\n method,\n body AS \"body!: Json<HashMap<String, JsonValue>>\",\n body_type,\n authentication AS \"authentication!: Json<HashMap<String, JsonValue>>\",\n authentication_type,\n sort_priority,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE id = ?\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -44,38 +44,43 @@
|
|||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "method",
|
"name": "url_parameters!: sqlx::types::Json<Vec<HttpUrlParameter>>",
|
||||||
"ordinal": 8,
|
"ordinal": 8,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "body!: Json<HashMap<String, JsonValue>>",
|
"name": "method",
|
||||||
"ordinal": 9,
|
"ordinal": 9,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "body_type",
|
"name": "body!: Json<HashMap<String, JsonValue>>",
|
||||||
"ordinal": 10,
|
"ordinal": 10,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "authentication!: Json<HashMap<String, JsonValue>>",
|
"name": "body_type",
|
||||||
"ordinal": 11,
|
"ordinal": 11,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "authentication_type",
|
"name": "authentication!: Json<HashMap<String, JsonValue>>",
|
||||||
"ordinal": 12,
|
"ordinal": 12,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sort_priority",
|
"name": "authentication_type",
|
||||||
"ordinal": 13,
|
"ordinal": 13,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sort_priority",
|
||||||
|
"ordinal": 14,
|
||||||
"type_info": "Float"
|
"type_info": "Float"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
|
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
|
||||||
"ordinal": 14,
|
"ordinal": 15,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -93,6 +98,7 @@
|
|||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
@@ -100,5 +106,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "55e4e8b66c18f85d17ada00b302720e5dcd35ec4288006e4a556448d59b63952"
|
"hash": "6483f3ffeb90e019e9078d98bb831b8e4fbedfb45751d6cd33bd42e518b634dd"
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "\n SELECT\n id,\n model,\n workspace_id,\n folder_id,\n created_at,\n updated_at,\n name,\n url,\n method,\n body AS \"body!: Json<HashMap<String, JsonValue>>\",\n body_type,\n authentication AS \"authentication!: Json<HashMap<String, JsonValue>>\",\n authentication_type,\n sort_priority,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE id = ?\n ",
|
"query": "\n SELECT\n id,\n model,\n workspace_id,\n folder_id,\n created_at,\n updated_at,\n name,\n url,\n url_parameters AS \"url_parameters!: sqlx::types::Json<Vec<HttpUrlParameter>>\",\n method,\n body AS \"body!: Json<HashMap<String, JsonValue>>\",\n body_type,\n authentication AS \"authentication!: Json<HashMap<String, JsonValue>>\",\n authentication_type,\n sort_priority,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE workspace_id = ?\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -44,38 +44,43 @@
|
|||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "method",
|
"name": "url_parameters!: sqlx::types::Json<Vec<HttpUrlParameter>>",
|
||||||
"ordinal": 8,
|
"ordinal": 8,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "body!: Json<HashMap<String, JsonValue>>",
|
"name": "method",
|
||||||
"ordinal": 9,
|
"ordinal": 9,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "body_type",
|
"name": "body!: Json<HashMap<String, JsonValue>>",
|
||||||
"ordinal": 10,
|
"ordinal": 10,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "authentication!: Json<HashMap<String, JsonValue>>",
|
"name": "body_type",
|
||||||
"ordinal": 11,
|
"ordinal": 11,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "authentication_type",
|
"name": "authentication!: Json<HashMap<String, JsonValue>>",
|
||||||
"ordinal": 12,
|
"ordinal": 12,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sort_priority",
|
"name": "authentication_type",
|
||||||
"ordinal": 13,
|
"ordinal": 13,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sort_priority",
|
||||||
|
"ordinal": 14,
|
||||||
"type_info": "Float"
|
"type_info": "Float"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
|
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
|
||||||
"ordinal": 14,
|
"ordinal": 15,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -93,6 +98,7 @@
|
|||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
@@ -100,5 +106,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "ae31827b9576ffba83a9de05e30688df3c83e145860f8dd608410a9a9254659d"
|
"hash": "7a7bc4df7e52ad3a987c97af8f43b46381e2cc16ba0c553713d0b6c64354eb39"
|
||||||
}
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "SQLite",
|
|
||||||
"query": "\n INSERT INTO http_requests (\n id,\n workspace_id,\n folder_id,\n name,\n url,\n method,\n body,\n body_type,\n authentication,\n authentication_type,\n headers,\n sort_priority\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n folder_id = excluded.folder_id,\n method = excluded.method,\n headers = excluded.headers,\n body = excluded.body,\n body_type = excluded.body_type,\n authentication = excluded.authentication,\n authentication_type = excluded.authentication_type,\n url = excluded.url,\n sort_priority = excluded.sort_priority\n ",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Right": 12
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "e5b410442b00ee354bb58eb0e8fb2af06e9dea4bb24763d717c72a840450a759"
|
|
||||||
}
|
|
||||||
2
src-tauri/migrations/20231113183810_url_params.sql
Normal file
2
src-tauri/migrations/20231113183810_url_params.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE http_requests
|
||||||
|
ADD COLUMN url_parameters TEXT NOT NULL DEFAULT '[]';
|
||||||
@@ -10,16 +10,11 @@ extern crate objc;
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::env::current_dir;
|
use std::env::current_dir;
|
||||||
use std::fs::{create_dir_all, File};
|
use std::fs::{create_dir_all, File};
|
||||||
use std::io::Write;
|
|
||||||
use std::process::exit;
|
use std::process::exit;
|
||||||
|
|
||||||
use base64::Engine;
|
|
||||||
use fern::colors::ColoredLevelConfig;
|
use fern::colors::ColoredLevelConfig;
|
||||||
use http::{HeaderMap, HeaderValue, Method};
|
use log::{debug, error, info};
|
||||||
use http::header::{ACCEPT, HeaderName, USER_AGENT};
|
|
||||||
use log::{debug, error, info, warn};
|
|
||||||
use rand::random;
|
use rand::random;
|
||||||
use reqwest::redirect::Policy;
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use sqlx::{Pool, Sqlite, SqlitePool};
|
use sqlx::{Pool, Sqlite, SqlitePool};
|
||||||
use sqlx::migrate::Migrator;
|
use sqlx::migrate::Migrator;
|
||||||
@@ -36,6 +31,7 @@ use window_ext::TrafficLightWindowExt;
|
|||||||
|
|
||||||
use crate::analytics::{AnalyticsAction, AnalyticsResource, track_event};
|
use crate::analytics::{AnalyticsAction, AnalyticsResource, track_event};
|
||||||
use crate::plugin::{ImportResources, ImportResult};
|
use crate::plugin::{ImportResources, ImportResult};
|
||||||
|
use crate::send::actually_send_request;
|
||||||
use crate::updates::YaakUpdater;
|
use crate::updates::YaakUpdater;
|
||||||
|
|
||||||
mod analytics;
|
mod analytics;
|
||||||
@@ -45,6 +41,7 @@ mod render;
|
|||||||
mod window_ext;
|
mod window_ext;
|
||||||
mod window_menu;
|
mod window_menu;
|
||||||
mod updates;
|
mod updates;
|
||||||
|
mod send;
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
pub struct CustomResponse {
|
pub struct CustomResponse {
|
||||||
@@ -88,188 +85,6 @@ async fn send_ephemeral_request(
|
|||||||
actually_send_request(request, &response, &environment_id2, &app_handle, pool).await
|
actually_send_request(request, &response, &environment_id2, &app_handle, pool).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn actually_send_request(
|
|
||||||
request: models::HttpRequest,
|
|
||||||
response: &models::HttpResponse,
|
|
||||||
environment_id: &str,
|
|
||||||
app_handle: &AppHandle<Wry>,
|
|
||||||
pool: &Pool<Sqlite>,
|
|
||||||
) -> Result<models::HttpResponse, String> {
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
let environment = models::get_environment(environment_id, pool).await.ok();
|
|
||||||
let environment_ref = environment.as_ref();
|
|
||||||
let workspace = models::get_workspace(&request.workspace_id, pool)
|
|
||||||
.await
|
|
||||||
.expect("Failed to get Workspace");
|
|
||||||
|
|
||||||
let mut url_string = render::render(&request.url, &workspace, environment.as_ref());
|
|
||||||
|
|
||||||
if !url_string.starts_with("http://") && !url_string.starts_with("https://") {
|
|
||||||
url_string = format!("http://{}", url_string);
|
|
||||||
}
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.redirect(Policy::none())
|
|
||||||
// .danger_accept_invalid_certs(true)
|
|
||||||
.build()
|
|
||||||
.expect("Failed to build client");
|
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert(USER_AGENT, HeaderValue::from_static("yaak"));
|
|
||||||
headers.insert(ACCEPT, HeaderValue::from_static("*/*"));
|
|
||||||
|
|
||||||
for h in request.headers.0 {
|
|
||||||
if h.name.is_empty() && h.value.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !h.enabled {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let name = render::render(&h.name, &workspace, environment_ref);
|
|
||||||
let value = render::render(&h.value, &workspace, environment_ref);
|
|
||||||
|
|
||||||
let header_name = match HeaderName::from_bytes(name.as_bytes()) {
|
|
||||||
Ok(n) => n,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Failed to create header name: {}", e);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let header_value = match HeaderValue::from_str(value.as_str()) {
|
|
||||||
Ok(n) => n,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Failed to create header value: {}", e);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
headers.insert(header_name, header_value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(b) = &request.authentication_type {
|
|
||||||
let empty_value = &serde_json::to_value("").unwrap();
|
|
||||||
let a = request.authentication.0;
|
|
||||||
|
|
||||||
if b == "basic" {
|
|
||||||
let raw_username = a
|
|
||||||
.get("username")
|
|
||||||
.unwrap_or(empty_value)
|
|
||||||
.as_str()
|
|
||||||
.unwrap_or("");
|
|
||||||
let raw_password = a
|
|
||||||
.get("password")
|
|
||||||
.unwrap_or(empty_value)
|
|
||||||
.as_str()
|
|
||||||
.unwrap_or("");
|
|
||||||
let username = render::render(raw_username, &workspace, environment_ref);
|
|
||||||
let password = render::render(raw_password, &workspace, environment_ref);
|
|
||||||
|
|
||||||
let auth = format!("{username}:{password}");
|
|
||||||
let encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode(auth);
|
|
||||||
headers.insert(
|
|
||||||
"Authorization",
|
|
||||||
HeaderValue::from_str(&format!("Basic {}", encoded)).unwrap(),
|
|
||||||
);
|
|
||||||
} else if b == "bearer" {
|
|
||||||
let raw_token = a.get("token").unwrap_or(empty_value).as_str().unwrap_or("");
|
|
||||||
let token = render::render(raw_token, &workspace, environment_ref);
|
|
||||||
headers.insert(
|
|
||||||
"Authorization",
|
|
||||||
HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let m = Method::from_bytes(request.method.to_uppercase().as_bytes())
|
|
||||||
.expect("Failed to create method");
|
|
||||||
|
|
||||||
let mut request_builder = client.request(m, url_string.to_string()).headers(headers);
|
|
||||||
|
|
||||||
if let Some(t) = &request.body_type {
|
|
||||||
let empty_value = &serde_json::to_value("").unwrap();
|
|
||||||
let b = request.body.0;
|
|
||||||
|
|
||||||
if b.contains_key("text") {
|
|
||||||
let raw_text = b.get("text").unwrap_or(empty_value).as_str().unwrap_or("");
|
|
||||||
let body = render::render(raw_text, &workspace, environment_ref);
|
|
||||||
request_builder = request_builder.body(body);
|
|
||||||
} else {
|
|
||||||
warn!("Unsupported body type: {}", t);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let sendable_req = match request_builder.build() {
|
|
||||||
Ok(r) => r,
|
|
||||||
Err(e) => {
|
|
||||||
return response_err(response, e.to_string(), app_handle, pool).await;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let raw_response = client.execute(sendable_req).await;
|
|
||||||
|
|
||||||
match raw_response {
|
|
||||||
Ok(v) => {
|
|
||||||
let mut response = response.clone();
|
|
||||||
response.status = v.status().as_u16() as i64;
|
|
||||||
response.status_reason = v.status().canonical_reason().map(|s| s.to_string());
|
|
||||||
response.headers = Json(
|
|
||||||
v.headers()
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| models::HttpResponseHeader {
|
|
||||||
name: k.as_str().to_string(),
|
|
||||||
value: v.to_str().unwrap().to_string(),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
);
|
|
||||||
response.url = v.url().to_string();
|
|
||||||
let body_bytes = v.bytes().await.expect("Failed to get body").to_vec();
|
|
||||||
response.content_length = Some(body_bytes.len() as i64);
|
|
||||||
|
|
||||||
{
|
|
||||||
// Write body to FS
|
|
||||||
let dir = app_handle.path_resolver().app_data_dir().unwrap();
|
|
||||||
let base_dir = dir.join("responses");
|
|
||||||
create_dir_all(base_dir.clone()).expect("Failed to create responses dir");
|
|
||||||
let body_path = match response.id.is_empty() {
|
|
||||||
false => base_dir.join(response.id.clone()),
|
|
||||||
true => base_dir.join(uuid::Uuid::new_v4().to_string()),
|
|
||||||
};
|
|
||||||
let mut f = File::options()
|
|
||||||
.create(true)
|
|
||||||
.truncate(true)
|
|
||||||
.write(true)
|
|
||||||
.open(&body_path)
|
|
||||||
.expect("Failed to open file");
|
|
||||||
f.write_all(body_bytes.as_slice())
|
|
||||||
.expect("Failed to write to file");
|
|
||||||
response.body_path = Some(
|
|
||||||
body_path
|
|
||||||
.to_str()
|
|
||||||
.expect("Failed to get body path")
|
|
||||||
.to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also store body directly on the model, if small enough
|
|
||||||
if body_bytes.len() < 100_000 {
|
|
||||||
response.body = Some(body_bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
response.elapsed = start.elapsed().as_millis() as i64;
|
|
||||||
response = models::update_response_if_id(&response, pool)
|
|
||||||
.await
|
|
||||||
.expect("Failed to update response");
|
|
||||||
if !request.id.is_empty() {
|
|
||||||
emit_side_effect(app_handle, "updated_model", &response);
|
|
||||||
}
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
Err(e) => response_err(response, e.to_string(), app_handle, pool).await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn import_data(
|
async fn import_data(
|
||||||
window: Window<Wry>,
|
window: Window<Wry>,
|
||||||
@@ -925,8 +740,9 @@ fn main() {
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
RunEvent::WindowEvent { label, event: WindowEvent::Focused(true), .. } => {
|
RunEvent::WindowEvent { label: _label, event: WindowEvent::Focused(true), .. } => {
|
||||||
let h = app_handle.clone();
|
let h = app_handle.clone();
|
||||||
|
// Run update check whenever window is focused
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
let val: State<'_, Mutex<YaakUpdater>> = h.state();
|
let val: State<'_, Mutex<YaakUpdater>> = h.state();
|
||||||
_ = val.lock().await.check(&h).await;
|
_ = val.lock().await.check(&h).await;
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ use std::fs;
|
|||||||
|
|
||||||
use rand::distributions::{Alphanumeric, DistString};
|
use rand::distributions::{Alphanumeric, DistString};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::types::chrono::NaiveDateTime;
|
|
||||||
use sqlx::types::{Json, JsonValue};
|
|
||||||
use sqlx::{Pool, Sqlite};
|
use sqlx::{Pool, Sqlite};
|
||||||
|
use sqlx::types::{Json, JsonValue};
|
||||||
|
use sqlx::types::chrono::NaiveDateTime;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
|
|
||||||
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
@@ -54,6 +54,15 @@ pub struct HttpRequestHeader {
|
|||||||
pub value: String,
|
pub value: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(default, rename_all = "camelCase")]
|
||||||
|
pub struct HttpUrlParameter {
|
||||||
|
#[serde(default = "default_enabled")]
|
||||||
|
pub enabled: bool,
|
||||||
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
fn default_http_request_method() -> String {
|
fn default_http_request_method() -> String {
|
||||||
"GET".to_string()
|
"GET".to_string()
|
||||||
}
|
}
|
||||||
@@ -70,6 +79,7 @@ pub struct HttpRequest {
|
|||||||
pub sort_priority: f64,
|
pub sort_priority: f64,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
|
pub url_parameters: Json<Vec<HttpUrlParameter>>,
|
||||||
#[serde(default = "default_http_request_method")]
|
#[serde(default = "default_http_request_method")]
|
||||||
pub method: String,
|
pub method: String,
|
||||||
pub body: Json<HashMap<String, JsonValue>>,
|
pub body: Json<HashMap<String, JsonValue>>,
|
||||||
@@ -439,6 +449,7 @@ pub async fn upsert_request(
|
|||||||
folder_id,
|
folder_id,
|
||||||
name,
|
name,
|
||||||
url,
|
url,
|
||||||
|
url_parameters,
|
||||||
method,
|
method,
|
||||||
body,
|
body,
|
||||||
body_type,
|
body_type,
|
||||||
@@ -447,7 +458,7 @@ pub async fn upsert_request(
|
|||||||
headers,
|
headers,
|
||||||
sort_priority
|
sort_priority
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
updated_at = CURRENT_TIMESTAMP,
|
updated_at = CURRENT_TIMESTAMP,
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
@@ -459,6 +470,7 @@ pub async fn upsert_request(
|
|||||||
authentication = excluded.authentication,
|
authentication = excluded.authentication,
|
||||||
authentication_type = excluded.authentication_type,
|
authentication_type = excluded.authentication_type,
|
||||||
url = excluded.url,
|
url = excluded.url,
|
||||||
|
url_parameters = excluded.url_parameters,
|
||||||
sort_priority = excluded.sort_priority
|
sort_priority = excluded.sort_priority
|
||||||
"#,
|
"#,
|
||||||
id,
|
id,
|
||||||
@@ -466,6 +478,7 @@ pub async fn upsert_request(
|
|||||||
r.folder_id,
|
r.folder_id,
|
||||||
trimmed_name,
|
trimmed_name,
|
||||||
r.url,
|
r.url,
|
||||||
|
r.url_parameters,
|
||||||
r.method,
|
r.method,
|
||||||
r.body,
|
r.body,
|
||||||
r.body_type,
|
r.body_type,
|
||||||
@@ -496,6 +509,7 @@ pub async fn find_requests(
|
|||||||
updated_at,
|
updated_at,
|
||||||
name,
|
name,
|
||||||
url,
|
url,
|
||||||
|
url_parameters AS "url_parameters!: sqlx::types::Json<Vec<HttpUrlParameter>>",
|
||||||
method,
|
method,
|
||||||
body AS "body!: Json<HashMap<String, JsonValue>>",
|
body AS "body!: Json<HashMap<String, JsonValue>>",
|
||||||
body_type,
|
body_type,
|
||||||
@@ -525,6 +539,7 @@ pub async fn get_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, s
|
|||||||
updated_at,
|
updated_at,
|
||||||
name,
|
name,
|
||||||
url,
|
url,
|
||||||
|
url_parameters AS "url_parameters!: sqlx::types::Json<Vec<HttpUrlParameter>>",
|
||||||
method,
|
method,
|
||||||
body AS "body!: Json<HashMap<String, JsonValue>>",
|
body AS "body!: Json<HashMap<String, JsonValue>>",
|
||||||
body_type,
|
body_type,
|
||||||
|
|||||||
205
src-tauri/src/send.rs
Normal file
205
src-tauri/src/send.rs
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
use std::fs::{create_dir_all, File};
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use http::{HeaderMap, HeaderName, HeaderValue, Method};
|
||||||
|
use http::header::{ACCEPT, USER_AGENT};
|
||||||
|
use log::warn;
|
||||||
|
use reqwest::redirect::Policy;
|
||||||
|
use sqlx::{Pool, Sqlite};
|
||||||
|
use sqlx::types::Json;
|
||||||
|
use tauri::{AppHandle, Wry};
|
||||||
|
|
||||||
|
use crate::{emit_side_effect, models, render, response_err};
|
||||||
|
|
||||||
|
pub async fn actually_send_request(
|
||||||
|
request: models::HttpRequest,
|
||||||
|
response: &models::HttpResponse,
|
||||||
|
environment_id: &str,
|
||||||
|
app_handle: &AppHandle<Wry>,
|
||||||
|
pool: &Pool<Sqlite>,
|
||||||
|
) -> Result<models::HttpResponse, String> {
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let environment = models::get_environment(environment_id, pool).await.ok();
|
||||||
|
let environment_ref = environment.as_ref();
|
||||||
|
let workspace = models::get_workspace(&request.workspace_id, pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to get Workspace");
|
||||||
|
|
||||||
|
let mut url_string = render::render(&request.url, &workspace, environment.as_ref());
|
||||||
|
|
||||||
|
if !url_string.starts_with("http://") && !url_string.starts_with("https://") {
|
||||||
|
url_string = format!("http://{}", url_string);
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.redirect(Policy::none())
|
||||||
|
// .danger_accept_invalid_certs(true)
|
||||||
|
.build()
|
||||||
|
.expect("Failed to build client");
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(USER_AGENT, HeaderValue::from_static("yaak"));
|
||||||
|
headers.insert(ACCEPT, HeaderValue::from_static("*/*"));
|
||||||
|
|
||||||
|
for h in request.headers.0 {
|
||||||
|
if h.name.is_empty() && h.value.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !h.enabled {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = render::render(&h.name, &workspace, environment_ref);
|
||||||
|
let value = render::render(&h.value, &workspace, environment_ref);
|
||||||
|
|
||||||
|
let header_name = match HeaderName::from_bytes(name.as_bytes()) {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to create header name: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let header_value = match HeaderValue::from_str(value.as_str()) {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to create header value: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
headers.insert(header_name, header_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(b) = &request.authentication_type {
|
||||||
|
let empty_value = &serde_json::to_value("").unwrap();
|
||||||
|
let a = request.authentication.0;
|
||||||
|
|
||||||
|
if b == "basic" {
|
||||||
|
let raw_username = a
|
||||||
|
.get("username")
|
||||||
|
.unwrap_or(empty_value)
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("");
|
||||||
|
let raw_password = a
|
||||||
|
.get("password")
|
||||||
|
.unwrap_or(empty_value)
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("");
|
||||||
|
let username = render::render(raw_username, &workspace, environment_ref);
|
||||||
|
let password = render::render(raw_password, &workspace, environment_ref);
|
||||||
|
|
||||||
|
let auth = format!("{username}:{password}");
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode(auth);
|
||||||
|
headers.insert(
|
||||||
|
"Authorization",
|
||||||
|
HeaderValue::from_str(&format!("Basic {}", encoded)).unwrap(),
|
||||||
|
);
|
||||||
|
} else if b == "bearer" {
|
||||||
|
let raw_token = a.get("token").unwrap_or(empty_value).as_str().unwrap_or("");
|
||||||
|
let token = render::render(raw_token, &workspace, environment_ref);
|
||||||
|
headers.insert(
|
||||||
|
"Authorization",
|
||||||
|
HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let m = Method::from_bytes(request.method.to_uppercase().as_bytes())
|
||||||
|
.expect("Failed to create method");
|
||||||
|
|
||||||
|
let mut request_builder = client.request(m, url_string.to_string()).headers(headers);
|
||||||
|
|
||||||
|
let mut query_params = Vec::new();
|
||||||
|
for p in request.url_parameters.0 {
|
||||||
|
if !p.enabled || p.name.is_empty() { continue; }
|
||||||
|
query_params.push((
|
||||||
|
render::render(&p.name, &workspace, environment_ref),
|
||||||
|
render::render(&p.value, &workspace, environment_ref),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
request_builder = request_builder.query(&query_params);
|
||||||
|
|
||||||
|
if let Some(t) = &request.body_type {
|
||||||
|
let empty_value = &serde_json::to_value("").unwrap();
|
||||||
|
let b = request.body.0;
|
||||||
|
|
||||||
|
if b.contains_key("text") {
|
||||||
|
let raw_text = b.get("text").unwrap_or(empty_value).as_str().unwrap_or("");
|
||||||
|
let body = render::render(raw_text, &workspace, environment_ref);
|
||||||
|
request_builder = request_builder.body(body);
|
||||||
|
} else {
|
||||||
|
warn!("Unsupported body type: {}", t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let sendable_req = match request_builder.build() {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
return response_err(response, e.to_string(), app_handle, pool).await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let raw_response = client.execute(sendable_req).await;
|
||||||
|
|
||||||
|
match raw_response {
|
||||||
|
Ok(v) => {
|
||||||
|
let mut response = response.clone();
|
||||||
|
response.status = v.status().as_u16() as i64;
|
||||||
|
response.status_reason = v.status().canonical_reason().map(|s| s.to_string());
|
||||||
|
response.headers = Json(
|
||||||
|
v.headers()
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| models::HttpResponseHeader {
|
||||||
|
name: k.as_str().to_string(),
|
||||||
|
value: v.to_str().unwrap().to_string(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
response.url = v.url().to_string();
|
||||||
|
let body_bytes = v.bytes().await.expect("Failed to get body").to_vec();
|
||||||
|
response.content_length = Some(body_bytes.len() as i64);
|
||||||
|
|
||||||
|
{
|
||||||
|
// Write body to FS
|
||||||
|
let dir = app_handle.path_resolver().app_data_dir().unwrap();
|
||||||
|
let base_dir = dir.join("responses");
|
||||||
|
create_dir_all(base_dir.clone()).expect("Failed to create responses dir");
|
||||||
|
let body_path = match response.id.is_empty() {
|
||||||
|
false => base_dir.join(response.id.clone()),
|
||||||
|
true => base_dir.join(uuid::Uuid::new_v4().to_string()),
|
||||||
|
};
|
||||||
|
let mut f = File::options()
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&body_path)
|
||||||
|
.expect("Failed to open file");
|
||||||
|
f.write_all(body_bytes.as_slice())
|
||||||
|
.expect("Failed to write to file");
|
||||||
|
response.body_path = Some(
|
||||||
|
body_path
|
||||||
|
.to_str()
|
||||||
|
.expect("Failed to get body path")
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also store body directly on the model, if small enough
|
||||||
|
if body_bytes.len() < 100_000 {
|
||||||
|
response.body = Some(body_bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
response.elapsed = start.elapsed().as_millis() as i64;
|
||||||
|
response = models::update_response_if_id(&response, pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to update response");
|
||||||
|
if !request.id.is_empty() {
|
||||||
|
emit_side_effect(app_handle, "updated_model", &response);
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
Err(e) => response_err(response, e.to_string(), app_handle, pool).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ use tauri::{AppHandle, updater, Window, Wry};
|
|||||||
use tauri::api::dialog;
|
use tauri::api::dialog;
|
||||||
|
|
||||||
// Check for updates every 3 hours
|
// Check for updates every 3 hours
|
||||||
const MAX_UPDATE_CHECK_SECONDS: u64 = 3600 * 3;
|
const MAX_UPDATE_CHECK_SECONDS: u64 = 60 * 60 * 3;
|
||||||
|
|
||||||
// Create updater struct
|
// Create updater struct
|
||||||
pub struct YaakUpdater {
|
pub struct YaakUpdater {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type Props = {
|
|||||||
onChange: (headers: HttpRequest['headers']) => void;
|
onChange: (headers: HttpRequest['headers']) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function HeaderEditor({ headers, onChange, forceUpdateKey }: Props) {
|
export function HeadersEditor({ headers, onChange, forceUpdateKey }: Props) {
|
||||||
return (
|
return (
|
||||||
<PairEditor
|
<PairEditor
|
||||||
valueAutocompleteVariables
|
valueAutocompleteVariables
|
||||||
@@ -3,17 +3,18 @@ import { PairEditor } from './core/PairEditor';
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
forceUpdateKey: string;
|
forceUpdateKey: string;
|
||||||
parameters: { name: string; value: string }[];
|
urlParameters: HttpRequest['headers'];
|
||||||
onChange: (headers: HttpRequest['headers']) => void;
|
onChange: (headers: HttpRequest['urlParameters']) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ParametersEditor({ parameters, forceUpdateKey, onChange }: Props) {
|
export function UrlParametersEditor({ urlParameters, forceUpdateKey, onChange }: Props) {
|
||||||
return (
|
return (
|
||||||
<PairEditor
|
<PairEditor
|
||||||
forceUpdateKey={forceUpdateKey}
|
valueAutocompleteVariables
|
||||||
pairs={parameters}
|
nameAutocompleteVariables
|
||||||
|
pairs={urlParameters}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
namePlaceholder="name"
|
forceUpdateKey={forceUpdateKey}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
|
|||||||
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
|
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
|
||||||
import { useUpdateRequest } from '../hooks/useUpdateRequest';
|
import { useUpdateRequest } from '../hooks/useUpdateRequest';
|
||||||
import { tryFormatJson } from '../lib/formatters';
|
import { tryFormatJson } from '../lib/formatters';
|
||||||
import type { HttpHeader, HttpRequest } from '../lib/models';
|
import type { HttpHeader, HttpRequest, HttpUrlParameter } from '../lib/models';
|
||||||
import {
|
import {
|
||||||
AUTH_TYPE_BASIC,
|
AUTH_TYPE_BASIC,
|
||||||
AUTH_TYPE_BEARER,
|
AUTH_TYPE_BEARER,
|
||||||
@@ -28,8 +28,8 @@ import type { TabItem } from './core/Tabs/Tabs';
|
|||||||
import { TabContent, Tabs } from './core/Tabs/Tabs';
|
import { TabContent, Tabs } from './core/Tabs/Tabs';
|
||||||
import { EmptyStateText } from './EmptyStateText';
|
import { EmptyStateText } from './EmptyStateText';
|
||||||
import { GraphQLEditor } from './GraphQLEditor';
|
import { GraphQLEditor } from './GraphQLEditor';
|
||||||
import { HeaderEditor } from './HeaderEditor';
|
import { HeadersEditor } from './HeadersEditor';
|
||||||
import { ParametersEditor } from './ParameterEditor';
|
import { UrlParametersEditor } from './ParameterEditor';
|
||||||
import { UrlBar } from './UrlBar';
|
import { UrlBar } from './UrlBar';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -92,7 +92,15 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// { value: 'params', label: 'URL Params' },
|
{
|
||||||
|
value: 'params',
|
||||||
|
label: (
|
||||||
|
<div className="flex items-center">
|
||||||
|
Params
|
||||||
|
<CountBadge count={activeRequest.urlParameters.filter((p) => p.name).length} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: 'headers',
|
value: 'headers',
|
||||||
label: (
|
label: (
|
||||||
@@ -141,6 +149,10 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
|
|||||||
(headers: HttpHeader[]) => updateRequest.mutate({ headers }),
|
(headers: HttpHeader[]) => updateRequest.mutate({ headers }),
|
||||||
[updateRequest],
|
[updateRequest],
|
||||||
);
|
);
|
||||||
|
const handleUrlParametersChange = useCallback(
|
||||||
|
(urlParameters: HttpUrlParameter[]) => updateRequest.mutate({ urlParameters }),
|
||||||
|
[updateRequest],
|
||||||
|
);
|
||||||
|
|
||||||
useListenToTauriEvent(
|
useListenToTauriEvent(
|
||||||
'send_request',
|
'send_request',
|
||||||
@@ -189,17 +201,17 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
|
|||||||
)}
|
)}
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value="headers">
|
<TabContent value="headers">
|
||||||
<HeaderEditor
|
<HeadersEditor
|
||||||
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
|
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
|
||||||
headers={activeRequest.headers}
|
headers={activeRequest.headers}
|
||||||
onChange={handleHeadersChange}
|
onChange={handleHeadersChange}
|
||||||
/>
|
/>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value="params">
|
<TabContent value="params">
|
||||||
<ParametersEditor
|
<UrlParametersEditor
|
||||||
forceUpdateKey={forceUpdateKey}
|
forceUpdateKey={forceUpdateKey}
|
||||||
parameters={[]}
|
urlParameters={activeRequest.urlParameters}
|
||||||
onChange={() => null}
|
onChange={handleUrlParametersChange}
|
||||||
/>
|
/>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value="body">
|
<TabContent value="body">
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ export interface HttpHeader {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HttpUrlParameter {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface HttpRequest extends BaseModel {
|
export interface HttpRequest extends BaseModel {
|
||||||
readonly workspaceId: string;
|
readonly workspaceId: string;
|
||||||
readonly model: 'http_request';
|
readonly model: 'http_request';
|
||||||
@@ -56,6 +62,7 @@ export interface HttpRequest extends BaseModel {
|
|||||||
sortPriority: number;
|
sortPriority: number;
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
urlParameters: HttpUrlParameter[];
|
||||||
body: Record<string, string | number | boolean | null | undefined>;
|
body: Record<string, string | number | boolean | null | undefined>;
|
||||||
bodyType: string | null;
|
bodyType: string | null;
|
||||||
authentication: Record<string, string | number | boolean | null | undefined>;
|
authentication: Record<string, string | number | boolean | null | undefined>;
|
||||||
|
|||||||
Reference in New Issue
Block a user