support for post visibility

This commit is contained in:
SrGooglo 2025-03-21 18:18:46 +00:00
parent 21441ef11a
commit db14fd0c94
19 changed files with 1730 additions and 1390 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,380 +1,387 @@
@file_preview_borderRadius: 7px; @file_preview_borderRadius: 7px;
.postCreator { .postCreator {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
width: 100%; width: 100%;
z-index: 55; z-index: 55;
max-width: 600px; max-width: 600px;
border-radius: 12px; border-radius: 12px;
background-color: var(--background-color-accent); background-color: var(--background-color-accent);
padding: 15px; padding: 15px;
gap: 10px; gap: 10px;
.postCreator-header { .postCreator-header {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
width: 100%; width: 100%;
p { p {
margin: 0; margin: 0;
} }
.postCreator-header-indicator { .postCreator-header-indicator {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 7px; gap: 7px;
align-items: center; align-items: center;
} }
} }
.actions { .actions {
display: inline-flex; display: inline-flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
width: 100%; width: 100%;
padding: 0 10px; padding: 0 10px;
gap: 10px; gap: 10px;
transition: all 150ms ease-in-out; transition: all 150ms ease-in-out;
.ant-btn { .ant-btn {
font-size: 1.2rem; font-size: 1.2rem;
color: var(--text-color); color: var(--text-color);
opacity: 0.7; opacity: 0.7;
svg { svg {
margin: 0; margin: 0;
} }
} }
} }
.uploader { .uploader {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
overflow-x: scroll; overflow-x: scroll;
transition: all 150ms ease-in-out; transition: all 150ms ease-in-out;
&.visible { &.visible {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
z-index: 150; z-index: 150;
display: flex; display: flex;
padding: 0; padding: 0;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 0; border: 0;
.ant-upload-drag { .ant-upload-drag {
width: 100%; width: 100%;
height: 100%; height: 100%;
opacity: 1; opacity: 1;
} }
.ant-upload-list { .ant-upload-list {
opacity: 0; opacity: 0;
} }
} }
.ant-upload-drag { .ant-upload-drag {
opacity: 0; opacity: 0;
width: 0; width: 0;
height: 0; height: 0;
background-color: transparent !important; background-color: transparent !important;
border: 0; border: 0;
} }
.ant-upload-wrapper { .ant-upload-wrapper {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
background-color: transparent; background-color: transparent;
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
.ant-upload-list { .ant-upload-list {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
width: 100%; width: 100%;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
white-space: nowrap; white-space: nowrap;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
gap: 10px; gap: 10px;
border-radius: @file_preview_borderRadius; border-radius: @file_preview_borderRadius;
.ant-upload-list-item-container { .ant-upload-list-item-container {
background-color: rgba(var(--bg_color_5), 1); background-color: rgba(var(--bg_color_5), 1);
width: fit-content; width: fit-content;
height: fit-content; height: fit-content;
border-radius: @file_preview_borderRadius; border-radius: @file_preview_borderRadius;
margin: 0; margin: 0;
} }
} }
} }
.ant-upload-list-item-actions { .ant-upload-list-item-actions {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
width: 100%; width: 100%;
font-size: 2rem; font-size: 2rem;
border-radius: @file_preview_borderRadius; border-radius: @file_preview_borderRadius;
} }
.hint { .hint {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
align-self: center; align-self: center;
text-align: center; text-align: center;
background-color: transparent; background-color: transparent;
font-size: 0.8rem; font-size: 0.8rem;
padding: 10px; padding: 10px;
svg { svg {
font-size: 1.5rem; font-size: 1.5rem;
margin-bottom: 6px; margin-bottom: 6px;
margin-right: 0 !important; margin-right: 0 !important;
} }
} }
.file { .file {
position: relative; position: relative;
width: 10vw; width: 10vw;
height: 10vw; height: 10vw;
max-width: 150px; max-width: 150px;
max-height: 150px; max-height: 150px;
font-size: 2rem; font-size: 2rem;
transition: all 150ms ease-in-out; transition: all 150ms ease-in-out;
border-radius: 10px; border-radius: 10px;
&.uploading { &.uploading {
img { img {
opacity: 0.5; opacity: 0.5;
} }
.actions { .actions {
backdrop-filter: blur(5px); backdrop-filter: blur(5px);
opacity: 1; opacity: 1;
} }
} }
&:hover { &:hover {
.actions { .actions {
opacity: 1; opacity: 1;
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
} }
.preview { .preview {
opacity: 0.5; opacity: 0.5;
} }
} }
.preview { .preview {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
img { img {
width: 100%; width: 100%;
height: 100%; height: 100%;
transition: all 150ms ease-in-out; transition: all 150ms ease-in-out;
border-radius: @file_preview_borderRadius; border-radius: @file_preview_borderRadius;
object-fit: contain; object-fit: contain;
} }
} }
}
}
.actions {
display: flex;
flex-direction: inline-flex;
svg {
color: var(--text-color);
margin: 0;
}
.actions { .ant-select {
display: flex; .ant-select-selector {
flex-direction: column; border: 0;
padding: 4px 10px !important;
align-items: center; background-color: rgba(var(--bg_color_1), 0.8);
justify-content: center;
.ant-select-selection-item {
width: 100%; display: flex;
height: 100%; align-items: center;
justify-content: center;
transition: all 150ms ease-in-out; color: var(--text-color);
}
opacity: 0; }
color: rgba(var(--bg_color_1), 1);
.ant-select-arrow {
border-radius: @file_preview_borderRadius; font-size: 0.6rem;
right: 7px;
svg { }
color: rgba(var(--bg_color_1), 1); }
margin: 0; }
}
} .textInput {
} display: flex;
}
width: 100%;
.textInput {
display: flex; transition: all 150ms ease-in-out;
width: 100%; background-color: transparent;
transition: all 150ms ease-in-out; svg {
margin: 0 !important;
background-color: transparent; }
svg { .avatar {
margin: 0 !important; width: fit-content;
} height: 45px;
.avatar { display: flex;
width: fit-content;
height: 45px; img {
width: 45px;
display: flex; height: 45px;
border-radius: 12px;
img { }
width: 45px; }
height: 45px;
border-radius: 12px; textarea {
} color: var(--text-color);
} }
textarea { textarea::placeholder {
color: var(--text-color); color: rgb(var(--bg_color_4));
} }
textarea::placeholder { .textArea {
color: rgb(var(--bg_color_4)); border-radius: 8px !important;
} transition: all 150ms ease-in-out !important;
.textArea { &.active {
border-radius: 8px !important; background-color: var(--background-color-primary);
transition: all 150ms ease-in-out !important; }
}
&.active {
background-color: var(--background-color-primary); .ant-btn-primary {
} z-index: 10;
} position: relative;
border-radius: 0 10px 10px 0;
.ant-btn-primary { height: 100%;
z-index: 10; vertical-align: bottom;
position: relative; border: none;
border-radius: 0 10px 10px 0; box-shadow: none;
height: 100%;
vertical-align: bottom; svg {
border: none; color: var(--text-color) !important;
box-shadow: none; }
}
svg {
color: var(--text-color) !important; .ant-input {
} background-color: transparent;
}
z-index: 10;
.ant-input { position: relative;
background-color: transparent; border-color: transparent !important;
box-shadow: none;
z-index: 10; border-radius: 3px 0 0;
position: relative; height: 100%;
border-color: transparent !important; padding: 5px 10px;
box-shadow: none; transition: height 150ms linear;
border-radius: 3px 0 0; width: 100%;
height: 100%; }
padding: 5px 10px;
transition: height 150ms linear; .ant-btn-primary[disabled] {
width: 100%; background-color: var(--background-color-accent);
} }
.ant-btn-primary[disabled] { .ant-input-affix-wrapper {
background-color: var(--background-color-accent); height: 100%;
}
border: 0;
.ant-input-affix-wrapper { outline: 0;
height: 100%; box-shadow: none;
border: 0; background-color: transparent;
outline: 0; }
box-shadow: none; }
}
background-color: transparent;
}
}
}

