mirror of
https://github.com/kristoferssolo/cipher-workshop.git
synced 2025-12-31 13:52:29 +00:00
feat(web): create AES-CBC page
The AES-CBC page supports:
- Text input: Large textarea for typing/pasting plaintext or ciphertext
- File input: File upload for binary encryption/decryption
- IV input: Hex-based 16-byte initialization vector
- Download: Download encrypted/decrypted output as a file
- Output formats: Hex, Binary, Octal, Text for decryption output
This commit is contained in:
parent
187b65d011
commit
c208ce2e81
@ -20,16 +20,31 @@ leptos_meta = { version = "0.8" }
|
|||||||
leptos_router = { version = "0.8", features = ["nightly"] }
|
leptos_router = { version = "0.8", features = ["nightly"] }
|
||||||
strum.workspace = true
|
strum.workspace = true
|
||||||
tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
|
tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
|
||||||
wasm-bindgen = { version = "=0.2.104", optional = true }
|
wasm-bindgen = "0.2"
|
||||||
|
js-sys = "0.3"
|
||||||
web-sys = { version = "0.3", features = [
|
web-sys = { version = "0.3", features = [
|
||||||
"Navigator",
|
"Blob",
|
||||||
"Window",
|
|
||||||
"Clipboard",
|
"Clipboard",
|
||||||
|
"Crypto",
|
||||||
|
"Document",
|
||||||
|
"Element",
|
||||||
|
"Event",
|
||||||
|
"EventTarget",
|
||||||
|
"File",
|
||||||
|
"FileList",
|
||||||
|
"FileReader",
|
||||||
|
"HtmlElement",
|
||||||
|
"HtmlInputElement",
|
||||||
|
"HtmlTextAreaElement",
|
||||||
|
"Navigator",
|
||||||
|
"ProgressEvent",
|
||||||
"Storage",
|
"Storage",
|
||||||
|
"Url",
|
||||||
|
"Window",
|
||||||
] }
|
] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
hydrate = ["leptos/hydrate", "dep:console_error_panic_hook", "dep:wasm-bindgen"]
|
hydrate = ["leptos/hydrate", "dep:console_error_panic_hook"]
|
||||||
ssr = [
|
ssr = [
|
||||||
"dep:axum",
|
"dep:axum",
|
||||||
"dep:tokio",
|
"dep:tokio",
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
use crate::pages::{
|
use crate::pages::{
|
||||||
aes::AesPage, des::DesPage, footer::Footer, header::Header, home::Home, not_found::NotFound,
|
aes::AesPage, aes_cbc::AesCbcPage, des::DesPage, footer::Footer, header::Header, home::Home,
|
||||||
|
not_found::NotFound,
|
||||||
};
|
};
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context};
|
use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context};
|
||||||
@ -49,6 +50,7 @@ pub fn App() -> impl IntoView {
|
|||||||
<Route path=StaticSegment("/") view=Home />
|
<Route path=StaticSegment("/") view=Home />
|
||||||
<Route path=StaticSegment("/des") view=DesPage />
|
<Route path=StaticSegment("/des") view=DesPage />
|
||||||
<Route path=StaticSegment("/aes") view=AesPage />
|
<Route path=StaticSegment("/aes") view=AesPage />
|
||||||
|
<Route path=StaticSegment("/aes-cbc") view=AesCbcPage />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
|
use crate::components::{
|
||||||
|
config_section::ConfigurationSection,
|
||||||
|
error_box::ErrorBox,
|
||||||
|
key_input::{KeyInput, KeySize},
|
||||||
|
output_box::OutputBox,
|
||||||
|
text_input::TextInput,
|
||||||
|
};
|
||||||
use cipher_factory::prelude::*;
|
use cipher_factory::prelude::*;
|
||||||
use leptos::{prelude::*, tachys::dom::event_target_value};
|
use leptos::prelude::*;
|
||||||
use std::{str::FromStr, time::Duration};
|
use std::time::Duration;
|
||||||
use strum::IntoEnumIterator;
|
|
||||||
use web_sys::WheelEvent;
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn CipherForm(algorithm: Algorithm) -> impl IntoView {
|
pub fn CipherForm(algorithm: Algorithm) -> impl IntoView {
|
||||||
@ -17,6 +22,11 @@ pub fn CipherForm(algorithm: Algorithm) -> impl IntoView {
|
|||||||
|
|
||||||
let (copy_feedback, set_copy_feedback) = signal(false);
|
let (copy_feedback, set_copy_feedback) = signal(false);
|
||||||
|
|
||||||
|
let key_size = match algorithm {
|
||||||
|
Algorithm::Des => KeySize::Des,
|
||||||
|
Algorithm::Aes | Algorithm::AesCbc => KeySize::Aes128,
|
||||||
|
};
|
||||||
|
|
||||||
let handle_submit = move || {
|
let handle_submit = move || {
|
||||||
set_error_msg(String::new());
|
set_error_msg(String::new());
|
||||||
set_output(String::new());
|
set_output(String::new());
|
||||||
@ -74,7 +84,7 @@ pub fn CipherForm(algorithm: Algorithm) -> impl IntoView {
|
|||||||
output_fmt=output_fmt
|
output_fmt=output_fmt
|
||||||
update_output=update_output
|
update_output=update_output
|
||||||
/>
|
/>
|
||||||
<KeyInput set_key_input=set_key_input />
|
<KeyInput key_input=key_input set_key_input=set_key_input key_size=key_size />
|
||||||
<TextInput mode=mode text_input=text_input set_text_input=set_text_input />
|
<TextInput mode=mode text_input=text_input set_text_input=set_text_input />
|
||||||
|
|
||||||
<button class="btn-primary" on:click=move |_| handle_submit()>
|
<button class="btn-primary" on:click=move |_| handle_submit()>
|
||||||
@ -92,230 +102,3 @@ pub fn CipherForm(algorithm: Algorithm) -> impl IntoView {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
|
||||||
fn RadioButton(
|
|
||||||
value: OperationMode,
|
|
||||||
current: ReadSignal<OperationMode>,
|
|
||||||
set_current: WriteSignal<OperationMode>,
|
|
||||||
) -> AnyView {
|
|
||||||
view! {
|
|
||||||
<div class="radio-button">
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="crypto-mode"
|
|
||||||
value=value.to_string()
|
|
||||||
prop:checked=move || current.get() == value
|
|
||||||
on:change=move |_| set_current.set(value)
|
|
||||||
/>
|
|
||||||
{value.to_string()}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
.into_any()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
fn ConfigurationSection(
|
|
||||||
mode: ReadSignal<OperationMode>,
|
|
||||||
set_mode: WriteSignal<OperationMode>,
|
|
||||||
output_fmt: ReadSignal<OutputFormat>,
|
|
||||||
update_output: impl Fn(OutputFormat) + Copy + Send + 'static,
|
|
||||||
) -> AnyView {
|
|
||||||
let handle_format_change = move |ev| {
|
|
||||||
let val = event_target_value(&ev);
|
|
||||||
let fmt = OutputFormat::from_str(&val).unwrap_or_default();
|
|
||||||
update_output(fmt);
|
|
||||||
};
|
|
||||||
|
|
||||||
let handle_format_wheel = move |ev: WheelEvent| {
|
|
||||||
ev.prevent_default();
|
|
||||||
|
|
||||||
let formats = OutputFormat::iter().collect::<Vec<_>>();
|
|
||||||
let current_idx = formats
|
|
||||||
.iter()
|
|
||||||
.position(|f| *f == output_fmt.get())
|
|
||||||
.unwrap_or(2);
|
|
||||||
|
|
||||||
let next_idx = if ev.delta_y() > 0.0 {
|
|
||||||
(current_idx + 1) % formats.len()
|
|
||||||
} else if current_idx == 0 {
|
|
||||||
formats.len() - 1
|
|
||||||
} else {
|
|
||||||
current_idx - 1
|
|
||||||
};
|
|
||||||
update_output(formats[next_idx]);
|
|
||||||
};
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<div class="form-group">
|
|
||||||
<label>"Configuration"</label>
|
|
||||||
<div class="controls-row">
|
|
||||||
<div class="radio-group">
|
|
||||||
<RadioButton value=OperationMode::Encrypt current=mode set_current=set_mode />
|
|
||||||
<RadioButton value=OperationMode::Decrypt current=mode set_current=set_mode />
|
|
||||||
</div>
|
|
||||||
{move || {
|
|
||||||
if mode.get() != OperationMode::Decrypt {
|
|
||||||
return view! { <span></span> }.into_any();
|
|
||||||
}
|
|
||||||
view! {
|
|
||||||
<div class="format-controls-box">
|
|
||||||
<div class="format-controls">
|
|
||||||
<label>"Output format:"</label>
|
|
||||||
<select
|
|
||||||
on:wheel=handle_format_wheel
|
|
||||||
on:change=handle_format_change
|
|
||||||
prop:value=move || output_fmt.get().to_string()
|
|
||||||
>
|
|
||||||
{OutputFormat::iter()
|
|
||||||
.map(|fmt| {
|
|
||||||
view! {
|
|
||||||
<option value=fmt.to_string()>{fmt.to_string()}</option>
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect_view()}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
.into_any()
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}.into_any()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clean_hex_input(input: String) -> String {
|
|
||||||
input
|
|
||||||
.chars()
|
|
||||||
.filter(|ch| ch.is_ascii_hexdigit())
|
|
||||||
.collect::<String>()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
fn KeyInput(set_key_input: WriteSignal<String>) -> AnyView {
|
|
||||||
view! {
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="lable-header">
|
|
||||||
<label>"Secret Key"</label>
|
|
||||||
<span class="input-hint">"Prefix: 0x (Hex), 0b (Bin), or nothing (Text)"</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Enter key (e.g., 0x1A2B...)"
|
|
||||||
on:input=move |ev| set_key_input(event_target_value(&ev))
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
.into_any()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
fn TextInput(
|
|
||||||
mode: ReadSignal<OperationMode>,
|
|
||||||
text_input: ReadSignal<String>,
|
|
||||||
set_text_input: WriteSignal<String>,
|
|
||||||
) -> AnyView {
|
|
||||||
let handle_hex_input = move |ev| {
|
|
||||||
let val = event_target_value(&ev);
|
|
||||||
let cleaned = clean_hex_input(val);
|
|
||||||
set_text_input(cleaned);
|
|
||||||
};
|
|
||||||
|
|
||||||
let handle_text_input = move |ev| {
|
|
||||||
set_text_input(event_target_value(&ev));
|
|
||||||
};
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<div class="form-group">
|
|
||||||
{move || {
|
|
||||||
match mode.get() {
|
|
||||||
OperationMode::Encrypt => {
|
|
||||||
view! {
|
|
||||||
<div class="lable-header">
|
|
||||||
<label>"Plaintext Input"</label>
|
|
||||||
<span class="input-hint">
|
|
||||||
"Prefix: 0x (Hex), 0b (Bin), or nothing (Text)"
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="input-wrapper standard-input">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Enter text..."
|
|
||||||
on:input=handle_text_input
|
|
||||||
spellcheck="false"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
.into_any()
|
|
||||||
}
|
|
||||||
OperationMode::Decrypt => {
|
|
||||||
view! {
|
|
||||||
<div class="lable-header">
|
|
||||||
<label>"Ciphertext Input"</label>
|
|
||||||
</div>
|
|
||||||
<div class="input-wrapper hex-input">
|
|
||||||
<span class="prefix">"0x"</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
prop:value=move || text_input.get()
|
|
||||||
placeholder="001122"
|
|
||||||
on:input=handle_hex_input
|
|
||||||
spellcheck="false"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
.into_any()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
.into_any()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
fn OutputBox(
|
|
||||||
output: ReadSignal<String>,
|
|
||||||
output_fmt: ReadSignal<OutputFormat>,
|
|
||||||
copy_to_clipboard: impl Fn(String) + Copy + Send + 'static,
|
|
||||||
copy_feedback: ReadSignal<bool>,
|
|
||||||
) -> AnyView {
|
|
||||||
view! {
|
|
||||||
{move || {
|
|
||||||
if output.get().is_empty() {
|
|
||||||
return view! { <span></span> }.into_any();
|
|
||||||
}
|
|
||||||
view! {
|
|
||||||
<div class="result-box">
|
|
||||||
<div class="result-toolbar">
|
|
||||||
<strong>"Output ("{output_fmt.get().to_string()}")"</strong>
|
|
||||||
<button class="btn-copy" on:click=move |_| copy_to_clipboard(output.get())>
|
|
||||||
{move || {
|
|
||||||
if copy_feedback.get() { "✔️ Copied" } else { "📋 Copy" }
|
|
||||||
}}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<code>{output.get()}</code>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
.into_any()
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
.into_any()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
fn ErrorBox(error_msg: ReadSignal<String>) -> AnyView {
|
|
||||||
view! {
|
|
||||||
{move || {
|
|
||||||
if error_msg.get().is_empty() {
|
|
||||||
return view! { <span></span> }.into_any();
|
|
||||||
}
|
|
||||||
view! { <div class="error-box">{error_msg.get()}</div> }.into_any()
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
.into_any()
|
|
||||||
}
|
|
||||||
|
|||||||
290
web/src/components/cipher_form_cbc.rs
Normal file
290
web/src/components/cipher_form_cbc.rs
Normal file
@ -0,0 +1,290 @@
|
|||||||
|
use crate::components::{
|
||||||
|
config_section::ConfigurationSection,
|
||||||
|
error_box::ErrorBox,
|
||||||
|
file_input::{FileTextInput, InputMode},
|
||||||
|
iv_input::IvInput,
|
||||||
|
key_input::{KeyInput, KeySize},
|
||||||
|
};
|
||||||
|
use cipher_factory::prelude::*;
|
||||||
|
use js_sys::{Array, Uint8Array};
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use std::time::Duration;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use web_sys::{Blob, Url};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn CipherFormCbc() -> impl IntoView {
|
||||||
|
let (mode, set_mode) = signal(OperationMode::Encrypt);
|
||||||
|
let (output_fmt, set_output_fmt) = signal(OutputFormat::Hex);
|
||||||
|
|
||||||
|
let (key_input, set_key_input) = signal(String::new());
|
||||||
|
let (iv_input, set_iv_input) = signal(String::new());
|
||||||
|
|
||||||
|
// Input mode and content
|
||||||
|
let (input_mode, set_input_mode) = signal(InputMode::Text);
|
||||||
|
let (text_content, set_text_content) = signal(String::new());
|
||||||
|
let (file_data, set_file_data) = signal(Option::<Vec<u8>>::None);
|
||||||
|
let (file_name, set_file_name) = signal(Option::<String>::None);
|
||||||
|
|
||||||
|
// Output state
|
||||||
|
let (output, set_output) = signal(String::new());
|
||||||
|
let (output_bytes, set_output_bytes) = signal(Option::<Vec<u8>>::None);
|
||||||
|
let (error_msg, set_error_msg) = signal(String::new());
|
||||||
|
let (copy_feedback, set_copy_feedback) = signal(false);
|
||||||
|
|
||||||
|
let is_decrypt_mode = Memo::new(move |_| mode.get() == OperationMode::Decrypt);
|
||||||
|
|
||||||
|
let handle_submit = move || {
|
||||||
|
set_error_msg(String::new());
|
||||||
|
set_output(String::new());
|
||||||
|
set_output_bytes(None);
|
||||||
|
|
||||||
|
let key = key_input.get();
|
||||||
|
let iv = iv_input.get();
|
||||||
|
|
||||||
|
if key.is_empty() {
|
||||||
|
set_error_msg("Please enter a secret key.".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if iv.is_empty() {
|
||||||
|
set_error_msg("Please enter an initialization vector (IV).".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format IV with 0x prefix (key keeps user format, IV is always hex)
|
||||||
|
let formatted_iv = format!("0x{iv}");
|
||||||
|
|
||||||
|
// Get input data
|
||||||
|
let input_data: Vec<u8> = match input_mode.get() {
|
||||||
|
InputMode::Text => {
|
||||||
|
let text = text_content.get();
|
||||||
|
if text.is_empty() {
|
||||||
|
set_error_msg("Please enter input text or select a file.".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if mode.get() == OperationMode::Decrypt {
|
||||||
|
// Parse hex input for decryption
|
||||||
|
match parse_hex_string(&text) {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(e) => {
|
||||||
|
set_error_msg(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
text.into_bytes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InputMode::File => match file_data.get() {
|
||||||
|
Some(data) => data,
|
||||||
|
None => {
|
||||||
|
set_error_msg("Please select a file.".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process encryption/decryption
|
||||||
|
match mode.get() {
|
||||||
|
OperationMode::Encrypt => {
|
||||||
|
match Algorithm::AesCbc.encrypt_cbc(&key, &formatted_iv, &input_data) {
|
||||||
|
Ok(ciphertext) => {
|
||||||
|
let hex_output = bytes_to_hex(&ciphertext);
|
||||||
|
set_output(hex_output);
|
||||||
|
set_output_bytes(Some(ciphertext));
|
||||||
|
}
|
||||||
|
Err(e) => set_error_msg(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OperationMode::Decrypt => {
|
||||||
|
match Algorithm::AesCbc.decrypt_cbc(&key, &formatted_iv, &input_data) {
|
||||||
|
Ok(plaintext) => {
|
||||||
|
set_output_bytes(Some(plaintext.clone()));
|
||||||
|
let formatted = match output_fmt.get() {
|
||||||
|
OutputFormat::Text => {
|
||||||
|
String::from_utf8(plaintext).unwrap_or_else(|_| {
|
||||||
|
set_error_msg(
|
||||||
|
"Output contains invalid UTF-8. Try Hex format.".to_string(),
|
||||||
|
);
|
||||||
|
String::new()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
OutputFormat::Hex => bytes_to_hex(&plaintext),
|
||||||
|
OutputFormat::Binary => bytes_to_binary(&plaintext),
|
||||||
|
OutputFormat::Octal => bytes_to_octal(&plaintext),
|
||||||
|
};
|
||||||
|
set_output(formatted);
|
||||||
|
}
|
||||||
|
Err(e) => set_error_msg(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let copy_to_clipboard = move |content: String| {
|
||||||
|
let clipboard = window().navigator().clipboard();
|
||||||
|
let _ = clipboard.write_text(&content);
|
||||||
|
set_copy_feedback(true);
|
||||||
|
set_timeout(move || set_copy_feedback(false), Duration::from_secs(2));
|
||||||
|
};
|
||||||
|
|
||||||
|
let update_output = move |fmt| {
|
||||||
|
set_output_fmt(fmt);
|
||||||
|
if !output.get().is_empty() {
|
||||||
|
handle_submit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let download_output = move |_| {
|
||||||
|
if let Some(bytes) = output_bytes.get() {
|
||||||
|
let original_name = file_name.get().unwrap_or_else(|| "output".to_string());
|
||||||
|
let download_name = if mode.get() == OperationMode::Encrypt {
|
||||||
|
format!("{original_name}.enc")
|
||||||
|
} else {
|
||||||
|
original_name
|
||||||
|
.strip_suffix(".enc")
|
||||||
|
.map_or_else(|| format!("{original_name}.dec"), String::from)
|
||||||
|
};
|
||||||
|
|
||||||
|
download_bytes(&bytes, &download_name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="cipher-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>"AES-CBC"</h2>
|
||||||
|
</div>
|
||||||
|
<ConfigurationSection
|
||||||
|
mode=mode
|
||||||
|
set_mode=set_mode
|
||||||
|
output_fmt=output_fmt
|
||||||
|
update_output=update_output
|
||||||
|
/>
|
||||||
|
<KeyInput key_input=key_input set_key_input=set_key_input key_size=KeySize::Aes128 />
|
||||||
|
<IvInput iv_input=iv_input set_iv_input=set_iv_input />
|
||||||
|
|
||||||
|
<FileTextInput
|
||||||
|
input_mode=input_mode
|
||||||
|
set_input_mode=set_input_mode
|
||||||
|
text_content=text_content
|
||||||
|
set_text_content=set_text_content
|
||||||
|
file_data=file_data
|
||||||
|
set_file_data=set_file_data
|
||||||
|
file_name=file_name
|
||||||
|
set_file_name=set_file_name
|
||||||
|
is_decrypt_mode=is_decrypt_mode
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button class="btn-primary" on:click=move |_| handle_submit()>
|
||||||
|
{move || format!("{} using AES-CBC", mode.get())}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
// Output section
|
||||||
|
{move || {
|
||||||
|
if output.get().is_empty() {
|
||||||
|
return view! { <span></span> }.into_any();
|
||||||
|
}
|
||||||
|
view! {
|
||||||
|
<div class="result-box">
|
||||||
|
<div class="result-toolbar">
|
||||||
|
<strong>"Output ("{output_fmt.get().to_string()}")"</strong>
|
||||||
|
<div class="result-actions">
|
||||||
|
<button class="btn-copy" on:click=move |_| copy_to_clipboard(output.get())>
|
||||||
|
{move || if copy_feedback.get() { "Copied" } else { "Copy" }}
|
||||||
|
</button>
|
||||||
|
{move || {
|
||||||
|
if output_bytes.get().is_some() {
|
||||||
|
view! {
|
||||||
|
<button class="btn-download" on:click=download_output>
|
||||||
|
"Download"
|
||||||
|
</button>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <span></span> }.into_any()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="result-content">
|
||||||
|
<code>{move || {
|
||||||
|
let out = output.get();
|
||||||
|
if out.len() > 1000 {
|
||||||
|
format!("{}... ({} chars total)", &out[..1000], out.len())
|
||||||
|
} else {
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}}
|
||||||
|
<ErrorBox error_msg=error_msg />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_hex_string(s: &str) -> Result<Vec<u8>, String> {
|
||||||
|
let trimmed = s.trim();
|
||||||
|
let s = trimmed
|
||||||
|
.strip_prefix("0x")
|
||||||
|
.or_else(|| trimmed.strip_prefix("0X"))
|
||||||
|
.unwrap_or(trimmed);
|
||||||
|
|
||||||
|
// Remove whitespace and newlines
|
||||||
|
let s: String = s.chars().filter(|c| !c.is_whitespace()).collect();
|
||||||
|
|
||||||
|
if !s.len().is_multiple_of(2) {
|
||||||
|
return Err("Hex string must have even length".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
(0..s.len())
|
||||||
|
.step_by(2)
|
||||||
|
.map(|i| {
|
||||||
|
u8::from_str_radix(&s[i..i + 2], 16)
|
||||||
|
.map_err(|_| format!("Invalid hex at position {i}"))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes_to_hex(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|b| format!("{b:02X}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes_to_binary(bytes: &[u8]) -> String {
|
||||||
|
bytes
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{b:08b}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes_to_octal(bytes: &[u8]) -> String {
|
||||||
|
bytes
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{b:03o}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_bytes(bytes: &[u8], filename: &str) {
|
||||||
|
let uint8_array = Uint8Array::new_with_length(bytes.len() as u32);
|
||||||
|
uint8_array.copy_from(bytes);
|
||||||
|
|
||||||
|
let array = Array::new();
|
||||||
|
array.push(&uint8_array.buffer());
|
||||||
|
|
||||||
|
let blob = Blob::new_with_u8_array_sequence(&array).unwrap();
|
||||||
|
let url = Url::create_object_url_with_blob(&blob).unwrap();
|
||||||
|
|
||||||
|
let document = web_sys::window().unwrap().document().unwrap();
|
||||||
|
let a = document.create_element("a").unwrap();
|
||||||
|
a.set_attribute("href", &url).unwrap();
|
||||||
|
a.set_attribute("download", filename).unwrap();
|
||||||
|
|
||||||
|
let a: web_sys::HtmlElement = a.unchecked_into();
|
||||||
|
a.click();
|
||||||
|
|
||||||
|
Url::revoke_object_url(&url).unwrap();
|
||||||
|
}
|
||||||
77
web/src/components/config_section.rs
Normal file
77
web/src/components/config_section.rs
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
use crate::components::radio_button::RadioButton;
|
||||||
|
use cipher_factory::prelude::*;
|
||||||
|
use leptos::{prelude::*, tachys::dom::event_target_value};
|
||||||
|
use std::str::FromStr;
|
||||||
|
use strum::IntoEnumIterator;
|
||||||
|
use web_sys::WheelEvent;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ConfigurationSection(
|
||||||
|
mode: ReadSignal<OperationMode>,
|
||||||
|
set_mode: WriteSignal<OperationMode>,
|
||||||
|
output_fmt: ReadSignal<OutputFormat>,
|
||||||
|
update_output: impl Fn(OutputFormat) + Copy + Send + 'static,
|
||||||
|
) -> AnyView {
|
||||||
|
let handle_format_change = move |ev| {
|
||||||
|
let val = event_target_value(&ev);
|
||||||
|
let fmt = OutputFormat::from_str(&val).unwrap_or_default();
|
||||||
|
update_output(fmt);
|
||||||
|
};
|
||||||
|
|
||||||
|
let handle_format_wheel = move |ev: WheelEvent| {
|
||||||
|
ev.prevent_default();
|
||||||
|
|
||||||
|
let formats = OutputFormat::iter().collect::<Vec<_>>();
|
||||||
|
let current_idx = formats
|
||||||
|
.iter()
|
||||||
|
.position(|f| *f == output_fmt.get())
|
||||||
|
.unwrap_or(2);
|
||||||
|
|
||||||
|
let next_idx = if ev.delta_y() > 0.0 {
|
||||||
|
(current_idx + 1) % formats.len()
|
||||||
|
} else if current_idx == 0 {
|
||||||
|
formats.len() - 1
|
||||||
|
} else {
|
||||||
|
current_idx - 1
|
||||||
|
};
|
||||||
|
update_output(formats[next_idx]);
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="form-group">
|
||||||
|
<label>"Configuration"</label>
|
||||||
|
<div class="controls-row">
|
||||||
|
<div class="radio-group">
|
||||||
|
<RadioButton value=OperationMode::Encrypt current=mode set_current=set_mode />
|
||||||
|
<RadioButton value=OperationMode::Decrypt current=mode set_current=set_mode />
|
||||||
|
</div>
|
||||||
|
{move || {
|
||||||
|
if mode.get() != OperationMode::Decrypt {
|
||||||
|
return view! { <span></span> }.into_any();
|
||||||
|
}
|
||||||
|
view! {
|
||||||
|
<div class="format-controls-box">
|
||||||
|
<div class="format-controls">
|
||||||
|
<label>"Output format:"</label>
|
||||||
|
<select
|
||||||
|
on:wheel=handle_format_wheel
|
||||||
|
on:change=handle_format_change
|
||||||
|
prop:value=move || output_fmt.get().to_string()
|
||||||
|
>
|
||||||
|
{OutputFormat::iter()
|
||||||
|
.map(|fmt| {
|
||||||
|
view! {
|
||||||
|
<option value=fmt.to_string()>{fmt.to_string()}</option>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect_view()}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
14
web/src/components/error_box.rs
Normal file
14
web/src/components/error_box.rs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ErrorBox(error_msg: ReadSignal<String>) -> AnyView {
|
||||||
|
view! {
|
||||||
|
{move || {
|
||||||
|
if error_msg.get().is_empty() {
|
||||||
|
return view! { <span></span> }.into_any();
|
||||||
|
}
|
||||||
|
view! { <div class="error-box">{error_msg.get()}</div> }.into_any()
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
151
web/src/components/file_input.rs
Normal file
151
web/src/components/file_input.rs
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
use js_sys::{ArrayBuffer, Uint8Array};
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use wasm_bindgen::{prelude::*, JsCast};
|
||||||
|
use web_sys::{Event, File, FileList, FileReader, HtmlInputElement};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum InputMode {
|
||||||
|
Text,
|
||||||
|
File,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn FileTextInput(
|
||||||
|
input_mode: ReadSignal<InputMode>,
|
||||||
|
set_input_mode: WriteSignal<InputMode>,
|
||||||
|
text_content: ReadSignal<String>,
|
||||||
|
set_text_content: WriteSignal<String>,
|
||||||
|
file_data: ReadSignal<Option<Vec<u8>>>,
|
||||||
|
set_file_data: WriteSignal<Option<Vec<u8>>>,
|
||||||
|
file_name: ReadSignal<Option<String>>,
|
||||||
|
set_file_name: WriteSignal<Option<String>>,
|
||||||
|
is_decrypt_mode: Memo<bool>,
|
||||||
|
) -> AnyView {
|
||||||
|
let handle_file_change = move |ev: Event| {
|
||||||
|
let target = ev.target().unwrap();
|
||||||
|
let input: HtmlInputElement = target.unchecked_into();
|
||||||
|
|
||||||
|
if let Some(files) = input.files() {
|
||||||
|
let files: FileList = files;
|
||||||
|
if let Some(file) = files.get(0) {
|
||||||
|
let file: File = file;
|
||||||
|
let name = file.name();
|
||||||
|
set_file_name(Some(name));
|
||||||
|
|
||||||
|
let reader = FileReader::new().unwrap();
|
||||||
|
let reader_clone = reader.clone();
|
||||||
|
|
||||||
|
let onload = Closure::wrap(Box::new(move |_: web_sys::ProgressEvent| {
|
||||||
|
if let Ok(result) = reader_clone.result()
|
||||||
|
&& let Some(array_buffer) = result.dyn_ref::<ArrayBuffer>()
|
||||||
|
{
|
||||||
|
let uint8_array = Uint8Array::new(array_buffer);
|
||||||
|
let data: Vec<u8> = uint8_array.to_vec();
|
||||||
|
set_file_data(Some(data));
|
||||||
|
}
|
||||||
|
}) as Box<dyn FnMut(_)>);
|
||||||
|
|
||||||
|
reader.set_onload(Some(onload.as_ref().unchecked_ref()));
|
||||||
|
onload.forget();
|
||||||
|
|
||||||
|
let _ = reader.read_as_array_buffer(&file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let handle_text_change = move |ev: Event| {
|
||||||
|
let target = ev.target().unwrap();
|
||||||
|
let textarea: web_sys::HtmlTextAreaElement = target.unchecked_into();
|
||||||
|
set_text_content(textarea.value());
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-header">
|
||||||
|
<label>{move || if is_decrypt_mode.get() { "Ciphertext Input" } else { "Plaintext Input" }}</label>
|
||||||
|
<div class="input-mode-toggle">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class=move || if input_mode.get() == InputMode::Text { "mode-btn active" } else { "mode-btn" }
|
||||||
|
on:click=move |_| set_input_mode(InputMode::Text)
|
||||||
|
>
|
||||||
|
"Text"
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class=move || if input_mode.get() == InputMode::File { "mode-btn active" } else { "mode-btn" }
|
||||||
|
on:click=move |_| set_input_mode(InputMode::File)
|
||||||
|
>
|
||||||
|
"File"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{move || {
|
||||||
|
match input_mode.get() {
|
||||||
|
InputMode::Text => {
|
||||||
|
view! {
|
||||||
|
<div class="textarea-wrapper">
|
||||||
|
<textarea
|
||||||
|
rows="6"
|
||||||
|
placeholder=move || {
|
||||||
|
if is_decrypt_mode.get() {
|
||||||
|
"Paste ciphertext here (hex format)..."
|
||||||
|
} else {
|
||||||
|
"Enter or paste your plaintext here..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prop:value=move || text_content.get()
|
||||||
|
on:input=handle_text_change
|
||||||
|
spellcheck="false"
|
||||||
|
></textarea>
|
||||||
|
<div class="char-count">
|
||||||
|
{move || {
|
||||||
|
let len = text_content.get().len();
|
||||||
|
format!("{} characters", len)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
InputMode::File => {
|
||||||
|
view! {
|
||||||
|
<div class="file-upload-area">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="file-input"
|
||||||
|
on:change=handle_file_change
|
||||||
|
class="file-input-hidden"
|
||||||
|
/>
|
||||||
|
<label for="file-input" class="file-upload-label">
|
||||||
|
{move || {
|
||||||
|
match file_name.get() {
|
||||||
|
Some(name) => view! {
|
||||||
|
<div class="file-selected">
|
||||||
|
<span class="file-icon">"[FILE]"</span>
|
||||||
|
<span class="file-name">{name}</span>
|
||||||
|
<span class="file-size">
|
||||||
|
{move || {
|
||||||
|
file_data.get().map(|d| format!("({} bytes)", d.len())).unwrap_or_default()
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}.into_any(),
|
||||||
|
None => view! {
|
||||||
|
<div class="file-placeholder">
|
||||||
|
<span class="upload-icon">"[+]"</span>
|
||||||
|
<span>"Click to select a file or drag and drop"</span>
|
||||||
|
</div>
|
||||||
|
}.into_any(),
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
71
web/src/components/iv_input.rs
Normal file
71
web/src/components/iv_input.rs
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
use js_sys::Uint8Array;
|
||||||
|
use leptos::{prelude::*, tachys::dom::event_target_value};
|
||||||
|
|
||||||
|
fn clean_hex_input(input: String) -> String {
|
||||||
|
input
|
||||||
|
.chars()
|
||||||
|
.filter(|ch| ch.is_ascii_hexdigit())
|
||||||
|
.collect::<String>()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_random_bytes(len: usize) -> Option<Vec<u8>> {
|
||||||
|
let window = web_sys::window()?;
|
||||||
|
let crypto = window.crypto().ok()?;
|
||||||
|
let array = Uint8Array::new_with_length(len as u32);
|
||||||
|
crypto.get_random_values_with_array_buffer_view(&array).ok()?;
|
||||||
|
Some(array.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes_to_hex(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|b| format!("{b:02X}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn IvInput(
|
||||||
|
iv_input: ReadSignal<String>,
|
||||||
|
set_iv_input: WriteSignal<String>,
|
||||||
|
) -> AnyView {
|
||||||
|
let handle_hex_input = move |ev| {
|
||||||
|
let val = event_target_value(&ev);
|
||||||
|
let cleaned = clean_hex_input(val);
|
||||||
|
set_iv_input(cleaned);
|
||||||
|
};
|
||||||
|
|
||||||
|
let generate_random_iv = move |_| {
|
||||||
|
if let Some(bytes) = generate_random_bytes(16) {
|
||||||
|
let hex = bytes_to_hex(&bytes);
|
||||||
|
set_iv_input(hex);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-header">
|
||||||
|
<label>"Initialization Vector (IV)"</label>
|
||||||
|
<div class="header-actions">
|
||||||
|
<span class="input-hint">"16 bytes (32 hex chars)"</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-generate"
|
||||||
|
on:click=generate_random_iv
|
||||||
|
title="Generate random IV"
|
||||||
|
>
|
||||||
|
"Random"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-wrapper hex-input">
|
||||||
|
<span class="prefix">"0x"</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="000102030405060708090A0B0C0D0E0F"
|
||||||
|
prop:value=move || iv_input.get()
|
||||||
|
on:input=handle_hex_input
|
||||||
|
spellcheck="false"
|
||||||
|
maxlength="32"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
73
web/src/components/key_input.rs
Normal file
73
web/src/components/key_input.rs
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
use js_sys::Uint8Array;
|
||||||
|
use leptos::{prelude::*, tachys::dom::event_target_value};
|
||||||
|
|
||||||
|
fn generate_random_bytes(len: usize) -> Option<Vec<u8>> {
|
||||||
|
let window = web_sys::window()?;
|
||||||
|
let crypto = window.crypto().ok()?;
|
||||||
|
let array = Uint8Array::new_with_length(len as u32);
|
||||||
|
crypto.get_random_values_with_array_buffer_view(&array).ok()?;
|
||||||
|
Some(array.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes_to_hex(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|b| format!("{b:02X}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Key sizes in bytes for different algorithms
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum KeySize {
|
||||||
|
/// DES: 8 bytes (64 bits, though only 56 are used)
|
||||||
|
Des,
|
||||||
|
/// AES-128: 16 bytes (128 bits)
|
||||||
|
Aes128,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeySize {
|
||||||
|
const fn bytes(self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::Des => 8,
|
||||||
|
Self::Aes128 => 16,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn KeyInput(
|
||||||
|
key_input: ReadSignal<String>,
|
||||||
|
set_key_input: WriteSignal<String>,
|
||||||
|
#[prop(default = KeySize::Aes128)] key_size: KeySize,
|
||||||
|
) -> AnyView {
|
||||||
|
let generate_random_key = move |_| {
|
||||||
|
if let Some(bytes) = generate_random_bytes(key_size.bytes()) {
|
||||||
|
let hex = format!("0x{}", bytes_to_hex(&bytes));
|
||||||
|
set_key_input(hex);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-header">
|
||||||
|
<label>"Secret Key"</label>
|
||||||
|
<div class="header-actions">
|
||||||
|
<span class="input-hint">"Prefix: 0x (Hex), 0b (Bin), or nothing (Text)"</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-generate"
|
||||||
|
on:click=generate_random_key
|
||||||
|
title="Generate random key"
|
||||||
|
>
|
||||||
|
"Random"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter key (e.g., 0x1A2B...)"
|
||||||
|
prop:value=move || key_input.get()
|
||||||
|
on:input=move |ev| set_key_input(event_target_value(&ev))
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
@ -1 +1,10 @@
|
|||||||
pub mod cipher_form;
|
pub mod cipher_form;
|
||||||
|
pub mod cipher_form_cbc;
|
||||||
|
pub mod config_section;
|
||||||
|
pub mod error_box;
|
||||||
|
pub mod file_input;
|
||||||
|
pub mod iv_input;
|
||||||
|
pub mod key_input;
|
||||||
|
pub mod output_box;
|
||||||
|
pub mod radio_button;
|
||||||
|
pub mod text_input;
|
||||||
|
|||||||
33
web/src/components/output_box.rs
Normal file
33
web/src/components/output_box.rs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
use cipher_factory::prelude::OutputFormat;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn OutputBox(
|
||||||
|
output: ReadSignal<String>,
|
||||||
|
output_fmt: ReadSignal<OutputFormat>,
|
||||||
|
copy_to_clipboard: impl Fn(String) + Copy + Send + 'static,
|
||||||
|
copy_feedback: ReadSignal<bool>,
|
||||||
|
) -> AnyView {
|
||||||
|
view! {
|
||||||
|
{move || {
|
||||||
|
if output.get().is_empty() {
|
||||||
|
return view! { <span></span> }.into_any();
|
||||||
|
}
|
||||||
|
view! {
|
||||||
|
<div class="result-box">
|
||||||
|
<div class="result-toolbar">
|
||||||
|
<strong>"Output ("{output_fmt.get().to_string()}")"</strong>
|
||||||
|
<button class="btn-copy" on:click=move |_| copy_to_clipboard(output.get())>
|
||||||
|
{move || {
|
||||||
|
if copy_feedback.get() { "Copied" } else { "Copy" }
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<code>{output.get()}</code>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
25
web/src/components/radio_button.rs
Normal file
25
web/src/components/radio_button.rs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
use cipher_factory::prelude::OperationMode;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn RadioButton(
|
||||||
|
value: OperationMode,
|
||||||
|
current: ReadSignal<OperationMode>,
|
||||||
|
set_current: WriteSignal<OperationMode>,
|
||||||
|
) -> AnyView {
|
||||||
|
view! {
|
||||||
|
<div class="radio-button">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="crypto-mode"
|
||||||
|
value=value.to_string()
|
||||||
|
prop:checked=move || current.get() == value
|
||||||
|
on:change=move |_| set_current.set(value)
|
||||||
|
/>
|
||||||
|
{value.to_string()}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
73
web/src/components/text_input.rs
Normal file
73
web/src/components/text_input.rs
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
use cipher_factory::prelude::OperationMode;
|
||||||
|
use leptos::{prelude::*, tachys::dom::event_target_value};
|
||||||
|
|
||||||
|
pub fn clean_hex_input(input: String) -> String {
|
||||||
|
input
|
||||||
|
.chars()
|
||||||
|
.filter(|ch| ch.is_ascii_hexdigit())
|
||||||
|
.collect::<String>()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn TextInput(
|
||||||
|
mode: ReadSignal<OperationMode>,
|
||||||
|
text_input: ReadSignal<String>,
|
||||||
|
set_text_input: WriteSignal<String>,
|
||||||
|
) -> AnyView {
|
||||||
|
let handle_hex_input = move |ev| {
|
||||||
|
let val = event_target_value(&ev);
|
||||||
|
let cleaned = clean_hex_input(val);
|
||||||
|
set_text_input(cleaned);
|
||||||
|
};
|
||||||
|
|
||||||
|
let handle_text_input = move |ev| {
|
||||||
|
set_text_input(event_target_value(&ev));
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="form-group">
|
||||||
|
{move || {
|
||||||
|
match mode.get() {
|
||||||
|
OperationMode::Encrypt => {
|
||||||
|
view! {
|
||||||
|
<div class="label-header">
|
||||||
|
<label>"Plaintext Input"</label>
|
||||||
|
<span class="input-hint">
|
||||||
|
"Prefix: 0x (Hex), 0b (Bin), or nothing (Text)"
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="input-wrapper standard-input">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter text..."
|
||||||
|
on:input=handle_text_input
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
|
OperationMode::Decrypt => {
|
||||||
|
view! {
|
||||||
|
<div class="label-header">
|
||||||
|
<label>"Ciphertext Input"</label>
|
||||||
|
</div>
|
||||||
|
<div class="input-wrapper hex-input">
|
||||||
|
<span class="prefix">"0x"</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
prop:value=move || text_input.get()
|
||||||
|
placeholder="001122"
|
||||||
|
on:input=handle_hex_input
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
9
web/src/pages/aes_cbc.rs
Normal file
9
web/src/pages/aes_cbc.rs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
use crate::components::cipher_form_cbc::CipherFormCbc;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AesCbcPage() -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<CipherFormCbc />
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -37,6 +37,9 @@ pub fn Header() -> impl IntoView {
|
|||||||
<li>
|
<li>
|
||||||
<A href="/aes">"AES"</A>
|
<A href="/aes">"AES"</A>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<A href="/aes-cbc">"AES-CBC"</A>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<button class="theme-toggle" on:click=toggle_theme>
|
<button class="theme-toggle" on:click=toggle_theme>
|
||||||
{move || theme.get().to_string()}
|
{move || theme.get().to_string()}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
pub mod aes;
|
pub mod aes;
|
||||||
|
pub mod aes_cbc;
|
||||||
pub mod des;
|
pub mod des;
|
||||||
pub mod footer;
|
pub mod footer;
|
||||||
pub mod header;
|
pub mod header;
|
||||||
|
|||||||
@ -671,3 +671,185 @@ main {
|
|||||||
text-transform: none;
|
text-transform: none;
|
||||||
letter-spacing: normal;
|
letter-spacing: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-generate {
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input mode toggle (Text/File switcher)
|
||||||
|
.input-mode-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px;
|
||||||
|
|
||||||
|
.mode-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--bg-body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Textarea wrapper
|
||||||
|
.textarea-wrapper {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
@include input-styles;
|
||||||
|
padding: 12px;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 120px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-count {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// File upload area
|
||||||
|
.file-upload-area {
|
||||||
|
.file-input-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-label {
|
||||||
|
@include input-styles;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 120px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-style: dashed;
|
||||||
|
border-width: 2px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--bg-highlight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-selected {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
word-break: break-all;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-size {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result actions
|
||||||
|
.result-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-download {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-content {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
code {
|
||||||
|
display: block;
|
||||||
|
padding: 15px;
|
||||||
|
word-break: break-all;
|
||||||
|
font-family: "Consolas", "Monaco", monospace;
|
||||||
|
color: var(--accent);
|
||||||
|
background: transparent;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user