mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
244 lines
7.9 KiB
TypeScript
244 lines
7.9 KiB
TypeScript
import { and, asc, eq, getTableColumns, inArray, like, sql } from "drizzle-orm";
|
|
import { pluginSettings, users } from "../db/schema.js";
|
|
import type { DatabaseContext } from "./database-context.js";
|
|
import {
|
|
countValue,
|
|
rowsAffected,
|
|
supportsReturning,
|
|
} from "./mutation-result.js";
|
|
import { insertReturning, updateReturning } from "./returning.js";
|
|
|
|
/** isExternal: the user has at least one external sign-in identity. */
|
|
export type UserRecord = typeof users.$inferSelect & { isExternal: boolean };
|
|
export type NewUserRecord = typeof users.$inferInsert;
|
|
export type UserUpdate = Partial<Omit<NewUserRecord, "id">>;
|
|
export type NewFirstLocalUserRecord = Omit<NewUserRecord, "isAdmin">;
|
|
|
|
// Qualified by hand: drizzle prints a column in a subquery without its table,
|
|
// which would bind users.id to the identity row's own id.
|
|
const userColumns = {
|
|
...getTableColumns(users),
|
|
isExternal:
|
|
sql<boolean>`EXISTS (SELECT 1 FROM ${sql.identifier("user_external_identities")} WHERE ${sql.identifier("user_external_identities")}.${sql.identifier("user_id")} = ${sql.identifier("users")}.${sql.identifier("id")})`.mapWith(
|
|
(value: unknown) => value === true || Number(value) === 1,
|
|
),
|
|
};
|
|
|
|
export class UserRepository {
|
|
constructor(
|
|
private readonly context: DatabaseContext,
|
|
private readonly onWrite?: () => void | Promise<void>,
|
|
) {}
|
|
|
|
async listAll(): Promise<UserRecord[]> {
|
|
return this.context.drizzle.select(userColumns).from(users);
|
|
}
|
|
|
|
/**
|
|
* One page of users, optionally filtered by username.
|
|
*
|
|
* The admin panel used to render every account at once, which is fine at
|
|
* home and not fine on a directory-backed install with thousands of them.
|
|
* Sorted by username so paging is stable between requests.
|
|
*/
|
|
async listPage(input: {
|
|
search?: string;
|
|
limit: number;
|
|
offset: number;
|
|
}): Promise<{ users: UserRecord[]; total: number }> {
|
|
const term = input.search?.trim();
|
|
const where = term
|
|
? like(sql`lower(${users.username})`, `%${term.toLowerCase()}%`)
|
|
: undefined;
|
|
|
|
const [rows, totalResult] = await Promise.all([
|
|
this.context.drizzle
|
|
.select(userColumns)
|
|
.from(users)
|
|
.where(where)
|
|
// Case-insensitive: a plain sort puts every capitalised name ahead of
|
|
// every lowercase one, which reads as unordered in the admin list.
|
|
.orderBy(asc(sql`lower(${users.username})`))
|
|
.limit(input.limit)
|
|
.offset(input.offset),
|
|
this.context.drizzle
|
|
.select({ count: sql<number>`COUNT(*)` })
|
|
.from(users)
|
|
.where(where),
|
|
]);
|
|
|
|
return { users: rows, total: countValue(totalResult[0]?.count) };
|
|
}
|
|
|
|
async findById(id: string): Promise<UserRecord | null> {
|
|
const rows = await this.context.drizzle
|
|
.select(userColumns)
|
|
.from(users)
|
|
.where(eq(users.id, id))
|
|
.limit(1);
|
|
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
async findByUsername(username: string): Promise<UserRecord | null> {
|
|
const rows = await this.context.drizzle
|
|
.select(userColumns)
|
|
.from(users)
|
|
.where(eq(users.username, username))
|
|
.limit(1);
|
|
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
async listByIds(ids: string[]): Promise<UserRecord[]> {
|
|
const uniqueIds = Array.from(new Set(ids.filter(Boolean)));
|
|
if (uniqueIds.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
return this.context.drizzle
|
|
.select(userColumns)
|
|
.from(users)
|
|
.where(inArray(users.id, uniqueIds));
|
|
}
|
|
|
|
async create(user: NewUserRecord): Promise<UserRecord> {
|
|
const rows = await insertReturning(this.context, users, user);
|
|
await this.afterWrite();
|
|
return (
|
|
(await this.findById(rows[0].id)) ?? { ...rows[0], isExternal: false }
|
|
);
|
|
}
|
|
|
|
async createFirstLocalUser(
|
|
user: NewFirstLocalUserRecord,
|
|
): Promise<{ user: UserRecord; isFirstUser: boolean }> {
|
|
const result = await this.createCheckingIfFirst((isFirstUser) => ({
|
|
...user,
|
|
isAdmin: isFirstUser,
|
|
}));
|
|
|
|
await this.afterWrite();
|
|
return { ...result, user: { ...result.user, isExternal: false } };
|
|
}
|
|
|
|
async createFirstSsoUser(
|
|
user: NewUserRecord,
|
|
): Promise<{ user: UserRecord; isFirstUser: boolean }> {
|
|
const result = await this.createCheckingIfFirst((isFirstUser) => ({
|
|
...user,
|
|
isAdmin: isFirstUser || Boolean(user.isAdmin),
|
|
}));
|
|
|
|
await this.afterWrite();
|
|
return { ...result, user: { ...result.user, isExternal: true } };
|
|
}
|
|
|
|
/**
|
|
* Creates a user, making them an admin if the table was empty.
|
|
*
|
|
* The check and the insert have to be one transaction: two people signing up
|
|
* at once would otherwise both see an empty table and both become admin.
|
|
*
|
|
* The two branches are not a style choice. better-sqlite3 is synchronous and
|
|
* rejects an async transaction callback outright — "Transaction function
|
|
* cannot return a promise" — so a single body cannot serve both. It fails
|
|
* loudly rather than silently skipping the write, which is the one mercy here.
|
|
*/
|
|
private async createCheckingIfFirst(
|
|
build: (isFirstUser: boolean) => NewUserRecord,
|
|
): Promise<{ user: typeof users.$inferSelect; isFirstUser: boolean }> {
|
|
if (this.context.dialect === "sqlite") {
|
|
/* eslint-disable no-restricted-syntax -- sqlite-only branch: the dialect
|
|
is checked directly above, and better-sqlite3 rejects an async
|
|
transaction callback, so this cannot use the shared helpers. */
|
|
return this.context.drizzle.transaction((tx) => {
|
|
const isFirstUser =
|
|
tx.select({ id: users.id }).from(users).all().length === 0;
|
|
const rows = tx
|
|
.insert(users)
|
|
.values(build(isFirstUser))
|
|
.returning()
|
|
.all();
|
|
return { user: rows[0], isFirstUser };
|
|
});
|
|
/* eslint-enable no-restricted-syntax */
|
|
}
|
|
|
|
return this.context.drizzle.transaction(async (tx) => {
|
|
const existing = await tx.select({ id: users.id }).from(users);
|
|
const isFirstUser = existing.length === 0;
|
|
const values = build(isFirstUser);
|
|
|
|
if (supportsReturning(this.context.dialect)) {
|
|
// eslint-disable-next-line no-restricted-syntax -- guarded by the check on this line
|
|
const rows = await tx.insert(users).values(values).returning();
|
|
return { user: rows[0], isFirstUser };
|
|
}
|
|
|
|
// users is keyed by a text id the caller supplies, so there is something
|
|
// to read back by even without RETURNING.
|
|
await tx.insert(users).values(values);
|
|
const [user] = await tx
|
|
.select()
|
|
.from(users)
|
|
.where(eq(users.id, values.id));
|
|
return { user, isFirstUser };
|
|
});
|
|
}
|
|
|
|
async update(id: string, update: UserUpdate): Promise<UserRecord | null> {
|
|
const rows = await updateReturning(
|
|
this.context,
|
|
users,
|
|
update,
|
|
eq(users.id, id),
|
|
);
|
|
|
|
await this.afterWrite();
|
|
return rows[0] ? this.findById(rows[0].id) : null;
|
|
}
|
|
|
|
async delete(id: string): Promise<boolean> {
|
|
// plugin_settings.scope_id is polymorphic, so it carries no foreign key to
|
|
// users and the engine will not cascade it. Removed here rather than in a
|
|
// route so every caller that deletes a user is covered.
|
|
await this.context.drizzle
|
|
.delete(pluginSettings)
|
|
.where(
|
|
and(
|
|
inArray(pluginSettings.scope, ["user", "secret"]),
|
|
eq(pluginSettings.scopeId, id),
|
|
),
|
|
);
|
|
|
|
const result = await this.context.drizzle
|
|
.delete(users)
|
|
.where(eq(users.id, id));
|
|
|
|
await this.afterWrite();
|
|
return rowsAffected(result) > 0;
|
|
}
|
|
|
|
async countAdmins(): Promise<number> {
|
|
const rows = await this.context.drizzle
|
|
.select({ id: users.id })
|
|
.from(users)
|
|
.where(eq(users.isAdmin, true));
|
|
|
|
return rows.length;
|
|
}
|
|
|
|
async countAll(): Promise<number> {
|
|
const rows = await this.context.drizzle
|
|
.select({ id: users.id })
|
|
.from(users);
|
|
|
|
return rows.length;
|
|
}
|
|
|
|
private async afterWrite(): Promise<void> {
|
|
await this.onWrite?.();
|
|
}
|
|
}
|