View File

@ -334,6 +334,8 @@ export class PostsListsComponent extends React.Component {
"posts", "posts",
) )
}) })
app.cores.api.client().sockets.posts.emit("connect_realtime")
} }
} }
@ -362,6 +364,8 @@ export class PostsListsComponent extends React.Component {
"posts", "posts",
) )
}) })
app.cores.api.client().sockets.posts.emit("disconnect_realtime")
} }
} }

View File

@ -1,37 +1,41 @@
export default { export default {
name: "Post", name: "Post",
collection: "posts", collection: "posts",
schema: { schema: {
user_id: { user_id: {
type: String, type: String,
required: true required: true,
}, },
created_at: { created_at: {
type: Date, type: Date,
required: true required: true,
}, },
message: { message: {
type: String type: String,
}, },
attachments: { attachments: {
type: Array, type: Array,
default: [] default: [],
}, },
flags: { flags: {
type: Array, type: Array,
default: [] default: [],
}, },
reply_to: { reply_to: {
type: String, type: String,
default: null default: null,
}, },
updated_at: { updated_at: {
type: String, type: String,
default: null default: null,
}, },
poll_options: { poll_options: {
type: Array, type: Array,
default: null default: null,
} },
} visibility: {
} type: String,
default: "public",
},
},
}

