You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
wiki/backend/models/groups.ts

335 lines
9.2 KiB

import { v4 as uuid } from 'uuid'
import { and, count, eq, ilike, or, sql } from 'drizzle-orm'
import { groups as groupsTable, userGroups, users as usersTable } from '../db/schema.ts'
import type { SystemIds } from './types.ts'
/** How a rule's `path` is compared against the page path. */
export type GroupRuleMatch = 'START' | 'END' | 'REGEX' | 'TAG' | 'TAGALL' | 'EXACT'
/** Whether a matching rule grants, denies, or unconditionally grants its roles. */
export type GroupRuleMode = 'ALLOW' | 'DENY' | 'FORCEALLOW'
/** A single page-rule entry within a group. */
export interface GroupRule {
id: string
name: string
roles: string[]
match: GroupRuleMatch
mode: GroupRuleMode
path: string
locales: string[]
sites: string[]
}
/** A group row, joined with the number of users assigned to it. */
export interface GroupWithUserCount {
id: string
name: string
permissions: string[]
rules: GroupRule[]
redirectOnLogin: string
redirectOnFirstLogin: string
redirectOnLogout: string
isSystem: boolean
userCount: number
createdAt: Date
updatedAt: Date
}
/** The subset of group fields that may be modified. `isSystem` is deliberately absent. */
export interface GroupPatch {
name?: string
redirectOnLogin?: string
redirectOnFirstLogin?: string
redirectOnLogout?: string
permissions?: string[]
rules?: GroupRule[]
}
/**
* Selection shared by getAllGroups() / getGroupById().
*
* `userCount` comes from a left join on `userGroups` aggregated per group, so groups with no members
* count 0 rather than dropping out of the result.
*/
/** A member of a group, mirroring the `UserCore` API schema. */
export interface GroupUser {
id: string
name: string
email: string
hasAvatar: boolean
isSystem: boolean
isActive: boolean
isVerified: boolean
createdAt: Date
updatedAt: Date
lastLoginAt: Date | null
}
export interface GroupUserPage {
total: number
users: GroupUser[]
}
/**
* Escape the LIKE wildcards `%` and `_` (and the escape character itself) so that a user-supplied
* filter is matched literally. Values are still parameterized by the driver — this is about a `%`
* in the filter silently matching everything, not about injection.
*/
function escapeLikePattern(value: string): string {
return value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')
}
const groupSelection = {
id: groupsTable.id,
name: groupsTable.name,
permissions: groupsTable.permissions,
rules: groupsTable.rules,
redirectOnLogin: groupsTable.redirectOnLogin,
redirectOnFirstLogin: groupsTable.redirectOnFirstLogin,
redirectOnLogout: groupsTable.redirectOnLogout,
isSystem: groupsTable.isSystem,
createdAt: groupsTable.createdAt,
updatedAt: groupsTable.updatedAt,
userCount: count(userGroups.userId)
}
/**
* Groups model
*/
class Groups {
async init(ids: SystemIds): Promise<void> {
WIKI.logger.info('Inserting default groups...')
await WIKI.db.insert(groupsTable).values([
{
id: ids.groupAdminId,
name: 'Administrators',
permissions: ['manage:system'],
rules: [],
isSystem: true
},
{
id: ids.groupUserId,
name: 'Users',
permissions: ['read:pages', 'read:assets', 'read:comments'],
rules: [
{
id: uuid(),
name: 'Default Rule',
roles: ['read:pages', 'read:assets', 'read:comments'],
match: 'START',
mode: 'ALLOW',
path: '',
locales: [],
sites: []
}
],
isSystem: true
},
{
id: ids.groupGuestId,
name: 'Guests',
permissions: ['read:pages', 'read:assets', 'read:comments'],
rules: [
{
id: uuid(),
name: 'Default Rule',
roles: ['read:pages', 'read:assets', 'read:comments'],
match: 'START',
mode: 'DENY',
path: '',
locales: [],
sites: []
}
],
isSystem: true
}
])
}
/**
* Create a new (non-system) group, seeded with the same starting permissions and default rule as
* the `Users` group.
*
* @param name Group name
* @returns The new group's ID
*/
async createGroup(name: string): Promise<string> {
const startingPermissions = ['read:pages', 'read:assets', 'read:comments']
const result = await WIKI.db
.insert(groupsTable)
.values({
name,
permissions: startingPermissions,
rules: [
{
id: uuid(),
name: 'Default Rule',
roles: startingPermissions,
match: 'START',
mode: 'ALLOW',
path: '',
locales: [],
sites: []
}
],
isSystem: false
})
.returning({ id: groupsTable.id })
return result[0].id
}
/**
* Fetch all groups, ordered by name
*/
async getAllGroups(): Promise<GroupWithUserCount[]> {
const results = await WIKI.db
.select(groupSelection)
.from(groupsTable)
.leftJoin(userGroups, eq(userGroups.groupId, groupsTable.id))
.groupBy(groupsTable.id)
.orderBy(groupsTable.name)
return results as GroupWithUserCount[]
}
/**
* Fetch a single group by ID
*
* @param id Group ID
* @returns The group, or null if no such group exists
*/
async getGroupById(id: string): Promise<GroupWithUserCount | null> {
const results = await WIKI.db
.select(groupSelection)
.from(groupsTable)
.leftJoin(userGroups, eq(userGroups.groupId, groupsTable.id))
.where(eq(groupsTable.id, id))
.groupBy(groupsTable.id)
.limit(1)
return (results[0] as GroupWithUserCount) ?? null
}
/**
* Update a group
*
* @param id Group ID
* @param patch Fields to change — must not be empty
* @returns Whether a group was updated
*/
async updateGroup(id: string, patch: GroupPatch): Promise<boolean> {
const result = await WIKI.db
.update(groupsTable)
.set({ ...patch, updatedAt: sql`now()` })
.where(eq(groupsTable.id, id))
return (result.rowCount ?? 0) > 0
}
/**
* Delete a group. Assignments in `userGroups` are removed by the FK cascade.
*
* @param id Group ID
* @returns Whether a group was deleted
*/
async deleteGroup(id: string): Promise<boolean> {
const result = await WIKI.db.delete(groupsTable).where(eq(groupsTable.id, id))
return (result.rowCount ?? 0) > 0
}
/**
* Assign a user to a group. Idempotent.
*
* @returns False if the user was already a member
*/
async assignUserToGroup(groupId: string, userId: string): Promise<boolean> {
const result = await WIKI.db
.insert(userGroups)
.values({ userId, groupId })
.onConflictDoNothing()
return (result.rowCount ?? 0) > 0
}
/**
* Remove a user from a group
*
* @returns False if the user was not a member
*/
async unassignUserFromGroup(groupId: string, userId: string): Promise<boolean> {
const result = await WIKI.db
.delete(userGroups)
.where(and(eq(userGroups.groupId, groupId), eq(userGroups.userId, userId)))
return (result.rowCount ?? 0) > 0
}
/**
* Fetch a page of the users assigned to a group, ordered by name.
*
* @param groupId Group ID
* @param filter Optional case-insensitive substring matched against name and email
* @param page 1-based page number
* @param limit Page size
*/
async getGroupUsers(
groupId: string,
{ filter = '', page = 1, limit = 20 }: { filter?: string; page?: number; limit?: number } = {}
): Promise<GroupUserPage> {
const conditions = [eq(userGroups.groupId, groupId)]
if (filter) {
const pattern = `%${escapeLikePattern(filter)}%`
conditions.push(or(ilike(usersTable.name, pattern), ilike(usersTable.email, pattern))!)
}
const where = and(...conditions)
const totals = await WIKI.db
.select({ total: count() })
.from(userGroups)
.innerJoin(usersTable, eq(usersTable.id, userGroups.userId))
.where(where)
const users = await WIKI.db
.select({
id: usersTable.id,
name: usersTable.name,
email: usersTable.email,
hasAvatar: usersTable.hasAvatar,
isSystem: usersTable.isSystem,
isActive: usersTable.isActive,
isVerified: usersTable.isVerified,
createdAt: usersTable.createdAt,
updatedAt: usersTable.updatedAt,
lastLoginAt: usersTable.lastLoginAt
})
.from(userGroups)
.innerJoin(usersTable, eq(usersTable.id, userGroups.userId))
.where(where)
.orderBy(usersTable.name)
.limit(limit)
.offset((page - 1) * limit)
return {
total: totals[0]?.total ?? 0,
users
}
}
/**
* Count the users assigned to a group
*/
async countUsersInGroup(groupId: string): Promise<number> {
return WIKI.db.$count(userGroups, eq(userGroups.groupId, groupId))
}
/**
* Whether a user is currently assigned to a group
*/
async isUserInGroup(groupId: string, userId: string): Promise<boolean> {
const total = await WIKI.db.$count(
userGroups,
and(eq(userGroups.groupId, groupId), eq(userGroups.userId, userId))
)
return total > 0
}
}
export const groups = new Groups()