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"
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) {
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({
to: user_id,
})
.limit(limit)
.skip(offset)
.skip(limit * page)
.lean()
followers = followers.map((follow) => {
return follow.user_id
})
const followersData = await User.find({
_id: {
$in: followers.map((follow) => {
return follow.user_id
}),
$in: followers,
},
})
return followersData
} else {
const count = await UserFollow.countDocuments({
to: user_id,
})
const nextPage = page + 1
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()
const userSockets = await global.websockets.find.clientsByUserId(
user._id.toString(),
)
for (const userSocket of userSockets) {
userSocket.emit(`self:user:update`, 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"
export default {
fn: async (req) => {
const { user_id } = req.params
fn: async (req) => {
const { user_id } = req.params
const user = await User.findOne({
_id: user_id
}).catch((err) => {
return false
})
const user = await User.findOne({
_id: user_id,
}).catch((err) => {
return false
})
const badges = await Badge.find({
name: {
$in: user.badges
}
})
const badges = await Badge.find({
name: {
$in: user.badges,
},
})
return badges
}
}
return badges
},
}

View File

@ -1,7 +1,7 @@
import Users from "@classes/users"
export default {
middlewares: ["withOptionalAuthentication"],
useMiddlewares: ["withOptionalAuthentication"],
fn: async (req) => {
const { user_id } = req.params
@ -10,7 +10,7 @@ export default {
return await Users.data({
user_id: ids.length > 1 ? ids : 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"
export default {
middlewares: ["withAuthentication"],
fn: async (req) => {
return await User.toggleFollow({
user_id: req.params.user_id,
from_user_id: req.auth.session.user_id,
to: req.body?.to,
})
}
}
useMiddlewares: ["withAuthentication"],
fn: async (req) => {
return await User.toggleFollow({
user_id: req.params.user_id,
from_user_id: req.auth.session.user_id,
to: req.body?.to,
})
},
}

View File

@ -1,12 +1,12 @@
import User from "@classes/users"
export default {
fn: async (req) => {
return await User.getFollowers({
user_id: req.params.user_id,
data: ToBoolean(req.query.fetchData),
limit: req.query.limit,
offset: req.query.offset,
})
}
}
fn: async (req) => {
return await User.getFollowers({
user_id: req.params.user_id,
data: ToBoolean(req.query.fetchData),
limit: parseInt(req.query.limit),
page: parseInt(req.query.page),
})
},
}

View File

@ -1,7 +1,7 @@
import { UserPublicKey } from "@db_models"
export default {
middlewares: ["withAuthentication"],
useMiddlewares: ["withAuthentication"],
fn: async (req) => {
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)
export default {
fn: async (req) => {
return await Users.resolveUserId({
username: req.params.user_id,
})
},
}
fn: async (req) => {
return await Users.resolveUserId({
username: req.params.user_id,
})
},
}

View File

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

View File

@ -1,25 +1,25 @@
import { UserConfig } from "@db_models"
export default {
middlewares: ["withAuthentication"],
fn: async (req) => {
const key = req.query.key
useMiddlewares: ["withAuthentication"],
fn: async (req) => {
const key = req.query.key
let config = await UserConfig.findOne({
user_id: req.auth.session.user_id
})
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 (!config) {
config = await UserConfig.create({
user_id: req.auth.session.user_id,
values: {},
})
}
if (key) {
return config.values?.[key]
}
if (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"
const baseConfig = [
{
key: "app:language",
type: "string",
value: "en-us"
},
{
key: "auth:mfa",
type: "boolean",
value: false
},
{
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
})
useMiddlewares: ["withAuthentication"],
fn: async (req) => {
let config = await UserConfig.findOne({
user_id: req.auth.session.user_id,
})
const values = {}
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
}
})
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)
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.updateOne({
user_id: req.auth.session.user_id
}, {
values: newValues
})
config = await UserConfig.findOne({
user_id: req.auth.session.user_id,
})
}
config = await UserConfig.findOne({
user_id: req.auth.session.user_id
})
}
return config.values
}
}
return config.values
},
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,16 +1,16 @@
import Users from "@classes/users"
export default {
middlewares: ["withAuthentication"],
fn: async (req) => {
const data = await Users.data({
user_id: user_id,
})
useMiddlewares: ["withAuthentication"],
fn: async (req) => {
const data = await Users.data({
user_id: user_id,
})
if (!data) {
throw new OperationError(404, "User not found")
}
if (!data) {
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"
const AllowedPublicUpdateFields = [
"public_name",
"avatar",
"email",
"cover",
"description",
"location",
"links",
"birthday",
"public_name",
"avatar",
"email",
"cover",
"description",
"location",
"links",
"birthday",
]
const MaxStringsLengths = {
public_name: 120,
email: 320,
description: 320,
public_name: 120,
email: 320,
description: 320,
}
export default {
middlewares: ["withAuthentication"],
fn: async (req) => {
const update = {}
useMiddlewares: ["withAuthentication"],
fn: async (req) => {
const update = {}
// sanitize update
AllowedPublicUpdateFields.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]
}
}
})
// sanitize update
AllowedPublicUpdateFields.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]
}
}
})
if (typeof update.email !== "undefined") {
const user = await User.findOne({
email: update.email,
}).catch((err) => {
return false
})
if (typeof update.email !== "undefined") {
const user = await User.findOne({
email: update.email,
}).catch((err) => {
return false
})
if (user) {
throw new OperationError(400, "Email is already in use")
}
}
if (user) {
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 SharedMiddlewares from "@shared-middlewares"
import InjectedAuth from "@shared-lib/injectedAuth"
export default class API extends Server {
static refName = "users"
static useEngine = "hyper-express"
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 = {
...SharedMiddlewares,
@ -20,6 +27,24 @@ export default class API extends Server {
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() {
await this.contexts.db.initialize()
await this.contexts.redis.initialize()