View File

@ -1,19 +1,19 @@
export default class Posts { export default class Posts {
static timeline = require("./methods/timeline").default static timeline = require("./methods/timeline").default
static globalTimeline = require("./methods/globalTimeline").default static globalTimeline = require("./methods/globalTimeline").default
static data = require("./methods/data").default static data = require("./methods/data").default
static getLiked = require("./methods/getLiked").default static getLiked = require("./methods/getLiked").default
static getSaved = require("./methods/getSaved").default static getSaved = require("./methods/getSaved").default
static fromUserId = require("./methods/fromUserId").default static fromUserId = require("./methods/fromUserId").default
static create = require("./methods/create").default static create = require("./methods/create").default
static fullfillPost = require("./methods/fullfill").default static stage = require("./methods/stage").default
static toggleSave = require("./methods/toggleSave").default static toggleSave = require("./methods/toggleSave").default
static toggleLike = require("./methods/toggleLike").default static toggleLike = require("./methods/toggleLike").default
static report = require("./methods/report").default static report = require("./methods/report").default
static flag = require("./methods/flag").default static flag = require("./methods/flag").default
static delete = require("./methods/delete").default static delete = require("./methods/delete").default
static update = require("./methods/update").default static update = require("./methods/update").default
static replies = require("./methods/replies").default static replies = require("./methods/replies").default
static votePoll = require("./methods/votePoll").default static votePoll = require("./methods/votePoll").default
static deleteVotePoll = require("./methods/deletePollVote").default static deleteVotePoll = require("./methods/deletePollVote").default
} }

View File

@ -2,73 +2,113 @@ import requiredFields from "@shared-utils/requiredFields"
import { DateTime } from "luxon" import { DateTime } from "luxon"
import { Post } from "@db_models" import { Post } from "@db_models"
import fullfill from "./fullfill" import stage from "./stage"
export default async (payload = {}) => { const visibilityOptions = ["public", "private", "only_mutuals"]
await requiredFields(["user_id"], payload)
let { user_id, message, attachments, timestamp, reply_to, poll_options } = payload export default async (payload = {}, req) => {
await requiredFields(["user_id"], payload)
// check if is a Array and have at least one element let {
const isAttachmentArray = Array.isArray(attachments) && attachments.length > 0 user_id,
message,
attachments,
timestamp,
reply_to,
poll_options,
visibility = "public",
} = payload
if (!isAttachmentArray && !message) { // check if visibility is valid
throw new OperationError(400, "Cannot create a post without message or attachments") if (!visibilityOptions.includes(visibility)) {
} throw new OperationError(400, "Invalid visibility option")
}
if (isAttachmentArray) { // check if is a Array and have at least one element
// clean empty attachments const isAttachmentArray =
attachments = attachments.filter((attachment) => attachment) Array.isArray(attachments) && attachments.length > 0
// fix attachments with url strings if needed if (!isAttachmentArray && !message) {
attachments = attachments.map((attachment) => { throw new OperationError(
if (typeof attachment === "string") { 400,
attachment = { "Cannot create a post without message or attachments",
url: attachment, )
} }
}
return attachment if (isAttachmentArray) {
}) // clean empty attachments
} attachments = attachments.filter((attachment) => attachment)
if (!timestamp) { // fix attachments with url strings if needed
timestamp = DateTime.local().toISO() attachments = attachments.map((attachment) => {
} else { if (typeof attachment === "string") {
timestamp = DateTime.fromISO(timestamp).toISO() attachment = {
} url: attachment,
}
}
if (Array.isArray(poll_options)) { return attachment
poll_options = poll_options.map((option) => { })
if (!option.id) { }
option.id = nanoid()
}
return option if (!timestamp) {
}) timestamp = DateTime.local().toISO()
} } else {
timestamp = DateTime.fromISO(timestamp).toISO()
}
let post = new Post({ if (Array.isArray(poll_options)) {
created_at: timestamp, poll_options = poll_options.map((option) => {
user_id: typeof user_id === "object" ? user_id.toString() : user_id, if (!option.id) {
message: message, option.id = nanoid()
attachments: attachments ?? [], }
reply_to: reply_to,
flags: [],
poll_options: poll_options,
})
await post.save() return option
})
}
post = post.toObject() let post = new Post({
created_at: timestamp,
user_id: typeof user_id === "object" ? user_id.toString() : user_id,
message: message,
attachments: attachments ?? [],
reply_to: reply_to,
flags: [],
poll_options: poll_options,
visibility: visibility.toLocaleLowerCase(),
})
const result = await fullfill({ await post.save()
posts: post,
for_user_id: user_id
})
// TODO: create background jobs (nsfw dectection) post = post.toObject()
global.websocket.io.of("/").emit(`post.new`, result[0])
return post const result = await stage({
} posts: post,
for_user_id: user_id,
})
// broadcast post to all users
if (visibility === "public") {
global.websocket.io
.to("global:posts:realtime")
.emit(`post.new`, result[0])
}
if (visibility === "private") {
const userSocket = await global.websocket.find.socketByUserId(
post.user_id,
)
if (userSocket) {
userSocket.emit(`post.new`, result[0])
}
}
// TODO: create background jobs (nsfw dectection)
global.queues.createJob("classify_post_attachments", {
post_id: post._id.toString(),
auth_token: req.headers.authorization,
})
return post
}

