Refactor users service routes and add websocket support

- Standardize middleware naming to useMiddlewares - Add websocket
support and handleWsUpgrade to users service - Update getFollowers to
support pagination and return metadata - Emit user update events via
websockets - Add linebridge^1.0.0 dependency - Minor code style and
consistency improvements across routes
This commit is contained in:
srgooglo 2025-07-04 14:16:21 +02:00
parent 35df3a421f
commit 33fe2044c4
19 changed files with 245 additions and 189 deletions

View File

@ -1,35 +1,44 @@
import { User, UserFollow } from "@db_models" import { User, UserFollow } from "@db_models"
export default async (payload = {}) => { export default async (payload = {}) => {
const { user_id, data = false, limit = 50, offset = 0 } = payload const { user_id, data = false, limit = 50, page = 0 } = payload
if (!user_id) { if (!user_id) {
throw new OperationError(400, "Missing user_id") throw new OperationError(400, "Missing user_id")
} }
if (data) { const total_followers = await UserFollow.countDocuments({
to: user_id,
})
if (data === true) {
let followers = await UserFollow.find({ let followers = await UserFollow.find({
to: user_id, to: user_id,
}) })
.limit(limit) .limit(limit)
.skip(offset) .skip(limit * page)
.lean()
followers = followers.map((follow) => {
return follow.user_id
})
const followersData = await User.find({ const followersData = await User.find({
_id: { _id: {
$in: followers.map((follow) => { $in: followers,
return follow.user_id
}),
}, },
}) })
return followersData const nextPage = page + 1
} else {
const count = await UserFollow.countDocuments({
to: user_id,
})
return { return {
count, items: followersData,
total_items: total_followers,
has_more: total_followers > limit * nextPage,
}
} else {
return {
count: total_followers,
} }
} }
} }

View File

@ -25,5 +25,13 @@ export default async (user_id, update) => {
user = user.toObject() user = user.toObject()
const userSockets = await global.websockets.find.clientsByUserId(
user._id.toString(),
)
for (const userSocket of userSockets) {
userSocket.emit(`self:user:update`, user)
}
return user return user
} }

View File

@ -1,3 +1,6 @@
{ {
"name": "users" "name": "users",
"dependencies": {
"linebridge": "^1.0.0-alpha.4"
}
} }

View File

@ -5,17 +5,17 @@ export default {
const { user_id } = req.params const { user_id } = req.params
const user = await User.findOne({ const user = await User.findOne({
_id: user_id _id: user_id,
}).catch((err) => { }).catch((err) => {
return false return false
}) })
const badges = await Badge.find({ const badges = await Badge.find({
name: { name: {
$in: user.badges $in: user.badges,
} },
}) })
return badges return badges
} },
} }

View File

@ -1,7 +1,7 @@
import Users from "@classes/users" import Users from "@classes/users"
export default { export default {
middlewares: ["withOptionalAuthentication"], useMiddlewares: ["withOptionalAuthentication"],
fn: async (req) => { fn: async (req) => {
const { user_id } = req.params const { user_id } = req.params
@ -10,7 +10,7 @@ export default {
return await Users.data({ return await Users.data({
user_id: ids.length > 1 ? ids : user_id, user_id: ids.length > 1 ? ids : user_id,
from_user_id: req.auth?.session.user_id, from_user_id: req.auth?.session.user_id,
basic: req.query?.basic, basic: ToBoolean(req.query?.basic),
}) })
}, },
} }

View File

@ -1,12 +1,12 @@
import User from "@classes/users" import User from "@classes/users"
export default { export default {
middlewares: ["withAuthentication"], useMiddlewares: ["withAuthentication"],
fn: async (req) => { fn: async (req) => {
return await User.toggleFollow({ return await User.toggleFollow({
user_id: req.params.user_id, user_id: req.params.user_id,
from_user_id: req.auth.session.user_id, from_user_id: req.auth.session.user_id,
to: req.body?.to, to: req.body?.to,
}) })
} },
} }

View File

@ -5,8 +5,8 @@ export default {
return await User.getFollowers({ return await User.getFollowers({
user_id: req.params.user_id, user_id: req.params.user_id,
data: ToBoolean(req.query.fetchData), data: ToBoolean(req.query.fetchData),
limit: req.query.limit, limit: parseInt(req.query.limit),
offset: req.query.offset, page: parseInt(req.query.page),
}) })
} },
} }

