mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat(i18n): initialize internationalization and update Vite config for locale handling
- Added `initI18n` function call in `main.tsx` to bootstrap internationalization before rendering the app. - Updated Vite configuration to handle specific locale files for English and Chinese.
This commit is contained in:
+68
-2
@@ -6,6 +6,17 @@ import { StorageService } from './storage';
|
||||
// The client already does heavy PBKDF2 (600k iterations).
|
||||
// This second layer only needs to be non-trivial, not expensive.
|
||||
const SERVER_HASH_ITERATIONS = 100_000;
|
||||
const AUTH_CONTEXT_CACHE_TTL_MS = 15 * 1000;
|
||||
|
||||
interface CachedUserEntry {
|
||||
user: User | null;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface CachedDeviceEntry {
|
||||
device: Awaited<ReturnType<StorageService['getDevice']>>;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface VerifiedAccessContext {
|
||||
payload: JWTPayload;
|
||||
@@ -14,11 +25,65 @@ export interface VerifiedAccessContext {
|
||||
|
||||
export class AuthService {
|
||||
private storage: StorageService;
|
||||
private static userCache = new Map<string, CachedUserEntry>();
|
||||
private static deviceCache = new Map<string, CachedDeviceEntry>();
|
||||
|
||||
constructor(private env: Env) {
|
||||
this.storage = new StorageService(env.DB);
|
||||
}
|
||||
|
||||
private readCachedUser(userId: string): User | null | undefined {
|
||||
const cached = AuthService.userCache.get(userId);
|
||||
if (!cached) return undefined;
|
||||
if (cached.expiresAt <= Date.now()) {
|
||||
AuthService.userCache.delete(userId);
|
||||
return undefined;
|
||||
}
|
||||
return cached.user;
|
||||
}
|
||||
|
||||
private writeCachedUser(userId: string, user: User | null): void {
|
||||
AuthService.userCache.set(userId, {
|
||||
user,
|
||||
expiresAt: Date.now() + AUTH_CONTEXT_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
private async getCachedUser(userId: string): Promise<User | null> {
|
||||
const cached = this.readCachedUser(userId);
|
||||
if (cached !== undefined) return cached;
|
||||
const user = await this.storage.getUserById(userId);
|
||||
this.writeCachedUser(userId, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
private readCachedDevice(userId: string, deviceId: string) {
|
||||
const cacheKey = `${userId}:${deviceId}`;
|
||||
const cached = AuthService.deviceCache.get(cacheKey);
|
||||
if (!cached) return undefined;
|
||||
if (cached.expiresAt <= Date.now()) {
|
||||
AuthService.deviceCache.delete(cacheKey);
|
||||
return undefined;
|
||||
}
|
||||
return cached.device;
|
||||
}
|
||||
|
||||
private writeCachedDevice(userId: string, deviceId: string, device: Awaited<ReturnType<StorageService['getDevice']>>): void {
|
||||
const cacheKey = `${userId}:${deviceId}`;
|
||||
AuthService.deviceCache.set(cacheKey, {
|
||||
device,
|
||||
expiresAt: Date.now() + AUTH_CONTEXT_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
private async getCachedDevice(userId: string, deviceId: string) {
|
||||
const cached = this.readCachedDevice(userId, deviceId);
|
||||
if (cached !== undefined) return cached;
|
||||
const device = await this.storage.getDevice(userId, deviceId);
|
||||
this.writeCachedDevice(userId, deviceId, device);
|
||||
return device;
|
||||
}
|
||||
|
||||
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
|
||||
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
|
||||
// Result is prefixed with "$s$" to distinguish from legacy raw client hashes.
|
||||
@@ -97,15 +162,16 @@ export class AuthService {
|
||||
const payload = await verifyJWT(parts[1], this.env.JWT_SECRET);
|
||||
if (!payload) return null;
|
||||
|
||||
const user = await this.storage.getUserById(payload.sub);
|
||||
const user = await this.getCachedUser(payload.sub);
|
||||
if (!user) return null;
|
||||
if (user.status !== 'active') return null;
|
||||
|
||||
if (payload.sstamp !== user.securityStamp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (payload.did) {
|
||||
const device = await this.storage.getDevice(user.id, payload.did);
|
||||
const device = await this.getCachedDevice(user.id, payload.did);
|
||||
if (!device) return null;
|
||||
if (!payload.dstamp || payload.dstamp !== device.sessionStamp) return null;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,43 @@ interface CipherRow {
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
const CIPHER_SCALAR_DATA_KEYS = new Set([
|
||||
'id',
|
||||
'userId',
|
||||
'user_id',
|
||||
'type',
|
||||
'folderId',
|
||||
'folder_id',
|
||||
'name',
|
||||
'notes',
|
||||
'favorite',
|
||||
'reprompt',
|
||||
'key',
|
||||
'createdAt',
|
||||
'created_at',
|
||||
'creationDate',
|
||||
'updatedAt',
|
||||
'updated_at',
|
||||
'revisionDate',
|
||||
'archivedAt',
|
||||
'archived_at',
|
||||
'archivedDate',
|
||||
'deletedAt',
|
||||
'deleted_at',
|
||||
'deletedDate',
|
||||
]);
|
||||
|
||||
function buildCipherData(cipher: Cipher, folderId: string | null): string {
|
||||
const payload: Record<string, unknown> = {
|
||||
...cipher,
|
||||
folderId,
|
||||
};
|
||||
for (const key of CIPHER_SCALAR_DATA_KEYS) {
|
||||
delete payload[key];
|
||||
}
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
|
||||
if (!row?.data) return null;
|
||||
try {
|
||||
@@ -68,10 +105,7 @@ export async function getCipher(db: D1Database, id: string): Promise<Cipher | nu
|
||||
|
||||
export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> {
|
||||
const folderId = normalizeOptionalId(cipher.folderId);
|
||||
const data = JSON.stringify({
|
||||
...cipher,
|
||||
folderId,
|
||||
});
|
||||
const data = buildCipherData(cipher, folderId);
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
@@ -117,8 +151,7 @@ export async function bulkSoftDeleteCiphers(
|
||||
if (!uniqueIds.length) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const patch = JSON.stringify({ deletedAt: now, updatedAt: now });
|
||||
const chunkSize = sqlChunkSize(4);
|
||||
const chunkSize = sqlChunkSize(3);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
@@ -126,10 +159,11 @@ export async function bulkSoftDeleteCiphers(
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE ciphers
|
||||
SET deleted_at = ?, updated_at = ?, data = json_patch(data, ?)
|
||||
SET deleted_at = ?, updated_at = ?,
|
||||
data = json_remove(data, '$.deletedAt', '$.deletedDate', '$.updatedAt', '$.revisionDate')
|
||||
WHERE user_id = ? AND id IN (${placeholders})`
|
||||
)
|
||||
.bind(now, now, patch, userId, ...chunk)
|
||||
.bind(now, now, userId, ...chunk)
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -148,8 +182,7 @@ export async function bulkRestoreCiphers(
|
||||
if (!uniqueIds.length) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const patch = JSON.stringify({ deletedAt: null, updatedAt: now });
|
||||
const chunkSize = sqlChunkSize(3);
|
||||
const chunkSize = sqlChunkSize(2);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
@@ -157,10 +190,11 @@ export async function bulkRestoreCiphers(
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE ciphers
|
||||
SET deleted_at = NULL, updated_at = ?, data = json_patch(data, ?)
|
||||
SET deleted_at = NULL, updated_at = ?,
|
||||
data = json_remove(data, '$.deletedAt', '$.deletedDate', '$.updatedAt', '$.revisionDate')
|
||||
WHERE user_id = ? AND id IN (${placeholders})`
|
||||
)
|
||||
.bind(now, patch, userId, ...chunk)
|
||||
.bind(now, userId, ...chunk)
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -262,8 +296,7 @@ export async function bulkMoveCiphers(
|
||||
const now = new Date().toISOString();
|
||||
const normalizedFolderId = normalizeOptionalId(folderId);
|
||||
const uniqueIds = sanitizeIds(ids);
|
||||
const patch = JSON.stringify({ folderId: normalizedFolderId, updatedAt: now });
|
||||
const chunkSize = sqlChunkSize(4);
|
||||
const chunkSize = sqlChunkSize(3);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
@@ -271,10 +304,11 @@ export async function bulkMoveCiphers(
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE ciphers
|
||||
SET folder_id = ?, updated_at = ?, data = json_patch(data, ?)
|
||||
SET folder_id = ?, updated_at = ?,
|
||||
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
|
||||
WHERE user_id = ? AND id IN (${placeholders})`
|
||||
)
|
||||
.bind(normalizedFolderId, now, patch, userId, ...chunk)
|
||||
.bind(normalizedFolderId, now, userId, ...chunk)
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -293,8 +327,7 @@ export async function bulkArchiveCiphers(
|
||||
if (!uniqueIds.length) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const patch = JSON.stringify({ archivedAt: now, archivedDate: now, updatedAt: now });
|
||||
const chunkSize = sqlChunkSize(4);
|
||||
const chunkSize = sqlChunkSize(3);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
@@ -302,10 +335,11 @@ export async function bulkArchiveCiphers(
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE ciphers
|
||||
SET archived_at = ?, updated_at = ?, data = json_patch(data, ?)
|
||||
SET archived_at = ?, updated_at = ?,
|
||||
data = json_remove(data, '$.archivedAt', '$.archivedDate', '$.updatedAt', '$.revisionDate')
|
||||
WHERE user_id = ? AND id IN (${placeholders}) AND deleted_at IS NULL`
|
||||
)
|
||||
.bind(now, now, patch, userId, ...chunk)
|
||||
.bind(now, now, userId, ...chunk)
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -324,8 +358,7 @@ export async function bulkUnarchiveCiphers(
|
||||
if (!uniqueIds.length) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const patch = JSON.stringify({ archivedAt: null, archivedDate: null, updatedAt: now });
|
||||
const chunkSize = sqlChunkSize(3);
|
||||
const chunkSize = sqlChunkSize(2);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
@@ -333,10 +366,11 @@ export async function bulkUnarchiveCiphers(
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE ciphers
|
||||
SET archived_at = NULL, updated_at = ?, data = json_patch(data, ?)
|
||||
SET archived_at = NULL, updated_at = ?,
|
||||
data = json_remove(data, '$.archivedAt', '$.archivedDate', '$.updatedAt', '$.revisionDate')
|
||||
WHERE user_id = ? AND id IN (${placeholders})`
|
||||
)
|
||||
.bind(now, patch, userId, ...chunk)
|
||||
.bind(now, userId, ...chunk)
|
||||
.run();
|
||||
}
|
||||
|
||||
|
||||
@@ -39,14 +39,14 @@ export async function clearFolderFromCiphers(
|
||||
folderId: string
|
||||
): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
const patch = JSON.stringify({ folderId: null, updatedAt: now });
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE ciphers
|
||||
SET folder_id = NULL, updated_at = ?, data = json_patch(data, ?)
|
||||
SET folder_id = NULL, updated_at = ?,
|
||||
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
|
||||
WHERE user_id = ? AND folder_id = ?`
|
||||
)
|
||||
.bind(now, patch, userId, folderId)
|
||||
.bind(now, userId, folderId)
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -61,8 +61,7 @@ export async function bulkDeleteFolders(
|
||||
if (!uniqueIds.length) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const patch = JSON.stringify({ folderId: null, updatedAt: now });
|
||||
const chunkSize = sqlChunkSize(3);
|
||||
const chunkSize = sqlChunkSize(2);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
@@ -70,10 +69,11 @@ export async function bulkDeleteFolders(
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE ciphers
|
||||
SET folder_id = NULL, updated_at = ?, data = json_patch(data, ?)
|
||||
SET folder_id = NULL, updated_at = ?,
|
||||
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
|
||||
WHERE user_id = ? AND folder_id IN (${placeholders})`
|
||||
)
|
||||
.bind(now, patch, userId, ...chunk)
|
||||
.bind(now, userId, ...chunk)
|
||||
.run();
|
||||
|
||||
await db
|
||||
|
||||
Reference in New Issue
Block a user