From 9ccd3d6212d32c454f7ec6317ff759d83d3ddfea Mon Sep 17 00:00:00 2001 From: blek Date: Wed, 1 Nov 2023 19:19:51 +1000 Subject: [PATCH 1/7] scratch curl API --- filed/src/web/curlapi.rs | 9 +++ filed/src/web/curlapi/upload.rs | 123 ++++++++++++++++++++++++++++++++ filed/src/web/forms.rs | 28 ++++---- filed/src/web/mod.rs | 4 +- 4 files changed, 150 insertions(+), 14 deletions(-) create mode 100644 filed/src/web/curlapi.rs create mode 100644 filed/src/web/curlapi/upload.rs diff --git a/filed/src/web/curlapi.rs b/filed/src/web/curlapi.rs new file mode 100644 index 0000000..35fa226 --- /dev/null +++ b/filed/src/web/curlapi.rs @@ -0,0 +1,9 @@ +use warp::{Filter, reply::Reply, reject::Rejection}; + +use super::state::SharedState; + +mod upload; + +pub fn get_routes(state: SharedState) -> impl Filter + Clone { + upload::get_routes(state) +} \ No newline at end of file diff --git a/filed/src/web/curlapi/upload.rs b/filed/src/web/curlapi/upload.rs new file mode 100644 index 0000000..a702bd9 --- /dev/null +++ b/filed/src/web/curlapi/upload.rs @@ -0,0 +1,123 @@ +use std::net::IpAddr; + +use warp::{filters::multipart::FormData, reply::{Reply, with_status}, reject::Rejection, Filter}; +use warp_real_ip::real_ip; + +use crate::{web::{state::SharedState, forms::{FormElement, UploadFormData}, rejection::HttpReject}, files::File}; + +pub async fn upload(form: FormData, ip: Option, state: SharedState) -> Result, Rejection> { + if ! state.config.files.allow_uploads { + return Ok( + Box::new( + with_status( + match state.config.files.upload_disable_reason { + Some(reason) => format!("Uploads are disabled for the following reason:\n{reason}"), + None => "Uploads are disabled.".into() + }, + warp::http::StatusCode::SERVICE_UNAVAILABLE + ) + ) + ) + } + + let params = FormElement::from_formdata(form) + .await + .map_err(|x| HttpReject::WarpError(x))?; + + let formdata = UploadFormData::from_formdata(params, true); + if let Some(formdata) = formdata { + + let mut breaks_conf = false; + if (!state.config.files.allow_custom_names) && formdata.filename.is_some() { + breaks_conf = true; + } + if (!state.config.files.allow_pass_protection) && formdata.password.is_some() { + breaks_conf = true; + } + + if breaks_conf { + return Ok( + Box::new( + with_status( + "Attempt to set name or password when they are disabled".to_string(), + warp::http::StatusCode::BAD_REQUEST + ) + ) + ); + } + + if let Some(pass) = state.config.files.upload_pass { + let mut pass_valid = false; + if let Some(upass) = formdata.instancepass { + pass_valid = upass == pass; + } else { + pass_valid = false + } + + if ! pass_valid { + return Ok( + Box::new( + with_status( + "Invalid instance password".to_string(), + warp::http::StatusCode::BAD_REQUEST + ) + ) + ); + } + } + + let file = File::create( + formdata.file, + formdata.mime, + formdata.filename.clone(), + state.env.clone(), + formdata.delmode, + formdata.password, + ip + ).await.map_err(|x| HttpReject::StringError(x.to_string()))?; + + state.file_mgr.save(&file, formdata.lookup_kind).map_err(|x| HttpReject::StringError(x.to_string()))?; + + return Ok( + Box::new( + format!( + concat!( + "File uploaded successfully.\n", + "It is available via this link:\n\n", + + "{}/upload/{}" + ), + state.env.instanceurl, + urlencoding::encode( + match formdata.filename { + Some(name) => name, + None => file.hash() + }.as_str() + ) + ) + ) + ); + + } else { + Ok( + Box::new( + with_status( + "Invalid form".to_string(), + warp::http::StatusCode::BAD_REQUEST + ) + ) + ) + } +} + +pub fn get_routes(state: SharedState) -> impl Filter + Clone { + warp::any() + .and(warp::path!("curlapi" / "upload")) + .and(warp::multipart::form()) + .and(real_ip(vec![state.env.proxy_addr])) + .and( + warp::any() + .map(move || state.clone()) + ) + .and_then(upload) +} \ No newline at end of file diff --git a/filed/src/web/forms.rs b/filed/src/web/forms.rs index 2d43aae..a8adc81 100644 --- a/filed/src/web/forms.rs +++ b/filed/src/web/forms.rs @@ -17,7 +17,7 @@ use crate::files::{File, lookup::LookupKind, DeleteMode}; use super::{state::SharedState, pages::{UploadSuccessPage, ErrorPage}, rejection::HttpReject}; #[derive(Debug, Serialize, Clone)] -struct FormElement { +pub struct FormElement { data: Vec, mime: String } @@ -58,15 +58,15 @@ impl FormElement { } } -struct UploadFormData { - filename: Option, - password: Option, - instancepass: Option, - lookup_kind: LookupKind, - delmode: DeleteMode, - file: Vec, - mime: String, - tos_consent: bool +pub struct UploadFormData { + pub filename: Option, + pub password: Option, + pub instancepass: Option, + pub lookup_kind: LookupKind, + pub delmode: DeleteMode, + pub file: Vec, + pub mime: String, + pub tos_consent: bool } impl Default for UploadFormData { @@ -86,7 +86,7 @@ impl Default for UploadFormData { impl UploadFormData { - pub fn from_formdata(data: HashMap) -> Option { + pub fn from_formdata(data: HashMap, use_defaults: bool) -> Option { let mut out = Self::default(); // Add a name @@ -125,7 +125,9 @@ impl UploadFormData { } }, None => { - return None + if ! use_defaults { + return None + } } } @@ -169,7 +171,7 @@ pub async fn upload(form: FormData, ip: Option, state: SharedState) -> R } let params: HashMap = FormElement::from_formdata(form).await.map_err(|x| HttpReject::WarpError(x))?; - let formdata = UploadFormData::from_formdata(params.clone()); + let formdata = UploadFormData::from_formdata(params.clone(), false); if let Some(formdata) = formdata { diff --git a/filed/src/web/mod.rs b/filed/src/web/mod.rs index 2fc92a3..bbc786d 100644 --- a/filed/src/web/mod.rs +++ b/filed/src/web/mod.rs @@ -8,16 +8,18 @@ use warp::{Filter, reply::Reply, reject::Rejection}; use crate::{env::Env, files::lookup::FileManager, config::types::Config}; mod pages; -mod forms; +pub mod forms; pub mod state; mod rejection; mod api; mod uploaded; +mod curlapi; use state::SharedState; pub fn routes(state: SharedState) -> impl Filter + Clone { static_dir!("static") + .or(curlapi::get_routes(state.clone())) .or(pages::get_routes(state.clone())) .or(forms::get_routes(state.clone())) .or(api::get_routes(state.clone())) -- 2.40.1 From 164af11b8bdd65d4672f7f34798a517588c42d88 Mon Sep 17 00:00:00 2001 From: blek Date: Thu, 2 Nov 2023 19:01:21 +1000 Subject: [PATCH 2/7] fix compile warning --- filed/src/web/curlapi/upload.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/filed/src/web/curlapi/upload.rs b/filed/src/web/curlapi/upload.rs index a702bd9..a15aa07 100644 --- a/filed/src/web/curlapi/upload.rs +++ b/filed/src/web/curlapi/upload.rs @@ -47,7 +47,7 @@ pub async fn upload(form: FormData, ip: Option, state: SharedState) -> R } if let Some(pass) = state.config.files.upload_pass { - let mut pass_valid = false; + let pass_valid: bool; if let Some(upass) = formdata.instancepass { pass_valid = upass == pass; } else { @@ -85,7 +85,7 @@ pub async fn upload(form: FormData, ip: Option, state: SharedState) -> R "File uploaded successfully.\n", "It is available via this link:\n\n", - "{}/upload/{}" + "{}/upload/{}\n" ), state.env.instanceurl, urlencoding::encode( -- 2.40.1 From aca37bc52ba280e716854bb010d2d580eaa77392 Mon Sep 17 00:00:00 2001 From: blek Date: Thu, 2 Nov 2023 20:00:39 +1000 Subject: [PATCH 3/7] curl API help --- filed/config/filed.toml.example | 4 ++ filed/src/config/types.rs | 7 +++- filed/src/web/curlapi.rs | 4 +- filed/src/web/curlapi/help.rs | 71 +++++++++++++++++++++++++++++++++ filed/src/web/curlapi/upload.rs | 2 +- 5 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 filed/src/web/curlapi/help.rs diff --git a/filed/config/filed.toml.example b/filed/config/filed.toml.example index dd3d161..9ab705d 100644 --- a/filed/config/filed.toml.example +++ b/filed/config/filed.toml.example @@ -84,3 +84,7 @@ sudo_delete=false # Whether /api/upload is enabled # It is not recommended to enable it if API key auth is not enabled upload=false + +# Whether curlapi is enabled +# curl {url}/curlapi/help for more info +curlapi=true diff --git a/filed/src/config/types.rs b/filed/src/config/types.rs index 878230d..050a1d2 100644 --- a/filed/src/config/types.rs +++ b/filed/src/config/types.rs @@ -112,6 +112,10 @@ pub struct APISettings { /// Whether /api/upload is enabled #[serde(default)] pub upload: bool, + + /// Whether curlapi is enabled + #[serde(default)] + pub curlapi: bool } impl Default for APISettings { @@ -123,7 +127,8 @@ impl Default for APISettings { get_all_own_only: true, delete: false, sudo_delete: false, - upload: false + upload: false, + curlapi: true } } } diff --git a/filed/src/web/curlapi.rs b/filed/src/web/curlapi.rs index 35fa226..8538802 100644 --- a/filed/src/web/curlapi.rs +++ b/filed/src/web/curlapi.rs @@ -3,7 +3,9 @@ use warp::{Filter, reply::Reply, reject::Rejection}; use super::state::SharedState; mod upload; +mod help; pub fn get_routes(state: SharedState) -> impl Filter + Clone { - upload::get_routes(state) + upload::get_routes(state.clone()) + .or(help::get_routes(state)) } \ No newline at end of file diff --git a/filed/src/web/curlapi/help.rs b/filed/src/web/curlapi/help.rs new file mode 100644 index 0000000..0480b8b --- /dev/null +++ b/filed/src/web/curlapi/help.rs @@ -0,0 +1,71 @@ +use warp::{Filter, reply::Reply, reject::Rejection}; + +use crate::web::state::SharedState; + +pub async fn help(state: SharedState) -> Result { + + let brand = format!( + "{} \x1b[1m{}\x1b[0m {}", + state.config.brand.instance_emoji, + state.config.brand.instance_name, + { + if state.config.brand.instance_name != "blek! File" { + "\n\x1b[90mPowered by blek! File\x1b[0m" + } else { "" } + } + ); + + let mut warns: String = String::new(); + if ! state.config.api.curlapi { + warns += "\x1b[1;31mWarning: curl API is disabled on this instance.\nYou can use the web UI to upload files.\x1b[0m\n\n" + } + if ! state.config.files.allow_uploads { + warns += { + format!( + "\x1b[1;31mWarning: all uploads are disabled on this instance{}\x1b[0m", + { + if let Some(reason) = state.config.files.upload_disable_reason { + format!(" for this reason:\n\"{}\"", reason) + } else { ".".to_string() } + } + ).as_str() + } + } + + let instance = state.env.instanceurl; + let help = +format!( +"To upload a new file, you can POST it like this: + \x1b[32mcurl\x1b[0m \x1b[33m-X POST\x1b[0m \x1b[34m{instance}/curlapi/upload\x1b[0m \x1b[33m-F'file=@file.txt'\x1b[0m \x1b[33m-F'tos_consent=on'\x1b[0m +You can also add a password: + \x1b[32mcurl\x1b[0m \x1b[33m-X POST\x1b[0m \x1b[34m{instance}/curlapi/upload\x1b[0m \x1b[33m-F'file=@file.txt'\x1b[0m \x1b[33m-F'filename=uwu'\x1b[0m \x1b[33m-F'tos_consent=on'\x1b[0m \x1b[33m-F'named=on'\x1b[0m +The `named=on` switch is neede because this API is basically +the HTML used at the regular web UI form wrapped into this URL + +\x1b[1;32mIMPORTANT:\x1b[0m Read the terms of service \x1b[1mbefore\x1b[0m uploading the file! +The ToS can be found here: \x1b[34m{instance}/tos\x1b[0m . + +{warns} +" +); + + Ok(format!(" + \x1b[31m┓ ╻\x1b[0m \x1b[35m┏┓•┓ \x1b[0m + \x1b[32m┣┓┃\x1b[0m \x1b[95m┣ ┓┃┏┓\x1b[0m + \x1b[34m┗┛•\x1b[0m \x1b[35m┻ ┗┗┗━\x1b[0m + +{brand} + +{help} +").into()) +} + +pub fn get_routes(state: SharedState) -> impl Filter + Clone { + warp::any() + .and(warp::path!("curlapi" / "help")) + .and( + warp::any() + .map(move || state.clone()) + ) + .and_then(help) +} \ No newline at end of file diff --git a/filed/src/web/curlapi/upload.rs b/filed/src/web/curlapi/upload.rs index a15aa07..fa85a1f 100644 --- a/filed/src/web/curlapi/upload.rs +++ b/filed/src/web/curlapi/upload.rs @@ -111,7 +111,7 @@ pub async fn upload(form: FormData, ip: Option, state: SharedState) -> R } pub fn get_routes(state: SharedState) -> impl Filter + Clone { - warp::any() + warp::post() .and(warp::path!("curlapi" / "upload")) .and(warp::multipart::form()) .and(real_ip(vec![state.env.proxy_addr])) -- 2.40.1 From 79ef0634f90bc29b769f5303165c4e18fd82f854 Mon Sep 17 00:00:00 2001 From: blek Date: Thu, 2 Nov 2023 23:44:46 +1000 Subject: [PATCH 4/7] web UI for curlapi/help --- filed/src/web/curlapi/help.rs | 30 ++++++--- filed/src/web/pages.rs | 8 +++ filed/static/assets/code.css | 13 ++++ filed/templates/curlapi_help.html | 101 ++++++++++++++++++++++++++++++ 4 files changed, 144 insertions(+), 8 deletions(-) create mode 100644 filed/static/assets/code.css create mode 100644 filed/templates/curlapi_help.html diff --git a/filed/src/web/curlapi/help.rs b/filed/src/web/curlapi/help.rs index 0480b8b..3bc652b 100644 --- a/filed/src/web/curlapi/help.rs +++ b/filed/src/web/curlapi/help.rs @@ -1,8 +1,18 @@ -use warp::{Filter, reply::Reply, reject::Rejection}; +use askama::Template; +use warp::{Filter, reply::{Reply, html}, reject::Rejection}; -use crate::web::state::SharedState; +use crate::web::{state::SharedState, pages::CurlHelpPage, rejection::HttpReject}; -pub async fn help(state: SharedState) -> Result { +pub async fn help(state: SharedState, ua: String) -> Result, Rejection> { + + if ! ua.starts_with("curl/") { + let page = CurlHelpPage { conf: state.config.clone(), env: state.env.clone() }; + return Ok( + Box::new( + html(page.render().map_err(|x| HttpReject::AskamaError(x))?) + ) + ) + } let brand = format!( "{} \x1b[1m{}\x1b[0m {}", @@ -45,19 +55,20 @@ the HTML used at the regular web UI form wrapped into this URL \x1b[1;32mIMPORTANT:\x1b[0m Read the terms of service \x1b[1mbefore\x1b[0m uploading the file! The ToS can be found here: \x1b[34m{instance}/tos\x1b[0m . -{warns} -" +{warns}" ); - Ok(format!(" + Ok( + Box::new( +format!(" \x1b[31m┓ ╻\x1b[0m \x1b[35m┏┓•┓ \x1b[0m \x1b[32m┣┓┃\x1b[0m \x1b[95m┣ ┓┃┏┓\x1b[0m \x1b[34m┗┛•\x1b[0m \x1b[35m┻ ┗┗┗━\x1b[0m {brand} - {help} -").into()) +").to_string()) +) } pub fn get_routes(state: SharedState) -> impl Filter + Clone { @@ -67,5 +78,8 @@ pub fn get_routes(state: SharedState) -> impl Filter("user-agent") + ) .and_then(help) } \ No newline at end of file diff --git a/filed/src/web/pages.rs b/filed/src/web/pages.rs index a0faf97..6b634e8 100644 --- a/filed/src/web/pages.rs +++ b/filed/src/web/pages.rs @@ -88,6 +88,14 @@ pub struct ErrorPage { pub link_text: Option } +#[derive(Template)] +#[template( path = "curlapi_help.html" )] +#[allow(dead_code)] +pub struct CurlHelpPage { + pub env: Env, + pub conf: Config +} + pub async fn uploaded(query: HashMap, state: SharedState) -> Result, Rejection> { if ! query.contains_key("file") { diff --git a/filed/static/assets/code.css b/filed/static/assets/code.css new file mode 100644 index 0000000..10adf06 --- /dev/null +++ b/filed/static/assets/code.css @@ -0,0 +1,13 @@ +.code { + display: block; + padding: 1em; + border: 1px solid var(--header-sec-color); + border-radius: 12px; + font-family: monospace; +} +.code-inline { + display: inline; + font-family: monospace; + background: #00000010; + padding: 2px 4px; +} \ No newline at end of file diff --git a/filed/templates/curlapi_help.html b/filed/templates/curlapi_help.html new file mode 100644 index 0000000..5a92970 --- /dev/null +++ b/filed/templates/curlapi_help.html @@ -0,0 +1,101 @@ + +{% extends "base.html" %} + +{% block head %} + + + + +{% endblock %} + +{% block body %} + +
+

