Adding Community and Site transfer

- Fixes #139
This commit is contained in:
Dessalines 2019-08-23 19:40:41 -07:00
parent addfc72080
commit 1e83aa8c72
14 changed files with 314 additions and 13 deletions

View File

@ -39,6 +39,8 @@ Front Page|Post
- Clean, mobile-friendly interface. - Clean, mobile-friendly interface.
- i18n / internationalization support. - i18n / internationalization support.
- NSFW post / community support. - NSFW post / community support.
- Cross-posting support.
- Can transfer site and communities to others.
- High performance. - High performance.
- Server is written in rust. - Server is written in rust.
- Front end is `~80kB` gzipped. - 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). 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 ## Credits

View File

@ -111,6 +111,13 @@ pub struct GetFollowedCommunitiesResponse {
communities: Vec<CommunityFollowerView> communities: Vec<CommunityFollowerView>
} }
#[derive(Serialize, Deserialize)]
pub struct TransferCommunity {
community_id: i32,
user_id: i32,
auth: String
}
impl Perform<GetCommunityResponse> for Oper<GetCommunity> { impl Perform<GetCommunityResponse> for Oper<GetCommunity> {
fn perform(&self) -> Result<GetCommunityResponse, Error> { fn perform(&self) -> Result<GetCommunityResponse, Error> {
let data: &GetCommunity = &self.data; let data: &GetCommunity = &self.data;
@ -148,7 +155,11 @@ impl Perform<GetCommunityResponse> for Oper<GetCommunity> {
} }
}; };
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 // Return the jwt
Ok( Ok(
@ -577,3 +588,107 @@ impl Perform<AddModToCommunityResponse> for Oper<AddModToCommunity> {
) )
} }
} }
impl Perform<GetCommunityResponse> for Oper<TransferCommunity> {
fn perform(&self) -> Result<GetCommunityResponse, Error> {
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,
}
)
}
}

View File

@ -22,7 +22,7 @@ pub mod site;
#[derive(EnumString,ToString,Debug)] #[derive(EnumString,ToString,Debug)]
pub enum UserOperation { 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)] #[derive(Fail, Debug)]

View File

@ -200,7 +200,11 @@ impl Perform<GetPostResponse> for Oper<GetPost> {
let moderators = CommunityModeratorView::for_community(&conn, post_view.community_id)?; 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 // Return the jwt
Ok( Ok(

View File

@ -83,6 +83,12 @@ pub struct GetSiteResponse {
banned: Vec<UserView>, banned: Vec<UserView>,
} }
#[derive(Serialize, Deserialize)]
pub struct TransferSite {
user_id: i32,
auth: String
}
impl Perform<ListCategoriesResponse> for Oper<ListCategories> { impl Perform<ListCategoriesResponse> for Oper<ListCategories> {
fn perform(&self) -> Result<ListCategoriesResponse, Error> { fn perform(&self) -> Result<ListCategoriesResponse, Error> {
let _data: &ListCategories = &self.data; let _data: &ListCategories = &self.data;
@ -251,7 +257,14 @@ impl Perform<GetSiteResponse> for Oper<GetSite> {
Err(_e) => None 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)?; let banned = UserView::banned(&conn)?;
Ok( Ok(
@ -399,3 +412,68 @@ impl Perform<SearchResponse> for Oper<Search> {
) )
} }
} }
impl Perform<GetSiteResponse> for Oper<TransferSite> {
fn perform(&self) -> Result<GetSiteResponse, Error> {
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,
}
)
}
}

View File

@ -432,7 +432,11 @@ impl Perform<AddAdminResponse> for Oper<AddAdmin> {
ModAdd::create(&conn, &form)?; 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( Ok(
AddAdminResponse { AddAdminResponse {

View File

@ -103,9 +103,10 @@ impl Likeable <CommentLikeForm> for CommentLike {
} }
fn remove(conn: &PgConnection, comment_like_form: &CommentLikeForm) -> Result<usize, Error> { fn remove(conn: &PgConnection, comment_like_form: &CommentLikeForm) -> Result<usize, Error> {
use crate::schema::comment_like::dsl::*; use crate::schema::comment_like::dsl::*;
diesel::delete(comment_like diesel::delete(
.filter(comment_id.eq(comment_like_form.comment_id)) comment_like
.filter(user_id.eq(comment_like_form.user_id))) .filter(comment_id.eq(comment_like_form.comment_id))
.filter(user_id.eq(comment_like_form.user_id)))
.execute(conn) .execute(conn)
} }
} }

View File

@ -101,6 +101,16 @@ impl Joinable<CommunityModeratorForm> for CommunityModerator {
} }
} }
impl CommunityModerator {
pub fn delete_for_community(conn: &PgConnection, for_community_id: i32) -> Result<usize, Error> {
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)] #[derive(Identifiable, Queryable, Associations, PartialEq, Debug)]
#[belongs_to(Community)] #[belongs_to(Community)]
#[table_name = "community_user_ban"] #[table_name = "community_user_ban"]

View File

@ -490,5 +490,15 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str
let res = Oper::new(user_operation, search).perform()?; let res = Oper::new(user_operation, search).perform()?;
Ok(serde_json::to_string(&res)?) Ok(serde_json::to_string(&res)?)
}, },
UserOperation::TransferCommunity => {
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)?)
},
} }
} }

View File

