mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
chore: switch storage to D1 (test branch)
This commit is contained in:
+287
-183
@@ -1,256 +1,360 @@
|
||||
import { Env, User, Cipher, Folder, Attachment } from '../types';
|
||||
import { User, Cipher, Folder, Attachment } from '../types';
|
||||
|
||||
const KEYS = {
|
||||
CONFIG_REGISTERED: 'config:registered',
|
||||
CONFIG_SETUP_DISABLED: 'config:setup_disabled',
|
||||
USER_PREFIX: 'user:',
|
||||
CIPHER_PREFIX: 'cipher:',
|
||||
FOLDER_PREFIX: 'folder:',
|
||||
ATTACHMENT_PREFIX: 'attachment:',
|
||||
CIPHERS_INDEX: 'index:ciphers',
|
||||
FOLDERS_INDEX: 'index:folders',
|
||||
ATTACHMENTS_INDEX: 'index:attachments',
|
||||
REFRESH_TOKEN_PREFIX: 'refresh:',
|
||||
REVISION_DATE_PREFIX: 'revision:',
|
||||
};
|
||||
// D1-backed storage.
|
||||
// Contract:
|
||||
// - All methods are scoped by userId where applicable.
|
||||
// - Uses SQL constraints (PK/unique/FK) to avoid KV-style index race conditions.
|
||||
// - Revision date is maintained per user for Bitwarden sync.
|
||||
|
||||
export class StorageService {
|
||||
constructor(private kv: KVNamespace) {}
|
||||
constructor(private db: D1Database) {}
|
||||
|
||||
// --- Config / setup ---
|
||||
|
||||
// Registration status
|
||||
async isRegistered(): Promise<boolean> {
|
||||
const value = await this.kv.get(KEYS.CONFIG_REGISTERED);
|
||||
return value === 'true';
|
||||
const row = await this.db.prepare('SELECT value FROM config WHERE key = ?').bind('registered').first<{ value: string }>();
|
||||
return row?.value === 'true';
|
||||
}
|
||||
|
||||
async setRegistered(): Promise<void> {
|
||||
await this.kv.put(KEYS.CONFIG_REGISTERED, 'true');
|
||||
await this.db.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
||||
.bind('registered', 'true')
|
||||
.run();
|
||||
}
|
||||
|
||||
// Setup page visibility
|
||||
async isSetupDisabled(): Promise<boolean> {
|
||||
const value = await this.kv.get(KEYS.CONFIG_SETUP_DISABLED);
|
||||
return value === 'true';
|
||||
const row = await this.db.prepare('SELECT value FROM config WHERE key = ?').bind('setup_disabled').first<{ value: string }>();
|
||||
return row?.value === 'true';
|
||||
}
|
||||
|
||||
async setSetupDisabled(): Promise<void> {
|
||||
await this.kv.put(KEYS.CONFIG_SETUP_DISABLED, 'true');
|
||||
await this.db.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
||||
.bind('setup_disabled', 'true')
|
||||
.run();
|
||||
}
|
||||
|
||||
// User operations
|
||||
// --- Users ---
|
||||
|
||||
async getUser(email: string): Promise<User | null> {
|
||||
const data = await this.kv.get(`${KEYS.USER_PREFIX}${email.toLowerCase()}`);
|
||||
return data ? JSON.parse(data) : null;
|
||||
const row = await this.db
|
||||
.prepare(
|
||||
'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at FROM users WHERE email = ?'
|
||||
)
|
||||
.bind(email.toLowerCase())
|
||||
.first<any>();
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
masterPasswordHash: row.master_password_hash,
|
||||
key: row.key,
|
||||
privateKey: row.private_key,
|
||||
publicKey: row.public_key,
|
||||
kdfType: row.kdf_type,
|
||||
kdfIterations: row.kdf_iterations,
|
||||
kdfMemory: row.kdf_memory ?? undefined,
|
||||
kdfParallelism: row.kdf_parallelism ?? undefined,
|
||||
securityStamp: row.security_stamp,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
async getUserById(id: string): Promise<User | null> {
|
||||
// Get user email from id mapping
|
||||
const email = await this.kv.get(`userid:${id}`);
|
||||
if (!email) return null;
|
||||
return this.getUser(email);
|
||||
const row = await this.db
|
||||
.prepare(
|
||||
'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at FROM users WHERE id = ?'
|
||||
)
|
||||
.bind(id)
|
||||
.first<any>();
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
masterPasswordHash: row.master_password_hash,
|
||||
key: row.key,
|
||||
privateKey: row.private_key,
|
||||
publicKey: row.public_key,
|
||||
kdfType: row.kdf_type,
|
||||
kdfIterations: row.kdf_iterations,
|
||||
kdfMemory: row.kdf_memory ?? undefined,
|
||||
kdfParallelism: row.kdf_parallelism ?? undefined,
|
||||
securityStamp: row.security_stamp,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
async saveUser(user: User): Promise<void> {
|
||||
await this.kv.put(`${KEYS.USER_PREFIX}${user.email.toLowerCase()}`, JSON.stringify(user));
|
||||
await this.kv.put(`userid:${user.id}`, user.email.toLowerCase());
|
||||
const email = user.email.toLowerCase();
|
||||
await this.db
|
||||
.prepare(
|
||||
'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||
'email=excluded.email, name=excluded.name, 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, updated_at=excluded.updated_at'
|
||||
)
|
||||
.bind(
|
||||
user.id,
|
||||
email,
|
||||
user.name,
|
||||
user.masterPasswordHash,
|
||||
user.key,
|
||||
user.privateKey,
|
||||
user.publicKey,
|
||||
user.kdfType,
|
||||
user.kdfIterations,
|
||||
user.kdfMemory ?? null,
|
||||
user.kdfParallelism ?? null,
|
||||
user.securityStamp,
|
||||
user.createdAt,
|
||||
user.updatedAt
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
// Cipher operations
|
||||
// --- Ciphers ---
|
||||
|
||||
async getCipher(id: string): Promise<Cipher | null> {
|
||||
const data = await this.kv.get(`${KEYS.CIPHER_PREFIX}${id}`);
|
||||
return data ? JSON.parse(data) : null;
|
||||
const row = await this.db.prepare('SELECT data FROM ciphers WHERE id = ?').bind(id).first<{ data: string }>();
|
||||
return row?.data ? (JSON.parse(row.data) as Cipher) : null;
|
||||
}
|
||||
|
||||
async saveCipher(cipher: Cipher): Promise<void> {
|
||||
await this.kv.put(`${KEYS.CIPHER_PREFIX}${cipher.id}`, JSON.stringify(cipher));
|
||||
|
||||
// Update index
|
||||
const index = await this.getCipherIds(cipher.userId);
|
||||
if (!index.includes(cipher.id)) {
|
||||
index.push(cipher.id);
|
||||
await this.kv.put(`${KEYS.CIPHERS_INDEX}:${cipher.userId}`, JSON.stringify(index));
|
||||
}
|
||||
const data = JSON.stringify(cipher);
|
||||
await this.db
|
||||
.prepare(
|
||||
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_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'
|
||||
)
|
||||
.bind(
|
||||
cipher.id,
|
||||
cipher.userId,
|
||||
Number(cipher.type) || 1,
|
||||
cipher.folderId,
|
||||
cipher.name,
|
||||
cipher.notes,
|
||||
cipher.favorite ? 1 : 0,
|
||||
data,
|
||||
cipher.reprompt ?? 0,
|
||||
cipher.key,
|
||||
cipher.createdAt,
|
||||
cipher.updatedAt,
|
||||
cipher.deletedAt
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
async deleteCipher(id: string, userId: string): Promise<void> {
|
||||
await this.kv.delete(`${KEYS.CIPHER_PREFIX}${id}`);
|
||||
|
||||
// Update index
|
||||
const index = await this.getCipherIds(userId);
|
||||
const newIndex = index.filter(cid => cid !== id);
|
||||
await this.kv.put(`${KEYS.CIPHERS_INDEX}:${userId}`, JSON.stringify(newIndex));
|
||||
}
|
||||
|
||||
async getCipherIds(userId: string): Promise<string[]> {
|
||||
const data = await this.kv.get(`${KEYS.CIPHERS_INDEX}:${userId}`);
|
||||
return data ? JSON.parse(data) : [];
|
||||
// hard delete
|
||||
await this.db.prepare('DELETE FROM ciphers WHERE id = ? AND user_id = ?').bind(id, userId).run();
|
||||
}
|
||||
|
||||
async getAllCiphers(userId: string): Promise<Cipher[]> {
|
||||
const ids = await this.getCipherIds(userId);
|
||||
const ciphers: Cipher[] = [];
|
||||
|
||||
for (const id of ids) {
|
||||
const cipher = await this.getCipher(id);
|
||||
if (cipher) ciphers.push(cipher);
|
||||
}
|
||||
|
||||
return ciphers;
|
||||
const res = await this.db.prepare('SELECT data FROM ciphers WHERE user_id = ? ORDER BY updated_at DESC').bind(userId).all<{ data: string }>();
|
||||
return (res.results || []).map(r => JSON.parse(r.data) as Cipher);
|
||||
}
|
||||
|
||||
// Folder operations
|
||||
async getFolder(id: string): Promise<Folder | null> {
|
||||
const data = await this.kv.get(`${KEYS.FOLDER_PREFIX}${id}`);
|
||||
return data ? JSON.parse(data) : null;
|
||||
}
|
||||
|
||||
async saveFolder(folder: Folder): Promise<void> {
|
||||
await this.kv.put(`${KEYS.FOLDER_PREFIX}${folder.id}`, JSON.stringify(folder));
|
||||
|
||||
// Update index
|
||||
const index = await this.getFolderIds(folder.userId);
|
||||
if (!index.includes(folder.id)) {
|
||||
index.push(folder.id);
|
||||
await this.kv.put(`${KEYS.FOLDERS_INDEX}:${folder.userId}`, JSON.stringify(index));
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFolder(id: string, userId: string): Promise<void> {
|
||||
await this.kv.delete(`${KEYS.FOLDER_PREFIX}${id}`);
|
||||
|
||||
// Update index
|
||||
const index = await this.getFolderIds(userId);
|
||||
const newIndex = index.filter(fid => fid !== id);
|
||||
await this.kv.put(`${KEYS.FOLDERS_INDEX}:${userId}`, JSON.stringify(newIndex));
|
||||
}
|
||||
|
||||
async getFolderIds(userId: string): Promise<string[]> {
|
||||
const data = await this.kv.get(`${KEYS.FOLDERS_INDEX}:${userId}`);
|
||||
return data ? JSON.parse(data) : [];
|
||||
}
|
||||
|
||||
async getAllFolders(userId: string): Promise<Folder[]> {
|
||||
const ids = await this.getFolderIds(userId);
|
||||
const folders: Folder[] = [];
|
||||
|
||||
for (const id of ids) {
|
||||
const folder = await this.getFolder(id);
|
||||
if (folder) folders.push(folder);
|
||||
}
|
||||
|
||||
return folders;
|
||||
}
|
||||
|
||||
// Refresh token operations
|
||||
async saveRefreshToken(token: string, userId: string): Promise<void> {
|
||||
// Store refresh token with 30 day expiry
|
||||
await this.kv.put(`${KEYS.REFRESH_TOKEN_PREFIX}${token}`, userId, {
|
||||
expirationTtl: 30 * 24 * 60 * 60,
|
||||
});
|
||||
}
|
||||
|
||||
async getRefreshTokenUserId(token: string): Promise<string | null> {
|
||||
return await this.kv.get(`${KEYS.REFRESH_TOKEN_PREFIX}${token}`);
|
||||
}
|
||||
|
||||
async deleteRefreshToken(token: string): Promise<void> {
|
||||
await this.kv.delete(`${KEYS.REFRESH_TOKEN_PREFIX}${token}`);
|
||||
}
|
||||
|
||||
// Revision date operations (for incremental sync)
|
||||
async getRevisionDate(userId: string): Promise<string> {
|
||||
const date = await this.kv.get(`${KEYS.REVISION_DATE_PREFIX}${userId}`);
|
||||
return date || new Date().toISOString();
|
||||
}
|
||||
|
||||
async updateRevisionDate(userId: string): Promise<string> {
|
||||
const date = new Date().toISOString();
|
||||
await this.kv.put(`${KEYS.REVISION_DATE_PREFIX}${userId}`, date);
|
||||
return date;
|
||||
}
|
||||
|
||||
// Bulk cipher operations
|
||||
async getCiphersByIds(ids: string[], userId: string): Promise<Cipher[]> {
|
||||
const ciphers: Cipher[] = [];
|
||||
for (const id of ids) {
|
||||
const cipher = await this.getCipher(id);
|
||||
if (cipher && cipher.userId === userId) {
|
||||
ciphers.push(cipher);
|
||||
}
|
||||
}
|
||||
return ciphers;
|
||||
if (ids.length === 0) return [];
|
||||
// D1 doesn't support binding arrays directly; build placeholders.
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
const stmt = this.db.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`);
|
||||
const res = await stmt.bind(userId, ...ids).all<{ data: string }>();
|
||||
return (res.results || []).map(r => JSON.parse(r.data) as Cipher);
|
||||
}
|
||||
|
||||
async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// D1 forbids raw BEGIN/COMMIT statements in this runtime.
|
||||
// For this endpoint, we accept per-row updates and then bump revision once.
|
||||
// Concurrency: each cipher write is an UPSERT on its PK, no shared index.
|
||||
for (const id of ids) {
|
||||
const cipher = await this.getCipher(id);
|
||||
if (cipher && cipher.userId === userId) {
|
||||
cipher.folderId = folderId;
|
||||
cipher.updatedAt = now;
|
||||
await this.saveCipher(cipher);
|
||||
}
|
||||
const row = await this.db
|
||||
.prepare('SELECT data FROM ciphers WHERE id = ? AND user_id = ?')
|
||||
.bind(id, userId)
|
||||
.first<{ data: string }>();
|
||||
if (!row?.data) continue;
|
||||
const cipher = JSON.parse(row.data) as Cipher;
|
||||
cipher.folderId = folderId;
|
||||
cipher.updatedAt = now;
|
||||
await this.saveCipher(cipher);
|
||||
}
|
||||
|
||||
await this.updateRevisionDate(userId);
|
||||
}
|
||||
|
||||
// Attachment operations
|
||||
// --- Folders ---
|
||||
|
||||
async getFolder(id: string): Promise<Folder | null> {
|
||||
const row = await this.db
|
||||
.prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE id = ?')
|
||||
.bind(id)
|
||||
.first<any>();
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
name: row.name,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
async saveFolder(folder: Folder): Promise<void> {
|
||||
await this.db
|
||||
.prepare(
|
||||
'INSERT INTO folders(id, user_id, name, created_at, updated_at) VALUES(?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET user_id=excluded.user_id, name=excluded.name, updated_at=excluded.updated_at'
|
||||
)
|
||||
.bind(folder.id, folder.userId, folder.name, folder.createdAt, folder.updatedAt)
|
||||
.run();
|
||||
}
|
||||
|
||||
async deleteFolder(id: string, userId: string): Promise<void> {
|
||||
await this.db.prepare('DELETE FROM folders WHERE id = ? AND user_id = ?').bind(id, userId).run();
|
||||
}
|
||||
|
||||
async getAllFolders(userId: string): Promise<Folder[]> {
|
||||
const res = await this.db
|
||||
.prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE user_id = ? ORDER BY updated_at DESC')
|
||||
.bind(userId)
|
||||
.all<any>();
|
||||
return (res.results || []).map(r => ({
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
name: r.name,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Attachments ---
|
||||
|
||||
async getAttachment(id: string): Promise<Attachment | null> {
|
||||
const data = await this.kv.get(`${KEYS.ATTACHMENT_PREFIX}${id}`);
|
||||
return data ? JSON.parse(data) : null;
|
||||
const row = await this.db
|
||||
.prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE id = ?')
|
||||
.bind(id)
|
||||
.first<any>();
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
cipherId: row.cipher_id,
|
||||
fileName: row.file_name,
|
||||
size: row.size,
|
||||
sizeName: row.size_name,
|
||||
key: row.key,
|
||||
};
|
||||
}
|
||||
|
||||
async saveAttachment(attachment: Attachment): Promise<void> {
|
||||
await this.kv.put(`${KEYS.ATTACHMENT_PREFIX}${attachment.id}`, JSON.stringify(attachment));
|
||||
await this.db
|
||||
.prepare(
|
||||
'INSERT INTO attachments(id, cipher_id, file_name, size, size_name, key) VALUES(?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET cipher_id=excluded.cipher_id, file_name=excluded.file_name, size=excluded.size, size_name=excluded.size_name, key=excluded.key'
|
||||
)
|
||||
.bind(attachment.id, attachment.cipherId, attachment.fileName, attachment.size, attachment.sizeName, attachment.key)
|
||||
.run();
|
||||
}
|
||||
|
||||
async deleteAttachment(id: string): Promise<void> {
|
||||
await this.kv.delete(`${KEYS.ATTACHMENT_PREFIX}${id}`);
|
||||
}
|
||||
|
||||
async getAttachmentIdsByCipher(cipherId: string): Promise<string[]> {
|
||||
const data = await this.kv.get(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`);
|
||||
return data ? JSON.parse(data) : [];
|
||||
await this.db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run();
|
||||
}
|
||||
|
||||
async getAttachmentsByCipher(cipherId: string): Promise<Attachment[]> {
|
||||
const ids = await this.getAttachmentIdsByCipher(cipherId);
|
||||
const attachments: Attachment[] = [];
|
||||
for (const id of ids) {
|
||||
const attachment = await this.getAttachment(id);
|
||||
if (attachment) attachments.push(attachment);
|
||||
}
|
||||
return attachments;
|
||||
const res = await this.db
|
||||
.prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id = ?')
|
||||
.bind(cipherId)
|
||||
.all<any>();
|
||||
return (res.results || []).map(r => ({
|
||||
id: r.id,
|
||||
cipherId: r.cipher_id,
|
||||
fileName: r.file_name,
|
||||
size: r.size,
|
||||
sizeName: r.size_name,
|
||||
key: r.key,
|
||||
}));
|
||||
}
|
||||
|
||||
async addAttachmentToCipher(cipherId: string, attachmentId: string): Promise<void> {
|
||||
const ids = await this.getAttachmentIdsByCipher(cipherId);
|
||||
if (!ids.includes(attachmentId)) {
|
||||
ids.push(attachmentId);
|
||||
await this.kv.put(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`, JSON.stringify(ids));
|
||||
}
|
||||
// Kept for API compatibility; no-op because attachments table already links cipher_id.
|
||||
// We still validate that the attachment exists and belongs to cipher.
|
||||
await this.db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run();
|
||||
}
|
||||
|
||||
async removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise<void> {
|
||||
const ids = await this.getAttachmentIdsByCipher(cipherId);
|
||||
const newIds = ids.filter(id => id !== attachmentId);
|
||||
await this.kv.put(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`, JSON.stringify(newIds));
|
||||
// No-op: schema uses NOT NULL cipher_id.
|
||||
// Callers always delete attachment row afterwards, so this method is kept for compatibility only.
|
||||
void cipherId;
|
||||
void attachmentId;
|
||||
}
|
||||
|
||||
async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> {
|
||||
const ids = await this.getAttachmentIdsByCipher(cipherId);
|
||||
for (const id of ids) {
|
||||
await this.deleteAttachment(id);
|
||||
}
|
||||
await this.kv.delete(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`);
|
||||
await this.db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run();
|
||||
}
|
||||
|
||||
async updateCipherRevisionDate(cipherId: string): Promise<void> {
|
||||
const cipher = await this.getCipher(cipherId);
|
||||
if (cipher) {
|
||||
cipher.updatedAt = new Date().toISOString();
|
||||
await this.saveCipher(cipher);
|
||||
await this.updateRevisionDate(cipher.userId);
|
||||
if (!cipher) return;
|
||||
cipher.updatedAt = new Date().toISOString();
|
||||
await this.saveCipher(cipher);
|
||||
await this.updateRevisionDate(cipher.userId);
|
||||
}
|
||||
|
||||
// --- Refresh tokens ---
|
||||
|
||||
async saveRefreshToken(token: string, userId: string, expiresAtMs?: number): Promise<void> {
|
||||
const expiresAt = expiresAtMs ?? (Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
await this.db.prepare(
|
||||
'INSERT INTO refresh_tokens(token, user_id, expires_at) VALUES(?, ?, ?) ' +
|
||||
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at'
|
||||
)
|
||||
.bind(token, userId, expiresAt)
|
||||
.run();
|
||||
}
|
||||
|
||||
async getRefreshTokenUserId(token: string): Promise<string | null> {
|
||||
const now = Date.now();
|
||||
const row = await this.db.prepare('SELECT user_id, expires_at FROM refresh_tokens WHERE token = ?')
|
||||
.bind(token)
|
||||
.first<{ user_id: string; expires_at: number }>();
|
||||
|
||||
if (!row) return null;
|
||||
if (row.expires_at && row.expires_at < now) {
|
||||
await this.deleteRefreshToken(token);
|
||||
return null;
|
||||
}
|
||||
return row.user_id;
|
||||
}
|
||||
|
||||
async deleteRefreshToken(token: string): Promise<void> {
|
||||
await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
|
||||
}
|
||||
|
||||
// --- Revision dates ---
|
||||
|
||||
async getRevisionDate(userId: string): Promise<string> {
|
||||
const row = await this.db.prepare('SELECT revision_date FROM user_revisions WHERE user_id = ?')
|
||||
.bind(userId)
|
||||
.first<{ revision_date: string }>();
|
||||
return row?.revision_date || new Date().toISOString();
|
||||
}
|
||||
|
||||
async updateRevisionDate(userId: string): Promise<string> {
|
||||
const date = new Date().toISOString();
|
||||
await this.db.prepare(
|
||||
'INSERT INTO user_revisions(user_id, revision_date) VALUES(?, ?) ' +
|
||||
'ON CONFLICT(user_id) DO UPDATE SET revision_date = excluded.revision_date'
|
||||
)
|
||||
.bind(userId, date)
|
||||
.run();
|
||||
return date;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user