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

@ -1,21 +1,21 @@
import { User, Badge } from "@db_models" import { User, Badge } from "@db_models"
export default { export default {
fn: async (req) => { fn: async (req) => {
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

@ -1,12 +1,12 @@
import User from "@classes/users" import User from "@classes/users"
export default { export default {
fn: async (req) => { fn: async (req) => {
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

@ -2,9 +2,9 @@ import Users from "@classes/users"
// resolve user id from a username (passed from params) // resolve user id from a username (passed from params)
export default { export default {
fn: async (req) => { fn: async (req) => {
return await Users.resolveUserId({ return await Users.resolveUserId({
username: req.params.user_id, username: 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,25 +1,25 @@
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: {},
}) })
} }
if (key) { if (key) {
return config.values?.[key] return config.values?.[key]
} }
return config.values return config.values
} },
} }

View File

@ -2,60 +2,65 @@ import { UserConfig } from "@db_models"
import lodash from "lodash" import lodash from "lodash"
const baseConfig = [ 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 = {}
baseConfig.forEach((config) => { baseConfig.forEach((config) => {
const fromBody = req.body[config.key] const fromBody = req.body[config.key]
if (typeof fromBody !== "undefined") { if (typeof fromBody !== "undefined") {
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,
} else { `Invalid type for ${config.key}`,
values[config.key] = config.value )
} }
}) } 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)
if (!config) { config = await UserConfig.updateOne(
config = await UserConfig.create({ {
user_id: req.auth.session.user_id, user_id: req.auth.session.user_id,
values },
}) {
} else { values: newValues,
const newValues = lodash.merge(config.values, values) },
)
config = await UserConfig.updateOne({ config = await UserConfig.findOne({
user_id: req.auth.session.user_id user_id: req.auth.session.user_id,
}, { })
values: newValues }
})
config = await UserConfig.findOne({ return config.values
user_id: req.auth.session.user_id },
}) }
}
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,16 +1,16 @@
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,
}) })
if (!data) { if (!data) {
throw new OperationError(404, "User not found") throw new OperationError(404, "User not found")
} }
return data.roles return data.roles
} },
} }

View File

@ -2,56 +2,62 @@ import UserClass from "@classes/users"
import { User } from "@db_models" import { User } from "@db_models"
const AllowedPublicUpdateFields = [ const AllowedPublicUpdateFields = [
"public_name", "public_name",
"avatar", "avatar",
"email", "email",
"cover", "cover",
"description", "description",
"location", "location",
"links", "links",
"birthday", "birthday",
] ]
const MaxStringsLengths = { const MaxStringsLengths = {
public_name: 120, public_name: 120,
email: 320, email: 320,
description: 320, description: 320,
} }
export default { export default {
middlewares: ["withAuthentication"], useMiddlewares: ["withAuthentication"],
fn: async (req) => { fn: async (req) => {
const update = {} const update = {}
// sanitize update // sanitize update
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 (
if (req.body[key].length > MaxStringsLengths[key]) { typeof req.body[key] === "string" &&
// create a substring MaxStringsLengths[key]
update[key] = req.body[key].substring(0, MaxStringsLengths[key]) ) {
} else { if (req.body[key].length > MaxStringsLengths[key]) {
update[key] = req.body[key] // create a substring
} update[key] = req.body[key].substring(
} else { 0,
update[key] = req.body[key] MaxStringsLengths[key],
} )
} } else {
}) update[key] = req.body[key]
}
} else {
update[key] = req.body[key]
}
}
})
if (typeof update.email !== "undefined") { if (typeof update.email !== "undefined") {
const user = await User.findOne({ const user = await User.findOne({
email: update.email, email: update.email,
}).catch((err) => { }).catch((err) => {
return false return false
}) })
if (user) { if (user) {
throw new OperationError(400, "Email is already in use") throw new OperationError(400, "Email is already in use")
} }
} }
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()