Curl API

+

+ blek! File has an API for uploading files via cURL. + To upload a file via cURL, follow these instructions: +

+

+ To upload a file, POST it like this: + + Copy! + +

+
+ curl -X POST {{env.instanceurl}}/curlapi/upload -F'file=@file.txt' -F'tos_consent=on' +
+

+ To add a password, do it like this: + + Copy! + +

+
+ curl -X POST {{env.instanceurl}}/curlapi/upload -F'file=@file.txt' -F'filename=uwu' -F'tos_consent=on' -F'named=on' +
+

+ Note that the + named=on + switch is required. + Its needed because the curl API is basically a wrapper of + this + HTML form. +

+ +
+

+ Important +

+

+ Read the + Terms of Service + before + uploading a file. +
+ You agree to them by adding the + tos_consent=on + switch. +

+
+ +
+

Web UI

+
+

+ Hey, it looks like you are viewing this page from a browser!
+ You can use the Web UI as well to upload a file! +

+

+ + Go to the web UI + +

+
+
+
+ +{% endblock %} + +{% block scripts %} + + + +{% endblock %} \ No newline at end of file -- 2.40.1 From 575f7d31e8ae7cecb13cc1b8606d94e982f0ed0d Mon Sep 17 00:00:00 2001 From: blek Date: Thu, 2 Nov 2023 23:49:26 +1000 Subject: [PATCH 5/7] match colors with the console UI --- filed/static/assets/alert.css | 3 +++ filed/templates/curlapi_help.html | 15 ++++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/filed/static/assets/alert.css b/filed/static/assets/alert.css index 55bc69d..2444755 100644 --- a/filed/static/assets/alert.css +++ b/filed/static/assets/alert.css @@ -24,4 +24,7 @@ .alert.blue .alert-title { background: #203050; +} +.alert.green .alert-title { + background: #205030; } \ No newline at end of file diff --git a/filed/templates/curlapi_help.html b/filed/templates/curlapi_help.html index 5a92970..e982594 100644 --- a/filed/templates/curlapi_help.html +++ b/filed/templates/curlapi_help.html @@ -25,7 +25,11 @@

- curl -X POST {{env.instanceurl}}/curlapi/upload -F'file=@file.txt' -F'tos_consent=on' + curl + -X POST + {{env.instanceurl}}/curlapi/upload + -F'file=@file.txt' + -F'tos_consent=on'

To add a password, do it like this: @@ -34,7 +38,12 @@

- curl -X POST {{env.instanceurl}}/curlapi/upload -F'file=@file.txt' -F'filename=uwu' -F'tos_consent=on' -F'named=on' + curl + -X POST + {{env.instanceurl}}/curlapi/upload + -F'file=@file.txt' + -F'tos_consent=on' + -F'named=on'

Note that the @@ -45,7 +54,7 @@ HTML form.

-
+

Important

-- 2.40.1 From 57ea39cedaf50b8c688a9ec200d78e5a0a44547f Mon Sep 17 00:00:00 2001 From: blek Date: Fri, 3 Nov 2023 00:02:40 +1000 Subject: [PATCH 6/7] make code blocks always in one line --- filed/static/assets/code.css | 14 ++++++++++++-- filed/templates/curlapi_help.html | 26 +++++++++++++++----------- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/filed/static/assets/code.css b/filed/static/assets/code.css index 10adf06..6ccfdfc 100644 --- a/filed/static/assets/code.css +++ b/filed/static/assets/code.css @@ -3,11 +3,21 @@ padding: 1em; border: 1px solid var(--header-sec-color); border-radius: 12px; - font-family: monospace; + overflow-x: auto; +} +.code .inner { + width: max-content; + display: block; } .code-inline { display: inline; - font-family: monospace; background: #00000010; + border: 1px solid #c2c4c210; + border-radius: 4px; padding: 2px 4px; +} + +.code, .code-inline { + font-family: monospace; + line-height: 1.5em; } \ No newline at end of file diff --git a/filed/templates/curlapi_help.html b/filed/templates/curlapi_help.html index e982594..d8ef7cf 100644 --- a/filed/templates/curlapi_help.html +++ b/filed/templates/curlapi_help.html @@ -25,11 +25,13 @@

- curl - -X POST - {{env.instanceurl}}/curlapi/upload - -F'file=@file.txt' - -F'tos_consent=on' + + curl + -X POST + {{env.instanceurl}}/curlapi/upload + -F'file=@file.txt' + -F'tos_consent=on' +

To add a password, do it like this: @@ -38,12 +40,14 @@

- curl - -X POST - {{env.instanceurl}}/curlapi/upload - -F'file=@file.txt' - -F'tos_consent=on' - -F'named=on' + + curl + -X POST + {{env.instanceurl}}/curlapi/upload + -F'file=@file.txt' + -F'tos_consent=on' + -F'named=on' +

Note that the -- 2.40.1 From 72abd4d5bd2ca8ddce0b33779f93950e1350796b Mon Sep 17 00:00:00 2001 From: blek Date: Fri, 3 Nov 2023 00:08:45 +1000 Subject: [PATCH 7/7] add a ToS switch --- filed/src/web/curlapi/upload.rs | 16 ++++++++++++++++ filed/src/web/forms.rs | 4 ++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/filed/src/web/curlapi/upload.rs b/filed/src/web/curlapi/upload.rs index fa85a1f..8658cf5 100644 --- a/filed/src/web/curlapi/upload.rs +++ b/filed/src/web/curlapi/upload.rs @@ -24,6 +24,22 @@ pub async fn upload(form: FormData, ip: Option, state: SharedState) -> R .await .map_err(|x| HttpReject::WarpError(x))?; + if let Some(consent) = params.get("tos_consent") { + if consent.data != "on".bytes().collect::>() { + return Ok( + Box::new( + format!("You need to agree to the ToS to upload a file.\nSee {}/curlapi/help for details\n\nTo agree to the ToS, add a -F'tos_consent=on'\n", state.env.instanceurl) + ) + ) + } + } else { + return Ok( + Box::new( + format!("You need to agree to the ToS to upload a file.\nSee {}/curlapi/help for details\n\nTo agree to the ToS, add a -F'tos_consent=on'\n", state.env.instanceurl) + ) + ) + } + let formdata = UploadFormData::from_formdata(params, true); if let Some(formdata) = formdata { diff --git a/filed/src/web/forms.rs b/filed/src/web/forms.rs index a8adc81..cbfcecf 100644 --- a/filed/src/web/forms.rs +++ b/filed/src/web/forms.rs @@ -18,8 +18,8 @@ use super::{state::SharedState, pages::{UploadSuccessPage, ErrorPage}, rejection #[derive(Debug, Serialize, Clone)] pub struct FormElement { - data: Vec, - mime: String + pub data: Vec, + pub mime: String } impl FormElement { -- 2.40.1