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:
shuaiplus
2026-04-29 02:49:45 +08:00
parent 3c5f43ecc2
commit 29a846c562
12 changed files with 2138 additions and 1828 deletions
+68 -2
View File
@@ -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;
}
+58 -24
View File
@@ -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();
}
+7 -7
View File
@@ -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