View File

@ -1,54 +1,54 @@
import { Post } from "@db_models" import { Post } from "@db_models"
import fullfillPostsData from "./fullfill" import stage from "./stage"
const maxLimit = 300 const maxLimit = 300
export default async (payload = {}) => { export default async (payload = {}) => {
let { let {
for_user_id, for_user_id,
post_id, post_id,
query = {}, query = {},
trim = 0, trim = 0,
limit = 20, limit = 20,
sort = { created_at: -1 }, sort = { created_at: -1 },
} = payload } = payload
// set a hard limit on the number of posts to retrieve, used for pagination // set a hard limit on the number of posts to retrieve, used for pagination
if (limit > maxLimit) { if (limit > maxLimit) {
limit = maxLimit limit = maxLimit
} }
let posts = [] let posts = []
if (post_id) { if (post_id) {
try { try {
const post = await Post.findById(post_id) const post = await Post.findById(post_id)
posts = [post] posts = [post]
} catch (error) { } catch (error) {
throw new OperationError(404, "Post not found") throw new OperationError(404, "Post not found")
} }
} else { } else {
posts = await Post.find({ ...query }) posts = await Post.find({ ...query })
.sort(sort) .sort(sort)
.skip(trim) .skip(trim)
.limit(limit) .limit(limit)
} }
// fullfill data // fullfill data
posts = await fullfillPostsData({ posts = await stage({
posts, posts,
for_user_id, for_user_id,
}) })
// if post_id is specified, return only one post // if post_id is specified, return only one post
if (post_id) { if (post_id) {
if (posts.length === 0) { if (posts.length === 0) {
throw new OperationError(404, "Post not found") throw new OperationError(404, "Post not found")
} }
return posts[0] return posts[0]
} }
return posts return posts
} }

View File

