添加登录页面

This commit is contained in:
wjsjwr 2025-02-22 16:59:51 +08:00
parent 4b052d8b2a
commit 16b3b63169
6 changed files with 384 additions and 34 deletions

View File

@ -10,15 +10,13 @@
<cargoProject FILE="$PROJECT_DIR$/bookkeeper/Cargo.toml" />
</component>
<component name="ChangeListManager">
<list default="true" id="76b3b902-7a5c-4bcd-9c1b-8241d748fb44" name="更改" comment="清仓筛选">
<list default="true" id="76b3b902-7a5c-4bcd-9c1b-8241d748fb44" name="更改" comment="添加总计">
<change afterPath="$PROJECT_DIR$/bookkeeper/templates/login.html.j2" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/bookkeeper/Cargo.lock" beforeDir="false" afterPath="$PROJECT_DIR$/bookkeeper/Cargo.lock" afterDir="false" />
<change beforePath="$PROJECT_DIR$/bookkeeper/Cargo.toml" beforeDir="false" afterPath="$PROJECT_DIR$/bookkeeper/Cargo.toml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/bookkeeper/migration/src/m20220101_000001_create_table.rs" beforeDir="false" afterPath="$PROJECT_DIR$/bookkeeper/migration/src/m20220101_000001_create_table.rs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/bookkeeper/migration/src/m20241230_160326_gain_evolution.rs" beforeDir="false" afterPath="$PROJECT_DIR$/bookkeeper/migration/src/m20241230_160326_gain_evolution.rs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/bookkeeper/Rocket.toml" beforeDir="false" afterPath="$PROJECT_DIR$/bookkeeper/Rocket.toml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/bookkeeper/src/main.rs" beforeDir="false" afterPath="$PROJECT_DIR$/bookkeeper/src/main.rs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/bookkeeper/templates/index.html.j2" beforeDir="false" afterPath="$PROJECT_DIR$/bookkeeper/templates/index.html.j2" afterDir="false" />
<change beforePath="$PROJECT_DIR$/bookkeeper/templates/tx.html.j2" beforeDir="false" afterPath="$PROJECT_DIR$/bookkeeper/templates/tx.html.j2" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -30,6 +28,7 @@
<option name="RECENT_TEMPLATES">
<list>
<option value="HTML File" />
<option value="Rust File" />
</list>
</option>
</component>
@ -158,7 +157,8 @@
<workItem from="1736238929025" duration="646000" />
<workItem from="1736239592084" duration="2278000" />
<workItem from="1737809478583" duration="76000" />
<workItem from="1739197188908" duration="1079000" />
<workItem from="1739197188908" duration="1873000" />
<workItem from="1740209396270" duration="4798000" />
</task>
<task id="LOCAL-00001" summary="Add bookkeeper">
<option name="closed" value="true" />
@ -264,7 +264,15 @@
<option name="project" value="LOCAL" />
<updated>1736241771673</updated>
</task>
<option name="localTasksCounter" value="14" />
<task id="LOCAL-00014" summary="添加总计">
<option name="closed" value="true" />
<created>1739198426101</created>
<option name="number" value="00014" />
<option name="presentableId" value="LOCAL-00014" />
<option name="project" value="LOCAL" />
<updated>1739198426101</updated>
</task>
<option name="localTasksCounter" value="15" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@ -295,7 +303,8 @@
<MESSAGE value="Filter by name" />
<MESSAGE value="更新版本号和部署脚本" />
<MESSAGE value="清仓筛选" />
<option name="LAST_COMMIT_MESSAGE" value="清仓筛选" />
<MESSAGE value="添加总计" />
<option name="LAST_COMMIT_MESSAGE" value="添加总计" />
</component>
<component name="XSLT-Support.FileAssociations.UIState">
<expand />

178
bookkeeper/Cargo.lock generated
View File

