Skip to content

Commit

Permalink
Merge pull request #667 from FreeFeed/spoiled-symmetric-bans
Browse files Browse the repository at this point in the history
Symmetric bans (broken)
  • Loading branch information
davidmz authored Apr 12, 2024
2 parents f058493 + 5ed5757 commit d443c6f
Show file tree
Hide file tree
Showing 25 changed files with 658 additions and 197 deletions.
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.19.0] - Not released
#### Changed
- The bans are fully symmetrical now. If A bans B, then:
- A doesn't see B's posts, comments content, likes and comment likes;
- B doesn't see A's posts, comments content, likes and comment likes.

By default, comments are displayed as placeholders, but the viewer can turn
them off completely. Also, one can disable bans in some groups, see the
[2.7.0] release for the bans logic in this case.
- /!\ The ban symmetry is DELIBERATELY BROKEN for now. It works for likes and
comment likes, but the A's comments content is still visible for B. Such
comments have an additional `_hideType = 4` field in API responses ('4' is the
`Comment.HIDDEN_VIEWER_BANNED` constant). The client code can hide such
comments on its level.

## [2.18.5] - 2024-04-09
### Fixed
Expand Down
20 changes: 13 additions & 7 deletions app/controllers/middlewares/comment-access-required.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,21 @@ export function commentAccessRequired({ mustBeVisible }) {
ctx.params.postId = comment.postId;
await applyMiddleware(postAccessRequired(), ctx);

if (await dbAdapter.isCommentBannedForViewer(comment.id, viewer?.id)) {
if (mustBeVisible) {
throw new ForbiddenException('You have banned the author of this comment');
} else {
comment.setHideType(Comment.HIDDEN_BANNED);
}
const banHideType = await dbAdapter.isCommentBannedForViewer(comment.id, viewer?.id);

if (mustBeVisible && banHideType === Comment.HIDDEN_AUTHOR_BANNED) {
throw new ForbiddenException('You have banned the author of this comment');
// } else if (mustBeVisible && banHideType === Comment.HIDDEN_VIEWER_BANNED) {
// throw new ForbiddenException('The author of this comment has banned you');
} else if (banHideType) {
comment.setHideType(banHideType);
}

if (comment.hideType !== Comment.VISIBLE && mustBeVisible) {
if (
comment.hideType !== Comment.VISIBLE &&
banHideType !== Comment.HIDDEN_VIEWER_BANNED &&
mustBeVisible
) {
throw new ForbiddenException(`You don't have access to this comment`);
}

Expand Down
8 changes: 6 additions & 2 deletions app/models.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,15 +204,19 @@ export class Attachment {
export class Comment {
static VISIBLE: 0;
static DELETED: 1;
static HIDDEN_BANNED: 2;
// The author of the comment was banned by a viewer
static HIDDEN_AUTHOR_BANNED: 2;
static HIDDEN_ARCHIVED: 3;
// A viewer was banned by the author of the comment
static HIDDEN_VIEWER_BANNED: 4;
id: UUID;
intId: number;
body: string;
userId: Nullable<UUID>;
hideType: 0 | 1 | 2 | 3;
hideType: 0 | 1 | 2 | 3 | 4;
postId: UUID;
seqNumber: number;
static hiddenBody(hideType: number): string;
constructor(params: { userId: UUID; body: string; postId: UUID });
create(): Promise<void>;
destroy(destroyedBy?: User): Promise<boolean>;
Expand Down
11 changes: 8 additions & 3 deletions app/models/comment.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@ export function addModel(dbAdapter) {
class Comment {
static VISIBLE = 0;
static DELETED = 1;
static HIDDEN_BANNED = 2;
// The author of the comment was banned by a viewer
static HIDDEN_AUTHOR_BANNED = 2;
static HIDDEN_ARCHIVED = 3;
// A viewer was banned by the author of the comment
static HIDDEN_VIEWER_BANNED = 4;

id;
intId;
Expand All @@ -40,10 +43,12 @@ export function addModel(dbAdapter) {
return 'Visible comment';
case this.DELETED:
return 'Deleted comment';
case this.HIDDEN_BANNED:
return 'Hidden comment';
case this.HIDDEN_AUTHOR_BANNED:
return 'Comment from blocked user';
case this.HIDDEN_ARCHIVED:
return 'Archived comment';
case this.HIDDEN_VIEWER_BANNED:
return 'Comment from user who blocked you';
default:
return 'Hidden comment';
}
Expand Down
7 changes: 6 additions & 1 deletion app/models/user-prefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ const schema = {
uniqueItems: true,
items: {
type: 'integer',
enum: [commentModel.DELETED, commentModel.HIDDEN_BANNED, commentModel.HIDDEN_ARCHIVED],
enum: [
commentModel.DELETED,
commentModel.HIDDEN_AUTHOR_BANNED,
commentModel.HIDDEN_ARCHIVED,
commentModel.HIDDEN_VIEWER_BANNED,
],
},
},
sendNotificationsDigest: {
Expand Down
61 changes: 43 additions & 18 deletions app/pubsub-listener.js
Original file line number Diff line number Diff line change
Expand Up @@ -335,15 +335,24 @@ export default class PubsubListener {
}

// See doc/visibility-rules.md for details
const bansMap = await dbAdapter.getUsersBansIdsMap(userIds);
const [bansMap, bannedByMap] = await Promise.all([
dbAdapter.getUsersBansIdsMap(userIds),
dbAdapter.getUsersBanedByIdsMap(userIds),
]);

/** @type {UUID[]} */
let usersDisabledBans = [];
/** @type {UUID[]} */
let adminsDisabledBans = [];

if (post) {
const postGroups = await dbAdapter.getPostGroups(post.id);
usersDisabledBans = await dbAdapter
.getUsersWithDisabledBansInGroups(postGroups.map((g) => g.id))
.then((us) => us.map((u) => u.user_id));

// Users/admins who have disabled bans in some post groups
const groupIds = postGroups.map((g) => g.id);
const disabledBans = await dbAdapter.getUsersWithDisabledBansInGroups(groupIds);
usersDisabledBans = disabledBans.map((u) => u.user_id);
adminsDisabledBans = disabledBans.filter((u) => u.is_admin).map((u) => u.user_id);
}

await Promise.all(
Expand All @@ -355,32 +364,48 @@ export default class PubsubListener {

// Bans
if (post && userId) {
const banIds = (!usersDisabledBans.includes(userId) && bansMap.get(userId)) || [];
const bannedUserIds = (!usersDisabledBans.includes(userId) && bansMap.get(userId)) || [];
const bannedByUserIds =
(!adminsDisabledBans.includes(userId) && bannedByMap.get(userId)) || [];

const isBanned = (id) => bannedUserIds.includes(id) || bannedByUserIds.includes(id);

if (
(type === eventNames.COMMENT_UPDATED && banIds.includes(data.comments.createdBy)) ||
(type === eventNames.LIKE_ADDED && banIds.includes(data.users.id)) ||
(type === eventNames.COMMENT_UPDATED && isBanned(data.comments.createdBy)) ||
(type === eventNames.LIKE_ADDED && isBanned(data.users.id)) ||
((type === eventNames.COMMENT_LIKE_ADDED || type === eventNames.COMMENT_LIKE_REMOVED) &&
(banIds.includes(data.comments.createdBy) || banIds.includes(data.comments.userId)))
(isBanned(data.comments.createdBy) || isBanned(data.comments.userId)))
) {
return;
}

// A very special case: comment author is banned, but the viewer chooses
// to see such comments as placeholders.
if (type === eventNames.COMMENT_CREATED && banIds.includes(data.comments.createdBy)) {
const user = await dbAdapter.getUserById(userId);
if (type === eventNames.COMMENT_CREATED) {
let hideType = null;

if (user.getHiddenCommentTypes().includes(Comment.HIDDEN_BANNED)) {
return;
if (bannedUserIds.includes(data.comments.createdBy)) {
hideType = Comment.HIDDEN_AUTHOR_BANNED;
// } else if (bannedByUserIds.includes(data.comments.createdBy)) {
// hideType = Comment.HIDDEN_VIEWER_BANNED;
}

const { createdBy } = data.comments;
data.comments.hideType = Comment.HIDDEN_BANNED;
data.comments.body = Comment.hiddenBody(Comment.HIDDEN_BANNED);
data.comments.createdBy = null;
data.users = data.users.filter((u) => u.id !== createdBy);
data.admins = data.admins.filter((u) => u.id !== createdBy);
if (hideType !== null) {
const user = await dbAdapter.getUserById(userId);

if (user.getHiddenCommentTypes().includes(hideType)) {
return;
}

const { createdBy } = data.comments;
data.comments.hideType = hideType;
data.comments.body = Comment.hiddenBody(hideType);
data.comments.createdBy = null;
data.users = data.users.filter((u) => u.id !== createdBy);
data.admins = data.admins.filter((u) => u.id !== createdBy);
} else if (bannedByUserIds.includes(data.comments.createdBy)) {
data.comments._hideType = Comment.HIDDEN_VIEWER_BANNED;
}
}
}

Expand Down
13 changes: 10 additions & 3 deletions app/serializers/v2/comment.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,15 @@ export async function serializeCommentsFull(comments, viewerId) {
createdBy: comment.userId,
};

if (bansMap[comment.id]) {
if (
bansMap[comment.id] === Comment.HIDDEN_AUTHOR_BANNED // ||
// bansMap[comment.id] === Comment.HIDDEN_VIEWER_BANNED
) {
ser.likes = 0;
ser.hasOwnLike = false;

ser.hideType = Comment.HIDDEN_BANNED;
ser.body = Comment.hiddenBody(Comment.HIDDEN_BANNED);
ser.hideType = bansMap[comment.id];
ser.body = Comment.hiddenBody(ser.hideType);
ser.createdBy = null;
} else {
const commentLikesData = likesInfo.find((it) => it.uid === comment.id) ?? {
Expand All @@ -59,6 +62,10 @@ export async function serializeCommentsFull(comments, viewerId) {
ser.likes = parseInt(commentLikesData.c_likes);
ser.hasOwnLike = commentLikesData.has_own_like;
userIds.add(comment.userId);

if (bansMap[comment.id]) {
ser._hideType = bansMap[comment.id];
}
}

return ser;
Expand Down
1 change: 1 addition & 0 deletions app/serializers/v2/post.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export function serializeComment(comment) {
'createdAt',
'updatedAt',
'hideType',
'_hideType',
'likes',
'hasOwnLike',
'seqNumber',
Expand Down
21 changes: 19 additions & 2 deletions app/support/DbAdapter/bans.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ const bansTrait = (superClass) =>
}

/**
* Returns Map.<userId, bannedUserIds>
* Returns Map<userId, bannedUserIds>
* @param {string[]} userIds
* @return {Map.<string, string[]>}
* @return {Promise<Map<string, string[]>>}
*/
async getUsersBansIdsMap(userIds) {
const { rows } = await this.database.raw(
Expand All @@ -29,6 +29,23 @@ const bansTrait = (superClass) =>
return new Map(rows.map((r) => [r.user_id, r.bans]));
}

/**
* Returns Map<userId, whoBannedUserIds>
* @param {string[]} userIds
* @return {Promise<Map<string, string[]>>}
*/
async getUsersBanedByIdsMap(userIds) {
const { rows } = await this.database.raw(
`
select banned_user_id, array_agg(user_id) as bans
from bans where banned_user_id = any(:userIds)
group by banned_user_id
`,
{ userIds },
);
return new Map(rows.map((r) => [r.banned_user_id, r.bans]));
}

async getUserIdsWhoBannedUser(userId) {
const res = await this.database('bans')
.select('user_id')
Expand Down
39 changes: 0 additions & 39 deletions app/support/DbAdapter/comments.js
Original file line number Diff line number Diff line change
Expand Up @@ -171,45 +171,6 @@ const commentsTrait = (superClass) =>
return parseInt(res[0].count);
}

async getAllPostCommentsWithoutBannedUsers(postId, viewerUserId) {
let query = this.database('comments').orderBy('created_at', 'asc').where('post_id', postId);

const [viewer, bannedUsersIds] = await Promise.all([
viewerUserId ? this.getUserById(viewerUserId) : null,
viewerUserId ? this.getUserBansIds(viewerUserId) : [],
]);

if (viewerUserId) {
const hiddenCommentTypes = viewer.getHiddenCommentTypes();

if (hiddenCommentTypes.length > 0) {
if (hiddenCommentTypes.includes(Comment.HIDDEN_BANNED) && bannedUsersIds.length > 0) {
query = query.where('user_id', 'not in', bannedUsersIds);
}

const ht = hiddenCommentTypes.filter(
(t) => t !== Comment.HIDDEN_BANNED && t !== Comment.VISIBLE,
);

if (ht.length > 0) {
query = query.where('hide_type', 'not in', ht);
}
}
}

const responses = await query;
const comments = responses.map((comm) => {
if (bannedUsersIds.includes(comm.user_id)) {
comm.user_id = null;
comm.hide_type = Comment.HIDDEN_BANNED;
comm.body = Comment.hiddenBody(Comment.HIDDEN_BANNED);
}

return comm;
});
return comments.map(initCommentObject);
}

_deletePostComments(postId) {
return this.database('comments').where({ post_id: postId }).delete();
}
Expand Down
5 changes: 5 additions & 0 deletions app/support/DbAdapter/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ export class DbAdapter {
getUsersByNormEmail(email: string): Promise<User[]>;
existsEmail(email: string): Promise<boolean>;
existsNormEmail(email: string): Promise<boolean>;
getUsersBansIdsMap(ids: UUID[]): Promise<Map<UUID, UUID[]>>;
getUsersBanedByIdsMap(ids: UUID[]): Promise<Map<UUID, UUID[]>>;
getUserIdsWhoBannedUser(id: UUID): Promise<UUID[]>;
getFeedOwnerById(id: UUID): Promise<User | Group | null>;
getFeedOwnerByUsername(name: string): Promise<User | Group | null>;
Expand Down Expand Up @@ -181,6 +183,9 @@ export class DbAdapter {
// Bans
getUserBansIds(id: UUID): Promise<UUID[]>;
getGroupsWithDisabledBans(userId: UUID, groupIds?: UUID[]): Promise<UUID[]>;
getUsersWithDisabledBansInGroups(
groupIds: UUID[],
): Promise<{ user_id: UUID; is_admin: boolean }[]>;
disableBansInGroup(userId: UUID, groupId: UUID, doDisable: boolean): Promise<boolean>;

// Posts
Expand Down
Loading

0 comments on commit d443c6f

Please sign in to comment.