@ -1,45 +1,65 @@
import { Post, PostLike, PostSave } from "@db_models" import { Post, PostLike, PostSave } from "@db_models"
export default async (payload = {}) => { export default async (payload = {}) => {
let { let { post_id } = payload
post_id
} = payload
if (!post_id) { if (!post_id) {
throw new OperationError(400, "Missing post_id") throw new OperationError(400, "Missing post_id")
} }
await Post.deleteOne({ const post = await Post.findById(post_id)
_id: post_id,
}).catch((err) => {
throw new OperationError(500, `An error has occurred: ${err.message}`)
})
// search for likes if (!post) {
await PostLike.deleteMany({ throw new OperationError(404, "Post not found")
post_id: post_id, }
}).catch((err) => {
throw new OperationError(500, `An error has occurred: ${err.message}`)
})
// deleted from saved await Post.deleteOne({
await PostSave.deleteMany({ _id: post_id,
post_id: post_id, }).catch((err) => {
}).catch((err) => { throw new OperationError(500, `An error has occurred: ${err.message}`)
throw new OperationError(500, `An error has occurred: ${err.message}`) })
})
// delete replies // search for likes
await Post.deleteMany({ await PostLike.deleteMany({
reply_to: post_id, post_id: post_id,
}).catch((err) => { }).catch((err) => {
throw new OperationError(500, `An error has occurred: ${err.message}`) throw new OperationError(500, `An error has occurred: ${err.message}`)
}) })
global.websocket.io.of("/").emit(`post.delete`, post_id) // deleted from saved
global.websocket.io.of("/").emit(`post.delete.${post_id}`, post_id) await PostSave.deleteMany({
post_id: post_id,
}).catch((err) => {
throw new OperationError(500, `An error has occurred: ${err.message}`)
})
return { // delete replies
deleted: true, await Post.deleteMany({
} reply_to: post_id,
} }).catch((err) => {
throw new OperationError(500, `An error has occurred: ${err.message}`)
})
if (post.visibility === "public") {
global.websocket.io
.to("global:posts:realtime")
.emit(`post.delete`, post)
global.websocket.io
.to("global:posts:realtime")
.emit(`post.delete.${post_id}`, post)
}
if (post.visibility === "private") {
const userSocket = await global.websocket.find.socketByUserId(
post.user_id,
)
if (userSocket) {
userSocket.emit(`post.delete`, post_id)
userSocket.emit(`post.delete.${post_id}`, post_id)
}
}
return {
deleted: true,
}
}

View File

@ -1,140 +0,0 @@
import { User, PostLike, PostSave, Post, VotePoll } from "@db_models"
export default async (payload = {}) => {
let {
posts,
for_user_id,
} = payload
if (!Array.isArray(posts)) {
posts = [posts]
}
if (posts.every((post) => !post)) {
return []
}
let postsSavesIds = []
if (for_user_id) {
const postsSaves = await PostSave.find({ user_id: for_user_id })
.sort({ saved_at: -1 })
postsSavesIds = postsSaves.map((postSave) => postSave.post_id)
}
const postsIds = posts.map((post) => post._id)
const usersIds = posts.map((post) => post.user_id)
let [usersData, likesData, pollVotes] = await Promise.all([
User.find({
_id: {
$in: usersIds
}
}).catch(() => { }),
PostLike.find({
post_id: {
$in: postsIds
}
}).catch(() => []),
VotePoll.find({
post_id: {
$in: postsIds
}
}).catch(() => [])
])
// wrap likesData by post_id
likesData = likesData.reduce((acc, like) => {
if (!acc[like.post_id]) {
acc[like.post_id] = []
}
acc[like.post_id].push(like)
return acc
}, {})
posts = await Promise.all(posts.map(async (post, index) => {
if (typeof post.toObject === "function") {
post = post.toObject()
}
let user = usersData.find((user) => user._id.toString() === post.user_id.toString())
if (!user) {
user = {
_deleted: true,
username: "Deleted user",
}
}
if (post.reply_to) {
post.reply_to_data = await Post.findById(post.reply_to)
if (post.reply_to_data) {
post.reply_to_data = post.reply_to_data.toObject()
const replyUserData = await User.findById(post.reply_to_data.user_id)
if (replyUserData) {
post.reply_to_data.user = replyUserData.toObject()
}
}
}
post.hasReplies = await Post.countDocuments({ reply_to: post._id })
let likes = likesData[post._id.toString()] ?? []
post.countLikes = likes.length
const postPollVotes = pollVotes.filter((vote) => {
if (vote.post_id !== post._id.toString()) {
return false
}
return true
})
if (for_user_id) {
post.isLiked = likes.some((like) => like.user_id.toString() === for_user_id)
post.isSaved = postsSavesIds.includes(post._id.toString())
if (Array.isArray(post.poll_options)) {
post.poll_options = post.poll_options.map((option) => {
option.voted = !!postPollVotes.find((vote) => {
if (vote.user_id !== for_user_id) {
return false
}
if (vote.option_id !== option.id) {
return false
}
return true
})
option.count = postPollVotes.filter((vote) => {
if (vote.option_id !== option.id) {
return false
}
return true
}).length
return option
})
}
}
post.share_url = `${process.env.APP_URL}/post/${post._id}`
return {
...post,
user,
}
}))
return posts
}

View File