View File

@ -1,7 +1,7 @@
import { UserPublicKey } from "@db_models" import { UserPublicKey } from "@db_models"
export default { export default {
middlewares: ["withAuthentication"], useMiddlewares: ["withAuthentication"],
fn: async (req) => { fn: async (req) => {
const targetUserId = req.params.user_id const targetUserId = req.params.user_id

View File

@ -1,12 +1,12 @@
import Users from "@classes/users" import Users from "@classes/users"
export default { export default {
middlewares: ["withOptionalAuthentication"], useMiddlewares: ["withOptionalAuthentication"],
fn: async (req) => { fn: async (req) => {
const data = await Users.data({ const data = await Users.data({
user_id: req.auth.session.user_id, user_id: req.auth.session.user_id,
}) })
return data.roles return data.roles
} },
} }

View File

@ -1,18 +1,18 @@
import { UserConfig } from "@db_models" import { UserConfig } from "@db_models"
export default { export default {
middlewares: ["withAuthentication"], useMiddlewares: ["withAuthentication"],
fn: async (req) => { fn: async (req) => {
const key = req.query.key const key = req.query.key
let config = await UserConfig.findOne({ let config = await UserConfig.findOne({
user_id: req.auth.session.user_id user_id: req.auth.session.user_id,
}) })
if (!config) { if (!config) {
config = await UserConfig.create({ config = await UserConfig.create({
user_id: req.auth.session.user_id, user_id: req.auth.session.user_id,
values: {} values: {},
}) })
} }
@ -21,5 +21,5 @@ export default {
} }
return config.values return config.values
} },
} }

View File

@ -5,20 +5,20 @@ const baseConfig = [
{ {
key: "app:language", key: "app:language",
type: "string", type: "string",
value: "en-us" value: "en-us",
}, },
{ {
key: "auth:mfa", key: "auth:mfa",
type: "boolean", type: "boolean",
value: false value: false,
}, },
] ]
export default { export default {
middlewares: ["withAuthentication"], useMiddlewares: ["withAuthentication"],
fn: async (req) => { fn: async (req) => {
let config = await UserConfig.findOne({ let config = await UserConfig.findOne({
user_id: req.auth.session.user_id user_id: req.auth.session.user_id,
}) })
const values = {} const values = {}
@ -29,33 +29,38 @@ export default {
if (typeof fromBody === config.type) { if (typeof fromBody === config.type) {
values[config.key] = req.body[config.key] values[config.key] = req.body[config.key]
} else { } else {
throw new OperationError(400, `Invalid type for ${config.key}`) throw new OperationError(
400,
`Invalid type for ${config.key}`,
)
} }
} else { } else {
values[config.key] = config.value values[config.key] = config.value
} }
}) })
if (!config) { if (!config) {
config = await UserConfig.create({ config = await UserConfig.create({
user_id: req.auth.session.user_id, user_id: req.auth.session.user_id,
values values,
}) })
} else { } else {
const newValues = lodash.merge(config.values, values) const newValues = lodash.merge(config.values, values)
config = await UserConfig.updateOne({ config = await UserConfig.updateOne(
user_id: req.auth.session.user_id {
}, { user_id: req.auth.session.user_id,
values: newValues },
}) {
values: newValues,
},
)
config = await UserConfig.findOne({ config = await UserConfig.findOne({
user_id: req.auth.session.user_id user_id: req.auth.session.user_id,
}) })
} }
return config.values return config.values
} },
} }

View File

