mirror of https://github.com/requarks/wiki
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.
335 lines
9.2 KiB
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()
|