@ -1,29 +1,24 @@
import { Post } from "@db_models" import { Post } from "@db_models"
import fullfillPostsData from "./fullfill" import stage from "./stage"
export default async (payload = {}) => { export default async (payload = {}) => {
const { const { post_id, for_user_id, trim = 0, limit = 50 } = payload
post_id,
for_user_id,
trim = 0,
limit = 50,
} = payload
if (!post_id) { if (!post_id) {
throw new OperationError(400, "Post ID is required") throw new OperationError(400, "Post ID is required")
} }
let posts = await Post.find({ let posts = await Post.find({
reply_to: post_id, reply_to: post_id,
}) })
.limit(limit) .limit(limit)
.skip(trim) .skip(trim)
.sort({ created_at: -1 }) .sort({ created_at: -1 })
posts = await fullfillPostsData({ posts = await stage({
posts, posts,
for_user_id, for_user_id,
}) })
return posts return posts
} }

View File

@ -0,0 +1,161 @@
import { User, PostLike, PostSave, Post, VotePoll } from "@db_models"
export default async (payload = {}) => {
let { posts, for_user_id } = payload
if (!Array.isArray(posts)) {
posts = [posts]
}
if (posts.every((post) => !post)) {
return []
}
let postsSavesIds = []
if (for_user_id) {
const postsSaves = await PostSave.find({ user_id: for_user_id }).sort({
saved_at: -1,
})
postsSavesIds = postsSaves.map((postSave) => postSave.post_id)
}
const postsIds = posts.map((post) => post._id)
const usersIds = posts.map((post) => post.user_id)
let [usersData, likesData, pollVotes] = await Promise.all([
User.find({
_id: {
$in: usersIds,
},
}).catch(() => {}),
PostLike.find({
post_id: {
$in: postsIds,
},
}).catch(() => []),
VotePoll.find({
post_id: {
$in: postsIds,
},
}).catch(() => []),
])
// wrap likesData by post_id
likesData = likesData.reduce((acc, like) => {
if (!acc[like.post_id]) {
acc[like.post_id] = []
}
acc[like.post_id].push(like)
return acc
}, {})
posts = await Promise.all(
posts.map(async (post, index) => {
if (typeof post.toObject === "function") {
post = post.toObject()
}
if (post.visibility === "private" && post.user_id !== for_user_id) {
return null
}
if (
post.visibility === "only_mutuals" &&
post.user_id !== for_user_id
) {
// TODO
return null
}
let user = usersData.find(
(user) => user._id.toString() === post.user_id.toString(),
)
if (!user) {
user = {
_deleted: true,
username: "Deleted user",
}
}
if (post.reply_to) {
post.reply_to_data = await Post.findById(post.reply_to)
if (post.reply_to_data) {
post.reply_to_data = post.reply_to_data.toObject()
const replyUserData = await User.findById(
post.reply_to_data.user_id,
)
if (replyUserData) {
post.reply_to_data.user = replyUserData.toObject()
}
}
}
post.hasReplies = await Post.countDocuments({ reply_to: post._id })
let likes = likesData[post._id.toString()] ?? []
post.countLikes = likes.length
const postPollVotes = pollVotes.filter((vote) => {
if (vote.post_id !== post._id.toString()) {
return false
}
return true
})
if (for_user_id) {
post.isLiked = likes.some(
(like) => like.user_id.toString() === for_user_id,
)
post.isSaved = postsSavesIds.includes(post._id.toString())
if (Array.isArray(post.poll_options)) {
post.poll_options = post.poll_options.map((option) => {
option.voted = !!postPollVotes.find((vote) => {
if (vote.user_id !== for_user_id) {
return false
}
if (vote.option_id !== option.id) {
return false
}
return true
})
option.count = postPollVotes.filter((vote) => {
if (vote.option_id !== option.id) {
return false
}
return true
}).length
return option
})
}
}
post.share_url = `${process.env.APP_URL}/post/${post._id}`
return {
...post,
user,
}
}),
)
// clear undefined and null
posts = posts.filter((post) => post !== undefined && post !== null)
return posts
}

View File

