mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: add archiving functionality for ciphers
- Introduced `archive` and `unarchive` endpoints in the API for ciphers. - Implemented bulk archiving and unarchiving of ciphers in the vault. - Updated the storage schema to include `archived_at` timestamps for ciphers. - Enhanced user interface to support archiving actions in the vault. - Added necessary translations for archive-related actions. - Updated user and device models to accommodate new fields related to archiving.
This commit is contained in:
@@ -17,6 +17,7 @@ interface CipherRow {
|
||||
key: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
archived_at: string | null;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
@@ -37,6 +38,7 @@ function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
|
||||
key: row.key ?? parsed.key ?? null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
archivedAt: row.archived_at ?? parsed.archivedAt ?? parsed.archivedDate ?? null,
|
||||
deletedAt: row.deleted_at ?? null,
|
||||
};
|
||||
} catch {
|
||||
@@ -46,7 +48,7 @@ function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
|
||||
}
|
||||
|
||||
function selectCipherColumns(): string {
|
||||
return 'id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at';
|
||||
return 'id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at';
|
||||
}
|
||||
|
||||
export async function getCipher(db: D1Database, id: string): Promise<Cipher | null> {
|
||||
@@ -60,10 +62,10 @@ export async function getCipher(db: D1Database, id: string): Promise<Cipher | nu
|
||||
export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> {
|
||||
const data = JSON.stringify(cipher);
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at'
|
||||
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at'
|
||||
);
|
||||
await safeBind(
|
||||
stmt,
|
||||
@@ -79,10 +81,15 @@ export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cip
|
||||
cipher.key,
|
||||
cipher.createdAt,
|
||||
cipher.updatedAt,
|
||||
cipher.archivedAt ?? null,
|
||||
cipher.deletedAt
|
||||
).run();
|
||||
}
|
||||
|
||||
function sanitizeIds(ids: string[]): string[] {
|
||||
return Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
export async function deleteCipher(db: D1Database, id: string, userId: string): Promise<void> {
|
||||
await db.prepare('DELETE FROM ciphers WHERE id = ? AND user_id = ?').bind(id, userId).run();
|
||||
}
|
||||
@@ -95,7 +102,7 @@ export async function bulkSoftDeleteCiphers(
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
if (ids.length === 0) return null;
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
const uniqueIds = sanitizeIds(ids);
|
||||
if (!uniqueIds.length) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
@@ -126,7 +133,7 @@ export async function bulkRestoreCiphers(
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
if (ids.length === 0) return null;
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
const uniqueIds = sanitizeIds(ids);
|
||||
if (!uniqueIds.length) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
@@ -157,7 +164,7 @@ export async function bulkDeleteCiphers(
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
if (ids.length === 0) return null;
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
const uniqueIds = sanitizeIds(ids);
|
||||
if (!uniqueIds.length) return null;
|
||||
|
||||
const chunkSize = sqlChunkSize(1);
|
||||
@@ -212,7 +219,7 @@ export async function getCiphersByIds(
|
||||
userId: string
|
||||
): Promise<Cipher[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
const uniqueIds = sanitizeIds(ids);
|
||||
if (!uniqueIds.length) return [];
|
||||
|
||||
const chunkSize = sqlChunkSize(1);
|
||||
@@ -242,7 +249,7 @@ export async function bulkMoveCiphers(
|
||||
): Promise<string | null> {
|
||||
if (ids.length === 0) return null;
|
||||
const now = new Date().toISOString();
|
||||
const uniqueIds = Array.from(new Set(ids));
|
||||
const uniqueIds = sanitizeIds(ids);
|
||||
const patch = JSON.stringify({ folderId, updatedAt: now });
|
||||
const chunkSize = sqlChunkSize(4);
|
||||
|
||||
@@ -261,3 +268,65 @@ export async function bulkMoveCiphers(
|
||||
|
||||
return updateRevisionDate(userId);
|
||||
}
|
||||
|
||||
export async function bulkArchiveCiphers(
|
||||
db: D1Database,
|
||||
sqlChunkSize: SqlChunkSize,
|
||||
updateRevisionDate: UpdateRevisionDate,
|
||||
ids: string[],
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
if (ids.length === 0) return null;
|
||||
const uniqueIds = sanitizeIds(ids);
|
||||
if (!uniqueIds.length) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const patch = JSON.stringify({ archivedAt: now, archivedDate: now, updatedAt: now });
|
||||
const chunkSize = sqlChunkSize(4);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE ciphers
|
||||
SET archived_at = ?, updated_at = ?, data = json_patch(data, ?)
|
||||
WHERE user_id = ? AND id IN (${placeholders}) AND deleted_at IS NULL`
|
||||
)
|
||||
.bind(now, now, patch, userId, ...chunk)
|
||||
.run();
|
||||
}
|
||||
|
||||
return updateRevisionDate(userId);
|
||||
}
|
||||
|
||||
export async function bulkUnarchiveCiphers(
|
||||
db: D1Database,
|
||||
sqlChunkSize: SqlChunkSize,
|
||||
updateRevisionDate: UpdateRevisionDate,
|
||||
ids: string[],
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
if (ids.length === 0) return null;
|
||||
const uniqueIds = sanitizeIds(ids);
|
||||
if (!uniqueIds.length) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const patch = JSON.stringify({ archivedAt: null, archivedDate: null, updatedAt: now });
|
||||
const chunkSize = sqlChunkSize(3);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE ciphers
|
||||
SET archived_at = NULL, updated_at = ?, data = json_patch(data, ?)
|
||||
WHERE user_id = ? AND id IN (${placeholders})`
|
||||
)
|
||||
.bind(now, patch, userId, ...chunk)
|
||||
.run();
|
||||
}
|
||||
|
||||
return updateRevisionDate(userId);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ function mapDeviceRow(row: any): Device {
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
sessionStamp: row.session_stamp || '',
|
||||
encryptedUserKey: row.encrypted_user_key ?? null,
|
||||
encryptedPublicKey: row.encrypted_public_key ?? null,
|
||||
encryptedPrivateKey: row.encrypted_private_key ?? null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
@@ -22,19 +25,92 @@ export async function upsertDevice(
|
||||
deviceIdentifier: string,
|
||||
name: string,
|
||||
type: number,
|
||||
sessionStamp?: string
|
||||
sessionStamp?: string,
|
||||
keys?: {
|
||||
encryptedUserKey?: string | null;
|
||||
encryptedPublicKey?: string | null;
|
||||
encryptedPrivateKey?: string | null;
|
||||
}
|
||||
): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
const effectiveSessionStamp = String(sessionStamp || '').trim() || (await getDeviceById(userId, deviceIdentifier))?.sessionStamp || '';
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, banned, banned_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, 0, NULL, ?, ?) ' +
|
||||
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, updated_at=excluded.updated_at'
|
||||
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?) ' +
|
||||
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' +
|
||||
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
|
||||
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
|
||||
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
|
||||
'updated_at=excluded.updated_at'
|
||||
)
|
||||
.bind(
|
||||
userId,
|
||||
deviceIdentifier,
|
||||
name,
|
||||
type,
|
||||
effectiveSessionStamp,
|
||||
keys?.encryptedUserKey ?? null,
|
||||
keys?.encryptedPublicKey ?? null,
|
||||
keys?.encryptedPrivateKey ?? null,
|
||||
now,
|
||||
now
|
||||
)
|
||||
.bind(userId, deviceIdentifier, name, type, effectiveSessionStamp, now, now)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function updateDeviceKeys(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
deviceIdentifier: string,
|
||||
keys: {
|
||||
encryptedUserKey?: string | null;
|
||||
encryptedPublicKey?: string | null;
|
||||
encryptedPrivateKey?: string | null;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare(
|
||||
'UPDATE devices SET encrypted_user_key = ?, encrypted_public_key = ?, encrypted_private_key = ?, updated_at = ? ' +
|
||||
'WHERE user_id = ? AND device_identifier = ?'
|
||||
)
|
||||
.bind(
|
||||
keys.encryptedUserKey ?? null,
|
||||
keys.encryptedPublicKey ?? null,
|
||||
keys.encryptedPrivateKey ?? null,
|
||||
now,
|
||||
userId,
|
||||
deviceIdentifier
|
||||
)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function clearDeviceKeys(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
deviceIdentifiers: string[]
|
||||
): Promise<number> {
|
||||
const uniqueIds = Array.from(
|
||||
new Set(deviceIdentifiers.map((id) => String(id || '').trim()).filter(Boolean))
|
||||
);
|
||||
if (!uniqueIds.length) return 0;
|
||||
|
||||
const placeholders = uniqueIds.map(() => '?').join(',');
|
||||
const result = await db
|
||||
.prepare(
|
||||
`UPDATE devices
|
||||
SET encrypted_user_key = NULL,
|
||||
encrypted_public_key = NULL,
|
||||
encrypted_private_key = NULL,
|
||||
updated_at = ?
|
||||
WHERE user_id = ? AND device_identifier IN (${placeholders})`
|
||||
)
|
||||
.bind(new Date().toISOString(), userId, ...uniqueIds)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0);
|
||||
}
|
||||
|
||||
export async function isKnownDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||
const row = await db
|
||||
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
|
||||
@@ -57,7 +133,7 @@ export async function isKnownDeviceByEmail(
|
||||
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
|
||||
const res = await db
|
||||
.prepare(
|
||||
'SELECT user_id, device_identifier, name, type, session_stamp, banned, banned_at, created_at, updated_at ' +
|
||||
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' +
|
||||
'FROM devices WHERE user_id = ? ORDER BY updated_at DESC'
|
||||
)
|
||||
.bind(userId)
|
||||
@@ -68,7 +144,7 @@ export async function getDevicesByUserId(db: D1Database, userId: string): Promis
|
||||
export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT user_id, device_identifier, name, type, session_stamp, banned, banned_at, created_at, updated_at ' +
|
||||
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' +
|
||||
'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
|
||||
)
|
||||
.bind(userId, deviceIdentifier)
|
||||
|
||||
@@ -6,10 +6,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
|
||||
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
|
||||
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
|
||||
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', totp_secret TEXT, totp_recovery_code TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||
'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
|
||||
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
|
||||
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
|
||||
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1',
|
||||
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
|
||||
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
|
||||
|
||||
@@ -20,9 +21,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'CREATE TABLE IF NOT EXISTS ciphers (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, folder_id TEXT, name TEXT, notes TEXT, ' +
|
||||
'favorite INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL, reprompt INTEGER, key TEXT, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, archived_at TEXT, deleted_at TEXT, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'ALTER TABLE ciphers ADD COLUMN archived_at TEXT',
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS folders (' +
|
||||
@@ -68,12 +71,15 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS devices (' +
|
||||
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, ' +
|
||||
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
'PRIMARY KEY (user_id, device_identifier), ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at)',
|
||||
'ALTER TABLE devices ADD COLUMN session_stamp TEXT',
|
||||
'ALTER TABLE devices ADD COLUMN encrypted_user_key TEXT',
|
||||
'ALTER TABLE devices ADD COLUMN encrypted_public_key TEXT',
|
||||
'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT',
|
||||
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
|
||||
'ALTER TABLE devices ADD COLUMN banned_at TEXT',
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { User } from '../types';
|
||||
|
||||
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
|
||||
const USER_SELECT_COLUMNS =
|
||||
'id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, ' +
|
||||
'kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, ' +
|
||||
'totp_secret, totp_recovery_code, created_at, updated_at';
|
||||
|
||||
function mapUserRow(row: any): User {
|
||||
return {
|
||||
@@ -19,6 +23,7 @@ function mapUserRow(row: any): User {
|
||||
securityStamp: row.security_stamp,
|
||||
role: row.role === 'admin' ? 'admin' : 'user',
|
||||
status: row.status === 'banned' ? 'banned' : 'active',
|
||||
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
|
||||
totpSecret: row.totp_secret ?? null,
|
||||
totpRecoveryCode: row.totp_recovery_code ?? null,
|
||||
createdAt: row.created_at,
|
||||
@@ -28,9 +33,7 @@ function mapUserRow(row: any): User {
|
||||
|
||||
export async function getUser(db: D1Database, email: string): Promise<User | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE email = ?'
|
||||
)
|
||||
.prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users WHERE email = ?`)
|
||||
.bind(email.toLowerCase())
|
||||
.first<any>();
|
||||
if (!row) return null;
|
||||
@@ -39,9 +42,7 @@ export async function getUser(db: D1Database, email: string): Promise<User | nul
|
||||
|
||||
export async function getUserById(db: D1Database, id: string): Promise<User | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE id = ?'
|
||||
)
|
||||
.prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users WHERE id = ?`)
|
||||
.bind(id)
|
||||
.first<any>();
|
||||
if (!row) return null;
|
||||
@@ -55,9 +56,7 @@ export async function getUserCount(db: D1Database): Promise<number> {
|
||||
|
||||
export async function getAllUsers(db: D1Database): Promise<User[]> {
|
||||
const res = await db
|
||||
.prepare(
|
||||
'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'
|
||||
)
|
||||
.prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users ORDER BY created_at ASC`)
|
||||
.all<any>();
|
||||
return (res.results || []).map((row) => mapUserRow(row));
|
||||
}
|
||||
@@ -65,11 +64,11 @@ export async function getAllUsers(db: D1Database): Promise<User[]> {
|
||||
export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
|
||||
const email = user.email.toLowerCase();
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||
'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' +
|
||||
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at'
|
||||
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at'
|
||||
);
|
||||
await safeBind(
|
||||
stmt,
|
||||
@@ -88,6 +87,7 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User):
|
||||
user.securityStamp,
|
||||
user.role,
|
||||
user.status,
|
||||
user.verifyDevices ? 1 : 0,
|
||||
user.totpSecret,
|
||||
user.totpRecoveryCode,
|
||||
user.createdAt,
|
||||
@@ -102,8 +102,8 @@ export async function createUser(db: D1Database, safeBind: SafeBind, user: User)
|
||||
export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
|
||||
const email = user.email.toLowerCase();
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at) ' +
|
||||
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at) ' +
|
||||
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
|
||||
);
|
||||
const result = await safeBind(
|
||||
@@ -123,6 +123,7 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user:
|
||||
user.securityStamp,
|
||||
user.role,
|
||||
user.status,
|
||||
user.verifyDevices ? 1 : 0,
|
||||
user.totpSecret,
|
||||
user.totpRecoveryCode,
|
||||
user.createdAt,
|
||||
|
||||
+42
-3
@@ -36,10 +36,12 @@ import {
|
||||
saveFolder as saveStoredFolder,
|
||||
} from './storage-folder-repo';
|
||||
import {
|
||||
bulkArchiveCiphers as archiveStoredCiphers,
|
||||
bulkDeleteCiphers as deleteStoredCiphers,
|
||||
bulkMoveCiphers as moveStoredCiphers,
|
||||
bulkRestoreCiphers as restoreStoredCiphers,
|
||||
bulkSoftDeleteCiphers as softDeleteStoredCiphers,
|
||||
bulkUnarchiveCiphers as unarchiveStoredCiphers,
|
||||
getAllCiphers as listStoredCiphers,
|
||||
getCipher as findStoredCipher,
|
||||
getCiphersByIds as listStoredCiphersByIds,
|
||||
@@ -80,6 +82,7 @@ import {
|
||||
import {
|
||||
deleteDevice as deleteStoredDevice,
|
||||
deleteDevicesByUserId as deleteStoredDevicesByUserId,
|
||||
clearDeviceKeys as clearStoredDeviceKeys,
|
||||
deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice,
|
||||
deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId,
|
||||
getDevice as findStoredDevice,
|
||||
@@ -90,6 +93,7 @@ import {
|
||||
isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
|
||||
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
|
||||
upsertDevice as saveStoredDevice,
|
||||
updateDeviceKeys as updateStoredDeviceKeys,
|
||||
} from './storage-device-repo';
|
||||
import {
|
||||
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
|
||||
@@ -102,7 +106,7 @@ import {
|
||||
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
||||
const STORAGE_SCHEMA_VERSION = '2026-03-19.1';
|
||||
const STORAGE_SCHEMA_VERSION = '2026-03-23.1';
|
||||
|
||||
// D1-backed storage.
|
||||
// Contract:
|
||||
@@ -286,6 +290,14 @@ export class StorageService {
|
||||
return restoreStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
|
||||
}
|
||||
|
||||
async bulkArchiveCiphers(ids: string[], userId: string): Promise<string | null> {
|
||||
return archiveStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
|
||||
}
|
||||
|
||||
async bulkUnarchiveCiphers(ids: string[], userId: string): Promise<string | null> {
|
||||
return unarchiveStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
|
||||
}
|
||||
|
||||
async bulkDeleteCiphers(ids: string[], userId: string): Promise<string | null> {
|
||||
return deleteStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
|
||||
}
|
||||
@@ -495,8 +507,19 @@ export class StorageService {
|
||||
|
||||
// --- Devices ---
|
||||
|
||||
async upsertDevice(userId: string, deviceIdentifier: string, name: string, type: number, sessionStamp?: string): Promise<void> {
|
||||
await saveStoredDevice(this.db, this.getDevice.bind(this), userId, deviceIdentifier, name, type, sessionStamp);
|
||||
async upsertDevice(
|
||||
userId: string,
|
||||
deviceIdentifier: string,
|
||||
name: string,
|
||||
type: number,
|
||||
sessionStamp?: string,
|
||||
keys?: {
|
||||
encryptedUserKey?: string | null;
|
||||
encryptedPublicKey?: string | null;
|
||||
encryptedPrivateKey?: string | null;
|
||||
}
|
||||
): Promise<void> {
|
||||
await saveStoredDevice(this.db, this.getDevice.bind(this), userId, deviceIdentifier, name, type, sessionStamp, keys);
|
||||
}
|
||||
|
||||
async isKnownDevice(userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||
@@ -515,6 +538,22 @@ export class StorageService {
|
||||
return findStoredDevice(this.db, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
async updateDeviceKeys(
|
||||
userId: string,
|
||||
deviceIdentifier: string,
|
||||
keys: {
|
||||
encryptedUserKey?: string | null;
|
||||
encryptedPublicKey?: string | null;
|
||||
encryptedPrivateKey?: string | null;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
return updateStoredDeviceKeys(this.db, userId, deviceIdentifier, keys);
|
||||
}
|
||||
|
||||
async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> {
|
||||
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
|
||||
}
|
||||
|
||||
async deleteDevice(userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||
return deleteStoredDevice(this.db, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user