@ -1,7 +1,7 @@
import Users from "@classes/users" import Users from "@classes/users"
export default { export default {
middlewares: ["withAuthentication"], useMiddlewares: ["withAuthentication"],
fn: async (req) => { fn: async (req) => {
return await Users.data({ return await Users.data({
user_id: req.auth.session.user_id, user_id: req.auth.session.user_id,

View File

@ -1,7 +1,7 @@
import { UserDHKeyPair } from "@db_models" import { UserDHKeyPair } from "@db_models"
export default { export default {
middlewares: ["withAuthentication"], useMiddlewares: ["withAuthentication"],
fn: async (req) => { fn: async (req) => {
const userId = req.auth.session.user_id const userId = req.auth.session.user_id

View File

@ -1,7 +1,7 @@
import { UserDHKeyPair } from "@db_models" import { UserDHKeyPair } from "@db_models"
export default { export default {
middlewares: ["withAuthentication"], useMiddlewares: ["withAuthentication"],
fn: async (req) => { fn: async (req) => {
const userId = req.auth.session.user_id const userId = req.auth.session.user_id
const { str } = req.body const { str } = req.body

View File

@ -1,7 +1,7 @@
import { UserPublicKey } from "@db_models" import { UserPublicKey } from "@db_models"
export default { export default {
middlewares: ["withAuthentication"], useMiddlewares: ["withAuthentication"],
fn: async (req) => { fn: async (req) => {
const userId = req.auth.session.user_id const userId = req.auth.session.user_id
const { public_key } = req.body const { public_key } = req.body

View File

@ -1,7 +1,7 @@
import Users from "@classes/users" import Users from "@classes/users"
export default { export default {
middlewares: ["withAuthentication"], useMiddlewares: ["withAuthentication"],
fn: async (req) => { fn: async (req) => {
const data = await Users.data({ const data = await Users.data({
user_id: user_id, user_id: user_id,
@ -12,5 +12,5 @@ export default {
} }
return data.roles return data.roles
} },
} }

View File

@ -19,7 +19,7 @@ const MaxStringsLengths = {
} }
export default { export default {
middlewares: ["withAuthentication"], useMiddlewares: ["withAuthentication"],
fn: async (req) => { fn: async (req) => {
const update = {} const update = {}
@ -27,10 +27,16 @@ export default {
AllowedPublicUpdateFields.forEach((key) => { AllowedPublicUpdateFields.forEach((key) => {
if (typeof req.body[key] !== "undefined") { if (typeof req.body[key] !== "undefined") {
// check maximung strings length // check maximung strings length
if (typeof req.body[key] === "string" && MaxStringsLengths[key]) { if (
typeof req.body[key] === "string" &&
MaxStringsLengths[key]
) {
if (req.body[key].length > MaxStringsLengths[key]) { if (req.body[key].length > MaxStringsLengths[key]) {
// create a substring // create a substring
update[key] = req.body[key].substring(0, MaxStringsLengths[key]) update[key] = req.body[key].substring(
0,
MaxStringsLengths[key],
)
} else { } else {
update[key] = req.body[key] update[key] = req.body[key]
} }
@ -53,5 +59,5 @@ export default {
} }
return await UserClass.update(req.auth.session.user_id, update) return await UserClass.update(req.auth.session.user_id, update)
} },
} }

View File

@ -4,12 +4,19 @@ import DbManager from "@shared-classes/DbManager"
import RedisClient from "@shared-classes/RedisClient" import RedisClient from "@shared-classes/RedisClient"
import SharedMiddlewares from "@shared-middlewares" import SharedMiddlewares from "@shared-middlewares"
import InjectedAuth from "@shared-lib/injectedAuth"
export default class API extends Server { export default class API extends Server {
static refName = "users" static refName = "users"
static useEngine = "hyper-express"
static routesPath = `${__dirname}/routes` static routesPath = `${__dirname}/routes`
static listen_port = process.env.HTTP_LISTEN_PORT ?? 3008 static listenPort = process.env.HTTP_LISTEN_PORT ?? 3008
static useMiddlewares = ["logs"]
static websockets = {
enabled: true,
path: "/users",
}
middlewares = { middlewares = {
...SharedMiddlewares, ...SharedMiddlewares,
@ -20,6 +27,24 @@ export default class API extends Server {
redis: RedisClient(), redis: RedisClient(),
} }
handleWsUpgrade = async (context, token, res) => {
if (!token) {
return res.upgrade(context)
}
context = await InjectedAuth(context, token, res).catch(() => {
res.close(401, "Failed to verify auth token")
return false
})
if (!context || !context.user) {
res.close(401, "Unauthorized or missing auth token")
return false
}
return res.upgrade(context)
}
async onInitialize() { async onInitialize() {
await this.contexts.db.initialize() await this.contexts.db.initialize()
await this.contexts.redis.initialize() await this.contexts.redis.initialize()