@ -17,13 +17,48 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "ahash"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
dependencies = [
"getrandom",
"getrandom 0.2.15",
"once_cell",
"version_check",
]
@ -352,6 +387,19 @@ version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
[[package]]
name = "bcrypt"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92758ad6077e4c76a6cadbce5005f666df70d4f13b19976b1a8062eef880040f"
dependencies = [
"base64",
"blowfish",
"getrandom 0.3.1",
"subtle",
"zeroize",
]
[[package]]
name = "bigdecimal"
version = "0.4.7"
@ -422,9 +470,20 @@ dependencies = [
]
[[package]]
name = "bookkeeper"
version = "0.1.3"
name = "blowfish"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
dependencies = [
"byteorder",
"cipher",
]
[[package]]
name = "bookkeeper"
version = "0.2.0"
dependencies = [
"bcrypt",
"chrono",
"entity",
"migration",
@ -574,6 +633,16 @@ dependencies = [
"phf_codegen",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "clap"
version = "4.5.23"
@ -641,7 +710,13 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"aes-gcm",
"base64",
"hkdf",
"percent-encoding",
"rand",
"sha2",
"subtle",
"time",
"version_check",
]
@ -726,9 +801,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [
"generic-array",
"rand_core",
"typenum",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "darling"
version = "0.20.10"
@ -1169,7 +1254,29 @@ checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [
"cfg-if",
"libc",
"wasi",
"wasi 0.11.0+wasi-snapshot-preview1",
]
[[package]]
name = "getrandom"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8"
dependencies = [
"cfg-if",
"libc",
"wasi 0.13.3+wasi-0.2.2",
"windows-targets 0.52.6",
]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
@ -1640,6 +1747,15 @@ dependencies = [
"libc",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "is-terminal"
version = "0.4.13"
@ -1884,7 +2000,7 @@ checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log",
"wasi",
"wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.48.0",
]
@ -1895,7 +2011,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
dependencies = [
"libc",
"wasi",
"wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.52.0",
]
@ -2054,6 +2170,12 @@ version = "1.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "ordered-float"
version = "3.9.2"
@ -2324,6 +2446,18 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
@ -2467,7 +2601,7 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
"getrandom 0.2.15",
]
[[package]]
@ -2560,7 +2694,7 @@ checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
dependencies = [
"cc",
"cfg-if",
"getrandom",
"getrandom 0.2.15",
"libc",
"spin",
"untrusted",
@ -3934,6 +4068,16 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "untrusted"
version = "0.9.0"
@ -4033,6 +4177,15 @@ version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "wasi"
version = "0.13.3+wasi-0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2"
dependencies = [
"wit-bindgen-rt",
]
[[package]]
name = "wasite"
version = "0.1.0"
@ -4341,6 +4494,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen-rt"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c"
dependencies = [
"bitflags 2.6.0",
]
[[package]]
name = "write16"
version = "1.0.0"

View File

@ -1,11 +1,11 @@
[package]
name = "bookkeeper"
version = "0.1.3"
version = "0.2.0"
edition = "2021"
[dependencies]
chrono = "0.4.39"
rocket = "0.5.1"
rocket = { version = "0.5.1", features = ["secrets"] }
rocket_dyn_templates = { version = "0.2.0", features = ["tera", "minijinja"]}
migration = { path = "migration" }
entity ={ path = "entity"}
@ -13,6 +13,7 @@ rust_decimal = "1.36.0"
sea-orm-rocket = "0.5.4"
serde_json = "1.0.134"
serde = { version = "1.0.216", features = ["derive"] }
bcrypt = "0.17.0"
[workspace]
members = [".", "entity", "migration"]

View File

@ -2,6 +2,7 @@
[default]
address = "0.0.0.0"
limits = { form = "64 kB", json = "1 MiB" }
secret_key = "b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad"
[default.databases.bookkeeper]
url = "sqlite://bookkeeper.db"
@ -20,7 +21,6 @@ port = 9001
[release]
port = 9999
ip_header = false
secret_key = "hPrYyЭRiMyµ5sBB1π+CMæ1køFsåqKvBiQJxBVHQk="
[global.templates]
dir = "templates"

View File

@ -1,6 +1,7 @@
#[macro_use]
extern crate rocket;
use bcrypt::{hash_with_salt, DEFAULT_COST};
use chrono::prelude::*;
use chrono::NaiveDate;
use chrono::TimeDelta;
@ -10,8 +11,10 @@ use migration::MigratorTrait;
use rocket::fairing::{self, AdHoc};
use rocket::form::Form;
use rocket::fs::FileServer;
use rocket::http::Status;
use rocket::response::{Flash, Redirect};
use rocket::http::{Cookie, CookieJar, Status};
use rocket::request::{FromRequest, Outcome, Request};
use rocket::response::Redirect;
use rocket::State;
use rocket::{Build, Rocket};
use rocket_dyn_templates::{context, Engines, Template};
use rust_decimal::Decimal;
@ -20,8 +23,121 @@ use sea_orm::ActiveValue::Set;
use sea_orm::{ActiveModelTrait, ColumnTrait, ConnectOptions, DatabaseConnection, EntityTrait};
use sea_orm_rocket::{rocket::figment::Figment, Config, Connection, Database};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::str::FromStr;
use std::time::Duration;
use std::sync::Mutex;
use std::time::{Duration, SystemTime};
// static CORRECT_PASSWORD: &str = "Admin!23-ca$hc0w";
static CORRECT_HASH: &str = "$2y$12$Q1TvUkuzKBThOlfOUjOuKexKJCDtVvz2RznuUbQntD1TTYLo4PWwy";
/// 记录单个 IP 的错误登录记录和封禁截止时间
#[derive(Default)]
struct LoginAttempt {
/// 记录每次错误的时间只保留5分钟内的记录
failed_times: Vec<SystemTime>,
/// 封禁截止时间,如果未封禁则为 None
banned_until: Option<SystemTime>,
}
/// 全局状态,记录每个 IP 的登录尝试情况
struct LoginState {
attempts: Mutex<HashMap<String, LoginAttempt>>,
}
impl LoginState {
fn new() -> Self {
LoginState {
attempts: Mutex::new(HashMap::new()),
}
}
}
/// 登录表单数据
#[derive(FromForm)]
struct LoginForm {
password: String,
}
/// 自定义请求守卫,用于提取客户端 IP 地址
struct ClientIP(String);
#[rocket::async_trait]
impl<'r> FromRequest<'r> for ClientIP {
type Error = ();
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
if let Some(ip) = req.client_ip() {
Outcome::Success(ClientIP(ip.to_string()))
} else {
Outcome::Error((Status::BadRequest, ()))
}
}
}
/// GET /login 显示登录页面
#[get("/login")]
fn login_page() -> Template {
let context = HashMap::<String, String>::new();
Template::render("login", &context)
}
/// POST /login 处理登录请求
#[post("/login", data = "<login_form>")]
fn login_post(
login_form: Form<LoginForm>,
cookies: &CookieJar<'_>,
state: &State<LoginState>,
client_ip: ClientIP,
) -> Result<Redirect, Template> {
let ip = client_ip.0;
let now = SystemTime::now();
let mut attempts = state.attempts.lock().unwrap();
let entry = attempts
.entry(ip.clone())
.or_insert(LoginAttempt::default());
// 检查该 IP 是否处于封禁状态
if let Some(banned_until) = entry.banned_until {
if now < banned_until {
let mut context = HashMap::new();
context.insert(
"error".to_string(),
"您的IP已被禁止访问请稍后再试。".to_string(),
);
return Err(Template::render("login", &context));
} else {
// 封禁时间已过,清除记录
entry.banned_until = None;
entry.failed_times.clear();
}
}
// 仅保留过去5分钟内的错误记录
entry.failed_times.retain(|&t| {
now.duration_since(t).unwrap_or(Duration::new(0, 0)) < Duration::from_secs(300)
});
let pass_hash = hash_with_salt(&login_form.password, DEFAULT_COST, *b"KuqZl505cBxPZT02")
.unwrap()
.to_string();
// 使用 bcrypt 对输入的密码进行验证
if pass_hash == CORRECT_HASH {
// 密码正确:清除错误记录,设置登录 Cookie
entry.failed_times.clear();
cookies.add_private(Cookie::new("auth", pass_hash));
Ok(Redirect::to("/"))
} else {
// 密码错误记录错误时间判断是否需要封禁IP
entry.failed_times.push(now);
if entry.failed_times.len() > 5 {
entry.banned_until = Some(now + Duration::from_secs(7200));
}
let mut context = HashMap::new();
context.insert("error".to_string(), "密码错误!".to_string());
Err(Template::render("login", &context))
}
}
#[derive(sea_orm_rocket::Database, Debug)]
#[database("bookkeeper")]
@ -57,13 +173,28 @@ impl sea_orm_rocket::Pool for SeaOrmPool {
}
}
fn verify(cookies: &CookieJar<'_>) -> bool {
if let Some(c) = cookies.get_private("auth") {
// println!("{} ? {}", c.value_trimmed(), )
if c.value_trimmed() == CORRECT_HASH {
return true;
}
}
false
}
#[get("/?<last>&<name>&<is_done>")]
async fn index(
conn: Connection<'_, Db>,
last: Option<u32>,
name: Option<&str>,
is_done: Option<bool>,
) -> Template {
cookies: &CookieJar<'_>,
) -> Result<Template, Redirect> {
if !verify(cookies) {
return Err(Redirect::to("/login"));
}
let mut partial = TE::find();
if let Some(last) = last {
@ -103,11 +234,11 @@ async fn index(
net_gain += ng;
}
}
let pct = gain / bought;
let net_pct = net_gain / net_bought;
Template::render(
Ok(Template::render(
"index",
context! {
rows: rows,
@ -117,7 +248,7 @@ async fn index(
pct: pct,
net_pct: net_pct,
},
)
))
}
#[derive(FromForm, Debug)]
@ -150,7 +281,14 @@ struct SplitPiece {
}
#[get("/tx/<id>")]
async fn get_tx(conn: Connection<'_, Db>, id: i32) -> Result<Template, Status> {
async fn get_tx(
conn: Connection<'_, Db>,
id: i32,
cookies: &CookieJar<'_>,
) -> Result<Template, Status> {
if !verify(cookies) {
return Err(Status::Forbidden);
}
let row = TE::find_by_id(id).one(conn.into_inner()).await.unwrap();
match row {
None => Err(Status::NotFound),
@ -169,7 +307,11 @@ async fn post_tx(
data: Form<TransModel<'_>>,
conn: Connection<'_, Db>,
id: i32,
cookies: &CookieJar<'_>,
) -> Result<Redirect, Status> {
if !verify(cookies) {
return Err(Status::Forbidden);
}
let mut record = translate(data);
record.id = Set(id);
match record.update(conn.into_inner()).await {
@ -179,7 +321,14 @@ async fn post_tx(
}
#[delete("/tx/<id>")]
async fn delete_tx(conn: Connection<'_, Db>, id: i32) -> Result<Redirect, Status> {
async fn delete_tx(
conn: Connection<'_, Db>,
id: i32,
cookies: &CookieJar<'_>,
) -> Result<Redirect, Status> {
if !verify(cookies) {
return Err(Status::Forbidden);
}
match TE::delete_by_id(id).exec(conn.into_inner()).await {
Err(_) => Err(Status::InternalServerError),
Ok(res) => {
@ -193,23 +342,34 @@ async fn delete_tx(conn: Connection<'_, Db>, id: i32) -> Result<Redirect, Status
}
#[get("/add")]
async fn get_add() -> Template {
Template::render(
async fn get_add(cookies: &CookieJar<'_>) -> Result<Template, Redirect> {
if !verify(cookies) {
return Err(Redirect::to("/login"));
}
Ok(Template::render(
"tx",
context! {
r: (),
target: "/add",
},
)
))
}
#[post("/add", data = "<trans>")]
async fn add(trans: Form<TransModel<'_>>, conn: Connection<'_, Db>) -> Flash<Redirect> {
async fn add(
trans: Form<TransModel<'_>>,
conn: Connection<'_, Db>,
cookies: &CookieJar<'_>,
) -> Result<Redirect, Status> {
if !verify(cookies) {
return Err(Status::Forbidden);
}
let record = translate(trans);
record.insert(conn.into_inner()).await.unwrap();
Flash::success(Redirect::to("/"), "OK!")
Ok(Redirect::to("/"))
}
/// translate from form to active model of transaction
@ -268,6 +428,7 @@ async fn rocket() -> _ {
rocket::build()
.attach(Db::init())
.attach(AdHoc::try_on_ignite("Migrations", run_migrations))
.manage(LoginState::new())
.attach(Template::custom(|engines: &mut Engines| {
engines
.minijinja
@ -281,7 +442,7 @@ async fn rocket() -> _ {
}))
.mount(
"/",
routes![index, add, get_add, get_tx, post_tx, delete_tx],
routes![index, add, get_add, get_tx, post_tx, delete_tx, login_page, login_post],
)
.mount("/static", FileServer::from("static"))
}

View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>登录</title>
</head>
<body>
<h1>登录页面</h1>
{% if error %}
<p style="color: red;">{{ error }}</p>
{% endif %}
<form action="/login" method="post">
<label>密码:<input type="password" name="password"></label>
<button type="submit">登录</button>
</form>
</body>
</html>