@ -1,42 +1,60 @@
import { Post } from "@db_models" import { Post } from "@db_models"
import { DateTime } from "luxon" import { DateTime } from "luxon"
import fullfill from "./fullfill" import stage from "./stage"
export default async (post_id, update) => { export default async (post_id, update) => {
let post = await Post.findById(post_id) let post = await Post.findById(post_id)
if (!post) { if (!post) {
throw new OperationError(404, "Post not found") throw new OperationError(404, "Post not found")
} }
const updateKeys = Object.keys(update) const updateKeys = Object.keys(update)
updateKeys.forEach((key) => { updateKeys.forEach((key) => {
post[key] = update[key] post[key] = update[key]
}) })
post.updated_at = DateTime.local().toISO() post.updated_at = DateTime.local().toISO()
if (Array.isArray(update.poll_options)) { if (Array.isArray(update.poll_options)) {
post.poll_options = update.poll_options.map((option) => { post.poll_options = update.poll_options.map((option) => {
if (!option.id) { if (!option.id) {
option.id = nanoid() option.id = nanoid()
} }
return option return option
}) })
} }
await post.save() await post.save()
post = post.toObject() post = post.toObject()
const result = await fullfill({ const result = await stage({
posts: post, posts: post,
}) for_user_id: post.user_id,
})
global.websocket.io.of("/").emit(`post.update`, result[0]) if (post.visibility === "public") {
global.websocket.io.of("/").emit(`post.update.${post_id}`, result[0]) global.websocket.io
.to("global:posts:realtime")
.emit(`post.update`, result[0])
global.websocket.io
.to("global:posts:realtime")
.emit(`post.update.${post_id}`, result[0])
}
return result[0] if (post.visibility === "private") {
} const userSocket = await global.websocket.find.socketByUserId(
post.user_id,
)
if (userSocket) {
userSocket.emit(`post.update`, result[0])
userSocket.emit(`post.update.${post_id}`, result[0])
}
}
return result[0]
}

View File

@ -2,30 +2,72 @@ import { Server } from "linebridge"
import DbManager from "@shared-classes/DbManager" import DbManager from "@shared-classes/DbManager"
import RedisClient from "@shared-classes/RedisClient" import RedisClient from "@shared-classes/RedisClient"
import TaskQueueManager from "@shared-classes/TaskQueueManager"
import SharedMiddlewares from "@shared-middlewares" import SharedMiddlewares from "@shared-middlewares"
export default class API extends Server { // wsfast
static refName = "posts" import HyperExpress from "hyper-express"
static enableWebsockets = true
static routesPath = `${__dirname}/routes`
static listen_port = process.env.HTTP_LISTEN_PORT ?? 3001
middlewares = { class WSFastServer {
...SharedMiddlewares router = new HyperExpress.Router()
}
contexts = { clients = new Set()
db: new DbManager(),
redis: RedisClient(),
}
async onInitialize() { routes = {
await this.contexts.db.initialize() connect: async (socket) => {
await this.contexts.redis.initialize() console.log("Client connected", socket)
} },
}
handleWsAuth = require("@shared-lib/handleWsAuth").default async initialize(engine) {
this.engine = engine
Object.keys(this.routes).forEach((route) => {
this.router.ws(`/${route}`, this.routes[route])
})
this.engine.app.use(this.router)
}
} }
Boot(API) export default class API extends Server {
static refName = "posts"
static enableWebsockets = true
static routesPath = `${__dirname}/routes`
static wsRoutesPath = `${__dirname}/routes_ws`
static listen_port = process.env.HTTP_LISTEN_PORT ?? 3001
middlewares = {
...SharedMiddlewares,
}
contexts = {
db: new DbManager(),
redis: RedisClient(),
ws: new WSFastServer(this.engine),
}
queuesManager = new TaskQueueManager(
{
workersPath: `${__dirname}/queues`,
},
this,
)
async onInitialize() {
await this.contexts.db.initialize()
await this.contexts.redis.initialize()
await this.queuesManager.initialize({
redisOptions: this.engine.ws.redis.options,
})
await this.contexts.ws.initialize(this.engine)
global.queues = this.queuesManager
}
handleWsAuth = require("@shared-lib/handleWsAuth").default
}
Boot(API)

View File

@ -0,0 +1,67 @@
import { Post } from "@db_models"
import axios from "axios"
const classifyAPI = "https://vision-service.ragestudio.net"
const adultLevels = [
"VERY_UNLIKELY",
"UNLIKELY",
"POSSIBLE",
"LIKELY",
"VERY_LIKELY",
]
export default {
id: "classify_post_attachments",
maxJobs: 100,
process: async (job) => {
const { post_id, auth_token } = job.data
let post = await Post.findById(post_id).lean()
console.log(`[CLASSIFY] Checking post ${post_id}`)
if (!post) {
return false
}
if (!Array.isArray(post.attachments)) {
return false
}
for await (const attachment of post.attachments) {
if (!attachment.url) {
continue
}
const response = await axios({
method: "GET",
url: `${classifyAPI}/safe_detect`,
headers: {
Authorization: auth_token,
},
params: {
url: attachment.url,
},
})
console.log(
`[CLASSIFY] Attachment [${attachment.url}] classified as ${response.data.detections.adult}`,
)
const adultLevel = adultLevels.indexOf(
response.data.detections.adult,
)
if (!Array.isArray(attachment.flags)) {
attachment.flags = []
}
if (adultLevel > 2) {
attachment.flags.push("nsfw")
}
}
await Post.findByIdAndUpdate(post._id, post)
},
}