@ -1,6 +1,6 @@
import { Component, linkEvent } from 'inferno'; import { Component, linkEvent } from 'inferno';
import { Link } from 'inferno-router'; 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 { WebSocketService, UserService } from '../services';
import { mdToHtml, getUnixTime, canMod, isMod } from '../utils'; import { mdToHtml, getUnixTime, canMod, isMod } from '../utils';
import * as moment from 'moment'; import * as moment from 'moment';
@ -148,6 +148,12 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
} }
</> </>
} }
{/* Community creators can transfer community to another mod */}
{this.amCommunityCreator && this.isMod &&
<li className="list-inline-item">
<span class="pointer" onClick={linkEvent(this, this.handleTransferCommunity)}><T i18nKey="transfer_community">#</T></span>
</li>
}
{/* Admins can ban from all, and appoint other admins */} {/* Admins can ban from all, and appoint other admins */}
{this.canAdmin && {this.canAdmin &&
<> <>
@ -166,6 +172,12 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
} }
</> </>
} }
{/* Site Creator can transfer to another admin */}
{this.amSiteCreator && this.isAdmin &&
<li className="list-inline-item">
<span class="pointer" onClick={linkEvent(this, this.handleTransferSite)}><T i18nKey="transfer_site">#</T></span>
</li>
}
</> </>
} }
<li className="list-inline-item"> <li className="list-inline-item">
@ -251,6 +263,20 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
return this.props.admins && canMod(UserService.Instance.user, this.props.admins.map(a => a.id), this.props.node.comment.creator_id); 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) { handleReplyClick(i: CommentNode) {
i.state.showReply = true; i.state.showReply = true;
i.setState(i.state); i.setState(i.state);
@ -431,6 +457,23 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
i.setState(i.state); 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 { get isCommentNew(): boolean {
let now = moment.utc().subtract(10, 'minutes'); let now = moment.utc().subtract(10, 'minutes');
let then = moment.utc(this.props.node.comment.published); let then = moment.utc(this.props.node.comment.published);

View File

@ -1,7 +1,7 @@
import { Component, linkEvent } from 'inferno'; import { Component, linkEvent } from 'inferno';
import { Subscription } from "rxjs"; import { Subscription } from "rxjs";
import { retryWhen, delay, take } from 'rxjs/operators'; 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 { WebSocketService, UserService } from '../services';
import { msgOp, hotRank } from '../utils'; import { msgOp, hotRank } from '../utils';
import { PostListing } from './post-listing'; import { PostListing } from './post-listing';
@ -370,6 +370,17 @@ export class Post extends Component<any, PostState> {
let res: SearchResponse = msg; let res: SearchResponse = msg;
this.state.crossPosts = res.posts.filter(p => p.id != this.state.post.id); this.state.crossPosts = res.posts.filter(p => p.id != this.state.post.id);
this.setState(this.state); 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);
} }
} }

View File

@ -1,5 +1,5 @@
export enum UserOperation { 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 { export enum CommentSortType {
@ -202,6 +202,17 @@ export interface AddModToCommunityForm {
auth?: string; auth?: string;
} }
export interface TransferCommunityForm {
community_id: number;
user_id: number;
auth?: string;
}
export interface TransferSiteForm {
user_id: number;
auth?: string;
}
export interface AddModToCommunityResponse { export interface AddModToCommunityResponse {
op: string; op: string;
moderators: Array<CommunityUser>; moderators: Array<CommunityUser>;

View File

@ -1,5 +1,5 @@
import { wsUri } from '../env'; 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 { webSocket } from 'rxjs/webSocket';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators'; import { retryWhen, delay, take } from 'rxjs/operators';
@ -136,6 +136,16 @@ export class WebSocketService {
this.subject.next(this.wsSendWrapper(UserOperation.AddModToCommunity, form)); 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) { public banUser(form: BanUserForm) {
this.setAuth(form); this.setAuth(form);
this.subject.next(this.wsSendWrapper(UserOperation.BanUser, form)); this.subject.next(this.wsSendWrapper(UserOperation.BanUser, form));

View File

@ -130,6 +130,8 @@ export const en = {
joined: 'Joined', joined: 'Joined',
by: 'by', by: 'by',
to: 'to', to: 'to',
transfer_community: 'transfer community',
transfer_site: 'transfer site',
powered_by: 'Powered by', powered_by: 'Powered by',
landing_0: 'Lemmy is a <1>link aggregator</1> / reddit alternative, intended to work in the <2>fediverse</2>.<3></3>It\'s self-hostable, has live-updating comment threads, and is tiny (<4>~80kB</4>). Federation into the ActivityPub network is on the roadmap. <5></5>This is a <6>very early beta version</6>, and a lot of features are currently broken or missing. <7></7>Suggest new features or report bugs <8>here.</8><9></9>Made with <10>Rust</10>, <11>Actix</11>, <12>Inferno</12>, <13>Typescript</13>.', landing_0: 'Lemmy is a <1>link aggregator</1> / reddit alternative, intended to work in the <2>fediverse</2>.<3></3>It\'s self-hostable, has live-updating comment threads, and is tiny (<4>~80kB</4>). Federation into the ActivityPub network is on the roadmap. <5></5>This is a <6>very early beta version</6>, and a lot of features are currently broken or missing. <7></7>Suggest new features or report bugs <8>here.</8><9></9>Made with <10>Rust</10>, <11>Actix</11>, <12>Inferno</12>, <13>Typescript</13>.',
not_logged_in: 'Not logged in.', not_logged_in: 'Not logged in.',