diff --git a/api-ports.md b/api-ports.md index b8bfce21..6743d4aa 100644 --- a/api-ports.md +++ b/api-ports.md @@ -9,7 +9,7 @@ 3006 -> sync 3007 -> ems (External Messaging Service) 3008 -> users -3009 -> unallocated +3009 -> notifications 3010 -> unallocated 3011 -> unallocated 3012 -> unallocated diff --git a/packages/app/.config.js b/packages/app/.config.js index 42e16caf..987d1efc 100755 --- a/packages/app/.config.js +++ b/packages/app/.config.js @@ -1,5 +1,4 @@ const path = require("path") -const { builtinModules } = require("module") const aliases = { "node:buffer": "buffer", @@ -19,7 +18,7 @@ const aliases = { hooks: path.join(__dirname, "src/hooks"), classes: path.join(__dirname, "src/classes"), "comty.js": path.join(__dirname, "../../", "comty.js", "src"), - models: path.join(__dirname, "../comty.js/src/models"), + models: path.join(__dirname, "../../", "comty.js/src/models"), } module.exports = (config = {}) => { @@ -30,11 +29,6 @@ module.exports = (config = {}) => { config.server = {} } - // config.define = { - // "global.Uint8Array": "Uint8Array", - // "process.env.NODE_DEBUG": false, - // } - config.resolve.alias = aliases config.server.port = process.env.listenPort ?? 8000 config.server.host = "0.0.0.0" @@ -56,25 +50,5 @@ module.exports = (config = {}) => { target: "esnext" } - // config.build = { - // sourcemap: "inline", - // target: `node16`, - // outDir: "dist", - // assetsDir: ".", - // minify: process.env.MODE !== "development", - // rollupOptions: { - // external: [ - // "electron", - // "electron-devtools-installer", - // ...builtinModules.flatMap(p => [p, `node:16`]), - // ], - // output: { - // entryFileNames: "[name].js", - // }, - // }, - // emptyOutDir: true, - // brotliSize: false, - // } - return config } \ No newline at end of file diff --git a/packages/app/constants/defaultSettings.json b/packages/app/constants/defaultSettings.json index 17967301..cda55504 100755 --- a/packages/app/constants/defaultSettings.json +++ b/packages/app/constants/defaultSettings.json @@ -1,4 +1,6 @@ { + "low_performance_mode": false, + "transcode_video_browser": false, "forceMobileMode": false, "ui.effects": true, "ui.general_volume": 50, diff --git a/packages/app/constants/routes.js b/packages/app/constants/routes.js index 615fc273..6e223bfa 100755 --- a/packages/app/constants/routes.js +++ b/packages/app/constants/routes.js @@ -82,6 +82,12 @@ export default [ useLayout: "minimal", public: true }, + { + path: "/marketplace/*", + useLayout: "default", + centeredContent: true, + extendedContent: true, + }, // THIS MUST BE THE LAST ROUTE { path: "/", diff --git a/packages/app/constants/settings/about/index.jsx b/packages/app/constants/settings/about/index.jsx index 0893ab9f..95d4db65 100755 --- a/packages/app/constants/settings/about/index.jsx +++ b/packages/app/constants/settings/about/index.jsx @@ -7,6 +7,8 @@ import { Icons } from "components/Icons" import config from "config" +import LatencyIndicator from "components/PerformanceIndicators/latency" + import "./index.less" const connectionsTooltipStrings = { @@ -48,9 +50,7 @@ export default { const [serverManifest, setServerManifest] = React.useState(null) const [serverOrigin, setServerOrigin] = React.useState(null) - const [serverHealth, setServerHealth] = React.useState(null) const [secureConnection, setSecureConnection] = React.useState(false) - const [connectionPing, setConnectionPing] = React.useState({}) const [capInfo, setCapInfo] = React.useState(null) const setCapacitorInfo = async () => { @@ -68,7 +68,7 @@ export default { } const checkServerOrigin = async () => { - const instance = app.cores.api.instance() + const instance = app.cores.api.client() if (instance) { setServerOrigin(instance.mainOrigin) @@ -79,29 +79,11 @@ export default { } } - const measurePing = async () => { - const result = await app.cores.api.measurePing() - - console.log(result) - - setConnectionPing(result) - } - React.useEffect(() => { checkServerVersion() checkServerOrigin() - measurePing() - setCapacitorInfo() - - const measureInterval = setInterval(() => { - measurePing() - }, 3000) - - return () => { - clearInterval(measureInterval) - } }, []) return
@@ -172,33 +154,13 @@ export default { width: "100%", }} > -
- - - {connectionPing?.http}ms - -
+ -
- - - {connectionPing?.ws}ms - -
+
diff --git a/packages/app/constants/settings/components/sessionItem/index.jsx b/packages/app/constants/settings/components/sessionItem/index.jsx index 6d719daa..fa36083c 100755 --- a/packages/app/constants/settings/components/sessionItem/index.jsx +++ b/packages/app/constants/settings/components/sessionItem/index.jsx @@ -62,8 +62,6 @@ const SessionItem = (props) => { return UAParser(session.client) }) - console.log(session, ua) - return
{ return null }) - console.log(response) - if (response) { setSessions(response) } @@ -72,7 +70,6 @@ export default () => { return `${total} Sessions` }} simple - />
diff --git a/packages/app/constants/settings/general/index.jsx b/packages/app/constants/settings/general/index.jsx index a564c3dc..cb6dbc06 100755 --- a/packages/app/constants/settings/general/index.jsx +++ b/packages/app/constants/settings/general/index.jsx @@ -72,13 +72,16 @@ export default { }, { id: "low_performance_mode", - storaged: true, group: "general", component: "Switch", icon: "MdSlowMotionVideo", title: "Low performance mode", description: "Enable low performance mode to reduce the memory usage and improve the performance in low-end devices. This will disable some animations and other decorative features.", emitEvent: "app.lowPerformanceMode", + props: { + disabled: true + }, + storaged: true, experimental: true, disabled: true, }, @@ -183,6 +186,23 @@ export default { storaged: true, mobile: false, }, + { + id: "transcode_video_browser", + group: "posts", + component: "Switch", + icon: "MdVideoCameraFront", + title: "Transcode video in browser", + description: "Transcode videos from the application instead of on the servers. This feature may speed up the posting process depending on your computer. This will consume your computer resources.", + dependsOn: { + "low_performance_mode": false, + }, + props: { + disabled: true, + }, + experimental: true, + storaged: true, + disabled: true, + }, { id: "feed_max_fetch", title: "Fetch max items", diff --git a/packages/app/constants/settings/profile/index.jsx b/packages/app/constants/settings/profile/index.jsx index e692efac..8024ae5a 100755 --- a/packages/app/constants/settings/profile/index.jsx +++ b/packages/app/constants/settings/profile/index.jsx @@ -28,28 +28,28 @@ export default { }, }, { - id: "fullName", + id: "public_name", group: "account.basicInfo", component: "Input", icon: "Edit3", title: "Name", description: "Change your public name", props: { - // set max length - "maxLength": 120, - "showCount": true, - "allowClear": true, - "placeholder": "Enter your name. e.g. John Doe", + maxLength: 120, + showCount: true, + allowClear: true, + placeholder: "Enter your name. e.g. John Doe", }, defaultValue: (ctx) => { - return ctx.userData.fullName + return ctx.userData.public_name }, onUpdate: async (value) => { const result = await UserModel.updateData({ - fullName: value + public_name: value }) if (result) { + app.message.success("Public name updated") return value } }, @@ -67,22 +67,22 @@ export default { storaged: false, }, { - "id": "email", - "group": "account.basicInfo", - "component": "Input", - "icon": "Mail", - "title": "Email", - "description": "Change your email address", - "props": { - "placeholder": "Enter your email address", - "allowClear": true, - "showCount": true, - "maxLength": 320, + id: "email", + group: "account.basicInfo", + component: "Input", + icon: "Mail", + title: "Email", + description: "Change your email address", + props: { + placeholder: "Enter your email address", + allowClear: true, + showCount: true, + maxLength: 320, }, - "defaultValue": (ctx) => { + defaultValue: (ctx) => { return ctx.userData.email }, - "onUpdate": async (value) => { + onUpdate: async (value) => { const result = await UserModel.updateData({ email: value }) @@ -91,22 +91,22 @@ export default { return value } }, - "debounced": true, + debounced: true, }, { - "id": "avatar", - "group": "account.profile", - "icon": "Image", - "title": "Avatar", - "description": "Change your avatar (Upload an image or use an URL)", - "component": loadable(() => import("../components/urlInput")), + id: "avatar", + group: "account.profile", + icon: "Image", + title: "Avatar", + description: "Change your avatar (Upload an image or use an URL)", + component: loadable(() => import("../components/urlInput")), extraActions: [ UploadButton ], - "defaultValue": (ctx) => { + defaultValue: (ctx) => { return ctx.userData.avatar }, - "onUpdate": async (value) => { + onUpdate: async (value) => { const result = await UserModel.updateData({ avatar: value }) @@ -118,19 +118,19 @@ export default { }, }, { - "id": "cover", - "group": "account.profile", - "icon": "Image", - "title": "Cover", - "description": "Change your profile cover (Upload an image or use an URL)", - "component": loadable(() => import("../components/urlInput")), + id: "cover", + group: "account.profile", + icon: "Image", + title: "Cover", + description: "Change your profile cover (Upload an image or use an URL)", + component: loadable(() => import("../components/urlInput")), extraActions: [ UploadButton ], - "defaultValue": (ctx) => { + defaultValue: (ctx) => { return ctx.userData.cover }, - "onUpdate": async (value) => { + onUpdate: async (value) => { const result = await UserModel.updateData({ cover: value }) @@ -142,22 +142,22 @@ export default { }, }, { - "id": "description", - "group": "account.profile", - "component": "TextArea", - "icon": "Edit3", - "title": "Description", - "description": "Change your description for your profile", - "props": { - "placeholder": "Enter here a description for your profile", - "maxLength": 320, - "showCount": true, - "allowClear": true + id: "description", + group: "account.profile", + component: "TextArea", + icon: "Edit3", + title: "Description", + description: "Change your description for your profile", + props: { + placeholder: "Enter here a description for your profile", + maxLength: 320, + showCount: true, + allowClear: true }, - "defaultValue": (ctx) => { + defaultValue: (ctx) => { return ctx.userData.description }, - "onUpdate": async (value) => { + onUpdate: async (value) => { const result = await UserModel.updateData({ description: value }) @@ -166,8 +166,7 @@ export default { return value } }, - "debounced": true, - storaged: false, + debounced: true, }, { id: "Links", @@ -194,7 +193,6 @@ export default { return ctx.userData.links ?? [] }, debounced: true, - storaged: false, } ] } \ No newline at end of file diff --git a/packages/app/constants/settings/security/index.jsx b/packages/app/constants/settings/security/index.jsx index 16c713e1..90d2024b 100755 --- a/packages/app/constants/settings/security/index.jsx +++ b/packages/app/constants/settings/security/index.jsx @@ -7,28 +7,31 @@ export default { group: "basic", settings: [ { - "id": "change-password", - "group": "security.account", - "title": "Change Password", - "description": "Change your password", - "icon": "Lock", - "component": loadable(() => import("../components/changePassword")), + id: "change-password", + group: "security.account", + title: "Change Password", + description: "Change your password", + icon: "Lock", + component: loadable(() => import("../components/changePassword")), }, { - "id": "two-factor-authentication", - "group": "security.account", - "title": "Two-Factor Authentication", - "description": "Add an extra layer of security to your account", - "icon": "MdOutlineSecurity", - "component": "Switch", + id: "auth:mfa", + group: "security.account", + title: "2-Factor Authentication", + description: "Use your email to validate logins to your account through a numerical code.", + icon: "IoMdKeypad", + component: "Switch", + defaultValue: (ctx) => { + return ctx.baseConfig["auth:mfa"] + } }, { - "id": "sessions", - "group": "security.account", - "title": "Sessions", - "description": "Manage your active sessions", - "icon": "Monitor", - "component": loadable(() => import("../components/sessions")), + id: "sessions", + group: "security.account", + title: "Sessions", + description: "Manage your active sessions", + icon: "Monitor", + component: loadable(() => import("../components/sessions")), } ] } \ No newline at end of file diff --git a/packages/app/constants/settings/tap_share/index.jsx b/packages/app/constants/settings/tap_share/index.jsx index b3423b21..6218fd33 100755 --- a/packages/app/constants/settings/tap_share/index.jsx +++ b/packages/app/constants/settings/tap_share/index.jsx @@ -228,6 +228,12 @@ class OwnTags extends React.Component { } + if (!this.state.data) { + return + } + return
{ this.state.data.length === 0 && ) }, - openPostCreator: () => { - app.layout.modal.open("post_creator", (props) => , { + openPostCreator: (params) => { + app.layout.modal.open("post_creator", (props) => , { framed: false }) } diff --git a/packages/app/src/components/ImageUploader/index.jsx b/packages/app/src/components/ImageUploader/index.jsx deleted file mode 100755 index b3a9e438..00000000 --- a/packages/app/src/components/ImageUploader/index.jsx +++ /dev/null @@ -1,90 +0,0 @@ -import React from "react" -import { Icons } from "components/Icons" -import * as antd from "antd" -import { getBase64 } from "utils" - -export default class ImageUploader extends React.Component { - state = { - previewVisible: false, - previewImage: "", - previewTitle: "", - fileList: [], - urlList: [], - } - - api = window.app.cores.api.withEndpoints() - - handleChange = ({ fileList }) => { - this.setState({ fileList }) - - if (typeof this.props.onChange === "function") { - this.props.onChange(fileList) - } - } - - handleCancel = () => this.setState({ previewVisible: false }) - - handlePreview = async file => { - if (!file.url && !file.preview) { - file.preview = await getBase64(file.originFileObj) - } - - this.setState({ - previewImage: file.url || file.preview, - previewVisible: true, - previewTitle: file.name || file.url.substring(file.url.lastIndexOf("/") + 1), - }) - } - - handleUploadRequest = async (req) => { - if (typeof this.props.onUpload === "function") { - this.props.onUpload(req) - } else { - const payloadData = new FormData() - payloadData.append(req.file.name, req.file) - - const result = await this.api.post.upload(payloadData).catch(() => { - req.onError("Error uploading image") - return false - }) - - if (result) { - req.onSuccess() - await this.setState({ urlList: [...this.state.urlList, ...result.urls] }) - } - - if (typeof this.props.onUploadDone === "function") { - await this.props.onUploadDone(this.state.urlList) - } - - return result.urls - } - } - - render() { - const uploadButton = (
- -
Upload
-
) - - return
- - {this.state.fileList.length >= 8 ? null : uploadButton} - - - - -
- } -} \ No newline at end of file diff --git a/packages/app/src/components/LiveChat/index.jsx b/packages/app/src/components/LiveChat/index.jsx index ce8e0404..e9a2ba2f 100755 --- a/packages/app/src/components/LiveChat/index.jsx +++ b/packages/app/src/components/LiveChat/index.jsx @@ -42,7 +42,7 @@ export default class LiveChat extends React.Component { timelineRef = React.createRef() - socket = app.cores.api.instance().wsInstances.chat + socket = app.cores.api.instance().sockets.chat roomEvents = { "room:recive:message": (message) => { diff --git a/packages/app/src/components/LoadMore/index.jsx b/packages/app/src/components/LoadMore/index.jsx index 6209f74f..9a1cf9df 100755 --- a/packages/app/src/components/LoadMore/index.jsx +++ b/packages/app/src/components/LoadMore/index.jsx @@ -50,21 +50,14 @@ export default React.forwardRef((props, ref) => { > {children} -
+ -
{loadingComponent && React.createElement(loadingComponent)} -
- - {/*
- {noResultComponent ? React.createElement(noResultComponent) : "No more result"} -
*/} +
}) \ No newline at end of file diff --git a/packages/app/src/components/Login/index.jsx b/packages/app/src/components/Login/index.jsx index 31c26856..3d387a30 100755 --- a/packages/app/src/components/Login/index.jsx +++ b/packages/app/src/components/Login/index.jsx @@ -200,7 +200,8 @@ export default class Login extends React.Component { } this.setState({ - phase: to + phase: to, + mfa_required: null, }) } diff --git a/packages/app/src/components/PerformanceIndicators/latency/index.jsx b/packages/app/src/components/PerformanceIndicators/latency/index.jsx new file mode 100644 index 00000000..a219ea91 --- /dev/null +++ b/packages/app/src/components/PerformanceIndicators/latency/index.jsx @@ -0,0 +1,91 @@ +import React from "react" +import * as antd from "antd" + +import { createIconRender } from "components/Icons" + +import "./index.less" + +const latencyToColor = (latency, type) => { + switch (type) { + case "http": { + if (latency < 200) { + return "green" + } + if (latency < 500) { + return "orange" + } + return "red" + } + case "ws": { + if (latency < 80) { + return "green" + } + if (latency < 120) { + return "orange" + } + return "red" + } + } +} + +const TypesDecorator = { + http: { + label: "HTTP", + icon: "MdHttp", + }, + ws: { + label: "WS", + icon: "MdSettingsEthernet", + } +} + +const LatencyIndicator = (props) => { + const { type } = props + const [latencyMs, setLatencyMs] = React.useState("0") + + const decorator = TypesDecorator[type] + + if (!decorator) { + return null + } + + function calculateLatency() { + if (typeof props.calculateLatency === "function") { + return setLatencyMs(props.calculateLatency()) + } + + app.cores.api.measurePing({ + select: [type] + }).then((result) => { + setLatencyMs(result) + }) + } + + React.useEffect(() => { + calculateLatency() + + const interval = setInterval(() => { + calculateLatency() + }, props.interval ?? 3000) + + return () => clearInterval(interval) + }, []) + + return
+ { + decorator.icon && createIconRender(decorator.icon) + } + { + !decorator.icon && (decorator.label ?? "Latency") + } + + {latencyMs}ms + +
+} + +export default LatencyIndicator \ No newline at end of file diff --git a/packages/app/src/components/PerformanceIndicators/latency/index.less b/packages/app/src/components/PerformanceIndicators/latency/index.less new file mode 100644 index 00000000..1cdca10d --- /dev/null +++ b/packages/app/src/components/PerformanceIndicators/latency/index.less @@ -0,0 +1,13 @@ +.latencyIndicator { + display: inline-flex; + + flex-direction: row; + align-items: center; + justify-content: center; + + gap: 7px; + + svg { + margin: 0; + } +} \ No newline at end of file diff --git a/packages/app/src/components/PostCard/components/actions/index.jsx b/packages/app/src/components/PostCard/components/actions/index.jsx index 5a30a8dc..0d3b47ed 100755 --- a/packages/app/src/components/PostCard/components/actions/index.jsx +++ b/packages/app/src/components/PostCard/components/actions/index.jsx @@ -4,7 +4,7 @@ import { Icons } from "components/Icons" import SaveButton from "./saveButton" import LikeButton from "./likeButton" -import CommentsButton from "./commentsButton" +import RepliesButton from "./replyButton" import "./index.less" @@ -32,7 +32,7 @@ const MoreActionsItems = [ { key: "onClickRepost", label: <> - + Repost , }, @@ -61,7 +61,7 @@ export default (props) => { const { onClickLike, onClickSave, - onClickComments, + onClickReply, } = props.actions ?? {} const genItems = () => { @@ -95,10 +95,10 @@ export default (props) => { onClick={onClickSave} />
-
- +
diff --git a/packages/app/src/components/PostCard/components/actions/commentsButton/index.jsx b/packages/app/src/components/PostCard/components/actions/replyButton/index.jsx similarity index 65% rename from packages/app/src/components/PostCard/components/actions/commentsButton/index.jsx rename to packages/app/src/components/PostCard/components/actions/replyButton/index.jsx index 156bf4f7..50ec6597 100755 --- a/packages/app/src/components/PostCard/components/actions/commentsButton/index.jsx +++ b/packages/app/src/components/PostCard/components/actions/replyButton/index.jsx @@ -6,16 +6,16 @@ import "./index.less" export default (props) => { return
} \ No newline at end of file diff --git a/packages/app/src/components/PostCard/components/actions/commentsButton/index.less b/packages/app/src/components/PostCard/components/actions/replyButton/index.less similarity index 73% rename from packages/app/src/components/PostCard/components/actions/commentsButton/index.less rename to packages/app/src/components/PostCard/components/actions/replyButton/index.less index 74e182c8..ada660a5 100755 --- a/packages/app/src/components/PostCard/components/actions/commentsButton/index.less +++ b/packages/app/src/components/PostCard/components/actions/replyButton/index.less @@ -1,10 +1,10 @@ -.comments_button { +.reply_button { display: inline-flex; flex-direction: row; align-items: center; - .comments_count { + .replies_count { font-size: 0.8rem; } } \ No newline at end of file diff --git a/packages/app/src/components/PostCard/components/header/index.jsx b/packages/app/src/components/PostCard/components/header/index.jsx index a0d5e004..8644464f 100755 --- a/packages/app/src/components/PostCard/components/header/index.jsx +++ b/packages/app/src/components/PostCard/components/header/index.jsx @@ -1,13 +1,29 @@ import React from "react" import { DateTime } from "luxon" -import { Tag } from "antd" +import { Tag, Skeleton } from "antd" import { Image } from "components" import { Icons } from "components/Icons" +import PostLink from "components/PostLink" + +import PostService from "models/post" import "./index.less" -export default (props) => { +const PostReplieView = (props) => { + const { data } = props + + if (!data) { + return null + } + + return
+ @{data.user.username} + {data.message} +
+} + +const PostCardHeader = (props) => { const [timeAgo, setTimeAgo] = React.useState(0) const goToProfile = () => { @@ -17,7 +33,12 @@ export default (props) => { const updateTimeAgo = () => { let createdAt = props.postData.timestamp ?? props.postData.created_at ?? "" - const timeAgo = DateTime.fromISO(createdAt, { locale: app.cores.settings.get("language") }).toRelative() + const timeAgo = DateTime.fromISO( + createdAt, + { + locale: app.cores.settings.get("language") + } + ).toRelative() setTimeAgo(timeAgo) } @@ -34,22 +55,41 @@ export default (props) => { } }, []) - return
-
-
+ return
+ { + !props.disableReplyTag && props.postData.reply_to &&
+ + + + Replied to + + + +
+ } + +
+
Avatar
-
+ +

{ - props.postData.user?.fullName ?? `${props.postData.user?.username}` + props.postData.user?.public_name ?? `${props.postData.user?.username}` } + { props.postData.user?.verified && } + { props.postData.flags?.includes("nsfw") && { }

- + {timeAgo}
-} \ No newline at end of file +} + +export default PostCardHeader \ No newline at end of file diff --git a/packages/app/src/components/PostCard/components/header/index.less b/packages/app/src/components/PostCard/components/header/index.less index c52dd80f..b9cc748a 100755 --- a/packages/app/src/components/PostCard/components/header/index.less +++ b/packages/app/src/components/PostCard/components/header/index.less @@ -1,9 +1,26 @@ -.post_header { +.post-header { display: flex; - flex-direction: row; - justify-content: space-between; + flex-direction: column; - .user { + gap: 10px; + + .post-header-replied_to { + display: flex; + flex-direction: row; + + align-items: center; + + gap: 7px; + + svg { + color: var(--text-color); + margin: 0 !important; + } + + line-height: 1.5rem; + } + + .post-header-user { display: inline-flex; flex-direction: row; align-items: center; @@ -17,7 +34,7 @@ margin-left: 6px; } - .avatar { + .post-header-user-avatar { width: 40px; height: 40px; @@ -30,7 +47,7 @@ } } - .info { + .post-header-user-info { display: inline-flex; flex-direction: column; @@ -51,7 +68,7 @@ color: var(--background-color-contrast); } - .timeago { + .post-header-user-info-timeago { font-weight: 400; font-size: 0.7rem; diff --git a/packages/app/src/components/PostCard/index.jsx b/packages/app/src/components/PostCard/index.jsx index 873e90b0..bdf48a8c 100755 --- a/packages/app/src/components/PostCard/index.jsx +++ b/packages/app/src/components/PostCard/index.jsx @@ -1,11 +1,8 @@ import React from "react" -import * as antd from "antd" import classnames from "classnames" import Plyr from "plyr-react" - -import { CommentsCard } from "components" +import { motion } from "framer-motion" import { Icons } from "components/Icons" - import { processString } from "utils" import PostHeader from "./components/header" @@ -13,6 +10,7 @@ import PostActions from "./components/actions" import PostAttachments from "./components/attachments" import "./index.less" +import { Divider } from "antd" const messageRegexs = [ { @@ -43,11 +41,14 @@ const messageRegexs = [ export default class PostCard extends React.PureComponent { state = { + data: this.props.data, + countLikes: this.props.data.countLikes ?? 0, - countComments: this.props.data.countComments ?? 0, + countReplies: this.props.data.countComments ?? 0, hasLiked: this.props.data.isLiked ?? false, hasSaved: this.props.data.isSaved ?? false, + hasReplies: this.props.data.hasReplies ?? false, open: this.props.defaultOpened ?? false, @@ -55,13 +56,28 @@ export default class PostCard extends React.PureComponent { nsfwAccepted: false, } + handleDataUpdate = (data) => { + this.setState({ + data: data, + }) + } + + onDoubleClick = async () => { + if (typeof this.props.events.onDoubleClick !== "function") { + console.warn("onDoubleClick event is not a function") + return + } + + return await this.props.events.onDoubleClick(this.state.data) + } + onClickDelete = async () => { if (typeof this.props.events.onClickDelete !== "function") { console.warn("onClickDelete event is not a function") return } - return await this.props.events.onClickDelete(this.props.data) + return await this.props.events.onClickDelete(this.state.data) } onClickLike = async () => { @@ -70,7 +86,16 @@ export default class PostCard extends React.PureComponent { return } - return await this.props.events.onClickLike(this.props.data) + const actionResult = await this.props.events.onClickLike(this.state.data) + + if (actionResult) { + this.setState({ + hasLiked: actionResult.liked, + countLikes: actionResult.count, + }) + } + + return actionResult } onClickSave = async () => { @@ -79,7 +104,15 @@ export default class PostCard extends React.PureComponent { return } - return await this.props.events.onClickSave(this.props.data) + const actionResult = await this.props.events.onClickSave(this.state.data) + + if (actionResult) { + this.setState({ + hasSaved: actionResult.saved, + }) + } + + return actionResult } onClickEdit = async () => { @@ -88,57 +121,26 @@ export default class PostCard extends React.PureComponent { return } - return await this.props.events.onClickEdit(this.props.data) + return await this.props.events.onClickEdit(this.state.data) } - onDoubleClick = async () => { - this.handleOpen() - } - - onClickComments = async () => { - this.handleOpen() - } - - handleOpen = (to) => { - if (typeof to === "undefined") { - to = !this.state.open + onClickReply = async () => { + if (typeof this.props.events.onClickReply !== "function") { + console.warn("onClickReply event is not a function") + return } - if (typeof this.props.events?.ontoggleOpen === "function") { - this.props.events?.ontoggleOpen(to, this.props.data) - } - - this.setState({ - open: to, - }) - - //app.controls.openPostViewer(this.props.data) + return await this.props.events.onClickReply(this.state.data) } - onLikesUpdate = (data) => { - console.log("onLikesUpdate", data) - - if (data.to) { + componentDidUpdate = (prevProps) => { + if (prevProps.data !== this.props.data) { this.setState({ - countLikes: this.state.countLikes + 1, - }) - } else { - this.setState({ - countLikes: this.state.countLikes - 1, + data: this.props.data, }) } } - componentDidMount = async () => { - // first listen to post changes - app.cores.api.listenEvent(`post.${this.props.data._id}.likes.update`, this.onLikesUpdate) - } - - componentWillUnmount = () => { - // remove the listener - app.cores.api.unlistenEvent(`post.${this.props.data._id}.likes.update`, this.onLikesUpdate) - } - componentDidCatch = (error, info) => { console.error(error) @@ -153,12 +155,28 @@ export default class PostCard extends React.PureComponent {
} + componentDidMount = () => { + app.cores.api.listenEvent(`post.update.${this.state.data._id}`, this.handleDataUpdate, "posts") + } + + componentWillUnmount = () => { + app.cores.api.unlistenEvent(`post.update.${this.state.data._id}`, this.handleDataUpdate, "posts") + } + render() { - return
{ - processString(messageRegexs)(this.props.data.message ?? "") + processString(messageRegexs)(this.state.data.message ?? "") }
{ - !this.props.disableAttachments && this.props.data.attachments && this.props.data.attachments.length > 0 && 0 && }
- -
+ { + !this.props.disableHasReplies && this.state.hasReplies && <> + +

View replies

+ + } + + } } \ No newline at end of file diff --git a/packages/app/src/components/PostCard/index.less b/packages/app/src/components/PostCard/index.less index 9c65bcb2..9a1e77da 100755 --- a/packages/app/src/components/PostCard/index.less +++ b/packages/app/src/components/PostCard/index.less @@ -13,7 +13,7 @@ margin: auto; gap: 15px; - padding: 17px 17px 0px 17px; + padding: 17px 17px 10px 17px; background-color: var(--background-color-accent); @@ -22,8 +22,6 @@ color: rgba(var(--background-color-contrast)); - transition: all 0.2s ease-in-out; - h1, h2, h3, diff --git a/packages/app/src/components/PostCreator/index.jsx b/packages/app/src/components/PostCreator/index.jsx index cde28e8a..cff2f855 100755 --- a/packages/app/src/components/PostCreator/index.jsx +++ b/packages/app/src/components/PostCreator/index.jsx @@ -1,24 +1,21 @@ import React from "react" import * as antd from "antd" import classnames from "classnames" -import { DateTime } from "luxon" import humanSize from "@tsmx/human-readable" - +import PostLink from "components/PostLink" import { Icons } from "components/Icons" -import clipboardEventFileToFile from "utils/clipboardEventFileToFile" +import clipboardEventFileToFile from "utils/clipboardEventFileToFile" import PostModel from "models/post" import "./index.less" const DEFAULT_POST_POLICY = { maxMessageLength: 512, - acceptedMimeTypes: ["image/gif", "image/png", "image/jpeg", "image/bmp"], - maximumFileSize: 10 * 1024 * 1024, + acceptedMimeTypes: ["image/gif", "image/png", "image/jpeg", "image/bmp", "video/*"], maximunFilesPerRequest: 10 } -// TODO: Fix close window when post created export default class PostCreator extends React.Component { state = { @@ -92,15 +89,30 @@ export default class PostCreator extends React.Component { const payload = { message: postMessage, attachments: postAttachments, - timestamp: DateTime.local().toISO(), + //timestamp: DateTime.local().toISO(), } - const response = await PostModel.create(payload).catch(error => { - console.error(error) - antd.message.error(error) + let response = null - return false - }) + if (this.props.reply_to) { + payload.reply_to = this.props.reply_to + } + + if (this.props.edit_post) { + response = await PostModel.update(this.props.edit_post, payload).catch(error => { + console.error(error) + antd.message.error(error) + + return false + }) + } else { + response = await PostModel.create(payload).catch(error => { + console.error(error) + antd.message.error(error) + + return false + }) + } this.setState({ loading: false @@ -116,6 +128,10 @@ export default class PostCreator extends React.Component { if (typeof this.props.close === "function") { this.props.close() } + + if (this.props.reply_to) { + app.navigation.goToPost(this.props.reply_to) + } } } @@ -182,8 +198,6 @@ export default class PostCreator extends React.Component { }) } - console.log(change) - switch (change.file.status) { case "uploading": { this.toggleUploaderVisibility(false) @@ -424,9 +438,37 @@ export default class PostCreator extends React.Component { dialog.click() } - componentDidMount() { + componentDidMount = async () => { + if (this.props.edit_post) { + await this.setState({ + loading: true, + postId: this.props.edit_post, + }) + + const post = await PostModel.getPost({ post_id: this.props.edit_post }) + + await this.setState({ + loading: false, + postMessage: post.message, + postAttachments: post.attachments.map((attachment) => { + return { + ...attachment, + uid: attachment.id, + } + }), + fileList: post.attachments.map((attachment) => { + return { + ...attachment, + uid: attachment.id, + id: attachment.id, + thumbUrl: attachment.url, + status: "done", + } + }), + }) + } // fetch the posting policy - this.fetchUploadPolicy() + //this.fetchUploadPolicy() // add a listener to the window document.addEventListener("paste", this.handlePaste) @@ -448,6 +490,10 @@ export default class PostCreator extends React.Component { render() { const { postMessage, fileList, loading, uploaderVisible, postingPolicy } = this.state + const editMode = !!this.props.edit_post + + const showHeader = !!this.props.edit_post || this.props.reply_to + return
+ { + showHeader &&
+ { + this.props.edit_post &&
+

+ + Editing post +

+
+ } + + { + this.props.reply_to &&
+

+ + Replaying to +

+ + { + this.props.close() + app.navigation.goToPost(this.props.reply_to) + }} + + /> +
+ } +
+ } +
@@ -475,7 +552,7 @@ export default class PostCreator extends React.Component { type="primary" disabled={loading || !this.canSubmit()} onClick={this.submit} - icon={loading ? : } + icon={loading ? : (editMode ? : )} />
diff --git a/packages/app/src/components/PostCreator/index.less b/packages/app/src/components/PostCreator/index.less index b0779926..4564e970 100755 --- a/packages/app/src/components/PostCreator/index.less +++ b/packages/app/src/components/PostCreator/index.less @@ -17,7 +17,29 @@ background-color: var(--background-color-accent); padding: 15px; - //transition: all 250ms ease-in-out; + gap: 10px; + + .postCreator-header { + display: flex; + flex-direction: row; + + align-items: center; + + width: 100%; + + p { + margin: 0; + } + + .postCreator-header-indicator { + display: flex; + flex-direction: row; + + gap: 7px; + + align-items: center; + } + } .actions { display: inline-flex; @@ -58,8 +80,6 @@ width: 100%; height: 100%; - padding: 10px; - overflow: hidden; overflow-x: scroll; @@ -125,6 +145,8 @@ display: flex; flex-direction: row; + align-items: center; + width: 100%; overflow-x: auto; @@ -146,6 +168,8 @@ height: fit-content; border-radius: @file_preview_borderRadius; + + margin: 0; } } } diff --git a/packages/app/src/components/PostLink/index.jsx b/packages/app/src/components/PostLink/index.jsx new file mode 100644 index 00000000..7af76fb5 --- /dev/null +++ b/packages/app/src/components/PostLink/index.jsx @@ -0,0 +1,28 @@ +import React from "react" +import { Tag } from "antd" + +import "./index.less" + +const PostLink = (props) => { + if (!props.post_id) { + return null + } + + return { + if (props.onClick) { + return props.onClick() + } + + app.navigation.goToPost(props.post_id) + }} + > + + #{props.post_id} + + +} + +export default PostLink \ No newline at end of file diff --git a/packages/app/src/components/PostLink/index.less b/packages/app/src/components/PostLink/index.less new file mode 100644 index 00000000..b386fd28 --- /dev/null +++ b/packages/app/src/components/PostLink/index.less @@ -0,0 +1,24 @@ +.post-link { + display: flex; + flex-direction: row; + + align-items: center; + + border-radius: 8px; + + font-size: 0.7rem; + font-weight: 800; + + font-family: "DM Mono", monospace; + + cursor: pointer; + + overflow: hidden; + + span { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + margin: 0; + } +} \ No newline at end of file diff --git a/packages/app/src/components/PostsList/index.jsx b/packages/app/src/components/PostsList/index.jsx index db59a1b6..360708ec 100755 --- a/packages/app/src/components/PostsList/index.jsx +++ b/packages/app/src/components/PostsList/index.jsx @@ -1,6 +1,7 @@ import React from "react" import * as antd from "antd" import { Icons } from "components/Icons" +import { AnimatePresence } from "framer-motion" import PostCard from "components/PostCard" import PlaylistTimelineEntry from "components/Music/PlaylistTimelineEntry" @@ -41,21 +42,21 @@ const Entry = React.memo((props) => { return React.createElement(typeToComponent[data.type ?? "post"] ?? PostCard, { key: data._id, data: data, - //disableAttachments: true, + disableReplyTag: props.disableReplyTag, events: { onClickLike: props.onLikePost, onClickSave: props.onSavePost, onClickDelete: props.onDeletePost, onClickEdit: props.onEditPost, + onClickReply: props.onReplyPost, + onDoubleClick: props.onDoubleClick, }, }) }) -const PostList = (props) => { - const parentRef = React.useRef() - +const PostList = React.forwardRef((props, ref) => { return {
} - { - props.list.map((data) => { - return - }) - } - - {/* + { - (data) => + props.list.map((data) => { + return + }) } - */} + -} + +}) export class PostsListsComponent extends React.Component { state = { @@ -238,12 +219,15 @@ export class PostsListsComponent extends React.Component { addPost: this.addPost, removePost: this.removePost, addRandomPost: () => { + const randomId = Math.random().toString(36).substring(7) + this.addPost({ - _id: Math.random().toString(36).substring(7), - message: `Random post ${Math.random().toString(36).substring(7)}`, + _id: randomId, + message: `Random post ${randomId}`, user: { - _id: Math.random().toString(36).substring(7), + _id: randomId, username: "random user", + avatar: `https://api.dicebear.com/7.x/thumbs/svg?seed=${randomId}`, } }) }, @@ -336,7 +320,7 @@ export class PostsListsComponent extends React.Component { console.error(`The event "${event}" is not defined in the timelineWsEvents object`) } - app.cores.api.listenEvent(event, this.timelineWsEvents[event]) + app.cores.api.listenEvent(event, this.timelineWsEvents[event], "posts") }) } } @@ -358,7 +342,7 @@ export class PostsListsComponent extends React.Component { console.error(`The event "${event}" is not defined in the timelineWsEvents object`) } - app.cores.api.unlistenEvent(event, this.timelineWsEvents[event]) + app.cores.api.unlistenEvent(event, this.timelineWsEvents[event], "posts") }) } } @@ -370,7 +354,7 @@ export class PostsListsComponent extends React.Component { window._hacks = null } - componentDidUpdate = async (prevProps) => { + componentDidUpdate = async (prevProps, prevState) => { if (prevProps.list !== this.props.list) { this.setState({ list: this.props.list, @@ -398,6 +382,22 @@ export class PostsListsComponent extends React.Component { return result } + onEditPost = (data) => { + app.controls.openPostCreator({ + edit_post: data._id, + }) + } + + onReplyPost = (data) => { + app.controls.openPostCreator({ + reply_to: data._id, + }) + } + + onDoubleClickPost = (data) => { + app.navigation.goToPost(data._id) + } + onDeletePost = async (data) => { antd.Modal.confirm({ title: "Are you sure you want to delete this post?", @@ -444,13 +444,16 @@ export class PostsListsComponent extends React.Component { } const PostListProps = { - listRef: this.listRef, list: this.state.list, + disableReplyTag: this.props.disableReplyTag, + onLikePost: this.onLikePost, onSavePost: this.onSavePost, onDeletePost: this.onDeletePost, onEditPost: this.onEditPost, + onReplyPost: this.onReplyPost, + onDoubleClick: this.onDoubleClickPost, onLoadMore: this.onLoadMore, hasMore: this.state.hasMore, @@ -463,12 +466,14 @@ export class PostsListsComponent extends React.Component { if (app.isMobile) { return } return
diff --git a/packages/app/src/components/PostsList/index.less b/packages/app/src/components/PostsList/index.less index c9d3dc2e..a18d82ea 100755 --- a/packages/app/src/components/PostsList/index.less +++ b/packages/app/src/components/PostsList/index.less @@ -59,7 +59,7 @@ html { position: relative; // WARN: Only use if is a performance issue (If is using virtualized list) - will-change: transform; + //will-change: transform; overflow: hidden; overflow-y: overlay; @@ -77,10 +77,14 @@ html { //margin: auto; z-index: 150; + background-color: var(--background-color-accent); + .post_card { border-radius: 0; border-bottom: 2px solid var(--border-color); + background-color: transparent; + &:first-child { border-radius: 8px; @@ -95,33 +99,11 @@ html { border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; } - } - .playlistTimelineEntry { - border-radius: 0; - border-bottom: 2px solid var(--border-color); - - &:last-child { - border-top-left-radius: 0px; - border-top-right-radius: 0px; - - border-bottom-left-radius: 8px; - border-bottom-right-radius: 8px; + &:last-of-type { + border-bottom: none; } } - - .postCard { - width: 100%; - min-width: 0; - // FIXME: This is a walkaround for a bug when a post contains multiple attachments cause a overflow - max-width: unset; - } - - .playlistTimelineEntry { - width: 100%; - min-width: 0; - max-width: unset; - } } .resume_btn_wrapper { diff --git a/packages/app/src/components/SyncRoomCard/index.jsx b/packages/app/src/components/SyncRoomCard/index.jsx index 5a4621a9..b330ecd6 100755 --- a/packages/app/src/components/SyncRoomCard/index.jsx +++ b/packages/app/src/components/SyncRoomCard/index.jsx @@ -52,7 +52,7 @@ export default class SyncRoomCard extends React.Component { } checkLatency = () => { - const instance = app.cores.api.instance().wsInstances.music + const instance = app.cores.api.instance().sockets.music if (instance) { this.setState({ @@ -67,7 +67,7 @@ export default class SyncRoomCard extends React.Component { }) // chat instance - const chatInstance = app.cores.api.instance().wsInstances.chat + const chatInstance = app.cores.api.instance().sockets.chat if (chatInstance) { Object.keys(this.chatEvents).forEach((event) => { @@ -92,7 +92,7 @@ export default class SyncRoomCard extends React.Component { } // chat instance - const chatInstance = app.cores.api.instance().wsInstances.chat + const chatInstance = app.cores.api.instance().sockets.chat if (chatInstance) { Object.keys(this.chatEvents).forEach((event) => { @@ -231,7 +231,7 @@ export default class SyncRoomCard extends React.Component {
{ - app.cores.api.instance().wsInstances.music.latency ?? "..." + app.cores.api.instance().sockets.music.latency ?? "..." }ms
diff --git a/packages/app/src/components/UploadButton/index.jsx b/packages/app/src/components/UploadButton/index.jsx index 3e57908b..a7de1f64 100755 --- a/packages/app/src/components/UploadButton/index.jsx +++ b/packages/app/src/components/UploadButton/index.jsx @@ -1,10 +1,15 @@ import React from "react" -import { Button, Upload } from "antd" - +import { Upload, Progress } from "antd" +import classnames from "classnames" import { Icons } from "components/Icons" +import useHacks from "hooks/useHacks" + +import "./index.less" + export default (props) => { const [uploading, setUploading] = React.useState(false) + const [progess, setProgess] = React.useState(null) const handleOnStart = (file_uid, file) => { if (typeof props.onStart === "function") { @@ -32,35 +37,37 @@ export default (props) => { const handleUpload = async (req) => { setUploading(true) + setProgess(1) handleOnStart(req.file.uid, req.file) - const response = await app.cores.remoteStorage.uploadFile(req.file, { + await app.cores.remoteStorage.uploadFile(req.file, { onProgress: (file, progress) => { - return handleOnProgress(file.uid, progress) - } - }).catch((err) => { - app.notification.new({ - title: "Could not upload file", - description: err - }, { - type: "error" - }) + setProgess(progress) + handleOnProgress(file.uid, progress) + }, + onError: (file, error) => { + setProgess(null) + handleOnError(file.uid, error) + setUploading(false) + }, + onFinish: (file, response) => { + if (typeof props.ctx?.onUpdateItem === "function") { + props.ctx.onUpdateItem(response.url) + } - return handleOnError(req.file.uid, err) + if (typeof props.onUploadDone === "function") { + props.onUploadDone(response) + } + + setUploading(false) + handleOnSuccess(req.file.uid, response) + + setTimeout(() => { + setProgess(null) + }, 1000) + }, }) - - if (typeof props.ctx?.onUpdateItem === "function") { - props.ctx.onUpdateItem(response.url) - } - - if (typeof props.onUploadDone === "function") { - await props.onUploadDone(response) - } - - setUploading(false) - - return handleOnSuccess(req.file.uid, response) } return { props.multiple ?? false } accept={ - props.accept ?? "image/*" + props.accept ?? [ + "image/*", + "video/*", + "audio/*", + ] } progress={false} fileList={[]} - > - +
} \ No newline at end of file diff --git a/packages/app/src/components/UploadButton/index.less b/packages/app/src/components/UploadButton/index.less new file mode 100644 index 00000000..3dabb98f --- /dev/null +++ b/packages/app/src/components/UploadButton/index.less @@ -0,0 +1,71 @@ +.uploadButton { + display: inline-flex; + flex-direction: row; + + align-items: center; + justify-content: center; + + background-color: var(--background-color-accent); + border-radius: 8px; + + padding: 5px 15px; + + cursor: pointer; + + transition: all 150ms ease-in-out; + + &.uploading { + border-radius: 12px; + + .uploadButton-content { + gap: 15px; + } + } + + .ant-upload { + display: inline-flex; + flex-direction: row; + + align-items: center; + justify-content: center; + } + + .uploadButton-content { + display: inline-flex; + flex-direction: row; + + align-items: center; + + gap: 7px; + + transition: all 150ms ease-in-out; + + .ant-progress { + position: relative; + + display: inline-flex; + flex-direction: row; + + align-items: center; + justify-content: center; + + height: 17px; + width: 17px; + + .ant-progress-inner, + .ant-progress-circle { + position: absolute; + + top: 0; + left: 0; + + width: 100% !important; + height: 100% !important; + } + + svg { + margin: 0 !important; + } + } + } +} \ No newline at end of file diff --git a/packages/app/src/components/UserCard/index.jsx b/packages/app/src/components/UserCard/index.jsx index 4b625d68..1c60db0d 100755 --- a/packages/app/src/components/UserCard/index.jsx +++ b/packages/app/src/components/UserCard/index.jsx @@ -113,7 +113,7 @@ export const UserCard = React.forwardRef((props, ref) => {

- {user.fullName || user.username} + {user.public_name || user.username} {user.verified && }

diff --git a/packages/app/src/components/UserCard/index.less b/packages/app/src/components/UserCard/index.less index b6426357..ef1fc49a 100755 --- a/packages/app/src/components/UserCard/index.less +++ b/packages/app/src/components/UserCard/index.less @@ -256,8 +256,6 @@ html { outline: 1px solid var(--border-color); - filter: drop-shadow(0 0 20px var(--border-color)); - h1, h2, h3, diff --git a/packages/app/src/components/index.js b/packages/app/src/components/index.js index 51f4b126..b4850494 100755 --- a/packages/app/src/components/index.js +++ b/packages/app/src/components/index.js @@ -15,7 +15,6 @@ export { default as StepsForm } from "./StepsForm" export { default as SearchButton } from "./SearchButton" export { default as Skeleton } from "./Skeleton" export { default as Navigation } from "./Navigation" -export { default as ImageUploader } from "./ImageUploader" export { default as ImageViewer } from "./ImageViewer" export { default as Image } from "./Image" export { default as LoadMore } from "./LoadMore" diff --git a/packages/app/src/cores/api/api.core.js b/packages/app/src/cores/api/api.core.js index 9e003fe3..c791ef4a 100755 --- a/packages/app/src/cores/api/api.core.js +++ b/packages/app/src/cores/api/api.core.js @@ -2,8 +2,8 @@ import Core from "evite/src/core" import createClient from "comty.js" -import measurePing from "comty.js/handlers/measurePing" -import request from "comty.js/handlers/request" +import request from "comty.js/request" +import measurePing from "comty.js/helpers/measurePing" import useRequest from "comty.js/hooks/useRequest" import { reconnectWebsockets, disconnectWebsockets } from "comty.js" @@ -13,11 +13,11 @@ export default class APICore extends Core { static bgColor = "coral" static textColor = "black" - instance = null + client = null public = { - instance: function () { - return this.instance + client: function () { + return this.client }.bind(this), customRequest: request, listenEvent: this.listenEvent.bind(this), @@ -28,82 +28,45 @@ export default class APICore extends Core { disconnectWebsockets: disconnectWebsockets, } - listenEvent(key, handler, instance) { - if (!this.instance.wsInstances[instance ?? "default"]) { + listenEvent(key, handler, instance = "default") { + if (!this.client.sockets[instance]) { console.error(`[API] Websocket instance ${instance} not found`) return false } - return this.instance.wsInstances[instance ?? "default"].on(key, handler) + return this.client.sockets[instance].on(key, handler) } - unlistenEvent(key, handler, instance) { - if (!this.instance.wsInstances[instance ?? "default"]) { + unlistenEvent(key, handler, instance = "default") { + if (!this.client.sockets[instance]) { console.error(`[API] Websocket instance ${instance} not found`) return false } - return this.instance.wsInstances[instance ?? "default"].off(key, handler) - } - - pendingPingsFromInstance = {} - - createPingIntervals() { - // Object.keys(this.instance.wsInstances).forEach((instance) => { - // this.console.debug(`[API] Creating ping interval for ${instance}`) - - // if (this.instance.wsInstances[instance].pingInterval) { - // clearInterval(this.instance.wsInstances[instance].pingInterval) - // } - - // this.instance.wsInstances[instance].pingInterval = setInterval(() => { - // if (this.instance.wsInstances[instance].pendingPingTry && this.instance.wsInstances[instance].pendingPingTry > 3) { - // this.console.debug(`[API] Ping timeout for ${instance}`) - - // return clearInterval(this.instance.wsInstances[instance].pingInterval) - // } - - // const timeStart = Date.now() - - // //this.console.debug(`[API] Ping ${instance}`, this.instance.wsInstances[instance].pendingPingTry) - - // this.instance.wsInstances[instance].emit("ping", () => { - // this.instance.wsInstances[instance].latency = Date.now() - timeStart - - // this.instance.wsInstances[instance].pendingPingTry = 0 - // }) - - // this.instance.wsInstances[instance].pendingPingTry = this.instance.wsInstances[instance].pendingPingTry ? this.instance.wsInstances[instance].pendingPingTry + 1 : 1 - // }, 5000) - - // // clear interval on close - // this.instance.wsInstances[instance].on("close", () => { - // clearInterval(this.instance.wsInstances[instance].pingInterval) - // }) - // }) + return this.client.sockets[instance].off(key, handler) } async onInitialize() { - this.instance = await createClient({ + this.client = await createClient({ enableWs: true, }) - this.instance.eventBus.on("auth:login_success", () => { + this.client.eventBus.on("auth:login_success", () => { app.eventBus.emit("auth:login_success") }) - this.instance.eventBus.on("auth:logout_success", () => { + this.client.eventBus.on("auth:logout_success", () => { app.eventBus.emit("auth:logout_success") }) - this.instance.eventBus.on("session.invalid", (error) => { + this.client.eventBus.on("session.invalid", (error) => { app.eventBus.emit("session.invalid", error) }) // make a basic request to check if the API is available - await this.instance.instances["default"]({ + await this.client.baseRequest({ method: "head", url: "/", }).catch((error) => { @@ -115,10 +78,6 @@ export default class APICore extends Core { `) }) - this.console.debug("[API] Attached to", this.instance) - - //this.createPingIntervals() - - return this.instance + return this.client } } \ No newline at end of file diff --git a/packages/app/src/cores/notifications/feedback.js b/packages/app/src/cores/notifications/feedback.js new file mode 100644 index 00000000..0c3257fe --- /dev/null +++ b/packages/app/src/cores/notifications/feedback.js @@ -0,0 +1,44 @@ +import { Haptics } from "@capacitor/haptics" + +const NotfTypeToAudio = { + info: "notification", + success: "notification", + warning: "warn", + error: "error", +} + +class NotificationFeedback { + static getSoundVolume = () => { + return (window.app.cores.settings.get("notifications_sound_volume") ?? 50) / 100 + } + + static playHaptic = async (options = {}) => { + const vibrationEnabled = options.vibrationEnabled ?? window.app.cores.settings.get("notifications_vibrate") + + if (vibrationEnabled) { + await Haptics.vibrate() + } + } + + static playAudio = (options = {}) => { + const soundEnabled = options.soundEnabled ?? window.app.cores.settings.get("notifications_sound") + const soundVolume = options.soundVolume ? options.soundVolume / 100 : NotificationFeedback.getSoundVolume() + + if (soundEnabled) { + if (typeof window.app.cores.sound?.play === "function") { + const sound = options.sound ?? NotfTypeToAudio[options.type] ?? "notification" + + window.app.cores.sound.play(sound, { + volume: soundVolume, + }) + } + } + } + + static async feedback(type) { + NotificationFeedback.playHaptic(type) + NotificationFeedback.playAudio(type) + } +} + +export default NotificationFeedback \ No newline at end of file diff --git a/packages/app/src/cores/notifications/notifications.core.js b/packages/app/src/cores/notifications/notifications.core.js new file mode 100755 index 00000000..6c2b8506 --- /dev/null +++ b/packages/app/src/cores/notifications/notifications.core.js @@ -0,0 +1,44 @@ +import Core from "evite/src/core" + +import NotificationUI from "./ui" +import NotificationFeedback from "./feedback" + +export default class NotificationCore extends Core { + static namespace = "notifications" + static depenpencies = [ + "api", + "settings", + ] + + #newNotifications = [] + + onEvents = { + "changeNotificationsSoundVolume": (value) => { + NotificationFeedback.playAudio({ + soundVolume: value + }) + }, + "changeNotificationsVibrate": (value) => { + NotificationFeedback.playHaptic({ + vibrationEnabled: value, + }) + } + } + + listenSockets = { + "notifications": { + "notification.new": (data) => { + this.new(data) + } + } + } + + public = { + new: this.new, + } + + async new(notification, options = {}) { + await NotificationUI.notify(notification, options) + await NotificationFeedback.feedback(options.type) + } +} \ No newline at end of file diff --git a/packages/app/src/cores/notifications/notifications.core.jsx b/packages/app/src/cores/notifications/ui.jsx old mode 100755 new mode 100644 similarity index 64% rename from packages/app/src/cores/notifications/notifications.core.jsx rename to packages/app/src/cores/notifications/ui.jsx index b0034322..96a3c7f0 --- a/packages/app/src/cores/notifications/notifications.core.jsx +++ b/packages/app/src/cores/notifications/ui.jsx @@ -1,46 +1,10 @@ -import Core from "evite/src/core" import React from "react" import { notification as Notf, Space, Button } from "antd" import { Icons, createIconRender } from "components/Icons" import { Translation } from "react-i18next" -import { Haptics } from "@capacitor/haptics" -const NotfTypeToAudio = { - info: "notification", - success: "notification", - warning: "warn", - error: "error", -} - -export default class NotificationCore extends Core { - static namespace = "notifications" - - onEvents = { - "changeNotificationsSoundVolume": (value) => { - this.playAudio({ soundVolume: value }) - }, - "changeNotificationsVibrate": (value) => { - this.playHaptic({ - vibrationEnabled: value, - }) - } - } - - registerToApp = { - notification: this - } - - getSoundVolume = () => { - return (window.app.cores.settings.get("notifications_sound_volume") ?? 50) / 100 - } - - new = (notification, options = {}) => { - this.notify(notification, options) - this.playHaptic(options) - this.playAudio(options) - } - - notify( +class NotificationUI { + static async notify( notification, options = { type: "info" @@ -142,27 +106,6 @@ export default class NotificationCore extends Core { return Notf[options.type](notfObj) } +} - playHaptic = async (options = {}) => { - const vibrationEnabled = options.vibrationEnabled ?? window.app.cores.settings.get("notifications_vibrate") - - if (vibrationEnabled) { - await Haptics.vibrate() - } - } - - playAudio = (options = {}) => { - const soundEnabled = options.soundEnabled ?? window.app.cores.settings.get("notifications_sound") - const soundVolume = options.soundVolume ? options.soundVolume / 100 : this.getSoundVolume() - - if (soundEnabled) { - if (typeof window.app.cores.sound?.play === "function") { - const sound = options.sound ?? NotfTypeToAudio[options.type] ?? "notification" - - window.app.cores.sound.play(sound, { - volume: soundVolume, - }) - } - } - } -} \ No newline at end of file +export default NotificationUI \ No newline at end of file diff --git a/packages/app/src/cores/remoteStorage/chunkedUpload.js b/packages/app/src/cores/remoteStorage/chunkedUpload.js new file mode 100644 index 00000000..fa8faf9b --- /dev/null +++ b/packages/app/src/cores/remoteStorage/chunkedUpload.js @@ -0,0 +1,186 @@ +import EventBus from "evite/src/internals/eventBus" +import SessionModel from "models/session" + +export default class ChunkedUpload { + constructor(params) { + this.endpoint = params.endpoint + this.file = params.file + this.headers = params.headers || {} + this.postParams = params.postParams + this.service = params.service ?? "default" + this.retries = params.retries ?? app.cores.settings.get("uploader.retries") ?? 3 + this.delayBeforeRetry = params.delayBeforeRetry || 5 + + this.start = 0 + this.chunk = null + this.chunkCount = 0 + + this.splitChunkSize = params.splitChunkSize || 1024 * 1024 * 10 + this.totalChunks = Math.ceil(this.file.size / this.splitChunkSize) + + this.retriesCount = 0 + this.offline = false + this.paused = false + + this.headers["Authorization"] = `Bearer ${SessionModel.token}` + this.headers["uploader-original-name"] = encodeURIComponent(this.file.name) + this.headers["uploader-file-id"] = this.uniqid(this.file) + this.headers["uploader-chunks-total"] = this.totalChunks + this.headers["provider-type"] = this.service + this.headers["chunk-size"] = this.splitChunkSize + + this._reader = new FileReader() + this.eventBus = new EventBus() + + this.validateParams() + this.nextSend() + + console.debug("[Uploader] Created", { + splitChunkSize: this.splitChunkSize, + totalChunks: this.totalChunks, + totalSize: this.file.size, + }) + + // restart sync when back online + // trigger events when offline/back online + window.addEventListener("online", () => { + if (!this.offline) return + + this.offline = false + this.eventBus.emit("online") + this.nextSend() + }) + + window.addEventListener("offline", () => { + this.offline = true + this.eventBus.emit("offline") + }) + } + + on(event, fn) { + this.eventBus.on(event, fn) + } + + validateParams() { + if (!this.endpoint || !this.endpoint.length) throw new TypeError("endpoint must be defined") + if (this.file instanceof File === false) throw new TypeError("file must be a File object") + if (this.headers && typeof this.headers !== "object") throw new TypeError("headers must be null or an object") + if (this.postParams && typeof this.postParams !== "object") throw new TypeError("postParams must be null or an object") + if (this.splitChunkSize && (typeof this.splitChunkSize !== "number" || this.splitChunkSize === 0)) throw new TypeError("splitChunkSize must be a positive number") + if (this.retries && (typeof this.retries !== "number" || this.retries === 0)) throw new TypeError("retries must be a positive number") + if (this.delayBeforeRetry && (typeof this.delayBeforeRetry !== "number")) throw new TypeError("delayBeforeRetry must be a positive number") + } + + uniqid(file) { + return Math.floor(Math.random() * 100000000) + Date.now() + this.file.size + "_tmp" + } + + loadChunk() { + return new Promise((resolve) => { + const length = this.totalChunks === 1 ? this.file.size : this.splitChunkSize + const start = length * this.chunkCount + + this._reader.onload = () => { + this.chunk = new Blob([this._reader.result], { type: "application/octet-stream" }) + resolve() + } + + this._reader.readAsArrayBuffer(this.file.slice(start, start + length)) + }) + } + + sendChunk() { + const form = new FormData() + + // send post fields on last request + if (this.chunkCount + 1 === this.totalChunks && this.postParams) Object.keys(this.postParams).forEach(key => form.append(key, this.postParams[key])) + + form.append("file", this.chunk) + + this.headers["uploader-chunk-number"] = this.chunkCount + + return fetch(this.endpoint, { method: "POST", headers: this.headers, body: form }) + } + + manageRetries() { + if (this.retriesCount++ < this.retries) { + setTimeout(() => this.nextSend(), this.delayBeforeRetry * 1000) + + this.eventBus.emit("fileRetry", { + message: `An error occured uploading chunk ${this.chunkCount}. ${this.retries - this.retriesCount} retries left`, + chunk: this.chunkCount, + retriesLeft: this.retries - this.retriesCount + }) + + return + } + + this.eventBus.emit("error", { + message: `An error occured uploading chunk ${this.chunkCount}. No more retries, stopping upload` + }) + } + + async nextSend() { + if (this.paused || this.offline) { + return + } + + await this.loadChunk() + const res = await this.sendChunk() + .catch((err) => { + if (this.paused || this.offline) return + + this.console.error(err) + + // this type of error can happen after network disconnection on CORS setup + this.manageRetries() + }) + + if (res.status === 200 || res.status === 201 || res.status === 204) { + if (++this.chunkCount < this.totalChunks) { + this.nextSend() + } else { + res.json().then((body) => { + this.eventBus.emit("finish", body) + }) + } + + const percentProgress = Math.round((100 / this.totalChunks) * this.chunkCount) + + this.eventBus.emit("progress", { + percentProgress + }) + } + + // errors that might be temporary, wait a bit then retry + else if ([408, 502, 503, 504].includes(res.status)) { + if (this.paused || this.offline) return + + this.manageRetries() + } + + else { + if (this.paused || this.offline) return + + try { + res.json().then((body) => { + this.eventBus.emit("error", { + message: `[${res.status}] ${body.error ?? body.message}` + }) + }) + } catch (error) { + this.eventBus.emit("error", { + message: `[${res.status}] ${res.statusText}` + }) + } + } + } + + togglePause() { + this.paused = !this.paused + + if (!this.paused) { + this.nextSend() + } + } +} \ No newline at end of file diff --git a/packages/app/src/cores/remoteStorage/remoteStorage.core.js b/packages/app/src/cores/remoteStorage/remoteStorage.core.js index 63fa9bf2..551608d1 100755 --- a/packages/app/src/cores/remoteStorage/remoteStorage.core.js +++ b/packages/app/src/cores/remoteStorage/remoteStorage.core.js @@ -1,171 +1,6 @@ import Core from "evite/src/core" -import EventBus from "evite/src/internals/eventBus" -import SessionModel from "models/session" -class ChunkedUpload { - constructor(params) { - this.endpoint = params.endpoint - this.file = params.file - this.headers = params.headers || {} - this.postParams = params.postParams - this.chunkSize = params.chunkSize || 1000000 - this.service = params.service ?? "default" - this.retries = params.retries ?? app.cores.settings.get("uploader.retries") ?? 3 - this.delayBeforeRetry = params.delayBeforeRetry || 5 - - this.start = 0 - this.chunk = null - this.chunkCount = 0 - this.totalChunks = Math.ceil(this.file.size / this.chunkSize) - this.retriesCount = 0 - this.offline = false - this.paused = false - - this.headers["Authorization"] = SessionModel.token - this.headers["uploader-original-name"] = encodeURIComponent(this.file.name) - this.headers["uploader-file-id"] = this.uniqid(this.file) - this.headers["uploader-chunks-total"] = this.totalChunks - this.headers["provider-type"] = this.service - - this._reader = new FileReader() - this.eventBus = new EventBus() - - this.validateParams() - this.sendChunks() - - // restart sync when back online - // trigger events when offline/back online - window.addEventListener("online", () => { - if (!this.offline) return - - this.offline = false - this.eventBus.emit("online") - this.sendChunks() - }) - - window.addEventListener("offline", () => { - this.offline = true - this.eventBus.emit("offline") - }) - } - - on(event, fn) { - this.eventBus.on(event, fn) - } - - validateParams() { - if (!this.endpoint || !this.endpoint.length) throw new TypeError("endpoint must be defined") - if (this.file instanceof File === false) throw new TypeError("file must be a File object") - if (this.headers && typeof this.headers !== "object") throw new TypeError("headers must be null or an object") - if (this.postParams && typeof this.postParams !== "object") throw new TypeError("postParams must be null or an object") - if (this.chunkSize && (typeof this.chunkSize !== "number" || this.chunkSize === 0)) throw new TypeError("chunkSize must be a positive number") - if (this.retries && (typeof this.retries !== "number" || this.retries === 0)) throw new TypeError("retries must be a positive number") - if (this.delayBeforeRetry && (typeof this.delayBeforeRetry !== "number")) throw new TypeError("delayBeforeRetry must be a positive number") - } - - uniqid(file) { - return Math.floor(Math.random() * 100000000) + Date.now() + this.file.size + "_tmp" - } - - getChunk() { - return new Promise((resolve) => { - const length = this.totalChunks === 1 ? this.file.size : this.chunkSize * 1000 * 1000 - const start = length * this.chunkCount - - this._reader.onload = () => { - this.chunk = new Blob([this._reader.result], { type: "application/octet-stream" }) - resolve() - } - - this._reader.readAsArrayBuffer(this.file.slice(start, start + length)) - }) - } - - sendChunk() { - const form = new FormData() - - // send post fields on last request - if (this.chunkCount + 1 === this.totalChunks && this.postParams) Object.keys(this.postParams).forEach(key => form.append(key, this.postParams[key])) - - form.append("file", this.chunk) - - this.headers["uploader-chunk-number"] = this.chunkCount - - return fetch(this.endpoint, { method: "POST", headers: this.headers, body: form }) - } - - manageRetries() { - if (this.retriesCount++ < this.retries) { - setTimeout(() => this.sendChunks(), this.delayBeforeRetry * 1000) - - this.eventBus.emit("fileRetry", { - message: `An error occured uploading chunk ${this.chunkCount}. ${this.retries - this.retriesCount} retries left`, - chunk: this.chunkCount, - retriesLeft: this.retries - this.retriesCount - }) - - return - } - - this.eventBus.emit("error", { - message: `An error occured uploading chunk ${this.chunkCount}. No more retries, stopping upload` - }) - } - - sendChunks() { - if (this.paused || this.offline) return - - this.getChunk() - .then(() => this.sendChunk()) - .then((res) => { - if (res.status === 200 || res.status === 201 || res.status === 204) { - if (++this.chunkCount < this.totalChunks) this.sendChunks() - else { - res.json().then((body) => { - this.eventBus.emit("finish", body) - }) - } - - const percentProgress = Math.round((100 / this.totalChunks) * this.chunkCount) - - this.eventBus.emit("progress", { - percentProgress - }) - } - - // errors that might be temporary, wait a bit then retry - else if ([408, 502, 503, 504].includes(res.status)) { - if (this.paused || this.offline) return - - this.manageRetries() - } - - else { - if (this.paused || this.offline) return - - this.eventBus.emit("error", { - message: `An error occured uploading chunk ${this.chunkCount}. Server responded with ${res.status}` - }) - } - }) - .catch((err) => { - if (this.paused || this.offline) return - - this.console.error(err) - - // this type of error can happen after network disconnection on CORS setup - this.manageRetries() - }) - } - - togglePause() { - this.paused = !this.paused - - if (!this.paused) { - this.sendChunks() - } - } -} +import ChunkedUpload from "./chunkedUpload" export default class RemoteStorage extends Core { static namespace = "remoteStorage" @@ -190,19 +25,15 @@ export default class RemoteStorage extends Core { onProgress = () => { }, onFinish = () => { }, onError = () => { }, - service = "default", + service = "standard", } = {}, ) { - const apiEndpoint = app.cores.api.instance().instances.files.getUri() - - // TODO: get value from settings - const chunkSize = 2 * 1000 * 1000 // 10MB - return new Promise((_resolve, _reject) => { const fn = async () => new Promise((resolve, reject) => { const uploader = new ChunkedUpload({ - endpoint: `${apiEndpoint}/upload/chunk`, - chunkSize: chunkSize, + endpoint: `${app.cores.api.client().mainOrigin}/upload/chunk`, + // TODO: get chunk size from settings + splitChunkSize: 5 * 1024 * 1024, // 5MB in bytes file: file, service: service, }) @@ -210,6 +41,13 @@ export default class RemoteStorage extends Core { uploader.on("error", ({ message }) => { this.console.error("[Uploader] Error", message) + app.notification.new({ + title: "Could not upload file", + description: message + }, { + type: "error" + }) + if (typeof onError === "function") { onError(file, message) } @@ -219,8 +57,6 @@ export default class RemoteStorage extends Core { }) uploader.on("progress", ({ percentProgress }) => { - //this.console.debug(`[Uploader] Progress: ${percentProgress}%`) - if (typeof onProgress === "function") { onProgress(file, percentProgress) } @@ -229,6 +65,12 @@ export default class RemoteStorage extends Core { uploader.on("finish", (data) => { this.console.debug("[Uploader] Finish", data) + app.notification.new({ + title: "File uploaded", + }, { + type: "success" + }) + if (typeof onFinish === "function") { onFinish(file, data) } diff --git a/packages/app/src/cores/rooms/rooms.core.js b/packages/app/src/cores/rooms/rooms.core.js deleted file mode 100755 index c51ec60a..00000000 --- a/packages/app/src/cores/rooms/rooms.core.js +++ /dev/null @@ -1,60 +0,0 @@ -import Core from "evite/src/core" -import socketio from "socket.io-client" -import remotes from "comty.js/remotes" -import SessionModel from "comty.js/models/session" - -export default class RoomsController extends Core { - static namespace = "rooms" - - connectedRooms = [] - - connectToRoom = async (roomId) => { - if (!this.checkRoomExists(roomId)) { - await this.createRoom(roomId) - } - - const room = this.createRoomSocket(roomId) - - this.connectedRooms.push(room) - - return room - } - - disconnectFromRoom = async (roomId) => { - if (!this.checkRoomExists(roomId)) { - throw new Error(`Room ${roomId} does not exist`) - } - - const room = this.connectedRooms.find((room) => room.roomId === roomId) - - room.leave() - - this.connectedRooms = this.connectedRooms.filter((room) => room.roomId !== roomId) - - return room - } - - checkRoomExists = (roomId) => { - return this.connectedRooms.some((room) => room.roomId === roomId) - } - - createRoomSocket = async (roomId) => { - let roomInterface = { - roomId: roomId, - socket: socketio(remotes.chat.origin, { - transports: ["websocket"], - query: { - room: roomId, - }, - auth: SessionModel.token, - autoConnect: true, - }), - } - - room.leave = () => { - roomInterface.socket.disconnect() - } - - return roomInterface - } -} \ No newline at end of file diff --git a/packages/app/src/cores/sync/sync.core.js b/packages/app/src/cores/sync/sync.core.js index dfab6d23..f2ad3bd2 100755 --- a/packages/app/src/cores/sync/sync.core.js +++ b/packages/app/src/cores/sync/sync.core.js @@ -189,7 +189,7 @@ class MusicSyncSubCore { } async onInitialize() { - this.musicWs = this.ctx.CORES.api.instance.wsInstances.music + this.musicWs = this.ctx.CORES.api.instance.sockets.music Object.keys(this.hubEvents).forEach((eventName) => { this.musicWs.on(eventName, this.hubEvents[eventName]) @@ -371,6 +371,7 @@ class MusicSyncSubCore { } export default class SyncCore extends Core { + static disabled = true static namespace = "sync" static dependencies = ["api", "player"] diff --git a/packages/app/src/cores/widgets/widgets.core.js b/packages/app/src/cores/widgets/widgets.core.js index 260263c9..85bf881e 100755 --- a/packages/app/src/cores/widgets/widgets.core.js +++ b/packages/app/src/cores/widgets/widgets.core.js @@ -6,7 +6,7 @@ export default class WidgetsCore extends Core { static storeKey = "widgets" static get apiInstance() { - return app.cores.api.instance().instances.marketplace + return app.cores.api.client().baseRequest } public = { @@ -21,7 +21,7 @@ export default class WidgetsCore extends Core { async onInitialize() { try { - await WidgetsCore.apiInstance() + //await WidgetsCore.apiInstance() const currentStore = this.getInstalled() diff --git a/packages/app/src/hooks/useUserRemoteConfig/index.jsx b/packages/app/src/hooks/useUserRemoteConfig/index.jsx new file mode 100644 index 00000000..161a9fa0 --- /dev/null +++ b/packages/app/src/hooks/useUserRemoteConfig/index.jsx @@ -0,0 +1,29 @@ +import UserModel from "models/user" +import React from "react" + +export default (props = {}) => { + const [firstLoad, setFirstLoad] = React.useState(true) + const [localData, setLocalData] = React.useState({}) + + React.useEffect(() => { + UserModel.getConfig().then((config) => { + setLocalData(config) + setFirstLoad(false) + }) + }, []) + + async function updateConfig(update) { + if (typeof props.onUpdate === "function") { + props.onUpdate(localData) + } + + const config = await UserModel.updateConfig(update) + setLocalData(config) + } + + return [ + localData, + updateConfig, + firstLoad, + ] +} \ No newline at end of file diff --git a/packages/app/src/layouts/components/modals/index.jsx b/packages/app/src/layouts/components/modals/index.jsx index 69c1a021..33aa4d45 100755 --- a/packages/app/src/layouts/components/modals/index.jsx +++ b/packages/app/src/layouts/components/modals/index.jsx @@ -58,19 +58,17 @@ class Modal extends React.Component { } handleClickOutside = (e) => { - if (this.contentRef.current && !this.contentRef.current.contains(e.target)) { - if (this.props.confirmOnOutsideClick) { - return AntdModal.confirm({ - title: this.props.confirmOnClickTitle ?? "Are you sure?", - content: this.props.confirmOnClickContent ?? "Are you sure you want to close this window?", - onOk: () => { - this.close() - } - }) - } - - return this.close() + if (this.props.confirmOnOutsideClick) { + return AntdModal.confirm({ + title: this.props.confirmOnClickTitle ?? "Are you sure?", + content: this.props.confirmOnClickContent ?? "Are you sure you want to close this window?", + onOk: () => { + this.close() + } + }) } + + return this.close() } render() { @@ -82,14 +80,15 @@ class Modal extends React.Component { ["framed"]: this.props.framed, } )} - onTouchEnd={this.handleClickOutside} - onMouseDown={this.handleClickOutside} > +
{ diff --git a/packages/app/src/layouts/components/modals/index.less b/packages/app/src/layouts/components/modals/index.less index 582e62c9..9b057c56 100755 --- a/packages/app/src/layouts/components/modals/index.less +++ b/packages/app/src/layouts/components/modals/index.less @@ -18,6 +18,17 @@ transition: all 150ms ease-in-out; + #mask_trigger { + position: fixed; + + top: 0; + left: 0; + + + width: 100vw; + height: 100vh; + } + &.framed { .app_modal_content { display: flex; diff --git a/packages/app/src/pages/account/index.jsx b/packages/app/src/pages/account/index.jsx index b7646c67..05bf19c9 100755 --- a/packages/app/src/pages/account/index.jsx +++ b/packages/app/src/pages/account/index.jsx @@ -2,9 +2,10 @@ import React from "react" import * as antd from "antd" import classnames from "classnames" import { Translation } from "react-i18next" +import { motion, AnimatePresence } from "framer-motion" import { Icons } from "components/Icons" -import { Skeleton, FollowButton, UserCard } from "components" +import { FollowButton, UserCard } from "components" import { SessionModel, UserModel, FollowsModel } from "models" import DetailsTab from "./tabs/details" @@ -21,36 +22,6 @@ const TabsComponent = { "music": MusicTab, } -const TabRender = React.memo((props, ref) => { - const [transitionActive, setTransitionActive] = React.useState(false) - const [activeKey, setActiveKey] = React.useState(props.renderKey) - - React.useEffect(() => { - setTransitionActive(true) - - setTimeout(() => { - setActiveKey(props.renderKey) - - setTimeout(() => { - setTransitionActive(false) - }, 100) - }, 100) - }, [props.renderKey]) - - const Tab = TabsComponent[activeKey] - - if (!Tab) { - return null - } - - // forwards ref to the tab - return
- { - React.createElement(Tab, props) - } -
-}) - export default class Account extends React.Component { state = { requestedUser: null, @@ -66,16 +37,8 @@ export default class Account extends React.Component { isNotExistent: false, } - profileRef = React.createRef() - contentRef = React.createRef() - coverComponent = React.createRef() - - leftPanelRef = React.createRef() - - actionsRef = React.createRef() - componentDidMount = async () => { app.layout.toggleCenteredContent(false) @@ -129,13 +92,6 @@ export default class Account extends React.Component { }) } - onPostListTopVisibility = (to) => { - if (to) { - this.profileRef.current.classList.remove("topHidden") - } else { - this.profileRef.current.classList.add("topHidden") - } - } onClickFollow = async () => { const result = await FollowsModel.toggleFollow({ @@ -165,8 +121,6 @@ export default class Account extends React.Component { return } - this.onPostListTopVisibility(true) - key = key.toLowerCase() if (this.state.tabActiveKey === key) { @@ -195,11 +149,10 @@ export default class Account extends React.Component { } return
this.toggleCoverExpanded()} id="profile-cover" @@ -217,18 +169,12 @@ export default class Account extends React.Component { }
-
+
-
+
- + + + { + React.createElement(TabsComponent[this.state.tabActiveKey], { + onTopVisibility: this.onPostListTopVisibility, + state: this.state + }) + } + +
-
+
{

- {props.state.followers.length} + {props.state.followersCount}

@@ -117,7 +117,7 @@ export default (props) => {

{ - getJoinLabel(Number(props.state.user.createdAt)) + getJoinLabel(Number(props.state.user.created_at ?? props.state.user.createdAt)) }

diff --git a/packages/app/src/pages/account/tabs/music/index.jsx b/packages/app/src/pages/account/tabs/music/index.jsx index 548d4dc3..d9600437 100755 --- a/packages/app/src/pages/account/tabs/music/index.jsx +++ b/packages/app/src/pages/account/tabs/music/index.jsx @@ -19,7 +19,7 @@ export default (props) => { return } diff --git a/packages/app/src/pages/home/components/global/index.jsx b/packages/app/src/pages/home/components/global/index.jsx index 96cb925a..d7ee11d0 100755 --- a/packages/app/src/pages/home/components/global/index.jsx +++ b/packages/app/src/pages/home/components/global/index.jsx @@ -2,19 +2,18 @@ import React from "react" import { PostsList } from "components" -import Post from "models/post" +import Feed from "models/feed" import "./index.less" export default class ExplorePosts extends React.Component { render() { return diff --git a/packages/app/src/pages/home/components/savedPosts/index.jsx b/packages/app/src/pages/home/components/savedPosts/index.jsx index 738647ef..490b1330 100755 --- a/packages/app/src/pages/home/components/savedPosts/index.jsx +++ b/packages/app/src/pages/home/components/savedPosts/index.jsx @@ -16,8 +16,9 @@ const emptyListRender = () => { export class SavedPosts extends React.Component { render() { return } } diff --git a/packages/app/src/pages/marketplace/index.jsx b/packages/app/src/pages/marketplace/index.jsx new file mode 100644 index 00000000..aea9f218 --- /dev/null +++ b/packages/app/src/pages/marketplace/index.jsx @@ -0,0 +1,80 @@ +import React from "react" +import SearchButton from "components/SearchButton" +import { Icons, createIconRender } from "components/Icons" +import Image from "components/Image" + +import "./index.less" + +const FieldItem = (props) => { + return
+
+ +
+ +
+

+ {props.title} +

+ +

+ {props.description} +

+
+
+} + +const ExtensionsBrowser = () => { + return
+
+

+ + Extensions +

+
+ +
+ + + + +
+
+} + +const Marketplace = () => { + return
+
+
+

+ Marketplace +

+
+ + +
+ + + + +
+} + +export default Marketplace \ No newline at end of file diff --git a/packages/app/src/pages/marketplace/index.less b/packages/app/src/pages/marketplace/index.less new file mode 100644 index 00000000..81b293a8 --- /dev/null +++ b/packages/app/src/pages/marketplace/index.less @@ -0,0 +1,114 @@ +.marketplace { + display: flex; + flex-direction: column; + + gap: 30px; + + width: 100%; + + .marketplace-header { + display: flex; + flex-direction: row; + + align-items: center; + + justify-content: space-between; + + width: 100%; + + .marketplace-header-card { + display: flex; + flex-direction: row; + + width: fit-content; + + gap: 10px; + + background-color: var(--background-color-accent); + padding: 10px 20px; + + border-radius: 12px; + } + + h1 { + font-family: "Space Grotesk", sans-serif; + font-size: 2rem; + margin: 0; + } + } + + .marketplace-field { + display: flex; + flex-direction: column; + + .marketplace-field-header { + + } + + .marketplace-field-slider { + display: flex; + flex-direction: row; + + gap: 20px; + } + + .marketplace-field-item { + position: relative; + + display: flex; + flex-direction: column; + + gap: 10px; + + width: 250px; + height: 280px; + + background-color: var(--background-color-accent); + + border-radius: 12px; + + padding: 10px; + + .marketplace-field-item-image { + width: 100%; + height: 60%; + + .lazy-load-image-background { + width: 100%; + height: 100%; + } + + img { + width: 100%; + height: 100%; + + object-fit: cover; + + overflow: hidden; + + border-radius: 12px; + + background-color: black; + } + } + + .marketplace-field-item-info { + display: flex; + flex-direction: column; + + gap: 7px; + + height: 40%; + width: 100%; + + overflow: hidden; + + h1, p { + text-wrap: none; + text-overflow: ellipsis; + margin: 0; + } + } + } + } +} \ No newline at end of file diff --git a/packages/app/src/pages/post/[post_id].jsx b/packages/app/src/pages/post/[post_id].jsx index 78c715d3..1ff1a18a 100755 --- a/packages/app/src/pages/post/[post_id].jsx +++ b/packages/app/src/pages/post/[post_id].jsx @@ -1,44 +1,50 @@ import React from "react" import * as antd from "antd" -import Post from "models/post" -import { PostCard, CommentsCard } from "components" +import PostCard from "components/PostCard" +import PostsList from "components/PostsList" + +import PostService from "models/post" import "./index.less" export default (props) => { const post_id = props.params.post_id - const [data, setData] = React.useState(null) + const [loading, result, error, repeat] = app.cores.api.useRequest(PostService.getPost, { + post_id, + }) - const loadData = async () => { - setData(null) - - const data = await Post.getPost({ post_id }).catch(() => { - antd.message.error("Failed to get post") - - return false - }) - - if (data) { - setData(data) - } + if (error) { + return } - React.useEffect(() => { - loadData() - }, []) - - if (!data) { + if (loading) { return } - return
-
- + return
+
+

Post

+ +
-
- + +
+

Replies

+
} \ No newline at end of file diff --git a/packages/app/src/pages/post/index.less b/packages/app/src/pages/post/index.less index 513ca02d..95cd697d 100755 --- a/packages/app/src/pages/post/index.less +++ b/packages/app/src/pages/post/index.less @@ -1,30 +1,8 @@ -.postPage { +.post-page { display: flex; - flex-direction: row; + flex-direction: column; width: 100%; - height: 100vh; - overflow: hidden; - - .postWrapper { - margin: 0 10px; - - height: 100%; - width: 70vw; - - min-width: 70vw; - max-width: 70vw; - } - - .commentsWrapper { - height: 100vh; - width: 100%; - - min-width: 300px; - - overflow: scroll; - - margin: 0 10px; - } + gap: 20px; } \ No newline at end of file diff --git a/packages/app/src/pages/settings/components/SettingItemComponent/index.jsx b/packages/app/src/pages/settings/components/SettingItemComponent/index.jsx index 8b304dcf..7a3d909d 100755 --- a/packages/app/src/pages/settings/components/SettingItemComponent/index.jsx +++ b/packages/app/src/pages/settings/components/SettingItemComponent/index.jsx @@ -308,6 +308,10 @@ export default class SettingItemComponent extends React.PureComponent { } } + if (typeof this.props.onUpdate === "function") { + await this.props.onUpdate(updateValue) + } + // finaly update value await this.setState({ value: updateValue diff --git a/packages/app/src/pages/settings/components/SettingTab/index.jsx b/packages/app/src/pages/settings/components/SettingTab/index.jsx index 857b6e89..2222afe8 100755 --- a/packages/app/src/pages/settings/components/SettingTab/index.jsx +++ b/packages/app/src/pages/settings/components/SettingTab/index.jsx @@ -16,50 +16,49 @@ import SettingItemComponent from "../SettingItemComponent" export default class SettingTab extends React.Component { state = { loading: true, - processedCtx: {} + tab: null, + ctx: {}, } - tab = composedTabs[this.props.activeKey] + loadTab = async () => { + await this.setState({ + loading: true, + processedCtx: {}, + }) - processCtx = async () => { - if (typeof this.tab.ctxData === "function") { - this.setState({ loading: true }) + const tab = composedTabs[this.props.activeKey] - const resultCtx = await this.tab.ctxData() + let ctx = {} - console.log(resultCtx) - - this.setState({ - loading: false, - processedCtx: resultCtx - }) + if (typeof tab.ctxData === "function") { + ctx = await tab.ctxData() } + + await this.setState({ + tab: tab, + loading: false, + ctx: { + baseConfig: this.props.baseConfig, + ...ctx + }, + }) } // check if props.activeKey change componentDidUpdate = async (prevProps) => { if (prevProps.activeKey !== this.props.activeKey) { - this.tab = composedTabs[this.props.activeKey] - - this.setState({ - loading: !!this.tab.ctxData, - processedCtx: {} - }) - - await this.processCtx() + await this.loadTab() } } componentDidMount = async () => { - this.setState({ - loading: !!this.tab.ctxData, - }) + await this.loadTab() + } - await this.processCtx() - - this.setState({ - loading: false - }) + handleSettingUpdate = async (key, value) => { + if (typeof this.props.onUpdate === "function") { + await this.props.onUpdate(key, value) + } } render() { @@ -67,14 +66,16 @@ export default class SettingTab extends React.Component { return } - if (this.tab.render) { - return React.createElement(this.tab.render, { - ctx: this.state.processedCtx + const { ctx, tab } = this.state + + if (tab.render) { + return React.createElement(tab.render, { + ctx: ctx, }) } if (this.props.withGroups) { - const group = composeGroupsFromSettingsTab(this.tab.settings) + const group = composeGroupsFromSettingsTab(tab.settings) return <> { @@ -98,9 +99,11 @@ export default class SettingTab extends React.Component {
{ - settings.map((setting) => this.handleSettingUpdate(setting.id, value)} />) }
@@ -109,8 +112,8 @@ export default class SettingTab extends React.Component { } { - this.tab.footer && React.createElement(this.tab.footer, { - ctx: this.state.processedCtx + tab.footer && React.createElement(tab.footer, { + ctx: this.state.ctx }) } @@ -118,18 +121,22 @@ export default class SettingTab extends React.Component { return <> { - this.tab.settings.map((setting, index) => { + tab.settings.map((setting, index) => { return this.handleSettingUpdate(setting.id, value)} /> }) } { - this.tab.footer && React.createElement(this.tab.footer, { - ctx: this.state.processedCtx + tab.footer && React.createElement(tab.footer, { + ctx: this.state.ctx }) } diff --git a/packages/app/src/pages/settings/index.jsx b/packages/app/src/pages/settings/index.jsx index bd266972..70bd5315 100755 --- a/packages/app/src/pages/settings/index.jsx +++ b/packages/app/src/pages/settings/index.jsx @@ -1,11 +1,11 @@ import React from "react" import * as antd from "antd" -import { Translation } from "react-i18next" -import classnames from "classnames" -import config from "config" -import useUrlQueryActiveKey from "hooks/useUrlQueryActiveKey" - import { createIconRender } from "components/Icons" +import { Translation } from "react-i18next" +import config from "config" + +import useUrlQueryActiveKey from "hooks/useUrlQueryActiveKey" +import useUserRemoteConfig from "hooks/useUserRemoteConfig" import { composedSettingsByGroups as settings @@ -88,6 +88,7 @@ const generateMenuItems = () => { } export default () => { + const [config, setConfig, loading] = useUserRemoteConfig() const [activeKey, setActiveKey] = useUrlQueryActiveKey({ defaultKey: "general", queryKey: "tab" @@ -113,11 +114,14 @@ export default () => { return items }, []) - return
+ function handleOnUpdate(key, value) { + setConfig({ + ...config, + [key]: value + }) + } + + return
{
- + { + loading && + } + { + !loading && + }
} \ No newline at end of file diff --git a/packages/app/src/pages/settings/index.less b/packages/app/src/pages/settings/index.less index c130b510..267ccdcc 100755 --- a/packages/app/src/pages/settings/index.less +++ b/packages/app/src/pages/settings/index.less @@ -95,6 +95,11 @@ padding: 0 20px; + .uploadButton{ + background-color: var(--background-color-primary); + border: 1px solid var(--border-color); + } + .setting_item_header { display: inline-flex; flex-direction: row; diff --git a/packages/app/src/theme/fixments.less b/packages/app/src/theme/fixments.less index ce8bb205..1d7cbfc4 100755 --- a/packages/app/src/theme/fixments.less +++ b/packages/app/src/theme/fixments.less @@ -299,23 +299,100 @@ } } -.ant-notification-notice { - background-color: var(--background-color-primary) !important; +.ant-notification { + display: flex; + flex-direction: column; + + gap: 20px; + + .ant-notification-notice-wrapper { + margin: 0; + overflow: hidden; + + border-radius: 12px; + outline: 1px solid var(--border-color) !important; + background-color: var(--background-color-primary) !important; - h1, - h2, - h3, - h4, - h5, - h6, - p, - span { - color: var(--text-color) !important; } - .ant-notification-notice-message, - .ant-notification-notice-description { - color: var(--text-color) !important; + .ant-notification-notice { + display: flex; + flex-direction: column; + + justify-content: center; + + background-color: transparent !important; + + h1, + h2, + h3, + h4, + h5, + h6, + p, + span { + color: var(--text-color) !important; + } + + .ant-notification-notice-close-x { + svg { + margin: 0 !important; + } + } + + .ant-notification-notice-with-icon { + display: flex; + flex-direction: column; + + gap: 10px; + + .ant-notification-notice-message { + margin: 0 !important; + } + } + + .ant-notification-notice-message, + .ant-notification-notice-description { + color: var(--text-color) !important; + } + + .ant-notification-notice-description { + display: none; + } + + .ant-notification-notice-description:not(:empty) { + display: flex; + } + + .ant-notification-notice-with-icon { + .ant-notification-notice-message { + margin-inline-start: 46px !important; + } + + .ant-notification-notice-description { + margin-inline-start: 46px !important; + } + + .ant-notification-notice-icon { + &.ant-notification-notice-icon-success { + color: #52c41a !important; + } + + max-width: 40px; + + svg { + margin: 0 !important; + } + + img { + width: 100%; + height: 100%; + object-fit: cover; + + border-radius: 8px; + } + } + } } } @@ -334,6 +411,7 @@ } } + .ant-message-error { svg { color: var(--ant-error-color) !important; @@ -518,30 +596,6 @@ z-index: 1000; } -.ant-notification-notice { - .ant-notification-notice-with-icon { - .ant-notification-notice-message { - margin-inline-start: 46px !important; - } - - .ant-notification-notice-description { - margin-inline-start: 46px !important; - } - - .ant-notification-notice-icon { - max-width: 40px; - - img { - width: 100%; - height: 100%; - object-fit: cover; - - border-radius: 8px; - } - } - } -} - .adm-action-sheet { .adm-action-sheet-button-item-wrapper { border-bottom-color: var(--border-color); diff --git a/packages/server/classes/ChunkFileUpload/index.js b/packages/server/classes/ChunkFileUpload/index.js new file mode 100755 index 00000000..28757b27 --- /dev/null +++ b/packages/server/classes/ChunkFileUpload/index.js @@ -0,0 +1,180 @@ +// Orginal forked from: Buzut/huge-uploader-nodejs +// Copyright (c) 2018, Quentin Busuttil All rights reserved. + +import fs from "node:fs" +import path from "node:path" +import mimetypes from "mime-types" + +export function checkTotalSize( + chunkSize, // in bytes + totalChunks, // number of chunks + maxFileSize, // in bytes +) { + const totalSize = chunkSize * totalChunks + + if (totalSize > maxFileSize) { + return false + } + + return true +} + +export function checkChunkUploadHeaders(headers) { + if ( + !headers["uploader-chunk-number"] || + !headers["uploader-chunks-total"] || + !headers["uploader-original-name"] || + !headers["uploader-file-id"] || + !headers["uploader-chunks-total"].match(/^[0-9]+$/) || + !headers["uploader-chunk-number"].match(/^[0-9]+$/) + ) { + return false + } + + return true +} + +export function createAssembleChunksPromise({ + chunksPath, // chunks to assemble + filePath, // final assembled file path + maxFileSize, +}) { + return () => new Promise(async (resolve, reject) => { + let fileSize = 0 + + const chunks = await fs.promises.readdir(chunksPath) + + if (chunks.length === 0) { + throw new Error("No chunks found") + } + + for await (const chunk of chunks) { + const chunkPath = path.join(chunksPath, chunk) + const data = await fs.promises.readFile(chunkPath) + + fileSize += data.length + + // check if final file gonna exceed max file size + // in case early estimation is wrong (due client send bad headers) + if (fileSize > maxFileSize) { + return reject(new OperationError(413, "File exceeds max total file size, aborting assembly...")) + } + + await fs.promises.appendFile(filePath, data) + + continue + } + + return resolve({ + chunksLength: chunks.length, + filePath: filePath, + }) + }) +} + +export async function handleChunkFile(fileStream, { tmpDir, headers, maxFileSize, maxChunkSize }) { + return await new Promise(async (resolve, reject) => { + const chunksPath = path.join(tmpDir, headers["uploader-file-id"], "chunks") + const chunkPath = path.join(chunksPath, headers["uploader-chunk-number"]) + + const chunkCount = +headers["uploader-chunk-number"] + const totalChunks = +headers["uploader-chunks-total"] + + // check if file has all chunks uploaded + const isLast = chunkCount === totalChunks - 1 + + // make sure chunk is in range + if (chunkCount < 0 || chunkCount >= totalChunks) { + throw new Error("Chunk is out of range") + } + + // if is the first chunk check if dir exists before write things + if (chunkCount === 0) { + if (!await fs.promises.stat(chunksPath).catch(() => false)) { + await fs.promises.mkdir(chunksPath, { recursive: true }) + } + } + + let dataWritten = 0 + + let writeStream = fs.createWriteStream(chunkPath) + + writeStream.on("error", (err) => { + reject(err) + }) + + writeStream.on("close", () => { + if (maxChunkSize !== undefined) { + if (dataWritten > maxChunkSize) { + reject(new OperationError(413, "Chunk size exceeds max chunk size, aborting upload...")) + return + } + + // estimate total file size, + // if estimation exceeds maxFileSize, abort upload + if (chunkCount === 0 && totalChunks > 0) { + if ((dataWritten * (totalChunks - 1)) > maxFileSize) { + reject(new OperationError(413, "File estimated size exceeds max total file size, aborting upload...")) + return + } + } + } + + if (isLast) { + const mimetype = mimetypes.lookup(headers["uploader-original-name"]) + const extension = mimetypes.extension(mimetype) + + let filename = headers["uploader-file-id"] + + if (headers["uploader-use-date"] === "true") { + filename = `${filename}_${Date.now()}` + } + + return resolve(createAssembleChunksPromise({ + // build data + chunksPath: chunksPath, + filePath: path.resolve(chunksPath, `${filename}.${extension}`), + maxFileSize: maxFileSize, + })) + } + + return resolve(null) + }) + + fileStream.on("data", (buffer) => { + dataWritten += buffer.byteLength + }) + + fileStream.pipe(writeStream) + }) +} + +export async function uploadChunkFile(req, { + tmpDir, + maxFileSize, + maxChunkSize, +}) { + return await new Promise(async (resolve, reject) => { + if (!checkChunkUploadHeaders(req.headers)) { + reject(new OperationErrorError(400, "Missing header(s)")) + return + } + + await req.multipart(async (field) => { + try { + const result = await handleChunkFile(field.file.stream, { + tmpDir: tmpDir, + headers: req.headers, + maxFileSize: maxFileSize, + maxChunkSize: maxChunkSize, + }) + + return resolve(result) + } catch (error) { + return reject(error) + } + }) + }) +} + +export default uploadChunkFile \ No newline at end of file diff --git a/packages/server/classes/FileUpload/index.js b/packages/server/classes/FileUpload/index.js deleted file mode 100755 index 6a609c2f..00000000 --- a/packages/server/classes/FileUpload/index.js +++ /dev/null @@ -1,260 +0,0 @@ -// Orginal forked from: Buzut/huge-uploader-nodejs -// Copyright (c) 2018, Quentin Busuttil All rights reserved. - -import fs from "node:fs" -import path from "node:path" -import { promisify } from "node:util" -import mimetypes from "mime-types" -import crypto from "node:crypto" - -import Busboy from "busboy" - -export function getFileHash(file) { - return new Promise((resolve, reject) => { - const hash = crypto.createHash("sha256") - - file.on("data", (chunk) => hash.update(chunk)) - - file.on("end", () => resolve(hash.digest("hex"))) - - file.on("error", reject) - }) -} - -export function checkHeaders(headers) { - if ( - !headers["uploader-chunk-number"] || - !headers["uploader-chunks-total"] || - !headers["uploader-original-name"] || - !headers["uploader-file-id"] || - !headers["uploader-chunks-total"].match(/^[0-9]+$/) || - !headers["uploader-chunk-number"].match(/^[0-9]+$/) - ) { - return false - } - - return true -} - -export function checkTotalSize(maxFileSize, maxChunkSize, totalChunks) { - if (maxChunkSize * totalChunks > maxFileSize) { - return false - } - - return true -} - -export function cleanChunks(dirPath) { - fs.readdir(dirPath, (err, files) => { - let filesLength = files.length - - files.forEach((file) => { - fs.unlink(path.join(dirPath, file), () => { - if (--filesLength === 0) fs.rmdir(dirPath, () => { }) // cb does nothing but required - }) - }) - }) -} - -export function createAssembleChunksPromise({ - tmpDir, - headers, - useDate, -}) { - const asyncReadFile = promisify(fs.readFile) - const asyncAppendFile = promisify(fs.appendFile) - - const originalMimeType = mimetypes.lookup(headers["uploader-original-name"]) - const originalExtension = mimetypes.extension(originalMimeType) - - const totalChunks = +headers["uploader-chunks-total"] - - const fileId = headers["uploader-file-id"] - const workPath = path.join(tmpDir, fileId) - const chunksPath = path.resolve(workPath, "chunks") - const assembledFilepath = path.join(workPath, `assembled.${originalExtension}`) - - let chunkCount = 0 - let finalFilepath = null - - return () => { - return new Promise((resolve, reject) => { - const onEnd = async () => { - try { - const hash = await getFileHash(fs.createReadStream(assembledFilepath)) - - if (useDate) { - finalFilepath = path.resolve(workPath, `${hash}_${Date.now()}.${originalExtension}`) - } else { - finalFilepath = path.resolve(workPath, `${hash}.${originalExtension}`) - } - - fs.renameSync(assembledFilepath, finalFilepath) - - cleanChunks(chunksPath) - - return resolve({ - filename: headers["uploader-original-name"], - filepath: finalFilepath, - cachePath: workPath, - hash, - mimetype: originalMimeType, - extension: originalExtension, - }) - } catch (error) { - return reject(error) - } - } - - const pipeChunk = () => { - asyncReadFile(path.join(chunksPath, chunkCount.toString())) - .then((chunk) => asyncAppendFile(assembledFilepath, chunk)) - .then(() => { - // 0 indexed files = length - 1, so increment before comparison - if (totalChunks > ++chunkCount) { - return pipeChunk(chunkCount) - } - - return onEnd() - }) - .catch(reject) - } - - pipeChunk() - }) - } -} - -export function mkdirIfDoesntExist(dirPath, callback) { - if (!fs.existsSync(dirPath)) { - fs.mkdir(dirPath, { recursive: true }, callback) - } -} - -export function handleFile(tmpDir, headers, fileStream) { - const dirPath = path.join(tmpDir, headers["uploader-file-id"]) - const chunksPath = path.join(dirPath, "chunks") - const chunkPath = path.join(chunksPath, headers["uploader-chunk-number"]) - const useDate = headers["uploader-use-date"] === "true" - const chunkCount = +headers["uploader-chunk-number"] - const totalChunks = +headers["uploader-chunks-total"] - - let error - let assembleChunksPromise - let finished = false - let writeStream - - const writeFile = () => { - writeStream = fs.createWriteStream(chunkPath) - - writeStream.on("error", (err) => { - error = err - fileStream.resume() - }) - - writeStream.on("close", () => { - finished = true - - // if all is uploaded - if (chunkCount === totalChunks - 1) { - assembleChunksPromise = createAssembleChunksPromise({ - tmpDir, - headers, - useDate, - }) - } - }) - - fileStream.pipe(writeStream) - } - - // make sure chunk is in range - if (chunkCount < 0 || chunkCount >= totalChunks) { - error = new Error("Chunk is out of range") - fileStream.resume() - } - - else if (chunkCount === 0) { - // create file upload dir if it's first chunk - mkdirIfDoesntExist(chunksPath, (err) => { - if (err) { - error = err - fileStream.resume() - } - - else writeFile() - }) - } - - else { - // make sure dir exists if it's not first chunk - fs.stat(dirPath, (err) => { - if (err) { - error = new Error("Upload has expired") - fileStream.resume() - } - - else writeFile() - }) - } - - return (callback) => { - if (finished && !error) callback(null, assembleChunksPromise) - else if (error) callback(error) - - else { - writeStream.on("error", callback) - writeStream.on("close", () => callback(null, assembleChunksPromise)) - } - } -} - -export function uploadFile(req, tmpDir, maxFileSize, maxChunkSize) { - return new Promise((resolve, reject) => { - if (!checkHeaders(req.headers)) { - reject(new Error("Missing header(s)")) - return - } - - if (!checkTotalSize(maxFileSize, req.headers["uploader-chunks-total"])) { - reject(new Error("File is above size limit")) - return - } - - try { - let limitReached = false - let getFileStatus - - const busboy = Busboy({ headers: req.headers, limits: { files: 1, fileSize: maxChunkSize * 1000 * 1000 } }) - - busboy.on("file", (fieldname, fileStream) => { - fileStream.on("limit", () => { - limitReached = true - fileStream.resume() - }) - - getFileStatus = handleFile(tmpDir, req.headers, fileStream) - }) - - busboy.on("close", () => { - if (limitReached) { - reject(new Error("Chunk is above size limit")) - return - } - - getFileStatus((fileErr, assembleChunksF) => { - if (fileErr) reject(fileErr) - else resolve(assembleChunksF) - }) - }) - - req.pipe(busboy) - } - - catch (err) { - reject(err) - } - }) -} - -export default uploadFile \ No newline at end of file diff --git a/packages/server/classes/Limits/index.js b/packages/server/classes/Limits/index.js new file mode 100644 index 00000000..a6ef78bf --- /dev/null +++ b/packages/server/classes/Limits/index.js @@ -0,0 +1,40 @@ +import { Config } from "@db_models" + +export default class Limits { + static async get(key) { + const { value } = await Config.findOne({ + key: "limits" + }).catch(() => { + return { + value: {} + } + }) + + const limits = { + maxChunkSizeInMB: 5, + maxFileSizeInMB: 8, + maxNumberOfFiles: 10, + maxPostCharacters: 2000, + maxAccountsPerIp: 10, + ...value, + } + + if (typeof key === "string") { + return { + value: limits[key] ?? null + } + } + + if (Array.isArray(key)) { + const result = {} + + key.forEach((k) => { + result[k] = limits[k] ?? null + }) + + return result + } + + return limits + } +} \ No newline at end of file diff --git a/packages/server/db_models/post/index.js b/packages/server/db_models/post/index.js index 8914aad7..73702094 100755 --- a/packages/server/db_models/post/index.js +++ b/packages/server/db_models/post/index.js @@ -8,5 +8,6 @@ export default { attachments: { type: Array, default: [] }, flags: { type: Array, default: [] }, reply_to: { type: String, default: null }, + updated_at: { type: String, default: null }, } } \ No newline at end of file diff --git a/packages/server/db_models/savedPost/index.js b/packages/server/db_models/postSave/index.js similarity index 84% rename from packages/server/db_models/savedPost/index.js rename to packages/server/db_models/postSave/index.js index 05634f5b..6589ff74 100755 --- a/packages/server/db_models/savedPost/index.js +++ b/packages/server/db_models/postSave/index.js @@ -1,6 +1,6 @@ export default { - name: "SavedPost", - collection: "savedPosts", + name: "PostSave", + collection: "post_saves", schema: { post_id: { type: "string", diff --git a/packages/server/db_models/user/index.js b/packages/server/db_models/user/index.js index 1bcf247c..7f267cee 100755 --- a/packages/server/db_models/user/index.js +++ b/packages/server/db_models/user/index.js @@ -15,6 +15,6 @@ export default { badges: { type: Array, default: [] }, links: { type: Array, default: [] }, location: { type: String, default: null }, - birthday: { type: Date, default: null }, + birthday: { type: Date, default: null, select: false }, } } \ No newline at end of file diff --git a/packages/server/index.js b/packages/server/index.js index bf3c8844..5ebf633b 100755 --- a/packages/server/index.js +++ b/packages/server/index.js @@ -10,18 +10,16 @@ import chalk from "chalk" import Spinnies from "spinnies" import chokidar from "chokidar" import IPCRouter from "linebridge/src/server/classes/IPCRouter" -import fastify from "fastify" -import { createProxyMiddleware } from "http-proxy-middleware" +import treeKill from "tree-kill" import { dots as DefaultSpinner } from "spinnies/spinners.json" import getInternalIp from "./lib/getInternalIp" import comtyAscii from "./ascii" import pkg from "./package.json" -import cors from "linebridge/src/server/middlewares/cors" - import { onExit } from "signal-exit" +import Proxy from "./proxy" const bootloaderBin = path.resolve(__dirname, "boot") const servicesPath = path.resolve(__dirname, "services") @@ -51,7 +49,7 @@ async function scanServices() { return finalServices } -let internal_proxy = null +let internal_proxy = new Proxy() let allReady = false let selectedProcessInstance = null let internalIp = null @@ -72,7 +70,7 @@ Observable.observe(serviceRegistry, (changes) => { //console.log(`Updated service | ${path} > ${value}`) //check if all services all ready - if (Object.values(serviceRegistry).every((service) => service.ready)) { + if (Object.values(serviceRegistry).every((service) => service.initialized)) { handleAllReady() } @@ -176,6 +174,8 @@ async function handleAllReady() { console.log(comtyAscii) console.log(`🎉 All services[${services.length}] ready!\n`) console.log(`USE: select , reboot, exit`) + + await internal_proxy.listen(9000, "0.0.0.0") } // SERVICE WATCHER FUNCTIONS @@ -189,6 +189,8 @@ async function handleNewServiceStarting(id) { } async function handleServiceStarted(id) { + serviceRegistry[id].initialized = true + if (serviceRegistry[id].ready === false) { if (spinnies.pick(id)) { spinnies.succeed(id, { text: `[${id}][${serviceRegistry[id].index}] Ready` }) @@ -199,7 +201,7 @@ async function handleServiceStarted(id) { } async function handleServiceExit(id, code, err) { - //console.log(`🛑 Service ${id} exited with code ${code}`, err) + serviceRegistry[id].initialized = true if (serviceRegistry[id].ready === false) { if (spinnies.pick(id)) { @@ -207,29 +209,14 @@ async function handleServiceExit(id, code, err) { } } + console.log(`[${id}] Exit with code ${code}`) + + // try to unregister from proxy + internal_proxy.unregisterAllFromService(id) + serviceRegistry[id].ready = false } -async function registerProxy(_path, target, pathRewrite) { - if (internal_proxy.proxys.has(_path)) { - console.warn(`Proxy already registered [${_path}], skipping...`) - return false - } - - console.log(`🔗 Registering path proxy [${_path}] -> [${target}]`) - - internal_proxy.proxys.add(_path) - - internal_proxy.use(_path, createProxyMiddleware({ - target: target, - changeOrigin: true, - pathRewrite: pathRewrite, - ws: true, - logLevel: "silent", - })) - - return true -} async function handleIPCData(service_id, msg) { if (msg.type === "log") { @@ -243,21 +230,35 @@ async function handleIPCData(service_id, msg) { if (msg.type === "router:register") { if (msg.data.path_overrides) { for await (let pathOverride of msg.data.path_overrides) { - await registerProxy( - `/${pathOverride}`, - `http://${internalIp}:${msg.data.listen.port}/${pathOverride}`, - { + await internal_proxy.register({ + serviceId: service_id, + path: `/${pathOverride}`, + target: `http://${internalIp}:${msg.data.listen.port}/${pathOverride}`, + pathRewrite: { [`^/${pathOverride}`]: "", - } - ) + }, + }) } } else { - await registerProxy( - `/${service_id}`, - `http://${msg.data.listen.ip}:${msg.data.listen.port}` - ) + await internal_proxy.register({ + serviceId: service_id, + path: `/${service_id}`, + target: `http://${msg.data.listen.ip}:${msg.data.listen.port}`, + }) } } + + if (msg.type === "router:ws:register") { + await internal_proxy.register({ + serviceId: service_id, + path: `/${msg.data.namespace}`, + target: `http://${internalIp}:${msg.data.listen.port}/${msg.data.namespace}`, + pathRewrite: { + [`^/${msg.data.namespace}`]: "", + }, + ws: true, + }) + } } function spawnService({ id, service, cwd }) { @@ -276,11 +277,15 @@ function spawnService({ id, service, cwd }) { silent: true, cwd: cwd, env: instanceEnv, + killSignal: "SIGKILL", }) instance.reload = () => { ipcRouter.unregister({ id, instance }) + // try to unregister from proxy + internal_proxy.unregisterAllFromService(id) + instance.kill() instance = spawnService({ id, service, cwd }) @@ -340,31 +345,6 @@ function createServiceLogTransformer({ id, color = "bgCyan" }) { async function main() { internalIp = await getInternalIp() - internal_proxy = fastify() - - internal_proxy.proxys = new Set() - - await internal_proxy.register(require("@fastify/middie")) - - await internal_proxy.use(cors) - - internal_proxy.get("/ping", (request, reply) => { - return reply.send({ - status: "ok" - }) - }) - - internal_proxy.get("/", (request, reply) => { - return reply.send({ - services: instancePool.map((instance) => { - return { - id: instance.id, - version: instance.version, - } - }), - }) - }) - console.clear() console.log(comtyAscii) console.log(`\nRunning ${chalk.bgBlue(`${pkg.name}`)} | ${chalk.bgMagenta(`[v${pkg.version}]`)} | ${internalIp} \n\n\n`) @@ -417,6 +397,7 @@ async function main() { if (process.env.NODE_ENV === "development") { const ignored = [ ...await getIgnoredFiles(cwd), + "**/.cache/**", "**/node_modules/**", "**/dist/**", "**/build/**", @@ -438,7 +419,6 @@ async function main() { } } - // create repl repl.start({ prompt: "> ", useGlobal: true, @@ -474,11 +454,6 @@ async function main() { } }) - await internal_proxy.listen({ - host: "0.0.0.0", - port: 9000 - }) - onExit((code, signal) => { console.clear() console.log(`\n🛑 Preparing to exit...`) @@ -493,7 +468,11 @@ async function main() { console.log(`Killing ${instance.id} [${instance.instance.pid}]`) instance.instance.kill() + + treeKill(instance.instance.pid) } + + treeKill(process.pid) }) } diff --git a/packages/server/package.json b/packages/server/package.json index 7d25d1be..bfbbc95c 100755 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -24,14 +24,21 @@ "clui": "^0.3.6", "dotenv": "^16.4.4", "fastify": "^4.26.2", - "http-proxy-middleware": "^2.0.6", + "http-proxy": "^1.18.1", + "http-proxy-middleware": "^3.0.0-beta.1", + "hyper-express": "^6.14.12", "jsonwebtoken": "^9.0.2", "linebridge": "^0.18.1", "module-alias": "^2.2.3", "p-map": "^4.0.0", "p-queue": "^7.3.4", + "radix3": "^1.1.1", "signal-exit": "^4.1.0", - "spinnies": "^0.5.1" + "spinnies": "^0.5.1", + "tree-kill": "^1.2.2", + "uWebSockets.js": "uNetworking/uWebSockets.js#v20.41.0", + "uws-reverse-proxy": "^3.2.1", + "yume-server": "^0.0.5" }, "devDependencies": { "chai": "^5.1.0", diff --git a/packages/server/proxy.js b/packages/server/proxy.js new file mode 100644 index 00000000..90fee2bd --- /dev/null +++ b/packages/server/proxy.js @@ -0,0 +1,188 @@ +import http from "node:http" +import httpProxy from "http-proxy" +import defaults from "linebridge/src/server/defaults" + +import pkg from "./package.json" + +export default class Proxy { + constructor() { + this.proxys = new Map() + this.wsProxys = new Map() + + this.http = http.createServer(this.handleHttpRequest) + this.http.on("upgrade", this.handleHttpUpgrade) + } + + http = null + + register = ({ serviceId, path, target, pathRewrite, ws } = {}) => { + if (!path) { + throw new Error("Path is required") + } + + if (!target) { + throw new Error("Target is required") + } + + if (this.proxys.has(path)) { + console.warn(`Proxy already registered [${path}], skipping...`) + return false + } + + const proxy = httpProxy.createProxyServer({ + target: target, + }) + + proxy.on("error", (e) => { + console.error(e) + }) + + const proxyObj = { + serviceId: serviceId ?? "default_service", + path: path, + target: target, + pathRewrite: pathRewrite, + proxy: proxy, + } + + if (ws) { + console.log(`🔗 Registering websocket proxy [${path}] -> [${target}]`) + this.wsProxys.set(path, proxyObj) + } else { + console.log(`🔗 Registering path proxy [${path}] -> [${target}]`) + this.proxys.set(path, proxyObj) + } + + return true + } + + unregister = (path) => { + if (!this.proxys.has(path)) { + console.warn(`Proxy not registered [${path}], skipping...`) + return false + } + + console.log(`🔗 Unregistering path proxy [${path}]`) + + this.proxys.get(path).proxy.close() + this.proxys.delete(path) + } + + unregisterAllFromService = (serviceId) => { + this.proxys.forEach((value, key) => { + if (value.serviceId === serviceId) { + this.unregister(value.path) + } + }) + } + + listen = async (port = 9000, host = "0.0.0.0", cb) => { + return await new Promise((resolve, reject) => { + this.http.listen(port, host, () => { + console.log(`🔗 Proxy listening on ${host}:${port}`) + + if (cb) { + cb(this) + } + + resolve(this) + }) + }) + } + + rewritePath = (rewriteConfig, path) => { + let result = path + const rules = [] + + for (const [key, value] of Object.entries(rewriteConfig)) { + rules.push({ + regex: new RegExp(key), + value: value, + }) + } + + for (const rule of rules) { + if (rule.regex.test(path)) { + result = result.replace(rule.regex, rule.value) + break + } + } + + return result + } + + setCorsHeaders = (res) => { + res.setHeader("Access-Control-Allow-Origin", "*") + res.setHeader("Access-Control-Allow-Methods", "GET,HEAD,PUT,PATCH,POST,DELETE") + res.setHeader("Access-Control-Allow-Headers", "*") + + return res + } + + handleHttpRequest = (req, res) => { + res = this.setCorsHeaders(res) + + const sanitizedUrl = req.url.split("?")[0] + + // preflight continue with code 204 + if (req.method === "OPTIONS") { + res.statusCode = 204 + res.end() + return + } + + if (sanitizedUrl === "/") { + return res.end(` + { + "name": "${pkg.name}", + "version": "${pkg.version}", + "lb_version": "${defaults.version}" + } + `) + } + + const namespace = `/${sanitizedUrl.split("/")[1]}` + const route = this.proxys.get(namespace) + + if (!route) { + res.statusCode = 404 + res.end(` + { + "error": "404 Not found" + } + `) + return + } + + if (route.pathRewrite) { + req.url = this.rewritePath(route.pathRewrite, req.url) + } + + //console.log(`HTTP REQUEST :`, req.url) + + route.proxy.web(req, res) + } + + handleHttpUpgrade = (req, socket, head) => { + const namespace = `/${req.url.split("/")[1]}` + const route = this.wsProxys.get(namespace) + + if (!route) { + // destroy socket + socket.destroy() + return false + } + + if (route.pathRewrite) { + req.url = this.rewritePath(route.pathRewrite, req.url) + } + + //console.log(`HTTP UPGRADING :`, req.url) + + route.proxy.ws(req, socket, head) + } + + close = () => { + this.http.close() + } +} \ No newline at end of file diff --git a/packages/server/services/auth/routes/auth/post.js b/packages/server/services/auth/routes/auth/post.js index b9dd04de..d10f4ed2 100644 --- a/packages/server/services/auth/routes/auth/post.js +++ b/packages/server/services/auth/routes/auth/post.js @@ -18,7 +18,7 @@ export default async (req, res) => { }) if (userConfig && userConfig.values) { - if (userConfig.values.mfa_enabled) { + if (userConfig.values["auth:mfa"]) { let codeVerified = false // search if is already a mfa session diff --git a/packages/server/services/files/controllers/stream/index.js b/packages/server/services/files/controllers/stream/index.js deleted file mode 100755 index 6994bfa9..00000000 --- a/packages/server/services/files/controllers/stream/index.js +++ /dev/null @@ -1,20 +0,0 @@ -import path from "path" -import createRoutesFromDirectory from "@utils/createRoutesFromDirectory" -import getMiddlewares from "@utils/getMiddlewares" - -export default async (router) => { - const routesPath = path.resolve(__dirname, "routes") - - const middlewares = await getMiddlewares(["withOptionalAuth"]) - - for (const middleware of middlewares) { - router.use(middleware) - } - - router = createRoutesFromDirectory("routes", routesPath, router) - - return { - path: "/stream", - router, - } -} \ No newline at end of file diff --git a/packages/server/services/files/controllers/stream/routes/get/*.js b/packages/server/services/files/controllers/stream/routes/get/*.js deleted file mode 100755 index 0fda1e2a..00000000 --- a/packages/server/services/files/controllers/stream/routes/get/*.js +++ /dev/null @@ -1,24 +0,0 @@ -import { NotFoundError, InternalServerError } from "@shared-classes/Errors" -import mimetypes from "mime-types" - -export default async (req, res) => { - const streamPath = req.params[0] - - global.storage.getObject(process.env.S3_BUCKET, streamPath, (err, dataStream) => { - if (err) { - console.error(err) - return new InternalServerError(req, res, "Error while getting file from storage") - } - - const extname = mimetypes.lookup(streamPath) - - // send chunked response - res.status(200) - - // set headers - res.setHeader("Content-Type", extname) - res.setHeader("Accept-Ranges", "bytes") - - return dataStream.pipe(res) - }) -} \ No newline at end of file diff --git a/packages/server/services/files/controllers/upload/index.js b/packages/server/services/files/controllers/upload/index.js deleted file mode 100755 index 7a718668..00000000 --- a/packages/server/services/files/controllers/upload/index.js +++ /dev/null @@ -1,20 +0,0 @@ -import path from "path" -import createRoutesFromDirectory from "@utils/createRoutesFromDirectory" -import getMiddlewares from "@utils/getMiddlewares" - -export default async (router) => { - const routesPath = path.resolve(__dirname, "routes") - - const middlewares = await getMiddlewares(["withOptionalAuth"]) - - for (const middleware of middlewares) { - router.use(middleware) - } - - router = createRoutesFromDirectory("routes", routesPath, router) - - return { - path: "/upload", - router, - } -} \ No newline at end of file diff --git a/packages/server/services/files/controllers/upload/routes/post/chunk.js b/packages/server/services/files/controllers/upload/routes/post/chunk.js deleted file mode 100755 index f3e96bb4..00000000 --- a/packages/server/services/files/controllers/upload/routes/post/chunk.js +++ /dev/null @@ -1,107 +0,0 @@ -import path from "path" -import fs from "fs" - -import * as Errors from "@shared-classes/Errors" -import FileUpload from "@shared-classes/FileUpload" -import PostProcess from "@services/post-process" - -const cachePath = global.cache.constructor.cachePath - -export default async (req, res) => { - // extract authentification header - let auth = req.session - - if (!auth) { - return new Errors.AuthorizationError(req, res) - } - - const providerType = req.headers["provider-type"] - - const userPath = path.join(cachePath, req.session.user_id) - - // 10 GB in bytes - const maxFileSize = 10 * 1000 * 1000 * 1000 - - // 10MB in bytes - const maxChunkSize = 10 * 1000 * 1000 - - let build = await FileUpload(req, userPath, maxFileSize, maxChunkSize) - .catch((err) => { - console.log("err", err) - - new Errors.InternalServerError(req, res, err.message) - - return false - }) - - if (build === false) { - return false - } else { - if (typeof build === "function") { - try { - build = await build() - - if (!req.headers["no-compression"]) { - build = await PostProcess(build) - } - - // compose remote path - const remotePath = `${req.session.user_id}/${path.basename(build.filepath)}` - - let url = null - - switch (providerType) { - case "premium-cdn": { - // use backblaze b2 - await global.b2Storage.authorize() - - const uploadUrl = await global.b2Storage.getUploadUrl({ - bucketId: process.env.B2_BUCKET_ID, - }) - - const data = await fs.promises.readFile(build.filepath) - - await global.b2Storage.uploadFile({ - uploadUrl: uploadUrl.data.uploadUrl, - uploadAuthToken: uploadUrl.data.authorizationToken, - fileName: remotePath, - data: data, - info: build.metadata - }) - - url = `https://${process.env.B2_CDN_ENDPOINT}/${process.env.B2_BUCKET}/${remotePath}` - - break - } - default: { - // upload to storage - await global.storage.fPutObject(process.env.S3_BUCKET, remotePath, build.filepath, build.metadata ?? { - "Content-Type": build.mimetype, - }) - - // compose url - url = global.storage.composeRemoteURL(remotePath) - - break - } - } - - // remove from cache - fs.promises.rm(build.cachePath, { recursive: true, force: true }) - - return res.json({ - name: build.filename, - id: remotePath, - url: url, - }) - } catch (error) { - console.log(error) - return new Errors.InternalServerError(req, res, error.message) - } - } - - return res.json({ - success: true, - }) - } -} \ No newline at end of file diff --git a/packages/server/services/files/file.service.js b/packages/server/services/files/file.service.js index 57dbeec8..593305f1 100755 --- a/packages/server/services/files/file.service.js +++ b/packages/server/services/files/file.service.js @@ -2,11 +2,13 @@ import { Server } from "linebridge/src/server" import B2 from "backblaze-b2" +import DbManager from "@shared-classes/DbManager" import RedisClient from "@shared-classes/RedisClient" import StorageClient from "@shared-classes/StorageClient" import CacheService from "@shared-classes/CacheService" import SharedMiddlewares from "@shared-middlewares" +import LimitsClass from "@shared-classes/Limits" class API extends Server { static refName = "files" @@ -14,13 +16,12 @@ class API extends Server { static routesPath = `${__dirname}/routes` static listen_port = process.env.HTTP_LISTEN_PORT ?? 3002 - static maxBodyLength = 1000 * 1000 * 1000 - middlewares = { ...SharedMiddlewares } contexts = { + db: new DbManager(), cache: new CacheService(), redis: RedisClient(), storage: StorageClient(), @@ -28,12 +29,19 @@ class API extends Server { applicationKeyId: process.env.B2_KEY_ID, applicationKey: process.env.B2_APP_KEY, }), + limits: {}, } async onInitialize() { + global.storage = this.contexts.storage + global.b2Storage = this.contexts.b2Storage + + await this.contexts.db.initialize() await this.contexts.redis.initialize() await this.contexts.storage.initialize() await this.contexts.b2Storage.authorize() + + this.contexts.limits = await LimitsClass.get() } } diff --git a/packages/server/services/files/routes/upload/chunk/post.js b/packages/server/services/files/routes/upload/chunk/post.js index 0fd030c8..d3e44d07 100644 --- a/packages/server/services/files/routes/upload/chunk/post.js +++ b/packages/server/services/files/routes/upload/chunk/post.js @@ -1,104 +1,54 @@ import path from "path" import fs from "fs" -import FileUpload from "@shared-classes/FileUpload" -import PostProcess from "@services/post-process" +import ChunkFileUpload from "@shared-classes/ChunkFileUpload" + +import RemoteUpload from "@services/remoteUpload" export default { - useContext: ["cache", "storage", "b2Storage"], + useContext: ["cache", "limits"], middlewares: [ "withAuthentication", ], fn: async (req, res) => { - const { cache, storage, b2Storage } = this.default.contexts - const providerType = req.headers["provider-type"] - const userPath = path.join(cache.constructor.cachePath, req.session.user_id) + const userPath = path.join(this.default.contexts.cache.constructor.cachePath, req.auth.session.user_id) - // 10 GB in bytes - const maxFileSize = 10 * 1000 * 1000 * 1000 + const tmpPath = path.resolve(userPath) - // 10MB in bytes - const maxChunkSize = 10 * 1000 * 1000 + let build = await ChunkFileUpload(req, { + tmpDir: tmpPath, + maxFileSize: parseInt(this.default.contexts.limits.maxFileSizeInMB) * 1024 * 1024, + maxChunkSize: parseInt(this.default.contexts.limits.maxChunkSizeInMB) * 1024 * 1024, + }).catch((err) => { + throw new OperationError(err.code, err.message) + }) - let build = await FileUpload(req, userPath, maxFileSize, maxChunkSize) - .catch((err) => { - console.log("err", err) + if (typeof build === "function") { + try { + build = await build() - throw new OperationError(500, err.message) - }) + const result = await RemoteUpload({ + parentDir: req.auth.session.user_id, + source: build.filePath, + service: providerType, + useCompression: req.headers["use-compression"] ?? true, + cachePath: tmpPath, + }) - if (build === false) { - return false - } else { - if (typeof build === "function") { - try { - build = await build() + fs.promises.rm(tmpPath, { recursive: true, force: true }) - if (!req.headers["no-compression"]) { - build = await PostProcess(build) - } + return result + } catch (error) { + fs.promises.rm(tmpPath, { recursive: true, force: true }) - // compose remote path - const remotePath = `${req.session.user_id}/${path.basename(build.filepath)}` - - let url = null - - switch (providerType) { - case "premium-cdn": { - // use backblaze b2 - await b2Storage.authorize() - - const uploadUrl = await b2Storage.getUploadUrl({ - bucketId: process.env.B2_BUCKET_ID, - }) - - const data = await fs.promises.readFile(build.filepath) - - await b2Storage.uploadFile({ - uploadUrl: uploadUrl.data.uploadUrl, - uploadAuthToken: uploadUrl.data.authorizationToken, - fileName: remotePath, - data: data, - info: build.metadata - }) - - url = `https://${process.env.B2_CDN_ENDPOINT}/${process.env.B2_BUCKET}/${remotePath}` - - break - } - default: { - // upload to storage - await storage.fPutObject(process.env.S3_BUCKET, remotePath, build.filepath, build.metadata ?? { - "Content-Type": build.mimetype, - }) - - // compose url - url = storage.composeRemoteURL(remotePath) - - break - } - } - - // remove from cache - fs.promises.rm(build.cachePath, { recursive: true, force: true }) - - return res.json({ - name: build.filename, - id: remotePath, - url: url, - }) - } catch (error) { - console.log(error) - - throw new OperationError(500, error.message) - } + throw new OperationError(error.code ?? 500, error.message ?? "Failed to upload file") } + } - return res.json({ - success: true, - }) + return { + ok: 1 } } } \ No newline at end of file diff --git a/packages/server/services/files/routes/upload/file/post.js b/packages/server/services/files/routes/upload/file/post.js new file mode 100644 index 00000000..d882b179 --- /dev/null +++ b/packages/server/services/files/routes/upload/file/post.js @@ -0,0 +1,48 @@ +import path from "node:path" +import fs from "node:fs" + +import RemoteUpload from "@services/remoteUpload" + +export default { + useContext: ["cache"], + middlewares: [ + "withAuthentication", + ], + fn: async (req, res) => { + const { cache } = this.default.contexts + + const providerType = req.headers["provider-type"] ?? "standard" + + const userPath = path.join(cache.constructor.cachePath, req.auth.session.user_id) + + let localFilepath = null + let tmpPath = path.resolve(userPath, `${Date.now()}`) + + await req.multipart(async (field) => { + if (!field.file) { + throw new OperationError(400, "Missing file") + } + + localFilepath = path.join(tmpPath, field.file.name) + + const existTmpDir = await fs.promises.stat(tmpPath).then(() => true).catch(() => false) + + if (!existTmpDir) { + await fs.promises.mkdir(tmpPath, { recursive: true }) + } + + await field.write(localFilepath) + }) + + const result = await RemoteUpload({ + parentDir: req.auth.session.user_id, + source: localFilepath, + service: providerType, + useCompression: req.headers["use-compression"] ?? true, + }) + + fs.promises.rm(tmpPath, { recursive: true, force: true }) + + return result + } +} diff --git a/packages/server/services/files/routes/upload/get.js b/packages/server/services/files/routes/upload/get.js new file mode 100644 index 00000000..20af2368 --- /dev/null +++ b/packages/server/services/files/routes/upload/get.js @@ -0,0 +1,6 @@ +export default { + useContext: ["cache", "limits"], + fn: async () => { + return this.default.contexts.limits + } +} \ No newline at end of file diff --git a/packages/server/services/files/services/post-process/video/index.js b/packages/server/services/files/services/post-process/video/index.js index a4ece015..2abacc99 100755 --- a/packages/server/services/files/services/post-process/video/index.js +++ b/packages/server/services/files/services/post-process/video/index.js @@ -29,11 +29,14 @@ async function processVideo( videoBitrate = 2024, } = options - const result = await videoTranscode(file.filepath, file.cachePath, { + const result = await videoTranscode(file.filepath, { videoCodec, format, audioBitrate, videoBitrate: [videoBitrate, true], + extraOptions: [ + "-threads 1" + ] }) file.filepath = result.filepath diff --git a/packages/server/services/files/services/remoteUpload/index.js b/packages/server/services/files/services/remoteUpload/index.js new file mode 100644 index 00000000..976bb5b3 --- /dev/null +++ b/packages/server/services/files/services/remoteUpload/index.js @@ -0,0 +1,123 @@ +import fs from "node:fs" +import path from "node:path" +import mimeTypes from "mime-types" +import getFileHash from "@shared-utils/readFileHash" + +import PostProcess from "../post-process" + +export async function standardUpload({ + source, + remotePath, + metadata, +}) { + // upload to storage + await global.storage.fPutObject(process.env.S3_BUCKET, remotePath, source, metadata) + + // compose url + const url = storage.composeRemoteURL(remotePath) + + return { + id: remotePath, + url: url, + metadata: metadata, + } +} + +export async function b2Upload({ + source, + remotePath, + metadata, +}) { + // use backblaze b2 + await b2Storage.authorize() + + const uploadUrl = await global.b2Storage.getUploadUrl({ + bucketId: process.env.B2_BUCKET_ID, + }) + + const data = await fs.promises.readFile(source) + + await global.b2Storage.uploadFile({ + uploadUrl: uploadUrl.data.uploadUrl, + uploadAuthToken: uploadUrl.data.authorizationToken, + fileName: remotePath, + data: data, + info: metadata + }) + + const url = `https://${process.env.B2_CDN_ENDPOINT}/${process.env.B2_BUCKET}/${remotePath}` + + return { + id: remotePath, + url: url, + metadata: metadata, + } +} + +export default async ({ + source, + parentDir, + service, + useCompression, + cachePath, +}) => { + if (!source) { + throw new OperationError(500, "source is required") + } + + if (!service) { + service = "standard" + } + + if (!parentDir) { + parentDir = "/" + } + + if (useCompression) { + try { + const processOutput = await PostProcess({ filepath: source, cachePath }) + + if (processOutput) { + if (processOutput.filepath) { + source = processOutput.filepath + } + } + } catch (error) { + console.error(error) + throw new OperationError(500, `Failed to process file`) + } + } + + const type = mimeTypes.lookup(path.basename(source)) + const hash = await getFileHash(fs.createReadStream(source)) + + const remotePath = path.join(parentDir, hash) + + let result = {} + + const metadata = { + "Content-Type": type, + "File-Hash": hash, + } + + switch (service) { + case "b2": + result = await b2Upload({ + remotePath, + source, + metadata, + }) + break + case "standard": + result = await standardUpload({ + remotePath, + source, + metadata, + }) + break + default: + throw new OperationError(500, "Unsupported service") + } + + return result +} \ No newline at end of file diff --git a/packages/server/services/files/services/videoTranscode/index.js b/packages/server/services/files/services/videoTranscode/index.js index 63228153..c25cedb9 100755 --- a/packages/server/services/files/services/videoTranscode/index.js +++ b/packages/server/services/files/services/videoTranscode/index.js @@ -10,11 +10,20 @@ const defaultParams = { format: "webm", } -export default (input, cachePath, params = defaultParams) => { +const maxTasks = 5 + +export default (input, params = defaultParams) => { return new Promise((resolve, reject) => { - const filename = path.basename(input) - const outputFilename = `${filename.split(".")[0]}_ff.${params.format ?? "webm"}` - const outputFilepath = `${cachePath}/${outputFilename}` + if (!global.ffmpegTasks) { + global.ffmpegTasks = [] + } + + if (global.ffmpegTasks.length >= maxTasks) { + return reject(new Error("Too many transcoding tasks")) + } + + const outputFilename = `${path.basename(input).split(".")[0]}_ff.${params.format ?? "webm"}` + const outputFilepath = `${path.dirname(input)}/${outputFilename}` console.debug(`[TRANSCODING] Transcoding ${input} to ${outputFilepath}`) @@ -22,8 +31,8 @@ export default (input, cachePath, params = defaultParams) => { console.debug(`[TRANSCODING] Finished transcode ${input} to ${outputFilepath}`) return resolve({ - filepath: outputFilepath, filename: outputFilename, + filepath: outputFilepath, }) } @@ -42,22 +51,33 @@ export default (input, cachePath, params = defaultParams) => { } // chain methods - Object.keys(commands).forEach((key) => { + for (let key in commands) { if (exec === null) { exec = ffmpeg(commands[key]) - } else { - if (typeof exec[key] !== "function") { - console.warn(`[TRANSCODING] Method ${key} is not a function`) - return false + continue + } + + if (key === "extraOptions" && Array.isArray(commands[key])) { + for (const option of commands[key]) { + exec = exec.inputOptions(option) } - if (Array.isArray(commands[key])) { - exec = exec[key](...commands[key]) - } else { - exec = exec[key](commands[key]) - } + continue } - }) + + if (typeof exec[key] !== "function") { + console.warn(`[TRANSCODING] Method ${key} is not a function`) + return false + } + + if (Array.isArray(commands[key])) { + exec = exec[key](...commands[key]) + } else { + exec = exec[key](commands[key]) + } + + continue + } exec .on("error", onError) diff --git a/packages/server/services/main/main.service.js b/packages/server/services/main/main.service.js index b9171f59..d5d209fb 100755 --- a/packages/server/services/main/main.service.js +++ b/packages/server/services/main/main.service.js @@ -1,28 +1,16 @@ import { Server } from "linebridge/src/server" -import { Config, User } from "@db_models" import DbManager from "@shared-classes/DbManager" -import StorageClient from "@shared-classes/StorageClient" -import Token from "@lib/token" +import StartupDB from "./startup_db" import SharedMiddlewares from "@shared-middlewares" export default class API extends Server { static refName = "main" static useEngine = "hyper-express" + static routesPath = `${__dirname}/routes` static listen_port = process.env.HTTP_LISTEN_PORT || 3000 - static requireWSAuth = true - - constructor(params) { - super(params) - - global.DEFAULT_POSTING_POLICY = { - maxMessageLength: 512, - maximumFileSize: 80 * 1024 * 1024, - maximunFilesPerRequest: 20, - } - } middlewares = { ...require("@middlewares").default, @@ -31,102 +19,16 @@ export default class API extends Server { events = require("./events") - storage = global.storage = StorageClient() - DB = new DbManager() + contexts = { + db: new DbManager(), + } async onInitialize() { - await this.DB.initialize() - await this.storage.initialize() - - await this.initializeConfigDB() - await this.checkSetup() + await this.contexts.db.initialize() + await StartupDB() } - initializeConfigDB = async () => { - let serverConfig = await Config.findOne({ key: "server" }).catch(() => { - return false - }) - - if (!serverConfig) { - serverConfig = new Config({ - key: "server", - value: { - setup: false, - }, - }) - - - await serverConfig.save() - } - } - - checkSetup = async () => { - return new Promise(async (resolve, reject) => { - let setupOk = (await Config.findOne({ key: "server" })).value?.setup ?? false - - if (!setupOk) { - console.log("⚠️ Server setup is not complete, running setup proccess.") - - let setupScript = await import("./setup") - - setupScript = setupScript.default ?? setupScript - - try { - for await (let script of setupScript) { - await script() - } - - console.log("✅ Server setup complete.") - - await Config.updateOne({ key: "server" }, { value: { setup: true } }) - - return resolve() - } catch (error) { - console.log("❌ Server setup failed.") - console.error(error) - process.exit(1) - } - } - - return resolve() - }) - } - - handleWsAuth = async (socket, token, err) => { - try { - const validation = await Token.validate(token) - - if (!validation.valid) { - if (validation.error) { - return err(`auth:server_error`) - } - - return err(`auth:token_invalid`) - } - - const userData = await User.findById(validation.data.user_id).catch((err) => { - console.error(`[${socket.id}] failed to get user data caused by server error`, err) - - return null - }) - - if (!userData) { - return err(`auth:user_failed`) - } - - socket.userData = userData - socket.token = token - socket.session = validation.data - - return { - token: token, - username: userData.username, - user_id: userData._id, - } - } catch (error) { - return err(`auth:authentification_failed`, error) - } - } + handleWsAuth = require("@shared-lib/handleWsAuth").default } Boot(API) \ No newline at end of file diff --git a/packages/server/services/main/routes/limits/get.js b/packages/server/services/main/routes/limits/get.js new file mode 100644 index 00000000..bafb50c1 --- /dev/null +++ b/packages/server/services/main/routes/limits/get.js @@ -0,0 +1,7 @@ +import LimitsClass from "@shared-classes/Limits" + +export default async (req) => { + const key = req.query.key + + return await LimitsClass.get(key) +} \ No newline at end of file diff --git a/packages/server/services/main/routes/ping/get.js b/packages/server/services/main/routes/ping/get.js new file mode 100644 index 00000000..c0414815 --- /dev/null +++ b/packages/server/services/main/routes/ping/get.js @@ -0,0 +1,3 @@ +export default () => { + return "pong" +} \ No newline at end of file diff --git a/packages/server/services/main/startup_db.js b/packages/server/services/main/startup_db.js new file mode 100644 index 00000000..e43a7835 --- /dev/null +++ b/packages/server/services/main/startup_db.js @@ -0,0 +1,47 @@ +import { Config } from "@db_models" + +export default async () => { + let serverConfig = await Config.findOne({ key: "server" }).catch(() => { + return false + }) + + if (!serverConfig) { + console.log("Server config DB is not created, creating it...") + + serverConfig = new Config({ + key: "server", + value: { + setup: false, + }, + }) + + await serverConfig.save() + } + + const setupScriptsCompleted = (serverConfig.value?.setup) ?? false + + if (!setupScriptsCompleted) { + console.log("⚠️ Server setup is not complete, running setup proccess.") + + let setupScript = await import("./setup") + setupScript = setupScript.default ?? setupScript + + try { + for await (let script of setupScript) { + await script() + } + + console.log("✅ Server setup complete.") + + await Config.updateOne({ key: "server" }, { value: { setup: true } }) + + serverConfig = await Config.findOne({ key: "server" }) + + return resolve() + } catch (error) { + console.log("❌ Server setup failed.") + console.error(error) + process.exit(1) + } + } +} \ No newline at end of file diff --git a/packages/server/services/notifications/notifications.service.js b/packages/server/services/notifications/notifications.service.js new file mode 100644 index 00000000..beb2cfe8 --- /dev/null +++ b/packages/server/services/notifications/notifications.service.js @@ -0,0 +1,32 @@ +import { Server } from "linebridge/src/server" + +import DbManager from "@shared-classes/DbManager" +import RedisClient from "@shared-classes/RedisClient" + +import SharedMiddlewares from "@shared-middlewares" + +class API extends Server { + static refName = "notifications" + static useEngine = "hyper-express" + static wsRoutesPath = `${__dirname}/ws_routes` + static routesPath = `${__dirname}/routes` + static listen_port = process.env.HTTP_LISTEN_PORT ?? 3009 + + middlewares = { + ...SharedMiddlewares + } + + contexts = { + db: new DbManager(), + redis: RedisClient(), + } + + async onInitialize() { + await this.contexts.db.initialize() + await this.contexts.redis.initialize() + } + + handleWsAuth = require("@shared-lib/handleWsAuth").default +} + +Boot(API) \ No newline at end of file diff --git a/packages/server/services/notifications/package.json b/packages/server/services/notifications/package.json new file mode 100644 index 00000000..bcd2c871 --- /dev/null +++ b/packages/server/services/notifications/package.json @@ -0,0 +1,6 @@ +{ + "name": "notifications", + "version": "1.0.0", + "main": "index.js", + "license": "MIT" +} diff --git a/packages/server/services/notifications/routes/notifications/test/get.js b/packages/server/services/notifications/routes/notifications/test/get.js new file mode 100644 index 00000000..96496f17 --- /dev/null +++ b/packages/server/services/notifications/routes/notifications/test/get.js @@ -0,0 +1,5 @@ +export default () =>{ + return { + hi: "hola xd" + } +} \ No newline at end of file diff --git a/packages/server/services/notifications/ws_routes/self/new.js b/packages/server/services/notifications/ws_routes/self/new.js new file mode 100644 index 00000000..f0dcfaa9 --- /dev/null +++ b/packages/server/services/notifications/ws_routes/self/new.js @@ -0,0 +1,9 @@ +export default async () => { + global.rtengine.io.of("/").emit("new", { + hi: "hola xd" + }) + + return { + hi: "hola xd" + } +} \ No newline at end of file diff --git a/packages/server/services/posts/classes/posts/index.js b/packages/server/services/posts/classes/posts/index.js index 9cd34dd0..6b5946e2 100644 --- a/packages/server/services/posts/classes/posts/index.js +++ b/packages/server/services/posts/classes/posts/index.js @@ -1,5 +1,6 @@ export default class Posts { - static feed = require("./methods/feed").default + static timeline = require("./methods/timeline").default + static globalTimeline = require("./methods/globalTimeline").default static data = require("./methods/data").default static getLiked = require("./methods/getLiked").default static getSaved = require("./methods/getSaved").default @@ -10,4 +11,7 @@ export default class Posts { static toggleLike = require("./methods/toggleLike").default static report = require("./methods/report").default static flag = require("./methods/flag").default + static delete = require("./methods/delete").default + static update = require("./methods/update").default + static replies = require("./methods/replies").default } \ No newline at end of file diff --git a/packages/server/services/posts/classes/posts/methods/create.js b/packages/server/services/posts/classes/posts/methods/create.js index 87bd047a..f8118257 100644 --- a/packages/server/services/posts/classes/posts/methods/create.js +++ b/packages/server/services/posts/classes/posts/methods/create.js @@ -2,6 +2,7 @@ import requiredFields from "@shared-utils/requiredFields" import { DateTime } from "luxon" import { Post } from "@db_models" +import fullfill from "./fullfill" export default async (payload = {}) => { await requiredFields(["user_id"], payload) @@ -32,9 +33,13 @@ export default async (payload = {}) => { post = post.toObject() - // TODO: create background jobs (nsfw dectection) + const result = await fullfill({ + posts: post, + for_user_id: user_id + }) - // TODO: Push event to Websocket + // TODO: create background jobs (nsfw dectection) + global.rtengine.io.of("/").emit(`post.new`, result[0]) return post } \ No newline at end of file diff --git a/packages/server/services/posts/classes/posts/methods/data.js b/packages/server/services/posts/classes/posts/methods/data.js index a0b079c2..c2daee3a 100644 --- a/packages/server/services/posts/classes/posts/methods/data.js +++ b/packages/server/services/posts/classes/posts/methods/data.js @@ -1,16 +1,23 @@ import { Post } from "@db_models" import fullfillPostsData from "./fullfill" +const maxLimit = 300 + export default async (payload = {}) => { let { for_user_id, post_id, query = {}, - skip = 0, + trim = 0, limit = 20, sort = { created_at: -1 }, } = payload + // set a hard limit on the number of posts to retrieve, used for pagination + if (limit > maxLimit) { + limit = maxLimit + } + let posts = [] if (post_id) { @@ -24,7 +31,7 @@ export default async (payload = {}) => { } else { posts = await Post.find({ ...query }) .sort(sort) - .skip(skip) + .skip(trim) .limit(limit) } @@ -32,7 +39,6 @@ export default async (payload = {}) => { posts = await fullfillPostsData({ posts, for_user_id, - skip, }) // if post_id is specified, return only one post diff --git a/packages/server/services/posts/classes/posts/methods/delete.js b/packages/server/services/posts/classes/posts/methods/delete.js new file mode 100644 index 00000000..b1cda1b0 --- /dev/null +++ b/packages/server/services/posts/classes/posts/methods/delete.js @@ -0,0 +1,38 @@ +import { Post, PostLike, PostSave } from "@db_models" + +export default async (payload = {}) => { + let { + post_id + } = payload + + if (!post_id) { + throw new OperationError(400, "Missing post_id") + } + + await Post.deleteOne({ + _id: post_id, + }).catch((err) => { + throw new OperationError(500, `An error has occurred: ${err.message}`) + }) + + // search for likes + await PostLike.deleteMany({ + post_id: post_id, + }).catch((err) => { + throw new OperationError(500, `An error has occurred: ${err.message}`) + }) + + // deleted from saved + await PostSave.deleteMany({ + post_id: post_id, + }).catch((err) => { + throw new OperationError(500, `An error has occurred: ${err.message}`) + }) + + global.rtengine.io.of("/").emit(`post.delete`, post_id) + global.rtengine.io.of("/").emit(`post.delete.${post_id}`, post_id) + + return { + deleted: true, + } +} \ No newline at end of file diff --git a/packages/server/services/posts/classes/posts/methods/fromUserId.js b/packages/server/services/posts/classes/posts/methods/fromUserId.js index 0ac5e5e7..38f5c731 100644 --- a/packages/server/services/posts/classes/posts/methods/fromUserId.js +++ b/packages/server/services/posts/classes/posts/methods/fromUserId.js @@ -4,7 +4,7 @@ export default async (payload = {}) => { const { for_user_id, user_id, - skip, + trim, limit, } = payload @@ -14,8 +14,8 @@ export default async (payload = {}) => { return await GetData({ for_user_id: for_user_id, - skip, - limit, + trim: trim, + limit: limit, query: { user_id: { $in: user_id diff --git a/packages/server/services/posts/classes/posts/methods/fullfill.js b/packages/server/services/posts/classes/posts/methods/fullfill.js index 6551b608..927c2f98 100644 --- a/packages/server/services/posts/classes/posts/methods/fullfill.js +++ b/packages/server/services/posts/classes/posts/methods/fullfill.js @@ -1,4 +1,4 @@ -import { User, Comment, PostLike, SavedPost } from "@db_models" +import { User, PostLike, PostSave, Post } from "@db_models" export default async (payload = {}) => { let { @@ -14,33 +14,26 @@ export default async (payload = {}) => { return [] } - let savedPostsIds = [] + let postsSavesIds = [] if (for_user_id) { - const savedPosts = await SavedPost.find({ user_id: for_user_id }) + const postsSaves = await PostSave.find({ user_id: for_user_id }) .sort({ saved_at: -1 }) - savedPostsIds = savedPosts.map((savedPost) => savedPost.post_id) + postsSavesIds = postsSaves.map((postSave) => postSave.post_id) } - let [usersData, likesData, commentsData] = await Promise.all([ + let [usersData, likesData, repliesData] = await Promise.all([ User.find({ _id: { $in: posts.map((post) => post.user_id) } - }) - .select("-email") - .select("-birthday"), + }).catch(() => { }), PostLike.find({ post_id: { $in: posts.map((post) => post._id) } }).catch(() => []), - Comment.find({ - parent_id: { - $in: posts.map((post) => post._id) - } - }).catch(() => []), ]) // wrap likesData by post_id @@ -54,19 +47,10 @@ export default async (payload = {}) => { return acc }, {}) - // wrap commentsData by post_id - commentsData = commentsData.reduce((acc, comment) => { - if (!acc[comment.parent_id]) { - acc[comment.parent_id] = [] - } - - acc[comment.parent_id].push(comment) - - return acc - }, {}) - posts = await Promise.all(posts.map(async (post, index) => { - post = post.toObject() + if (typeof post.toObject === "function") { + post = post.toObject() + } let user = usersData.find((user) => user._id.toString() === post.user_id.toString()) @@ -77,22 +61,21 @@ export default async (payload = {}) => { } } + if (post.reply_to) { + post.reply_to_data = await Post.findById(post.reply_to) + } + let likes = likesData[post._id.toString()] ?? [] post.countLikes = likes.length - let comments = commentsData[post._id.toString()] ?? [] - - post.countComments = comments.length - if (for_user_id) { post.isLiked = likes.some((like) => like.user_id.toString() === for_user_id) - post.isSaved = savedPostsIds.includes(post._id.toString()) + post.isSaved = postsSavesIds.includes(post._id.toString()) } return { ...post, - comments: comments.map((comment) => comment._id.toString()), user, } })) diff --git a/packages/server/services/posts/classes/posts/methods/getLiked.js b/packages/server/services/posts/classes/posts/methods/getLiked.js index bd823c92..84ed4a6a 100644 --- a/packages/server/services/posts/classes/posts/methods/getLiked.js +++ b/packages/server/services/posts/classes/posts/methods/getLiked.js @@ -2,7 +2,7 @@ import { PostLike } from "@db_models" import GetData from "./data" export default async (payload = {}) => { - let { user_id } = payload + let { user_id, trim, limit } = payload if (!user_id) { throw new OperationError(400, "Missing user_id") @@ -13,6 +13,8 @@ export default async (payload = {}) => { ids = ids.map((item) => item.post_id) return await GetData({ + trim: trim, + limit: limit, for_user_id: user_id, query: { _id: { diff --git a/packages/server/services/posts/classes/posts/methods/getSaved.js b/packages/server/services/posts/classes/posts/methods/getSaved.js index c9ce760a..e3697df1 100644 --- a/packages/server/services/posts/classes/posts/methods/getSaved.js +++ b/packages/server/services/posts/classes/posts/methods/getSaved.js @@ -1,18 +1,24 @@ -import { SavedPost } from "@db_models" +import { PostSave } from "@db_models" import GetData from "./data" export default async (payload = {}) => { - let { user_id } = payload + let { user_id, trim, limit } = payload if (!user_id) { throw new OperationError(400, "Missing user_id") } - let ids = await SavedPost.find({ user_id }) + let ids = await PostSave.find({ user_id }) + + if (ids.length === 0) { + return [] + } ids = ids.map((item) => item.post_id) return await GetData({ + trim: trim, + limit: limit, for_user_id: user_id, query: { _id: { @@ -20,5 +26,4 @@ export default async (payload = {}) => { } } }) -} - +} \ No newline at end of file diff --git a/packages/server/services/posts/classes/posts/methods/globalTimeline.js b/packages/server/services/posts/classes/posts/methods/globalTimeline.js new file mode 100644 index 00000000..29ff1ccf --- /dev/null +++ b/packages/server/services/posts/classes/posts/methods/globalTimeline.js @@ -0,0 +1,20 @@ +import GetPostData from "./data" + +export default async (payload = {}) => { + let { + user_id, + trim, + limit, + } = payload + + let query = {} + + const posts = await GetPostData({ + for_user_id: user_id, + trim, + limit, + query: query, + }) + + return posts +} \ No newline at end of file diff --git a/packages/server/services/posts/classes/posts/methods/replies.js b/packages/server/services/posts/classes/posts/methods/replies.js new file mode 100644 index 00000000..2cc8ec33 --- /dev/null +++ b/packages/server/services/posts/classes/posts/methods/replies.js @@ -0,0 +1,29 @@ +import { Post } from "@db_models" +import fullfillPostsData from "./fullfill" + +export default async (payload = {}) => { + const { + post_id, + for_user_id, + trim = 0, + limit = 50, + } = payload + + if (!post_id) { + throw new OperationError(400, "Post ID is required") + } + + let posts = await Post.find({ + reply_to: post_id, + }) + .limit(limit) + .skip(trim) + .sort({ created_at: -1 }) + + posts = await fullfillPostsData({ + posts, + for_user_id, + }) + + return posts +} \ No newline at end of file diff --git a/packages/server/services/posts/classes/posts/methods/feed.js b/packages/server/services/posts/classes/posts/methods/timeline.js similarity index 96% rename from packages/server/services/posts/classes/posts/methods/feed.js rename to packages/server/services/posts/classes/posts/methods/timeline.js index df292bfd..30702f61 100644 --- a/packages/server/services/posts/classes/posts/methods/feed.js +++ b/packages/server/services/posts/classes/posts/methods/timeline.js @@ -5,7 +5,7 @@ import GetPostData from "./data" export default async (payload = {}) => { let { user_id, - skip, + trim, limit, } = payload @@ -34,7 +34,7 @@ export default async (payload = {}) => { const posts = await GetPostData({ for_user_id: user_id, - skip, + trim, limit, query: query, }) diff --git a/packages/server/services/posts/classes/posts/methods/toggleLike.js b/packages/server/services/posts/classes/posts/methods/toggleLike.js index 7c7c7f54..64477469 100644 --- a/packages/server/services/posts/classes/posts/methods/toggleLike.js +++ b/packages/server/services/posts/classes/posts/methods/toggleLike.js @@ -17,8 +17,8 @@ export default async (payload = {}) => { } let likeObj = await PostLike.findOne({ - post_id, user_id, + post_id, }) if (typeof to === "undefined") { @@ -40,13 +40,22 @@ export default async (payload = {}) => { await PostLike.findByIdAndDelete(likeObj._id) } - // global.engine.ws.io.of("/").emit(`post.${post_id}.likes.update`, { - // to, - // post_id, - // user_id, - // }) + const count = await PostLike.count({ + post_id, + }) + + const eventData = { + to, + post_id, + user_id, + count: count, + } + + global.rtengine.io.of("/").emit(`post.${post_id}.likes.update`, eventData) + global.rtengine.io.of("/").emit(`post.like.update`, eventData) return { - liked: to + liked: to, + count: count } } \ No newline at end of file diff --git a/packages/server/services/posts/classes/posts/methods/toggleSave.js b/packages/server/services/posts/classes/posts/methods/toggleSave.js index df6f686f..53f86eb5 100644 --- a/packages/server/services/posts/classes/posts/methods/toggleSave.js +++ b/packages/server/services/posts/classes/posts/methods/toggleSave.js @@ -1,4 +1,4 @@ -import { Post, SavedPost } from "@db_models" +import { Post, PostSave } from "@db_models" export default async (payload = {}) => { let { post_id, user_id } = payload @@ -16,16 +16,16 @@ export default async (payload = {}) => { throw new OperationError(404, "Post not found") } - let post = await SavedPost.findOne({ post_id, user_id }) + let post = await PostSave.findOne({ post_id, user_id }) if (post) { - await SavedPost.findByIdAndDelete(post._id).catch((err) => { + await PostSave.findByIdAndDelete(post._id).catch((err) => { throw new OperationError(500, `An error has occurred: ${err.message}`) }) post = null } else { - post = new SavedPost({ + post = new PostSave({ post_id, user_id, }) diff --git a/packages/server/services/posts/classes/posts/methods/update.js b/packages/server/services/posts/classes/posts/methods/update.js new file mode 100644 index 00000000..af32da67 --- /dev/null +++ b/packages/server/services/posts/classes/posts/methods/update.js @@ -0,0 +1,32 @@ +import { Post } from "@db_models" +import { DateTime } from "luxon" +import fullfill from "./fullfill" + +export default async (post_id, update) => { + let post = await Post.findById(post_id) + + if (!post) { + throw new OperationError(404, "Post not found") + } + + const updateKeys = Object.keys(update) + + updateKeys.forEach((key) => { + post[key] = update[key] + }) + + post.updated_at = DateTime.local().toISO() + + await post.save() + + post = post.toObject() + + const result = await fullfill({ + posts: post, + }) + + global.rtengine.io.of("/").emit(`post.update`, result[0]) + global.rtengine.io.of("/").emit(`post.update.${post_id}`, result[0]) + + return result[0] +} \ No newline at end of file diff --git a/packages/server/services/posts/posts.service.js b/packages/server/services/posts/posts.service.js index b1cb218d..ffe79cb8 100644 --- a/packages/server/services/posts/posts.service.js +++ b/packages/server/services/posts/posts.service.js @@ -17,13 +17,15 @@ export default class API extends Server { contexts = { db: new DbManager(), - redis: RedisClient() + redis: RedisClient(), } async onInitialize() { await this.contexts.db.initialize() await this.contexts.redis.initialize() } + + handleWsAuth = require("@shared-lib/handleWsAuth").default } Boot(API) \ No newline at end of file diff --git a/packages/server/services/posts/routes/posts/[post_id]/delete.js b/packages/server/services/posts/routes/posts/[post_id]/delete.js new file mode 100644 index 00000000..fe1315c5 --- /dev/null +++ b/packages/server/services/posts/routes/posts/[post_id]/delete.js @@ -0,0 +1,27 @@ +import PostClass from "@classes/posts" +import { Post } from "@db_models" +export default { + middlewares: ["withAuthentication"], + fn: async (req, res) => { + // check if post is owned or if is admin + const post = await Post.findById(req.params.post_id).catch(() => { + return false + }) + + if (!post) { + throw new OperationError(404, "Post not found") + } + + const user = await req.auth.user() + + if (post.user_id.toString() !== user._id.toString()) { + if (!user.roles.includes("admin")) { + throw new OperationError(403, "You cannot delete this post") + } + } + + return await PostClass.delete({ + post_id: req.params.post_id + }) + } +} \ No newline at end of file diff --git a/packages/server/services/posts/routes/posts/[post_id]/replies/get.js b/packages/server/services/posts/routes/posts/[post_id]/replies/get.js new file mode 100644 index 00000000..059837e5 --- /dev/null +++ b/packages/server/services/posts/routes/posts/[post_id]/replies/get.js @@ -0,0 +1,13 @@ +import PostClass from "@classes/posts" + +export default { + middlewares: ["withOptionalAuthentication"], + fn: async (req) => { + return await PostClass.replies({ + post_id: req.params.post_id, + for_user_id: req.auth?.session.user_id, + trim: req.query.trim, + limit: req.query.limit + }) + } +} \ No newline at end of file diff --git a/packages/server/services/posts/routes/posts/[post_id]/update/put.js b/packages/server/services/posts/routes/posts/[post_id]/update/put.js new file mode 100644 index 00000000..8f2007e8 --- /dev/null +++ b/packages/server/services/posts/routes/posts/[post_id]/update/put.js @@ -0,0 +1,44 @@ +import PostClass from "@classes/posts" +import { Post } from "@db_models" + +const AllowedFields = ["message", "tags", "attachments"] + +// TODO: Get limits from LimitsAPI +const MaxStringsLengths = { + message: 2000 +} + +export default { + middlewares: ["withAuthentication"], + fn: async (req) => { + let update = {} + + const post = await Post.findById(req.params.post_id) + + if (!post) { + throw new OperationError(404, "Post not found") + } + + if (post.user_id !== req.auth.session.user_id) { + throw new OperationError(403, "You cannot edit this post") + } + + AllowedFields.forEach((key) => { + if (typeof req.body[key] !== "undefined") { + // check maximung strings length + if (typeof req.body[key] === "string" && MaxStringsLengths[key]) { + if (req.body[key].length > MaxStringsLengths[key]) { + // create a substring + update[key] = req.body[key].substring(0, MaxStringsLengths[key]) + } else { + update[key] = req.body[key] + } + } else { + update[key] = req.body[key] + } + } + }) + + return await PostClass.update(req.params.post_id, update) + } +} \ No newline at end of file diff --git a/packages/server/services/posts/routes/posts/feed/global/get.js b/packages/server/services/posts/routes/posts/feed/global/get.js new file mode 100644 index 00000000..102eec4a --- /dev/null +++ b/packages/server/services/posts/routes/posts/feed/global/get.js @@ -0,0 +1,19 @@ +import Posts from "@classes/posts" + +export default { + middlewares: ["withOptionalAuthentication"], + fn: async (req, res) => { + const payload = { + limit: req.query?.limit, + trim: req.query?.trim, + } + + if (req.auth) { + payload.user_id = req.auth.session.user_id + } + + const result = await Posts.globalTimeline(payload) + + return result + } +} \ No newline at end of file diff --git a/packages/server/services/posts/routes/feed/get.js b/packages/server/services/posts/routes/posts/feed/timeline/get.js similarity index 78% rename from packages/server/services/posts/routes/feed/get.js rename to packages/server/services/posts/routes/posts/feed/timeline/get.js index 3bad7282..a5bfaa19 100644 --- a/packages/server/services/posts/routes/feed/get.js +++ b/packages/server/services/posts/routes/posts/feed/timeline/get.js @@ -5,14 +5,14 @@ export default { fn: async (req, res) => { const payload = { limit: req.query?.limit, - skip: req.query?.skip, + trim: req.query?.trim, } if (req.auth) { payload.user_id = req.auth.session.user_id } - const result = await Posts.feed(payload) + const result = await Posts.timeline(payload) return result } diff --git a/packages/server/services/posts/routes/posts/liked/get.js b/packages/server/services/posts/routes/posts/liked/get.js index 10ebfe66..e70c49c3 100644 --- a/packages/server/services/posts/routes/posts/liked/get.js +++ b/packages/server/services/posts/routes/posts/liked/get.js @@ -4,6 +4,8 @@ export default { middlewares: ["withAuthentication"], fn: async (req) => { return await Posts.getLiked({ + trim: req.query.trim, + limit: req.query.limit, user_id: req.auth.session.user_id }) } diff --git a/packages/server/services/posts/routes/posts/saved/get.js b/packages/server/services/posts/routes/posts/saved/get.js index b4b97c77..02391afe 100644 --- a/packages/server/services/posts/routes/posts/saved/get.js +++ b/packages/server/services/posts/routes/posts/saved/get.js @@ -4,6 +4,8 @@ export default { middlewares: ["withAuthentication"], fn: async (req) => { return await Posts.getSaved({ + trim: req.query.trim, + limit: req.query.limit, user_id: req.auth.session.user_id }) } diff --git a/packages/server/services/posts/routes/posts/user/[user_id]/get.js b/packages/server/services/posts/routes/posts/user/[user_id]/get.js index 481b2105..61c102ec 100644 --- a/packages/server/services/posts/routes/posts/user/[user_id]/get.js +++ b/packages/server/services/posts/routes/posts/user/[user_id]/get.js @@ -5,7 +5,7 @@ export default { fn: async (req, res) => { return await Posts.fromUserId({ skip: req.query.skip, - limit: req.query.limit, + trim: req.query.trim, user_id: req.params.user_id, for_user_id: req.auth?.session?.user_id, }) diff --git a/packages/server/services/users/classes/users/method/update.js b/packages/server/services/users/classes/users/method/update.js index 6f7a8c1c..35efaa8e 100644 --- a/packages/server/services/users/classes/users/method/update.js +++ b/packages/server/services/users/classes/users/method/update.js @@ -1,29 +1,31 @@ import { User } from "@db_models" -export default async (payload = {}) => { - if (typeof payload.user_id === "undefined") { +export default async (user_id, update) => { + if (typeof user_id === "undefined") { throw new Error("No user_id provided") } - if (typeof payload.update === "undefined") { + if (typeof update === "undefined") { throw new Error("No update provided") } - let user = await User.findById(payload.user_id) + let user = await User.findById(user_id) if (!user) { - throw new Error("User not found") + throw new OperationError(404, "User not found") } - const updateKeys = Object.keys(payload.update) + const updateKeys = Object.keys(update) updateKeys.forEach((key) => { - user[key] = payload.update[key] + user[key] = update[key] }) await user.save() - global.rtengine.io.of("/").emit(`user.update.${payload.user_id}`, user.toObject()) + user = user.toObject() - return user.toObject() + global.rtengine.io.of("/").emit(`user.update.${update}`, user) + + return user } \ No newline at end of file diff --git a/packages/server/services/users/routes/users/[user_id]/follow/post.js b/packages/server/services/users/routes/users/[user_id]/follow/post.js index c1cda11d..1c8cda09 100644 --- a/packages/server/services/users/routes/users/[user_id]/follow/post.js +++ b/packages/server/services/users/routes/users/[user_id]/follow/post.js @@ -6,7 +6,7 @@ export default { return await User.toggleFollow({ user_id: req.params.user_id, from_user_id: req.auth.session.user_id, - to: req.body.to, + to: req.body?.to, }) } } \ No newline at end of file diff --git a/packages/server/services/users/routes/users/self/config/get.js b/packages/server/services/users/routes/users/self/config/get.js new file mode 100644 index 00000000..539da041 --- /dev/null +++ b/packages/server/services/users/routes/users/self/config/get.js @@ -0,0 +1,25 @@ +import { UserConfig } from "@db_models" + +export default { + middlewares: ["withAuthentication"], + fn: async (req) => { + const key = req.query.key + + let config = await UserConfig.findOne({ + user_id: req.auth.session.user_id + }) + + if (!config) { + config = await UserConfig.create({ + user_id: req.auth.session.user_id, + values: {} + }) + } + + if (key) { + return config.values?.[key] + } + + return config.values + } +} \ No newline at end of file diff --git a/packages/server/services/users/routes/users/self/config/put.js b/packages/server/services/users/routes/users/self/config/put.js new file mode 100644 index 00000000..88d264ca --- /dev/null +++ b/packages/server/services/users/routes/users/self/config/put.js @@ -0,0 +1,61 @@ +import { UserConfig } from "@db_models" +import lodash from "lodash" + +const baseConfig = [ + { + key: "app:language", + type: "string", + value: "en-us" + }, + { + key: "auth:mfa", + type: "boolean", + value: false + }, +] + +export default { + middlewares: ["withAuthentication"], + fn: async (req) => { + let config = await UserConfig.findOne({ + user_id: req.auth.session.user_id + }) + + const values = {} + + baseConfig.forEach((config) => { + const fromBody = req.body[config.key] + if (typeof fromBody !== "undefined") { + if (typeof fromBody === config.type) { + values[config.key] = req.body[config.key] + } else { + throw new OperationError(400, `Invalid type for ${config.key}`) + } + } else { + values[config.key] = config.value + } + }) + + + if (!config) { + config = await UserConfig.create({ + user_id: req.auth.session.user_id, + values + }) + } else { + const newValues = lodash.merge(config.values, values) + + config = await UserConfig.updateOne({ + user_id: req.auth.session.user_id + }, { + values: newValues + }) + + config = await UserConfig.findOne({ + user_id: req.auth.session.user_id + }) + } + + return config.values + } +} \ No newline at end of file diff --git a/packages/server/services/users/routes/users/self/update/post.js b/packages/server/services/users/routes/users/self/update/post.js index e4b53d1a..a8e50587 100644 --- a/packages/server/services/users/routes/users/self/update/post.js +++ b/packages/server/services/users/routes/users/self/update/post.js @@ -21,7 +21,7 @@ const MaxStringsLengths = { export default { middlewares: ["withAuthentication"], fn: async (req) => { - let { update } = req.body + let update = {} if (!update) { throw new OperationError(400, "Missing update") @@ -33,13 +33,17 @@ export default { // sanitize update AllowedPublicUpdateFields.forEach((key) => { - if (typeof update[key] !== "undefined") { + if (typeof req.body[key] !== "undefined") { // check maximung strings length - if (typeof update[key] === "string" && MaxStringsLengths[key]) { - if (update[key].length > MaxStringsLengths[key]) { + if (typeof req.body[key] === "string" && MaxStringsLengths[key]) { + if (req.body[key].length > MaxStringsLengths[key]) { // create a substring - update[key] = update[key].substring(0, MaxStringsLengths[key]) + update[key] = req.body[key].substring(0, MaxStringsLengths[key]) + } else { + update[key] = req.body[key] } + } else { + update[key] = req.body[key] } } }) @@ -56,9 +60,6 @@ export default { } } - return await UserClass.update({ - user_id: req.auth.session.user_id, - update: update, - }) + return await UserClass.update(req.auth.session.user_id, update) } } \ No newline at end of file diff --git a/packages/server/utils/readFileHash.js b/packages/server/utils/readFileHash.js new file mode 100644 index 00000000..5f6be7f8 --- /dev/null +++ b/packages/server/utils/readFileHash.js @@ -0,0 +1,18 @@ +import fs from "node:fs" +import crypto from "crypto" + +export default async (file) => { + return new Promise((resolve, reject) => { + if (typeof file === "string") { + file = fs.createReadStream(file) + } + + const hash = crypto.createHash("sha256") + + file.on("data", (chunk) => hash.update(chunk)) + + file.on("end", () => resolve(hash.digest("hex"))) + + file.on("error", reject) + }) +} \ No newline at end of file diff --git a/scripts/post-install.js b/scripts/post-install.js index b7d5bc85..c6c3a7f3 100755 --- a/scripts/post-install.js +++ b/scripts/post-install.js @@ -66,10 +66,13 @@ async function linkSharedResources(pkgJSON, packagePath) { async function linkInternalSubmodules(packages) { const appPath = path.resolve(rootPath, pkgjson._web_app_path) + const comtyjsPath = path.resolve(rootPath, "comty.js") const evitePath = path.resolve(rootPath, "evite") const linebridePath = path.resolve(rootPath, "linebridge") + //* EVITE LINKING console.log(`Linking Evite to app...`) + await child_process.execSync("yarn link", { cwd: evitePath, stdio: "inherit", @@ -80,6 +83,20 @@ async function linkInternalSubmodules(packages) { stdio: "inherit", }) + //* COMTY.JS LINKING + console.log(`Linking comty.js to app...`) + + await child_process.execSync(`yarn link`, { + cwd: comtyjsPath, + stdio: "inherit", + }) + + await child_process.execSync(`yarn link "comty.js"`, { + cwd: appPath, + stdio: "inherit", + }) + + //* LINEBRIDE LINKING console.log(`Linking Linebride to servers...`) await child_process.execSync(`yarn link`, { @@ -104,7 +121,7 @@ async function linkInternalSubmodules(packages) { console.log(`Linking Linebride to package [${packageName}]...`) } - console.log(`✅ Evite dependencies installed`) + console.log(`✅ All submodules linked!`) return true }