- Unit tests added too.
- No undeletes working yet.
Err(_e) => return Err(APIError::err("couldnt_update_comment").into()),
};
- updated_comment.send_update(&user, &conn)?;
-
if let Some(deleted) = data.deleted.to_owned() {
if deleted {
updated_comment.send_delete(&user, &conn)?;
} else {
// TODO: undo delete
}
+ } else {
+ updated_comment.send_update(&user, &conn)?;
}
let mut recipient_ids = Vec::new();
let conn = pool.get()?;
// Check for a site ban
- if UserView::read(&conn, user_id)?.banned {
+ let user = User_::read(&conn, user_id)?;
+ if user.banned {
return Err(APIError::err("site_ban").into());
}
if let Some(deleted) = data.deleted.to_owned() {
if deleted {
- updated_community.send_delete(&conn)?;
+ updated_community.send_delete(&user, &conn)?;
} else {
// TODO: undo delete
}
title: read_community.title,
description: read_community.description,
category_id: read_community.category_id,
- creator_id: data.user_id,
+ creator_id: data.user_id, // This makes the new user the community creator
removed: None,
deleted: None,
nsfw: read_community.nsfw,
ModStickyPost::create(&conn, &form)?;
}
- updated_post.send_update(&user, &conn)?;
-
if let Some(deleted) = data.deleted.to_owned() {
if deleted {
updated_post.send_delete(&user, &conn)?;
} else {
// TODO: undo delete
}
+ } else {
+ updated_post.send_update(&user, &conn)?;
}
let post_view = PostView::read(&conn, data.edit_id, Some(user_id))?;
Ok(comment)
}
-}
-impl ToTombstone for Comment {
fn to_tombstone(&self) -> Result<Tombstone, Error> {
- create_tombstone(self.deleted, &self.ap_id, self.published, self.updated, NoteType.to_string())
+ create_tombstone(
+ self.deleted,
+ &self.ap_id,
+ self.updated,
+ NoteType.to_string(),
+ )
}
}
Ok(())
}
- // TODO: this code is literally copied from post.rs
- fn send_delete(&self, actor: &User_, conn: &PgConnection) -> Result<(), Error> {
+ fn send_delete(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error> {
+ let note = self.to_apub(&conn)?;
+ let post = Post::read(&conn, self.post_id)?;
+ let community = Community::read(&conn, post.community_id)?;
+ let id = format!("{}/delete/{}", self.ap_id, uuid::Uuid::new_v4());
let mut delete = Delete::default();
+
+ populate_object_props(
+ &mut delete.object_props,
+ &community.get_followers_url(),
+ &id,
+ )?;
+
delete
.delete_props
- .set_actor_xsd_any_uri(actor.actor_id.to_owned())?
- .set_object_base_box(BaseBox::from_concrete(self.to_tombstone()?)?)?;
+ .set_actor_xsd_any_uri(creator.actor_id.to_owned())?
+ .set_object_base_box(note)?;
// Insert the sent activity into the activity table
let activity_form = activity::ActivityForm {
};
activity::Activity::create(&conn, &activity_form)?;
- let post = Post::read(conn, self.post_id)?;
- let community = Community::read(conn, post.community_id)?;
send_activity(
&delete,
- &actor.private_key.to_owned().unwrap(),
- &actor.actor_id,
+ &creator.private_key.as_ref().unwrap(),
+ &creator.actor_id,
community.get_follower_inboxes(&conn)?,
)?;
Ok(())
Ok(group.extend(actor_props).extend(self.get_public_key_ext()))
}
-}
-impl ToTombstone for Community {
fn to_tombstone(&self) -> Result<Tombstone, Error> {
- create_tombstone(self.deleted, &self.actor_id, self.published, self.updated, GroupType.to_string())
+ create_tombstone(
+ self.deleted,
+ &self.actor_id,
+ self.updated,
+ GroupType.to_string(),
+ )
}
}
Ok(())
}
- fn send_delete(&self, conn: &PgConnection) -> Result<(), Error> {
+ fn send_delete(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error> {
+ let group = self.to_apub(conn)?;
+ let id = format!("{}/delete/{}", self.actor_id, uuid::Uuid::new_v4());
+
let mut delete = Delete::default();
+ populate_object_props(&mut delete.object_props, &self.get_followers_url(), &id)?;
+
delete
.delete_props
- .set_actor_xsd_any_uri(self.actor_id.to_owned())?
- .set_object_base_box(BaseBox::from_concrete(self.to_tombstone()?)?)?;
+ .set_actor_xsd_any_uri(creator.actor_id.to_owned())?
+ .set_object_base_box(group)?;
// Insert the sent activity into the activity table
let activity_form = activity::ActivityForm {
};
activity::Activity::create(&conn, &activity_form)?;
+ // Note: For an accept, since it was automatic, no one pushed a button,
+ // the community was the actor.
+ // But for delete, the creator is the actor, and does the signing
send_activity(
&delete,
- &self.private_key.to_owned().unwrap(),
- &self.actor_id,
+ &creator.private_key.as_ref().unwrap(),
+ &creator.actor_id,
self.get_follower_inboxes(&conn)?,
)?;
Ok(())
pub mod user;
pub mod user_inbox;
+use crate::api::community::CommunityResponse;
+use crate::websocket::server::SendCommunityRoomMessage;
+use activitystreams::object::kind::{NoteType, PageType};
use activitystreams::{
activity::{Accept, Create, Delete, Dislike, Follow, Like, Update},
actor::{properties::ApActorProperties, Actor, Group, Person},
object::{properties::ObjectProperties, Note, Page, Tombstone},
public, BaseBox,
};
-use activitystreams::object::kind::{NoteType, PageType};
-use crate::api::community::CommunityResponse;
-use crate::websocket::server::SendCommunityRoomMessage;
use actix_web::body::Body;
use actix_web::web::Path;
use actix_web::{web, HttpRequest, HttpResponse, Result};
pub trait ToApub {
type Response;
fn to_apub(&self, conn: &PgConnection) -> Result<Self::Response, Error>;
+ fn to_tombstone(&self) -> Result<Tombstone, Error>;
}
fn create_tombstone(
deleted: bool,
object_id: &str,
- published: NaiveDateTime,
updated: Option<NaiveDateTime>,
former_type: String,
) -> Result<Tombstone, Error> {
if deleted {
- let mut tombstone = Tombstone::default();
- // TODO: might want to include deleted time as well
- tombstone
- .object_props
- .set_id(object_id)?
- .set_published(convert_datetime(published))?;
if let Some(updated) = updated {
+ let mut tombstone = Tombstone::default();
+ tombstone.object_props.set_id(object_id)?;
tombstone
- .object_props
- .set_updated(convert_datetime(updated))?;
+ .tombstone_props
+ .set_former_type_xsd_string(former_type)?
+ .set_deleted(convert_datetime(updated))?;
+ Ok(tombstone)
+ } else {
+ Err(format_err!(
+ "Cant convert to tombstone because updated time was None."
+ ))
}
- tombstone.tombstone_props.set_former_type_xsd_string(former_type)?;
- Ok(tombstone)
} else {
Err(format_err!(
"Cant convert object to tombstone if it wasnt deleted"
}
}
-pub trait ToTombstone {
- fn to_tombstone(&self) -> Result<Tombstone, Error>;
-}
-
pub trait FromApub {
type ApubType;
fn from_apub(apub: &Self::ApubType, conn: &PgConnection) -> Result<Self, Error>
pub trait ApubObjectType {
fn send_create(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error>;
fn send_update(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error>;
- fn send_delete(&self, actor: &User_, conn: &PgConnection) -> Result<(), Error>;
+ fn send_delete(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error>;
}
pub trait ApubLikeableType {
Err(format_err!("Accept not implemented."))
}
- fn send_delete(&self, conn: &PgConnection) -> Result<(), Error>;
+ fn send_delete(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error>;
// TODO default because there is no user following yet.
#[allow(unused_variables)]
Ok(page)
}
-}
-impl ToTombstone for Post {
fn to_tombstone(&self) -> Result<Tombstone, Error> {
- create_tombstone(self.deleted, &self.ap_id, self.published, self.updated, PageType.to_string())
+ create_tombstone(
+ self.deleted,
+ &self.ap_id,
+ self.updated,
+ PageType.to_string(),
+ )
}
}
Ok(())
}
- fn send_delete(&self, actor: &User_, conn: &PgConnection) -> Result<(), Error> {
+ fn send_delete(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error> {
+ let page = self.to_apub(conn)?;
+ let community = Community::read(conn, self.community_id)?;
+ let id = format!("{}/delete/{}", self.ap_id, uuid::Uuid::new_v4());
let mut delete = Delete::default();
+
+ populate_object_props(
+ &mut delete.object_props,
+ &community.get_followers_url(),
+ &id,
+ )?;
+
delete
.delete_props
- .set_actor_xsd_any_uri(actor.actor_id.to_owned())?
- .set_object_base_box(BaseBox::from_concrete(self.to_tombstone()?)?)?;
+ .set_actor_xsd_any_uri(creator.actor_id.to_owned())?
+ .set_object_base_box(page)?;
// Insert the sent activity into the activity table
let activity_form = activity::ActivityForm {
let community = Community::read(conn, self.community_id)?;
send_activity(
&delete,
- &actor.private_key.to_owned().unwrap(),
- &actor.actor_id,
+ &creator.private_key.as_ref().unwrap(),
+ &creator.actor_id,
community.get_follower_inboxes(&conn)?,
)?;
Ok(())
(SharedAcceptedObjects::Dislike(d), Some("Page")) => {
receive_dislike_post(&d, &request, &conn, chat_server)
}
+ (SharedAcceptedObjects::Delete(d), Some("Page")) => {
+ receive_delete_post(&d, &request, &conn, chat_server)
+ }
(SharedAcceptedObjects::Create(c), Some("Note")) => {
receive_create_comment(&c, &request, &conn, chat_server)
}
(SharedAcceptedObjects::Dislike(d), Some("Note")) => {
receive_dislike_comment(&d, &request, &conn, chat_server)
}
- (SharedAcceptedObjects::Delete(d), Some("Tombstone")) => {
- receive_delete(&d, &request, &conn, chat_server)
+ (SharedAcceptedObjects::Delete(d), Some("Note")) => {
+ receive_delete_comment(&d, &request, &conn, chat_server)
+ }
+ (SharedAcceptedObjects::Delete(d), Some("Group")) => {
+ receive_delete_community(&d, &request, &conn, chat_server)
}
_ => Err(format_err!("Unknown incoming activity type.")),
}
Ok(HttpResponse::Ok().finish())
}
-fn receive_delete(
+fn receive_delete_community(
delete: &Delete,
request: &HttpRequest,
conn: &PgConnection,
chat_server: ChatServerParam,
) -> Result<HttpResponse, Error> {
- let tombstone = delete
+ let user_uri = delete
+ .delete_props
+ .get_actor_xsd_any_uri()
+ .unwrap()
+ .to_string();
+
+ let group = delete
.delete_props
.get_object_base_box()
.to_owned()
.unwrap()
.to_owned()
- .into_concrete::<Tombstone>()?;
- let former_type = tombstone.tombstone_props.get_former_type_xsd_string().unwrap().to_string();
- // TODO: handle these
- match former_type.as_str() {
- "Group" => {},
- d => return Err(format_err!("Delete type {} not supported", d)),
- }
- let community_apub_id = tombstone.object_props.get_id().unwrap().to_string();
+ .into_concrete::<GroupExt>()?;
- let community = Community::read_from_actor_id(conn, &community_apub_id)?;
- verify(request, &community.public_key.clone().unwrap())?;
+ let user = get_or_fetch_and_upsert_remote_user(&user_uri, &conn)?;
+ verify(request, &user.public_key.unwrap())?;
// Insert the received activity into the activity table
let activity_form = activity::ActivityForm {
- user_id: community.creator_id,
+ user_id: user.id,
data: serde_json::to_value(&delete)?,
local: false,
updated: None,
};
activity::Activity::create(&conn, &activity_form)?;
+ let community_actor_id = CommunityForm::from_apub(&group, &conn)?.actor_id;
+ let community = Community::read_from_actor_id(conn, &community_actor_id)?;
+
let community_form = CommunityForm {
- name: "".to_string(),
- title: "".to_string(),
- description: None,
+ name: community.name.to_owned(),
+ title: community.title.to_owned(),
+ description: community.description.to_owned(),
category_id: community.category_id, // Note: need to keep this due to foreign key constraint
creator_id: community.creator_id, // Note: need to keep this due to foreign key constraint
removed: None,
published: None,
- updated: None,
+ updated: Some(naive_now()),
deleted: Some(true),
- nsfw: false,
+ nsfw: community.nsfw,
actor_id: community.actor_id,
- local: false,
- private_key: None,
+ local: community.local,
+ private_key: community.private_key,
public_key: community.public_key,
- last_refreshed_at: Some(community.last_refreshed_at),
+ last_refreshed_at: None,
};
- Community::update(conn, community.id, &community_form)?;
+ Community::update(&conn, community.id, &community_form)?;
let res = CommunityResponse {
community: CommunityView::read(&conn, community.id, None)?,
Ok(HttpResponse::Ok().finish())
}
+
+fn receive_delete_post(
+ delete: &Delete,
+ request: &HttpRequest,
+ conn: &PgConnection,
+ chat_server: ChatServerParam,
+) -> Result<HttpResponse, Error> {
+ let user_uri = delete
+ .delete_props
+ .get_actor_xsd_any_uri()
+ .unwrap()
+ .to_string();
+
+ let page = delete
+ .delete_props
+ .get_object_base_box()
+ .to_owned()
+ .unwrap()
+ .to_owned()
+ .into_concrete::<Page>()?;
+
+ let user = get_or_fetch_and_upsert_remote_user(&user_uri, &conn)?;
+ verify(request, &user.public_key.unwrap())?;
+
+ // Insert the received activity into the activity table
+ let activity_form = activity::ActivityForm {
+ user_id: user.id,
+ data: serde_json::to_value(&delete)?,
+ local: false,
+ updated: None,
+ };
+ activity::Activity::create(&conn, &activity_form)?;
+
+ let post_ap_id = PostForm::from_apub(&page, conn)?.ap_id;
+ let post = Post::read_from_apub_id(conn, &post_ap_id)?;
+
+ let post_form = PostForm {
+ name: post.name.to_owned(),
+ url: post.url.to_owned(),
+ body: post.body.to_owned(),
+ creator_id: post.creator_id.to_owned(),
+ community_id: post.community_id,
+ removed: None,
+ deleted: Some(true),
+ nsfw: post.nsfw,
+ locked: None,
+ stickied: None,
+ updated: Some(naive_now()),
+ embed_title: post.embed_title,
+ embed_description: post.embed_description,
+ embed_html: post.embed_html,
+ thumbnail_url: post.thumbnail_url,
+ ap_id: post.ap_id,
+ local: post.local,
+ published: None,
+ };
+ Post::update(&conn, post.id, &post_form)?;
+
+ // Refetch the view
+ let post_view = PostView::read(&conn, post.id, None)?;
+
+ let res = PostResponse { post: post_view };
+
+ chat_server.do_send(SendPost {
+ op: UserOperation::EditPost,
+ post: res,
+ my_id: None,
+ });
+
+ Ok(HttpResponse::Ok().finish())
+}
+
+fn receive_delete_comment(
+ delete: &Delete,
+ request: &HttpRequest,
+ conn: &PgConnection,
+ chat_server: ChatServerParam,
+) -> Result<HttpResponse, Error> {
+ let user_uri = delete
+ .delete_props
+ .get_actor_xsd_any_uri()
+ .unwrap()
+ .to_string();
+
+ let note = delete
+ .delete_props
+ .get_object_base_box()
+ .to_owned()
+ .unwrap()
+ .to_owned()
+ .into_concrete::<Note>()?;
+
+ let user = get_or_fetch_and_upsert_remote_user(&user_uri, &conn)?;
+ verify(request, &user.public_key.unwrap())?;
+
+ // Insert the received activity into the activity table
+ let activity_form = activity::ActivityForm {
+ user_id: user.id,
+ data: serde_json::to_value(&delete)?,
+ local: false,
+ updated: None,
+ };
+ activity::Activity::create(&conn, &activity_form)?;
+
+ let comment_ap_id = CommentForm::from_apub(¬e, &conn)?.ap_id;
+ let comment = Comment::read_from_apub_id(conn, &comment_ap_id)?;
+ let comment_form = CommentForm {
+ content: comment.content.to_owned(),
+ parent_id: comment.parent_id,
+ post_id: comment.post_id,
+ creator_id: comment.creator_id,
+ removed: None,
+ deleted: Some(true),
+ read: None,
+ published: None,
+ updated: Some(naive_now()),
+ ap_id: comment.ap_id,
+ local: comment.local,
+ };
+ Comment::update(&conn, comment.id, &comment_form)?;
+
+ // Refetch the view
+ let comment_view = CommentView::read(&conn, comment.id, None)?;
+
+ // TODO get those recipient actor ids from somewhere
+ let recipient_ids = vec![];
+ let res = CommentResponse {
+ comment: comment_view,
+ recipient_ids,
+ };
+
+ chat_server.do_send(SendComment {
+ op: UserOperation::EditComment,
+ comment: res,
+ my_id: None,
+ });
+
+ Ok(HttpResponse::Ok().finish())
+}
Ok(person.extend(actor_props).extend(self.get_public_key_ext()))
}
+ fn to_tombstone(&self) -> Result<Tombstone, Error> {
+ unimplemented!()
+ }
}
impl ActorType for User_ {
Ok(())
}
- fn send_delete(&self, _conn: &PgConnection) -> Result<(), Error> {
+ fn send_delete(&self, _creator: &User_, _conn: &PgConnection) -> Result<(), Error> {
unimplemented!()
}
}
pub last_refreshed_at: chrono::NaiveDateTime,
}
+// TODO add better delete, remove, lock actions here.
#[derive(Insertable, AsChangeset, Clone, Serialize, Deserialize, Debug)]
#[table_name = "community"]
pub struct CommunityForm {
GetPostResponse,
CommentForm,
CommentResponse,
+ CommunityForm,
+ GetCommunityForm,
+ GetCommunityResponse,
} from '../interfaces';
let lemmyAlphaUrl = 'http://localhost:8540';
expect(getPostRes.comments[1].creator_local).toBe(false);
});
});
+
+ describe('delete community', () => {
+ test('/u/lemmy_beta deletes a federated comment, post, and community, lemmy_alpha sees its deleted.', async () => {
+ // Create a test community
+ let communityName = 'test_community';
+ let communityForm: CommunityForm = {
+ name: communityName,
+ title: communityName,
+ category_id: 1,
+ nsfw: false,
+ auth: lemmyBetaAuth,
+ };
+
+ let createCommunityRes: CommunityResponse = await fetch(
+ `${lemmyBetaApiUrl}/community`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: wrapper(communityForm),
+ }
+ ).then(d => d.json());
+
+ expect(createCommunityRes.community.name).toBe(communityName);
+
+ // Cache it on lemmy_alpha
+ let searchUrl = `${lemmyAlphaApiUrl}/search?q=http://lemmy_beta:8550/c/${communityName}&type_=All&sort=TopAll`;
+ let searchResponse: SearchResponse = await fetch(searchUrl, {
+ method: 'GET',
+ }).then(d => d.json());
+
+ let communityOnAlphaId = searchResponse.communities[0].id;
+
+ // Follow it
+ let followForm: FollowCommunityForm = {
+ community_id: communityOnAlphaId,
+ follow: true,
+ auth: lemmyAlphaAuth,
+ };
+
+ let followRes: CommunityResponse = await fetch(
+ `${lemmyAlphaApiUrl}/community/follow`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: wrapper(followForm),
+ }
+ ).then(d => d.json());
+
+ // Make sure the follow response went through
+ expect(followRes.community.local).toBe(false);
+ expect(followRes.community.name).toBe(communityName);
+
+ // Lemmy beta creates a test post
+ let postName = 'A jest test post with delete';
+ let createPostForm: PostForm = {
+ name: postName,
+ auth: lemmyBetaAuth,
+ community_id: createCommunityRes.community.id,
+ creator_id: 2,
+ nsfw: false,
+ };
+
+ let createPostRes: PostResponse = await fetch(`${lemmyBetaApiUrl}/post`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: wrapper(createPostForm),
+ }).then(d => d.json());
+ expect(createPostRes.post.name).toBe(postName);
+
+ // Lemmy beta creates a test comment
+ let commentContent = 'A jest test federated comment with delete';
+ let createCommentForm: CommentForm = {
+ content: commentContent,
+ post_id: createPostRes.post.id,
+ auth: lemmyBetaAuth,
+ };
+
+ let createCommentRes: CommentResponse = await fetch(
+ `${lemmyBetaApiUrl}/comment`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: wrapper(createCommentForm),
+ }
+ ).then(d => d.json());
+
+ expect(createCommentRes.comment.content).toBe(commentContent);
+
+ // lemmy_beta deletes the comment
+ let deleteCommentForm: CommentForm = {
+ content: commentContent,
+ edit_id: createCommentRes.comment.id,
+ post_id: createPostRes.post.id,
+ deleted: true,
+ auth: lemmyBetaAuth,
+ creator_id: createCommentRes.comment.creator_id,
+ };
+
+ let deleteCommentRes: CommentResponse = await fetch(
+ `${lemmyBetaApiUrl}/comment`,
+ {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: wrapper(deleteCommentForm),
+ }
+ ).then(d => d.json());
+ expect(deleteCommentRes.comment.deleted).toBe(true);
+
+ // lemmy_alpha sees that the comment is deleted
+ let getPostUrl = `${lemmyAlphaApiUrl}/post?id=3`;
+ let getPostRes: GetPostResponse = await fetch(getPostUrl, {
+ method: 'GET',
+ }).then(d => d.json());
+ expect(getPostRes.comments[0].deleted).toBe(true);
+
+ // lemmy_beta deletes the post
+ let deletePostForm: PostForm = {
+ name: postName,
+ edit_id: createPostRes.post.id,
+ auth: lemmyBetaAuth,
+ community_id: createPostRes.post.community_id,
+ creator_id: createPostRes.post.creator_id,
+ nsfw: false,
+ deleted: true,
+ };
+
+ let deletePostRes: PostResponse = await fetch(`${lemmyBetaApiUrl}/post`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: wrapper(deletePostForm),
+ }).then(d => d.json());
+ expect(deletePostRes.post.deleted).toBe(true);
+
+ // Make sure lemmy_alpha sees the post is deleted
+ let getPostResAgain: GetPostResponse = await fetch(getPostUrl, {
+ method: 'GET',
+ }).then(d => d.json());
+ expect(getPostResAgain.post.deleted).toBe(true);
+
+ // lemmy_beta deletes the community
+ let deleteCommunityForm: CommunityForm = {
+ name: communityName,
+ title: communityName,
+ category_id: 1,
+ edit_id: createCommunityRes.community.id,
+ nsfw: false,
+ deleted: true,
+ auth: lemmyBetaAuth,
+ };
+
+ let deleteResponse: CommunityResponse = await fetch(
+ `${lemmyBetaApiUrl}/community`,
+ {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: wrapper(deleteCommunityForm),
+ }
+ ).then(d => d.json());
+
+ // Make sure the delete went through
+ expect(deleteResponse.community.deleted).toBe(true);
+
+ // Re-get it from alpha, make sure its deleted there too
+ let getCommunityUrl = `${lemmyAlphaApiUrl}/community?id=${communityOnAlphaId}&auth=${lemmyAlphaAuth}`;
+ let getCommunityRes: GetCommunityResponse = await fetch(getCommunityUrl, {
+ method: 'GET',
+ }).then(d => d.json());
+
+ expect(getCommunityRes.community.deleted).toBe(true);
+ });
+ });
});
function wrapper(form: any): string {