From 1e83aa8c726a3bd4529044e578a24e7bfff5eb9d Mon Sep 17 00:00:00 2001 From: Dessalines Date: Fri, 23 Aug 2019 19:40:41 -0700 Subject: [PATCH] Adding Community and Site transfer - Fixes #139 --- README.md | 4 +- server/src/api/community.rs | 117 +++++++++++++++++++++++++++- server/src/api/mod.rs | 2 +- server/src/api/post.rs | 6 +- server/src/api/site.rs | 80 ++++++++++++++++++- server/src/api/user.rs | 6 +- server/src/db/comment.rs | 7 +- server/src/db/community.rs | 10 +++ server/src/websocket/server.rs | 10 +++ ui/src/components/comment-node.tsx | 45 ++++++++++- ui/src/components/post.tsx | 13 +++- ui/src/interfaces.ts | 13 +++- ui/src/services/WebSocketService.ts | 12 ++- ui/src/translations/en.ts | 2 + 14 files changed, 314 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 915df5e87..7830991f8 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ Front Page|Post - Clean, mobile-friendly interface. - i18n / internationalization support. - NSFW post / community support. +- Cross-posting support. +- Can transfer site and communities to others. - High performance. - Server is written in rust. - Front end is `~80kB` gzipped. @@ -161,7 +163,7 @@ Lemmy is free, open-source software, meaning no advertising, monetizing, or vent If you'd like to add translations, take a look a look at the [english translation file](ui/src/translations/en.ts). -- Languages supported: English (`en`), Chinese (`zh`), French (`fr`), Swedish (`sv`), German (`de`), Russian (`ru`). +- Languages supported: English (`en`), Chinese (`zh`), French (`fr`), Spanish (`es`), Swedish (`sv`), German (`de`), Russian (`ru`). ## Credits diff --git a/server/src/api/community.rs b/server/src/api/community.rs index 740584888..37bc20dbf 100644 --- a/server/src/api/community.rs +++ b/server/src/api/community.rs @@ -111,6 +111,13 @@ pub struct GetFollowedCommunitiesResponse { communities: Vec } +#[derive(Serialize, Deserialize)] +pub struct TransferCommunity { + community_id: i32, + user_id: i32, + auth: String +} + impl Perform for Oper { fn perform(&self) -> Result { let data: &GetCommunity = &self.data; @@ -148,7 +155,11 @@ impl Perform for Oper { } }; - let admins = UserView::admins(&conn)?; + let site_creator_id = Site::read(&conn, 1)?.creator_id; + let mut admins = UserView::admins(&conn)?; + let creator_index = admins.iter().position(|r| r.id == site_creator_id).unwrap(); + let creator_user = admins.remove(creator_index); + admins.insert(0, creator_user); // Return the jwt Ok( @@ -577,3 +588,107 @@ impl Perform for Oper { ) } } + +impl Perform for Oper { + fn perform(&self) -> Result { + let data: &TransferCommunity = &self.data; + let conn = establish_connection(); + + let claims = match Claims::decode(&data.auth) { + Ok(claims) => claims.claims, + Err(_e) => { + return Err(APIError::err(&self.op, "not_logged_in"))? + } + }; + + let user_id = claims.id; + + let read_community = Community::read(&conn, data.community_id)?; + + // Make sure user is the creator + if read_community.creator_id != user_id { + return Err(APIError::err(&self.op, "not_an_admin"))? + } + + let community_form = CommunityForm { + name: read_community.name, + title: read_community.title, + description: read_community.description, + category_id: read_community.category_id, + creator_id: data.user_id, + removed: None, + deleted: None, + nsfw: read_community.nsfw, + updated: Some(naive_now()) + }; + + let _updated_community = match Community::update(&conn, data.community_id, &community_form) { + Ok(community) => community, + Err(_e) => { + return Err(APIError::err(&self.op, "couldnt_update_community"))? + } + }; + + // You also have to re-do the community_moderator table, reordering it. + let mut community_mods = CommunityModeratorView::for_community(&conn, data.community_id)?; + let creator_index = community_mods.iter().position(|r| r.user_id == data.user_id).unwrap(); + let creator_user = community_mods.remove(creator_index); + community_mods.insert(0, creator_user); + + CommunityModerator::delete_for_community(&conn, data.community_id)?; + + for cmod in &community_mods { + + let community_moderator_form = CommunityModeratorForm { + community_id: cmod.community_id, + user_id: cmod.user_id + }; + + let _inserted_community_moderator = match CommunityModerator::join(&conn, &community_moderator_form) { + Ok(user) => user, + Err(_e) => { + return Err(APIError::err(&self.op, "community_moderator_already_exists"))? + } + }; + } + + // Mod tables + let form = ModAddCommunityForm { + mod_user_id: user_id, + other_user_id: data.user_id, + community_id: data.community_id, + removed: Some(false), + }; + ModAddCommunity::create(&conn, &form)?; + + let community_view = match CommunityView::read(&conn, data.community_id, Some(user_id)) { + Ok(community) => community, + Err(_e) => { + return Err(APIError::err(&self.op, "couldnt_find_community"))? + } + }; + + let moderators = match CommunityModeratorView::for_community(&conn, data.community_id) { + Ok(moderators) => moderators, + Err(_e) => { + return Err(APIError::err(&self.op, "couldnt_find_community"))? + } + }; + + let site_creator_id = Site::read(&conn, 1)?.creator_id; + let mut admins = UserView::admins(&conn)?; + let creator_index = admins.iter().position(|r| r.id == site_creator_id).unwrap(); + let creator_user = admins.remove(creator_index); + admins.insert(0, creator_user); + + // Return the jwt + Ok( + GetCommunityResponse { + op: self.op.to_string(), + community: community_view, + moderators: moderators, + admins: admins, + } + ) + } +} diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs index 3a4a08658..ac11d30cc 100644 --- a/server/src/api/mod.rs +++ b/server/src/api/mod.rs @@ -22,7 +22,7 @@ pub mod site; #[derive(EnumString,ToString,Debug)] pub enum UserOperation { - Login, Register, CreateCommunity, CreatePost, ListCommunities, ListCategories, GetPost, GetCommunity, CreateComment, EditComment, SaveComment, CreateCommentLike, GetPosts, CreatePostLike, EditPost, SavePost, EditCommunity, FollowCommunity, GetFollowedCommunities, GetUserDetails, GetReplies, GetModlog, BanFromCommunity, AddModToCommunity, CreateSite, EditSite, GetSite, AddAdmin, BanUser, Search, MarkAllAsRead, SaveUserSettings + Login, Register, CreateCommunity, CreatePost, ListCommunities, ListCategories, GetPost, GetCommunity, CreateComment, EditComment, SaveComment, CreateCommentLike, GetPosts, CreatePostLike, EditPost, SavePost, EditCommunity, FollowCommunity, GetFollowedCommunities, GetUserDetails, GetReplies, GetModlog, BanFromCommunity, AddModToCommunity, CreateSite, EditSite, GetSite, AddAdmin, BanUser, Search, MarkAllAsRead, SaveUserSettings, TransferCommunity, TransferSite } #[derive(Fail, Debug)] diff --git a/server/src/api/post.rs b/server/src/api/post.rs index c5985f467..8fc24ac18 100644 --- a/server/src/api/post.rs +++ b/server/src/api/post.rs @@ -200,7 +200,11 @@ impl Perform for Oper { let moderators = CommunityModeratorView::for_community(&conn, post_view.community_id)?; - let admins = UserView::admins(&conn)?; + let site_creator_id = Site::read(&conn, 1)?.creator_id; + let mut admins = UserView::admins(&conn)?; + let creator_index = admins.iter().position(|r| r.id == site_creator_id).unwrap(); + let creator_user = admins.remove(creator_index); + admins.insert(0, creator_user); // Return the jwt Ok( diff --git a/server/src/api/site.rs b/server/src/api/site.rs index c98539bee..7827913b9 100644 --- a/server/src/api/site.rs +++ b/server/src/api/site.rs @@ -83,6 +83,12 @@ pub struct GetSiteResponse { banned: Vec, } +#[derive(Serialize, Deserialize)] +pub struct TransferSite { + user_id: i32, + auth: String +} + impl Perform for Oper { fn perform(&self) -> Result { let _data: &ListCategories = &self.data; @@ -251,7 +257,14 @@ impl Perform for Oper { Err(_e) => None }; - let admins = UserView::admins(&conn)?; + let mut admins = UserView::admins(&conn)?; + if site_view.is_some() { + let site_creator_id = site_view.to_owned().unwrap().creator_id; + let creator_index = admins.iter().position(|r| r.id == site_creator_id).unwrap(); + let creator_user = admins.remove(creator_index); + admins.insert(0, creator_user); + } + let banned = UserView::banned(&conn)?; Ok( @@ -399,3 +412,68 @@ impl Perform for Oper { ) } } + +impl Perform for Oper { + fn perform(&self) -> Result { + let data: &TransferSite = &self.data; + let conn = establish_connection(); + + let claims = match Claims::decode(&data.auth) { + Ok(claims) => claims.claims, + Err(_e) => { + return Err(APIError::err(&self.op, "not_logged_in"))? + } + }; + + let user_id = claims.id; + + let read_site = Site::read(&conn, 1)?; + + // Make sure user is the creator + if read_site.creator_id != user_id { + return Err(APIError::err(&self.op, "not_an_admin"))? + } + + let site_form = SiteForm { + name: read_site.name, + description: read_site.description, + creator_id: data.user_id, + updated: Some(naive_now()), + }; + + match Site::update(&conn, 1, &site_form) { + Ok(site) => site, + Err(_e) => { + return Err(APIError::err(&self.op, "couldnt_update_site"))? + } + }; + + // Mod tables + let form = ModAddForm { + mod_user_id: user_id, + other_user_id: data.user_id, + removed: Some(false), + }; + + ModAdd::create(&conn, &form)?; + + let site_view = SiteView::read(&conn)?; + + let mut admins = UserView::admins(&conn)?; + let creator_index = admins.iter().position(|r| r.id == site_view.creator_id).unwrap(); + let creator_user = admins.remove(creator_index); + admins.insert(0, creator_user); + + let banned = UserView::banned(&conn)?; + + Ok( + GetSiteResponse { + op: self.op.to_string(), + site: Some(site_view), + admins: admins, + banned: banned, + } + ) + } +} + diff --git a/server/src/api/user.rs b/server/src/api/user.rs index 425cc1cbd..d8610fa9a 100644 --- a/server/src/api/user.rs +++ b/server/src/api/user.rs @@ -432,7 +432,11 @@ impl Perform for Oper { ModAdd::create(&conn, &form)?; - let admins = UserView::admins(&conn)?; + let site_creator_id = Site::read(&conn, 1)?.creator_id; + let mut admins = UserView::admins(&conn)?; + let creator_index = admins.iter().position(|r| r.id == site_creator_id).unwrap(); + let creator_user = admins.remove(creator_index); + admins.insert(0, creator_user); Ok( AddAdminResponse { diff --git a/server/src/db/comment.rs b/server/src/db/comment.rs index ce0b5a632..7901357f0 100644 --- a/server/src/db/comment.rs +++ b/server/src/db/comment.rs @@ -103,9 +103,10 @@ impl Likeable for CommentLike { } fn remove(conn: &PgConnection, comment_like_form: &CommentLikeForm) -> Result { use crate::schema::comment_like::dsl::*; - diesel::delete(comment_like - .filter(comment_id.eq(comment_like_form.comment_id)) - .filter(user_id.eq(comment_like_form.user_id))) + diesel::delete( + comment_like + .filter(comment_id.eq(comment_like_form.comment_id)) + .filter(user_id.eq(comment_like_form.user_id))) .execute(conn) } } diff --git a/server/src/db/community.rs b/server/src/db/community.rs index dd6ea94b0..e07b5c003 100644 --- a/server/src/db/community.rs +++ b/server/src/db/community.rs @@ -101,6 +101,16 @@ impl Joinable for CommunityModerator { } } +impl CommunityModerator { + pub fn delete_for_community(conn: &PgConnection, for_community_id: i32) -> Result { + use crate::schema::community_moderator::dsl::*; + diesel::delete( + community_moderator + .filter(community_id.eq(for_community_id))) + .execute(conn) + } +} + #[derive(Identifiable, Queryable, Associations, PartialEq, Debug)] #[belongs_to(Community)] #[table_name = "community_user_ban"] diff --git a/server/src/websocket/server.rs b/server/src/websocket/server.rs index c0dee2679..b4cbce3af 100644 --- a/server/src/websocket/server.rs +++ b/server/src/websocket/server.rs @@ -490,5 +490,15 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result { + let transfer_community: TransferCommunity = serde_json::from_str(data)?; + let res = Oper::new(user_operation, transfer_community).perform()?; + Ok(serde_json::to_string(&res)?) + }, + UserOperation::TransferSite => { + let transfer_site: TransferSite = serde_json::from_str(data)?; + let res = Oper::new(user_operation, transfer_site).perform()?; + Ok(serde_json::to_string(&res)?) + }, } } diff --git a/ui/src/components/comment-node.tsx b/ui/src/components/comment-node.tsx index 610252eab..f518da904 100644 --- a/ui/src/components/comment-node.tsx +++ b/ui/src/components/comment-node.tsx @@ -1,6 +1,6 @@ import { Component, linkEvent } from 'inferno'; import { Link } from 'inferno-router'; -import { CommentNode as CommentNodeI, CommentLikeForm, CommentForm as CommentFormI, SaveCommentForm, BanFromCommunityForm, BanUserForm, CommunityUser, UserView, AddModToCommunityForm, AddAdminForm } from '../interfaces'; +import { CommentNode as CommentNodeI, CommentLikeForm, CommentForm as CommentFormI, SaveCommentForm, BanFromCommunityForm, BanUserForm, CommunityUser, UserView, AddModToCommunityForm, AddAdminForm, TransferCommunityForm, TransferSiteForm } from '../interfaces'; import { WebSocketService, UserService } from '../services'; import { mdToHtml, getUnixTime, canMod, isMod } from '../utils'; import * as moment from 'moment'; @@ -148,6 +148,12 @@ export class CommentNode extends Component { } } + {/* Community creators can transfer community to another mod */} + {this.amCommunityCreator && this.isMod && +
  • + # +
  • + } {/* Admins can ban from all, and appoint other admins */} {this.canAdmin && <> @@ -166,6 +172,12 @@ export class CommentNode extends Component { } } + {/* Site Creator can transfer to another admin */} + {this.amSiteCreator && this.isAdmin && +
  • + # +
  • + } }
  • @@ -251,6 +263,20 @@ export class CommentNode extends Component { return this.props.admins && canMod(UserService.Instance.user, this.props.admins.map(a => a.id), this.props.node.comment.creator_id); } + get amCommunityCreator(): boolean { + return this.props.moderators && + UserService.Instance.user && + (this.props.node.comment.creator_id != UserService.Instance.user.id) && + (UserService.Instance.user.id == this.props.moderators[0].user_id); + } + + get amSiteCreator(): boolean { + return this.props.admins && + UserService.Instance.user && + (this.props.node.comment.creator_id != UserService.Instance.user.id) && + (UserService.Instance.user.id == this.props.admins[0].id); + } + handleReplyClick(i: CommentNode) { i.state.showReply = true; i.setState(i.state); @@ -431,6 +457,23 @@ export class CommentNode extends Component { i.setState(i.state); } + handleTransferCommunity(i: CommentNode) { + let form: TransferCommunityForm = { + community_id: i.props.node.comment.community_id, + user_id: i.props.node.comment.creator_id, + }; + WebSocketService.Instance.transferCommunity(form); + i.setState(i.state); + } + + handleTransferSite(i: CommentNode) { + let form: TransferSiteForm = { + user_id: i.props.node.comment.creator_id, + }; + WebSocketService.Instance.transferSite(form); + i.setState(i.state); + } + get isCommentNew(): boolean { let now = moment.utc().subtract(10, 'minutes'); let then = moment.utc(this.props.node.comment.published); diff --git a/ui/src/components/post.tsx b/ui/src/components/post.tsx index ab82ca4fe..a6df4105e 100644 --- a/ui/src/components/post.tsx +++ b/ui/src/components/post.tsx @@ -1,7 +1,7 @@ import { Component, linkEvent } from 'inferno'; import { Subscription } from "rxjs"; import { retryWhen, delay, take } from 'rxjs/operators'; -import { UserOperation, Community, Post as PostI, GetPostResponse, PostResponse, Comment, CommentForm as CommentFormI, CommentResponse, CommentSortType, CreatePostLikeResponse, CommunityUser, CommunityResponse, CommentNode as CommentNodeI, BanFromCommunityResponse, BanUserResponse, AddModToCommunityResponse, AddAdminResponse, UserView, SearchType, SortType, SearchForm, SearchResponse } from '../interfaces'; +import { UserOperation, Community, Post as PostI, GetPostResponse, PostResponse, Comment, CommentForm as CommentFormI, CommentResponse, CommentSortType, CreatePostLikeResponse, CommunityUser, CommunityResponse, CommentNode as CommentNodeI, BanFromCommunityResponse, BanUserResponse, AddModToCommunityResponse, AddAdminResponse, UserView, SearchType, SortType, SearchForm, SearchResponse, GetSiteResponse, GetCommunityResponse } from '../interfaces'; import { WebSocketService, UserService } from '../services'; import { msgOp, hotRank } from '../utils'; import { PostListing } from './post-listing'; @@ -370,6 +370,17 @@ export class Post extends Component { let res: SearchResponse = msg; this.state.crossPosts = res.posts.filter(p => p.id != this.state.post.id); this.setState(this.state); + } else if (op == UserOperation.TransferSite) { + let res: GetSiteResponse = msg; + + this.state.admins = res.admins; + this.setState(this.state); + } else if (op == UserOperation.TransferCommunity) { + let res: GetCommunityResponse = msg; + this.state.community = res.community; + this.state.moderators = res.moderators; + this.state.admins = res.admins; + this.setState(this.state); } } diff --git a/ui/src/interfaces.ts b/ui/src/interfaces.ts index 0a3daf668..251b64a0d 100644 --- a/ui/src/interfaces.ts +++ b/ui/src/interfaces.ts @@ -1,5 +1,5 @@ export enum UserOperation { - Login, Register, CreateCommunity, CreatePost, ListCommunities, ListCategories, GetPost, GetCommunity, CreateComment, EditComment, SaveComment, CreateCommentLike, GetPosts, CreatePostLike, EditPost, SavePost, EditCommunity, FollowCommunity, GetFollowedCommunities, GetUserDetails, GetReplies, GetModlog, BanFromCommunity, AddModToCommunity, CreateSite, EditSite, GetSite, AddAdmin, BanUser, Search, MarkAllAsRead, SaveUserSettings + Login, Register, CreateCommunity, CreatePost, ListCommunities, ListCategories, GetPost, GetCommunity, CreateComment, EditComment, SaveComment, CreateCommentLike, GetPosts, CreatePostLike, EditPost, SavePost, EditCommunity, FollowCommunity, GetFollowedCommunities, GetUserDetails, GetReplies, GetModlog, BanFromCommunity, AddModToCommunity, CreateSite, EditSite, GetSite, AddAdmin, BanUser, Search, MarkAllAsRead, SaveUserSettings, TransferCommunity, TransferSite } export enum CommentSortType { @@ -202,6 +202,17 @@ export interface AddModToCommunityForm { auth?: string; } +export interface TransferCommunityForm { + community_id: number; + user_id: number; + auth?: string; +} + +export interface TransferSiteForm { + user_id: number; + auth?: string; +} + export interface AddModToCommunityResponse { op: string; moderators: Array; diff --git a/ui/src/services/WebSocketService.ts b/ui/src/services/WebSocketService.ts index c34b6b3c1..f67dbf6d9 100644 --- a/ui/src/services/WebSocketService.ts +++ b/ui/src/services/WebSocketService.ts @@ -1,5 +1,5 @@ import { wsUri } from '../env'; -import { LoginForm, RegisterForm, UserOperation, CommunityForm, PostForm, SavePostForm, CommentForm, SaveCommentForm, CommentLikeForm, GetPostsForm, CreatePostLikeForm, FollowCommunityForm, GetUserDetailsForm, ListCommunitiesForm, GetModlogForm, BanFromCommunityForm, AddModToCommunityForm, AddAdminForm, BanUserForm, SiteForm, Site, UserView, GetRepliesForm, SearchForm, UserSettingsForm } from '../interfaces'; +import { LoginForm, RegisterForm, UserOperation, CommunityForm, PostForm, SavePostForm, CommentForm, SaveCommentForm, CommentLikeForm, GetPostsForm, CreatePostLikeForm, FollowCommunityForm, GetUserDetailsForm, ListCommunitiesForm, GetModlogForm, BanFromCommunityForm, AddModToCommunityForm, TransferCommunityForm, AddAdminForm, TransferSiteForm, BanUserForm, SiteForm, Site, UserView, GetRepliesForm, SearchForm, UserSettingsForm } from '../interfaces'; import { webSocket } from 'rxjs/webSocket'; import { Subject } from 'rxjs'; import { retryWhen, delay, take } from 'rxjs/operators'; @@ -136,6 +136,16 @@ export class WebSocketService { this.subject.next(this.wsSendWrapper(UserOperation.AddModToCommunity, form)); } + public transferCommunity(form: TransferCommunityForm) { + this.setAuth(form); + this.subject.next(this.wsSendWrapper(UserOperation.TransferCommunity, form)); + } + + public transferSite(form: TransferSiteForm) { + this.setAuth(form); + this.subject.next(this.wsSendWrapper(UserOperation.TransferSite, form)); + } + public banUser(form: BanUserForm) { this.setAuth(form); this.subject.next(this.wsSendWrapper(UserOperation.BanUser, form)); diff --git a/ui/src/translations/en.ts b/ui/src/translations/en.ts index ff38a84b2..c854ea7be 100644 --- a/ui/src/translations/en.ts +++ b/ui/src/translations/en.ts @@ -130,6 +130,8 @@ export const en = { joined: 'Joined', by: 'by', to: 'to', + transfer_community: 'transfer community', + transfer_site: 'transfer site', powered_by: 'Powered by', landing_0: 'Lemmy is a <1>link aggregator / reddit alternative, intended to work in the <2>fediverse.<3>It\'s self-hostable, has live-updating comment threads, and is tiny (<4>~80kB). Federation into the ActivityPub network is on the roadmap. <5>This is a <6>very early beta version, and a lot of features are currently broken or missing. <7>Suggest new features or report bugs <8>here.<9>Made with <10>Rust, <11>Actix, <12>Inferno, <13>Typescript.', not_logged_in: 'Not logged in.',