View File

@ -1,44 +1,56 @@
import PostClass from "@classes/posts" import PostClass from "@classes/posts"
import { Post } from "@db_models" import { Post } from "@db_models"
const AllowedFields = ["message", "tags", "attachments", "poll_options"] const AllowedFields = [
"message",
"tags",
"attachments",
"poll_options",
"visibility",
]
// TODO: Get limits from LimitsAPI // TODO: Get limits from LimitsAPI
const MaxStringsLengths = { const MaxStringsLengths = {
message: 2000 message: 2000,
} }
export default { export default {
middlewares: ["withAuthentication"], middlewares: ["withAuthentication"],
fn: async (req) => { fn: async (req) => {
let update = {} let update = {}
const post = await Post.findById(req.params.post_id) const post = await Post.findById(req.params.post_id)
if (!post) { if (!post) {
throw new OperationError(404, "Post not found") throw new OperationError(404, "Post not found")
} }
if (post.user_id !== req.auth.session.user_id) { if (post.user_id !== req.auth.session.user_id) {
throw new OperationError(403, "You cannot edit this post") throw new OperationError(403, "You cannot edit this post")
} }
AllowedFields.forEach((key) => { AllowedFields.forEach((key) => {
if (typeof req.body[key] !== "undefined") { if (typeof req.body[key] !== "undefined") {
// check maximung strings length // check maximung strings length
if (typeof req.body[key] === "string" && MaxStringsLengths[key]) { if (
if (req.body[key].length > MaxStringsLengths[key]) { typeof req.body[key] === "string" &&
// create a substring MaxStringsLengths[key]
update[key] = req.body[key].substring(0, MaxStringsLengths[key]) ) {
} else { if (req.body[key].length > MaxStringsLengths[key]) {
update[key] = req.body[key] // create a substring
} update[key] = req.body[key].substring(
} else { 0,
update[key] = req.body[key] MaxStringsLengths[key],
} )
} } else {
}) update[key] = req.body[key]
}
} else {
update[key] = req.body[key]
}
}
})
return await PostClass.update(req.params.post_id, update) return await PostClass.update(req.params.post_id, update)
} },
} }

View File

@ -1,13 +1,16 @@
import Posts from "@classes/posts" import Posts from "@classes/posts"
export default { export default {
middlewares: ["withAuthentication"], middlewares: ["withAuthentication"],
fn: async (req, res) => { fn: async (req, res) => {
const result = await Posts.create({ const result = await Posts.create(
...req.body, {
user_id: req.auth.session.user_id, ...req.body,
}) user_id: req.auth.session.user_id,
},
req,
)
return result return result
} },
} }

View File

@ -1,25 +1,25 @@
import { Post } from "@db_models" import { Post } from "@db_models"
import fullfill from "@classes/posts/methods/fullfill" import stage from "@classes/posts/methods/stage"
export default { export default {
middlewares: ["withOptionalAuthentication"], middlewares: ["withOptionalAuthentication"],
fn: async (req) => { fn: async (req) => {
const { limit, trim } = req.query const { limit, trim } = req.query
let result = await Post.find({ let result = await Post.find({
message: { message: {
$regex: new RegExp(`#${req.params.trending}`, "gi") $regex: new RegExp(`#${req.params.trending}`, "gi"),
} },
}) })
.sort({ created_at: -1 }) .sort({ created_at: -1 })
.skip(trim ?? 0) .skip(trim ?? 0)
.limit(limit ?? 20) .limit(limit ?? 20)
result = await fullfill({ result = await stage({
posts: result, posts: result,
for_user_id: req.auth.session.user_id, for_user_id: req.auth.session.user_id,
}) })
return result return result
} },
} }

View File

@ -0,0 +1,4 @@
export default async function (socket) {
console.log(`Socket ${socket.id} connected to realtime posts`)
socket.join("global:posts:realtime")
}

View File

@ -0,0 +1,4 @@
export default async function (socket) {
console.log(`Socket ${socket.id} disconnected from realtime posts`)
socket.leave("global:posts:realtime")
}