oss-sync 77ee3bc426
Some checks are pending
CI / build-and-test (push) Waiting to run
sync: update from private repo (ddadfd71)
2026-07-08 23:35:00 +00:00

564 lines
20 KiB
TypeScript

// Extracted from src/db/repository.ts (mechanical split — bodies unchanged).
// users repository domain. Facade: src/db/repository.ts (Repository class).
import Database from 'better-sqlite3';
import { scryptSync, randomBytes, timingSafeEqual } from 'crypto';
import { v4 as uuidv4 } from 'uuid';
import { buildVisibilityWhere } from '../../bridge/visibility.js';
import { utc } from './shared.js';
export interface User {
id: string;
email: string;
name: string | null;
avatarUrl: string | null;
role: 'admin' | 'user';
status: 'active' | 'pending' | 'disabled';
defaultVisibility: 'private' | 'org' | 'public';
defaultVisibilityOrgId: string | null;
createdAt: string;
updatedAt: string;
}
export interface CreateUserParams {
email: string;
name: string;
role: 'admin' | 'user';
status: 'active' | 'pending' | 'disabled';
avatarUrl?: string;
}
export interface FindOrCreateByOAuthParams {
provider: string;
providerId: string;
email: string;
name: string;
avatarUrl?: string;
}
export interface CreateLocalUserParams {
email: string;
password: string;
role: 'admin' | 'user';
status: 'active' | 'pending' | 'disabled';
name?: string;
}
export interface LocalOrg {
id: string;
name: string;
createdBy: string | null;
createdAt: string;
}
export interface LocalOrgMember {
userId: string;
role: string;
}
export interface GiteaOrgInput {
orgId: string;
orgName: string;
}
export interface GiteaOrg extends GiteaOrgInput {
fetchedAt: string;
}
export interface UserRow {
id: string;
email: string;
name: string | null;
avatar_url: string | null;
role: string;
status: string;
default_visibility: string | null;
default_visibility_org_id: string | null;
created_at: string;
updated_at: string;
}
export function rowToUser(row: UserRow): User {
return {
id: row.id,
email: row.email,
name: row.name,
avatarUrl: row.avatar_url,
role: row.role as 'admin' | 'user',
status: row.status as 'active' | 'pending' | 'disabled',
defaultVisibility: (row.default_visibility ?? 'private') as User['defaultVisibility'],
defaultVisibilityOrgId: row.default_visibility_org_id,
createdAt: utc(row.created_at),
updatedAt: utc(row.updated_at),
};
}
export function createUser(db: Database.Database, params: CreateUserParams): User {
const id = uuidv4();
const now = new Date().toISOString();
db
.prepare(
`INSERT INTO users (id, email, name, avatar_url, role, status, created_at, updated_at)
VALUES (@id, @email, @name, @avatarUrl, @role, @status, @now, @now)`
)
.run({
id,
email: params.email,
name: params.name,
avatarUrl: params.avatarUrl ?? null,
role: params.role,
status: params.status,
now,
});
const user = getUserById(db, id);
if (!user) throw new Error(`createUser: failed to retrieve created user ${id}`);
return user;
}
/**
* Ensure the synthetic 'local' user row exists. No-auth single-user
* deployments own per-user rows under the id 'local' (tasks, jobs, SSH
* connections, DEKs, …). Many of those tables FK to users(id) with
* foreign_keys ON, so the row must exist or the inserts fail — e.g.
* ssh_user_deks → SSH connection creation returned create_failed.
* Idempotent (INSERT OR IGNORE), so it is safe to call on every startup.
* role='admin' mirrors the synthetic 'local' user the HTTP layer injects
* for task-visibility routes in no-auth mode.
*/
export function ensureLocalUser(db: Database.Database): void {
const now = new Date().toISOString();
db
.prepare(
`INSERT OR IGNORE INTO users (id, email, name, avatar_url, role, status, created_at, updated_at)
VALUES ('local', 'local@localhost', 'local', NULL, 'admin', 'active', @now, @now)`
)
.run({ now });
}
// ── Local auth (email + password) ─────────────────────────────────────
/** scrypt hash with a fresh per-user salt. Overwrites any existing credential. */
export function setLocalPassword(db: Database.Database, userId: string, plainPassword: string): void {
const salt = randomBytes(16).toString('hex');
const hash = scryptSync(plainPassword, salt, 64).toString('hex');
const now = new Date().toISOString();
db
.prepare(
`INSERT INTO local_credentials (user_id, password_hash, salt, updated_at)
VALUES (@userId, @hash, @salt, @now)
ON CONFLICT(user_id) DO UPDATE SET password_hash=@hash, salt=@salt, updated_at=@now`,
)
.run({ userId, hash, salt, now });
}
/** Constant-time verify. False when the user has no local credential. */
export function verifyLocalPassword(db: Database.Database, userId: string, plainPassword: string): boolean {
const row = db
.prepare('SELECT password_hash, salt FROM local_credentials WHERE user_id = ?')
.get(userId) as { password_hash: string; salt: string } | undefined;
if (!row) return false;
const expected = Buffer.from(row.password_hash, 'hex');
const actual = scryptSync(plainPassword, row.salt, expected.length);
return expected.length === actual.length && timingSafeEqual(expected, actual);
}
export function hasLocalCredential(db: Database.Database, userId: string): boolean {
return !!db.prepare('SELECT 1 FROM local_credentials WHERE user_id = ?').get(userId);
}
/**
* Create a brand-new local account (self-signup or admin-created). The email
* MUST be unused: attaching a password to an existing account would be an
* account-takeover vector, so we reject instead of linking. Linking a local
* credential to an existing OAuth account is a separate, authenticated action
* (not v1 signup).
*/
export function createLocalUser(db: Database.Database, params: CreateLocalUserParams): User {
if (getUserByEmail(db, params.email)) {
throw new Error(`createLocalUser: a user with email ${params.email} already exists`);
}
const user = createUser(db, {
email: params.email,
name: params.name ?? params.email,
role: params.role,
status: params.status,
});
db
.prepare(
`INSERT OR IGNORE INTO oauth_accounts (id, user_id, provider, provider_id, created_at)
VALUES (@id, @userId, 'local', @providerId, @now)`,
)
.run({ id: uuidv4(), userId: user.id, providerId: params.email, now: new Date().toISOString() });
setLocalPassword(db, user.id, params.password);
return user;
}
/**
* Idempotently seed the shared system admin under the fixed id `local` — the
* same owner the no-auth path synthesizes. This makes all pre-existing
* `local`-owned data belong to the logged-in admin once local auth is turned
* on, and lets an existing no-auth deployment gain a login mid-stream.
* Re-running updates the password and keeps role=admin/status=active.
*/
export function upsertLocalSystemAdmin(db: Database.Database, params: { email: string; password: string; name?: string }): User {
const LOCAL_ID = 'local';
const now = new Date().toISOString();
const existing = getUserById(db, LOCAL_ID);
if (!existing) {
db
.prepare(
`INSERT INTO users (id, email, name, avatar_url, role, status, created_at, updated_at)
VALUES (@id, @email, @name, NULL, 'admin', 'active', @now, @now)`,
)
.run({ id: LOCAL_ID, email: params.email, name: params.name ?? 'Local Admin', now });
} else {
db
.prepare(`UPDATE users SET email=@email, role='admin', status='active', updated_at=@now WHERE id=@id`)
.run({ id: LOCAL_ID, email: params.email, now });
}
db
.prepare(
`INSERT OR IGNORE INTO oauth_accounts (id, user_id, provider, provider_id, created_at)
VALUES (@id, @userId, 'local', @providerId, @now)`,
)
.run({ id: uuidv4(), userId: LOCAL_ID, providerId: params.email, now });
setLocalPassword(db, LOCAL_ID, params.password);
const user = getUserById(db, LOCAL_ID);
if (!user) throw new Error('upsertLocalSystemAdmin: failed to retrieve local admin');
return user;
}
// ── Local organizations ───────────────────────────────────────────────
export function rowToLocalOrg(r: { id: string; name: string; created_by: string | null; created_at: string }): LocalOrg {
return { id: r.id, name: r.name, createdBy: r.created_by, createdAt: r.created_at };
}
/** Create a local org. id is prefixed `lorg:` so it never collides with a
* Gitea numeric org id (both live in visibility_scope_org_id). */
export function createLocalOrg(db: Database.Database, name: string, createdBy: string | null): LocalOrg {
const id = `lorg:${uuidv4()}`;
const now = new Date().toISOString();
db
.prepare(`INSERT INTO local_orgs (id, name, created_by, created_at) VALUES (@id, @name, @createdBy, @now)`)
.run({ id, name, createdBy, now });
return { id, name, createdBy, createdAt: now };
}
export function getLocalOrg(db: Database.Database, id: string): LocalOrg | null {
const r = db.prepare('SELECT id, name, created_by, created_at FROM local_orgs WHERE id = ?').get(id) as
| { id: string; name: string; created_by: string | null; created_at: string }
| undefined;
return r ? rowToLocalOrg(r) : null;
}
export function listLocalOrgs(db: Database.Database): LocalOrg[] {
const rows = db.prepare('SELECT id, name, created_by, created_at FROM local_orgs ORDER BY name COLLATE NOCASE').all() as Array<{ id: string; name: string; created_by: string | null; created_at: string }>;
return rows.map(r => rowToLocalOrg(r));
}
export function renameLocalOrg(db: Database.Database, id: string, name: string): void {
db.prepare('UPDATE local_orgs SET name = ? WHERE id = ?').run(name, id);
}
/** Tables carrying `visibility_scope_org_id` (org-scoped resources). */
const ORG_SCOPED_TABLES = ['local_tasks', 'scheduled_tasks', 'jobs'];
/**
* Delete a local org. Members cascade via FK. Resources scoped to this org
* (visibility='org', visibility_scope_org_id=id) would become invisible to
* everyone once the org is gone — so first downgrade them to 'private'
* (owner + admin can still see them; no data loss). Atomic.
*/
export function deleteLocalOrg(db: Database.Database, id: string): void {
const tx = db.transaction((orgId: string) => {
for (const table of ORG_SCOPED_TABLES) {
db
.prepare(`UPDATE ${table} SET visibility = 'private', visibility_scope_org_id = NULL WHERE visibility_scope_org_id = ?`)
.run(orgId);
}
db.prepare('DELETE FROM local_orgs WHERE id = ?').run(orgId);
});
tx(id);
}
/** Add or update a member (idempotent — re-add updates the role). */
export function addOrgMember(db: Database.Database, orgId: string, userId: string, role: string = 'member'): void {
const now = new Date().toISOString();
db
.prepare(
`INSERT INTO local_org_members (org_id, user_id, role, added_at)
VALUES (@orgId, @userId, @role, @now)
ON CONFLICT(org_id, user_id) DO UPDATE SET role=@role`,
)
.run({ orgId, userId, role, now });
}
export function removeOrgMember(db: Database.Database, orgId: string, userId: string): void {
db.prepare('DELETE FROM local_org_members WHERE org_id = ? AND user_id = ?').run(orgId, userId);
}
export function listOrgMembers(db: Database.Database, orgId: string): LocalOrgMember[] {
const rows = db
.prepare('SELECT user_id, role FROM local_org_members WHERE org_id = ? ORDER BY added_at')
.all(orgId) as Array<{ user_id: string; role: string }>;
return rows.map(r => ({ userId: r.user_id, role: r.role }));
}
/** Orgs a user belongs to — merged into session.orgIds so the existing
* provider-agnostic 'org' visibility (buildVisibilityWhere) covers them. */
export function listUserLocalOrgs(db: Database.Database, userId: string): Array<{ orgId: string; name: string }> {
const rows = db
.prepare(
`SELECT o.id AS org_id, o.name AS name
FROM local_org_members m JOIN local_orgs o ON o.id = m.org_id
WHERE m.user_id = ? ORDER BY o.name COLLATE NOCASE`,
)
.all(userId) as Array<{ org_id: string; name: string }>;
return rows.map(r => ({ orgId: r.org_id, name: r.name }));
}
export function getUserById(db: Database.Database, id: string): User | null {
const row = db
.prepare('SELECT * FROM users WHERE id = ?')
.get(id) as UserRow | undefined;
return row ? rowToUser(row) : null;
}
export function getUserByEmail(db: Database.Database, email: string): User | null {
const row = db
.prepare('SELECT * FROM users WHERE email = ?')
.get(email) as UserRow | undefined;
return row ? rowToUser(row) : null;
}
export function findOrCreateUserByOAuth(db: Database.Database, params: FindOrCreateByOAuthParams): User {
// 1. Check if oauth_account already exists
const existing = db
.prepare('SELECT user_id FROM oauth_accounts WHERE provider = ? AND provider_id = ?')
.get(params.provider, params.providerId) as { user_id: string } | undefined;
if (existing) {
const user = getUserById(db, existing.user_id);
if (!user) throw new Error(`findOrCreateUserByOAuth: user ${existing.user_id} not found`);
// Sync mutable profile fields from the provider on every re-login so
// existing users whose name was missing on first login pick it up once
// their Gitea profile is populated. Email upgrade only applies when the
// dummy @gitea.local placeholder is being replaced.
const patch: { email?: string; name?: string; avatarUrl?: string | null } = {};
if (user.email.endsWith('@gitea.local') && !params.email.endsWith('@gitea.local')) {
patch.email = params.email;
}
if (params.name && params.name !== user.name) patch.name = params.name;
if (params.avatarUrl !== undefined && params.avatarUrl !== user.avatarUrl) {
patch.avatarUrl = params.avatarUrl;
}
if (Object.keys(patch).length > 0) {
updateUser(db, user.id, patch);
const refreshed = getUserById(db, user.id);
if (refreshed) return refreshed;
}
return user;
}
// 2. Check if user exists by email
let user = getUserByEmail(db, params.email);
if (!user) {
// 3. Create new user with status=pending
user = createUser(db, {
email: params.email,
name: params.name,
role: 'user',
status: 'pending',
avatarUrl: params.avatarUrl,
});
}
// 4. Link oauth_account to user
const oauthId = uuidv4();
const now = new Date().toISOString();
db
.prepare(
`INSERT OR IGNORE INTO oauth_accounts (id, user_id, provider, provider_id, created_at)
VALUES (@id, @userId, @provider, @providerId, @now)`
)
.run({
id: oauthId,
userId: user.id,
provider: params.provider,
providerId: params.providerId,
now,
});
return user;
}
export function listUsers(db: Database.Database): User[] {
const rows = db
.prepare('SELECT * FROM users ORDER BY created_at ASC')
.all() as UserRow[];
return rows.map(row => rowToUser(row));
}
/**
* Active users who share at least one organization with the given org id
* set (Gitea org cache `user_gitea_orgs` OR local org membership
* `local_org_members`). Used by the member-invite picker so a requester only
* sees collaborators inside their own org(s) — exposing the full user list is
* a privacy leak. Empty `orgIds` → empty result. Deduplicated by user id.
*/
export function listActiveUsersInOrgs(db: Database.Database, orgIds: string[]): User[] {
if (orgIds.length === 0) return [];
const placeholders = orgIds.map(() => '?').join(', ');
const rows = db
.prepare(
`SELECT u.* FROM users u
WHERE u.status = 'active' AND u.id IN (
SELECT user_id FROM user_gitea_orgs WHERE org_id IN (${placeholders})
UNION
SELECT user_id FROM local_org_members WHERE org_id IN (${placeholders})
)
ORDER BY u.created_at ASC`,
)
.all(...orgIds, ...orgIds) as UserRow[];
return rows.map(row => rowToUser(row));
}
export function updateUser(db: Database.Database, id: string, updates: {
status?: 'active' | 'pending' | 'disabled';
role?: 'admin' | 'user';
email?: string;
name?: string;
avatarUrl?: string | null;
defaultVisibility?: 'private' | 'org' | 'public';
defaultVisibilityOrgId?: string | null;
}): void {
const setClauses: string[] = ["updated_at = datetime('now')"];
const params: Record<string, unknown> = { id };
if (updates.status !== undefined) {
setClauses.push('status = @status');
params['status'] = updates.status;
}
if (updates.role !== undefined) {
setClauses.push('role = @role');
params['role'] = updates.role;
}
if (updates.email !== undefined) {
setClauses.push('email = @email');
params['email'] = updates.email;
}
if (updates.name !== undefined) {
setClauses.push('name = @name');
params['name'] = updates.name;
}
if (updates.avatarUrl !== undefined) {
setClauses.push('avatar_url = @avatar_url');
params['avatar_url'] = updates.avatarUrl;
}
if (updates.defaultVisibility !== undefined) {
setClauses.push('default_visibility = @default_visibility');
params['default_visibility'] = updates.defaultVisibility;
}
if (updates.defaultVisibilityOrgId !== undefined) {
setClauses.push('default_visibility_org_id = @default_visibility_org_id');
params['default_visibility_org_id'] = updates.defaultVisibilityOrgId;
}
if (setClauses.length === 1) return;
db
.prepare(`UPDATE users SET ${setClauses.join(', ')} WHERE id = @id`)
.run(params);
}
export function deleteUser(db: Database.Database, id: string): void {
// Never delete the shared `local` system/admin user: it is the no-auth
// fallback owner and owns all single-user-mode data. Deleting it would
// break no-auth mode and orphan every `local`-owned task/job/folder.
if (id === 'local') {
throw new Error('cannot delete the local/system user');
}
db.prepare('DELETE FROM users WHERE id = ?').run(id);
}
export function deleteSessionsByUserId(db: Database.Database, userId: string): void {
// Sessions store passport user info as JSON in sess column
// Delete sessions where sess contains the user id
const rows = db
.prepare('SELECT sid, sess FROM sessions')
.all() as Array<{ sid: string; sess: string }>;
const toDelete: string[] = [];
for (const row of rows) {
try {
const sess = JSON.parse(row.sess) as Record<string, unknown>;
const passport = sess['passport'] as Record<string, unknown> | undefined;
if (passport && passport['user'] === userId) {
toDelete.push(row.sid);
}
} catch {
// ignore parse errors
}
}
if (toDelete.length > 0) {
const placeholders = toDelete.map(() => '?').join(', ');
db.prepare(`DELETE FROM sessions WHERE sid IN (${placeholders})`).run(...toDelete);
}
}
export function replaceUserGiteaOrgs(db: Database.Database, userId: string, orgs: GiteaOrgInput[]): void {
const tx = db.transaction((uid: string, items: GiteaOrgInput[]) => {
db.prepare('DELETE FROM user_gitea_orgs WHERE user_id = ?').run(uid);
const insert = db.prepare(
'INSERT INTO user_gitea_orgs (user_id, org_id, org_name) VALUES (?, ?, ?)'
);
for (const o of items) insert.run(uid, o.orgId, o.orgName);
});
tx(userId, orgs);
}
export function listUserGiteaOrgs(db: Database.Database, userId: string): GiteaOrg[] {
const rows = db
.prepare('SELECT org_id, org_name, fetched_at FROM user_gitea_orgs WHERE user_id = ? ORDER BY org_name ASC')
.all(userId) as Array<{ org_id: string; org_name: string; fetched_at: string }>;
return rows.map(r => ({ orgId: r.org_id, orgName: r.org_name, fetchedAt: r.fetched_at }));
}