mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
refactor: enhance manual chunking in Vite config for better code splitting
This commit is contained in:
@@ -0,0 +1,644 @@
|
||||
import { Env, Send, SendAuthType, SendType } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import {
|
||||
getBlobStorageMaxBytes,
|
||||
getSendFileObjectKey,
|
||||
putBlobObject,
|
||||
deleteBlobObject,
|
||||
} from '../services/blob-store';
|
||||
import {
|
||||
formatSize,
|
||||
getAliasedProp,
|
||||
normalizeEmails,
|
||||
notifyVaultSyncForRequest,
|
||||
parseDate,
|
||||
parseFileLength,
|
||||
parseInteger,
|
||||
parseMaxAccessCount,
|
||||
parseSendAuthType,
|
||||
parseSendType,
|
||||
parseStoredSendData,
|
||||
sanitizeSendData,
|
||||
sendToResponse,
|
||||
setSendPassword,
|
||||
validateDeletionDate,
|
||||
} from './sends-shared';
|
||||
|
||||
export async function handleGetSends(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const url = new URL(request.url);
|
||||
const pagination = parsePagination(url);
|
||||
|
||||
let sends: Send[];
|
||||
let continuationToken: string | null = null;
|
||||
if (pagination) {
|
||||
const pageRows = await storage.getSendsPage(userId, pagination.limit + 1, pagination.offset);
|
||||
const hasNext = pageRows.length > pagination.limit;
|
||||
sends = hasNext ? pageRows.slice(0, pagination.limit) : pageRows;
|
||||
continuationToken = hasNext ? encodeContinuationToken(pagination.offset + sends.length) : null;
|
||||
} else {
|
||||
sends = await storage.getAllSends(userId);
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
data: sends.map(sendToResponse),
|
||||
object: 'list',
|
||||
continuationToken,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleGetSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
void request;
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found', 404);
|
||||
}
|
||||
|
||||
return jsonResponse(sendToResponse(send));
|
||||
}
|
||||
|
||||
export async function handleCreateSend(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const typeRaw = getAliasedProp(body, ['type', 'Type']);
|
||||
const sendType = parseSendType(typeRaw.value);
|
||||
if (sendType === null) {
|
||||
return errorResponse('Invalid Send type', 400);
|
||||
}
|
||||
if (sendType === SendType.File) {
|
||||
return errorResponse('File sends should use /api/sends/file/v2', 400);
|
||||
}
|
||||
|
||||
const nameRaw = getAliasedProp(body, ['name', 'Name']);
|
||||
const keyRaw = getAliasedProp(body, ['key', 'Key']);
|
||||
const deletionDateRaw = getAliasedProp(body, ['deletionDate', 'DeletionDate']);
|
||||
const textRaw = getAliasedProp(body, ['text', 'Text']);
|
||||
|
||||
if (typeof nameRaw.value !== 'string' || !nameRaw.value.trim()) {
|
||||
return errorResponse('Name is required', 400);
|
||||
}
|
||||
if (typeof keyRaw.value !== 'string' || !keyRaw.value.trim()) {
|
||||
return errorResponse('Key is required', 400);
|
||||
}
|
||||
|
||||
const deletionDate = parseDate(deletionDateRaw.value);
|
||||
if (!deletionDate) {
|
||||
return errorResponse('Invalid deletionDate', 400);
|
||||
}
|
||||
|
||||
const deletionValidation = validateDeletionDate(deletionDate);
|
||||
if (deletionValidation) return deletionValidation;
|
||||
|
||||
const sendData = sanitizeSendData(textRaw.value);
|
||||
if (!sendData) {
|
||||
return errorResponse('Send data not provided', 400);
|
||||
}
|
||||
|
||||
const maxAccessRaw = getAliasedProp(body, ['maxAccessCount', 'MaxAccessCount']);
|
||||
const maxAccess = parseMaxAccessCount(maxAccessRaw.value);
|
||||
if (!maxAccess.ok) return maxAccess.response;
|
||||
|
||||
const expirationRaw = getAliasedProp(body, ['expirationDate', 'ExpirationDate']);
|
||||
const expirationDate = expirationRaw.value === null || expirationRaw.value === undefined
|
||||
? null
|
||||
: parseDate(expirationRaw.value);
|
||||
if (expirationRaw.value !== null && expirationRaw.value !== undefined && !expirationDate) {
|
||||
return errorResponse('Invalid expirationDate', 400);
|
||||
}
|
||||
|
||||
const disabledRaw = getAliasedProp(body, ['disabled', 'Disabled']);
|
||||
const hideEmailRaw = getAliasedProp(body, ['hideEmail', 'HideEmail']);
|
||||
const notesRaw = getAliasedProp(body, ['notes', 'Notes']);
|
||||
const passwordRaw = getAliasedProp(body, ['password', 'Password']);
|
||||
const authTypeRaw = getAliasedProp(body, ['authType', 'AuthType']);
|
||||
const emailsRaw = getAliasedProp(body, ['emails', 'Emails']);
|
||||
|
||||
const requestedAuthType = parseSendAuthType(authTypeRaw.value);
|
||||
if (authTypeRaw.present && requestedAuthType === null) {
|
||||
return errorResponse('Invalid authType', 400);
|
||||
}
|
||||
|
||||
const normalizedEmails = normalizeEmails(emailsRaw.value);
|
||||
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
|
||||
return errorResponse('Invalid emails', 400);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const send: Send = {
|
||||
id: generateUUID(),
|
||||
userId,
|
||||
type: sendType,
|
||||
name: nameRaw.value.trim(),
|
||||
notes: typeof notesRaw.value === 'string' ? notesRaw.value : null,
|
||||
data: JSON.stringify(sendData),
|
||||
key: keyRaw.value,
|
||||
passwordHash: null,
|
||||
passwordSalt: null,
|
||||
passwordIterations: null,
|
||||
authType: requestedAuthType ?? SendAuthType.None,
|
||||
emails: normalizedEmails,
|
||||
maxAccessCount: maxAccess.value,
|
||||
accessCount: 0,
|
||||
disabled: typeof disabledRaw.value === 'boolean' ? disabledRaw.value : false,
|
||||
hideEmail: typeof hideEmailRaw.value === 'boolean' ? hideEmailRaw.value : null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
expirationDate: expirationDate ? expirationDate.toISOString() : null,
|
||||
deletionDate: deletionDate.toISOString(),
|
||||
};
|
||||
|
||||
if (typeof passwordRaw.value === 'string' && passwordRaw.value.length > 0) {
|
||||
await setSendPassword(send, passwordRaw.value);
|
||||
} else if (send.authType === SendAuthType.Password) {
|
||||
return errorResponse('Password is required for password auth', 400);
|
||||
}
|
||||
|
||||
if (send.authType !== SendAuthType.Email) {
|
||||
send.emails = null;
|
||||
}
|
||||
|
||||
await storage.saveSend(send);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(sendToResponse(send));
|
||||
}
|
||||
|
||||
export async function handleCreateFileSendV2(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.send.maxFileSizeBytes);
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const typeRaw = getAliasedProp(body, ['type', 'Type']);
|
||||
const sendType = parseSendType(typeRaw.value);
|
||||
if (sendType !== SendType.File) {
|
||||
return errorResponse('Send content is not a file', 400);
|
||||
}
|
||||
|
||||
const fileLengthRaw = getAliasedProp(body, ['fileLength', 'FileLength']);
|
||||
const fileLengthParsed = parseFileLength(fileLengthRaw.value);
|
||||
if (!fileLengthParsed.ok) return fileLengthParsed.response;
|
||||
if (fileLengthParsed.value > maxFileSize) {
|
||||
return errorResponse('Send storage limit exceeded with this file', 400);
|
||||
}
|
||||
|
||||
const nameRaw = getAliasedProp(body, ['name', 'Name']);
|
||||
const keyRaw = getAliasedProp(body, ['key', 'Key']);
|
||||
const deletionDateRaw = getAliasedProp(body, ['deletionDate', 'DeletionDate']);
|
||||
const fileRaw = getAliasedProp(body, ['file', 'File']);
|
||||
|
||||
if (typeof nameRaw.value !== 'string' || !nameRaw.value.trim()) {
|
||||
return errorResponse('Name is required', 400);
|
||||
}
|
||||
if (typeof keyRaw.value !== 'string' || !keyRaw.value.trim()) {
|
||||
return errorResponse('Key is required', 400);
|
||||
}
|
||||
|
||||
const deletionDate = parseDate(deletionDateRaw.value);
|
||||
if (!deletionDate) {
|
||||
return errorResponse('Invalid deletionDate', 400);
|
||||
}
|
||||
const deletionValidation = validateDeletionDate(deletionDate);
|
||||
if (deletionValidation) return deletionValidation;
|
||||
|
||||
const fileData = sanitizeSendData(fileRaw.value);
|
||||
if (!fileData) {
|
||||
return errorResponse('Send data not provided', 400);
|
||||
}
|
||||
|
||||
const fileId = generateUUID();
|
||||
fileData.id = fileId;
|
||||
fileData.size = fileLengthParsed.value;
|
||||
fileData.sizeName = formatSize(fileLengthParsed.value);
|
||||
|
||||
const maxAccessRaw = getAliasedProp(body, ['maxAccessCount', 'MaxAccessCount']);
|
||||
const maxAccess = parseMaxAccessCount(maxAccessRaw.value);
|
||||
if (!maxAccess.ok) return maxAccess.response;
|
||||
|
||||
const expirationRaw = getAliasedProp(body, ['expirationDate', 'ExpirationDate']);
|
||||
const expirationDate = expirationRaw.value === null || expirationRaw.value === undefined
|
||||
? null
|
||||
: parseDate(expirationRaw.value);
|
||||
if (expirationRaw.value !== null && expirationRaw.value !== undefined && !expirationDate) {
|
||||
return errorResponse('Invalid expirationDate', 400);
|
||||
}
|
||||
|
||||
const disabledRaw = getAliasedProp(body, ['disabled', 'Disabled']);
|
||||
const hideEmailRaw = getAliasedProp(body, ['hideEmail', 'HideEmail']);
|
||||
const notesRaw = getAliasedProp(body, ['notes', 'Notes']);
|
||||
const passwordRaw = getAliasedProp(body, ['password', 'Password']);
|
||||
const authTypeRaw = getAliasedProp(body, ['authType', 'AuthType']);
|
||||
const emailsRaw = getAliasedProp(body, ['emails', 'Emails']);
|
||||
|
||||
const requestedAuthType = parseSendAuthType(authTypeRaw.value);
|
||||
if (authTypeRaw.present && requestedAuthType === null) {
|
||||
return errorResponse('Invalid authType', 400);
|
||||
}
|
||||
|
||||
const normalizedEmails = normalizeEmails(emailsRaw.value);
|
||||
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
|
||||
return errorResponse('Invalid emails', 400);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const send: Send = {
|
||||
id: generateUUID(),
|
||||
userId,
|
||||
type: sendType,
|
||||
name: nameRaw.value.trim(),
|
||||
notes: typeof notesRaw.value === 'string' ? notesRaw.value : null,
|
||||
data: JSON.stringify(fileData),
|
||||
key: keyRaw.value,
|
||||
passwordHash: null,
|
||||
passwordSalt: null,
|
||||
passwordIterations: null,
|
||||
authType: requestedAuthType ?? SendAuthType.None,
|
||||
emails: normalizedEmails,
|
||||
maxAccessCount: maxAccess.value,
|
||||
accessCount: 0,
|
||||
disabled: typeof disabledRaw.value === 'boolean' ? disabledRaw.value : false,
|
||||
hideEmail: typeof hideEmailRaw.value === 'boolean' ? hideEmailRaw.value : null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
expirationDate: expirationDate ? expirationDate.toISOString() : null,
|
||||
deletionDate: deletionDate.toISOString(),
|
||||
};
|
||||
|
||||
if (typeof passwordRaw.value === 'string' && passwordRaw.value.length > 0) {
|
||||
await setSendPassword(send, passwordRaw.value);
|
||||
} else if (send.authType === SendAuthType.Password) {
|
||||
return errorResponse('Password is required for password auth', 400);
|
||||
}
|
||||
|
||||
if (send.authType !== SendAuthType.Email) {
|
||||
send.emails = null;
|
||||
}
|
||||
|
||||
await storage.saveSend(send);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse({
|
||||
fileUploadType: 0,
|
||||
object: 'send-fileUpload',
|
||||
url: `/api/sends/${send.id}/file/${fileId}`,
|
||||
sendResponse: sendToResponse(send),
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleGetSendFileUpload(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
sendId: string,
|
||||
fileId: string
|
||||
): Promise<Response> {
|
||||
void request;
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found', 404);
|
||||
}
|
||||
if (send.type !== SendType.File) {
|
||||
return errorResponse('Send is not a file type send.', 400);
|
||||
}
|
||||
|
||||
const sendData = parseStoredSendData(send);
|
||||
const expectedFileId = typeof sendData.id === 'string' ? sendData.id : null;
|
||||
if (!expectedFileId || expectedFileId !== fileId) {
|
||||
return errorResponse('Send file does not match send data.', 400);
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
fileUploadType: 0,
|
||||
object: 'send-fileUpload',
|
||||
url: `/api/sends/${send.id}/file/${fileId}`,
|
||||
sendResponse: sendToResponse(send),
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleUploadSendFile(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
sendId: string,
|
||||
fileId: string
|
||||
): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.send.maxFileSizeBytes);
|
||||
const send = await storage.getSend(sendId);
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found. Unable to save the file.', 404);
|
||||
}
|
||||
if (send.type !== SendType.File) {
|
||||
return errorResponse('Send is not a file type send.', 400);
|
||||
}
|
||||
|
||||
const sendData = parseStoredSendData(send);
|
||||
const expectedFileId = typeof sendData.id === 'string' ? sendData.id : null;
|
||||
if (!expectedFileId || expectedFileId !== fileId) {
|
||||
return errorResponse('Send file does not match send data.', 400);
|
||||
}
|
||||
|
||||
const contentType = request.headers.get('content-type') || '';
|
||||
if (!contentType.includes('multipart/form-data')) {
|
||||
return errorResponse('Content-Type must be multipart/form-data', 400);
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('data') as File | null;
|
||||
if (!file) {
|
||||
return errorResponse('No file uploaded', 400);
|
||||
}
|
||||
|
||||
if (file.size > maxFileSize) {
|
||||
return errorResponse('Send storage limit exceeded with this file', 413);
|
||||
}
|
||||
|
||||
const expectedFileName = typeof sendData.fileName === 'string' ? sendData.fileName : null;
|
||||
if (expectedFileName && file.name !== expectedFileName) {
|
||||
return errorResponse('Send file name does not match.', 400);
|
||||
}
|
||||
|
||||
const expectedSize = parseInteger(sendData.size);
|
||||
if (expectedSize !== null && file.size !== expectedSize) {
|
||||
return errorResponse('Send file size does not match.', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
await putBlobObject(env, getSendFileObjectKey(sendId, fileId), file.stream(), {
|
||||
size: file.size,
|
||||
contentType: 'application/octet-stream',
|
||||
customMetadata: {
|
||||
sendId,
|
||||
fileId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes('KV object too large')) {
|
||||
return errorResponse('Send storage limit exceeded with this file', 413);
|
||||
}
|
||||
return errorResponse('Attachment storage is not configured', 500);
|
||||
}
|
||||
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
export async function handleUpdateSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found', 404);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const typeRaw = getAliasedProp(body, ['type', 'Type']);
|
||||
if (typeRaw.present) {
|
||||
const incomingType = parseSendType(typeRaw.value);
|
||||
if (incomingType === null) {
|
||||
return errorResponse('Invalid Send type', 400);
|
||||
}
|
||||
if (incomingType !== send.type) {
|
||||
return errorResponse("Sends can't change type", 400);
|
||||
}
|
||||
}
|
||||
|
||||
const deletionRaw = getAliasedProp(body, ['deletionDate', 'DeletionDate']);
|
||||
if (deletionRaw.present) {
|
||||
const deletionDate = parseDate(deletionRaw.value);
|
||||
if (!deletionDate) return errorResponse('Invalid deletionDate', 400);
|
||||
const deletionValidation = validateDeletionDate(deletionDate);
|
||||
if (deletionValidation) return deletionValidation;
|
||||
send.deletionDate = deletionDate.toISOString();
|
||||
}
|
||||
|
||||
const expirationRaw = getAliasedProp(body, ['expirationDate', 'ExpirationDate']);
|
||||
if (expirationRaw.present) {
|
||||
if (expirationRaw.value === null || expirationRaw.value === '') {
|
||||
send.expirationDate = null;
|
||||
} else {
|
||||
const expiration = parseDate(expirationRaw.value);
|
||||
if (!expiration) return errorResponse('Invalid expirationDate', 400);
|
||||
send.expirationDate = expiration.toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
const nameRaw = getAliasedProp(body, ['name', 'Name']);
|
||||
if (nameRaw.present) {
|
||||
if (typeof nameRaw.value !== 'string' || !nameRaw.value.trim()) {
|
||||
return errorResponse('Name is required', 400);
|
||||
}
|
||||
send.name = nameRaw.value.trim();
|
||||
}
|
||||
|
||||
const keyRaw = getAliasedProp(body, ['key', 'Key']);
|
||||
if (keyRaw.present) {
|
||||
if (typeof keyRaw.value !== 'string' || !keyRaw.value.trim()) {
|
||||
return errorResponse('Key is required', 400);
|
||||
}
|
||||
send.key = keyRaw.value;
|
||||
}
|
||||
|
||||
const notesRaw = getAliasedProp(body, ['notes', 'Notes']);
|
||||
if (notesRaw.present) {
|
||||
send.notes = typeof notesRaw.value === 'string' ? notesRaw.value : null;
|
||||
}
|
||||
|
||||
const disabledRaw = getAliasedProp(body, ['disabled', 'Disabled']);
|
||||
if (disabledRaw.present) {
|
||||
if (typeof disabledRaw.value !== 'boolean') {
|
||||
return errorResponse('Invalid disabled', 400);
|
||||
}
|
||||
send.disabled = disabledRaw.value;
|
||||
}
|
||||
|
||||
const hideEmailRaw = getAliasedProp(body, ['hideEmail', 'HideEmail']);
|
||||
if (hideEmailRaw.present) {
|
||||
if (hideEmailRaw.value === null) {
|
||||
send.hideEmail = null;
|
||||
} else if (typeof hideEmailRaw.value === 'boolean') {
|
||||
send.hideEmail = hideEmailRaw.value;
|
||||
} else {
|
||||
return errorResponse('Invalid hideEmail', 400);
|
||||
}
|
||||
}
|
||||
|
||||
const maxAccessRaw = getAliasedProp(body, ['maxAccessCount', 'MaxAccessCount']);
|
||||
if (maxAccessRaw.present) {
|
||||
const parsedMax = parseMaxAccessCount(maxAccessRaw.value);
|
||||
if (!parsedMax.ok) return parsedMax.response;
|
||||
send.maxAccessCount = parsedMax.value;
|
||||
}
|
||||
|
||||
if (send.type === SendType.Text) {
|
||||
const textRaw = getAliasedProp(body, ['text', 'Text']);
|
||||
if (textRaw.present) {
|
||||
const textData = sanitizeSendData(textRaw.value);
|
||||
if (!textData) {
|
||||
return errorResponse('Send data not provided', 400);
|
||||
}
|
||||
send.data = JSON.stringify(textData);
|
||||
}
|
||||
}
|
||||
|
||||
const authTypeRaw = getAliasedProp(body, ['authType', 'AuthType']);
|
||||
if (authTypeRaw.present) {
|
||||
const parsedAuthType = parseSendAuthType(authTypeRaw.value);
|
||||
if (parsedAuthType === null) {
|
||||
return errorResponse('Invalid authType', 400);
|
||||
}
|
||||
send.authType = parsedAuthType;
|
||||
if (parsedAuthType !== SendAuthType.Email) {
|
||||
send.emails = null;
|
||||
}
|
||||
}
|
||||
|
||||
const emailsRaw = getAliasedProp(body, ['emails', 'Emails']);
|
||||
if (emailsRaw.present) {
|
||||
const normalizedEmails = normalizeEmails(emailsRaw.value);
|
||||
if (emailsRaw.value !== null && normalizedEmails === null) {
|
||||
return errorResponse('Invalid emails', 400);
|
||||
}
|
||||
send.emails = normalizedEmails;
|
||||
if (send.emails) {
|
||||
send.authType = SendAuthType.Email;
|
||||
} else if (send.authType === SendAuthType.Email) {
|
||||
send.authType = SendAuthType.None;
|
||||
}
|
||||
}
|
||||
|
||||
const passwordRaw = getAliasedProp(body, ['password', 'Password']);
|
||||
if (passwordRaw.present && typeof passwordRaw.value === 'string') {
|
||||
await setSendPassword(send, passwordRaw.value);
|
||||
}
|
||||
|
||||
if (send.authType === SendAuthType.Password && !send.passwordHash) {
|
||||
return errorResponse('Password is required for password auth', 400);
|
||||
}
|
||||
|
||||
send.updatedAt = new Date().toISOString();
|
||||
await storage.saveSend(send);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(sendToResponse(send));
|
||||
}
|
||||
|
||||
export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
void request;
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found', 404);
|
||||
}
|
||||
|
||||
if (send.type === SendType.File) {
|
||||
const data = parseStoredSendData(send);
|
||||
const fileId = typeof data.id === 'string' ? data.id : null;
|
||||
if (fileId) {
|
||||
await deleteBlobObject(env, getSendFileObjectKey(send.id, fileId));
|
||||
}
|
||||
}
|
||||
|
||||
await storage.deleteSend(sendId, userId);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
export async function handleBulkDeleteSends(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
let body: { ids?: string[] };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
if (!body.ids || !Array.isArray(body.ids)) {
|
||||
return errorResponse('ids array is required', 400);
|
||||
}
|
||||
|
||||
const sends = await storage.getSendsByIds(body.ids, userId);
|
||||
for (const send of sends) {
|
||||
if (send.type !== SendType.File) continue;
|
||||
const data = parseStoredSendData(send);
|
||||
const fileId = typeof data.id === 'string' ? data.id : null;
|
||||
if (fileId) {
|
||||
await deleteBlobObject(env, getSendFileObjectKey(send.id, fileId));
|
||||
}
|
||||
}
|
||||
|
||||
const revisionDate = await storage.bulkDeleteSends(body.ids, userId);
|
||||
if (revisionDate) {
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
void request;
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found', 404);
|
||||
}
|
||||
|
||||
await setSendPassword(send, null);
|
||||
send.updatedAt = new Date().toISOString();
|
||||
await storage.saveSend(send);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(sendToResponse(send));
|
||||
}
|
||||
|
||||
export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
void request;
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found', 404);
|
||||
}
|
||||
|
||||
send.authType = SendAuthType.None;
|
||||
send.emails = null;
|
||||
send.updatedAt = new Date().toISOString();
|
||||
await storage.saveSend(send);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(sendToResponse(send));
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import { Env, SendType } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import {
|
||||
createSendAccessToken,
|
||||
createSendFileDownloadToken,
|
||||
verifySendAccessToken,
|
||||
verifySendFileDownloadToken,
|
||||
} from '../utils/jwt';
|
||||
import {
|
||||
getBlobObject,
|
||||
getSendFileObjectKey,
|
||||
} from '../services/blob-store';
|
||||
import {
|
||||
SEND_INACCESSIBLE_MSG,
|
||||
extractBearerToken,
|
||||
fromAccessId,
|
||||
getCreatorIdentifier,
|
||||
getSafeJwtSecret,
|
||||
hasEmailAuth,
|
||||
isSendAvailable,
|
||||
notifyVaultSyncForRequest,
|
||||
parseStoredSendData,
|
||||
resolveSendFromIdOrAccessId,
|
||||
sendPasswordLimitKey,
|
||||
sendPasswordLockedErrorResponse,
|
||||
sendPasswordLockedOAuthResponse,
|
||||
sendToAccessResponse,
|
||||
validatePublicSendAccess,
|
||||
verifySendPassword,
|
||||
verifySendPasswordHashB64,
|
||||
} from './sends-shared';
|
||||
|
||||
export async function handleAccessSend(request: Request, env: Env, accessId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const sendId = fromAccessId(accessId);
|
||||
if (!sendId) {
|
||||
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||
}
|
||||
|
||||
const send = await storage.getSend(sendId);
|
||||
if (!send || !isSendAvailable(send)) {
|
||||
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||
}
|
||||
|
||||
let body: unknown = {};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
|
||||
let sendPasswordLimitIpKey: string | null = null;
|
||||
let sendPasswordRateLimit: RateLimitService | null = null;
|
||||
if (send.passwordHash) {
|
||||
const clientIdentifier = getClientIdentifier(request);
|
||||
if (!clientIdentifier) {
|
||||
return errorResponse('Client IP is required', 403);
|
||||
}
|
||||
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier);
|
||||
sendPasswordRateLimit = new RateLimitService(env.DB);
|
||||
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
|
||||
if (!sendPasswordCheck.allowed) {
|
||||
return sendPasswordLockedErrorResponse(sendPasswordCheck.retryAfterSeconds || 60);
|
||||
}
|
||||
}
|
||||
|
||||
const validation = await validatePublicSendAccess(send, body);
|
||||
if (!validation.ok) {
|
||||
if (validation.reason === 'invalid_password' && sendPasswordRateLimit && sendPasswordLimitIpKey) {
|
||||
const failed = await sendPasswordRateLimit.recordFailedLogin(sendPasswordLimitIpKey);
|
||||
if (failed.locked) {
|
||||
return sendPasswordLockedErrorResponse(failed.retryAfterSeconds || 60);
|
||||
}
|
||||
}
|
||||
return validation.response;
|
||||
}
|
||||
|
||||
if (send.passwordHash && sendPasswordRateLimit && sendPasswordLimitIpKey) {
|
||||
await sendPasswordRateLimit.clearLoginAttempts(sendPasswordLimitIpKey);
|
||||
}
|
||||
|
||||
if (send.type === SendType.Text) {
|
||||
const updated = await storage.incrementSendAccessCount(send.id);
|
||||
if (!updated) {
|
||||
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||
}
|
||||
send.accessCount += 1;
|
||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||
}
|
||||
|
||||
const creatorIdentifier = await getCreatorIdentifier(storage, send);
|
||||
return jsonResponse(sendToAccessResponse(send, creatorIdentifier));
|
||||
}
|
||||
|
||||
export async function handleAccessSendFile(
|
||||
request: Request,
|
||||
env: Env,
|
||||
idOrAccessId: string,
|
||||
fileId: string
|
||||
): Promise<Response> {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) {
|
||||
return errorResponse('Server configuration error', 500);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await resolveSendFromIdOrAccessId(storage, idOrAccessId);
|
||||
if (!send || !isSendAvailable(send) || send.type !== SendType.File) {
|
||||
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||
}
|
||||
|
||||
const data = parseStoredSendData(send);
|
||||
const expectedFileId = typeof data.id === 'string' ? data.id : null;
|
||||
if (!expectedFileId || expectedFileId !== fileId) {
|
||||
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||
}
|
||||
|
||||
let body: unknown = {};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
|
||||
let sendPasswordLimitIpKey: string | null = null;
|
||||
let sendPasswordRateLimit: RateLimitService | null = null;
|
||||
if (send.passwordHash) {
|
||||
const clientIdentifier = getClientIdentifier(request);
|
||||
if (!clientIdentifier) {
|
||||
return errorResponse('Client IP is required', 403);
|
||||
}
|
||||
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier);
|
||||
sendPasswordRateLimit = new RateLimitService(env.DB);
|
||||
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
|
||||
if (!sendPasswordCheck.allowed) {
|
||||
return sendPasswordLockedErrorResponse(sendPasswordCheck.retryAfterSeconds || 60);
|
||||
}
|
||||
}
|
||||
|
||||
const validation = await validatePublicSendAccess(send, body);
|
||||
if (!validation.ok) {
|
||||
if (validation.reason === 'invalid_password' && sendPasswordRateLimit && sendPasswordLimitIpKey) {
|
||||
const failed = await sendPasswordRateLimit.recordFailedLogin(sendPasswordLimitIpKey);
|
||||
if (failed.locked) {
|
||||
return sendPasswordLockedErrorResponse(failed.retryAfterSeconds || 60);
|
||||
}
|
||||
}
|
||||
return validation.response;
|
||||
}
|
||||
|
||||
if (send.passwordHash && sendPasswordRateLimit && sendPasswordLimitIpKey) {
|
||||
await sendPasswordRateLimit.clearLoginAttempts(sendPasswordLimitIpKey);
|
||||
}
|
||||
|
||||
const updated = await storage.incrementSendAccessCount(send.id);
|
||||
if (!updated) {
|
||||
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||
}
|
||||
send.accessCount += 1;
|
||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||
|
||||
const token = await createSendFileDownloadToken(send.id, fileId, secret);
|
||||
const url = new URL(request.url);
|
||||
const downloadUrl = `${url.origin}/api/sends/${send.id}/${fileId}?t=${token}`;
|
||||
|
||||
return jsonResponse({
|
||||
object: 'send-fileDownload',
|
||||
id: fileId,
|
||||
url: downloadUrl,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleAccessSendV2(request: Request, env: Env): Promise<Response> {
|
||||
const jwt = getSafeJwtSecret(env);
|
||||
if (!jwt.ok) return jwt.response;
|
||||
|
||||
const token = extractBearerToken(request);
|
||||
if (!token) {
|
||||
return errorResponse('Unauthorized', 401);
|
||||
}
|
||||
|
||||
const claims = await verifySendAccessToken(token, jwt.secret);
|
||||
if (!claims) {
|
||||
return errorResponse('Unauthorized', 401);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(claims.sub);
|
||||
if (!send || !isSendAvailable(send)) {
|
||||
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||
}
|
||||
|
||||
if (send.type === SendType.Text) {
|
||||
const updated = await storage.incrementSendAccessCount(send.id);
|
||||
if (!updated) {
|
||||
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||
}
|
||||
send.accessCount += 1;
|
||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||
}
|
||||
|
||||
const creatorIdentifier = await getCreatorIdentifier(storage, send);
|
||||
return jsonResponse(sendToAccessResponse(send, creatorIdentifier));
|
||||
}
|
||||
|
||||
export async function handleAccessSendFileV2(request: Request, env: Env, fileId: string): Promise<Response> {
|
||||
const jwt = getSafeJwtSecret(env);
|
||||
if (!jwt.ok) return jwt.response;
|
||||
|
||||
const token = extractBearerToken(request);
|
||||
if (!token) {
|
||||
return errorResponse('Unauthorized', 401);
|
||||
}
|
||||
|
||||
const claims = await verifySendAccessToken(token, jwt.secret);
|
||||
if (!claims) {
|
||||
return errorResponse('Unauthorized', 401);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(claims.sub);
|
||||
if (!send || !isSendAvailable(send) || send.type !== SendType.File) {
|
||||
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||
}
|
||||
|
||||
const data = parseStoredSendData(send);
|
||||
const expectedFileId = typeof data.id === 'string' ? data.id : null;
|
||||
if (!expectedFileId || expectedFileId !== fileId) {
|
||||
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||
}
|
||||
|
||||
const updated = await storage.incrementSendAccessCount(send.id);
|
||||
if (!updated) {
|
||||
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||
}
|
||||
send.accessCount += 1;
|
||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||
|
||||
const downloadToken = await createSendFileDownloadToken(send.id, fileId, jwt.secret);
|
||||
const url = new URL(request.url);
|
||||
const downloadUrl = `${url.origin}/api/sends/${send.id}/${fileId}?t=${downloadToken}`;
|
||||
|
||||
return jsonResponse({
|
||||
object: 'send-fileDownload',
|
||||
id: fileId,
|
||||
url: downloadUrl,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleDownloadSendFile(
|
||||
request: Request,
|
||||
env: Env,
|
||||
sendId: string,
|
||||
fileId: string
|
||||
): Promise<Response> {
|
||||
const jwt = getSafeJwtSecret(env);
|
||||
if (!jwt.ok) return jwt.response;
|
||||
|
||||
const url = new URL(request.url);
|
||||
const token = url.searchParams.get('t') || url.searchParams.get('token');
|
||||
if (!token) {
|
||||
return errorResponse('Token required', 401);
|
||||
}
|
||||
|
||||
const claims = await verifySendFileDownloadToken(token, jwt.secret);
|
||||
if (!claims) {
|
||||
return errorResponse('Invalid or expired token', 401);
|
||||
}
|
||||
if (claims.sendId !== sendId || claims.fileId !== fileId) {
|
||||
return errorResponse('Token mismatch', 401);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId));
|
||||
if (!object) {
|
||||
return errorResponse('Send file not found', 404);
|
||||
}
|
||||
|
||||
const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp);
|
||||
if (!firstUse) {
|
||||
return errorResponse('Invalid or expired token', 401);
|
||||
}
|
||||
|
||||
return new Response(object.body, {
|
||||
headers: {
|
||||
'Content-Type': object.contentType || 'application/octet-stream',
|
||||
'Content-Length': String(object.size),
|
||||
'Cache-Control': 'private, no-cache',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function issueSendAccessToken(
|
||||
env: Env,
|
||||
sendIdOrAccessId: string,
|
||||
passwordHashB64?: string | null,
|
||||
password?: string | null,
|
||||
rateLimit?: RateLimitService,
|
||||
sendPasswordLimitIpKey?: string
|
||||
): Promise<{ token: string } | { error: Response }> {
|
||||
const jwt = getSafeJwtSecret(env);
|
||||
if (!jwt.ok) {
|
||||
return { error: jwt.response };
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await resolveSendFromIdOrAccessId(storage, sendIdOrAccessId);
|
||||
|
||||
if (!send || !isSendAvailable(send)) {
|
||||
return {
|
||||
error: jsonResponse(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: SEND_INACCESSIBLE_MSG,
|
||||
send_access_error_type: 'send_not_available',
|
||||
ErrorModel: {
|
||||
Message: SEND_INACCESSIBLE_MSG,
|
||||
Object: 'error',
|
||||
},
|
||||
},
|
||||
400
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (hasEmailAuth(send)) {
|
||||
const message = 'Email verification for this Send is not supported by this server.';
|
||||
return {
|
||||
error: jsonResponse(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: message,
|
||||
send_access_error_type: 'email_verification_not_supported',
|
||||
ErrorModel: {
|
||||
Message: message,
|
||||
Object: 'error',
|
||||
},
|
||||
},
|
||||
400
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (send.passwordHash) {
|
||||
if (rateLimit && sendPasswordLimitIpKey) {
|
||||
const sendPasswordCheck = await rateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
|
||||
if (!sendPasswordCheck.allowed) {
|
||||
return {
|
||||
error: sendPasswordLockedOAuthResponse(sendPasswordCheck.retryAfterSeconds || 60),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let ok = false;
|
||||
if (passwordHashB64) {
|
||||
ok = verifySendPasswordHashB64(send, passwordHashB64);
|
||||
} else if (password) {
|
||||
ok = await verifySendPassword(send, password);
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
if (rateLimit && sendPasswordLimitIpKey) {
|
||||
const failed = await rateLimit.recordFailedLogin(sendPasswordLimitIpKey);
|
||||
if (failed.locked) {
|
||||
return {
|
||||
error: sendPasswordLockedOAuthResponse(failed.retryAfterSeconds || 60),
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
error: jsonResponse(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: 'Invalid password.',
|
||||
send_access_error_type: 'invalid_password',
|
||||
ErrorModel: {
|
||||
Message: 'Invalid password.',
|
||||
Object: 'error',
|
||||
},
|
||||
},
|
||||
400
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (rateLimit && sendPasswordLimitIpKey) {
|
||||
await rateLimit.clearLoginAttempts(sendPasswordLimitIpKey);
|
||||
}
|
||||
}
|
||||
|
||||
const token = await createSendAccessToken(send.id, jwt.secret);
|
||||
return { token };
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types';
|
||||
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { readActingDeviceIdentifier } from '../utils/device';
|
||||
import { LIMITS } from '../config/limits';
|
||||
|
||||
export const SEND_INACCESSIBLE_MSG = 'Send does not exist or is no longer available';
|
||||
const SEND_PASSWORD_ITERATIONS = 100_000;
|
||||
export const SEND_PASSWORD_LIMIT_SCOPE = 'send-password';
|
||||
|
||||
export async function notifyVaultSyncForRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
revisionDate: string
|
||||
): Promise<void> {
|
||||
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
}
|
||||
|
||||
export function getAliasedProp(source: unknown, aliases: string[]): { present: boolean; value: unknown } {
|
||||
if (!source || typeof source !== 'object') return { present: false, value: undefined };
|
||||
for (const key of aliases) {
|
||||
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
const value = (source as Record<string, unknown>)[key];
|
||||
return { present: true, value };
|
||||
}
|
||||
}
|
||||
return { present: false, value: undefined };
|
||||
}
|
||||
|
||||
export function base64UrlEncode(data: Uint8Array): string {
|
||||
const base64 = btoa(String.fromCharCode(...data));
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function base64UrlDecode(input: string): Uint8Array | null {
|
||||
try {
|
||||
let normalized = input.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (normalized.length % 4) normalized += '=';
|
||||
const raw = atob(normalized);
|
||||
const out = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
||||
return out;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function uuidToBytes(uuid: string): Uint8Array | null {
|
||||
const hex = uuid.replace(/-/g, '').toLowerCase();
|
||||
if (!/^[0-9a-f]{32}$/.test(hex)) return null;
|
||||
const bytes = new Uint8Array(16);
|
||||
for (let i = 0; i < 16; i++) {
|
||||
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function bytesToUuid(bytes: Uint8Array): string | null {
|
||||
if (bytes.length !== 16) return null;
|
||||
const hex = Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
return [
|
||||
hex.slice(0, 8),
|
||||
hex.slice(8, 12),
|
||||
hex.slice(12, 16),
|
||||
hex.slice(16, 20),
|
||||
hex.slice(20, 32),
|
||||
].join('-');
|
||||
}
|
||||
|
||||
function toAccessId(sendId: string): string {
|
||||
const bytes = uuidToBytes(sendId);
|
||||
if (!bytes) return '';
|
||||
return base64UrlEncode(bytes);
|
||||
}
|
||||
|
||||
export function fromAccessId(accessId: string): string | null {
|
||||
const bytes = base64UrlDecode(accessId);
|
||||
if (!bytes || bytes.length !== 16) return null;
|
||||
return bytesToUuid(bytes);
|
||||
}
|
||||
|
||||
function isLikelyUuid(value: string): boolean {
|
||||
return /^[a-f0-9-]{36}$/i.test(value);
|
||||
}
|
||||
|
||||
export async function resolveSendFromIdOrAccessId(storage: StorageService, idOrAccessId: string): Promise<Send | null> {
|
||||
if (isLikelyUuid(idOrAccessId)) {
|
||||
const send = await storage.getSend(idOrAccessId);
|
||||
if (send) return send;
|
||||
}
|
||||
|
||||
const sendId = fromAccessId(idOrAccessId);
|
||||
if (!sendId) return null;
|
||||
return storage.getSend(sendId);
|
||||
}
|
||||
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} Bytes`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export function parseDate(raw: unknown): Date | null {
|
||||
if (typeof raw !== 'string' || !raw.trim()) return null;
|
||||
const date = new Date(raw);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date;
|
||||
}
|
||||
|
||||
export function parseInteger(raw: unknown): number | null {
|
||||
if (raw === null || raw === undefined || raw === '') return null;
|
||||
const value = typeof raw === 'string' ? Number(raw) : raw;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function sanitizeSendData(raw: unknown): Record<string, unknown> | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const data = { ...(raw as Record<string, unknown>) };
|
||||
delete data.response;
|
||||
return data;
|
||||
}
|
||||
|
||||
export function parseStoredSendData(send: Send): Record<string, unknown> {
|
||||
try {
|
||||
const parsed = JSON.parse(send.data) as unknown;
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return { ...(parsed as Record<string, unknown>) };
|
||||
}
|
||||
return {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSendDataSizeField(data: Record<string, unknown>): Record<string, unknown> {
|
||||
const normalized = { ...data };
|
||||
if (typeof normalized.size === 'number' && Number.isFinite(normalized.size)) {
|
||||
normalized.size = String(Math.trunc(normalized.size));
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function isSendAvailable(send: Send): boolean {
|
||||
const now = Date.now();
|
||||
|
||||
if (send.maxAccessCount !== null && send.accessCount >= send.maxAccessCount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (send.expirationDate) {
|
||||
const expirationMs = new Date(send.expirationDate).getTime();
|
||||
if (!Number.isNaN(expirationMs) && now >= expirationMs) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const deletionMs = new Date(send.deletionDate).getTime();
|
||||
if (!Number.isNaN(deletionMs) && now >= deletionMs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (send.disabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function deriveSendPasswordHash(password: string, salt: Uint8Array, iterations: number): Promise<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey('raw', encoder.encode(password), { name: 'PBKDF2' }, false, ['deriveBits']);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt,
|
||||
iterations,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
key,
|
||||
256
|
||||
);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
diff |= a[i] ^ b[i];
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
function isLikelyHashB64(value: string): boolean {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return false;
|
||||
if (!/^[A-Za-z0-9+/_=-]+$/.test(raw)) return false;
|
||||
const decoded = base64UrlDecode(raw);
|
||||
return !!decoded && decoded.length === 32;
|
||||
}
|
||||
|
||||
export async function setSendPassword(send: Send, password: string | null): Promise<void> {
|
||||
if (!password) {
|
||||
send.passwordHash = null;
|
||||
send.passwordSalt = null;
|
||||
send.passwordIterations = null;
|
||||
if (send.authType === SendAuthType.Password) {
|
||||
send.authType = SendAuthType.None;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLikelyHashB64(password)) {
|
||||
send.passwordHash = password.trim();
|
||||
send.passwordSalt = null;
|
||||
send.passwordIterations = null;
|
||||
send.authType = SendAuthType.Password;
|
||||
return;
|
||||
}
|
||||
|
||||
const salt = crypto.getRandomValues(new Uint8Array(64));
|
||||
const hash = await deriveSendPasswordHash(password, salt, SEND_PASSWORD_ITERATIONS);
|
||||
|
||||
send.passwordSalt = base64UrlEncode(salt);
|
||||
send.passwordHash = base64UrlEncode(hash);
|
||||
send.passwordIterations = SEND_PASSWORD_ITERATIONS;
|
||||
send.authType = SendAuthType.Password;
|
||||
}
|
||||
|
||||
export async function verifySendPassword(send: Send, password: string): Promise<boolean> {
|
||||
if (!send.passwordHash) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!send.passwordSalt || !send.passwordIterations) {
|
||||
return verifySendPasswordHashB64(send, password);
|
||||
}
|
||||
|
||||
const salt = base64UrlDecode(send.passwordSalt);
|
||||
const expected = base64UrlDecode(send.passwordHash);
|
||||
if (!salt || !expected) return false;
|
||||
|
||||
const actual = await deriveSendPasswordHash(password, salt, send.passwordIterations);
|
||||
return constantTimeEqual(actual, expected);
|
||||
}
|
||||
|
||||
export function verifySendPasswordHashB64(send: Send, passwordHashB64: string): boolean {
|
||||
if (!send.passwordHash || !passwordHashB64) return false;
|
||||
const expected = base64UrlDecode(send.passwordHash);
|
||||
const provided = base64UrlDecode(passwordHashB64);
|
||||
if (!expected || !provided) return false;
|
||||
return constantTimeEqual(expected, provided);
|
||||
}
|
||||
|
||||
export function validateDeletionDate(date: Date): Response | null {
|
||||
const maxMs = Date.now() + LIMITS.send.maxDeletionDays * 24 * 60 * 60 * 1000;
|
||||
if (date.getTime() > maxMs) {
|
||||
return errorResponse(
|
||||
'You cannot have a Send with a deletion date that far into the future. Adjust the Deletion Date to a value less than 31 days from now and try again.',
|
||||
400
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseMaxAccessCount(value: unknown): { ok: true; value: number | null } | { ok: false; response: Response } {
|
||||
const parsed = parseInteger(value);
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return { ok: true, value: null };
|
||||
}
|
||||
if (parsed === null || parsed < 0) {
|
||||
return { ok: false, response: errorResponse('Invalid maxAccessCount', 400) };
|
||||
}
|
||||
return { ok: true, value: parsed };
|
||||
}
|
||||
|
||||
export function parseFileLength(value: unknown): { ok: true; value: number } | { ok: false; response: Response } {
|
||||
const parsed = parseInteger(value);
|
||||
if (parsed === null) {
|
||||
return { ok: false, response: errorResponse('Invalid send length', 400) };
|
||||
}
|
||||
if (parsed < 0) {
|
||||
return { ok: false, response: errorResponse("Send size can't be negative", 400) };
|
||||
}
|
||||
return { ok: true, value: parsed };
|
||||
}
|
||||
|
||||
export function parseSendType(value: unknown): SendType | null {
|
||||
const type = parseInteger(value);
|
||||
if (type === SendType.Text || type === SendType.File) return type;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseSendAuthType(value: unknown): SendAuthType | null {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
const parsed = parseInteger(value);
|
||||
if (parsed === SendAuthType.Email || parsed === SendAuthType.Password || parsed === SendAuthType.None) {
|
||||
return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeEmails(value: unknown): string | null {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
if (typeof value === 'string') return value;
|
||||
if (Array.isArray(value)) {
|
||||
const strings = value.filter((v) => typeof v === 'string').map((v) => String(v));
|
||||
if (strings.length === 0) return null;
|
||||
return strings.join(',');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasEmailAuth(send: Send): boolean {
|
||||
return send.authType === SendAuthType.Email;
|
||||
}
|
||||
|
||||
export function getSafeJwtSecret(env: Env): { ok: true; secret: string } | { ok: false; response: Response } {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
|
||||
return { ok: false, response: errorResponse('Server configuration error', 500) };
|
||||
}
|
||||
return { ok: true, secret };
|
||||
}
|
||||
|
||||
export function extractBearerToken(request: Request): string | null {
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (!authHeader) return null;
|
||||
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
export function sendToResponse(send: Send): SendResponse {
|
||||
const data = normalizeSendDataSizeField(parseStoredSendData(send));
|
||||
return {
|
||||
id: send.id,
|
||||
accessId: toAccessId(send.id),
|
||||
type: Number(send.type) || 0,
|
||||
name: send.name,
|
||||
notes: send.notes,
|
||||
text: send.type === SendType.Text ? data : null,
|
||||
file: send.type === SendType.File ? data : null,
|
||||
key: send.key,
|
||||
maxAccessCount: send.maxAccessCount,
|
||||
accessCount: send.accessCount,
|
||||
password: send.passwordHash,
|
||||
emails: send.emails,
|
||||
authType: send.authType,
|
||||
disabled: send.disabled,
|
||||
hideEmail: send.hideEmail,
|
||||
revisionDate: send.updatedAt,
|
||||
expirationDate: send.expirationDate,
|
||||
deletionDate: send.deletionDate,
|
||||
object: 'send',
|
||||
};
|
||||
}
|
||||
|
||||
export function sendToAccessResponse(send: Send, creatorIdentifier: string | null): Record<string, unknown> {
|
||||
const data = normalizeSendDataSizeField(parseStoredSendData(send));
|
||||
return {
|
||||
id: send.id,
|
||||
type: Number(send.type) || 0,
|
||||
name: send.name,
|
||||
text: send.type === SendType.Text ? data : null,
|
||||
file: send.type === SendType.File ? data : null,
|
||||
expirationDate: send.expirationDate,
|
||||
deletionDate: send.deletionDate,
|
||||
creatorIdentifier,
|
||||
object: 'send-access',
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCreatorIdentifier(storage: StorageService, send: Send): Promise<string | null> {
|
||||
if (send.hideEmail) return null;
|
||||
const owner = await storage.getUserById(send.userId);
|
||||
return owner?.email ?? null;
|
||||
}
|
||||
|
||||
export type PublicSendAccessValidationResult =
|
||||
| { ok: true }
|
||||
| { ok: false; response: Response; reason: 'email_auth_unsupported' | 'password_missing' | 'invalid_password' };
|
||||
|
||||
export function sendPasswordLimitKey(clientIdentifier: string): string {
|
||||
return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}`;
|
||||
}
|
||||
|
||||
function sendPasswordLockMessage(retryAfterSeconds: number): string {
|
||||
return `Too many failed send password attempts. Try again in ${Math.ceil(retryAfterSeconds / 60)} minutes.`;
|
||||
}
|
||||
|
||||
export function sendPasswordLockedErrorResponse(retryAfterSeconds: number): Response {
|
||||
return errorResponse(sendPasswordLockMessage(retryAfterSeconds), 429);
|
||||
}
|
||||
|
||||
export function sendPasswordLockedOAuthResponse(retryAfterSeconds: number): Response {
|
||||
const message = sendPasswordLockMessage(retryAfterSeconds);
|
||||
return jsonResponse(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: message,
|
||||
send_access_error_type: 'too_many_password_attempts',
|
||||
ErrorModel: {
|
||||
Message: message,
|
||||
Object: 'error',
|
||||
},
|
||||
},
|
||||
429
|
||||
);
|
||||
}
|
||||
|
||||
export async function validatePublicSendAccess(send: Send, body: unknown): Promise<PublicSendAccessValidationResult> {
|
||||
if (hasEmailAuth(send)) {
|
||||
return { ok: false, response: errorResponse(SEND_INACCESSIBLE_MSG, 404), reason: 'email_auth_unsupported' };
|
||||
}
|
||||
|
||||
if (!send.passwordHash) return { ok: true };
|
||||
|
||||
const passwordRaw = getAliasedProp(body, ['password', 'Password']);
|
||||
const passwordHashB64Raw = getAliasedProp(body, [
|
||||
'password_hash_b64',
|
||||
'passwordHashB64',
|
||||
'passwordHash',
|
||||
'password_hash',
|
||||
]);
|
||||
|
||||
let validPassword = false;
|
||||
if (send.passwordSalt && send.passwordIterations) {
|
||||
if (typeof passwordRaw.value !== 'string') {
|
||||
return { ok: false, response: errorResponse('Password not provided', 401), reason: 'password_missing' };
|
||||
}
|
||||
validPassword = await verifySendPassword(send, passwordRaw.value);
|
||||
} else {
|
||||
const candidate =
|
||||
typeof passwordHashB64Raw.value === 'string'
|
||||
? passwordHashB64Raw.value
|
||||
: typeof passwordRaw.value === 'string'
|
||||
? passwordRaw.value
|
||||
: '';
|
||||
if (!candidate) return { ok: false, response: errorResponse('Password not provided', 401), reason: 'password_missing' };
|
||||
validPassword = verifySendPasswordHashB64(send, candidate);
|
||||
}
|
||||
if (!validPassword) {
|
||||
return { ok: false, response: errorResponse('Invalid password', 400), reason: 'invalid_password' };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
+3
-1473
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,276 @@
|
||||
import type { Env, User } from './types';
|
||||
import { errorResponse, jsonResponse } from './utils/response';
|
||||
import {
|
||||
handleGetProfile,
|
||||
handleSetKeys,
|
||||
handleGetRevisionDate,
|
||||
handleVerifyPassword,
|
||||
handleChangePassword,
|
||||
handleGetTotpStatus,
|
||||
handleSetTotpStatus,
|
||||
handleGetTotpRecoveryCode,
|
||||
} from './handlers/accounts';
|
||||
import {
|
||||
handleGetCiphers,
|
||||
handleGetCipher,
|
||||
handleCreateCipher,
|
||||
handleUpdateCipher,
|
||||
handleDeleteCipher,
|
||||
handleDeleteCipherCompat,
|
||||
handlePermanentDeleteCipher,
|
||||
handleRestoreCipher,
|
||||
handlePartialUpdateCipher,
|
||||
handleBulkMoveCiphers,
|
||||
handleBulkDeleteCiphers,
|
||||
handleBulkPermanentDeleteCiphers,
|
||||
handleBulkRestoreCiphers,
|
||||
} from './handlers/ciphers';
|
||||
import {
|
||||
handleGetFolders,
|
||||
handleGetFolder,
|
||||
handleCreateFolder,
|
||||
handleUpdateFolder,
|
||||
handleDeleteFolder,
|
||||
handleBulkDeleteFolders,
|
||||
} from './handlers/folders';
|
||||
import {
|
||||
handleGetSends,
|
||||
handleGetSend,
|
||||
handleCreateSend,
|
||||
handleGetSendFileUpload,
|
||||
handleUploadSendFile,
|
||||
handleUpdateSend,
|
||||
handleDeleteSend,
|
||||
handleBulkDeleteSends,
|
||||
handleRemoveSendPassword,
|
||||
handleRemoveSendAuth,
|
||||
} from './handlers/sends';
|
||||
import { handleSync } from './handlers/sync';
|
||||
import { handleCiphersImport } from './handlers/import';
|
||||
import {
|
||||
handleCreateAttachment,
|
||||
handleUploadAttachment,
|
||||
handleGetAttachment,
|
||||
handleDeleteAttachment,
|
||||
} from './handlers/attachments';
|
||||
import { handleAuthenticatedDeviceRoute } from './router-devices';
|
||||
import { handleAdminRoute } from './router-admin';
|
||||
|
||||
export async function handleAuthenticatedRoute(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
currentUser: User,
|
||||
path: string,
|
||||
method: string
|
||||
): Promise<Response | null> {
|
||||
if (method === 'POST' || method === 'PUT' || method === 'DELETE') {
|
||||
const blockedAccountPaths = new Set([
|
||||
'/api/accounts/set-password',
|
||||
'/api/accounts/delete',
|
||||
'/api/accounts/delete-account',
|
||||
'/api/accounts/delete-vault',
|
||||
]);
|
||||
if (blockedAccountPaths.has(path)) {
|
||||
return errorResponse('Not implemented', 501);
|
||||
}
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/profile') {
|
||||
if (method === 'GET') return handleGetProfile(request, env, userId);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if ((path === '/api/accounts/password' || path === '/api/accounts/change-password') && (method === 'POST' || method === 'PUT')) {
|
||||
return handleChangePassword(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/keys' && method === 'POST') {
|
||||
return handleSetKeys(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/totp') {
|
||||
if (method === 'GET') return handleGetTotpStatus(request, env, userId);
|
||||
if (method === 'PUT' || method === 'POST') return handleSetTotpStatus(request, env, userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((path === '/api/accounts/totp/recovery-code' || path === '/api/two-factor/get-recover') && method === 'POST') {
|
||||
return handleGetTotpRecoveryCode(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/revision-date' && method === 'GET') {
|
||||
return handleGetRevisionDate(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/verify-password' && method === 'POST') {
|
||||
return handleVerifyPassword(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/sync' && method === 'GET') {
|
||||
return handleSync(request, env, userId);
|
||||
}
|
||||
|
||||
if (path.startsWith('/notifications/')) {
|
||||
return errorResponse('Not found', 404);
|
||||
}
|
||||
|
||||
if (path === '/api/ciphers' || path === '/api/ciphers/create') {
|
||||
if (method === 'GET') return handleGetCiphers(request, env, userId);
|
||||
if (method === 'POST') return handleCreateCipher(request, env, userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (path === '/api/ciphers/import' && method === 'POST') {
|
||||
return handleCiphersImport(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/ciphers/delete' && method === 'POST') {
|
||||
return handleBulkDeleteCiphers(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/ciphers/delete-permanent' && method === 'POST') {
|
||||
return handleBulkPermanentDeleteCiphers(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/ciphers/restore' && method === 'POST') {
|
||||
return handleBulkRestoreCiphers(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/ciphers/move' && (method === 'POST' || method === 'PUT')) {
|
||||
return handleBulkMoveCiphers(request, env, userId);
|
||||
}
|
||||
|
||||
const cipherMatch = path.match(/^\/api\/ciphers\/([a-f0-9-]+)(\/.*)?$/i);
|
||||
if (cipherMatch) {
|
||||
const cipherId = cipherMatch[1];
|
||||
const subPath = cipherMatch[2] || '';
|
||||
|
||||
if (subPath === '' || subPath === '/') {
|
||||
if (method === 'GET') return handleGetCipher(request, env, userId, cipherId);
|
||||
if (method === 'PUT' || method === 'POST') return handleUpdateCipher(request, env, userId, cipherId);
|
||||
if (method === 'DELETE') return handleDeleteCipherCompat(request, env, userId, cipherId);
|
||||
}
|
||||
|
||||
if (subPath === '/delete' && method === 'PUT') return handleDeleteCipher(request, env, userId, cipherId);
|
||||
if (subPath === '/delete' && method === 'DELETE') return handlePermanentDeleteCipher(request, env, userId, cipherId);
|
||||
if (subPath === '/restore' && method === 'PUT') return handleRestoreCipher(request, env, userId, cipherId);
|
||||
if (subPath === '/partial' && (method === 'PUT' || method === 'POST')) return handlePartialUpdateCipher(request, env, userId, cipherId);
|
||||
if (subPath === '/share' && method === 'POST') return handleGetCipher(request, env, userId, cipherId);
|
||||
if (subPath === '/details' && method === 'GET') return handleGetCipher(request, env, userId, cipherId);
|
||||
if (subPath === '/attachment/v2' && method === 'POST') return handleCreateAttachment(request, env, userId, cipherId);
|
||||
if (subPath === '/attachment' && method === 'POST') return handleCreateAttachment(request, env, userId, cipherId);
|
||||
|
||||
const attachmentMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)$/i);
|
||||
if (attachmentMatch) {
|
||||
const attachmentId = attachmentMatch[1];
|
||||
if (method === 'POST') return handleUploadAttachment(request, env, userId, cipherId, attachmentId);
|
||||
if (method === 'GET') return handleGetAttachment(request, env, userId, cipherId, attachmentId);
|
||||
if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
|
||||
}
|
||||
|
||||
const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i);
|
||||
if (attachmentDeleteMatch && method === 'POST') {
|
||||
return handleDeleteAttachment(request, env, userId, cipherId, attachmentDeleteMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (path === '/api/folders') {
|
||||
if (method === 'GET') return handleGetFolders(request, env, userId);
|
||||
if (method === 'POST') return handleCreateFolder(request, env, userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (path === '/api/folders/delete' && method === 'POST') {
|
||||
return handleBulkDeleteFolders(request, env, userId);
|
||||
}
|
||||
|
||||
const folderMatch = path.match(/^\/api\/folders\/([a-f0-9-]+)$/i);
|
||||
if (folderMatch) {
|
||||
const folderId = folderMatch[1];
|
||||
if (method === 'GET') return handleGetFolder(request, env, userId, folderId);
|
||||
if (method === 'PUT') return handleUpdateFolder(request, env, userId, folderId);
|
||||
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
|
||||
}
|
||||
|
||||
if (path.startsWith('/api/auth-requests')) {
|
||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||
}
|
||||
|
||||
if (path === '/api/collections' || path.startsWith('/api/collections/')) {
|
||||
if (method === 'GET') {
|
||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (path === '/api/organizations' || path.startsWith('/api/organizations/')) {
|
||||
if (method === 'GET') {
|
||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (path === '/api/sends') {
|
||||
if (method === 'GET') return handleGetSends(request, env, userId);
|
||||
if (method === 'POST') return handleCreateSend(request, env, userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (path === '/api/sends/delete' && method === 'POST') {
|
||||
return handleBulkDeleteSends(request, env, userId);
|
||||
}
|
||||
|
||||
const sendMatch = path.match(/^\/api\/sends\/([^/]+)(\/.*)?$/i);
|
||||
if (sendMatch) {
|
||||
const sendId = sendMatch[1];
|
||||
const subPath = sendMatch[2] || '';
|
||||
|
||||
if (subPath === '' || subPath === '/') {
|
||||
if (method === 'GET') return handleGetSend(request, env, userId, sendId);
|
||||
if (method === 'PUT') return handleUpdateSend(request, env, userId, sendId);
|
||||
if (method === 'DELETE') return handleDeleteSend(request, env, userId, sendId);
|
||||
}
|
||||
|
||||
if (subPath === '/remove-password' && (method === 'PUT' || method === 'POST')) {
|
||||
return handleRemoveSendPassword(request, env, userId, sendId);
|
||||
}
|
||||
|
||||
if (subPath === '/remove-auth' && (method === 'PUT' || method === 'POST')) {
|
||||
return handleRemoveSendAuth(request, env, userId, sendId);
|
||||
}
|
||||
|
||||
const sendFileUploadMatch = subPath.match(/^\/file\/([^/]+)\/?$/i);
|
||||
if (sendFileUploadMatch) {
|
||||
const fileId = sendFileUploadMatch[1];
|
||||
if (method === 'GET') return handleGetSendFileUpload(request, env, userId, sendId, fileId);
|
||||
if (method === 'POST' || method === 'PUT') return handleUploadSendFile(request, env, userId, sendId, fileId);
|
||||
}
|
||||
}
|
||||
|
||||
if (path === '/api/policies' || path.startsWith('/api/policies/')) {
|
||||
if (method === 'GET') {
|
||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (path === '/api/settings/domains') {
|
||||
if (method === 'GET' || method === 'PUT' || method === 'POST') {
|
||||
return jsonResponse({
|
||||
equivalentDomains: [],
|
||||
globalEquivalentDomains: [],
|
||||
object: 'domains',
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const authenticatedDeviceResponse = await handleAuthenticatedDeviceRoute(request, env, userId, path, method);
|
||||
if (authenticatedDeviceResponse) return authenticatedDeviceResponse;
|
||||
|
||||
const adminResponse = await handleAdminRoute(request, env, currentUser, path, method);
|
||||
if (adminResponse) return adminResponse;
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { LIMITS } from './config/limits';
|
||||
import { DEFAULT_DEV_SECRET } from './types';
|
||||
import {
|
||||
handleAccessSend,
|
||||
handleAccessSendFile,
|
||||
handleAccessSendV2,
|
||||
handleAccessSendFileV2,
|
||||
handleDownloadSendFile,
|
||||
} from './handlers/sends';
|
||||
import { handleSetupStatus } from './handlers/setup';
|
||||
import { handleKnownDevice } from './handlers/devices';
|
||||
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
|
||||
import {
|
||||
handleRegister,
|
||||
handleRecoverTwoFactor,
|
||||
} from './handlers/accounts';
|
||||
import { handlePublicDownloadAttachment } from './handlers/attachments';
|
||||
import {
|
||||
handleNotificationsHub,
|
||||
handleNotificationsNegotiate,
|
||||
} from './handlers/notifications';
|
||||
import { jsonResponse } from './utils/response';
|
||||
import type { Env } from './types';
|
||||
|
||||
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
|
||||
|
||||
function isSameOriginWriteRequest(request: Request): boolean {
|
||||
const targetOrigin = new URL(request.url).origin;
|
||||
const origin = request.headers.get('Origin');
|
||||
if (origin) {
|
||||
return origin === targetOrigin;
|
||||
}
|
||||
|
||||
const referer = request.headers.get('Referer');
|
||||
if (referer) {
|
||||
try {
|
||||
return new URL(referer).origin === targetOrigin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function getNwIconSvg(): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="NW icon"><rect x="4" y="4" width="88" height="88" rx="20" fill="#111418"/><text x="48" y="60" text-anchor="middle" font-size="36" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-weight="800" letter-spacing="0.5" fill="#FFFFFF">NW</text></svg>`;
|
||||
}
|
||||
|
||||
function handleNwFavicon(): Response {
|
||||
return new Response(getNwIconSvg(), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/svg+xml; charset=utf-8',
|
||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isValidIconHostname(hostname: string): boolean {
|
||||
if (!hostname) return false;
|
||||
if (hostname.length > 253) return false;
|
||||
|
||||
const normalized = hostname.toLowerCase().replace(/\.$/, '');
|
||||
const domainPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59})$/;
|
||||
const ipv4Pattern = /^(?:\d{1,3}\.){3}\d{1,3}$/;
|
||||
|
||||
if (domainPattern.test(normalized)) return true;
|
||||
if (!ipv4Pattern.test(normalized)) return false;
|
||||
|
||||
const parts = normalized.split('.');
|
||||
return parts.every((p) => {
|
||||
const n = Number(p);
|
||||
return Number.isInteger(n) && n >= 0 && n <= 255;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleGetIcon(env: Env, hostname: string): Promise<Response> {
|
||||
try {
|
||||
void env;
|
||||
const normalizedHostname = hostname.toLowerCase();
|
||||
if (!isValidIconHostname(normalizedHostname)) {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
const cache = caches.default;
|
||||
const cacheKey = new Request(`https://nodewarden-icons.local/icons/${normalizedHostname}/icon.png`, { method: 'GET' });
|
||||
const cached = await cache.match(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const resp = await fetch(`https://favicon.im/${normalizedHostname}`, {
|
||||
headers: { 'User-Agent': 'NodeWarden/1.0' },
|
||||
redirect: 'follow',
|
||||
cf: {
|
||||
cacheEverything: true,
|
||||
cacheTtl: LIMITS.cache.iconTtlSeconds,
|
||||
},
|
||||
});
|
||||
|
||||
if (!resp.ok) return new Response(null, { status: 204 });
|
||||
|
||||
const body = await resp.arrayBuffer();
|
||||
if (body.byteLength === 0) {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
const iconResponse = new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
|
||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
|
||||
},
|
||||
});
|
||||
await cache.put(cacheKey, iconResponse.clone());
|
||||
return iconResponse;
|
||||
} catch {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWebConfigResponse(env: Env) {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
const jwtUnsafeReason =
|
||||
!secret
|
||||
? 'missing'
|
||||
: secret === DEFAULT_DEV_SECRET
|
||||
? 'default'
|
||||
: secret.length < LIMITS.auth.jwtSecretMinLength
|
||||
? 'too_short'
|
||||
: null;
|
||||
|
||||
return {
|
||||
defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
|
||||
jwtUnsafeReason,
|
||||
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handlePublicRoute(
|
||||
request: Request,
|
||||
env: Env,
|
||||
path: string,
|
||||
method: string,
|
||||
enforcePublicRateLimit: PublicRateLimiter
|
||||
): Promise<Response | null> {
|
||||
if (path === '/setup/status' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleSetupStatus(request, env);
|
||||
}
|
||||
|
||||
if (path === '/api/web/config' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return jsonResponse(buildWebConfigResponse(env));
|
||||
}
|
||||
|
||||
if (path === '/.well-known/appspecific/com.chrome.devtools.json' && method === 'GET') {
|
||||
return new Response('{}', {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if ((path === '/favicon.ico' || path === '/favicon.svg') && method === 'GET') {
|
||||
return handleNwFavicon();
|
||||
}
|
||||
|
||||
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
||||
if (iconMatch) {
|
||||
return handleGetIcon(env, iconMatch[1]);
|
||||
}
|
||||
|
||||
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
||||
if (publicAttachmentMatch && method === 'GET') {
|
||||
return handlePublicDownloadAttachment(request, env, publicAttachmentMatch[1], publicAttachmentMatch[2]);
|
||||
}
|
||||
|
||||
const sendAccessMatch = path.match(/^\/api\/sends\/access\/([^/]+)$/i);
|
||||
if (sendAccessMatch && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit();
|
||||
if (blocked) return blocked;
|
||||
return handleAccessSend(request, env, sendAccessMatch[1]);
|
||||
}
|
||||
|
||||
if (path === '/api/sends/access' && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit();
|
||||
if (blocked) return blocked;
|
||||
return handleAccessSendV2(request, env);
|
||||
}
|
||||
|
||||
const sendAccessFileV2Match = path.match(/^\/api\/sends\/access\/file\/([^/]+)\/?$/i);
|
||||
if (sendAccessFileV2Match && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit();
|
||||
if (blocked) return blocked;
|
||||
return handleAccessSendFileV2(request, env, sendAccessFileV2Match[1]);
|
||||
}
|
||||
|
||||
const sendAccessFileMatch = path.match(/^\/api\/sends\/([^/]+)\/access\/file\/([^/]+)\/?$/i);
|
||||
if (sendAccessFileMatch && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit();
|
||||
if (blocked) return blocked;
|
||||
return handleAccessSendFile(request, env, sendAccessFileMatch[1], sendAccessFileMatch[2]);
|
||||
}
|
||||
|
||||
const sendDownloadMatch = path.match(/^\/api\/sends\/([^/]+)\/([^/]+)\/?$/i);
|
||||
if (sendDownloadMatch && method === 'GET') {
|
||||
return handleDownloadSendFile(request, env, sendDownloadMatch[1], sendDownloadMatch[2]);
|
||||
}
|
||||
|
||||
if (path === '/identity/connect/token' && method === 'POST') {
|
||||
return handleToken(request, env);
|
||||
}
|
||||
|
||||
if (path === '/api/devices/knowndevice' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit();
|
||||
if (blocked) return jsonResponse(false);
|
||||
return handleKnownDevice(request, env);
|
||||
}
|
||||
|
||||
if ((path === '/identity/connect/revocation' || path === '/identity/connect/revoke') && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleRevocation(request, env);
|
||||
}
|
||||
|
||||
if (path === '/identity/accounts/prelogin' && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handlePrelogin(request, env);
|
||||
}
|
||||
|
||||
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
|
||||
return handleRecoverTwoFactor(request, env);
|
||||
}
|
||||
|
||||
if ((path === '/config' || path === '/api/config') && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
const origin = new URL(request.url).origin;
|
||||
return jsonResponse({
|
||||
version: LIMITS.compatibility.bitwardenServerVersion,
|
||||
gitHash: 'nodewarden',
|
||||
server: null,
|
||||
environment: {
|
||||
vault: origin,
|
||||
api: origin + '/api',
|
||||
identity: origin + '/identity',
|
||||
notifications: origin + '/notifications',
|
||||
sso: '',
|
||||
},
|
||||
featureStates: {
|
||||
'duo-redirect': true,
|
||||
'email-verification': true,
|
||||
'pm-19051-send-email-verification': false,
|
||||
'unauth-ui-refresh': true,
|
||||
},
|
||||
object: 'config',
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/version' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return jsonResponse(LIMITS.compatibility.bitwardenServerVersion);
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/register' && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit('register', LIMITS.rateLimit.registerRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
if (!isSameOriginWriteRequest(request)) {
|
||||
return new Response(JSON.stringify({ error: 'Forbidden origin' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return handleRegister(request, env);
|
||||
}
|
||||
|
||||
if (path === '/notifications/hub/negotiate' && method === 'POST') {
|
||||
return handleNotificationsNegotiate(request, env);
|
||||
}
|
||||
|
||||
if (path === '/notifications/hub' && method === 'GET') {
|
||||
return handleNotificationsHub(request, env);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+52
-692
@@ -1,120 +1,11 @@
|
||||
import { Env, DEFAULT_DEV_SECRET } from './types';
|
||||
import { DEFAULT_DEV_SECRET, Env } from './types';
|
||||
import { AuthService } from './services/auth';
|
||||
import { StorageService } from './services/storage';
|
||||
import { RateLimitService, getClientIdentifier } from './services/ratelimit';
|
||||
import { handleCors, errorResponse, jsonResponse } from './utils/response';
|
||||
import { handleCors, errorResponse } from './utils/response';
|
||||
import { LIMITS } from './config/limits';
|
||||
|
||||
// Identity handlers
|
||||
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
|
||||
|
||||
// Account handlers
|
||||
import {
|
||||
handleRegister,
|
||||
handleGetProfile,
|
||||
handleSetKeys,
|
||||
handleGetRevisionDate,
|
||||
handleVerifyPassword,
|
||||
handleChangePassword,
|
||||
handleGetTotpStatus,
|
||||
handleSetTotpStatus,
|
||||
handleGetTotpRecoveryCode,
|
||||
handleRecoverTwoFactor,
|
||||
} from './handlers/accounts';
|
||||
|
||||
// Cipher handlers
|
||||
import {
|
||||
handleGetCiphers,
|
||||
handleGetCipher,
|
||||
handleCreateCipher,
|
||||
handleUpdateCipher,
|
||||
handleDeleteCipher,
|
||||
handleDeleteCipherCompat,
|
||||
handlePermanentDeleteCipher,
|
||||
handleRestoreCipher,
|
||||
handlePartialUpdateCipher,
|
||||
handleBulkMoveCiphers,
|
||||
handleBulkDeleteCiphers,
|
||||
handleBulkPermanentDeleteCiphers,
|
||||
handleBulkRestoreCiphers,
|
||||
} from './handlers/ciphers';
|
||||
|
||||
// Folder handlers
|
||||
import {
|
||||
handleGetFolders,
|
||||
handleGetFolder,
|
||||
handleCreateFolder,
|
||||
handleUpdateFolder,
|
||||
handleDeleteFolder,
|
||||
handleBulkDeleteFolders,
|
||||
} from './handlers/folders';
|
||||
|
||||
// Send handlers
|
||||
import {
|
||||
handleGetSends,
|
||||
handleGetSend,
|
||||
handleCreateSend,
|
||||
handleCreateFileSendV2,
|
||||
handleGetSendFileUpload,
|
||||
handleUploadSendFile,
|
||||
handleUpdateSend,
|
||||
handleDeleteSend,
|
||||
handleBulkDeleteSends,
|
||||
handleRemoveSendPassword,
|
||||
handleRemoveSendAuth,
|
||||
handleAccessSend,
|
||||
handleAccessSendFile,
|
||||
handleAccessSendV2,
|
||||
handleAccessSendFileV2,
|
||||
handleDownloadSendFile,
|
||||
} from './handlers/sends';
|
||||
|
||||
// Sync handler
|
||||
import { handleSync } from './handlers/sync';
|
||||
|
||||
// Setup handlers
|
||||
import { handleSetupStatus } from './handlers/setup';
|
||||
import {
|
||||
handleKnownDevice,
|
||||
} from './handlers/devices';
|
||||
|
||||
// Import handler
|
||||
import { handleCiphersImport } from './handlers/import';
|
||||
|
||||
// Attachment handlers
|
||||
import {
|
||||
handleCreateAttachment,
|
||||
handleUploadAttachment,
|
||||
handleGetAttachment,
|
||||
handleDeleteAttachment,
|
||||
handlePublicDownloadAttachment,
|
||||
} from './handlers/attachments';
|
||||
import {
|
||||
handleNotificationsHub,
|
||||
handleNotificationsNegotiate,
|
||||
} from './handlers/notifications';
|
||||
import { handleAdminRoute } from './router-admin';
|
||||
import { handleAuthenticatedDeviceRoute } from './router-devices';
|
||||
|
||||
function isSameOriginWriteRequest(request: Request): boolean {
|
||||
const targetOrigin = new URL(request.url).origin;
|
||||
const origin = request.headers.get('Origin');
|
||||
if (origin) {
|
||||
return origin === targetOrigin;
|
||||
}
|
||||
|
||||
const referer = request.headers.get('Referer');
|
||||
if (referer) {
|
||||
try {
|
||||
return new URL(referer).origin === targetOrigin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Require browser-origin evidence for setup/register write operations.
|
||||
return false;
|
||||
}
|
||||
import { handleAuthenticatedRoute } from './router-authenticated';
|
||||
import { handlePublicRoute } from './router-public';
|
||||
|
||||
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
@@ -124,10 +15,6 @@ function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' |
|
||||
return null;
|
||||
}
|
||||
|
||||
function getNwIconSvg(): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="NW icon"><rect x="4" y="4" width="88" height="88" rx="20" fill="#111418"/><text x="48" y="60" text-anchor="middle" font-size="36" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-weight="800" letter-spacing="0.5" fill="#FFFFFF">NW</text></svg>`;
|
||||
}
|
||||
|
||||
function isImportBypassRequest(request: Request, path: string, method: string): boolean {
|
||||
if (request.headers.get('X-NodeWarden-Import') !== '1') return false;
|
||||
|
||||
@@ -140,85 +27,6 @@ function isImportBypassRequest(request: Request, path: string, method: string):
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleNwFavicon(): Response {
|
||||
return new Response(getNwIconSvg(), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/svg+xml; charset=utf-8',
|
||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isValidIconHostname(hostname: string): boolean {
|
||||
if (!hostname) return false;
|
||||
if (hostname.length > 253) return false;
|
||||
|
||||
const normalized = hostname.toLowerCase().replace(/\.$/, '');
|
||||
// Slightly relaxed domain validation:
|
||||
// - keep strict label boundaries (no leading/trailing hyphen)
|
||||
// - allow punycode TLD (e.g. xn--...)
|
||||
const domainPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59})$/;
|
||||
const ipv4Pattern = /^(?:\d{1,3}\.){3}\d{1,3}$/;
|
||||
|
||||
if (domainPattern.test(normalized)) return true;
|
||||
if (!ipv4Pattern.test(normalized)) return false;
|
||||
|
||||
const parts = normalized.split('.');
|
||||
return parts.every(p => {
|
||||
const n = Number(p);
|
||||
return Number.isInteger(n) && n >= 0 && n <= 255;
|
||||
});
|
||||
}
|
||||
|
||||
// Icons handler - proxy to favicon.im
|
||||
async function handleGetIcon(request: Request, env: Env, hostname: string): Promise<Response> {
|
||||
try {
|
||||
void env;
|
||||
const normalizedHostname = hostname.toLowerCase();
|
||||
if (!isValidIconHostname(normalizedHostname)) {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
const cache = caches.default;
|
||||
const cacheKey = new Request(`https://nodewarden-icons.local/icons/${normalizedHostname}/icon.png`, { method: 'GET' });
|
||||
const cached = await cache.match(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const iconUrl = `https://favicon.im/${normalizedHostname}`;
|
||||
const resp = await fetch(iconUrl, {
|
||||
headers: { 'User-Agent': 'NodeWarden/1.0' },
|
||||
redirect: 'follow',
|
||||
cf: {
|
||||
cacheEverything: true,
|
||||
cacheTtl: LIMITS.cache.iconTtlSeconds,
|
||||
},
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
const body = await resp.arrayBuffer();
|
||||
if (body.byteLength === 0) {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
const iconResponse = new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
|
||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`, // 7 days
|
||||
},
|
||||
});
|
||||
await cache.put(cacheKey, iconResponse.clone());
|
||||
return iconResponse;
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
} catch {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleRequest(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
@@ -230,42 +38,43 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
maxRequests: number = LIMITS.rateLimit.publicRequestsPerMinute
|
||||
): Promise<Response | null> {
|
||||
if (!clientId) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Forbidden',
|
||||
error_description: 'Client IP is required',
|
||||
}), {
|
||||
status: 403,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'Forbidden',
|
||||
error_description: 'Client IP is required',
|
||||
}),
|
||||
{
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const rateLimit = new RateLimitService(env.DB);
|
||||
const check = await rateLimit.consumeBudget(`${clientId}:${category}`, maxRequests);
|
||||
if (check.allowed) return null;
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Too many requests',
|
||||
error_description: `Rate limit exceeded. Try again in ${check.retryAfterSeconds} seconds.`,
|
||||
}), {
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': String(check.retryAfterSeconds || 60),
|
||||
'X-RateLimit-Remaining': '0',
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'Too many requests',
|
||||
error_description: `Rate limit exceeded. Try again in ${check.retryAfterSeconds} seconds.`,
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': String(check.retryAfterSeconds || 60),
|
||||
'X-RateLimit-Remaining': '0',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Handle CORS preflight
|
||||
if (method === 'OPTIONS') {
|
||||
return handleCors(request);
|
||||
}
|
||||
|
||||
// Route matching
|
||||
try {
|
||||
|
||||
// Reject oversized bodies before any path-specific parsing.
|
||||
// Large file/archive upload paths enforce their own limits and are exempt here.
|
||||
const isLargeUploadPath =
|
||||
/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path) ||
|
||||
/^\/api\/sends\/[a-f0-9-]+\/file\/[a-f0-9-]+$/i.test(path) ||
|
||||
@@ -277,207 +86,17 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
}
|
||||
}
|
||||
|
||||
// Setup status
|
||||
if (path === '/setup/status' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleSetupStatus(request, env);
|
||||
}
|
||||
const publicResponse = await handlePublicRoute(request, env, path, method, enforcePublicRateLimit);
|
||||
if (publicResponse) return publicResponse;
|
||||
|
||||
// Web runtime config for static client bootstrap
|
||||
if (path === '/api/web/config' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
const jwtUnsafeReason = jwtSecretUnsafeReason(env);
|
||||
return jsonResponse({
|
||||
defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
|
||||
jwtUnsafeReason,
|
||||
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
||||
});
|
||||
}
|
||||
|
||||
// Browser/devtools probe endpoint
|
||||
if (path === '/.well-known/appspecific/com.chrome.devtools.json' && method === 'GET') {
|
||||
return new Response('{}', {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Favicon
|
||||
if ((path === '/favicon.ico' || path === '/favicon.svg') && method === 'GET') {
|
||||
return handleNwFavicon();
|
||||
}
|
||||
|
||||
// Icon endpoint - proxy to Bitwarden's icon service (no auth required)
|
||||
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
||||
if (iconMatch) {
|
||||
const hostname = iconMatch[1];
|
||||
return handleGetIcon(request, env, hostname);
|
||||
}
|
||||
|
||||
// Public attachment download (no auth header, uses token in query string)
|
||||
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
||||
if (publicAttachmentMatch && method === 'GET') {
|
||||
const cipherId = publicAttachmentMatch[1];
|
||||
const attachmentId = publicAttachmentMatch[2];
|
||||
return handlePublicDownloadAttachment(request, env, cipherId, attachmentId);
|
||||
}
|
||||
|
||||
// Public Send access endpoints
|
||||
const sendAccessMatch = path.match(/^\/api\/sends\/access\/([^/]+)$/i);
|
||||
if (sendAccessMatch && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit();
|
||||
if (blocked) return blocked;
|
||||
const accessId = sendAccessMatch[1];
|
||||
return handleAccessSend(request, env, accessId);
|
||||
}
|
||||
|
||||
const sendAccessV2Match = path === '/api/sends/access';
|
||||
if (sendAccessV2Match && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit();
|
||||
if (blocked) return blocked;
|
||||
return handleAccessSendV2(request, env);
|
||||
}
|
||||
|
||||
const sendAccessFileV2Match = path.match(/^\/api\/sends\/access\/file\/([^/]+)\/?$/i);
|
||||
if (sendAccessFileV2Match && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit();
|
||||
if (blocked) return blocked;
|
||||
const fileId = sendAccessFileV2Match[1];
|
||||
return handleAccessSendFileV2(request, env, fileId);
|
||||
}
|
||||
|
||||
const sendAccessFileMatch = path.match(/^\/api\/sends\/([^/]+)\/access\/file\/([^/]+)\/?$/i);
|
||||
if (sendAccessFileMatch && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit();
|
||||
if (blocked) return blocked;
|
||||
const idOrAccessId = sendAccessFileMatch[1];
|
||||
const fileId = sendAccessFileMatch[2];
|
||||
return handleAccessSendFile(request, env, idOrAccessId, fileId);
|
||||
}
|
||||
|
||||
const sendDownloadMatch = path.match(/^\/api\/sends\/([^/]+)\/([^/]+)\/?$/i);
|
||||
if (sendDownloadMatch && method === 'GET') {
|
||||
const sendId = sendDownloadMatch[1];
|
||||
const fileId = sendDownloadMatch[2];
|
||||
return handleDownloadSendFile(request, env, sendId, fileId);
|
||||
}
|
||||
|
||||
// Identity endpoints (no auth required)
|
||||
if (path === '/identity/connect/token' && method === 'POST') {
|
||||
return handleToken(request, env);
|
||||
}
|
||||
|
||||
// Known device check (no auth required).
|
||||
if (path === '/api/devices/knowndevice' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit();
|
||||
if (blocked) return jsonResponse(false);
|
||||
return handleKnownDevice(request, env);
|
||||
}
|
||||
|
||||
if ((path === '/identity/connect/revocation' || path === '/identity/connect/revoke') && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleRevocation(request, env);
|
||||
}
|
||||
|
||||
if (path === '/identity/accounts/prelogin' && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handlePrelogin(request, env);
|
||||
}
|
||||
|
||||
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
|
||||
return handleRecoverTwoFactor(request, env);
|
||||
}
|
||||
|
||||
// Config endpoint (no auth required for basic config)
|
||||
// Bitwarden clients call GET "/config" (relative to the API base URL).
|
||||
// They also tolerate different casing, but their response models use PascalCase.
|
||||
const isConfigRequest = (path === '/config' || path === '/api/config') && method === 'GET';
|
||||
if (isConfigRequest) {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
const origin = url.origin;
|
||||
return jsonResponse({
|
||||
// ── Version Strategy (Plan E) ──────────────────────────────────────
|
||||
// Bitwarden clients use this version for backwards-compatibility feature gating.
|
||||
// Confirmed version-gated features (from client source code):
|
||||
// - Individual cipher key encryption: >= 2024.2.0
|
||||
// (clients/libs/common/src/vault/services/cipher.service.ts: CIPHER_KEY_ENC_MIN_SERVER_VER)
|
||||
// (android/.../FeatureFlagManagerImpl.kt: CIPHER_KEY_ENC_MIN_SERVER_VERSION)
|
||||
// - MasterPasswordUnlockData (mobile): >= 2025.8.0
|
||||
// (documented in Vaultwarden source comments)
|
||||
// There is NO global minimum version that blocks all client functionality.
|
||||
// Keep this aligned with Vaultwarden's reported version to maintain compatibility.
|
||||
// When Vaultwarden bumps their version, update this value accordingly.
|
||||
// Vaultwarden source: src/api/core/mod.rs → fn config()
|
||||
version: LIMITS.compatibility.bitwardenServerVersion,
|
||||
gitHash: 'nodewarden',
|
||||
server: null,
|
||||
environment: {
|
||||
vault: origin,
|
||||
api: origin + '/api',
|
||||
identity: origin + '/identity',
|
||||
notifications: origin + '/notifications',
|
||||
sso: '',
|
||||
},
|
||||
// Feature flags control client behavior. Clients use server-provided values;
|
||||
// flags not listed here fall back to DefaultFeatureFlagValue (all false).
|
||||
// Only enable flags for features we actually support.
|
||||
// Reference: clients/libs/common/src/enums/feature-flag.enum.ts
|
||||
featureStates: {
|
||||
'duo-redirect': true,
|
||||
'email-verification': true,
|
||||
'pm-19051-send-email-verification': false,
|
||||
'unauth-ui-refresh': true,
|
||||
},
|
||||
object: 'config',
|
||||
});
|
||||
}
|
||||
|
||||
// Version endpoint (some clients probe this to validate the server)
|
||||
if (path === '/api/version' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return jsonResponse(LIMITS.compatibility.bitwardenServerVersion); // Always same value as /config.version
|
||||
}
|
||||
|
||||
// Registration endpoint (no auth required):
|
||||
// - first user can self-register and becomes admin
|
||||
// - later registrations require inviteCode in request body
|
||||
if (path === '/api/accounts/register' && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit('register', LIMITS.rateLimit.registerRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
if (!isSameOriginWriteRequest(request)) {
|
||||
return errorResponse('Forbidden origin', 403);
|
||||
}
|
||||
return handleRegister(request, env);
|
||||
}
|
||||
|
||||
// If JWT_SECRET is not safely configured, block any other endpoints.
|
||||
const secret = jwtSecretUnsafeReason(env);
|
||||
if (secret) {
|
||||
const secretIssue = jwtSecretUnsafeReason(env);
|
||||
if (secretIssue) {
|
||||
return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500);
|
||||
}
|
||||
|
||||
if (path === '/notifications/hub/negotiate' && method === 'POST') {
|
||||
return handleNotificationsNegotiate(request, env);
|
||||
}
|
||||
|
||||
if (path === '/notifications/hub' && method === 'GET') {
|
||||
return handleNotificationsHub(request, env);
|
||||
}
|
||||
|
||||
// All other API endpoints require authentication
|
||||
const auth = new AuthService(env);
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
const payload = await auth.verifyAccessToken(authHeader);
|
||||
|
||||
if (!payload) {
|
||||
return errorResponse('Unauthorized', 401);
|
||||
}
|
||||
@@ -498,291 +117,32 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
if (currentUser.status !== 'active') {
|
||||
return errorResponse('Account is disabled', 403);
|
||||
}
|
||||
// Unified rate limiting for all authenticated API requests.
|
||||
|
||||
if (!isImportBypassRequest(request, path, method)) {
|
||||
const rateLimit = new RateLimitService(env.DB);
|
||||
const rateLimitCheck = await rateLimit.consumeBudget(
|
||||
userId + ':api',
|
||||
LIMITS.rateLimit.apiRequestsPerMinute
|
||||
);
|
||||
|
||||
const rateLimitCheck = await rateLimit.consumeBudget(`${userId}:api`, LIMITS.rateLimit.apiRequestsPerMinute);
|
||||
if (!rateLimitCheck.allowed) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Too many requests',
|
||||
error_description: `Rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`,
|
||||
}), {
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': rateLimitCheck.retryAfterSeconds!.toString(),
|
||||
'X-RateLimit-Remaining': '0',
|
||||
},
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'Too many requests',
|
||||
error_description: `Rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`,
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': String(rateLimitCheck.retryAfterSeconds || 60),
|
||||
'X-RateLimit-Remaining': '0',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Block account operations we do not support yet.
|
||||
if (method === 'POST' || method === 'PUT' || method === 'DELETE') {
|
||||
const blockedAccountPaths = new Set([
|
||||
'/api/accounts/set-password',
|
||||
'/api/accounts/delete',
|
||||
'/api/accounts/delete-account',
|
||||
'/api/accounts/delete-vault',
|
||||
]);
|
||||
if (blockedAccountPaths.has(path)) {
|
||||
return errorResponse('Not implemented', 501);
|
||||
}
|
||||
}
|
||||
const authenticatedResponse = await handleAuthenticatedRoute(request, env, userId, currentUser, path, method);
|
||||
if (authenticatedResponse) return authenticatedResponse;
|
||||
|
||||
// Account endpoints
|
||||
if (path === '/api/accounts/profile') {
|
||||
if (method === 'GET') return handleGetProfile(request, env, userId);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if ((path === '/api/accounts/password' || path === '/api/accounts/change-password') && (method === 'POST' || method === 'PUT')) {
|
||||
return handleChangePassword(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/keys' && method === 'POST') {
|
||||
return handleSetKeys(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/totp') {
|
||||
if (method === 'GET') return handleGetTotpStatus(request, env, userId);
|
||||
if (method === 'PUT' || method === 'POST') return handleSetTotpStatus(request, env, userId);
|
||||
}
|
||||
|
||||
if ((path === '/api/accounts/totp/recovery-code' || path === '/api/two-factor/get-recover') && method === 'POST') {
|
||||
return handleGetTotpRecoveryCode(request, env, userId);
|
||||
}
|
||||
|
||||
// Revision date endpoint
|
||||
if (path === '/api/accounts/revision-date' && method === 'GET') {
|
||||
return handleGetRevisionDate(request, env, userId);
|
||||
}
|
||||
|
||||
// Verify password endpoint
|
||||
if (path === '/api/accounts/verify-password' && method === 'POST') {
|
||||
return handleVerifyPassword(request, env, userId);
|
||||
}
|
||||
|
||||
// Sync endpoint
|
||||
if (path === '/api/sync' && method === 'GET') {
|
||||
return handleSync(request, env, userId);
|
||||
}
|
||||
|
||||
if (path.startsWith('/notifications/')) {
|
||||
return errorResponse('Not found', 404);
|
||||
}
|
||||
|
||||
// Cipher endpoints
|
||||
if (path === '/api/ciphers' || path === '/api/ciphers/create') {
|
||||
if (method === 'GET') return handleGetCiphers(request, env, userId);
|
||||
if (method === 'POST') return handleCreateCipher(request, env, userId);
|
||||
}
|
||||
|
||||
// Ciphers import endpoint (Bitwarden client format)
|
||||
if (path === '/api/ciphers/import' && method === 'POST') {
|
||||
return handleCiphersImport(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/ciphers/delete' && method === 'POST') {
|
||||
return handleBulkDeleteCiphers(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/ciphers/delete-permanent' && method === 'POST') {
|
||||
return handleBulkPermanentDeleteCiphers(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/ciphers/restore' && method === 'POST') {
|
||||
return handleBulkRestoreCiphers(request, env, userId);
|
||||
}
|
||||
|
||||
// Bulk cipher operations (only move is allowed)
|
||||
if (path === '/api/ciphers/move') {
|
||||
if (method === 'POST' || method === 'PUT') {
|
||||
return handleBulkMoveCiphers(request, env, userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Match /api/ciphers/:id patterns
|
||||
const cipherMatch = path.match(/^\/api\/ciphers\/([a-f0-9-]+)(\/.*)?$/i);
|
||||
if (cipherMatch) {
|
||||
const cipherId = cipherMatch[1];
|
||||
const subPath = cipherMatch[2] || '';
|
||||
|
||||
if (subPath === '' || subPath === '/') {
|
||||
if (method === 'GET') return handleGetCipher(request, env, userId, cipherId);
|
||||
if (method === 'PUT' || method === 'POST') return handleUpdateCipher(request, env, userId, cipherId);
|
||||
if (method === 'DELETE') return handleDeleteCipherCompat(request, env, userId, cipherId);
|
||||
}
|
||||
|
||||
if (subPath === '/delete' && method === 'PUT') {
|
||||
return handleDeleteCipher(request, env, userId, cipherId);
|
||||
}
|
||||
|
||||
if (subPath === '/delete' && method === 'DELETE') {
|
||||
return handlePermanentDeleteCipher(request, env, userId, cipherId);
|
||||
}
|
||||
|
||||
if (subPath === '/restore' && method === 'PUT') {
|
||||
return handleRestoreCipher(request, env, userId, cipherId);
|
||||
}
|
||||
|
||||
if (subPath === '/partial' && (method === 'PUT' || method === 'POST')) {
|
||||
return handlePartialUpdateCipher(request, env, userId, cipherId);
|
||||
}
|
||||
|
||||
// Share endpoint - just return the cipher (single user mode)
|
||||
if (subPath === '/share' && method === 'POST') {
|
||||
return handleGetCipher(request, env, userId, cipherId);
|
||||
}
|
||||
|
||||
if (subPath === '/details' && method === 'GET') {
|
||||
return handleGetCipher(request, env, userId, cipherId);
|
||||
}
|
||||
|
||||
// Attachment endpoints
|
||||
// POST /api/ciphers/{id}/attachment/v2 - Create attachment metadata
|
||||
if (subPath === '/attachment/v2' && method === 'POST') {
|
||||
return handleCreateAttachment(request, env, userId, cipherId);
|
||||
}
|
||||
|
||||
// Legacy attachment endpoint - also goes to v2 flow
|
||||
if (subPath === '/attachment' && method === 'POST') {
|
||||
return handleCreateAttachment(request, env, userId, cipherId);
|
||||
}
|
||||
|
||||
// Match /api/ciphers/{id}/attachment/{attachmentId}
|
||||
const attachmentMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)$/i);
|
||||
if (attachmentMatch) {
|
||||
const attachmentId = attachmentMatch[1];
|
||||
if (method === 'POST') return handleUploadAttachment(request, env, userId, cipherId, attachmentId);
|
||||
if (method === 'GET') return handleGetAttachment(request, env, userId, cipherId, attachmentId);
|
||||
if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
|
||||
}
|
||||
|
||||
// DELETE via POST (legacy)
|
||||
const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i);
|
||||
if (attachmentDeleteMatch && method === 'POST') {
|
||||
const attachmentId = attachmentDeleteMatch[1];
|
||||
return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
|
||||
}
|
||||
}
|
||||
|
||||
// Folder endpoints
|
||||
if (path === '/api/folders') {
|
||||
if (method === 'GET') return handleGetFolders(request, env, userId);
|
||||
if (method === 'POST') return handleCreateFolder(request, env, userId);
|
||||
}
|
||||
if (path === '/api/folders/delete' && method === 'POST') {
|
||||
return handleBulkDeleteFolders(request, env, userId);
|
||||
}
|
||||
|
||||
// Match /api/folders/:id patterns
|
||||
const folderMatch = path.match(/^\/api\/folders\/([a-f0-9-]+)$/i);
|
||||
if (folderMatch) {
|
||||
const folderId = folderMatch[1];
|
||||
if (method === 'GET') return handleGetFolder(request, env, userId, folderId);
|
||||
if (method === 'PUT') return handleUpdateFolder(request, env, userId, folderId);
|
||||
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
|
||||
}
|
||||
|
||||
// Auth requests endpoint (stub - we don't support passwordless login)
|
||||
if (path.startsWith('/api/auth-requests')) {
|
||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||
}
|
||||
|
||||
// Collections endpoint (stub - no organization support)
|
||||
if (path === '/api/collections' || path.startsWith('/api/collections/')) {
|
||||
if (method === 'GET') {
|
||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||
}
|
||||
}
|
||||
|
||||
// Organizations endpoint (stub - no organization support)
|
||||
if (path === '/api/organizations' || path.startsWith('/api/organizations/')) {
|
||||
if (method === 'GET') {
|
||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||
}
|
||||
}
|
||||
|
||||
// Send endpoints
|
||||
if (path === '/api/sends') {
|
||||
if (method === 'GET') return handleGetSends(request, env, userId);
|
||||
if (method === 'POST') return handleCreateSend(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/sends/delete' && method === 'POST') {
|
||||
return handleBulkDeleteSends(request, env, userId);
|
||||
}
|
||||
|
||||
if ((path === '/api/sends/file/v2' || path === '/api/sends/file') && method === 'POST') {
|
||||
return handleCreateFileSendV2(request, env, userId);
|
||||
}
|
||||
|
||||
const sendMatch = path.match(/^\/api\/sends\/([^/]+)(\/.*)?$/i);
|
||||
if (sendMatch) {
|
||||
const sendId = sendMatch[1];
|
||||
const subPath = sendMatch[2] || '';
|
||||
|
||||
if (subPath === '' || subPath === '/') {
|
||||
if (method === 'GET') return handleGetSend(request, env, userId, sendId);
|
||||
if (method === 'PUT') return handleUpdateSend(request, env, userId, sendId);
|
||||
if (method === 'DELETE') return handleDeleteSend(request, env, userId, sendId);
|
||||
}
|
||||
|
||||
if (subPath === '/remove-password' && (method === 'PUT' || method === 'POST')) {
|
||||
return handleRemoveSendPassword(request, env, userId, sendId);
|
||||
}
|
||||
|
||||
if (subPath === '/remove-auth' && (method === 'PUT' || method === 'POST')) {
|
||||
return handleRemoveSendAuth(request, env, userId, sendId);
|
||||
}
|
||||
|
||||
const sendFileUploadMatch = subPath.match(/^\/file\/([^/]+)\/?$/i);
|
||||
if (sendFileUploadMatch) {
|
||||
const fileId = sendFileUploadMatch[1];
|
||||
if (method === 'GET') return handleGetSendFileUpload(request, env, userId, sendId, fileId);
|
||||
if (method === 'POST' || method === 'PUT') return handleUploadSendFile(request, env, userId, sendId, fileId);
|
||||
}
|
||||
}
|
||||
|
||||
// Policies endpoint (stub - not implemented)
|
||||
if (path === '/api/policies' || path.startsWith('/api/policies/')) {
|
||||
if (method === 'GET') {
|
||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||
}
|
||||
}
|
||||
|
||||
// Settings domains endpoint (stub)
|
||||
if (path === '/api/settings/domains') {
|
||||
if (method === 'GET') {
|
||||
return jsonResponse({
|
||||
equivalentDomains: [],
|
||||
globalEquivalentDomains: [],
|
||||
object: 'domains',
|
||||
});
|
||||
}
|
||||
if (method === 'PUT' || method === 'POST') {
|
||||
return jsonResponse({
|
||||
equivalentDomains: [],
|
||||
globalEquivalentDomains: [],
|
||||
object: 'domains',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const authenticatedDeviceResponse = await handleAuthenticatedDeviceRoute(request, env, userId, path, method);
|
||||
if (authenticatedDeviceResponse) return authenticatedDeviceResponse;
|
||||
|
||||
const adminResponse = await handleAdminRoute(request, env, currentUser, path, method);
|
||||
if (adminResponse) return adminResponse;
|
||||
|
||||
// Not found
|
||||
return errorResponse('Not found', 404);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Request error:', error);
|
||||
return errorResponse('Internal server error', 500);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { AuditLog, Invite } from '../types';
|
||||
|
||||
export async function createInvite(db: D1Database, invite: Invite): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO invites(code, created_by, used_by, expires_at, status, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
.bind(invite.code, invite.createdBy, invite.usedBy, invite.expiresAt, invite.status, invite.createdAt, invite.updatedAt)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function getInvite(db: D1Database, code: string): Promise<Invite | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT code, created_by, used_by, expires_at, status, created_at, updated_at FROM invites WHERE code = ?')
|
||||
.bind(code)
|
||||
.first<any>();
|
||||
if (!row) return null;
|
||||
return {
|
||||
code: row.code,
|
||||
createdBy: row.created_by,
|
||||
usedBy: row.used_by ?? null,
|
||||
expiresAt: row.expires_at,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listInvites(db: D1Database, includeInactive: boolean = false): Promise<Invite[]> {
|
||||
const now = new Date().toISOString();
|
||||
const predicate = includeInactive
|
||||
? '1 = 1'
|
||||
: "(status = 'active' AND expires_at > ?)";
|
||||
const query =
|
||||
'SELECT code, created_by, used_by, expires_at, status, created_at, updated_at FROM invites ' +
|
||||
`WHERE ${predicate} ORDER BY created_at DESC`;
|
||||
const res = includeInactive
|
||||
? await db.prepare(query).all<any>()
|
||||
: await db.prepare(query).bind(now).all<any>();
|
||||
|
||||
return (res.results || []).map((row) => ({
|
||||
code: row.code,
|
||||
createdBy: row.created_by,
|
||||
usedBy: row.used_by ?? null,
|
||||
expiresAt: row.expires_at,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function markInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare(
|
||||
"UPDATE invites SET status = 'used', used_by = ?, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?"
|
||||
)
|
||||
.bind(userId, now, code, now)
|
||||
.run();
|
||||
return (result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function revokeInvite(db: D1Database, code: string): Promise<boolean> {
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare("UPDATE invites SET status = 'revoked', updated_at = ? WHERE code = ? AND status = 'active'")
|
||||
.bind(now, code)
|
||||
.run();
|
||||
return (result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function deleteAllInvites(db: D1Database): Promise<number> {
|
||||
const result = await db.prepare('DELETE FROM invites').run();
|
||||
return Number(result.meta.changes ?? 0);
|
||||
}
|
||||
|
||||
export async function createAuditLog(db: D1Database, log: AuditLog): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO audit_logs(id, actor_user_id, action, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
.bind(log.id, log.actorUserId, log.action, log.targetType, log.targetId, log.metadata, log.createdAt)
|
||||
.run();
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { Attachment, Cipher } from '../types';
|
||||
|
||||
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
|
||||
type SqlChunkSize = (fixedBindCount: number) => number;
|
||||
type GetCipher = (id: string) => Promise<Cipher | null>;
|
||||
type SaveCipher = (cipher: Cipher) => Promise<void>;
|
||||
type UpdateRevisionDate = (userId: string) => Promise<string>;
|
||||
|
||||
export async function getAttachment(db: D1Database, id: string): Promise<Attachment | null> {
|
||||
const row = await 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,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveAttachment(db: D1Database, safeBind: SafeBind, attachment: Attachment): Promise<void> {
|
||||
const stmt = 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'
|
||||
);
|
||||
await safeBind(stmt, attachment.id, attachment.cipherId, attachment.fileName, attachment.size, attachment.sizeName, attachment.key).run();
|
||||
}
|
||||
|
||||
export async function deleteAttachment(db: D1Database, id: string): Promise<void> {
|
||||
await db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run();
|
||||
}
|
||||
|
||||
export async function getAttachmentsByCipher(db: D1Database, cipherId: string): Promise<Attachment[]> {
|
||||
const res = await 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,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getAttachmentsByCipherIds(
|
||||
db: D1Database,
|
||||
sqlChunkSize: SqlChunkSize,
|
||||
cipherIds: string[]
|
||||
): Promise<Map<string, Attachment[]>> {
|
||||
const grouped = new Map<string, Attachment[]>();
|
||||
if (cipherIds.length === 0) return grouped;
|
||||
|
||||
const uniqueCipherIds = [...new Set(cipherIds)];
|
||||
const chunkSize = sqlChunkSize(0);
|
||||
|
||||
for (let i = 0; i < uniqueCipherIds.length; i += chunkSize) {
|
||||
const chunk = uniqueCipherIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
const res = await db
|
||||
.prepare(`SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id IN (${placeholders})`)
|
||||
.bind(...chunk)
|
||||
.all<any>();
|
||||
|
||||
for (const row of res.results || []) {
|
||||
const item: Attachment = {
|
||||
id: row.id,
|
||||
cipherId: row.cipher_id,
|
||||
fileName: row.file_name,
|
||||
size: row.size,
|
||||
sizeName: row.size_name,
|
||||
key: row.key,
|
||||
};
|
||||
const list = grouped.get(item.cipherId);
|
||||
if (list) list.push(item);
|
||||
else grouped.set(item.cipherId, [item]);
|
||||
}
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export async function getAttachmentsByUserId(db: D1Database, userId: string): Promise<Map<string, Attachment[]>> {
|
||||
const grouped = new Map<string, Attachment[]>();
|
||||
const res = await db
|
||||
.prepare(
|
||||
`SELECT a.id, a.cipher_id, a.file_name, a.size, a.size_name, a.key
|
||||
FROM attachments a
|
||||
INNER JOIN ciphers c ON c.id = a.cipher_id
|
||||
WHERE c.user_id = ?`
|
||||
)
|
||||
.bind(userId)
|
||||
.all<any>();
|
||||
|
||||
for (const row of res.results || []) {
|
||||
const item: Attachment = {
|
||||
id: row.id,
|
||||
cipherId: row.cipher_id,
|
||||
fileName: row.file_name,
|
||||
size: row.size,
|
||||
sizeName: row.size_name,
|
||||
key: row.key,
|
||||
};
|
||||
const list = grouped.get(item.cipherId);
|
||||
if (list) list.push(item);
|
||||
else grouped.set(item.cipherId, [item]);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export async function addAttachmentToCipher(db: D1Database, cipherId: string, attachmentId: string): Promise<void> {
|
||||
await db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run();
|
||||
}
|
||||
|
||||
export async function removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise<void> {
|
||||
void cipherId;
|
||||
void attachmentId;
|
||||
}
|
||||
|
||||
export async function deleteAllAttachmentsByCipher(db: D1Database, cipherId: string): Promise<void> {
|
||||
await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run();
|
||||
}
|
||||
|
||||
export async function updateCipherRevisionDate(
|
||||
getCipherById: GetCipher,
|
||||
saveCipherRecord: SaveCipher,
|
||||
updateRevisionDate: UpdateRevisionDate,
|
||||
cipherId: string
|
||||
): Promise<{ userId: string; revisionDate: string } | null> {
|
||||
const cipher = await getCipherById(cipherId);
|
||||
if (!cipher) return null;
|
||||
cipher.updatedAt = new Date().toISOString();
|
||||
await saveCipherRecord(cipher);
|
||||
const revisionDate = await updateRevisionDate(cipher.userId);
|
||||
return { userId: cipher.userId, revisionDate };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
type ShouldRunPeriodicCleanup = (lastRunAt: number, intervalMs: number) => boolean;
|
||||
|
||||
export async function ensureUsedAttachmentDownloadTokenTable(db: D1Database): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' +
|
||||
'jti TEXT PRIMARY KEY, ' +
|
||||
'expires_at INTEGER NOT NULL' +
|
||||
')'
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function consumeAttachmentDownloadToken(
|
||||
db: D1Database,
|
||||
shouldRunPeriodicCleanup: ShouldRunPeriodicCleanup,
|
||||
lastCleanupAt: number,
|
||||
cleanupIntervalMs: number,
|
||||
jti: string,
|
||||
expUnixSeconds: number
|
||||
): Promise<{ consumed: boolean; cleanedUpAt: number | null }> {
|
||||
const nowMs = Date.now();
|
||||
let cleanedUpAt: number | null = null;
|
||||
|
||||
if (shouldRunPeriodicCleanup(lastCleanupAt, cleanupIntervalMs)) {
|
||||
await db
|
||||
.prepare('DELETE FROM used_attachment_download_tokens WHERE expires_at < ?')
|
||||
.bind(nowMs)
|
||||
.run();
|
||||
cleanedUpAt = nowMs;
|
||||
}
|
||||
|
||||
const expiresAtMs = expUnixSeconds * 1000;
|
||||
const result = await db
|
||||
.prepare(
|
||||
'INSERT INTO used_attachment_download_tokens(jti, expires_at) VALUES(?, ?) ' +
|
||||
'ON CONFLICT(jti) DO NOTHING'
|
||||
)
|
||||
.bind(jti, expiresAtMs)
|
||||
.run();
|
||||
|
||||
return {
|
||||
consumed: (result.meta.changes ?? 0) > 0,
|
||||
cleanedUpAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import type { Cipher } from '../types';
|
||||
|
||||
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
|
||||
type SqlChunkSize = (fixedBindCount: number) => number;
|
||||
type UpdateRevisionDate = (userId: string) => Promise<string>;
|
||||
|
||||
export async function getCipher(db: D1Database, id: string): Promise<Cipher | null> {
|
||||
const row = await db.prepare('SELECT data FROM ciphers WHERE id = ?').bind(id).first<{ data: string }>();
|
||||
if (!row?.data) return null;
|
||||
try {
|
||||
return JSON.parse(row.data) as Cipher;
|
||||
} catch {
|
||||
console.error('Corrupted cipher data, id:', id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'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'
|
||||
);
|
||||
await safeBind(
|
||||
stmt,
|
||||
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();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
export async function bulkSoftDeleteCiphers(
|
||||
db: D1Database,
|
||||
sqlChunkSize: SqlChunkSize,
|
||||
updateRevisionDate: UpdateRevisionDate,
|
||||
ids: string[],
|
||||
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)));
|
||||
if (!uniqueIds.length) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const patch = JSON.stringify({ deletedAt: 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 deleted_at = ?, updated_at = ?, data = json_patch(data, ?)
|
||||
WHERE user_id = ? AND id IN (${placeholders})`
|
||||
)
|
||||
.bind(now, now, patch, userId, ...chunk)
|
||||
.run();
|
||||
}
|
||||
|
||||
return updateRevisionDate(userId);
|
||||
}
|
||||
|
||||
export async function bulkRestoreCiphers(
|
||||
db: D1Database,
|
||||
sqlChunkSize: SqlChunkSize,
|
||||
updateRevisionDate: UpdateRevisionDate,
|
||||
ids: string[],
|
||||
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)));
|
||||
if (!uniqueIds.length) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const patch = JSON.stringify({ deletedAt: 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 deleted_at = NULL, updated_at = ?, data = json_patch(data, ?)
|
||||
WHERE user_id = ? AND id IN (${placeholders})`
|
||||
)
|
||||
.bind(now, patch, userId, ...chunk)
|
||||
.run();
|
||||
}
|
||||
|
||||
return updateRevisionDate(userId);
|
||||
}
|
||||
|
||||
export async function bulkDeleteCiphers(
|
||||
db: D1Database,
|
||||
sqlChunkSize: SqlChunkSize,
|
||||
updateRevisionDate: UpdateRevisionDate,
|
||||
ids: string[],
|
||||
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)));
|
||||
if (!uniqueIds.length) return null;
|
||||
|
||||
const chunkSize = sqlChunkSize(1);
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
await db.prepare(`DELETE FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`).bind(userId, ...chunk).run();
|
||||
}
|
||||
|
||||
return updateRevisionDate(userId);
|
||||
}
|
||||
|
||||
export async function getAllCiphers(db: D1Database, userId: string): Promise<Cipher[]> {
|
||||
const res = await db.prepare('SELECT data FROM ciphers WHERE user_id = ? ORDER BY updated_at DESC').bind(userId).all<{ data: string }>();
|
||||
return (res.results || []).flatMap((r) => {
|
||||
try {
|
||||
return [JSON.parse(r.data) as Cipher];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCiphersPage(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
includeDeleted: boolean,
|
||||
limit: number,
|
||||
offset: number
|
||||
): Promise<Cipher[]> {
|
||||
const whereDeleted = includeDeleted ? '' : 'AND deleted_at IS NULL';
|
||||
const res = await db
|
||||
.prepare(
|
||||
`SELECT data FROM ciphers
|
||||
WHERE user_id = ?
|
||||
${whereDeleted}
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
)
|
||||
.bind(userId, limit, offset)
|
||||
.all<{ data: string }>();
|
||||
return (res.results || []).flatMap((r) => {
|
||||
try {
|
||||
return [JSON.parse(r.data) as Cipher];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCiphersByIds(
|
||||
db: D1Database,
|
||||
sqlChunkSize: SqlChunkSize,
|
||||
ids: string[],
|
||||
userId: string
|
||||
): Promise<Cipher[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
if (!uniqueIds.length) return [];
|
||||
|
||||
const chunkSize = sqlChunkSize(1);
|
||||
const out: Cipher[] = [];
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
const stmt = db.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`);
|
||||
const res = await stmt.bind(userId, ...chunk).all<{ data: string }>();
|
||||
out.push(
|
||||
...(res.results || []).flatMap((r) => {
|
||||
try {
|
||||
return [JSON.parse(r.data) as Cipher];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function bulkMoveCiphers(
|
||||
db: D1Database,
|
||||
sqlChunkSize: SqlChunkSize,
|
||||
updateRevisionDate: UpdateRevisionDate,
|
||||
ids: string[],
|
||||
folderId: string | null,
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
if (ids.length === 0) return null;
|
||||
const now = new Date().toISOString();
|
||||
const uniqueIds = Array.from(new Set(ids));
|
||||
const patch = JSON.stringify({ folderId, 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 folder_id = ?, updated_at = ?, data = json_patch(data, ?)
|
||||
WHERE user_id = ? AND id IN (${placeholders})`
|
||||
)
|
||||
.bind(folderId, now, patch, userId, ...chunk)
|
||||
.run();
|
||||
}
|
||||
|
||||
return updateRevisionDate(userId);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export async function isRegistered(db: D1Database): Promise<boolean> {
|
||||
const row = await db.prepare('SELECT value FROM config WHERE key = ?').bind('registered').first<{ value: string }>();
|
||||
return row?.value === 'true';
|
||||
}
|
||||
|
||||
export async function getConfigValue(db: D1Database, key: string): Promise<string | null> {
|
||||
const row = await db.prepare('SELECT value FROM config WHERE key = ?').bind(key).first<{ value: string }>();
|
||||
return typeof row?.value === 'string' ? row.value : null;
|
||||
}
|
||||
|
||||
export async function setConfigValue(db: D1Database, key: string, value: string): Promise<void> {
|
||||
await db
|
||||
.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
||||
.bind(key, value)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function setRegistered(db: D1Database): Promise<void> {
|
||||
await db.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
||||
.bind('registered', 'true')
|
||||
.run();
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { Device, TrustedDeviceTokenSummary, User } from '../types';
|
||||
|
||||
type GetUserByEmail = (email: string) => Promise<User | null>;
|
||||
type TrustedTokenKeyFn = (token: string) => Promise<string>;
|
||||
|
||||
function mapDeviceRow(row: any): Device {
|
||||
return {
|
||||
userId: row.user_id,
|
||||
deviceIdentifier: row.device_identifier,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
sessionStamp: row.session_stamp || '',
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function upsertDevice(
|
||||
db: D1Database,
|
||||
getDeviceById: (userId: string, deviceIdentifier: string) => Promise<Device | null>,
|
||||
userId: string,
|
||||
deviceIdentifier: string,
|
||||
name: string,
|
||||
type: number,
|
||||
sessionStamp?: string
|
||||
): 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'
|
||||
)
|
||||
.bind(userId, deviceIdentifier, name, type, effectiveSessionStamp, now, now)
|
||||
.run();
|
||||
}
|
||||
|
||||
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')
|
||||
.bind(userId, deviceIdentifier)
|
||||
.first<{ '1': number }>();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export async function isKnownDeviceByEmail(
|
||||
getUserByEmail: GetUserByEmail,
|
||||
isKnownDeviceForUser: (userId: string, deviceIdentifier: string) => Promise<boolean>,
|
||||
email: string,
|
||||
deviceIdentifier: string
|
||||
): Promise<boolean> {
|
||||
const user = await getUserByEmail(email);
|
||||
if (!user) return false;
|
||||
return isKnownDeviceForUser(user.id, deviceIdentifier);
|
||||
}
|
||||
|
||||
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 ' +
|
||||
'FROM devices WHERE user_id = ? ORDER BY updated_at DESC'
|
||||
)
|
||||
.bind(userId)
|
||||
.all<any>();
|
||||
return (res.results || []).map(mapDeviceRow);
|
||||
}
|
||||
|
||||
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 ' +
|
||||
'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
|
||||
)
|
||||
.bind(userId, deviceIdentifier)
|
||||
.first<any>();
|
||||
return row ? mapDeviceRow(row) : null;
|
||||
}
|
||||
|
||||
export async function deleteDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||
const result = await db
|
||||
.prepare('DELETE FROM devices WHERE user_id = ? AND device_identifier = ?')
|
||||
.bind(userId, deviceIdentifier)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function deleteDevicesByUserId(db: D1Database, userId: string): Promise<number> {
|
||||
const result = await db.prepare('DELETE FROM devices WHERE user_id = ?').bind(userId).run();
|
||||
return Number(result.meta.changes ?? 0);
|
||||
}
|
||||
|
||||
export async function getTrustedDeviceTokenSummariesByUserId(db: D1Database, userId: string): Promise<TrustedDeviceTokenSummary[]> {
|
||||
const now = Date.now();
|
||||
await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(now).run();
|
||||
|
||||
const res = await db
|
||||
.prepare(
|
||||
'SELECT device_identifier, MAX(expires_at) AS expires_at, COUNT(*) AS token_count ' +
|
||||
'FROM trusted_two_factor_device_tokens WHERE user_id = ? GROUP BY device_identifier ORDER BY expires_at DESC'
|
||||
)
|
||||
.bind(userId)
|
||||
.all<any>();
|
||||
|
||||
return (res.results || []).map((row) => ({
|
||||
deviceIdentifier: row.device_identifier,
|
||||
expiresAt: Number(row.expires_at || 0),
|
||||
tokenCount: Number(row.token_count || 0),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function deleteTrustedTwoFactorTokensByDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<number> {
|
||||
const result = await db
|
||||
.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE user_id = ? AND device_identifier = ?')
|
||||
.bind(userId, deviceIdentifier)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0);
|
||||
}
|
||||
|
||||
export async function deleteTrustedTwoFactorTokensByUserId(db: D1Database, userId: string): Promise<number> {
|
||||
const result = await db
|
||||
.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE user_id = ?')
|
||||
.bind(userId)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0);
|
||||
}
|
||||
|
||||
export async function saveTrustedTwoFactorDeviceToken(
|
||||
db: D1Database,
|
||||
trustedTokenKey: TrustedTokenKeyFn,
|
||||
token: string,
|
||||
userId: string,
|
||||
deviceIdentifier: string,
|
||||
expiresAtMs: number
|
||||
): Promise<void> {
|
||||
const tokenKey = await trustedTokenKey(token);
|
||||
await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(Date.now()).run();
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO trusted_two_factor_device_tokens(token, user_id, device_identifier, expires_at) VALUES(?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, device_identifier=excluded.device_identifier, expires_at=excluded.expires_at'
|
||||
)
|
||||
.bind(tokenKey, userId, deviceIdentifier, expiresAtMs)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function getTrustedTwoFactorDeviceTokenUserId(
|
||||
db: D1Database,
|
||||
trustedTokenKey: TrustedTokenKeyFn,
|
||||
token: string,
|
||||
deviceIdentifier: string
|
||||
): Promise<string | null> {
|
||||
const now = Date.now();
|
||||
const tokenKey = await trustedTokenKey(token);
|
||||
const row = await db
|
||||
.prepare('SELECT user_id, expires_at FROM trusted_two_factor_device_tokens WHERE token = ? AND device_identifier = ?')
|
||||
.bind(tokenKey, deviceIdentifier)
|
||||
.first<{ user_id: string; expires_at: number }>();
|
||||
|
||||
if (!row) return null;
|
||||
if (row.expires_at && row.expires_at < now) {
|
||||
await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE token = ?').bind(tokenKey).run();
|
||||
return null;
|
||||
}
|
||||
return row.user_id;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { Cipher, Folder } from '../types';
|
||||
|
||||
function mapFolderRow(row: any): Folder {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
name: row.name,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getFolder(db: D1Database, id: string): Promise<Folder | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE id = ?')
|
||||
.bind(id)
|
||||
.first<any>();
|
||||
if (!row) return null;
|
||||
return mapFolderRow(row);
|
||||
}
|
||||
|
||||
export async function saveFolder(db: D1Database, folder: Folder): Promise<void> {
|
||||
await 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();
|
||||
}
|
||||
|
||||
export async function deleteFolder(db: D1Database, id: string, userId: string): Promise<void> {
|
||||
await db.prepare('DELETE FROM folders WHERE id = ? AND user_id = ?').bind(id, userId).run();
|
||||
}
|
||||
|
||||
export async function clearFolderFromCiphers(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
folderId: string,
|
||||
saveCipher: (cipher: Cipher) => Promise<void>
|
||||
): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
const res = await db
|
||||
.prepare('SELECT data FROM ciphers WHERE user_id = ? AND folder_id = ?')
|
||||
.bind(userId, folderId)
|
||||
.all<{ data: string }>();
|
||||
|
||||
for (const row of (res.results || [])) {
|
||||
let cipher: Cipher;
|
||||
try {
|
||||
cipher = JSON.parse(row.data) as Cipher;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
cipher.folderId = null;
|
||||
cipher.updatedAt = now;
|
||||
await saveCipher(cipher);
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkDeleteFolders(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
ids: string[],
|
||||
sqlChunkSize: (fixedBindCount: number) => number,
|
||||
saveCipher: (cipher: Cipher) => Promise<void>,
|
||||
updateRevisionDate: (userId: string) => Promise<string>
|
||||
): Promise<string | null> {
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
if (!uniqueIds.length) return null;
|
||||
|
||||
const chunkSize = sqlChunkSize(1);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
const res = await db
|
||||
.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND folder_id IN (${placeholders})`)
|
||||
.bind(userId, ...chunk)
|
||||
.all<{ data: string }>();
|
||||
|
||||
for (const row of res.results || []) {
|
||||
let cipher: Cipher;
|
||||
try {
|
||||
cipher = JSON.parse(row.data) as Cipher;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
cipher.folderId = null;
|
||||
cipher.updatedAt = now;
|
||||
await saveCipher(cipher);
|
||||
}
|
||||
|
||||
await db
|
||||
.prepare(`DELETE FROM folders WHERE user_id = ? AND id IN (${placeholders})`)
|
||||
.bind(userId, ...chunk)
|
||||
.run();
|
||||
}
|
||||
|
||||
return updateRevisionDate(userId);
|
||||
}
|
||||
|
||||
export async function getAllFolders(db: D1Database, userId: string): Promise<Folder[]> {
|
||||
const res = await 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((row) => mapFolderRow(row));
|
||||
}
|
||||
|
||||
export async function getFoldersPage(db: D1Database, userId: string, limit: number, offset: number): Promise<Folder[]> {
|
||||
const res = await db
|
||||
.prepare(
|
||||
'SELECT id, user_id, name, created_at, updated_at FROM folders WHERE user_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?'
|
||||
)
|
||||
.bind(userId, limit, offset)
|
||||
.all<any>();
|
||||
return (res.results || []).map((row) => mapFolderRow(row));
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { RefreshTokenRecord } from '../types';
|
||||
|
||||
type RefreshTokenKeyFn = (token: string) => Promise<string>;
|
||||
type CleanupExpiredFn = (nowMs: number) => Promise<void>;
|
||||
|
||||
export async function saveRefreshToken(
|
||||
db: D1Database,
|
||||
refreshTokenKey: RefreshTokenKeyFn,
|
||||
maybeCleanupExpiredRefreshTokens: CleanupExpiredFn,
|
||||
token: string,
|
||||
userId: string,
|
||||
expiresAtMs: number,
|
||||
deviceIdentifier?: string | null,
|
||||
deviceSessionStamp?: string | null
|
||||
): Promise<void> {
|
||||
await maybeCleanupExpiredRefreshTokens(Date.now());
|
||||
const tokenKey = await refreshTokenKey(token);
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO refresh_tokens(token, user_id, expires_at, device_identifier, device_session_stamp) VALUES(?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at, device_identifier=excluded.device_identifier, device_session_stamp=excluded.device_session_stamp'
|
||||
)
|
||||
.bind(tokenKey, userId, expiresAtMs, deviceIdentifier ?? null, deviceSessionStamp ?? null)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function getRefreshTokenRecord(
|
||||
db: D1Database,
|
||||
refreshTokenKey: RefreshTokenKeyFn,
|
||||
maybeCleanupExpiredRefreshTokens: CleanupExpiredFn,
|
||||
saveRefreshTokenRecord: (
|
||||
token: string,
|
||||
userId: string,
|
||||
expiresAtMs?: number,
|
||||
deviceIdentifier?: string | null,
|
||||
deviceSessionStamp?: string | null
|
||||
) => Promise<void>,
|
||||
deleteRefreshTokenRecord: (token: string) => Promise<void>,
|
||||
token: string
|
||||
): Promise<RefreshTokenRecord | null> {
|
||||
const now = Date.now();
|
||||
await maybeCleanupExpiredRefreshTokens(now);
|
||||
const tokenKey = await refreshTokenKey(token);
|
||||
|
||||
let row = await db
|
||||
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
|
||||
.bind(tokenKey)
|
||||
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
|
||||
|
||||
if (!row) {
|
||||
const legacyRow = await db
|
||||
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
|
||||
.bind(token)
|
||||
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
|
||||
|
||||
if (legacyRow) {
|
||||
if (legacyRow.expires_at && legacyRow.expires_at < now) {
|
||||
await deleteRefreshTokenRecord(token);
|
||||
return null;
|
||||
}
|
||||
await saveRefreshTokenRecord(
|
||||
token,
|
||||
legacyRow.user_id,
|
||||
legacyRow.expires_at,
|
||||
legacyRow.device_identifier ?? null,
|
||||
legacyRow.device_session_stamp ?? null
|
||||
);
|
||||
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
|
||||
return {
|
||||
userId: legacyRow.user_id,
|
||||
expiresAt: legacyRow.expires_at,
|
||||
deviceIdentifier: legacyRow.device_identifier ?? null,
|
||||
deviceSessionStamp: legacyRow.device_session_stamp ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!row) return null;
|
||||
if (row.expires_at && row.expires_at < now) {
|
||||
await deleteRefreshTokenRecord(token);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
userId: row.user_id,
|
||||
expiresAt: row.expires_at,
|
||||
deviceIdentifier: row.device_identifier ?? null,
|
||||
deviceSessionStamp: row.device_session_stamp ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteRefreshToken(db: D1Database, refreshTokenKey: RefreshTokenKeyFn, token: string): Promise<void> {
|
||||
const tokenKey = await refreshTokenKey(token);
|
||||
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
|
||||
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(tokenKey).run();
|
||||
}
|
||||
|
||||
export async function deleteRefreshTokensByUserId(db: D1Database, userId: string): Promise<number> {
|
||||
const result = await db.prepare('DELETE FROM refresh_tokens WHERE user_id = ?').bind(userId).run();
|
||||
return Number(result.meta.changes ?? 0);
|
||||
}
|
||||
|
||||
export async function deleteRefreshTokensByDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<number> {
|
||||
const result = await db
|
||||
.prepare('DELETE FROM refresh_tokens WHERE user_id = ? AND device_identifier = ?')
|
||||
.bind(userId, deviceIdentifier)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0);
|
||||
}
|
||||
|
||||
export async function constrainRefreshTokenExpiry(
|
||||
db: D1Database,
|
||||
refreshTokenKey: RefreshTokenKeyFn,
|
||||
token: string,
|
||||
maxExpiresAtMs: number
|
||||
): Promise<void> {
|
||||
const tokenKey = await refreshTokenKey(token);
|
||||
|
||||
await db
|
||||
.prepare(
|
||||
'UPDATE refresh_tokens ' +
|
||||
'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' +
|
||||
'WHERE token = ?'
|
||||
)
|
||||
.bind(maxExpiresAtMs, maxExpiresAtMs, tokenKey)
|
||||
.run();
|
||||
|
||||
await db
|
||||
.prepare(
|
||||
'UPDATE refresh_tokens ' +
|
||||
'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' +
|
||||
'WHERE token = ?'
|
||||
)
|
||||
.bind(maxExpiresAtMs, maxExpiresAtMs, token)
|
||||
.run();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export async function getRevisionDate(db: D1Database, userId: string): Promise<string> {
|
||||
const row = await db
|
||||
.prepare('SELECT revision_date FROM user_revisions WHERE user_id = ?')
|
||||
.bind(userId)
|
||||
.first<{ revision_date: string }>();
|
||||
|
||||
if (row?.revision_date) return row.revision_date;
|
||||
|
||||
const date = new Date().toISOString();
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO user_revisions(user_id, revision_date) VALUES(?, ?) ' +
|
||||
'ON CONFLICT(user_id) DO NOTHING'
|
||||
)
|
||||
.bind(userId, date)
|
||||
.run();
|
||||
|
||||
return date;
|
||||
}
|
||||
|
||||
export async function updateRevisionDate(db: D1Database, userId: string): Promise<string> {
|
||||
const date = new Date().toISOString();
|
||||
await 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;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { Send } from '../types';
|
||||
|
||||
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
|
||||
type SqlChunkSize = (fixedBindCount: number) => number;
|
||||
type UpdateRevisionDate = (userId: string) => Promise<string>;
|
||||
|
||||
function mapSendRow(row: any): Send {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
type: row.type,
|
||||
name: row.name,
|
||||
notes: row.notes,
|
||||
data: row.data,
|
||||
key: row.key,
|
||||
passwordHash: row.password_hash,
|
||||
passwordSalt: row.password_salt,
|
||||
passwordIterations: row.password_iterations,
|
||||
authType: row.auth_type ?? 0,
|
||||
emails: row.emails ?? null,
|
||||
maxAccessCount: row.max_access_count,
|
||||
accessCount: row.access_count,
|
||||
disabled: !!row.disabled,
|
||||
hideEmail: row.hide_email === null || row.hide_email === undefined ? null : !!row.hide_email,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
expirationDate: row.expiration_date,
|
||||
deletionDate: row.deletion_date,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getSend(db: D1Database, id: string): Promise<Send | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE id = ?'
|
||||
)
|
||||
.bind(id)
|
||||
.first<any>();
|
||||
if (!row) return null;
|
||||
return mapSendRow(row);
|
||||
}
|
||||
|
||||
export async function saveSend(db: D1Database, safeBind: SafeBind, send: Send): Promise<void> {
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO sends(id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||
'user_id=excluded.user_id, type=excluded.type, name=excluded.name, notes=excluded.notes, data=excluded.data, key=excluded.key, ' +
|
||||
'password_hash=excluded.password_hash, password_salt=excluded.password_salt, password_iterations=excluded.password_iterations, auth_type=excluded.auth_type, emails=excluded.emails, ' +
|
||||
'max_access_count=excluded.max_access_count, access_count=excluded.access_count, disabled=excluded.disabled, hide_email=excluded.hide_email, ' +
|
||||
'updated_at=excluded.updated_at, expiration_date=excluded.expiration_date, deletion_date=excluded.deletion_date'
|
||||
);
|
||||
|
||||
await safeBind(
|
||||
stmt,
|
||||
send.id,
|
||||
send.userId,
|
||||
Number(send.type) || 0,
|
||||
send.name,
|
||||
send.notes,
|
||||
send.data,
|
||||
send.key,
|
||||
send.passwordHash,
|
||||
send.passwordSalt,
|
||||
send.passwordIterations,
|
||||
send.authType,
|
||||
send.emails,
|
||||
send.maxAccessCount,
|
||||
send.accessCount,
|
||||
send.disabled ? 1 : 0,
|
||||
send.hideEmail === null || send.hideEmail === undefined ? null : send.hideEmail ? 1 : 0,
|
||||
send.createdAt,
|
||||
send.updatedAt,
|
||||
send.expirationDate,
|
||||
send.deletionDate
|
||||
).run();
|
||||
}
|
||||
|
||||
export async function incrementSendAccessCount(db: D1Database, sendId: string): Promise<boolean> {
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare(
|
||||
'UPDATE sends SET access_count = access_count + 1, updated_at = ? ' +
|
||||
'WHERE id = ? AND (max_access_count IS NULL OR access_count < max_access_count)'
|
||||
)
|
||||
.bind(now, sendId)
|
||||
.run();
|
||||
return (result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function deleteSend(db: D1Database, id: string, userId: string): Promise<void> {
|
||||
await db.prepare('DELETE FROM sends WHERE id = ? AND user_id = ?').bind(id, userId).run();
|
||||
}
|
||||
|
||||
export async function getSendsByIds(
|
||||
db: D1Database,
|
||||
sqlChunkSize: SqlChunkSize,
|
||||
ids: string[],
|
||||
userId: string
|
||||
): Promise<Send[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
if (!uniqueIds.length) return [];
|
||||
const chunkSize = sqlChunkSize(1);
|
||||
const out: Send[] = [];
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
const res = await db
|
||||
.prepare(
|
||||
`SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date
|
||||
FROM sends
|
||||
WHERE user_id = ? AND id IN (${placeholders})`
|
||||
)
|
||||
.bind(userId, ...chunk)
|
||||
.all<any>();
|
||||
out.push(...(res.results || []).map((row) => mapSendRow(row)));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function bulkDeleteSends(
|
||||
db: D1Database,
|
||||
sqlChunkSize: SqlChunkSize,
|
||||
updateRevisionDate: UpdateRevisionDate,
|
||||
ids: string[],
|
||||
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)));
|
||||
if (!uniqueIds.length) return null;
|
||||
const chunkSize = sqlChunkSize(1);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
await db.prepare(`DELETE FROM sends WHERE user_id = ? AND id IN (${placeholders})`).bind(userId, ...chunk).run();
|
||||
}
|
||||
|
||||
return updateRevisionDate(userId);
|
||||
}
|
||||
|
||||
export async function getAllSends(db: D1Database, userId: string): Promise<Send[]> {
|
||||
const res = await db
|
||||
.prepare(
|
||||
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE user_id = ? ORDER BY updated_at DESC'
|
||||
)
|
||||
.bind(userId)
|
||||
.all<any>();
|
||||
return (res.results || []).map((row) => mapSendRow(row));
|
||||
}
|
||||
|
||||
export async function getSendsPage(db: D1Database, userId: string, limit: number, offset: number): Promise<Send[]> {
|
||||
const res = await db
|
||||
.prepare(
|
||||
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE user_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?'
|
||||
)
|
||||
.bind(userId, limit, offset)
|
||||
.all<any>();
|
||||
return (res.results || []).map((row) => mapSendRow(row));
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { User } from '../types';
|
||||
|
||||
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
|
||||
|
||||
function mapUserRow(row: any): User {
|
||||
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,
|
||||
role: row.role === 'admin' ? 'admin' : 'user',
|
||||
status: row.status === 'banned' ? 'banned' : 'active',
|
||||
totpSecret: row.totp_secret ?? null,
|
||||
totpRecoveryCode: row.totp_recovery_code ?? null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUser(db: D1Database, email: string): Promise<User | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT id, email, name, 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 = ?'
|
||||
)
|
||||
.bind(email.toLowerCase())
|
||||
.first<any>();
|
||||
if (!row) return null;
|
||||
return mapUserRow(row);
|
||||
}
|
||||
|
||||
export async function getUserById(db: D1Database, id: string): Promise<User | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT id, email, name, 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 = ?'
|
||||
)
|
||||
.bind(id)
|
||||
.first<any>();
|
||||
if (!row) return null;
|
||||
return mapUserRow(row);
|
||||
}
|
||||
|
||||
export async function getUserCount(db: D1Database): Promise<number> {
|
||||
const row = await db.prepare('SELECT COUNT(*) AS count FROM users').first<{ count: number }>();
|
||||
return Number(row?.count || 0);
|
||||
}
|
||||
|
||||
export async function getAllUsers(db: D1Database): Promise<User[]> {
|
||||
const res = await db
|
||||
.prepare(
|
||||
'SELECT id, email, name, 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'
|
||||
)
|
||||
.all<any>();
|
||||
return (res.results || []).map((row) => mapUserRow(row));
|
||||
}
|
||||
|
||||
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_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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'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, role=excluded.role, status=excluded.status, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at'
|
||||
);
|
||||
await safeBind(
|
||||
stmt,
|
||||
user.id,
|
||||
email,
|
||||
user.name,
|
||||
user.masterPasswordHash,
|
||||
user.key,
|
||||
user.privateKey,
|
||||
user.publicKey,
|
||||
user.kdfType,
|
||||
user.kdfIterations,
|
||||
user.kdfMemory,
|
||||
user.kdfParallelism,
|
||||
user.securityStamp,
|
||||
user.role,
|
||||
user.status,
|
||||
user.totpSecret,
|
||||
user.totpRecoveryCode,
|
||||
user.createdAt,
|
||||
user.updatedAt
|
||||
).run();
|
||||
}
|
||||
|
||||
export async function createUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
|
||||
await saveUser(db, safeBind, 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_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 ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
|
||||
);
|
||||
const result = await safeBind(
|
||||
stmt,
|
||||
user.id,
|
||||
email,
|
||||
user.name,
|
||||
user.masterPasswordHash,
|
||||
user.key,
|
||||
user.privateKey,
|
||||
user.publicKey,
|
||||
user.kdfType,
|
||||
user.kdfIterations,
|
||||
user.kdfMemory,
|
||||
user.kdfParallelism,
|
||||
user.securityStamp,
|
||||
user.role,
|
||||
user.status,
|
||||
user.totpSecret,
|
||||
user.totpRecoveryCode,
|
||||
user.createdAt,
|
||||
user.updatedAt
|
||||
).run();
|
||||
|
||||
return (result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function deleteUserById(db: D1Database, id: string): Promise<boolean> {
|
||||
const result = await db.prepare('DELETE FROM users WHERE id = ?').bind(id).run();
|
||||
return (result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
+213
-896
File diff suppressed because it is too large
Load Diff
+265
-1546
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
||||
import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||
import { Link } from 'wouter';
|
||||
import AppMainRoutes from '@/components/AppMainRoutes';
|
||||
import type { AppMainRoutesProps } from '@/components/AppMainRoutes';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { Profile } from '@/lib/types';
|
||||
|
||||
interface AppAuthenticatedShellProps {
|
||||
profile: Profile | null;
|
||||
location: string;
|
||||
mobilePrimaryRoute: string;
|
||||
currentPageTitle: string;
|
||||
showSidebarToggle: boolean;
|
||||
sidebarToggleTitle: string;
|
||||
settingsAccountRoute: string;
|
||||
importRoute: string;
|
||||
isImportRoute: boolean;
|
||||
onLock: () => void;
|
||||
onLogout: () => void;
|
||||
mainRoutesProps: AppMainRoutesProps;
|
||||
}
|
||||
|
||||
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
|
||||
return (
|
||||
<div className="app-page">
|
||||
<div className="app-shell">
|
||||
<header className="topbar">
|
||||
<div className="brand">
|
||||
<img src="/logo-64.png" alt="NodeWarden logo" className="brand-logo" />
|
||||
<span className="brand-name">NodeWarden</span>
|
||||
<span className="mobile-page-title">{props.currentPageTitle}</span>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<div className="user-chip">
|
||||
<ShieldUser size={16} />
|
||||
<span>{props.profile?.email}</span>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary small" onClick={props.onLock}>
|
||||
<Lock size={14} className="btn-icon" /> {t('txt_lock')}
|
||||
</button>
|
||||
{props.showSidebarToggle && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small mobile-sidebar-toggle"
|
||||
aria-label={props.sidebarToggleTitle}
|
||||
title={props.sidebarToggleTitle}
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('nodewarden:toggle-sidebar'))}
|
||||
>
|
||||
<FolderIcon size={16} className="btn-icon" />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-secondary small mobile-lock-btn" aria-label={t('txt_lock')} title={t('txt_lock')} onClick={props.onLock}>
|
||||
<Lock size={14} className="btn-icon" />
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary small" onClick={props.onLogout}>
|
||||
<LogOut size={14} className="btn-icon" /> {t('txt_sign_out')}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="app-main">
|
||||
<aside className="app-side">
|
||||
<Link href="/vault" className={`side-link ${props.location === '/vault' ? 'active' : ''}`}>
|
||||
<KeyRound size={16} />
|
||||
<span>{t('nav_my_vault')}</span>
|
||||
</Link>
|
||||
<Link href="/vault/totp" className={`side-link ${props.location === '/vault/totp' ? 'active' : ''}`}>
|
||||
<Clock3 size={16} />
|
||||
<span>{t('txt_verification_code')}</span>
|
||||
</Link>
|
||||
<Link href="/sends" className={`side-link ${props.location === '/sends' ? 'active' : ''}`}>
|
||||
<SendIcon size={16} />
|
||||
<span>{t('nav_sends')}</span>
|
||||
</Link>
|
||||
{props.profile?.role === 'admin' && (
|
||||
<Link href="/admin" className={`side-link ${props.location === '/admin' ? 'active' : ''}`}>
|
||||
<ShieldUser size={16} />
|
||||
<span>{t('nav_admin_panel')}</span>
|
||||
</Link>
|
||||
)}
|
||||
<Link href={props.settingsAccountRoute} className={`side-link ${props.location === props.settingsAccountRoute ? 'active' : ''}`}>
|
||||
<SettingsIcon size={16} />
|
||||
<span>{t('nav_account_settings')}</span>
|
||||
</Link>
|
||||
<Link href="/security/devices" className={`side-link ${props.location === '/security/devices' ? 'active' : ''}`}>
|
||||
<Shield size={16} />
|
||||
<span>{t('nav_device_management')}</span>
|
||||
</Link>
|
||||
{props.profile?.role === 'admin' && (
|
||||
<Link href="/help" className={`side-link ${props.location === '/help' ? 'active' : ''}`}>
|
||||
<Cloud size={16} />
|
||||
<span>{t('nav_backup_strategy')}</span>
|
||||
</Link>
|
||||
)}
|
||||
<Link href={props.importRoute} className={`side-link ${props.isImportRoute ? 'active' : ''}`}>
|
||||
<ArrowUpDown size={14} />
|
||||
<span>{t('nav_import_export')}</span>
|
||||
</Link>
|
||||
</aside>
|
||||
<main className="content">
|
||||
<AppMainRoutes {...props.mainRoutesProps} />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<nav className="mobile-tabbar" aria-label={t('txt_menu')}>
|
||||
<Link href="/vault" className={`mobile-tab ${props.mobilePrimaryRoute === '/vault' ? 'active' : ''}`}>
|
||||
<KeyRound size={18} />
|
||||
<span>{t('nav_my_vault')}</span>
|
||||
</Link>
|
||||
<Link href="/vault/totp" className={`mobile-tab ${props.mobilePrimaryRoute === '/vault/totp' ? 'active' : ''}`}>
|
||||
<Clock3 size={18} />
|
||||
<span>{t('txt_verification_code')}</span>
|
||||
</Link>
|
||||
<Link href="/sends" className={`mobile-tab ${props.mobilePrimaryRoute === '/sends' ? 'active' : ''}`}>
|
||||
<SendIcon size={18} />
|
||||
<span>{t('nav_sends')}</span>
|
||||
</Link>
|
||||
<Link href="/settings" className={`mobile-tab ${props.mobilePrimaryRoute === '/settings' ? 'active' : ''}`}>
|
||||
<SettingsIcon size={18} />
|
||||
<span>{t('txt_settings')}</span>
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import ToastHost from '@/components/ToastHost';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { ToastMessage } from '@/lib/types';
|
||||
|
||||
export interface AppConfirmState {
|
||||
title: string;
|
||||
message: string;
|
||||
danger?: boolean;
|
||||
showIcon?: boolean;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
interface AppGlobalOverlaysProps {
|
||||
toasts: ToastMessage[];
|
||||
onCloseToast: (id: string) => void;
|
||||
confirm: AppConfirmState | null;
|
||||
onCancelConfirm: () => void;
|
||||
pendingTotpOpen: boolean;
|
||||
totpCode: string;
|
||||
rememberDevice: boolean;
|
||||
onTotpCodeChange: (value: string) => void;
|
||||
onRememberDeviceChange: (checked: boolean) => void;
|
||||
onConfirmTotp: () => void;
|
||||
onCancelTotp: () => void;
|
||||
onUseRecoveryCode: () => void;
|
||||
disableTotpOpen: boolean;
|
||||
disableTotpPassword: string;
|
||||
onDisableTotpPasswordChange: (value: string) => void;
|
||||
onConfirmDisableTotp: () => void;
|
||||
onCancelDisableTotp: () => void;
|
||||
}
|
||||
|
||||
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
return (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
open={!!props.confirm}
|
||||
title={props.confirm?.title || ''}
|
||||
message={props.confirm?.message || ''}
|
||||
danger={props.confirm?.danger}
|
||||
showIcon={props.confirm?.showIcon}
|
||||
onConfirm={() => props.confirm?.onConfirm()}
|
||||
onCancel={props.onCancelConfirm}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={props.pendingTotpOpen}
|
||||
title={t('txt_two_step_verification')}
|
||||
message={t('txt_password_is_already_verified')}
|
||||
confirmText={t('txt_verify')}
|
||||
cancelText={t('txt_cancel')}
|
||||
showIcon={false}
|
||||
onConfirm={props.onConfirmTotp}
|
||||
onCancel={props.onCancelTotp}
|
||||
afterActions={(
|
||||
<div className="dialog-extra">
|
||||
<div className="dialog-divider" />
|
||||
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onUseRecoveryCode}>
|
||||
{t('txt_use_recovery_code')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_totp_code')}</span>
|
||||
<input className="input" value={props.totpCode} onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<label className="check-line" style={{ marginBottom: 0 }}>
|
||||
<input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} />
|
||||
<span>{t('txt_trust_this_device_for_30_days')}</span>
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={props.disableTotpOpen}
|
||||
title={t('txt_disable_totp')}
|
||||
message={t('txt_enter_master_password_to_disable_two_step_verification')}
|
||||
confirmText={t('txt_disable_totp')}
|
||||
cancelText={t('txt_cancel')}
|
||||
danger
|
||||
showIcon={false}
|
||||
onConfirm={props.onConfirmDisableTotp}
|
||||
onCancel={props.onCancelDisableTotp}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_master_password')}</span>
|
||||
<input className="input" type="password" value={props.disableTotpPassword} onInput={(e) => props.onDisableTotpPasswordChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ToastHost toasts={props.toasts} onClose={props.onCloseToast} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,15 +2,15 @@ import { lazy, Suspense } from 'preact/compat';
|
||||
import { Link, Route, Switch } from 'wouter';
|
||||
import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||
import SendsPage from '@/components/SendsPage';
|
||||
import TotpCodesPage from '@/components/TotpCodesPage';
|
||||
import VaultPage from '@/components/VaultPage';
|
||||
import type { AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
|
||||
import type { ExportRequest } from '@/lib/export-formats';
|
||||
|
||||
const SendsPage = lazy(() => import('@/components/SendsPage'));
|
||||
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
|
||||
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
||||
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
|
||||
const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage'));
|
||||
const AdminPage = lazy(() => import('@/components/AdminPage'));
|
||||
@@ -21,7 +21,7 @@ function RouteContentFallback() {
|
||||
return <div className="loading-screen">{t('txt_loading_nodewarden')}</div>;
|
||||
}
|
||||
|
||||
interface AppMainRoutesProps {
|
||||
export interface AppMainRoutesProps {
|
||||
profile: Profile | null;
|
||||
session: SessionState | null;
|
||||
mobileLayout: boolean;
|
||||
@@ -128,41 +128,47 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
return (
|
||||
<Switch>
|
||||
<Route path="/sends">
|
||||
<SendsPage
|
||||
sends={props.decryptedSends}
|
||||
loading={props.sendsLoading}
|
||||
onRefresh={props.onRefreshVault}
|
||||
onCreate={props.onCreateSend}
|
||||
onUpdate={props.onUpdateSend}
|
||||
onDelete={props.onDeleteSend}
|
||||
onBulkDelete={props.onBulkDeleteSends}
|
||||
onNotify={props.onNotify}
|
||||
/>
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<SendsPage
|
||||
sends={props.decryptedSends}
|
||||
loading={props.sendsLoading}
|
||||
onRefresh={props.onRefreshVault}
|
||||
onCreate={props.onCreateSend}
|
||||
onUpdate={props.onUpdateSend}
|
||||
onDelete={props.onDeleteSend}
|
||||
onBulkDelete={props.onBulkDeleteSends}
|
||||
onNotify={props.onNotify}
|
||||
/>
|
||||
</Suspense>
|
||||
</Route>
|
||||
<Route path="/vault/totp">
|
||||
<TotpCodesPage ciphers={props.decryptedCiphers} loading={props.ciphersLoading} onNotify={props.onNotify} />
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<TotpCodesPage ciphers={props.decryptedCiphers} loading={props.ciphersLoading} onNotify={props.onNotify} />
|
||||
</Suspense>
|
||||
</Route>
|
||||
<Route path="/vault">
|
||||
<VaultPage
|
||||
ciphers={props.decryptedCiphers}
|
||||
folders={props.decryptedFolders}
|
||||
loading={props.ciphersLoading || props.foldersLoading}
|
||||
emailForReprompt={props.profile?.email || props.session?.email || ''}
|
||||
onRefresh={props.onRefreshVault}
|
||||
onCreate={props.onCreateVaultItem}
|
||||
onUpdate={props.onUpdateVaultItem}
|
||||
onDelete={props.onDeleteVaultItem}
|
||||
onBulkDelete={props.onBulkDeleteVaultItems}
|
||||
onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems}
|
||||
onBulkRestore={props.onBulkRestoreVaultItems}
|
||||
onBulkMove={props.onBulkMoveVaultItems}
|
||||
onVerifyMasterPassword={props.onVerifyMasterPassword}
|
||||
onNotify={props.onNotify}
|
||||
onCreateFolder={props.onCreateFolder}
|
||||
onDeleteFolder={props.onDeleteFolder}
|
||||
onBulkDeleteFolders={props.onBulkDeleteFolders}
|
||||
onDownloadAttachment={props.onDownloadVaultAttachment}
|
||||
/>
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<VaultPage
|
||||
ciphers={props.decryptedCiphers}
|
||||
folders={props.decryptedFolders}
|
||||
loading={props.ciphersLoading || props.foldersLoading}
|
||||
emailForReprompt={props.profile?.email || props.session?.email || ''}
|
||||
onRefresh={props.onRefreshVault}
|
||||
onCreate={props.onCreateVaultItem}
|
||||
onUpdate={props.onUpdateVaultItem}
|
||||
onDelete={props.onDeleteVaultItem}
|
||||
onBulkDelete={props.onBulkDeleteVaultItems}
|
||||
onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems}
|
||||
onBulkRestore={props.onBulkRestoreVaultItems}
|
||||
onBulkMove={props.onBulkMoveVaultItems}
|
||||
onVerifyMasterPassword={props.onVerifyMasterPassword}
|
||||
onNotify={props.onNotify}
|
||||
onCreateFolder={props.onCreateFolder}
|
||||
onDeleteFolder={props.onDeleteFolder}
|
||||
onBulkDeleteFolders={props.onBulkDeleteFolders}
|
||||
onDownloadAttachment={props.onDownloadVaultAttachment}
|
||||
/>
|
||||
</Suspense>
|
||||
</Route>
|
||||
<Route path={props.settingsAccountRoute}>
|
||||
{props.profile && (
|
||||
|
||||
@@ -13,14 +13,14 @@ import {
|
||||
type ExportRequest,
|
||||
} from '@/lib/export-formats';
|
||||
import {
|
||||
getFileAcceptBySource,
|
||||
IMPORT_SOURCES,
|
||||
type BitwardenJsonInput,
|
||||
type ImportSourceId,
|
||||
normalizeBitwardenEncryptedAccountImport,
|
||||
normalizeBitwardenImport,
|
||||
parseImportPayloadBySource,
|
||||
} from '@/lib/import-formats';
|
||||
import { getFileAcceptBySource, IMPORT_SOURCES, type ImportSourceId } from '@/lib/import-format-sources';
|
||||
import {
|
||||
type BitwardenJsonInput,
|
||||
normalizeBitwardenEncryptedAccountImport,
|
||||
normalizeBitwardenImport,
|
||||
} from '@/lib/import-formats-bitwarden';
|
||||
import { base64ToBytes, decryptStr, hkdfExpand, pbkdf2 } from '@/lib/crypto';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { Folder } from '@/lib/types';
|
||||
|
||||
+191
-1379
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,328 @@
|
||||
import { Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, Trash2 } from 'lucide-preact';
|
||||
import type { Cipher } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import {
|
||||
TOTP_PERIOD_SECONDS,
|
||||
TOTP_RING_CIRCUMFERENCE,
|
||||
copyToClipboard,
|
||||
formatAttachmentSize,
|
||||
formatHistoryTime,
|
||||
formatTotp,
|
||||
maskSecret,
|
||||
openUri,
|
||||
parseFieldType,
|
||||
toBooleanFieldValue,
|
||||
} from '@/components/vault/vault-page-helpers';
|
||||
|
||||
interface VaultDetailViewProps {
|
||||
selectedCipher: Cipher;
|
||||
repromptApprovedCipherId: string | null;
|
||||
showPassword: boolean;
|
||||
totpLive: { code: string; remain: number } | null;
|
||||
passkeyCreatedAt: string | null;
|
||||
hiddenFieldVisibleMap: Record<number, boolean>;
|
||||
folderName: (id: string | null | undefined) => string;
|
||||
onOpenReprompt: () => void;
|
||||
onToggleShowPassword: () => void;
|
||||
onToggleHiddenField: (index: number) => void;
|
||||
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => void;
|
||||
onStartEdit: () => void;
|
||||
onDelete: (cipher: Cipher) => void;
|
||||
}
|
||||
|
||||
export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{Number(props.selectedCipher.reprompt || 0) === 1 && props.repromptApprovedCipherId !== props.selectedCipher.id && (
|
||||
<div className="card">
|
||||
<h4>{t('txt_master_password_reprompt_2')}</h4>
|
||||
<div className="detail-sub">{t('txt_this_item_requires_master_password_every_time_before_viewing_details')}</div>
|
||||
<div className="actions" style={{ marginTop: '10px' }}>
|
||||
<button type="button" className="btn btn-primary" onClick={props.onOpenReprompt}>
|
||||
<Eye size={14} className="btn-icon" /> {t('txt_unlock_details')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(Number(props.selectedCipher.reprompt || 0) !== 1 || props.repromptApprovedCipherId === props.selectedCipher.id) && (
|
||||
<>
|
||||
<div className="card">
|
||||
<h3 className="detail-title">{props.selectedCipher.decName || t('txt_no_name')}</h3>
|
||||
<div className="detail-sub">{props.folderName(props.selectedCipher.folderId)}</div>
|
||||
</div>
|
||||
|
||||
{props.selectedCipher.login && (
|
||||
<div className="card">
|
||||
<h4>{t('txt_login_credentials')}</h4>
|
||||
<div className="kv-row">
|
||||
<span className="kv-label">{t('txt_username')}</span>
|
||||
<div className="kv-main">
|
||||
<strong className="value-ellipsis" title={props.selectedCipher.login.decUsername || ''}>{props.selectedCipher.login.decUsername || ''}</strong>
|
||||
</div>
|
||||
<div className="kv-actions">
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.selectedCipher.login?.decUsername || '')}>
|
||||
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kv-row">
|
||||
<span className="kv-label">{t('txt_password')}</span>
|
||||
<div className="kv-main">
|
||||
<strong>{props.showPassword ? props.selectedCipher.login.decPassword || '' : maskSecret(props.selectedCipher.login.decPassword || '')}</strong>
|
||||
</div>
|
||||
<div className="kv-actions">
|
||||
<button type="button" className="btn btn-secondary small" onClick={props.onToggleShowPassword}>
|
||||
{props.showPassword ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
|
||||
{props.showPassword ? t('txt_hide') : t('txt_reveal')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.selectedCipher.login?.decPassword || '')}>
|
||||
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{!!props.selectedCipher.login.decTotp && (
|
||||
<div className="kv-row">
|
||||
<span className="kv-label">{t('txt_totp')}</span>
|
||||
<div className="kv-main">
|
||||
<div className="totp-inline">
|
||||
<strong>{props.totpLive ? formatTotp(props.totpLive.code) : t('txt_text_3')}</strong>
|
||||
<div
|
||||
className="totp-timer"
|
||||
title={t('txt_refresh_in_seconds_s', { seconds: props.totpLive ? props.totpLive.remain : 0 })}
|
||||
aria-label={t('txt_refresh_in_seconds_s', { seconds: props.totpLive ? props.totpLive.remain : 0 })}
|
||||
>
|
||||
<svg viewBox="0 0 36 36" className="totp-ring" role="presentation" aria-hidden="true">
|
||||
<circle className="totp-ring-track" cx="18" cy="18" r="15.9155" />
|
||||
<circle
|
||||
className="totp-ring-progress"
|
||||
cx="18"
|
||||
cy="18"
|
||||
r="15.9155"
|
||||
style={{
|
||||
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
|
||||
strokeDashoffset: String(
|
||||
TOTP_RING_CIRCUMFERENCE -
|
||||
TOTP_RING_CIRCUMFERENCE *
|
||||
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.totpLive?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
<span className="totp-timer-value">{props.totpLive ? props.totpLive.remain : 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kv-actions">
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.totpLive?.code || '')}>
|
||||
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!!props.passkeyCreatedAt && (
|
||||
<div className="kv-row">
|
||||
<span className="kv-label">{t('txt_passkey')}</span>
|
||||
<div className="kv-main">
|
||||
<strong>{t('txt_passkey_created_at_value', { value: formatHistoryTime(props.passkeyCreatedAt) })}</strong>
|
||||
</div>
|
||||
<div className="kv-actions" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(props.selectedCipher.login?.uris || []).length > 0 && (
|
||||
<div className="card">
|
||||
<h4>{t('txt_autofill_options')}</h4>
|
||||
{(props.selectedCipher.login?.uris || []).map((uri, index) => {
|
||||
const value = uri.decUri || uri.uri || '';
|
||||
if (!value.trim()) return null;
|
||||
return (
|
||||
<div key={`view-uri-${index}`} className="kv-row">
|
||||
<span className="kv-label">{t('txt_website')}</span>
|
||||
<div className="kv-main">
|
||||
<strong className="value-ellipsis" title={value}>{value}</strong>
|
||||
</div>
|
||||
<div className="kv-actions">
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => openUri(value)}>
|
||||
<ExternalLink size={14} className="btn-icon" /> {t('txt_open')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(value)}>
|
||||
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.selectedCipher.card && (
|
||||
<div className="card">
|
||||
<h4>{t('txt_card_details')}</h4>
|
||||
<div className="kv-line"><span>{t('txt_cardholder_name')}</span><strong>{props.selectedCipher.card.decCardholderName || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_number')}</span><strong>{props.selectedCipher.card.decNumber || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_brand')}</span><strong>{props.selectedCipher.card.decBrand || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_expiry')}</span><strong>{`${props.selectedCipher.card.decExpMonth || ''}/${props.selectedCipher.card.decExpYear || ''}`}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_security_code')}</span><strong>{props.selectedCipher.card.decCode || ''}</strong></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.selectedCipher.identity && (
|
||||
<div className="card">
|
||||
<h4>{t('txt_identity_details')}</h4>
|
||||
<div className="kv-line"><span>{t('txt_name')}</span><strong>{`${props.selectedCipher.identity.decFirstName || ''} ${props.selectedCipher.identity.decLastName || ''}`.trim()}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_username')}</span><strong>{props.selectedCipher.identity.decUsername || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_email')}</span><strong>{props.selectedCipher.identity.decEmail || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_phone')}</span><strong>{props.selectedCipher.identity.decPhone || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_company')}</span><strong>{props.selectedCipher.identity.decCompany || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_address')}</span><strong>{[props.selectedCipher.identity.decAddress1, props.selectedCipher.identity.decAddress2, props.selectedCipher.identity.decAddress3, props.selectedCipher.identity.decCity, props.selectedCipher.identity.decState, props.selectedCipher.identity.decPostalCode, props.selectedCipher.identity.decCountry].filter(Boolean).join(', ')}</strong></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.selectedCipher.sshKey && (
|
||||
<div className="card">
|
||||
<h4>{t('txt_ssh_key')}</h4>
|
||||
<div className="kv-row">
|
||||
<span className="kv-label">{t('txt_private_key')}</span>
|
||||
<div className="kv-main">
|
||||
<strong className="value-ellipsis" title={maskSecret(props.selectedCipher.sshKey.decPrivateKey || '')}>
|
||||
{maskSecret(props.selectedCipher.sshKey.decPrivateKey || '')}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="kv-actions" />
|
||||
</div>
|
||||
<div className="kv-row">
|
||||
<span className="kv-label">{t('txt_public_key')}</span>
|
||||
<div className="kv-main">
|
||||
<strong className="value-ellipsis" title={props.selectedCipher.sshKey.decPublicKey || ''}>
|
||||
{props.selectedCipher.sshKey.decPublicKey || ''}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="kv-actions" />
|
||||
</div>
|
||||
<div className="kv-row">
|
||||
<span className="kv-label">{t('txt_fingerprint')}</span>
|
||||
<div className="kv-main">
|
||||
<strong className="value-ellipsis" title={props.selectedCipher.sshKey.decFingerprint || ''}>
|
||||
{props.selectedCipher.sshKey.decFingerprint || ''}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="kv-actions" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!(props.selectedCipher.decNotes || '').trim() && (
|
||||
<div className="card">
|
||||
<h4>{t('txt_notes')}</h4>
|
||||
<div className="notes">{props.selectedCipher.decNotes || ''}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(props.selectedCipher.fields || []).some((x) => parseFieldType(x.type) !== 3) && (
|
||||
<div className="card">
|
||||
<h4>{t('txt_custom_fields')}</h4>
|
||||
{(props.selectedCipher.fields || [])
|
||||
.filter((x) => parseFieldType(x.type) !== 3)
|
||||
.map((field, index) => {
|
||||
const fieldType = parseFieldType(field.type);
|
||||
const fieldName = field.decName || t('txt_field');
|
||||
const rawValue = field.decValue || '';
|
||||
const isHiddenVisible = !!props.hiddenFieldVisibleMap[index];
|
||||
if (fieldType === 2) {
|
||||
const checked = toBooleanFieldValue(rawValue);
|
||||
return (
|
||||
<div key={`view-field-${index}`} className="kv-row custom-field-row">
|
||||
<span className="kv-label" title={fieldName}>{fieldName}</span>
|
||||
<div className="kv-main boolean-main">
|
||||
<label className="check-line cf-check view">
|
||||
<input type="checkbox" checked={checked} disabled />
|
||||
</label>
|
||||
<span className="boolean-text value-ellipsis" title={checked ? t('txt_checked') : t('txt_unchecked')}>
|
||||
{checked ? t('txt_checked') : t('txt_unchecked')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="kv-actions" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={`view-field-${index}`} className="kv-row custom-field-row">
|
||||
<span className="kv-label" title={fieldName}>{fieldName}</span>
|
||||
<div className="kv-main">
|
||||
<strong className="value-ellipsis" title={fieldType === 1 && !isHiddenVisible ? '' : rawValue}>
|
||||
{fieldType === 1 && !isHiddenVisible ? maskSecret(rawValue) : rawValue}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="kv-actions">
|
||||
{fieldType === 1 && (
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onToggleHiddenField(index)}>
|
||||
{isHiddenVisible ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
|
||||
{isHiddenVisible ? t('txt_hide') : t('txt_reveal')}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(rawValue)}>
|
||||
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedAttachments.some((attachment) => String(attachment?.id || '').trim()) && (
|
||||
<div className="card">
|
||||
<h4>{t('txt_attachments')}</h4>
|
||||
<div className="attachment-list">
|
||||
{selectedAttachments.map((attachment) => {
|
||||
const attachmentId = String(attachment?.id || '').trim();
|
||||
if (!attachmentId) return null;
|
||||
const fileName = String(attachment.decFileName || attachment.fileName || attachmentId).trim() || attachmentId;
|
||||
return (
|
||||
<div key={`view-attachment-${attachmentId}`} className="attachment-row">
|
||||
<div className="attachment-main">
|
||||
<Paperclip size={14} />
|
||||
<div className="attachment-text">
|
||||
<strong className="value-ellipsis" title={fileName}>{fileName}</strong>
|
||||
<span>{formatAttachmentSize(attachment)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kv-actions">
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onDownloadAttachment(props.selectedCipher, attachmentId)}>
|
||||
<Download size={14} className="btn-icon" /> {t('txt_download')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(props.selectedCipher.creationDate || props.selectedCipher.revisionDate) && (
|
||||
<div className="card">
|
||||
<h4>{t('txt_item_history')}</h4>
|
||||
<div className="detail-sub">{t('txt_last_edited_value', { value: formatHistoryTime(props.selectedCipher.revisionDate) })}</div>
|
||||
<div className="detail-sub">{t('txt_created_value', { value: formatHistoryTime(props.selectedCipher.creationDate) })}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="detail-actions">
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={props.onStartEdit}>
|
||||
<Pencil size={14} className="btn-icon" /> {t('txt_edit')}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="btn btn-danger" onClick={() => props.onDelete(props.selectedCipher)}>
|
||||
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import type { CustomFieldType, Folder } from '@/lib/types';
|
||||
import { FIELD_TYPE_OPTIONS, toBooleanFieldValue } from '@/components/vault/vault-page-helpers';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
interface VaultDialogsProps {
|
||||
fieldModalOpen: boolean;
|
||||
fieldType: CustomFieldType;
|
||||
fieldLabel: string;
|
||||
fieldValue: string;
|
||||
pendingDeleteOpen: boolean;
|
||||
bulkDeleteOpen: boolean;
|
||||
sidebarTrashMode: boolean;
|
||||
selectedCount: number;
|
||||
moveOpen: boolean;
|
||||
moveFolderId: string;
|
||||
folders: Folder[];
|
||||
createFolderOpen: boolean;
|
||||
newFolderName: string;
|
||||
pendingDeleteFolder: Folder | null;
|
||||
deleteAllFoldersOpen: boolean;
|
||||
repromptOpen: boolean;
|
||||
repromptPassword: string;
|
||||
onConfirmAddField: () => void;
|
||||
onCancelFieldModal: () => void;
|
||||
onFieldTypeChange: (value: CustomFieldType) => void;
|
||||
onFieldLabelChange: (value: string) => void;
|
||||
onFieldValueChange: (value: string) => void;
|
||||
onConfirmDelete: () => void;
|
||||
onCancelDelete: () => void;
|
||||
onConfirmBulkDelete: () => void;
|
||||
onCancelBulkDelete: () => void;
|
||||
onConfirmMove: () => void;
|
||||
onCancelMove: () => void;
|
||||
onMoveFolderIdChange: (value: string) => void;
|
||||
onConfirmCreateFolder: () => void;
|
||||
onCancelCreateFolder: () => void;
|
||||
onNewFolderNameChange: (value: string) => void;
|
||||
onConfirmDeleteFolder: () => void;
|
||||
onCancelDeleteFolder: () => void;
|
||||
onConfirmDeleteAllFolders: () => void;
|
||||
onCancelDeleteAllFolders: () => void;
|
||||
onConfirmReprompt: () => void;
|
||||
onCancelReprompt: () => void;
|
||||
onRepromptPasswordChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function VaultDialogs(props: VaultDialogsProps) {
|
||||
return (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
open={props.fieldModalOpen}
|
||||
title={t('txt_add_field')}
|
||||
message={t('txt_configure_custom_field_values')}
|
||||
confirmText={t('txt_add')}
|
||||
cancelText={t('txt_cancel')}
|
||||
onConfirm={props.onConfirmAddField}
|
||||
onCancel={props.onCancelFieldModal}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_field_type')}</span>
|
||||
<select className="input" value={props.fieldType} onInput={(e) => props.onFieldTypeChange(Number((e.currentTarget as HTMLSelectElement).value) as CustomFieldType)}>
|
||||
{FIELD_TYPE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_field_label')}</span>
|
||||
<input className="input" value={props.fieldLabel} onInput={(e) => props.onFieldLabelChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
{props.fieldType === 2 ? (
|
||||
<label className="check-line">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toBooleanFieldValue(props.fieldValue)}
|
||||
onInput={(e) => props.onFieldValueChange((e.currentTarget as HTMLInputElement).checked ? 'true' : 'false')}
|
||||
/>
|
||||
{t('txt_enabled')}
|
||||
</label>
|
||||
) : (
|
||||
<label className="field">
|
||||
<span>{t('txt_field_value')}</span>
|
||||
<input className="input" value={props.fieldValue} onInput={(e) => props.onFieldValueChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
)}
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog open={props.pendingDeleteOpen} title={t('txt_delete_item')} message={t('txt_are_you_sure_you_want_to_delete_this_item')} danger onConfirm={props.onConfirmDelete} onCancel={props.onCancelDelete} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={props.bulkDeleteOpen}
|
||||
title={props.sidebarTrashMode ? t('txt_delete_selected_items_permanently') : t('txt_delete_selected_items')}
|
||||
message={
|
||||
props.sidebarTrashMode
|
||||
? t('txt_are_you_sure_you_want_to_delete_count_selected_items_permanently', { count: props.selectedCount })
|
||||
: t('txt_are_you_sure_you_want_to_delete_count_selected_items', { count: props.selectedCount })
|
||||
}
|
||||
danger
|
||||
onConfirm={props.onConfirmBulkDelete}
|
||||
onCancel={props.onCancelBulkDelete}
|
||||
/>
|
||||
|
||||
<ConfirmDialog open={props.moveOpen} title={t('txt_move_selected_items')} message={t('txt_choose_destination_folder')} confirmText={t('txt_move')} cancelText={t('txt_cancel')} onConfirm={props.onConfirmMove} onCancel={props.onCancelMove}>
|
||||
<label className="field">
|
||||
<span>{t('txt_folder')}</span>
|
||||
<select className="input" value={props.moveFolderId} onInput={(e) => props.onMoveFolderIdChange((e.currentTarget as HTMLSelectElement).value)}>
|
||||
<option value="__none__">{t('txt_no_folder')}</option>
|
||||
{props.folders.map((folder) => (
|
||||
<option key={folder.id} value={folder.id}>
|
||||
{folder.decName || folder.name || folder.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog open={props.createFolderOpen} title={t('txt_create_folder')} message={t('txt_enter_a_folder_name')} confirmText={t('txt_create')} cancelText={t('txt_cancel')} onConfirm={props.onConfirmCreateFolder} onCancel={props.onCancelCreateFolder}>
|
||||
<label className="field">
|
||||
<span>{t('txt_folder_name')}</span>
|
||||
<input className="input" value={props.newFolderName} onInput={(e) => props.onNewFolderNameChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!props.pendingDeleteFolder}
|
||||
title={t('txt_delete_folder')}
|
||||
message={t('txt_delete_folder_message', { name: props.pendingDeleteFolder?.decName || props.pendingDeleteFolder?.name || props.pendingDeleteFolder?.id || '' })}
|
||||
confirmText={t('txt_delete')}
|
||||
cancelText={t('txt_cancel')}
|
||||
danger
|
||||
onConfirm={props.onConfirmDeleteFolder}
|
||||
onCancel={props.onCancelDeleteFolder}
|
||||
/>
|
||||
|
||||
<ConfirmDialog open={props.deleteAllFoldersOpen} title={t('txt_delete_all_folders')} message={t('txt_delete_all_folders_message')} confirmText={t('txt_delete')} cancelText={t('txt_cancel')} danger onConfirm={props.onConfirmDeleteAllFolders} onCancel={props.onCancelDeleteAllFolders} />
|
||||
|
||||
<ConfirmDialog open={props.repromptOpen} title={t('txt_unlock_item')} message={t('txt_enter_master_password_to_view_this_item')} confirmText={t('txt_unlock')} cancelText={t('txt_cancel')} showIcon={false} onConfirm={props.onConfirmReprompt} onCancel={props.onCancelReprompt}>
|
||||
<label className="field">
|
||||
<span>{t('txt_master_password')}</span>
|
||||
<input className="input" type="password" value={props.repromptPassword} onInput={(e) => props.onRepromptPasswordChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import type { RefObject } from 'preact';
|
||||
import { CheckCheck, Download, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
|
||||
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { CREATE_TYPE_OPTIONS, cipherTypeLabel, formatAttachmentSize, toBooleanFieldValue } from '@/components/vault/vault-page-helpers';
|
||||
|
||||
interface VaultEditorProps {
|
||||
draft: VaultDraft;
|
||||
isCreating: boolean;
|
||||
busy: boolean;
|
||||
folders: Folder[];
|
||||
selectedCipher: Cipher | null;
|
||||
editExistingAttachments: Array<any>;
|
||||
removedAttachmentIds: Record<string, boolean>;
|
||||
removedAttachmentCount: number;
|
||||
attachmentQueue: File[];
|
||||
attachmentInputRef: RefObject<HTMLInputElement>;
|
||||
localError: string;
|
||||
onUpdateDraft: (patch: Partial<VaultDraft>) => void;
|
||||
onSeedSshDefaults: (force?: boolean) => void;
|
||||
onUpdateSshPublicKey: (value: string) => void;
|
||||
onUpdateDraftLoginUri: (index: number, value: string) => void;
|
||||
onQueueAttachmentFiles: (list: FileList | null) => void;
|
||||
onToggleExistingAttachmentRemoval: (attachmentId: string) => void;
|
||||
onRemoveQueuedAttachment: (index: number) => void;
|
||||
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => void;
|
||||
onPatchDraftCustomField: (index: number, patch: Partial<VaultDraftField>) => void;
|
||||
onUpdateDraftCustomFields: (fields: VaultDraftField[]) => void;
|
||||
onOpenFieldModal: () => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
onDeleteSelected: () => void;
|
||||
}
|
||||
|
||||
export default function VaultEditor(props: VaultEditorProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="section-head">
|
||||
<h3 className="detail-title">{props.isCreating ? t('txt_new_type_header', { type: cipherTypeLabel(props.draft.type) }) : t('txt_edit_type_header', { type: cipherTypeLabel(props.draft.type) })}</h3>
|
||||
<button type="button" className={`btn btn-secondary small ${props.draft.favorite ? 'star-on' : ''}`} onClick={() => props.onUpdateDraft({ favorite: !props.draft.favorite })}>
|
||||
{props.draft.favorite ? <Star size={14} className="btn-icon" /> : <StarOff size={14} className="btn-icon" />}
|
||||
{t('txt_favorite')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="field-grid">
|
||||
<label className="field">
|
||||
<span>{t('txt_type')}</span>
|
||||
<select
|
||||
className="input"
|
||||
value={props.draft.type}
|
||||
disabled={!props.isCreating}
|
||||
onInput={(e) => {
|
||||
const nextType = Number((e.currentTarget as HTMLSelectElement).value);
|
||||
props.onUpdateDraft({ type: nextType });
|
||||
if (nextType === 5) props.onSeedSshDefaults();
|
||||
}}
|
||||
>
|
||||
{CREATE_TYPE_OPTIONS.map((option) => (
|
||||
<option key={option.type} value={option.type}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_folder')}</span>
|
||||
<select className="input" value={props.draft.folderId} onInput={(e) => props.onUpdateDraft({ folderId: (e.currentTarget as HTMLSelectElement).value })}>
|
||||
<option value="">{t('txt_no_folder')}</option>
|
||||
{props.folders.map((folder) => (
|
||||
<option key={folder.id} value={folder.id}>
|
||||
{folder.decName || folder.name || folder.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>{t('txt_name')}</span>
|
||||
<input className="input" value={props.draft.name} onInput={(e) => props.onUpdateDraft({ name: (e.currentTarget as HTMLInputElement).value })} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{props.draft.type === 1 && (
|
||||
<div className="card">
|
||||
<h4>{t('txt_login_credentials')}</h4>
|
||||
<div className="field-grid">
|
||||
<label className="field">
|
||||
<span>{t('txt_username')}</span>
|
||||
<input className="input" value={props.draft.loginUsername} onInput={(e) => props.onUpdateDraft({ loginUsername: (e.currentTarget as HTMLInputElement).value })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_password')}</span>
|
||||
<input className="input" value={props.draft.loginPassword} onInput={(e) => props.onUpdateDraft({ loginPassword: (e.currentTarget as HTMLInputElement).value })} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>{t('txt_totp_secret')}</span>
|
||||
<input className="input" value={props.draft.loginTotp} onInput={(e) => props.onUpdateDraft({ loginTotp: (e.currentTarget as HTMLInputElement).value })} />
|
||||
</label>
|
||||
<div className="section-head">
|
||||
<h4>{t('txt_websites')}</h4>
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onUpdateDraft({ loginUris: [...props.draft.loginUris, ''] })}>
|
||||
<Plus size={14} className="btn-icon" /> {t('txt_add_website')}
|
||||
</button>
|
||||
</div>
|
||||
{props.draft.loginUris.map((uri, index) => (
|
||||
<div key={`uri-${index}`} className="website-row">
|
||||
<input className="input" value={uri} onInput={(e) => props.onUpdateDraftLoginUri(index, (e.currentTarget as HTMLInputElement).value)} />
|
||||
{props.draft.loginUris.length > 1 && (
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onUpdateDraft({ loginUris: props.draft.loginUris.filter((_, i) => i !== index) })}>
|
||||
<X size={14} className="btn-icon" />
|
||||
{t('txt_remove')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.draft.type === 3 && (
|
||||
<div className="card">
|
||||
<h4>{t('txt_card_details')}</h4>
|
||||
<div className="field-grid">
|
||||
<label className="field"><span>{t('txt_cardholder_name')}</span><input className="input" value={props.draft.cardholderName} onInput={(e) => props.onUpdateDraft({ cardholderName: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_number')}</span><input className="input" value={props.draft.cardNumber} onInput={(e) => props.onUpdateDraft({ cardNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_brand')}</span><input className="input" value={props.draft.cardBrand} onInput={(e) => props.onUpdateDraft({ cardBrand: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_security_code_cvv')}</span><input className="input" value={props.draft.cardCode} onInput={(e) => props.onUpdateDraft({ cardCode: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_expiry_month')}</span><input className="input" value={props.draft.cardExpMonth} onInput={(e) => props.onUpdateDraft({ cardExpMonth: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_expiry_year')}</span><input className="input" value={props.draft.cardExpYear} onInput={(e) => props.onUpdateDraft({ cardExpYear: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.draft.type === 4 && (
|
||||
<div className="card">
|
||||
<h4>{t('txt_identity_details')}</h4>
|
||||
<div className="field-grid">
|
||||
<label className="field"><span>{t('txt_title')}</span><input className="input" value={props.draft.identTitle} onInput={(e) => props.onUpdateDraft({ identTitle: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_first_name')}</span><input className="input" value={props.draft.identFirstName} onInput={(e) => props.onUpdateDraft({ identFirstName: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_middle_name')}</span><input className="input" value={props.draft.identMiddleName} onInput={(e) => props.onUpdateDraft({ identMiddleName: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_last_name')}</span><input className="input" value={props.draft.identLastName} onInput={(e) => props.onUpdateDraft({ identLastName: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_username')}</span><input className="input" value={props.draft.identUsername} onInput={(e) => props.onUpdateDraft({ identUsername: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_company')}</span><input className="input" value={props.draft.identCompany} onInput={(e) => props.onUpdateDraft({ identCompany: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_ssn')}</span><input className="input" value={props.draft.identSsn} onInput={(e) => props.onUpdateDraft({ identSsn: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_passport_number')}</span><input className="input" value={props.draft.identPassportNumber} onInput={(e) => props.onUpdateDraft({ identPassportNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_license_number')}</span><input className="input" value={props.draft.identLicenseNumber} onInput={(e) => props.onUpdateDraft({ identLicenseNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_email')}</span><input className="input" value={props.draft.identEmail} onInput={(e) => props.onUpdateDraft({ identEmail: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_phone')}</span><input className="input" value={props.draft.identPhone} onInput={(e) => props.onUpdateDraft({ identPhone: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_address_1')}</span><input className="input" value={props.draft.identAddress1} onInput={(e) => props.onUpdateDraft({ identAddress1: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_address_2')}</span><input className="input" value={props.draft.identAddress2} onInput={(e) => props.onUpdateDraft({ identAddress2: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_address_3')}</span><input className="input" value={props.draft.identAddress3} onInput={(e) => props.onUpdateDraft({ identAddress3: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_city_town')}</span><input className="input" value={props.draft.identCity} onInput={(e) => props.onUpdateDraft({ identCity: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_state_province')}</span><input className="input" value={props.draft.identState} onInput={(e) => props.onUpdateDraft({ identState: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_postal_code')}</span><input className="input" value={props.draft.identPostalCode} onInput={(e) => props.onUpdateDraft({ identPostalCode: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_country')}</span><input className="input" value={props.draft.identCountry} onInput={(e) => props.onUpdateDraft({ identCountry: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.draft.type === 5 && (
|
||||
<div className="card">
|
||||
<div className="section-head">
|
||||
<h4>{t('txt_ssh_key')}</h4>
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onSeedSshDefaults(true)}>
|
||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_regenerate')}
|
||||
</button>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>{t('txt_private_key')}</span>
|
||||
<textarea className="input textarea" value={props.draft.sshPrivateKey} onInput={(e) => props.onUpdateDraft({ sshPrivateKey: (e.currentTarget as HTMLTextAreaElement).value })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_public_key')}</span>
|
||||
<textarea className="input textarea" value={props.draft.sshPublicKey} onInput={(e) => props.onUpdateSshPublicKey((e.currentTarget as HTMLTextAreaElement).value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_fingerprint')}</span>
|
||||
<input className="input input-readonly" value={props.draft.sshFingerprint} readOnly />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="section-head attachment-head">
|
||||
<h4>{t('txt_attachments')}</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small attachment-add-btn"
|
||||
disabled={props.busy}
|
||||
onClick={() => props.attachmentInputRef.current?.click()}
|
||||
title={t('txt_upload_attachments')}
|
||||
aria-label={t('txt_upload_attachments')}
|
||||
>
|
||||
<Plus size={14} className="btn-icon" />
|
||||
</button>
|
||||
</div>
|
||||
{!props.isCreating && props.selectedCipher && props.editExistingAttachments.length > 0 && (
|
||||
<div className="attachment-list">
|
||||
{props.editExistingAttachments.map((attachment) => {
|
||||
const attachmentId = String(attachment?.id || '').trim();
|
||||
if (!attachmentId) return null;
|
||||
const removed = !!props.removedAttachmentIds[attachmentId];
|
||||
const fileName = String(attachment.decFileName || attachment.fileName || attachmentId).trim() || attachmentId;
|
||||
return (
|
||||
<div key={`edit-attachment-${attachmentId}`} className={`attachment-row ${removed ? 'is-removed' : ''}`}>
|
||||
<div className="attachment-main">
|
||||
<Paperclip size={14} />
|
||||
<div className="attachment-text">
|
||||
<strong className="value-ellipsis" title={fileName}>{fileName}</strong>
|
||||
<span>{formatAttachmentSize(attachment)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kv-actions">
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.busy || removed} onClick={() => props.onDownloadAttachment(props.selectedCipher as Cipher, attachmentId)}>
|
||||
<Download size={14} className="btn-icon" /> {t('txt_download')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={() => props.onToggleExistingAttachmentRemoval(attachmentId)}>
|
||||
<X size={14} className="btn-icon" />
|
||||
{removed ? t('txt_cancel') : t('txt_remove')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!!props.removedAttachmentCount && <div className="detail-sub">{t('txt_marked_for_removal_count', { count: props.removedAttachmentCount })}</div>}
|
||||
<input
|
||||
ref={props.attachmentInputRef}
|
||||
type="file"
|
||||
className="attachment-file-input"
|
||||
multiple
|
||||
disabled={props.busy}
|
||||
onChange={(e) => {
|
||||
const input = e.currentTarget as HTMLInputElement;
|
||||
props.onQueueAttachmentFiles(input.files);
|
||||
input.value = '';
|
||||
}}
|
||||
/>
|
||||
{!!props.attachmentQueue.length && (
|
||||
<div className="attachment-list">
|
||||
<div className="attachment-queue-title">{t('txt_new_attachments')}</div>
|
||||
{props.attachmentQueue.map((file, index) => (
|
||||
<div key={`queued-attachment-${index}-${file.name}`} className="attachment-row">
|
||||
<div className="attachment-main">
|
||||
<Upload size={14} />
|
||||
<div className="attachment-text">
|
||||
<strong className="value-ellipsis" title={file.name}>{file.name}</strong>
|
||||
<span>{formatAttachmentSize({ size: file.size })}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kv-actions">
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={() => props.onRemoveQueuedAttachment(index)}>
|
||||
<X size={14} className="btn-icon" />
|
||||
{t('txt_remove')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h4>{t('txt_additional_options')}</h4>
|
||||
<label className="field">
|
||||
<span>{t('txt_notes')}</span>
|
||||
<textarea className="input textarea" value={props.draft.notes} onInput={(e) => props.onUpdateDraft({ notes: (e.currentTarget as HTMLTextAreaElement).value })} />
|
||||
</label>
|
||||
<label className="check-line">
|
||||
<input type="checkbox" checked={props.draft.reprompt} onInput={(e) => props.onUpdateDraft({ reprompt: (e.currentTarget as HTMLInputElement).checked })} />
|
||||
{t('txt_master_password_reprompt')}
|
||||
</label>
|
||||
<div className="section-head">
|
||||
<h4>{t('txt_custom_fields')}</h4>
|
||||
<button type="button" className="btn btn-secondary small" onClick={props.onOpenFieldModal}>
|
||||
<Plus size={14} className="btn-icon" /> {t('txt_add_field')}
|
||||
</button>
|
||||
</div>
|
||||
{props.draft.customFields
|
||||
.map((field, originalIndex) => ({ field, originalIndex }))
|
||||
.filter((entry) => entry.field.type !== 3)
|
||||
.map(({ field, originalIndex }) => (
|
||||
<div key={`field-${originalIndex}`} className="uri-row">
|
||||
<input className="input" value={field.label} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { label: (e.currentTarget as HTMLInputElement).value })} />
|
||||
{field.type === 2 ? (
|
||||
<label className="check-line cf-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toBooleanFieldValue(field.value)}
|
||||
onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).checked ? 'true' : 'false' })}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<input className="input" value={field.value} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).value })} />
|
||||
)}
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onUpdateDraftCustomFields(props.draft.customFields.filter((_, i) => i !== originalIndex))}>
|
||||
<X size={14} className="btn-icon" />
|
||||
{t('txt_remove')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="detail-actions">
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-primary" disabled={props.busy} onClick={props.onSave}>
|
||||
<CheckCheck size={14} className="btn-icon" />
|
||||
{t('txt_confirm')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={props.busy} onClick={props.onCancel}>
|
||||
<X size={14} className="btn-icon" />
|
||||
{t('txt_cancel')}
|
||||
</button>
|
||||
</div>
|
||||
{!props.isCreating && props.selectedCipher && (
|
||||
<button type="button" className="btn btn-danger" disabled={props.busy} onClick={props.onDeleteSelected}>
|
||||
<Trash2 size={14} className="btn-icon" />
|
||||
{t('txt_delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{props.localError && <div className="local-error">{props.localError}</div>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { RefObject } from 'preact';
|
||||
import { ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, Trash2, X } from 'lucide-preact';
|
||||
import type { Cipher } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import {
|
||||
CREATE_TYPE_OPTIONS,
|
||||
CreateTypeIcon,
|
||||
VAULT_SORT_OPTIONS,
|
||||
VaultListIcon,
|
||||
type SidebarFilter,
|
||||
type VaultSortMode,
|
||||
} from '@/components/vault/vault-page-helpers';
|
||||
|
||||
interface VirtualRange {
|
||||
start: number;
|
||||
end: number;
|
||||
padTop: number;
|
||||
padBottom: number;
|
||||
}
|
||||
|
||||
interface VaultListPanelProps {
|
||||
busy: boolean;
|
||||
loading: boolean;
|
||||
searchInput: string;
|
||||
sortMode: VaultSortMode;
|
||||
sortMenuOpen: boolean;
|
||||
selectedCount: number;
|
||||
totalCipherCount: number;
|
||||
filteredCiphers: Cipher[];
|
||||
visibleCiphers: Cipher[];
|
||||
virtualRange: VirtualRange;
|
||||
selectedCipherId: string;
|
||||
selectedMap: Record<string, boolean>;
|
||||
sidebarFilter: SidebarFilter;
|
||||
createMenuOpen: boolean;
|
||||
createMenuRef: RefObject<HTMLDivElement>;
|
||||
sortMenuRef: RefObject<HTMLDivElement>;
|
||||
listPanelRef: RefObject<HTMLDivElement>;
|
||||
onSearchInput: (value: string) => void;
|
||||
onSearchCompositionStart: () => void;
|
||||
onSearchCompositionEnd: (value: string) => void;
|
||||
onToggleSortMenu: () => void;
|
||||
onSelectSortMode: (value: VaultSortMode) => void;
|
||||
onSyncVault: () => void;
|
||||
onOpenBulkDelete: () => void;
|
||||
onSelectAll: () => void;
|
||||
onToggleCreateMenu: () => void;
|
||||
onStartCreate: (type: number) => void;
|
||||
onBulkRestore: () => void;
|
||||
onOpenMove: () => void;
|
||||
onClearSelection: () => void;
|
||||
onScroll: (top: number) => void;
|
||||
onToggleSelected: (cipherId: string, checked: boolean) => void;
|
||||
onSelectCipher: (cipherId: string) => void;
|
||||
listSubtitle: (cipher: Cipher) => string;
|
||||
}
|
||||
|
||||
export default function VaultListPanel(props: VaultListPanelProps) {
|
||||
return (
|
||||
<section className="list-col">
|
||||
<div className="list-head">
|
||||
<input
|
||||
className="search-input"
|
||||
placeholder={t('txt_search_your_secure_vault')}
|
||||
value={props.searchInput}
|
||||
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
|
||||
onCompositionStart={props.onSearchCompositionStart}
|
||||
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-secondary small sort-trigger ${props.sortMenuOpen ? 'active' : ''}`}
|
||||
aria-label={t('txt_sort')}
|
||||
title={t('txt_sort')}
|
||||
onClick={props.onToggleSortMenu}
|
||||
>
|
||||
<ArrowUpDown size={14} className="btn-icon" />
|
||||
</button>
|
||||
{props.sortMenuOpen && (
|
||||
<div className="sort-menu">
|
||||
{VAULT_SORT_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`sort-menu-item ${props.sortMode === option.value ? 'active' : ''}`}
|
||||
onClick={() => props.onSelectSortMode(option.value)}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{props.sortMode === option.value ? <Check size={14} /> : <span className="sort-menu-check-placeholder" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="list-count" title={t('txt_total_items_count', { count: props.totalCipherCount })}>
|
||||
{t('txt_total_items_count', { count: props.totalCipherCount })}
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={props.busy || props.loading} onClick={props.onSyncVault}>
|
||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="toolbar actions">
|
||||
<button type="button" className="btn btn-danger small" disabled={!props.selectedCount || props.busy} onClick={props.onOpenBulkDelete}>
|
||||
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
|
||||
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
||||
</button>
|
||||
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary small mobile-fab-trigger"
|
||||
aria-label={t('txt_add')}
|
||||
title={t('txt_add')}
|
||||
onClick={props.onToggleCreateMenu}
|
||||
>
|
||||
<Plus size={14} className="btn-icon" />
|
||||
</button>
|
||||
{props.createMenuOpen && (
|
||||
<div className="create-menu">
|
||||
{CREATE_TYPE_OPTIONS.map((option) => (
|
||||
<button key={option.type} type="button" className="create-menu-item" onClick={() => props.onStartCreate(option.type)}>
|
||||
<CreateTypeIcon type={option.type} />
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{props.selectedCount > 0 && props.sidebarFilter.kind === 'trash' && (
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkRestore}>
|
||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
|
||||
</button>
|
||||
)}
|
||||
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && (
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}>
|
||||
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
|
||||
</button>
|
||||
)}
|
||||
{props.selectedCount > 0 && (
|
||||
<button type="button" className="btn btn-secondary small" onClick={props.onClearSelection}>
|
||||
<X size={14} className="btn-icon" /> {t('txt_cancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
||||
{!!props.filteredCiphers.length && (
|
||||
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
|
||||
{props.visibleCiphers.map((cipher) => (
|
||||
<div key={cipher.id} className={`list-item ${props.selectedCipherId === cipher.id ? 'active' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="row-check"
|
||||
checked={!!props.selectedMap[cipher.id]}
|
||||
onInput={(e) => props.onToggleSelected(cipher.id, (e.currentTarget as HTMLInputElement).checked)}
|
||||
/>
|
||||
<button type="button" className="row-main" onClick={() => props.onSelectCipher(cipher.id)}>
|
||||
<div className="list-icon-wrap">
|
||||
<VaultListIcon cipher={cipher} />
|
||||
</div>
|
||||
<div className="list-text">
|
||||
<span className="list-title" title={cipher.decName || t('txt_no_name')}>
|
||||
<span className="list-title-text">{cipher.decName || t('txt_no_name')}</span>
|
||||
</span>
|
||||
<span className="list-sub" title={props.listSubtitle(cipher)}>{props.listSubtitle(cipher)}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!props.filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
CreditCard,
|
||||
Folder as FolderIcon,
|
||||
FolderPlus,
|
||||
FolderX,
|
||||
Globe,
|
||||
KeyRound,
|
||||
LayoutGrid,
|
||||
ShieldUser,
|
||||
Star,
|
||||
StickyNote,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-preact';
|
||||
import type { Folder } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { SidebarFilter } from '@/components/vault/vault-page-helpers';
|
||||
|
||||
interface VaultSidebarProps {
|
||||
folders: Folder[];
|
||||
sidebarFilter: SidebarFilter;
|
||||
busy: boolean;
|
||||
isMobileLayout: boolean;
|
||||
mobileSidebarOpen: boolean;
|
||||
onCloseMobileSidebar: () => void;
|
||||
onChangeFilter: (filter: SidebarFilter) => void;
|
||||
onOpenDeleteAllFolders: () => void;
|
||||
onOpenCreateFolder: () => void;
|
||||
onOpenDeleteFolder: (folder: Folder) => void;
|
||||
}
|
||||
|
||||
export default function VaultSidebar(props: VaultSidebarProps) {
|
||||
return (
|
||||
<aside className={`sidebar ${props.isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${props.isMobileLayout && props.mobileSidebarOpen ? 'open' : ''}`}>
|
||||
{props.isMobileLayout && (
|
||||
<div className="mobile-sidebar-head">
|
||||
<div className="mobile-sidebar-title">{t('txt_folders')}</div>
|
||||
<button type="button" className="mobile-sidebar-close" onClick={props.onCloseMobileSidebar} aria-label={t('txt_close')}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="sidebar-block">
|
||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'all' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'all' })}>
|
||||
<LayoutGrid size={14} className="tree-icon" /> <span className="tree-label">{t('txt_all_items')}</span>
|
||||
</button>
|
||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'favorite' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'favorite' })}>
|
||||
<Star size={14} className="tree-icon" /> <span className="tree-label">{t('txt_favorites')}</span>
|
||||
</button>
|
||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'trash' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'trash' })}>
|
||||
<Trash2 size={14} className="tree-icon" /> <span className="tree-label">{t('txt_trash')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-block">
|
||||
<div className="sidebar-title">{t('txt_type')}</div>
|
||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'login' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'login' })}>
|
||||
<Globe size={14} className="tree-icon" /> <span className="tree-label">{t('txt_login')}</span>
|
||||
</button>
|
||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'card' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'card' })}>
|
||||
<CreditCard size={14} className="tree-icon" /> <span className="tree-label">{t('txt_card')}</span>
|
||||
</button>
|
||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'identity' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'identity' })}>
|
||||
<ShieldUser size={14} className="tree-icon" /> <span className="tree-label">{t('txt_identity')}</span>
|
||||
</button>
|
||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'note' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'note' })}>
|
||||
<StickyNote size={14} className="tree-icon" /> <span className="tree-label">{t('txt_note')}</span>
|
||||
</button>
|
||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'ssh' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'ssh' })}>
|
||||
<KeyRound size={14} className="tree-icon" /> <span className="tree-label">{t('txt_ssh_key')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-block">
|
||||
<div className="sidebar-title-row">
|
||||
<div className="sidebar-title">{t('txt_folders')}</div>
|
||||
<div className="folder-title-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="folder-delete-btn"
|
||||
title={t('txt_delete_all_folders')}
|
||||
aria-label={t('txt_delete_all_folders')}
|
||||
disabled={props.busy || props.folders.length === 0}
|
||||
onClick={props.onOpenDeleteAllFolders}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<button type="button" className="folder-add-btn" onClick={props.onOpenCreateFolder}>
|
||||
<FolderPlus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'folder' && props.sidebarFilter.folderId === null ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'folder', folderId: null })}>
|
||||
<FolderX size={14} className="tree-icon" /> <span className="tree-label">{t('txt_no_folder')}</span>
|
||||
</button>
|
||||
{props.folders.map((folder) => (
|
||||
<div key={folder.id} className="folder-row">
|
||||
<button
|
||||
type="button"
|
||||
className={`tree-btn ${props.sidebarFilter.kind === 'folder' && props.sidebarFilter.folderId === folder.id ? 'active' : ''}`}
|
||||
onClick={() => props.onChangeFilter({ kind: 'folder', folderId: folder.id })}
|
||||
>
|
||||
<FolderIcon size={14} className="tree-icon" />
|
||||
<span className="tree-label" title={folder.decName || folder.name || folder.id}>
|
||||
{folder.decName || folder.name || folder.id}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="folder-delete-btn"
|
||||
title={t('txt_delete')}
|
||||
aria-label={t('txt_delete')}
|
||||
disabled={props.busy}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
props.onOpenDeleteFolder(folder);
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import {
|
||||
CreditCard,
|
||||
FileKey2,
|
||||
Globe,
|
||||
KeyRound,
|
||||
ShieldUser,
|
||||
StickyNote,
|
||||
} from 'lucide-preact';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField } from '@/lib/types';
|
||||
|
||||
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
||||
export type VaultSortMode = 'edited' | 'created' | 'name';
|
||||
export type SidebarFilter =
|
||||
| { kind: 'all' }
|
||||
| { kind: 'favorite' }
|
||||
| { kind: 'trash' }
|
||||
| { kind: 'type'; value: TypeFilter }
|
||||
| { kind: 'folder'; folderId: string | null };
|
||||
|
||||
interface TypeOption {
|
||||
type: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const CREATE_TYPE_OPTIONS: TypeOption[] = [
|
||||
{ type: 1, label: t('txt_login') },
|
||||
{ type: 3, label: t('txt_card') },
|
||||
{ type: 4, label: t('txt_identity') },
|
||||
{ type: 2, label: t('txt_note') },
|
||||
{ type: 5, label: t('txt_ssh_key') },
|
||||
];
|
||||
|
||||
export const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1';
|
||||
export const MOBILE_LAYOUT_QUERY = '(max-width: 900px)';
|
||||
export const VAULT_LIST_ROW_HEIGHT = 66;
|
||||
export const VAULT_LIST_OVERSCAN = 10;
|
||||
export const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [
|
||||
{ value: 'edited', label: t('txt_sort_last_edited') },
|
||||
{ value: 'created', label: t('txt_sort_created') },
|
||||
{ value: 'name', label: t('txt_sort_name') },
|
||||
];
|
||||
|
||||
export const FIELD_TYPE_OPTIONS: Array<{ value: CustomFieldType; label: string }> = [
|
||||
{ value: 0, label: t('txt_text') },
|
||||
{ value: 1, label: t('txt_hidden') },
|
||||
{ value: 2, label: t('txt_boolean') },
|
||||
];
|
||||
|
||||
export const TOTP_PERIOD_SECONDS = 30;
|
||||
export const TOTP_RING_RADIUS = 14;
|
||||
export const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
||||
|
||||
export function CreateTypeIcon({ type }: { type: number }) {
|
||||
if (type === 1) return <Globe size={15} />;
|
||||
if (type === 3) return <CreditCard size={15} />;
|
||||
if (type === 4) return <ShieldUser size={15} />;
|
||||
if (type === 2) return <StickyNote size={15} />;
|
||||
if (type === 5) return <KeyRound size={15} />;
|
||||
return <FileKey2 size={15} />;
|
||||
}
|
||||
|
||||
export function cipherTypeKey(type: number): TypeFilter {
|
||||
if (type === 1) return 'login';
|
||||
if (type === 3) return 'card';
|
||||
if (type === 4) return 'identity';
|
||||
if (type === 2) return 'note';
|
||||
return 'ssh';
|
||||
}
|
||||
|
||||
export function cipherTypeLabel(type: number): string {
|
||||
if (type === 1) return t('txt_login');
|
||||
if (type === 3) return t('txt_card');
|
||||
if (type === 4) return t('txt_identity');
|
||||
if (type === 2) return t('txt_secure_note');
|
||||
if (type === 5) return t('txt_ssh_key');
|
||||
return t('txt_item');
|
||||
}
|
||||
|
||||
export function TypeIcon({ type }: { type: number }) {
|
||||
if (type === 1) return <Globe size={18} />;
|
||||
if (type === 3) return <CreditCard size={18} />;
|
||||
if (type === 4) return <ShieldUser size={18} />;
|
||||
if (type === 2) return <StickyNote size={18} />;
|
||||
if (type === 5) return <KeyRound size={18} />;
|
||||
return <FileKey2 size={18} />;
|
||||
}
|
||||
|
||||
export function parseFieldType(value: number | string | null | undefined): CustomFieldType {
|
||||
if (value === 1 || value === 2 || value === 3) return value;
|
||||
if (value === '1' || String(value).toLowerCase() === 'hidden') return 1;
|
||||
if (value === '2' || String(value).toLowerCase() === 'boolean') return 2;
|
||||
if (value === '3' || String(value).toLowerCase() === 'linked') return 3;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function toBooleanFieldValue(raw: string): boolean {
|
||||
const v = String(raw || '').trim().toLowerCase();
|
||||
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
|
||||
}
|
||||
|
||||
export function firstCipherUri(cipher: Cipher): string {
|
||||
const uris = cipher.login?.uris || [];
|
||||
for (const uri of uris) {
|
||||
const raw = uri.decUri || uri.uri || '';
|
||||
if (raw.trim()) return raw.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function hostFromUri(uri: string): string {
|
||||
if (!uri.trim()) return '';
|
||||
try {
|
||||
const normalized = /^https?:\/\//i.test(uri) ? uri : `https://${uri}`;
|
||||
return new URL(normalized).hostname || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmptyDraft(type: number): VaultDraft {
|
||||
return {
|
||||
type,
|
||||
favorite: false,
|
||||
name: '',
|
||||
folderId: '',
|
||||
notes: '',
|
||||
reprompt: false,
|
||||
loginUsername: '',
|
||||
loginPassword: '',
|
||||
loginTotp: '',
|
||||
loginUris: [''],
|
||||
loginFido2Credentials: [],
|
||||
cardholderName: '',
|
||||
cardNumber: '',
|
||||
cardBrand: '',
|
||||
cardExpMonth: '',
|
||||
cardExpYear: '',
|
||||
cardCode: '',
|
||||
identTitle: '',
|
||||
identFirstName: '',
|
||||
identMiddleName: '',
|
||||
identLastName: '',
|
||||
identUsername: '',
|
||||
identCompany: '',
|
||||
identSsn: '',
|
||||
identPassportNumber: '',
|
||||
identLicenseNumber: '',
|
||||
identEmail: '',
|
||||
identPhone: '',
|
||||
identAddress1: '',
|
||||
identAddress2: '',
|
||||
identAddress3: '',
|
||||
identCity: '',
|
||||
identState: '',
|
||||
identPostalCode: '',
|
||||
identCountry: '',
|
||||
sshPrivateKey: '',
|
||||
sshPublicKey: '',
|
||||
sshFingerprint: '',
|
||||
customFields: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function draftFromCipher(cipher: Cipher): VaultDraft {
|
||||
const draft = createEmptyDraft(Number(cipher.type || 1));
|
||||
draft.id = cipher.id;
|
||||
draft.favorite = !!cipher.favorite;
|
||||
draft.name = cipher.decName || '';
|
||||
draft.folderId = cipher.folderId || '';
|
||||
draft.notes = cipher.decNotes || '';
|
||||
draft.reprompt = Number(cipher.reprompt || 0) === 1;
|
||||
|
||||
if (cipher.login) {
|
||||
draft.loginUsername = cipher.login.decUsername || '';
|
||||
draft.loginPassword = cipher.login.decPassword || '';
|
||||
draft.loginTotp = cipher.login.decTotp || '';
|
||||
draft.loginUris = (cipher.login.uris || []).map((x) => x.decUri || x.uri || '');
|
||||
draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials)
|
||||
? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
|
||||
: [];
|
||||
if (!draft.loginUris.length) draft.loginUris = [''];
|
||||
}
|
||||
if (cipher.card) {
|
||||
draft.cardholderName = cipher.card.decCardholderName || '';
|
||||
draft.cardNumber = cipher.card.decNumber || '';
|
||||
draft.cardBrand = cipher.card.decBrand || '';
|
||||
draft.cardExpMonth = cipher.card.decExpMonth || '';
|
||||
draft.cardExpYear = cipher.card.decExpYear || '';
|
||||
draft.cardCode = cipher.card.decCode || '';
|
||||
}
|
||||
if (cipher.identity) {
|
||||
draft.identTitle = cipher.identity.decTitle || '';
|
||||
draft.identFirstName = cipher.identity.decFirstName || '';
|
||||
draft.identMiddleName = cipher.identity.decMiddleName || '';
|
||||
draft.identLastName = cipher.identity.decLastName || '';
|
||||
draft.identUsername = cipher.identity.decUsername || '';
|
||||
draft.identCompany = cipher.identity.decCompany || '';
|
||||
draft.identSsn = cipher.identity.decSsn || '';
|
||||
draft.identPassportNumber = cipher.identity.decPassportNumber || '';
|
||||
draft.identLicenseNumber = cipher.identity.decLicenseNumber || '';
|
||||
draft.identEmail = cipher.identity.decEmail || '';
|
||||
draft.identPhone = cipher.identity.decPhone || '';
|
||||
draft.identAddress1 = cipher.identity.decAddress1 || '';
|
||||
draft.identAddress2 = cipher.identity.decAddress2 || '';
|
||||
draft.identAddress3 = cipher.identity.decAddress3 || '';
|
||||
draft.identCity = cipher.identity.decCity || '';
|
||||
draft.identState = cipher.identity.decState || '';
|
||||
draft.identPostalCode = cipher.identity.decPostalCode || '';
|
||||
draft.identCountry = cipher.identity.decCountry || '';
|
||||
}
|
||||
if (cipher.sshKey) {
|
||||
draft.sshPrivateKey = cipher.sshKey.decPrivateKey || '';
|
||||
draft.sshPublicKey = cipher.sshKey.decPublicKey || '';
|
||||
draft.sshFingerprint = cipher.sshKey.decFingerprint || '';
|
||||
}
|
||||
draft.customFields = (cipher.fields || []).map((field) => ({
|
||||
type: parseFieldType(field.type),
|
||||
label: field.decName || '',
|
||||
value: field.decValue || '',
|
||||
}));
|
||||
|
||||
return draft;
|
||||
}
|
||||
|
||||
export function maskSecret(value: string): string {
|
||||
if (!value) return '';
|
||||
return '*'.repeat(Math.max(8, Math.min(24, value.length)));
|
||||
}
|
||||
|
||||
export function formatTotp(code: string): string {
|
||||
if (!code || code.length < 6) return code;
|
||||
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
|
||||
}
|
||||
|
||||
export function formatHistoryTime(value: string | null | undefined): string {
|
||||
if (!value) return t('txt_dash');
|
||||
const date = new Date(value);
|
||||
if (!Number.isFinite(date.getTime())) return value;
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
export function parseAttachmentSizeBytes(attachment: CipherAttachment): number {
|
||||
const raw = attachment?.size;
|
||||
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw;
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function formatAttachmentSize(attachment: CipherAttachment): string {
|
||||
const sizeName = String(attachment?.sizeName || '').trim();
|
||||
if (sizeName) return sizeName;
|
||||
const bytes = parseAttachmentSizeBytes(attachment);
|
||||
if (bytes <= 0) return '0 B';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export function sortTimeValue(cipher: Cipher): number {
|
||||
const candidates = [cipher.revisionDate, cipher.creationDate];
|
||||
for (const value of candidates) {
|
||||
const time = new Date(String(value || '')).getTime();
|
||||
if (Number.isFinite(time)) return time;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function creationTimeValue(cipher: Cipher): number {
|
||||
const time = new Date(String(cipher.creationDate || '')).getTime();
|
||||
return Number.isFinite(time) ? time : 0;
|
||||
}
|
||||
|
||||
export function firstPasskeyCreationTime(cipher: Cipher | null): string | null {
|
||||
const credentials = cipher?.login?.fido2Credentials;
|
||||
if (!Array.isArray(credentials) || credentials.length === 0) return null;
|
||||
for (const credential of credentials) {
|
||||
const raw = String(credential?.creationDate || '').trim();
|
||||
if (raw) return raw;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const failedIconHosts = new Set<string>();
|
||||
|
||||
export function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
||||
const uri = firstCipherUri(cipher);
|
||||
const host = hostFromUri(uri);
|
||||
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
|
||||
if (host && !errored) {
|
||||
return (
|
||||
<img
|
||||
className="list-icon"
|
||||
src={`/icons/${host}/icon.png?v=2`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={() => {
|
||||
failedIconHosts.add(host);
|
||||
setErrored(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="list-icon-fallback">
|
||||
<TypeIcon type={Number(cipher.type || 1)} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function copyToClipboard(value: string): void {
|
||||
if (!value.trim()) return;
|
||||
void navigator.clipboard.writeText(value);
|
||||
}
|
||||
|
||||
export function openUri(raw: string): void {
|
||||
const value = raw.trim();
|
||||
if (!value) return;
|
||||
const url = /^https?:\/\//i.test(value) ? value : `https://${value}`;
|
||||
window.open(url, '_blank', 'noopener');
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import {
|
||||
changeMasterPassword,
|
||||
deleteAllAuthorizedDevices,
|
||||
deleteAuthorizedDevice,
|
||||
deriveLoginHash,
|
||||
getCurrentDeviceIdentifier,
|
||||
getTotpRecoveryCode,
|
||||
revokeAuthorizedDeviceTrust,
|
||||
revokeAllAuthorizedDeviceTrust,
|
||||
setTotp,
|
||||
} from '@/lib/api/auth';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { AppConfirmState } from '@/components/AppGlobalOverlays';
|
||||
import type { AuthedFetch } from '@/lib/api/shared';
|
||||
import type { AuthorizedDevice, Profile } from '@/lib/types';
|
||||
|
||||
type Notify = (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
|
||||
interface UseAccountSecurityActionsOptions {
|
||||
authedFetch: AuthedFetch;
|
||||
profile: Profile | null;
|
||||
defaultKdfIterations: number;
|
||||
disableTotpPassword: string;
|
||||
clearDisableTotpDialog: () => void;
|
||||
onPromptLogout: () => void;
|
||||
onLogoutNow: () => void;
|
||||
onNotify: Notify;
|
||||
onSetConfirm: (next: AppConfirmState | null) => void;
|
||||
refetchTotpStatus: () => Promise<unknown>;
|
||||
refetchAuthorizedDevices: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
export default function useAccountSecurityActions(options: UseAccountSecurityActionsOptions) {
|
||||
const {
|
||||
authedFetch,
|
||||
profile,
|
||||
defaultKdfIterations,
|
||||
disableTotpPassword,
|
||||
clearDisableTotpDialog,
|
||||
onPromptLogout,
|
||||
onLogoutNow,
|
||||
onNotify,
|
||||
onSetConfirm,
|
||||
refetchTotpStatus,
|
||||
refetchAuthorizedDevices,
|
||||
} = options;
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
async changePassword(currentPassword: string, nextPassword: string, nextPassword2: string) {
|
||||
if (!profile) return;
|
||||
if (!currentPassword || !nextPassword) {
|
||||
onNotify('error', t('txt_current_new_password_is_required'));
|
||||
return;
|
||||
}
|
||||
if (nextPassword.length < 12) {
|
||||
onNotify('error', t('txt_new_password_must_be_at_least_12_chars'));
|
||||
return;
|
||||
}
|
||||
if (nextPassword !== nextPassword2) {
|
||||
onNotify('error', t('txt_new_passwords_do_not_match'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await changeMasterPassword(authedFetch, {
|
||||
email: profile.email,
|
||||
currentPassword,
|
||||
newPassword: nextPassword,
|
||||
currentIterations: defaultKdfIterations,
|
||||
profileKey: profile.key,
|
||||
});
|
||||
onPromptLogout();
|
||||
onNotify('success', t('txt_master_password_changed_please_login_again'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_change_password_failed'));
|
||||
}
|
||||
},
|
||||
|
||||
async enableTotp(secret: string, token: string) {
|
||||
if (!secret.trim() || !token.trim()) {
|
||||
const error = new Error(t('txt_secret_and_code_are_required'));
|
||||
onNotify('error', error.message);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await setTotp(authedFetch, { enabled: true, secret: secret.trim(), token: token.trim() });
|
||||
onNotify('success', t('txt_totp_enabled'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_enable_totp_failed'));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async disableTotp() {
|
||||
if (!profile) return;
|
||||
if (!disableTotpPassword) {
|
||||
onNotify('error', t('txt_please_input_master_password'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations);
|
||||
await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash });
|
||||
if (profile.id) localStorage.removeItem(`nodewarden.totp.secret.${profile.id}`);
|
||||
clearDisableTotpDialog();
|
||||
await refetchTotpStatus();
|
||||
onNotify('success', t('txt_totp_disabled'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_disable_totp_failed'));
|
||||
}
|
||||
},
|
||||
|
||||
async getRecoveryCode(masterPassword: string): Promise<string> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||
const code = await getTotpRecoveryCode(authedFetch, derived.hash);
|
||||
if (!code) throw new Error(t('txt_recovery_code_is_empty'));
|
||||
return code;
|
||||
},
|
||||
|
||||
async refreshAuthorizedDevices() {
|
||||
await refetchAuthorizedDevices();
|
||||
},
|
||||
|
||||
openRevokeDeviceTrust(device: AuthorizedDevice) {
|
||||
onSetConfirm({
|
||||
title: t('txt_revoke_device_authorization'),
|
||||
message: t('txt_revoke_30_day_totp_trust_for_name', { name: device.name }),
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
onSetConfirm(null);
|
||||
void (async () => {
|
||||
await revokeAuthorizedDeviceTrust(authedFetch, device.identifier);
|
||||
await refetchAuthorizedDevices();
|
||||
onNotify('success', t('txt_device_authorization_revoked'));
|
||||
})();
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
openRemoveDevice(device: AuthorizedDevice) {
|
||||
onSetConfirm({
|
||||
title: t('txt_remove_device'),
|
||||
message: t('txt_remove_device_and_sign_out_name', { name: device.name }),
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
onSetConfirm(null);
|
||||
void (async () => {
|
||||
await deleteAuthorizedDevice(authedFetch, device.identifier);
|
||||
if (device.identifier === getCurrentDeviceIdentifier()) {
|
||||
onNotify('success', t('txt_device_removed'));
|
||||
onLogoutNow();
|
||||
return;
|
||||
}
|
||||
await refetchAuthorizedDevices();
|
||||
onNotify('success', t('txt_device_removed'));
|
||||
})();
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
openRevokeAllDeviceTrust() {
|
||||
onSetConfirm({
|
||||
title: t('txt_revoke_all_trusted_devices'),
|
||||
message: t('txt_revoke_30_day_totp_trust_from_all_devices'),
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
onSetConfirm(null);
|
||||
void (async () => {
|
||||
await revokeAllAuthorizedDeviceTrust(authedFetch);
|
||||
await refetchAuthorizedDevices();
|
||||
onNotify('success', t('txt_all_device_authorizations_revoked'));
|
||||
})();
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
openRemoveAllDevices() {
|
||||
onSetConfirm({
|
||||
title: t('txt_remove_all_devices'),
|
||||
message: t('txt_remove_all_devices_and_sign_out_all_sessions'),
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
onSetConfirm(null);
|
||||
void (async () => {
|
||||
await deleteAllAuthorizedDevices(authedFetch);
|
||||
onNotify('success', t('txt_all_devices_removed'));
|
||||
onLogoutNow();
|
||||
})();
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
[
|
||||
authedFetch,
|
||||
clearDisableTotpDialog,
|
||||
defaultKdfIterations,
|
||||
disableTotpPassword,
|
||||
onLogoutNow,
|
||||
onNotify,
|
||||
onPromptLogout,
|
||||
onSetConfirm,
|
||||
profile,
|
||||
refetchAuthorizedDevices,
|
||||
refetchTotpStatus,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import { createInvite, deleteAllInvites, deleteUser, revokeInvite, setUserStatus } from '@/lib/api/admin';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { AppConfirmState } from '@/components/AppGlobalOverlays';
|
||||
import type { AuthedFetch } from '@/lib/api/shared';
|
||||
|
||||
type Notify = (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
|
||||
interface UseAdminActionsOptions {
|
||||
authedFetch: AuthedFetch;
|
||||
onNotify: Notify;
|
||||
onSetConfirm: (next: AppConfirmState | null) => void;
|
||||
refetchUsers: () => Promise<unknown>;
|
||||
refetchInvites: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
export default function useAdminActions(options: UseAdminActionsOptions) {
|
||||
const { authedFetch, onNotify, onSetConfirm, refetchUsers, refetchInvites } = options;
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
refreshAdmin() {
|
||||
void refetchUsers();
|
||||
void refetchInvites();
|
||||
},
|
||||
|
||||
async createInvite(hours: number) {
|
||||
await createInvite(authedFetch, hours);
|
||||
await refetchInvites();
|
||||
onNotify('success', t('txt_invite_created'));
|
||||
},
|
||||
|
||||
async toggleUserStatus(userId: string, status: 'active' | 'banned') {
|
||||
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
|
||||
await refetchUsers();
|
||||
onNotify('success', t('txt_user_status_updated'));
|
||||
},
|
||||
|
||||
async revokeInvite(code: string) {
|
||||
await revokeInvite(authedFetch, code);
|
||||
await refetchInvites();
|
||||
onNotify('success', t('txt_invite_revoked'));
|
||||
},
|
||||
|
||||
async deleteAllInvites() {
|
||||
onSetConfirm({
|
||||
title: t('txt_delete_all_invites'),
|
||||
message: t('txt_delete_all_invite_codes_active_inactive'),
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
onSetConfirm(null);
|
||||
void (async () => {
|
||||
await deleteAllInvites(authedFetch);
|
||||
await refetchInvites();
|
||||
onNotify('success', t('txt_all_invites_deleted'));
|
||||
})();
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async deleteUser(userId: string) {
|
||||
onSetConfirm({
|
||||
title: t('txt_delete_user'),
|
||||
message: t('txt_delete_this_user_and_all_user_data'),
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
onSetConfirm(null);
|
||||
void (async () => {
|
||||
await deleteUser(authedFetch, userId);
|
||||
await refetchUsers();
|
||||
onNotify('success', t('txt_user_deleted'));
|
||||
})();
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
[authedFetch, onNotify, onSetConfirm, refetchInvites, refetchUsers]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import {
|
||||
deleteRemoteBackup,
|
||||
downloadRemoteBackup,
|
||||
exportAdminBackup,
|
||||
getAdminBackupSettings,
|
||||
importAdminBackup,
|
||||
listRemoteBackups,
|
||||
restoreRemoteBackup,
|
||||
runAdminBackupNow,
|
||||
saveAdminBackupSettings,
|
||||
} from '@/lib/api/backup';
|
||||
import { downloadBytesAsFile } from '@/lib/download';
|
||||
import type { AuthedFetch } from '@/lib/api/shared';
|
||||
|
||||
interface UseBackupActionsOptions {
|
||||
authedFetch: AuthedFetch;
|
||||
onImported?: () => void;
|
||||
onRestored?: () => void;
|
||||
}
|
||||
|
||||
export default function useBackupActions(options: UseBackupActionsOptions) {
|
||||
const { authedFetch, onImported, onRestored } = options;
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
async exportBackup() {
|
||||
const payload = await exportAdminBackup(authedFetch);
|
||||
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
|
||||
},
|
||||
|
||||
async importBackup(file: File, replaceExisting: boolean = false) {
|
||||
await importAdminBackup(authedFetch, file, replaceExisting);
|
||||
onImported?.();
|
||||
},
|
||||
|
||||
async loadSettings() {
|
||||
return getAdminBackupSettings(authedFetch);
|
||||
},
|
||||
|
||||
async saveSettings(settings: Parameters<typeof saveAdminBackupSettings>[1]) {
|
||||
return saveAdminBackupSettings(authedFetch, settings);
|
||||
},
|
||||
|
||||
async runRemoteBackup(destinationId?: string | null) {
|
||||
return runAdminBackupNow(authedFetch, destinationId);
|
||||
},
|
||||
|
||||
async listRemoteBackups(destinationId: string, path: string) {
|
||||
return listRemoteBackups(authedFetch, destinationId, path);
|
||||
},
|
||||
|
||||
async downloadRemoteBackup(destinationId: string, path: string) {
|
||||
const payload = await downloadRemoteBackup(authedFetch, destinationId, path);
|
||||
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
|
||||
},
|
||||
|
||||
async deleteRemoteBackup(destinationId: string, path: string) {
|
||||
await deleteRemoteBackup(authedFetch, destinationId, path);
|
||||
},
|
||||
|
||||
async restoreRemoteBackup(destinationId: string, path: string, replaceExisting: boolean = false) {
|
||||
await restoreRemoteBackup(authedFetch, destinationId, path, replaceExisting);
|
||||
onRestored?.();
|
||||
},
|
||||
}),
|
||||
[authedFetch, onImported, onRestored]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import type { ToastMessage } from '@/lib/types';
|
||||
|
||||
export function useToastManager() {
|
||||
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||
|
||||
function removeToast(id: string) {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
}
|
||||
|
||||
function pushToast(type: ToastMessage['type'], text: string) {
|
||||
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
setToasts((prev) => [...prev.slice(-3), { id, type, text }]);
|
||||
window.setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
}, 4500);
|
||||
}
|
||||
|
||||
return {
|
||||
toasts,
|
||||
pushToast,
|
||||
removeToast,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,703 @@
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||
import type { ExportRequest, ZipAttachmentEntry } from '@/lib/export-formats';
|
||||
import {
|
||||
attachNodeWardenEncryptedAttachmentPayload,
|
||||
buildAccountEncryptedBitwardenJsonString,
|
||||
buildBitwardenZipBytes,
|
||||
buildExportFileName,
|
||||
buildNodeWardenAttachmentRecords,
|
||||
buildNodeWardenPlainJsonDocument,
|
||||
buildPasswordProtectedBitwardenJsonString,
|
||||
buildPlainBitwardenJsonString,
|
||||
encryptZipBytesWithPassword,
|
||||
} from '@/lib/export-formats';
|
||||
import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr } from '@/lib/crypto';
|
||||
import { t } from '@/lib/i18n';
|
||||
import {
|
||||
buildPublicSendUrl,
|
||||
importCipherToDraft,
|
||||
looksLikeCipherString,
|
||||
summarizeImportResult,
|
||||
} from '@/lib/app-support';
|
||||
import { buildSendShareKey, bulkDeleteSends, createSend, deleteSend, updateSend } from '@/lib/api/send';
|
||||
import {
|
||||
buildCipherImportPayload,
|
||||
bulkDeleteCiphers,
|
||||
bulkDeleteFolders,
|
||||
bulkMoveCiphers,
|
||||
bulkPermanentDeleteCiphers,
|
||||
bulkRestoreCiphers,
|
||||
createCipher,
|
||||
createFolder,
|
||||
deleteCipher,
|
||||
deleteCipherAttachment,
|
||||
deleteFolder,
|
||||
downloadCipherAttachmentDecrypted,
|
||||
encryptFolderImportName,
|
||||
getAttachmentDownloadInfo,
|
||||
importCiphers,
|
||||
type CiphersImportPayload,
|
||||
type ImportedCipherMapEntry,
|
||||
updateCipher,
|
||||
uploadCipherAttachment,
|
||||
} from '@/lib/api/vault';
|
||||
import { deriveLoginHash, getPreloginKdfConfig, verifyMasterPassword } from '@/lib/api/auth';
|
||||
import type { AuthedFetch } from '@/lib/api/shared';
|
||||
import { downloadBytesAsFile } from '@/lib/download';
|
||||
import type { Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
|
||||
|
||||
type Notify = (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
|
||||
interface UseVaultSendActionsOptions {
|
||||
authedFetch: AuthedFetch;
|
||||
importAuthedFetch: AuthedFetch;
|
||||
session: SessionState | null;
|
||||
profile: Profile | null;
|
||||
defaultKdfIterations: number;
|
||||
encryptedCiphers: Cipher[] | undefined;
|
||||
encryptedFolders: VaultFolder[] | undefined;
|
||||
refetchCiphers: () => Promise<{ data?: Cipher[] | undefined } | unknown>;
|
||||
refetchFolders: () => Promise<{ data?: VaultFolder[] | undefined } | unknown>;
|
||||
refetchSends: () => Promise<unknown>;
|
||||
onNotify: Notify;
|
||||
}
|
||||
|
||||
function extractImportIdMaps(cipherMap: ImportedCipherMapEntry[] | null) {
|
||||
const byIndex = new Map<number, string>();
|
||||
const bySourceId = new Map<string, string>();
|
||||
for (const row of cipherMap || []) {
|
||||
const idx = Number(row?.index);
|
||||
const id = String(row?.id || '').trim();
|
||||
if (!Number.isFinite(idx) || !id) continue;
|
||||
byIndex.set(idx, id);
|
||||
const sourceId = String(row?.sourceId || '').trim();
|
||||
if (sourceId) bySourceId.set(sourceId, id);
|
||||
}
|
||||
return { byIndex, bySourceId };
|
||||
}
|
||||
|
||||
export default function useVaultSendActions(options: UseVaultSendActionsOptions) {
|
||||
const {
|
||||
authedFetch,
|
||||
importAuthedFetch,
|
||||
session,
|
||||
profile,
|
||||
defaultKdfIterations,
|
||||
encryptedCiphers,
|
||||
encryptedFolders,
|
||||
refetchCiphers,
|
||||
refetchFolders,
|
||||
refetchSends,
|
||||
onNotify,
|
||||
} = options;
|
||||
|
||||
return useMemo(() => {
|
||||
const refetchVault = async () => {
|
||||
await Promise.all([refetchCiphers(), refetchFolders(), refetchSends()]);
|
||||
};
|
||||
|
||||
const uploadImportedAttachments = async (
|
||||
attachments: ImportAttachmentFile[],
|
||||
idMaps: { byIndex: Map<number, string>; bySourceId: Map<string, string> }
|
||||
): Promise<{ total: number; imported: number; failed: Array<{ fileName: string; reason: string }> }> => {
|
||||
if (!attachments.length) {
|
||||
return { total: 0, imported: 0, failed: [] };
|
||||
}
|
||||
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
|
||||
|
||||
const initialCiphers = (((await refetchCiphers()) as { data?: Cipher[] | undefined })?.data) || [];
|
||||
const cipherById = new Map(initialCiphers.map((cipher) => [String(cipher.id || ''), cipher]));
|
||||
const failed: Array<{ fileName: string; reason: string }> = [];
|
||||
let imported = 0;
|
||||
|
||||
for (const attachment of attachments) {
|
||||
const sourceId = String(attachment.sourceCipherId || '').trim();
|
||||
const sourceIndex = Number(attachment.sourceCipherIndex);
|
||||
const byId = sourceId ? idMaps.bySourceId.get(sourceId) : null;
|
||||
const byIndex = Number.isFinite(sourceIndex) ? idMaps.byIndex.get(sourceIndex) : null;
|
||||
const targetCipherId = byId || byIndex || null;
|
||||
if (!targetCipherId) {
|
||||
failed.push({
|
||||
fileName: String(attachment.fileName || '').trim() || 'attachment.bin',
|
||||
reason: t('txt_import_attachment_target_not_found'),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = String(attachment.fileName || '').trim() || 'attachment.bin';
|
||||
const fileBytes = Uint8Array.from(attachment.bytes);
|
||||
const file = new File([fileBytes], name, { type: 'application/octet-stream' });
|
||||
const cipher = cipherById.get(targetCipherId) || null;
|
||||
try {
|
||||
await uploadCipherAttachment(importAuthedFetch, session, targetCipherId, file, cipher);
|
||||
imported += 1;
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
fileName: name,
|
||||
reason: error instanceof Error ? error.message : t('txt_upload_attachment_failed'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await refetchCiphers();
|
||||
return { total: attachments.length, imported, failed };
|
||||
};
|
||||
|
||||
return {
|
||||
async refreshVault() {
|
||||
await refetchVault();
|
||||
onNotify('success', t('txt_vault_synced'));
|
||||
},
|
||||
|
||||
async createVaultItem(draft: VaultDraft, attachments: File[] = []) {
|
||||
if (!session) return;
|
||||
try {
|
||||
const created = await createCipher(authedFetch, session, draft);
|
||||
for (const file of attachments) {
|
||||
await uploadCipherAttachment(authedFetch, session, created.id, file);
|
||||
}
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
onNotify('success', t('txt_item_created'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_create_item_failed'));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async updateVaultItem(cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) {
|
||||
if (!session) return;
|
||||
const addFiles = Array.isArray(options?.addFiles) ? options.addFiles : [];
|
||||
const removeAttachmentIds = Array.isArray(options?.removeAttachmentIds) ? options.removeAttachmentIds : [];
|
||||
try {
|
||||
await updateCipher(authedFetch, session, cipher, draft);
|
||||
for (const attachmentId of removeAttachmentIds) {
|
||||
const id = String(attachmentId || '').trim();
|
||||
if (!id) continue;
|
||||
await deleteCipherAttachment(authedFetch, cipher.id, id);
|
||||
}
|
||||
for (const file of addFiles) {
|
||||
await uploadCipherAttachment(authedFetch, session, cipher.id, file, cipher);
|
||||
}
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
onNotify('success', t('txt_item_updated'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_update_item_failed'));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async downloadVaultAttachment(cipher: Cipher, attachmentId: string) {
|
||||
if (!session) return;
|
||||
try {
|
||||
const file = await downloadCipherAttachmentDecrypted(authedFetch, session, cipher, attachmentId);
|
||||
const fileName = String(file.fileName || '').trim() || 'attachment.bin';
|
||||
downloadBytesAsFile(file.bytes, fileName, 'application/octet-stream');
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_download_failed'));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteVaultItem(cipher: Cipher) {
|
||||
try {
|
||||
await deleteCipher(authedFetch, cipher.id);
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
onNotify('success', t('txt_item_deleted'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_delete_item_failed'));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async bulkDeleteVaultItems(ids: string[]) {
|
||||
try {
|
||||
await bulkDeleteCiphers(authedFetch, ids);
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
onNotify('success', t('txt_deleted_selected_items'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_delete_failed'));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async bulkMoveVaultItems(ids: string[], folderId: string | null) {
|
||||
try {
|
||||
await bulkMoveCiphers(authedFetch, ids, folderId);
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
onNotify('success', t('txt_moved_selected_items'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_move_failed'));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async createFolder(name: string) {
|
||||
const folderName = name.trim();
|
||||
if (!folderName) {
|
||||
onNotify('error', t('txt_folder_name_is_required'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
||||
await createFolder(authedFetch, session, folderName);
|
||||
await refetchFolders();
|
||||
onNotify('success', t('txt_folder_created'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_create_folder_failed'));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteFolder(folderId: string) {
|
||||
const id = String(folderId || '').trim();
|
||||
if (!id) {
|
||||
onNotify('error', t('txt_folder_not_found'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteFolder(authedFetch, id);
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
onNotify('success', t('txt_folder_deleted'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_delete_folder_failed'));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async bulkRestoreVaultItems(ids: string[]) {
|
||||
try {
|
||||
await bulkRestoreCiphers(authedFetch, ids);
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
onNotify('success', t('txt_restored_selected_items'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_restore_failed'));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async bulkPermanentDeleteVaultItems(ids: string[]) {
|
||||
try {
|
||||
await bulkPermanentDeleteCiphers(authedFetch, ids);
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
onNotify('success', t('txt_deleted_selected_items_permanently'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_permanent_delete_failed'));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async bulkDeleteFolders(folderIds: string[]) {
|
||||
const ids = Array.from(new Set(folderIds.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
if (!ids.length) return;
|
||||
try {
|
||||
await bulkDeleteFolders(authedFetch, ids);
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
onNotify('success', t('txt_folders_deleted'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_delete_all_folders_failed'));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async verifyMasterPassword(email: string, password: string) {
|
||||
const derived = await deriveLoginHash(email, password, defaultKdfIterations);
|
||||
await verifyMasterPassword(authedFetch, derived.hash);
|
||||
},
|
||||
|
||||
async createSend(draft: SendDraft, autoCopyLink: boolean) {
|
||||
if (!session) return;
|
||||
try {
|
||||
const created = await createSend(authedFetch, session, draft);
|
||||
await refetchSends();
|
||||
if (autoCopyLink && created.key && session.symEncKey && session.symMacKey) {
|
||||
const keyPart = await buildSendShareKey(created.key, session.symEncKey, session.symMacKey);
|
||||
const shareUrl = buildPublicSendUrl(window.location.origin, created.accessId, keyPart);
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
}
|
||||
onNotify('success', t('txt_send_created'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_create_send_failed'));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async updateSend(send: Send, draft: SendDraft, autoCopyLink: boolean) {
|
||||
if (!session) return;
|
||||
try {
|
||||
const updated = await updateSend(authedFetch, session, send, draft);
|
||||
await refetchSends();
|
||||
if (autoCopyLink && updated.key && session.symEncKey && session.symMacKey) {
|
||||
const keyPart = await buildSendShareKey(updated.key, session.symEncKey, session.symMacKey);
|
||||
const shareUrl = buildPublicSendUrl(window.location.origin, updated.accessId, keyPart);
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
}
|
||||
onNotify('success', t('txt_send_updated'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_update_send_failed'));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteSend(send: Send) {
|
||||
try {
|
||||
await deleteSend(authedFetch, send.id);
|
||||
await refetchSends();
|
||||
onNotify('success', t('txt_send_deleted'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_delete_send_failed'));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async bulkDeleteSends(ids: string[]) {
|
||||
try {
|
||||
await bulkDeleteSends(authedFetch, ids);
|
||||
await refetchSends();
|
||||
onNotify('success', t('txt_deleted_selected_sends'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_delete_sends_failed'));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async importVault(
|
||||
payload: CiphersImportPayload,
|
||||
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
|
||||
attachments: ImportAttachmentFile[] = []
|
||||
): Promise<ImportResultSummary> {
|
||||
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
|
||||
|
||||
const mode = options.folderMode || 'original';
|
||||
const targetFolderId = (options.targetFolderId || '').trim() || null;
|
||||
const nextPayload: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
|
||||
if (mode === 'original') {
|
||||
const folderIndexByLegacyId = new Map<string, number>();
|
||||
const folderIndexByName = new Map<string, number>();
|
||||
for (let i = 0; i < payload.folders.length; i++) {
|
||||
const folderRaw = (payload.folders[i] || {}) as Record<string, unknown>;
|
||||
const name = String(folderRaw.name || '').trim();
|
||||
if (!name) continue;
|
||||
let folderIndex = folderIndexByName.get(name);
|
||||
if (folderIndex == null) {
|
||||
folderIndex = nextPayload.folders.length;
|
||||
nextPayload.folders.push({ name: await encryptFolderImportName(session, name) });
|
||||
folderIndexByName.set(name, folderIndex);
|
||||
}
|
||||
const legacyId = String(folderRaw.id || '').trim();
|
||||
if (legacyId) folderIndexByLegacyId.set(legacyId, folderIndex);
|
||||
}
|
||||
|
||||
for (let i = 0; i < payload.ciphers.length; i++) {
|
||||
const raw = (payload.ciphers[i] || {}) as Record<string, unknown>;
|
||||
let folderIndex: number | undefined;
|
||||
for (const relation of payload.folderRelationships || []) {
|
||||
const cipherIndex = Number(relation?.key);
|
||||
const relFolderIndex = Number(relation?.value);
|
||||
if (cipherIndex !== i || !Number.isFinite(relFolderIndex)) continue;
|
||||
const importedFolder = payload.folders[relFolderIndex] as Record<string, unknown> | undefined;
|
||||
const importedName = String(importedFolder?.name || '').trim();
|
||||
if (importedName) folderIndex = folderIndexByName.get(importedName);
|
||||
if (folderIndex != null) break;
|
||||
}
|
||||
if (folderIndex == null) {
|
||||
const rawFolderId = String(raw.folderId || '').trim();
|
||||
if (rawFolderId) folderIndex = folderIndexByLegacyId.get(rawFolderId);
|
||||
}
|
||||
if (folderIndex == null) {
|
||||
const rawFolderName = String(raw.folder || '').trim();
|
||||
if (rawFolderName) folderIndex = folderIndexByName.get(rawFolderName);
|
||||
}
|
||||
if (folderIndex != null) {
|
||||
nextPayload.folderRelationships.push({ key: i, value: folderIndex });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < payload.ciphers.length; i++) {
|
||||
const raw = (payload.ciphers[i] || {}) as Record<string, unknown>;
|
||||
const draft = importCipherToDraft(raw, mode === 'target' ? targetFolderId : null);
|
||||
nextPayload.ciphers.push(await buildCipherImportPayload(session, draft));
|
||||
}
|
||||
|
||||
const importedCipherMap = await importCiphers(importAuthedFetch, nextPayload, {
|
||||
returnCipherMap: attachments.length > 0,
|
||||
});
|
||||
await Promise.all([refetchFolders(), refetchCiphers()]);
|
||||
const attachmentSummary = attachments.length
|
||||
? await uploadImportedAttachments(attachments, extractImportIdMaps(importedCipherMap))
|
||||
: undefined;
|
||||
return summarizeImportResult(payload.ciphers, mode === 'original' ? nextPayload.folders.length : 0, attachmentSummary);
|
||||
},
|
||||
|
||||
async importEncryptedRaw(
|
||||
payload: CiphersImportPayload,
|
||||
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
|
||||
attachments: ImportAttachmentFile[] = []
|
||||
): Promise<ImportResultSummary> {
|
||||
const mode = options.folderMode || 'original';
|
||||
const targetFolderId = (options.targetFolderId || '').trim() || null;
|
||||
const nextPayload: CiphersImportPayload = {
|
||||
ciphers: payload.ciphers.map((raw) => ({ ...(raw as Record<string, unknown>) })),
|
||||
folders: mode === 'original' ? payload.folders : [],
|
||||
folderRelationships: mode === 'original' ? payload.folderRelationships : [],
|
||||
};
|
||||
if (mode === 'none') {
|
||||
for (const raw of nextPayload.ciphers) (raw as Record<string, unknown>).folderId = null;
|
||||
} else if (mode === 'target' && targetFolderId) {
|
||||
for (const raw of nextPayload.ciphers) (raw as Record<string, unknown>).folderId = targetFolderId;
|
||||
}
|
||||
|
||||
const importedCipherMap = await importCiphers(importAuthedFetch, nextPayload, {
|
||||
returnCipherMap: attachments.length > 0,
|
||||
});
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
const attachmentSummary = attachments.length
|
||||
? await uploadImportedAttachments(attachments, extractImportIdMaps(importedCipherMap))
|
||||
: undefined;
|
||||
return summarizeImportResult(
|
||||
nextPayload.ciphers,
|
||||
mode === 'original' ? nextPayload.folders.length : 0,
|
||||
attachmentSummary
|
||||
);
|
||||
},
|
||||
|
||||
async exportVault(request: ExportRequest) {
|
||||
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
|
||||
const masterPassword = String(request.masterPassword || '').trim();
|
||||
if (!masterPassword) throw new Error(t('txt_master_password_is_required'));
|
||||
const email = String(profile?.email || session.email || '').trim().toLowerCase();
|
||||
if (!email) throw new Error(t('txt_profile_unavailable'));
|
||||
const verifyDerived = await deriveLoginHash(email, masterPassword, defaultKdfIterations);
|
||||
await verifyMasterPassword(authedFetch, verifyDerived.hash);
|
||||
|
||||
const rawFolders = encryptedFolders || [];
|
||||
const rawCiphers = encryptedCiphers || [];
|
||||
if (!rawFolders || !rawCiphers) throw new Error(t('txt_vault_not_ready'));
|
||||
|
||||
let plainJsonCache: string | null = null;
|
||||
let plainJsonDocCache: Record<string, unknown> | null = null;
|
||||
let encryptedJsonCache: string | null = null;
|
||||
let nodeWardenAttachmentsCache: ReturnType<typeof buildNodeWardenAttachmentRecords> | null = null;
|
||||
|
||||
const getPlainJson = async () => {
|
||||
if (!plainJsonCache) {
|
||||
plainJsonCache = await buildPlainBitwardenJsonString({
|
||||
folders: rawFolders,
|
||||
ciphers: rawCiphers,
|
||||
userEncB64: session.symEncKey!,
|
||||
userMacB64: session.symMacKey!,
|
||||
});
|
||||
}
|
||||
return plainJsonCache;
|
||||
};
|
||||
|
||||
const getPlainJsonDoc = async () => {
|
||||
if (!plainJsonDocCache) {
|
||||
plainJsonDocCache = JSON.parse(await getPlainJson()) as Record<string, unknown>;
|
||||
}
|
||||
return plainJsonDocCache;
|
||||
};
|
||||
|
||||
const getEncryptedJson = async () => {
|
||||
if (!encryptedJsonCache) {
|
||||
encryptedJsonCache = await buildAccountEncryptedBitwardenJsonString({
|
||||
folders: rawFolders,
|
||||
ciphers: rawCiphers,
|
||||
userEncB64: session.symEncKey!,
|
||||
userMacB64: session.symMacKey!,
|
||||
});
|
||||
}
|
||||
return encryptedJsonCache;
|
||||
};
|
||||
|
||||
const zipAttachments = async (): Promise<ZipAttachmentEntry[]> => {
|
||||
const userEnc = base64ToBytes(session.symEncKey!);
|
||||
const userMac = base64ToBytes(session.symMacKey!);
|
||||
const out: ZipAttachmentEntry[] = [];
|
||||
const activeCiphers = rawCiphers.filter((cipher) => !cipher.deletedDate && !(cipher as { organizationId?: unknown }).organizationId);
|
||||
|
||||
for (const cipher of activeCiphers) {
|
||||
const cipherId = String(cipher.id || '').trim();
|
||||
if (!cipherId) continue;
|
||||
const attachments = Array.isArray(cipher.attachments) ? cipher.attachments : [];
|
||||
if (!attachments.length) continue;
|
||||
|
||||
let itemEnc = userEnc;
|
||||
let itemMac = userMac;
|
||||
const itemKey = String(cipher.key || '').trim();
|
||||
if (itemKey && looksLikeCipherString(itemKey)) {
|
||||
try {
|
||||
const rawItemKey = await decryptBw(itemKey, userEnc, userMac);
|
||||
if (rawItemKey.length >= 64) {
|
||||
itemEnc = rawItemKey.slice(0, 32);
|
||||
itemMac = rawItemKey.slice(32, 64);
|
||||
}
|
||||
} catch {
|
||||
// fallback to user key
|
||||
}
|
||||
}
|
||||
|
||||
for (const attachment of attachments) {
|
||||
const attachmentId = String(attachment?.id || '').trim();
|
||||
if (!attachmentId) continue;
|
||||
const info = await getAttachmentDownloadInfo(authedFetch, cipherId, attachmentId);
|
||||
const fileResp = await fetch(info.url, { cache: 'no-store' });
|
||||
if (!fileResp.ok) throw new Error(`Failed to download attachment ${attachmentId}`);
|
||||
const encryptedBytes = new Uint8Array(await fileResp.arrayBuffer());
|
||||
|
||||
let fileEnc = itemEnc;
|
||||
let fileMac = itemMac;
|
||||
const attachmentKeyCipher = String(info.key || attachment?.key || '').trim();
|
||||
if (attachmentKeyCipher && looksLikeCipherString(attachmentKeyCipher)) {
|
||||
try {
|
||||
const rawAttachmentKey = await decryptBw(attachmentKeyCipher, itemEnc, itemMac);
|
||||
if (rawAttachmentKey.length >= 64) {
|
||||
fileEnc = rawAttachmentKey.slice(0, 32);
|
||||
fileMac = rawAttachmentKey.slice(32, 64);
|
||||
}
|
||||
} catch {
|
||||
// fallback to item key
|
||||
}
|
||||
}
|
||||
|
||||
const plainBytes = await decryptBwFileData(encryptedBytes, fileEnc, fileMac);
|
||||
const fileNameRaw = String(info.fileName || attachment?.fileName || '').trim();
|
||||
let fileName = fileNameRaw || `attachment-${attachmentId}`;
|
||||
if (fileNameRaw && looksLikeCipherString(fileNameRaw)) {
|
||||
try {
|
||||
fileName = (await decryptStr(fileNameRaw, itemEnc, itemMac)) || fileName;
|
||||
} catch {
|
||||
// fallback to raw encrypted name
|
||||
}
|
||||
}
|
||||
|
||||
out.push({ cipherId, fileName, bytes: plainBytes });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const getNodeWardenAttachmentRecords = async () => {
|
||||
if (nodeWardenAttachmentsCache) return nodeWardenAttachmentsCache;
|
||||
const [doc, attachments] = await Promise.all([getPlainJsonDoc(), zipAttachments()]);
|
||||
const cipherIndexById = new Map<string, number>();
|
||||
const items = Array.isArray(doc.items) ? (doc.items as Array<Record<string, unknown>>) : [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const id = String(items[i]?.id || '').trim();
|
||||
if (id) cipherIndexById.set(id, i);
|
||||
}
|
||||
nodeWardenAttachmentsCache = buildNodeWardenAttachmentRecords(attachments, cipherIndexById);
|
||||
return nodeWardenAttachmentsCache;
|
||||
};
|
||||
|
||||
let result: { fileName: string; mimeType: string; bytes: Uint8Array } | null = null;
|
||||
const format = request.format;
|
||||
|
||||
if (format === 'bitwarden_json') {
|
||||
result = {
|
||||
fileName: buildExportFileName(format),
|
||||
mimeType: 'application/json',
|
||||
bytes: new TextEncoder().encode(await getPlainJson()),
|
||||
};
|
||||
} else if (format === 'bitwarden_encrypted_json') {
|
||||
if (request.encryptedJsonMode === 'password') {
|
||||
const plainJson = await getPlainJson();
|
||||
const kdf = await getPreloginKdfConfig(profile?.email || session.email, defaultKdfIterations);
|
||||
const encrypted = await buildPasswordProtectedBitwardenJsonString({
|
||||
plaintextJson: plainJson,
|
||||
password: String(request.filePassword || ''),
|
||||
kdf,
|
||||
});
|
||||
result = {
|
||||
fileName: buildExportFileName(format),
|
||||
mimeType: 'application/json',
|
||||
bytes: new TextEncoder().encode(encrypted),
|
||||
};
|
||||
} else {
|
||||
result = {
|
||||
fileName: buildExportFileName(format),
|
||||
mimeType: 'application/json',
|
||||
bytes: new TextEncoder().encode(await getEncryptedJson()),
|
||||
};
|
||||
}
|
||||
} else if (format === 'nodewarden_json') {
|
||||
const [plainDoc, attachments] = await Promise.all([getPlainJsonDoc(), getNodeWardenAttachmentRecords()]);
|
||||
const nodeWardenDoc = buildNodeWardenPlainJsonDocument(plainDoc, attachments);
|
||||
result = {
|
||||
fileName: buildExportFileName(format),
|
||||
mimeType: 'application/json',
|
||||
bytes: new TextEncoder().encode(JSON.stringify(nodeWardenDoc, null, 2)),
|
||||
};
|
||||
} else if (format === 'nodewarden_encrypted_json') {
|
||||
if (request.encryptedJsonMode === 'password') {
|
||||
const [plainDoc, attachments] = await Promise.all([getPlainJsonDoc(), getNodeWardenAttachmentRecords()]);
|
||||
const nodeWardenDoc = buildNodeWardenPlainJsonDocument(plainDoc, attachments);
|
||||
const kdf = await getPreloginKdfConfig(profile?.email || session.email, defaultKdfIterations);
|
||||
const encrypted = await buildPasswordProtectedBitwardenJsonString({
|
||||
plaintextJson: JSON.stringify(nodeWardenDoc, null, 2),
|
||||
password: String(request.filePassword || ''),
|
||||
kdf,
|
||||
});
|
||||
result = {
|
||||
fileName: buildExportFileName(format),
|
||||
mimeType: 'application/json',
|
||||
bytes: new TextEncoder().encode(encrypted),
|
||||
};
|
||||
} else {
|
||||
const [encryptedJson, attachments] = await Promise.all([getEncryptedJson(), getNodeWardenAttachmentRecords()]);
|
||||
const withAttachments = await attachNodeWardenEncryptedAttachmentPayload(
|
||||
encryptedJson,
|
||||
attachments,
|
||||
session.symEncKey!,
|
||||
session.symMacKey!
|
||||
);
|
||||
result = {
|
||||
fileName: buildExportFileName(format),
|
||||
mimeType: 'application/json',
|
||||
bytes: new TextEncoder().encode(withAttachments),
|
||||
};
|
||||
}
|
||||
} else if (format === 'bitwarden_json_zip' || format === 'bitwarden_encrypted_json_zip') {
|
||||
let dataJson = await getPlainJson();
|
||||
if (format === 'bitwarden_encrypted_json_zip') {
|
||||
if (request.encryptedJsonMode === 'password') {
|
||||
const kdf = await getPreloginKdfConfig(profile?.email || session.email, defaultKdfIterations);
|
||||
dataJson = await buildPasswordProtectedBitwardenJsonString({
|
||||
plaintextJson: await getPlainJson(),
|
||||
password: String(request.filePassword || ''),
|
||||
kdf,
|
||||
});
|
||||
} else {
|
||||
dataJson = await getEncryptedJson();
|
||||
}
|
||||
}
|
||||
const attachments = await zipAttachments();
|
||||
const zipBytes = buildBitwardenZipBytes(dataJson, attachments);
|
||||
const encryptedZip = await encryptZipBytesWithPassword(zipBytes, String(request.zipPassword || ''));
|
||||
result = {
|
||||
fileName: buildExportFileName(format, encryptedZip.encrypted),
|
||||
mimeType: 'application/zip',
|
||||
bytes: encryptedZip.bytes,
|
||||
};
|
||||
}
|
||||
|
||||
if (!result) throw new Error(t('txt_unsupported_export_format'));
|
||||
downloadBytesAsFile(result.bytes, result.fileName, result.mimeType);
|
||||
},
|
||||
};
|
||||
}, [
|
||||
authedFetch,
|
||||
defaultKdfIterations,
|
||||
encryptedCiphers,
|
||||
encryptedFolders,
|
||||
importAuthedFetch,
|
||||
onNotify,
|
||||
profile,
|
||||
refetchCiphers,
|
||||
refetchFolders,
|
||||
refetchSends,
|
||||
session,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import {
|
||||
createAuthedFetch,
|
||||
deriveLoginHash,
|
||||
getProfile,
|
||||
getSetupStatus,
|
||||
getWebConfig,
|
||||
loadSession,
|
||||
loginWithPassword,
|
||||
refreshAccessToken,
|
||||
recoverTwoFactor,
|
||||
registerAccount,
|
||||
unlockVaultKey,
|
||||
} from '@/lib/api/auth';
|
||||
import type { AppPhase, Profile, SessionState } from '@/lib/types';
|
||||
|
||||
export interface PendingTotp {
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
masterKey: Uint8Array;
|
||||
}
|
||||
|
||||
export type JwtUnsafeReason = 'missing' | 'default' | 'too_short';
|
||||
|
||||
export interface BootstrapAppResult {
|
||||
setupRegistered: boolean;
|
||||
defaultKdfIterations: number;
|
||||
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
|
||||
session: SessionState | null;
|
||||
profile: Profile | null;
|
||||
phase: AppPhase;
|
||||
}
|
||||
|
||||
export interface CompletedLogin {
|
||||
session: SessionState;
|
||||
profile: Profile;
|
||||
}
|
||||
|
||||
export type PasswordLoginResult =
|
||||
| { kind: 'success'; login: CompletedLogin }
|
||||
| { kind: 'totp'; pendingTotp: PendingTotp }
|
||||
| { kind: 'error'; message: string };
|
||||
|
||||
export interface RecoverTwoFactorResult {
|
||||
login: CompletedLogin | null;
|
||||
newRecoveryCode: string | null;
|
||||
}
|
||||
|
||||
function decodeJwtExp(accessToken: string): number | null {
|
||||
try {
|
||||
const parts = accessToken.split('.');
|
||||
if (parts.length < 2) return null;
|
||||
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = payload.padEnd(Math.ceil(payload.length / 4) * 4, '=');
|
||||
const json = JSON.parse(atob(padded)) as { exp?: unknown };
|
||||
const exp = Number(json.exp);
|
||||
return Number.isFinite(exp) ? exp : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeRefreshSession(session: SessionState): Promise<SessionState | null> {
|
||||
if (!session.refreshToken) return session;
|
||||
const exp = decodeJwtExp(session.accessToken);
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (exp !== null && exp - nowSeconds > 60) {
|
||||
return session;
|
||||
}
|
||||
|
||||
const refreshed = await refreshAccessToken(session.refreshToken);
|
||||
if (!refreshed?.access_token) {
|
||||
return exp !== null && exp > nowSeconds ? session : null;
|
||||
}
|
||||
|
||||
return {
|
||||
...session,
|
||||
accessToken: refreshed.access_token,
|
||||
refreshToken: refreshed.refresh_token || session.refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
export async function bootstrapAppSession(): Promise<BootstrapAppResult> {
|
||||
const [setup, config] = await Promise.all([getSetupStatus(), getWebConfig()]);
|
||||
const setupRegistered = setup.registered;
|
||||
const defaultKdfIterations = Number(config.defaultKdfIterations || 600000);
|
||||
const jwtUnsafeReason = config.jwtUnsafeReason || null;
|
||||
|
||||
if (jwtUnsafeReason) {
|
||||
return {
|
||||
setupRegistered,
|
||||
defaultKdfIterations,
|
||||
jwtWarning: {
|
||||
reason: jwtUnsafeReason,
|
||||
minLength: Number(config.jwtSecretMinLength || 32),
|
||||
},
|
||||
session: null,
|
||||
profile: null,
|
||||
phase: 'login',
|
||||
};
|
||||
}
|
||||
|
||||
const loaded = loadSession();
|
||||
if (!loaded) {
|
||||
return {
|
||||
setupRegistered,
|
||||
defaultKdfIterations,
|
||||
jwtWarning: null,
|
||||
session: null,
|
||||
profile: null,
|
||||
phase: setupRegistered ? 'login' : 'register',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await maybeRefreshSession(loaded);
|
||||
if (!session) {
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
const profile = await getProfile(
|
||||
createAuthedFetch(
|
||||
() => session,
|
||||
() => {}
|
||||
)
|
||||
);
|
||||
return {
|
||||
setupRegistered,
|
||||
defaultKdfIterations,
|
||||
jwtWarning: null,
|
||||
session,
|
||||
profile,
|
||||
phase: 'locked',
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
setupRegistered,
|
||||
defaultKdfIterations,
|
||||
jwtWarning: null,
|
||||
session: null,
|
||||
profile: null,
|
||||
phase: setupRegistered ? 'login' : 'register',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function completeLogin(
|
||||
tokenAccess: string,
|
||||
tokenRefresh: string,
|
||||
email: string,
|
||||
masterKey: Uint8Array
|
||||
): Promise<CompletedLogin> {
|
||||
const baseSession: SessionState = { accessToken: tokenAccess, refreshToken: tokenRefresh, email };
|
||||
const tempFetch = createAuthedFetch(
|
||||
() => baseSession,
|
||||
() => {}
|
||||
);
|
||||
const profile = await getProfile(tempFetch);
|
||||
const keys = await unlockVaultKey(profile.key, masterKey);
|
||||
return {
|
||||
session: { ...baseSession, ...keys },
|
||||
profile,
|
||||
};
|
||||
}
|
||||
|
||||
export async function performPasswordLogin(
|
||||
email: string,
|
||||
password: string,
|
||||
fallbackIterations: number
|
||||
): Promise<PasswordLoginResult> {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const derived = await deriveLoginHash(normalizedEmail, password, fallbackIterations);
|
||||
const token = await loginWithPassword(normalizedEmail, derived.hash, { useRememberToken: true });
|
||||
|
||||
if ('access_token' in token && token.access_token) {
|
||||
return {
|
||||
kind: 'success',
|
||||
login: await completeLogin(token.access_token, token.refresh_token, normalizedEmail, derived.masterKey),
|
||||
};
|
||||
}
|
||||
|
||||
const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
||||
if (tokenError.TwoFactorProviders) {
|
||||
return {
|
||||
kind: 'totp',
|
||||
pendingTotp: {
|
||||
email: normalizedEmail,
|
||||
passwordHash: derived.hash,
|
||||
masterKey: derived.masterKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'error',
|
||||
message: tokenError.error_description || tokenError.error || 'Login failed',
|
||||
};
|
||||
}
|
||||
|
||||
export async function performTotpLogin(
|
||||
pendingTotp: PendingTotp,
|
||||
totpCode: string,
|
||||
rememberDevice: boolean
|
||||
): Promise<CompletedLogin> {
|
||||
const token = await loginWithPassword(pendingTotp.email, pendingTotp.passwordHash, {
|
||||
totpCode: totpCode.trim(),
|
||||
rememberDevice,
|
||||
});
|
||||
if ('access_token' in token && token.access_token) {
|
||||
return completeLogin(token.access_token, token.refresh_token, pendingTotp.email, pendingTotp.masterKey);
|
||||
}
|
||||
const tokenError = token as { error_description?: string; error?: string };
|
||||
throw new Error(tokenError.error_description || tokenError.error || 'TOTP verify failed');
|
||||
}
|
||||
|
||||
export async function performRecoverTwoFactorLogin(
|
||||
email: string,
|
||||
password: string,
|
||||
recoveryCode: string,
|
||||
fallbackIterations: number
|
||||
): Promise<RecoverTwoFactorResult> {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const derived = await deriveLoginHash(normalizedEmail, password, fallbackIterations);
|
||||
const recovered = await recoverTwoFactor(normalizedEmail, derived.hash, recoveryCode.trim());
|
||||
const token = await loginWithPassword(normalizedEmail, derived.hash, { useRememberToken: false });
|
||||
|
||||
if ('access_token' in token && token.access_token) {
|
||||
return {
|
||||
login: await completeLogin(token.access_token, token.refresh_token, normalizedEmail, derived.masterKey),
|
||||
newRecoveryCode: recovered.newRecoveryCode || null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
login: null,
|
||||
newRecoveryCode: recovered.newRecoveryCode || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function performRegistration(args: {
|
||||
email: string;
|
||||
name: string;
|
||||
password: string;
|
||||
inviteCode: string;
|
||||
fallbackIterations: number;
|
||||
}) {
|
||||
return registerAccount({
|
||||
email: args.email.trim().toLowerCase(),
|
||||
name: args.name.trim(),
|
||||
password: args.password,
|
||||
inviteCode: args.inviteCode.trim(),
|
||||
fallbackIterations: args.fallbackIterations,
|
||||
});
|
||||
}
|
||||
|
||||
export async function performUnlock(
|
||||
session: SessionState,
|
||||
profile: Profile,
|
||||
password: string,
|
||||
fallbackIterations: number
|
||||
): Promise<SessionState> {
|
||||
const derived = await deriveLoginHash(profile.email || session.email, password, fallbackIterations);
|
||||
const keys = await unlockVaultKey(profile.key, derived.masterKey);
|
||||
const refreshedSession = await maybeRefreshSession(session);
|
||||
if (!refreshedSession) {
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
return { ...refreshedSession, ...keys };
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { hkdf } from '@/lib/crypto';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { Cipher, VaultDraft } from '@/lib/types';
|
||||
import type { ImportResultSummary } from '@/components/ImportPage';
|
||||
|
||||
const SEND_KEY_SALT = 'bitwarden-send';
|
||||
const SEND_KEY_PURPOSE = 'send';
|
||||
const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e);
|
||||
|
||||
export interface WebVaultSignalRInvocation {
|
||||
type?: number;
|
||||
target?: string;
|
||||
arguments?: Array<{
|
||||
ContextId?: string | null;
|
||||
Type?: number;
|
||||
Payload?: {
|
||||
UserId?: string;
|
||||
Date?: string;
|
||||
RevisionDate?: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export function looksLikeCipherString(value: string): boolean {
|
||||
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
|
||||
}
|
||||
|
||||
export function asText(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function readInviteCodeFromUrl(): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
|
||||
const searchInvite = new URLSearchParams(window.location.search || '').get('invite');
|
||||
if (searchInvite && searchInvite.trim()) return searchInvite.trim();
|
||||
|
||||
const rawHash = String(window.location.hash || '');
|
||||
const queryIndex = rawHash.indexOf('?');
|
||||
if (queryIndex >= 0) {
|
||||
const hashInvite = new URLSearchParams(rawHash.slice(queryIndex + 1)).get('invite');
|
||||
if (hashInvite && hashInvite.trim()) return hashInvite.trim();
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function summarizeImportResult(
|
||||
ciphers: Array<Record<string, unknown>>,
|
||||
folderCount: number,
|
||||
attachmentSummary?: {
|
||||
total: number;
|
||||
imported: number;
|
||||
failed: Array<{ fileName: string; reason: string }>;
|
||||
}
|
||||
): ImportResultSummary {
|
||||
const typeLabel = (type: number): string => {
|
||||
if (type === 1) return t('txt_login');
|
||||
if (type === 2) return t('txt_secure_note');
|
||||
if (type === 3) return t('txt_card');
|
||||
if (type === 4) return t('txt_identity');
|
||||
if (type === 5) return t('txt_ssh_key');
|
||||
return t('txt_other');
|
||||
};
|
||||
const counter = new Map<number, number>();
|
||||
for (const raw of ciphers) {
|
||||
const cipherType = Number(raw?.type || 1) || 1;
|
||||
counter.set(cipherType, (counter.get(cipherType) || 0) + 1);
|
||||
}
|
||||
const order = [1, 2, 3, 4, 5];
|
||||
const seen = new Set<number>(order);
|
||||
const typeCounts = order
|
||||
.filter((type) => (counter.get(type) || 0) > 0)
|
||||
.map((type) => ({ label: typeLabel(type), count: counter.get(type) || 0 }));
|
||||
for (const [type, count] of counter.entries()) {
|
||||
if (!seen.has(type) && count > 0) typeCounts.push({ label: typeLabel(type), count });
|
||||
}
|
||||
return {
|
||||
totalItems: ciphers.length,
|
||||
folderCount: Math.max(0, folderCount),
|
||||
typeCounts,
|
||||
attachmentCount: Math.max(0, attachmentSummary?.total || 0),
|
||||
importedAttachmentCount: Math.max(0, attachmentSummary?.imported || 0),
|
||||
failedAttachments: attachmentSummary?.failed || [],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildEmptyImportDraft(type: number): VaultDraft {
|
||||
return {
|
||||
type,
|
||||
favorite: false,
|
||||
name: '',
|
||||
folderId: '',
|
||||
notes: '',
|
||||
reprompt: false,
|
||||
loginUsername: '',
|
||||
loginPassword: '',
|
||||
loginTotp: '',
|
||||
loginUris: [''],
|
||||
loginFido2Credentials: [],
|
||||
cardholderName: '',
|
||||
cardNumber: '',
|
||||
cardBrand: '',
|
||||
cardExpMonth: '',
|
||||
cardExpYear: '',
|
||||
cardCode: '',
|
||||
identTitle: '',
|
||||
identFirstName: '',
|
||||
identMiddleName: '',
|
||||
identLastName: '',
|
||||
identUsername: '',
|
||||
identCompany: '',
|
||||
identSsn: '',
|
||||
identPassportNumber: '',
|
||||
identLicenseNumber: '',
|
||||
identEmail: '',
|
||||
identPhone: '',
|
||||
identAddress1: '',
|
||||
identAddress2: '',
|
||||
identAddress3: '',
|
||||
identCity: '',
|
||||
identState: '',
|
||||
identPostalCode: '',
|
||||
identCountry: '',
|
||||
sshPrivateKey: '',
|
||||
sshPublicKey: '',
|
||||
sshFingerprint: '',
|
||||
customFields: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function importCipherToDraft(cipher: Record<string, unknown>, folderId: string | null): VaultDraft {
|
||||
const type = Number(cipher.type || 1) || 1;
|
||||
const draft = buildEmptyImportDraft(type);
|
||||
draft.name = asText(cipher.name).trim() || 'Untitled';
|
||||
draft.notes = asText(cipher.notes);
|
||||
draft.favorite = !!cipher.favorite;
|
||||
draft.reprompt = Number(cipher.reprompt || 0) === 1;
|
||||
draft.folderId = folderId || '';
|
||||
|
||||
const customFieldsRaw = Array.isArray(cipher.fields) ? cipher.fields : [];
|
||||
draft.customFields = customFieldsRaw
|
||||
.map((raw) => {
|
||||
const field = (raw || {}) as Record<string, unknown>;
|
||||
const label = asText(field.name).trim();
|
||||
if (!label) return null;
|
||||
const parsedType = Number(field.type ?? 0);
|
||||
const fieldType = parsedType === 1 || parsedType === 2 || parsedType === 3 ? (parsedType as 1 | 2 | 3) : 0;
|
||||
return {
|
||||
type: fieldType,
|
||||
label,
|
||||
value: asText(field.value),
|
||||
};
|
||||
})
|
||||
.filter((x): x is VaultDraft['customFields'][number] => !!x);
|
||||
|
||||
if (type === 1) {
|
||||
const login = (cipher.login || {}) as Record<string, unknown>;
|
||||
draft.loginUsername = asText(login.username);
|
||||
draft.loginPassword = asText(login.password);
|
||||
draft.loginTotp = asText(login.totp);
|
||||
draft.loginFido2Credentials = Array.isArray(login.fido2Credentials)
|
||||
? login.fido2Credentials
|
||||
.filter((credential): credential is Record<string, unknown> => !!credential && typeof credential === 'object')
|
||||
.map((credential) => ({ ...credential }))
|
||||
: [];
|
||||
const urisRaw = Array.isArray(login.uris) ? login.uris : [];
|
||||
const uris = urisRaw
|
||||
.map((u) => asText((u as Record<string, unknown>)?.uri).trim())
|
||||
.filter((u) => !!u);
|
||||
draft.loginUris = uris.length ? uris : [''];
|
||||
} else if (type === 3) {
|
||||
const card = (cipher.card || {}) as Record<string, unknown>;
|
||||
draft.cardholderName = asText(card.cardholderName);
|
||||
draft.cardNumber = asText(card.number);
|
||||
draft.cardBrand = asText(card.brand);
|
||||
draft.cardExpMonth = asText(card.expMonth);
|
||||
draft.cardExpYear = asText(card.expYear);
|
||||
draft.cardCode = asText(card.code);
|
||||
} else if (type === 4) {
|
||||
const identity = (cipher.identity || {}) as Record<string, unknown>;
|
||||
draft.identTitle = asText(identity.title);
|
||||
draft.identFirstName = asText(identity.firstName);
|
||||
draft.identMiddleName = asText(identity.middleName);
|
||||
draft.identLastName = asText(identity.lastName);
|
||||
draft.identUsername = asText(identity.username);
|
||||
draft.identCompany = asText(identity.company);
|
||||
draft.identSsn = asText(identity.ssn);
|
||||
draft.identPassportNumber = asText(identity.passportNumber);
|
||||
draft.identLicenseNumber = asText(identity.licenseNumber);
|
||||
draft.identEmail = asText(identity.email);
|
||||
draft.identPhone = asText(identity.phone);
|
||||
draft.identAddress1 = asText(identity.address1);
|
||||
draft.identAddress2 = asText(identity.address2);
|
||||
draft.identAddress3 = asText(identity.address3);
|
||||
draft.identCity = asText(identity.city);
|
||||
draft.identState = asText(identity.state);
|
||||
draft.identPostalCode = asText(identity.postalCode);
|
||||
draft.identCountry = asText(identity.country);
|
||||
} else if (type === 5) {
|
||||
const sshKey = (cipher.sshKey || {}) as Record<string, unknown>;
|
||||
draft.sshPrivateKey = asText(sshKey.privateKey);
|
||||
draft.sshPublicKey = asText(sshKey.publicKey);
|
||||
draft.sshFingerprint = asText(sshKey.keyFingerprint ?? sshKey.fingerprint);
|
||||
}
|
||||
|
||||
return draft;
|
||||
}
|
||||
|
||||
export function buildPublicSendUrl(origin: string, accessId: string, keyPart: string): string {
|
||||
return `${origin}/#/send/${accessId}/${keyPart}`;
|
||||
}
|
||||
|
||||
export function parseSignalRTextFrames(raw: string): WebVaultSignalRInvocation[] {
|
||||
return raw
|
||||
.split(SIGNALR_RECORD_SEPARATOR)
|
||||
.map((frame) => frame.trim())
|
||||
.filter(Boolean)
|
||||
.map((frame) => {
|
||||
try {
|
||||
return JSON.parse(frame) as WebVaultSignalRInvocation;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((frame): frame is WebVaultSignalRInvocation => !!frame);
|
||||
}
|
||||
|
||||
export async function deriveSendKeyParts(sendKeyMaterial: Uint8Array): Promise<{ enc: Uint8Array; mac: Uint8Array }> {
|
||||
if (sendKeyMaterial.length >= 64) {
|
||||
return { enc: sendKeyMaterial.slice(0, 32), mac: sendKeyMaterial.slice(32, 64) };
|
||||
}
|
||||
const derived = await hkdf(sendKeyMaterial, SEND_KEY_SALT, SEND_KEY_PURPOSE, 64);
|
||||
return { enc: derived.slice(0, 32), mac: derived.slice(32, 64) };
|
||||
}
|
||||
|
||||
export function findCipherById(ciphers: Cipher[], id: string): Cipher | null {
|
||||
return ciphers.find((cipher) => cipher.id === id) || null;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export function downloadBytesAsFile(bytes: Uint8Array, fileName: string, mimeType: string): void {
|
||||
const payload = bytes.slice();
|
||||
const blob = new Blob([payload], { type: mimeType || 'application/octet-stream' });
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = fileName || 'download.bin';
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||
|
||||
export type CsvRow = Record<string, string>;
|
||||
|
||||
export function txt(v: unknown): string {
|
||||
if (v === null || v === undefined) return '';
|
||||
return String(v).trim();
|
||||
}
|
||||
|
||||
export function val(v: unknown, fallback: string | null = null): string | null {
|
||||
const s = txt(v);
|
||||
return s ? s : fallback;
|
||||
}
|
||||
|
||||
export function normalizeUri(raw: string): string | null {
|
||||
const s = txt(raw);
|
||||
if (!s) return null;
|
||||
if (!s.includes('://') && s.includes('.')) return (`http://${s}`).slice(0, 1000);
|
||||
return s.slice(0, 1000);
|
||||
}
|
||||
|
||||
export function nameFromUrl(raw: string): string | null {
|
||||
const uri = normalizeUri(raw);
|
||||
if (!uri) return null;
|
||||
try {
|
||||
const host = new URL(uri).hostname || '';
|
||||
if (!host) return null;
|
||||
return host.startsWith('www.') ? host.slice(4) : host;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function convertToNoteIfNeeded(cipher: Record<string, unknown>): void {
|
||||
if (Number(cipher.type || 1) !== 1) return;
|
||||
const login = cipher.login as Record<string, unknown> | null;
|
||||
const hasLoginData =
|
||||
!!txt(login?.username) ||
|
||||
!!txt(login?.password) ||
|
||||
!!txt(login?.totp) ||
|
||||
(Array.isArray(login?.uris) && login!.uris.length > 0);
|
||||
if (hasLoginData) return;
|
||||
cipher.type = 2;
|
||||
cipher.login = null;
|
||||
cipher.secureNote = { type: 0 };
|
||||
}
|
||||
|
||||
export function splitFullName(
|
||||
fullName: string | null
|
||||
): { firstName: string | null; middleName: string | null; lastName: string | null } {
|
||||
const parts = txt(fullName).split(/\s+/).filter(Boolean);
|
||||
return {
|
||||
firstName: parts[0] || null,
|
||||
middleName: parts.length > 2 ? parts.slice(1, -1).join(' ') : null,
|
||||
lastName: parts.length > 1 ? parts[parts.length - 1] : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseEpochMaybe(epoch: unknown): string | null {
|
||||
const n = Number(epoch);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
const ms = n >= 1_000_000_000_000 ? n : n * 1000;
|
||||
const d = new Date(ms);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
export function parseCardExpiry(raw: string): { month: string | null; year: string | null } {
|
||||
const s = txt(raw);
|
||||
if (!s) return { month: null, year: null };
|
||||
const yyyymm = s.match(/^(\d{4})(\d{2})$/);
|
||||
if (yyyymm) return { month: String(Number(yyyymm[2])), year: yyyymm[1] };
|
||||
const mmYYYY = s.match(/^(\d{1,2})\/(\d{4})$/);
|
||||
if (mmYYYY) return { month: String(Number(mmYYYY[1])), year: mmYYYY[2] };
|
||||
const mmYY = s.match(/^(\d{1,2})\/(\d{2})$/);
|
||||
if (mmYY) return { month: String(Number(mmYY[1])), year: `20${mmYY[2]}` };
|
||||
const dashed = s.match(/^(\d{4})-(\d{2})/);
|
||||
if (dashed) return { month: String(Number(dashed[2])), year: dashed[1] };
|
||||
return { month: null, year: null };
|
||||
}
|
||||
|
||||
export function parseCsv(raw: string): CsvRow[] {
|
||||
const rows: string[][] = [];
|
||||
let cell = '';
|
||||
let row: string[] = [];
|
||||
let inQuotes = false;
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const ch = raw[i];
|
||||
if (inQuotes) {
|
||||
if (ch === '"') {
|
||||
if (raw[i + 1] === '"') {
|
||||
cell += '"';
|
||||
i++;
|
||||
} else inQuotes = false;
|
||||
} else cell += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inQuotes = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === ',') {
|
||||
row.push(cell);
|
||||
cell = '';
|
||||
continue;
|
||||
}
|
||||
if (ch === '\n') {
|
||||
row.push(cell);
|
||||
rows.push(row);
|
||||
row = [];
|
||||
cell = '';
|
||||
continue;
|
||||
}
|
||||
if (ch === '\r') continue;
|
||||
cell += ch;
|
||||
}
|
||||
row.push(cell);
|
||||
rows.push(row);
|
||||
const nonEmpty = rows.filter((r) => r.some((c) => txt(c)));
|
||||
if (!nonEmpty.length) return [];
|
||||
const headers = nonEmpty[0].map((h) => txt(h));
|
||||
const out: CsvRow[] = [];
|
||||
for (let i = 1; i < nonEmpty.length; i++) {
|
||||
const values = nonEmpty[i];
|
||||
const obj: CsvRow = {};
|
||||
for (let c = 0; c < headers.length; c++) {
|
||||
if (headers[c]) obj[headers[c]] = values[c] ?? '';
|
||||
}
|
||||
out.push(obj);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseCsvRows(raw: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let cell = '';
|
||||
let row: string[] = [];
|
||||
let inQuotes = false;
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const ch = raw[i];
|
||||
if (inQuotes) {
|
||||
if (ch === '"') {
|
||||
if (raw[i + 1] === '"') {
|
||||
cell += '"';
|
||||
i++;
|
||||
} else inQuotes = false;
|
||||
} else cell += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inQuotes = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === ',') {
|
||||
row.push(cell);
|
||||
cell = '';
|
||||
continue;
|
||||
}
|
||||
if (ch === '\n') {
|
||||
row.push(cell);
|
||||
rows.push(row);
|
||||
row = [];
|
||||
cell = '';
|
||||
continue;
|
||||
}
|
||||
if (ch === '\r') continue;
|
||||
cell += ch;
|
||||
}
|
||||
row.push(cell);
|
||||
rows.push(row);
|
||||
return rows.filter((r) => r.some((c) => txt(c)));
|
||||
}
|
||||
|
||||
export function processKvp(cipher: Record<string, unknown>, key: string, value: string, hidden = false): void {
|
||||
const k = txt(key);
|
||||
const v = txt(value);
|
||||
if (!v) return;
|
||||
const fields = Array.isArray(cipher.fields) ? (cipher.fields as Array<Record<string, unknown>>) : [];
|
||||
if (v.length > 200 || /\r\n|\r|\n/.test(v)) {
|
||||
const existing = txt(cipher.notes);
|
||||
cipher.notes = `${existing}${existing ? '\n' : ''}${k ? `${k}: ` : ''}${v}`;
|
||||
return;
|
||||
}
|
||||
fields.push({ type: hidden ? 1 : 0, name: k, value: v, linkedId: null });
|
||||
cipher.fields = fields;
|
||||
}
|
||||
|
||||
export function makeLoginCipher(): Record<string, unknown> {
|
||||
return {
|
||||
type: 1,
|
||||
name: '--',
|
||||
notes: null,
|
||||
favorite: false,
|
||||
reprompt: 0,
|
||||
key: null,
|
||||
login: { username: null, password: null, totp: null, fido2Credentials: null, uris: null },
|
||||
card: null,
|
||||
identity: null,
|
||||
secureNote: null,
|
||||
fields: [],
|
||||
passwordHistory: null,
|
||||
sshKey: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function addFolder(result: CiphersImportPayload, folderName: string, cipherIndex: number): void {
|
||||
const name = txt(folderName).replace(/\\/g, '/');
|
||||
if (!name || name === '(none)') return;
|
||||
let i = result.folders.findIndex((f) => f.name === name);
|
||||
if (i < 0) {
|
||||
i = result.folders.length;
|
||||
result.folders.push({ name });
|
||||
}
|
||||
result.folderRelationships.push({ key: cipherIndex, value: i });
|
||||
}
|
||||
|
||||
export function cardBrand(number: string | null): string | null {
|
||||
const n = txt(number).replace(/\s+/g, '');
|
||||
if (!n) return null;
|
||||
if (/^4/.test(n)) return 'Visa';
|
||||
if (/^(5[1-5]|2[2-7])/.test(n)) return 'Mastercard';
|
||||
if (/^3[47]/.test(n)) return 'Amex';
|
||||
if (/^6(?:011|5)/.test(n)) return 'Discover';
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
type ImportSourceEntry = { id: string; label: string };
|
||||
|
||||
export const IMPORT_SOURCES = [
|
||||
{ id: 'bitwarden_json', label: 'Bitwarden (json)' },
|
||||
{ id: 'bitwarden_csv', label: 'Bitwarden (csv)' },
|
||||
{ id: 'bitwarden_zip', label: 'Bitwarden (zip)' },
|
||||
{ id: 'nodewarden_json', label: 'NodeWarden (json)' },
|
||||
{ id: 'onepassword_1pux', label: '1Password (1pux/json)' },
|
||||
{ id: 'onepassword_1pif', label: '1Password (1pif)' },
|
||||
{ id: 'onepassword_mac_csv', label: '1Password 6 and 7 Mac (csv)' },
|
||||
{ id: 'onepassword_win_csv', label: '1Password 6 and 7 Windows (csv)' },
|
||||
{ id: 'protonpass_json', label: 'ProtonPass (json/zip)' },
|
||||
{ id: 'avira_csv', label: 'Avira (csv)' },
|
||||
{ id: 'avast_csv', label: 'Avast Passwords (csv)' },
|
||||
{ id: 'avast_json', label: 'Avast Passwords (json)' },
|
||||
{ id: 'chrome', label: 'Chrome' },
|
||||
{ id: 'edge', label: 'Edge' },
|
||||
{ id: 'brave', label: 'Brave' },
|
||||
{ id: 'opera', label: 'Opera' },
|
||||
{ id: 'vivaldi', label: 'Vivaldi' },
|
||||
{ id: 'firefox_csv', label: 'Firefox (csv)' },
|
||||
{ id: 'safari_csv', label: 'Safari and macOS (csv)' },
|
||||
{ id: 'lastpass', label: 'LastPass (csv)' },
|
||||
{ id: 'dashlane_csv', label: 'Dashlane (csv)' },
|
||||
{ id: 'dashlane_json', label: 'Dashlane (json)' },
|
||||
{ id: 'keepass_xml', label: 'KeePass 2 (xml)' },
|
||||
{ id: 'keepassx_csv', label: 'KeePassX (csv)' },
|
||||
{ id: 'arc_csv', label: 'Arc (csv)' },
|
||||
{ id: 'ascendo_csv', label: 'Ascendo DataVault (csv)' },
|
||||
{ id: 'blackberry_csv', label: 'BlackBerry Password Keeper (csv)' },
|
||||
{ id: 'blur_csv', label: 'Blur (csv)' },
|
||||
{ id: 'buttercup_csv', label: 'Buttercup (csv)' },
|
||||
{ id: 'codebook_csv', label: 'Codebook (csv)' },
|
||||
{ id: 'encryptr_csv', label: 'Encryptr (csv)' },
|
||||
{ id: 'enpass_csv', label: 'Enpass (csv)' },
|
||||
{ id: 'enpass_json', label: 'Enpass (json)' },
|
||||
{ id: 'keeper_csv', label: 'Keeper (csv)' },
|
||||
{ id: 'keeper_json', label: 'Keeper (json)' },
|
||||
{ id: 'logmeonce_csv', label: 'LogMeOnce (csv)' },
|
||||
{ id: 'meldium_csv', label: 'Meldium (csv)' },
|
||||
{ id: 'msecure_csv', label: 'mSecure (csv)' },
|
||||
{ id: 'myki_csv', label: 'Myki (csv)' },
|
||||
{ id: 'netwrix_csv', label: 'Netwrix Password Secure (csv)' },
|
||||
{ id: 'nordpass_csv', label: 'NordPass (csv)' },
|
||||
{ id: 'roboform_csv', label: 'RoboForm (csv)' },
|
||||
{ id: 'zohovault_csv', label: 'Zoho Vault (csv)' },
|
||||
{ id: 'passman_json', label: 'Passman (json)' },
|
||||
{ id: 'passky_json', label: 'Passky (json)' },
|
||||
{ id: 'psono_json', label: 'Psono (json)' },
|
||||
{ id: 'passwordboss_json', label: 'Password Boss (json)' },
|
||||
] as const satisfies readonly ImportSourceEntry[];
|
||||
|
||||
export type ImportSourceId = (typeof IMPORT_SOURCES)[number]['id'];
|
||||
|
||||
export function getFileAcceptBySource(source: ImportSourceId): string {
|
||||
if (source === 'bitwarden_zip') return '.zip,application/zip,application/x-zip-compressed';
|
||||
if (
|
||||
source === 'bitwarden_json' ||
|
||||
source === 'nodewarden_json' ||
|
||||
source === 'onepassword_1pux' ||
|
||||
source === 'protonpass_json' ||
|
||||
source === 'avast_json' ||
|
||||
source === 'dashlane_json' ||
|
||||
source === 'enpass_json' ||
|
||||
source === 'keeper_json' ||
|
||||
source === 'passman_json' ||
|
||||
source === 'passky_json' ||
|
||||
source === 'psono_json' ||
|
||||
source === 'passwordboss_json'
|
||||
) {
|
||||
if (source === 'onepassword_1pux') return '.1pux,.zip,.json,application/zip,application/json';
|
||||
if (source === 'protonpass_json') return '.zip,.json,application/zip,application/json';
|
||||
return '.json,application/json';
|
||||
}
|
||||
if (source === 'onepassword_1pif') return '.1pif,.txt,.json,text/plain,application/json';
|
||||
if (source === 'keepass_xml') return '.xml,text/xml,application/xml';
|
||||
return '.csv,text/csv';
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||
import {
|
||||
addFolder,
|
||||
cardBrand,
|
||||
type CsvRow,
|
||||
convertToNoteIfNeeded,
|
||||
makeLoginCipher,
|
||||
normalizeUri,
|
||||
parseCsv,
|
||||
parseCsvRows,
|
||||
processKvp,
|
||||
txt,
|
||||
val,
|
||||
} from '@/lib/import-format-shared';
|
||||
|
||||
function splitPipedField(raw: string): string {
|
||||
const s = txt(raw);
|
||||
if (!s) return '';
|
||||
const p = s.split('|');
|
||||
if (p.length <= 2) return s;
|
||||
return [...p.slice(0, 2), p.slice(2).join('|')].pop() || '';
|
||||
}
|
||||
|
||||
export function parseMSecureCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsvRows(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
for (const row of rows) {
|
||||
if (row.length < 3) continue;
|
||||
const folderName = txt(row[2]) && txt(row[2]) !== 'Unassigned' ? row[2] : '';
|
||||
const type = txt(row[1]);
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.name = val(txt(row[0]).split('|')[0], '--');
|
||||
|
||||
if (type === 'Web Logins' || type === 'Login') {
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
login.username = val(splitPipedField(row[5] || ''));
|
||||
login.password = val(splitPipedField(row[6] || ''));
|
||||
const uri = normalizeUri(splitPipedField(row[4] || '') || '');
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
cipher.notes = val((row[3] || '').split('\\n').join('\n'));
|
||||
} else if (type === 'Credit Card') {
|
||||
cipher.type = 3;
|
||||
cipher.login = null;
|
||||
const cardNumber = val(splitPipedField(row[4] || ''));
|
||||
let expMonth: string | null = null;
|
||||
let expYear: string | null = null;
|
||||
const exp = splitPipedField(row[5] || '');
|
||||
const m = exp.match(/^(\d{1,2})\s*\/\s*(\d{2,4})$/);
|
||||
if (m) {
|
||||
expMonth = m[1];
|
||||
expYear = m[2].length === 2 ? `20${m[2]}` : m[2];
|
||||
}
|
||||
let code: string | null = null;
|
||||
let holder: string | null = null;
|
||||
for (const entry of row) {
|
||||
if (/^Security Code\|\d*\|/.test(entry)) code = val(splitPipedField(entry));
|
||||
if (/^Name on Card\|\d*\|/.test(entry)) holder = val(splitPipedField(entry));
|
||||
}
|
||||
const noteRegex = /\|\d*\|/;
|
||||
const rawNotes = row.slice(2).filter((entry) => txt(entry) && !noteRegex.test(entry));
|
||||
const indexedNotes = [8, 10, 11]
|
||||
.filter((idx) => row[idx] && noteRegex.test(row[idx]))
|
||||
.map((idx) => `${txt(row[idx]).split('|')[0]}: ${splitPipedField(row[idx])}`);
|
||||
cipher.notes = [...rawNotes, ...indexedNotes].join('\n') || null;
|
||||
cipher.card = {
|
||||
number: cardNumber,
|
||||
cardholderName: holder,
|
||||
code,
|
||||
expMonth,
|
||||
expYear,
|
||||
brand: cardBrand(cardNumber),
|
||||
};
|
||||
} else if (row.length > 3) {
|
||||
cipher.type = 2;
|
||||
cipher.login = null;
|
||||
cipher.secureNote = { type: 0 };
|
||||
const noteLines: string[] = [];
|
||||
for (let i = 3; i < row.length; i++) {
|
||||
if (txt(row[i])) noteLines.push(row[i]);
|
||||
}
|
||||
cipher.notes = noteLines.join('\n') || null;
|
||||
}
|
||||
|
||||
if (txt(type) && Number(cipher.type) !== 1 && Number(cipher.type) !== 3) {
|
||||
cipher.name = `${type}: ${txt(cipher.name)}`;
|
||||
}
|
||||
const idx = result.ciphers.push(cipher) - 1;
|
||||
addFolder(result, folderName, idx);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseMykiCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsv(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
const mappedBase = new Set(['nickname', 'additionalInfo']);
|
||||
|
||||
function unmapped(cipher: Record<string, unknown>, row: CsvRow, mapped: Set<string>): void {
|
||||
for (const key of Object.keys(row)) {
|
||||
if (mapped.has(key)) continue;
|
||||
processKvp(cipher, key, row[key], false);
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.name = val(row.nickname, '--');
|
||||
cipher.notes = val(txt(row.additionalInfo).replace(/\s+$/g, ''));
|
||||
|
||||
if (row.url !== undefined) {
|
||||
const mapped = new Set([...mappedBase, 'url', 'username', 'password', 'twofaSecret']);
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
const uri = normalizeUri(row.url || '');
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
login.username = val(row.username);
|
||||
login.password = val(row.password);
|
||||
login.totp = val(row.twofaSecret);
|
||||
unmapped(cipher, row, mapped);
|
||||
} else if (row.authToken !== undefined) {
|
||||
const mapped = new Set([...mappedBase, 'authToken']);
|
||||
(cipher.login as Record<string, unknown>).totp = val(row.authToken);
|
||||
unmapped(cipher, row, mapped);
|
||||
} else if (row.cardNumber !== undefined) {
|
||||
const mapped = new Set([...mappedBase, 'cardNumber', 'cardName', 'exp_month', 'exp_year', 'cvv']);
|
||||
cipher.type = 3;
|
||||
cipher.login = null;
|
||||
cipher.card = {
|
||||
cardholderName: val(row.cardName),
|
||||
number: val(row.cardNumber),
|
||||
brand: cardBrand(val(row.cardNumber)),
|
||||
expMonth: val(row.exp_month),
|
||||
expYear: val(row.exp_year),
|
||||
code: val(row.cvv),
|
||||
};
|
||||
unmapped(cipher, row, mapped);
|
||||
} else if (row.firstName !== undefined) {
|
||||
const mapped = new Set([
|
||||
...mappedBase,
|
||||
'title',
|
||||
'firstName',
|
||||
'middleName',
|
||||
'lastName',
|
||||
'email',
|
||||
'firstAddressLine',
|
||||
'secondAddressLine',
|
||||
'city',
|
||||
'country',
|
||||
'zipCode',
|
||||
]);
|
||||
cipher.type = 4;
|
||||
cipher.login = null;
|
||||
cipher.identity = {
|
||||
title: val(row.title),
|
||||
firstName: val(row.firstName),
|
||||
middleName: val(row.middleName),
|
||||
lastName: val(row.lastName),
|
||||
phone: val((row as Record<string, string>).number),
|
||||
email: val(row.email),
|
||||
address1: val(row.firstAddressLine),
|
||||
address2: val(row.secondAddressLine),
|
||||
city: val(row.city),
|
||||
country: val(row.country),
|
||||
postalCode: val(row.zipCode),
|
||||
};
|
||||
unmapped(cipher, row, mapped);
|
||||
} else if (row.idType !== undefined) {
|
||||
const mapped = new Set([...mappedBase, 'idName', 'idNumber', 'idCountry']);
|
||||
const fullName = txt((row as Record<string, string>).idName);
|
||||
const parts = fullName.split(/\s+/).filter(Boolean);
|
||||
const idType = txt((row as Record<string, string>).idType);
|
||||
const idNumber = val((row as Record<string, string>).idNumber);
|
||||
cipher.type = 4;
|
||||
cipher.login = null;
|
||||
cipher.identity = {
|
||||
firstName: parts[0] || null,
|
||||
middleName: parts.length >= 3 ? parts[1] : null,
|
||||
lastName: parts.length >= 2 ? parts.slice(parts.length >= 3 ? 2 : 1).join(' ') : null,
|
||||
country: val((row as Record<string, string>).idCountry),
|
||||
passportNumber: idType === 'Passport' ? idNumber : null,
|
||||
ssn: idType === 'Social Security' ? idNumber : null,
|
||||
licenseNumber: idType !== 'Passport' && idType !== 'Social Security' ? idNumber : null,
|
||||
};
|
||||
unmapped(cipher, row, mapped);
|
||||
} else if (row.content !== undefined) {
|
||||
const mapped = new Set([...mappedBase, 'content']);
|
||||
cipher.type = 2;
|
||||
cipher.login = null;
|
||||
cipher.secureNote = { type: 0 };
|
||||
cipher.notes = val(txt(row.content).replace(/\s+$/g, ''));
|
||||
unmapped(cipher, row, mapped);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
result.ciphers.push(cipher);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseNetwrixCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsv(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
const mapped = new Set(['Organisationseinheit', 'Informationen', 'Beschreibung', 'Benutzername', 'Passwort', 'Internetseite', 'One-Time Passwort']);
|
||||
for (const row of rows) {
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.notes = val(txt(row.Informationen).replace(/\s+$/g, ''));
|
||||
cipher.name = val(row.Beschreibung, '--');
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
login.username = val(row.Benutzername);
|
||||
login.password = val(row.Passwort);
|
||||
login.totp = val((row as Record<string, string>)['One-Time Passwort']);
|
||||
const uri = normalizeUri(row.Internetseite || '');
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
for (const key of Object.keys(row)) {
|
||||
if (mapped.has(key)) continue;
|
||||
processKvp(cipher, key, row[key], false);
|
||||
}
|
||||
const idx = result.ciphers.push(cipher) - 1;
|
||||
addFolder(result, row.Organisationseinheit, idx);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseRoboFormCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsv(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
for (const row of rows) {
|
||||
const cipher = makeLoginCipher();
|
||||
const folder = txt(row.Folder).startsWith('/') ? txt(row.Folder).slice(1) : txt(row.Folder);
|
||||
cipher.notes = val(row.Note);
|
||||
cipher.name = val(row.Name, '--');
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
login.username = val(row.Login);
|
||||
login.password = val(row.Pwd, val(row.Password));
|
||||
const uri = normalizeUri(row.Url || row.URL || '');
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
if (txt(row.Rf_fields)) processKvp(cipher, 'Rf_fields', txt(row.Rf_fields), true);
|
||||
if (txt(row.RfFieldsV2)) processKvp(cipher, 'RfFieldsV2', txt(row.RfFieldsV2), true);
|
||||
|
||||
convertToNoteIfNeeded(cipher);
|
||||
const idx = result.ciphers.push(cipher) - 1;
|
||||
addFolder(result, folder, idx);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseZohoVaultCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsv(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
for (const row of rows) {
|
||||
if (!txt(row['Password Name']) && !txt(row['Secret Name'])) continue;
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.favorite = txt(row.Favorite) === '1';
|
||||
cipher.notes = val(row.Notes);
|
||||
cipher.name = val(row['Password Name'], val(row['Secret Name'], '--'));
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
const uri = normalizeUri(txt(row['Password URL']) || txt(row['Secret URL']));
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
login.totp = val(row.login_totp);
|
||||
|
||||
const parseData = (data: string) => {
|
||||
if (!txt(data)) return;
|
||||
for (const line of data.split(/\r?\n/)) {
|
||||
const pos = line.indexOf(':');
|
||||
if (pos < 0) continue;
|
||||
const key = txt(line.slice(0, pos));
|
||||
const value = txt(line.slice(pos + 1));
|
||||
if (!key || !value || key === 'SecretType') continue;
|
||||
const low = key.toLowerCase();
|
||||
if (!txt(login.username) && ['username', 'user', 'email', 'login', 'id'].includes(low)) login.username = value;
|
||||
else if (!txt(login.password) && ['password', 'pass', 'passwd'].includes(low)) login.password = value;
|
||||
else processKvp(cipher, key, value, false);
|
||||
}
|
||||
};
|
||||
parseData(txt(row.SecretData));
|
||||
parseData(txt(row.CustomData));
|
||||
|
||||
convertToNoteIfNeeded(cipher);
|
||||
const idx = result.ciphers.push(cipher) - 1;
|
||||
addFolder(result, row['Folder Name'], idx);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseNordpassCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsv(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
for (const r of rows) {
|
||||
const t = txt(r.type);
|
||||
if (!t) continue;
|
||||
if (t === 'password') {
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.name = val(r.name, '--');
|
||||
cipher.notes = val(r.note);
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
login.username = val(r.username);
|
||||
login.password = val(r.password);
|
||||
const uris: string[] = [];
|
||||
const main = normalizeUri(r.url || '');
|
||||
if (main) uris.push(main);
|
||||
if (txt(r.additional_urls)) {
|
||||
try {
|
||||
const extra = JSON.parse(r.additional_urls) as string[];
|
||||
for (const u of extra || []) {
|
||||
const n = normalizeUri(u || '');
|
||||
if (n) uris.push(n);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
login.uris = uris.length ? uris.map((u) => ({ uri: u, match: null })) : null;
|
||||
if (txt(r.custom_fields)) {
|
||||
try {
|
||||
const cfs = JSON.parse(r.custom_fields) as any[];
|
||||
for (const cf of cfs || []) processKvp(cipher, cf.label || '', cf.value || '', cf.type === 'hidden');
|
||||
} catch {}
|
||||
}
|
||||
const idx = result.ciphers.push(cipher) - 1;
|
||||
addFolder(result, r.folder, idx);
|
||||
continue;
|
||||
}
|
||||
if (t === 'note') {
|
||||
const idx =
|
||||
result.ciphers.push({
|
||||
type: 2,
|
||||
name: val(r.name, '--'),
|
||||
notes: val(r.note),
|
||||
favorite: false,
|
||||
reprompt: 0,
|
||||
key: null,
|
||||
login: null,
|
||||
card: null,
|
||||
identity: null,
|
||||
secureNote: { type: 0 },
|
||||
fields: null,
|
||||
passwordHistory: null,
|
||||
sshKey: null,
|
||||
}) - 1;
|
||||
addFolder(result, r.folder, idx);
|
||||
continue;
|
||||
}
|
||||
if (t === 'credit_card') {
|
||||
const idx =
|
||||
result.ciphers.push({
|
||||
type: 3,
|
||||
name: val(r.name, '--'),
|
||||
notes: val(r.note),
|
||||
favorite: false,
|
||||
reprompt: 0,
|
||||
key: null,
|
||||
login: null,
|
||||
card: {
|
||||
cardholderName: val(r.cardholdername),
|
||||
number: val(r.cardnumber),
|
||||
brand: cardBrand(val(r.cardnumber)),
|
||||
code: val(r.cvc),
|
||||
expMonth: val(r.expiry_month),
|
||||
expYear: val(r.expiry_year),
|
||||
},
|
||||
identity: null,
|
||||
secureNote: null,
|
||||
fields: null,
|
||||
passwordHistory: null,
|
||||
sshKey: null,
|
||||
}) - 1;
|
||||
addFolder(result, r.folder, idx);
|
||||
continue;
|
||||
}
|
||||
if (t === 'personal_info') {
|
||||
const identity = {
|
||||
title: val(r.title),
|
||||
firstName: val(r.first_name),
|
||||
middleName: val(r.middle_name),
|
||||
lastName: val(r.last_name),
|
||||
phone: val(r.phone_number),
|
||||
email: val(r.email),
|
||||
address1: val(r.address1),
|
||||
address2: val(r.address2),
|
||||
city: val(r.city),
|
||||
state: val(r.state),
|
||||
postalCode: val(r.postal_code),
|
||||
country: val(r.country),
|
||||
username: val(r.username),
|
||||
company: val(r.company),
|
||||
};
|
||||
const idx =
|
||||
result.ciphers.push({
|
||||
type: 4,
|
||||
name: val(r.name, '--'),
|
||||
notes: val(r.note),
|
||||
favorite: false,
|
||||
reprompt: 0,
|
||||
key: null,
|
||||
login: null,
|
||||
card: null,
|
||||
identity,
|
||||
secureNote: null,
|
||||
fields: null,
|
||||
passwordHistory: null,
|
||||
sshKey: null,
|
||||
}) - 1;
|
||||
addFolder(result, r.folder, idx);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parsePassmanJson(textRaw: string): CiphersImportPayload {
|
||||
const rows = JSON.parse(textRaw) as any[];
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
for (const c of rows || []) {
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.name = val(c.label, '--');
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
login.username = val(c.username, val(c.email));
|
||||
login.password = val(c.password);
|
||||
const uri = normalizeUri(c.url || '');
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
login.totp = val(c?.otp?.secret);
|
||||
const email = txt(c.email);
|
||||
const desc = txt(c.description);
|
||||
cipher.notes = `${login.username && email && txt(login.username) !== email ? `Email: ${email}\n` : ''}${desc}` || null;
|
||||
for (const cf of c.custom_fields || []) {
|
||||
const t = txt(cf.field_type);
|
||||
if (t === 'text' || t === 'password') processKvp(cipher, cf.label || '', cf.value || '', false);
|
||||
}
|
||||
const idx = result.ciphers.push(cipher) - 1;
|
||||
const folder = c?.tags?.[0]?.text;
|
||||
if (folder) addFolder(result, String(folder), idx);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parsePasskyJson(textRaw: string): CiphersImportPayload {
|
||||
const parsed = JSON.parse(textRaw) as { encrypted?: boolean; passwords?: any[] };
|
||||
if (parsed.encrypted === true) throw new Error('Unable to import an encrypted passky backup.');
|
||||
const list = Array.isArray(parsed.passwords) ? parsed.passwords : [];
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
for (const p of list) {
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.name = val(p.website, '--');
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
login.username = val(p.username);
|
||||
login.password = val(p.password);
|
||||
const uri = normalizeUri(String(p.website || ''));
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
cipher.notes = val(p.message);
|
||||
result.ciphers.push(cipher);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parsePsonoJson(textRaw: string): CiphersImportPayload {
|
||||
const parsed = JSON.parse(textRaw) as any;
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
|
||||
function parseItem(item: any, folderName: string | null) {
|
||||
if (!item || typeof item !== 'object') return;
|
||||
const type = txt(item.type);
|
||||
const cipher = makeLoginCipher();
|
||||
if (type === 'website_password') {
|
||||
cipher.name = val(item.website_password_title, '--');
|
||||
cipher.notes = val(item.website_password_notes);
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
login.username = val(item.website_password_username);
|
||||
login.password = val(item.website_password_password);
|
||||
const uri = normalizeUri(item.website_password_url || '');
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
const idx = result.ciphers.push(cipher) - 1;
|
||||
if (folderName) addFolder(result, folderName, idx);
|
||||
return;
|
||||
}
|
||||
if (type === 'application_password') {
|
||||
cipher.name = val(item.application_password_title, '--');
|
||||
cipher.notes = val(item.application_password_notes);
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
login.username = val(item.application_password_username);
|
||||
login.password = val(item.application_password_password);
|
||||
const idx = result.ciphers.push(cipher) - 1;
|
||||
if (folderName) addFolder(result, folderName, idx);
|
||||
return;
|
||||
}
|
||||
if (type === 'totp') {
|
||||
cipher.name = val(item.totp_title, '--');
|
||||
cipher.notes = val(item.totp_notes);
|
||||
(cipher.login as Record<string, unknown>).totp = val(item.totp_code);
|
||||
const idx = result.ciphers.push(cipher) - 1;
|
||||
if (folderName) addFolder(result, folderName, idx);
|
||||
return;
|
||||
}
|
||||
if (type === 'bookmark') {
|
||||
cipher.name = val(item.bookmark_title, '--');
|
||||
cipher.notes = val(item.bookmark_notes);
|
||||
const uri = normalizeUri(item.bookmark_url || '');
|
||||
(cipher.login as Record<string, unknown>).uris = uri ? [{ uri, match: null }] : null;
|
||||
const idx = result.ciphers.push(cipher) - 1;
|
||||
if (folderName) addFolder(result, folderName, idx);
|
||||
return;
|
||||
}
|
||||
if (type === 'note' || type === 'environment_variables') {
|
||||
const secure = {
|
||||
type: 2,
|
||||
name: val(type === 'note' ? item.note_title : item.environment_variables_title, '--'),
|
||||
notes: val(type === 'note' ? item.note_notes : item.environment_variables_notes),
|
||||
favorite: false,
|
||||
reprompt: 0,
|
||||
key: null,
|
||||
login: null,
|
||||
card: null,
|
||||
identity: null,
|
||||
secureNote: { type: 0 },
|
||||
fields: null,
|
||||
passwordHistory: null,
|
||||
sshKey: null,
|
||||
} as Record<string, unknown>;
|
||||
const idx = result.ciphers.push(secure) - 1;
|
||||
if (folderName) addFolder(result, folderName, idx);
|
||||
}
|
||||
}
|
||||
|
||||
function walkFolders(folders: any[], parent: string | null) {
|
||||
for (const f of folders || []) {
|
||||
const name = parent ? `${parent}/${txt(f.name)}` : txt(f.name);
|
||||
for (const item of f.items || []) parseItem(item, name);
|
||||
if (Array.isArray(f.folders)) walkFolders(f.folders, name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of parsed.items || []) parseItem(item, null);
|
||||
walkFolders(parsed.folders || [], null);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parsePasswordBossJson(textRaw: string): CiphersImportPayload {
|
||||
const parsed = JSON.parse(textRaw) as { folders?: any[]; items?: any[] };
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
const folderNameById = new Map<string, string>();
|
||||
for (const f of parsed.folders || []) {
|
||||
if (f?.id && f?.name) folderNameById.set(String(f.id), String(f.name));
|
||||
}
|
||||
for (const item of parsed.items || []) {
|
||||
const ids = item?.identifiers || {};
|
||||
const isCard = txt(item?.type) === 'CreditCard';
|
||||
const base = isCard
|
||||
? {
|
||||
type: 3,
|
||||
name: val(item?.name, '--'),
|
||||
notes: val(ids.notes),
|
||||
favorite: false,
|
||||
reprompt: 0,
|
||||
key: null,
|
||||
login: null,
|
||||
card: {
|
||||
number: val(ids.cardNumber),
|
||||
cardholderName: val(ids.nameOnCard),
|
||||
code: val(ids.security_code),
|
||||
brand: cardBrand(val(ids.cardNumber)),
|
||||
expMonth: null,
|
||||
expYear: null,
|
||||
},
|
||||
identity: null,
|
||||
secureNote: null,
|
||||
fields: [],
|
||||
passwordHistory: null,
|
||||
sshKey: null,
|
||||
}
|
||||
: makeLoginCipher();
|
||||
if (!isCard) {
|
||||
base.name = val(item?.name, '--');
|
||||
base.notes = val(ids.notes);
|
||||
const login = base.login as Record<string, unknown>;
|
||||
login.username = val(ids.username, val(ids.email));
|
||||
login.password = val(ids.password);
|
||||
login.totp = val(ids.totp);
|
||||
const uri = normalizeUri(item?.login_url || ids.url || '');
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
}
|
||||
if (Array.isArray(ids.custom_fields)) {
|
||||
for (const cf of ids.custom_fields) processKvp(base as Record<string, unknown>, cf?.name || '', cf?.value || '', false);
|
||||
}
|
||||
const idx = result.ciphers.push(base as Record<string, unknown>) - 1;
|
||||
const folderId = item?.folder;
|
||||
if (folderId && folderNameById.has(String(folderId))) addFolder(result, folderNameById.get(String(folderId)) || '', idx);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||
|
||||
export interface BitwardenFolderInput {
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
export interface BitwardenUriInput {
|
||||
uri?: string | null;
|
||||
match?: number | null;
|
||||
}
|
||||
|
||||
export interface BitwardenFieldInput {
|
||||
name?: string | null;
|
||||
value?: string | null;
|
||||
type?: number | null;
|
||||
linkedId?: number | null;
|
||||
}
|
||||
|
||||
export interface BitwardenCipherInput {
|
||||
id?: string | null;
|
||||
type?: number | null;
|
||||
name?: string | null;
|
||||
notes?: string | null;
|
||||
favorite?: boolean | null;
|
||||
reprompt?: number | null;
|
||||
key?: string | null;
|
||||
folderId?: string | null;
|
||||
login?: {
|
||||
uris?: BitwardenUriInput[] | null;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
totp?: string | null;
|
||||
fido2Credentials?: Array<Record<string, unknown>> | null;
|
||||
} | null;
|
||||
card?: Record<string, unknown> | null;
|
||||
identity?: Record<string, unknown> | null;
|
||||
secureNote?: { type?: number | null } | null;
|
||||
fields?: BitwardenFieldInput[] | null;
|
||||
passwordHistory?: Array<{ password?: string | null; lastUsedDate?: string | null }> | null;
|
||||
sshKey?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface BitwardenJsonInput {
|
||||
encrypted?: boolean;
|
||||
passwordProtected?: boolean;
|
||||
encKeyValidation_DO_NOT_EDIT?: string;
|
||||
collections?: Array<{ id?: string | null; name?: string | null }> | null;
|
||||
folders?: BitwardenFolderInput[] | null;
|
||||
items?: BitwardenCipherInput[] | null;
|
||||
}
|
||||
|
||||
function txt(v: unknown): string {
|
||||
if (v === null || v === undefined) return '';
|
||||
return String(v).trim();
|
||||
}
|
||||
|
||||
export function normalizeBitwardenImport(raw: unknown): CiphersImportPayload {
|
||||
const parsed = raw as BitwardenJsonInput | null;
|
||||
if (!parsed || typeof parsed !== 'object') throw new Error('Invalid Bitwarden JSON');
|
||||
if (parsed.encrypted === true) throw new Error('Encrypted export requires encrypted import flow.');
|
||||
|
||||
const foldersRaw = Array.isArray(parsed.folders) ? parsed.folders : [];
|
||||
const itemsRaw = Array.isArray(parsed.items) ? parsed.items : [];
|
||||
const folders: Array<{ name: string }> = [];
|
||||
const folderIndexById = new Map<string, number>();
|
||||
for (const folder of foldersRaw) {
|
||||
const name = txt(folder?.name);
|
||||
if (!name) continue;
|
||||
const idx = folders.length;
|
||||
folders.push({ name });
|
||||
const id = txt(folder?.id);
|
||||
if (id) folderIndexById.set(id, idx);
|
||||
}
|
||||
|
||||
const ciphers: Array<Record<string, unknown>> = [];
|
||||
const folderRelationships: Array<{ key: number; value: number }> = [];
|
||||
let hasAnyExplicitFolderLink = false;
|
||||
for (const item of itemsRaw) {
|
||||
ciphers.push({
|
||||
id: item?.id ?? null,
|
||||
type: Number(item?.type || 1) || 1,
|
||||
name: item?.name ?? 'Untitled',
|
||||
notes: item?.notes ?? null,
|
||||
favorite: !!item?.favorite,
|
||||
reprompt: Number(item?.reprompt ?? 0) || 0,
|
||||
key: item?.key ?? null,
|
||||
login: item?.login
|
||||
? {
|
||||
username: item.login.username ?? null,
|
||||
password: item.login.password ?? null,
|
||||
totp: item.login.totp ?? null,
|
||||
fido2Credentials: Array.isArray(item.login.fido2Credentials) ? item.login.fido2Credentials : null,
|
||||
uris: Array.isArray(item.login.uris)
|
||||
? item.login.uris.map((u) => ({ uri: u?.uri ?? null, match: u?.match ?? null }))
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
card: item?.card ?? null,
|
||||
identity: item?.identity ?? null,
|
||||
secureNote: item?.secureNote ?? null,
|
||||
fields: Array.isArray(item?.fields)
|
||||
? item.fields.map((f) => ({
|
||||
name: f?.name ?? null,
|
||||
value: f?.value ?? null,
|
||||
type: Number(f?.type ?? 0) || 0,
|
||||
linkedId: f?.linkedId ?? null,
|
||||
}))
|
||||
: null,
|
||||
passwordHistory: Array.isArray(item?.passwordHistory)
|
||||
? item.passwordHistory
|
||||
.map((x) => ({ password: x?.password ?? null, lastUsedDate: x?.lastUsedDate ?? null }))
|
||||
.filter((x) => !!x.password)
|
||||
: null,
|
||||
sshKey: item?.sshKey ?? null,
|
||||
});
|
||||
const folderId = txt(item?.folderId);
|
||||
if (!folderId) continue;
|
||||
const folderIndex = folderIndexById.get(folderId);
|
||||
if (folderIndex !== undefined) {
|
||||
hasAnyExplicitFolderLink = true;
|
||||
folderRelationships.push({ key: ciphers.length - 1, value: folderIndex });
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAnyExplicitFolderLink && folders.length === 1 && ciphers.length > 0) {
|
||||
for (let i = 0; i < ciphers.length; i++) {
|
||||
folderRelationships.push({ key: i, value: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
return { ciphers, folders, folderRelationships };
|
||||
}
|
||||
|
||||
export function normalizeBitwardenEncryptedAccountImport(raw: BitwardenJsonInput): CiphersImportPayload {
|
||||
const itemsRaw = Array.isArray(raw.items) ? raw.items : [];
|
||||
const foldersRaw = Array.isArray(raw.folders) ? raw.folders : [];
|
||||
if (!Array.isArray(raw.folders) && Array.isArray(raw.collections)) {
|
||||
throw new Error('Encrypted organization export is not supported yet.');
|
||||
}
|
||||
|
||||
const folders = foldersRaw.map((f) => ({ name: String(f?.name ?? '') }));
|
||||
const folderIndexByLegacyId = new Map<string, number>();
|
||||
for (let i = 0; i < foldersRaw.length; i++) {
|
||||
const folderId = txt(foldersRaw[i]?.id);
|
||||
if (folderId) folderIndexByLegacyId.set(folderId, i);
|
||||
}
|
||||
const ciphers = itemsRaw.map((x) => ({ ...(x as Record<string, unknown>) }));
|
||||
const folderRelationships: Array<{ key: number; value: number }> = [];
|
||||
for (let i = 0; i < itemsRaw.length; i++) {
|
||||
const folderId = txt(itemsRaw[i]?.folderId);
|
||||
if (!folderId) continue;
|
||||
const folderIndex = folderIndexByLegacyId.get(folderId);
|
||||
if (folderIndex !== undefined) folderRelationships.push({ key: i, value: folderIndex });
|
||||
}
|
||||
return { ciphers, folders, folderRelationships };
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||
import { addFolder, cardBrand, makeLoginCipher, nameFromUrl, normalizeUri, parseCsv, txt, val } from '@/lib/import-format-shared';
|
||||
|
||||
export function parseChromeCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsv(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
for (const row of rows) {
|
||||
const cipher = makeLoginCipher();
|
||||
const m = txt(row.url).match(/^android:\/\/.*@([^/]+)\//);
|
||||
const uri = m ? `androidapp://${m[1]}` : normalizeUri(row.url || '');
|
||||
cipher.name = val(row.name, m?.[1] || '--');
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
login.username = val(row.username);
|
||||
login.password = val(row.password);
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
cipher.notes = val(row.note);
|
||||
result.ciphers.push(cipher);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseFirefoxCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsv(textRaw).filter((r) => txt(r.url) !== 'chrome://FirefoxAccounts');
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
for (const row of rows) {
|
||||
const cipher = makeLoginCipher();
|
||||
const raw = val(row.url, val(row.hostname, '') || '') || '';
|
||||
let name: string | null = null;
|
||||
try {
|
||||
const host = new URL(normalizeUri(raw) || '').hostname || '';
|
||||
name = host.startsWith('www.') ? host.slice(4) : host || null;
|
||||
} catch {}
|
||||
cipher.name = val(name, '--');
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
login.username = val(row.username);
|
||||
login.password = val(row.password);
|
||||
const uri = normalizeUri(raw);
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
result.ciphers.push(cipher);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseSafariCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsv(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
for (const row of rows) {
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.name = val(row.Title, '--');
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
login.username = val(row.Username);
|
||||
login.password = val(row.Password);
|
||||
const uri = normalizeUri(row.Url || row.URL || '');
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
login.totp = val(row.OTPAuth);
|
||||
cipher.notes = val(row.Notes);
|
||||
result.ciphers.push(cipher);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseBitwardenCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsv(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
for (const row of rows) {
|
||||
const type = txt(row.type).toLowerCase() || 'login';
|
||||
if (type === 'note') {
|
||||
const idx =
|
||||
result.ciphers.push({
|
||||
type: 2,
|
||||
name: val(row.name, '--'),
|
||||
notes: val(row.notes),
|
||||
favorite: txt(row.favorite) === '1',
|
||||
reprompt: 0,
|
||||
key: null,
|
||||
login: null,
|
||||
card: null,
|
||||
identity: null,
|
||||
secureNote: { type: 0 },
|
||||
fields: null,
|
||||
passwordHistory: null,
|
||||
sshKey: null,
|
||||
}) - 1;
|
||||
addFolder(result, row.folder, idx);
|
||||
continue;
|
||||
}
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.name = val(row.name, '--');
|
||||
cipher.notes = val(row.notes);
|
||||
cipher.favorite = txt(row.favorite) === '1';
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
login.username = val(row.login_username);
|
||||
login.password = val(row.login_password);
|
||||
login.totp = val(row.login_totp);
|
||||
const uri = normalizeUri(row.login_uri || '');
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
const idx = result.ciphers.push(cipher) - 1;
|
||||
addFolder(result, row.folder, idx);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseAviraCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsv(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
for (const row of rows) {
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.name = val(row.name, val(nameFromUrl(row.website), '--'));
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
login.uris = normalizeUri(row.website || '') ? [{ uri: normalizeUri(row.website || ''), match: null }] : null;
|
||||
login.password = val(row.password);
|
||||
if (!txt(row.username) && txt(row.secondary_username)) {
|
||||
login.username = val(row.secondary_username);
|
||||
} else {
|
||||
login.username = val(row.username);
|
||||
cipher.notes = val(row.secondary_username);
|
||||
}
|
||||
result.ciphers.push(cipher);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseAvastCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsv(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
for (const row of rows) {
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.name = val(row.name, '--');
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
login.uris = normalizeUri(row.web || '') ? [{ uri: normalizeUri(row.web || ''), match: null }] : null;
|
||||
login.password = val(row.password);
|
||||
login.username = val(row.login);
|
||||
result.ciphers.push(cipher);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseAvastJson(textRaw: string): CiphersImportPayload {
|
||||
const parsed = JSON.parse(textRaw) as { logins?: any[]; notes?: any[]; cards?: any[] };
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
for (const value of parsed.logins || []) {
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.name = val(value?.custName, '--');
|
||||
cipher.notes = val(value?.note);
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
const uri = normalizeUri(value?.url || '');
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
login.password = val(value?.pwd);
|
||||
login.username = val(value?.loginName);
|
||||
result.ciphers.push(cipher);
|
||||
}
|
||||
for (const value of parsed.notes || []) {
|
||||
result.ciphers.push({
|
||||
type: 2,
|
||||
name: val(value?.label, '--'),
|
||||
notes: val(value?.text),
|
||||
favorite: false,
|
||||
reprompt: 0,
|
||||
key: null,
|
||||
login: null,
|
||||
card: null,
|
||||
identity: null,
|
||||
secureNote: { type: 0 },
|
||||
fields: null,
|
||||
passwordHistory: null,
|
||||
sshKey: null,
|
||||
});
|
||||
}
|
||||
for (const value of parsed.cards || []) {
|
||||
result.ciphers.push({
|
||||
type: 3,
|
||||
name: val(value?.custName, '--'),
|
||||
notes: val(value?.note),
|
||||
favorite: false,
|
||||
reprompt: 0,
|
||||
key: null,
|
||||
login: null,
|
||||
card: {
|
||||
cardholderName: val(value?.holderName),
|
||||
number: val(value?.cardNumber),
|
||||
code: val(value?.cvv),
|
||||
brand: cardBrand(val(value?.cardNumber)),
|
||||
expMonth: val(value?.expirationDate?.month),
|
||||
expYear: val(value?.expirationDate?.year),
|
||||
},
|
||||
identity: null,
|
||||
secureNote: null,
|
||||
fields: null,
|
||||
passwordHistory: null,
|
||||
sshKey: null,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||
import {
|
||||
addFolder,
|
||||
cardBrand,
|
||||
convertToNoteIfNeeded,
|
||||
makeLoginCipher,
|
||||
nameFromUrl,
|
||||
normalizeUri,
|
||||
parseCsv,
|
||||
parseCsvRows,
|
||||
processKvp,
|
||||
txt,
|
||||
val,
|
||||
} from '@/lib/import-format-shared';
|
||||
|
||||
export function parseArcCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsv(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
for (const row of rows) {
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.name = val(nameFromUrl(row.url), '--');
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
login.username = val(row.username);
|
||||
login.password = val(row.password);
|
||||
const uri = normalizeUri(row.url || '');
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
cipher.notes = val(row.note);
|
||||
result.ciphers.push(cipher);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseAscendoCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsvRows(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
for (const row of rows) {
|
||||
if (row.length < 2) continue;
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.name = val(row[0], '--');
|
||||
cipher.notes = val(row[row.length - 1]);
|
||||
if (row.length > 2 && row.length % 2 === 0) {
|
||||
for (let i = 0; i < row.length - 2; i += 2) {
|
||||
const field = txt(row[i + 1]);
|
||||
const fieldValue = txt(row[i + 2]);
|
||||
if (!field || !fieldValue) continue;
|
||||
const low = field.toLowerCase();
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
if (!txt(login.password) && ['password', 'pass', 'passwd'].includes(low)) login.password = fieldValue;
|
||||
else if (!txt(login.username) && ['username', 'user', 'email', 'login', 'id'].includes(low)) login.username = fieldValue;
|
||||
else if ((!Array.isArray(login.uris) || !login.uris.length) && ['url', 'uri', 'website', 'web site', 'host', 'hostname'].includes(low)) {
|
||||
const uri = normalizeUri(fieldValue);
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
} else processKvp(cipher, field, fieldValue, false);
|
||||
}
|
||||
}
|
||||
convertToNoteIfNeeded(cipher);
|
||||
result.ciphers.push(cipher);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseBlackberryCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsv(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
for (const row of rows) {
|
||||
if (txt(row.grouping) === 'list') continue;
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.favorite = txt(row.fav) === '1';
|
||||
cipher.name = val(row.name, '--');
|
||||
cipher.notes = val(row.extra);
|
||||
if (txt(row.grouping) !== 'note') {
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
const uri = normalizeUri(row.url || '');
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
login.password = val(row.password);
|
||||
login.username = val(row.username);
|
||||
}
|
||||
convertToNoteIfNeeded(cipher);
|
||||
result.ciphers.push(cipher);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseBlurCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsv(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
for (const row of rows) {
|
||||
const label = txt(row.label) === 'null' ? '' : txt(row.label);
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.name = val(label, val(nameFromUrl(row.domain), '--'));
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
const uri = normalizeUri(row.domain || '');
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
login.password = val(row.password);
|
||||
if (!txt(row.email) && txt(row.username)) login.username = val(row.username);
|
||||
else {
|
||||
login.username = val(row.email);
|
||||
cipher.notes = val(row.username);
|
||||
}
|
||||
result.ciphers.push(cipher);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseButtercupCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsv(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
const official = new Set(['!group_id', '!group_name', '!type', 'title', 'username', 'password', 'url', 'note', 'id']);
|
||||
for (const row of rows) {
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.name = val(row.title, '--');
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
login.username = val(row.username);
|
||||
login.password = val(row.password);
|
||||
const uri = normalizeUri(row.URL || row.url || row.Url || '');
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
cipher.notes = val(row.note || row.Note || row.notes || row.Notes);
|
||||
|
||||
for (const key of Object.keys(row)) {
|
||||
if (official.has(key.toLowerCase())) continue;
|
||||
processKvp(cipher, key, row[key], false);
|
||||
}
|
||||
const idx = result.ciphers.push(cipher) - 1;
|
||||
addFolder(result, row['!group_name'], idx);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseCodebookCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsv(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
for (const row of rows) {
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.favorite = txt(row.Favorite).toLowerCase() === 'true';
|
||||
cipher.name = val(row.Entry, '--');
|
||||
cipher.notes = val(row.Note);
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
login.username = val(row.Username, val(row.Email));
|
||||
login.password = val(row.Password);
|
||||
login.totp = val(row.TOTP);
|
||||
const uri = normalizeUri(row.Website || '');
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
if (txt(row.Username)) processKvp(cipher, 'Email', row.Email || '', false);
|
||||
processKvp(cipher, 'Phone', row.Phone || '', false);
|
||||
processKvp(cipher, 'PIN', row.PIN || '', false);
|
||||
processKvp(cipher, 'Account', row.Account || '', false);
|
||||
processKvp(cipher, 'Date', row.Date || '', false);
|
||||
convertToNoteIfNeeded(cipher);
|
||||
const idx = result.ciphers.push(cipher) - 1;
|
||||
addFolder(result, row.Category, idx);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseEncryptrCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsv(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
for (const row of rows) {
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.name = val(row.Label, '--');
|
||||
cipher.notes = val(row.Notes);
|
||||
const text = val(row.Text);
|
||||
if (text) cipher.notes = txt(cipher.notes) ? `${txt(cipher.notes)}\n\n${text}` : text;
|
||||
const type = txt(row['Entry Type']);
|
||||
if (type === 'Password') {
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
login.username = val(row.Username);
|
||||
login.password = val(row.Password);
|
||||
const uri = normalizeUri(row['Site URL'] || '');
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
} else if (type === 'Credit Card') {
|
||||
const expiry = txt(row.Expiry);
|
||||
let expMonth: string | null = null;
|
||||
let expYear: string | null = null;
|
||||
const parts = expiry.split('/');
|
||||
if (parts.length > 1) {
|
||||
expMonth = txt(parts[0]);
|
||||
const y = txt(parts[1]);
|
||||
expYear = y.length === 2 ? `20${y}` : y || null;
|
||||
}
|
||||
cipher.type = 3;
|
||||
cipher.login = null;
|
||||
cipher.card = {
|
||||
cardholderName: val(row['Name on card']),
|
||||
number: val(row['Card Number']),
|
||||
brand: cardBrand(val(row['Card Number'])),
|
||||
code: val(row.CVV),
|
||||
expMonth,
|
||||
expYear,
|
||||
};
|
||||
}
|
||||
convertToNoteIfNeeded(cipher);
|
||||
result.ciphers.push(cipher);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseKeePassXCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsv(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
for (const row of rows) {
|
||||
if (!txt(row.Title)) continue;
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.notes = val(row.Notes);
|
||||
cipher.name = val(row.Title, '--');
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
login.username = val(row.Username);
|
||||
login.password = val(row.Password);
|
||||
login.totp = val(row.TOTP);
|
||||
const uri = normalizeUri(row.URL || '');
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
const idx = result.ciphers.push(cipher) - 1;
|
||||
addFolder(result, txt(row.Group).replace(/^Root\//, ''), idx);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseLastPassCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsv(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
for (const row of rows) {
|
||||
const isSecureNote = txt(row.url) === 'http://sn';
|
||||
if (isSecureNote) {
|
||||
const idx =
|
||||
result.ciphers.push({
|
||||
type: 2,
|
||||
name: val(row.name, '--'),
|
||||
notes: val(row.extra),
|
||||
favorite: txt(row.fav) === '1',
|
||||
reprompt: 0,
|
||||
key: null,
|
||||
login: null,
|
||||
card: null,
|
||||
identity: null,
|
||||
secureNote: { type: 0 },
|
||||
fields: null,
|
||||
passwordHistory: null,
|
||||
sshKey: null,
|
||||
}) - 1;
|
||||
addFolder(result, txt(row.grouping).replace(/[\x00-\x1F\x7F-\x9F]/g, ''), idx);
|
||||
continue;
|
||||
}
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.name = val(row.name, '--');
|
||||
cipher.favorite = txt(row.fav) === '1';
|
||||
cipher.notes = val(row.extra);
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
login.username = val(row.username);
|
||||
login.password = val(row.password);
|
||||
login.totp = val(row.totp);
|
||||
const uri = normalizeUri(row.url || '');
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
const idx = result.ciphers.push(cipher) - 1;
|
||||
addFolder(result, txt(row.grouping).replace(/[\x00-\x1F\x7F-\x9F]/g, ''), idx);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseDashlaneCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsv(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
for (const row of rows) {
|
||||
const keys = Object.keys(row);
|
||||
if (keys[0] === 'username') {
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.name = val(row.title, '--');
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
login.username = val(row.username);
|
||||
login.password = val(row.password);
|
||||
login.totp = val(row.otpUrl || row.otpSecret);
|
||||
const uri = normalizeUri(row.url || '');
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
cipher.notes = val(row.note);
|
||||
const idx = result.ciphers.push(cipher) - 1;
|
||||
addFolder(result, row.category, idx);
|
||||
continue;
|
||||
}
|
||||
if (keys[0] === 'title' && keys[1] === 'note') {
|
||||
result.ciphers.push({
|
||||
type: 2,
|
||||
name: val(row.title, '--'),
|
||||
notes: val(row.note),
|
||||
favorite: false,
|
||||
reprompt: 0,
|
||||
key: null,
|
||||
login: null,
|
||||
card: null,
|
||||
identity: null,
|
||||
secureNote: { type: 0 },
|
||||
fields: null,
|
||||
passwordHistory: null,
|
||||
sshKey: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseDashlaneJson(textRaw: string): CiphersImportPayload {
|
||||
const data = JSON.parse(textRaw) as Record<string, unknown>;
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
const auth = data.AUTHENTIFIANT;
|
||||
if (Array.isArray(auth)) {
|
||||
for (const item of auth) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const row = item as Record<string, unknown>;
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.name = val(row.title, '--');
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
login.username = val(row.login, val(row.secondaryLogin, val(row.email)));
|
||||
login.password = val(row.password);
|
||||
const uri = normalizeUri(String(row.domain ?? ''));
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
cipher.notes = val(row.note);
|
||||
result.ciphers.push(cipher);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseKeePassXml(textRaw: string): CiphersImportPayload {
|
||||
const doc = new DOMParser().parseFromString(textRaw, 'application/xml');
|
||||
if (doc.querySelector('parsererror')) throw new Error('Invalid XML file');
|
||||
const rootGroup = doc.querySelector('KeePassFile > Root > Group');
|
||||
if (!rootGroup) throw new Error('Invalid KeePass XML structure');
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
|
||||
function qd(parent: Element, selector: string): Element[] {
|
||||
return Array.from(parent.querySelectorAll(selector)).filter((x) => x.parentNode === parent);
|
||||
}
|
||||
|
||||
function ensureFolder(path: string): number {
|
||||
let i = result.folders.findIndex((f) => f.name === path);
|
||||
if (i < 0) {
|
||||
i = result.folders.length;
|
||||
result.folders.push({ name: path });
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
function walk(group: Element, isRoot: boolean, prefix: string): void {
|
||||
let current = prefix;
|
||||
let folder = -1;
|
||||
if (!isRoot) {
|
||||
const name = txt(qd(group, 'Name')[0]?.textContent) || '-';
|
||||
current = current ? `${current}/${name}` : name;
|
||||
folder = ensureFolder(current);
|
||||
}
|
||||
for (const entry of qd(group, 'Entry')) {
|
||||
const cipher = makeLoginCipher();
|
||||
for (const s of qd(entry, 'String')) {
|
||||
const key = txt(qd(s, 'Key')[0]?.textContent);
|
||||
const value = txt(qd(s, 'Value')[0]?.textContent);
|
||||
if (!value) continue;
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
if (key === 'Title') cipher.name = value;
|
||||
else if (key === 'UserName') login.username = value;
|
||||
else if (key === 'Password') login.password = value;
|
||||
else if (key === 'URL') {
|
||||
const uri = normalizeUri(value);
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
} else if (key === 'otp') login.totp = value.replace('key=', '');
|
||||
else if (key === 'Notes') cipher.notes = `${txt(cipher.notes)}${txt(cipher.notes) ? '\n' : ''}${value}`;
|
||||
}
|
||||
const idx = result.ciphers.push(cipher) - 1;
|
||||
if (!isRoot && folder >= 0) result.folderRelationships.push({ key: idx, value: folder });
|
||||
}
|
||||
for (const child of qd(group, 'Group')) walk(child, false, current);
|
||||
}
|
||||
|
||||
walk(rootGroup, true, '');
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||
import {
|
||||
addFolder,
|
||||
cardBrand,
|
||||
convertToNoteIfNeeded,
|
||||
makeLoginCipher,
|
||||
normalizeUri,
|
||||
parseCardExpiry,
|
||||
parseCsv,
|
||||
parseEpochMaybe,
|
||||
processKvp,
|
||||
txt,
|
||||
val,
|
||||
} from '@/lib/import-format-shared';
|
||||
|
||||
function onePasswordTypeHints(typeName: string): 1 | 2 | 3 | 4 {
|
||||
const t = txt(typeName).toLowerCase();
|
||||
if (t.includes('creditcard') || t.includes('credit card')) return 3;
|
||||
if (t.includes('identity')) return 4;
|
||||
if (t.includes('securenote') || t.includes('secure note')) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function onePasswordCategoryType(categoryUuid: string): 1 | 2 | 3 | 4 {
|
||||
const c = txt(categoryUuid);
|
||||
if (['002', '101'].includes(c)) return 3;
|
||||
if (['004', '103', '104', '105', '106', '107', '108'].includes(c)) return 4;
|
||||
if (['003', '100', '113'].includes(c)) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function parseOnePasswordCsv(textRaw: string, isMac: boolean): CiphersImportPayload {
|
||||
const rows = parseCsv(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
const ignored = new Set(['ainfo', 'autosubmit', 'notesplain', 'ps', 'scope', 'tags', 'title', 'uuid', 'notes', 'type']);
|
||||
for (const row of rows) {
|
||||
const title = txt(row.title || row.Title);
|
||||
if (!title) continue;
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.name = title || '--';
|
||||
cipher.notes = `${txt(row.notesPlain)}\n${txt(row.notes)}`.trim() || null;
|
||||
|
||||
let type: 1 | 2 | 3 | 4 = 1;
|
||||
if (isMac) {
|
||||
const t = txt(row.type).toLowerCase();
|
||||
if (t === 'credit card') type = 3;
|
||||
else if (t === 'identity') type = 4;
|
||||
else if (t === 'secure note') type = 2;
|
||||
} else {
|
||||
const values = Object.keys(row).map((k) => `${k}:${txt(row[k])}`.toLowerCase());
|
||||
const hasCard = values.some((x) => /number/i.test(x)) && values.some((x) => /expiry date/i.test(x));
|
||||
const hasIdentity = values.some((x) => /first name|initial|last name|email/.test(x));
|
||||
if (hasCard) type = 3;
|
||||
else if (hasIdentity) type = 4;
|
||||
}
|
||||
if (type === 2) {
|
||||
cipher.type = 2;
|
||||
cipher.login = null;
|
||||
cipher.secureNote = { type: 0 };
|
||||
} else if (type === 3) {
|
||||
cipher.type = 3;
|
||||
cipher.login = null;
|
||||
cipher.card = { cardholderName: null, number: null, brand: null, expMonth: null, expYear: null, code: null };
|
||||
} else if (type === 4) {
|
||||
cipher.type = 4;
|
||||
cipher.login = null;
|
||||
cipher.identity = {
|
||||
firstName: null,
|
||||
middleName: null,
|
||||
lastName: null,
|
||||
username: null,
|
||||
email: null,
|
||||
phone: null,
|
||||
company: null,
|
||||
};
|
||||
}
|
||||
|
||||
let altUsername: string | null = null;
|
||||
for (const property of Object.keys(row)) {
|
||||
const rawVal = txt(row[property]);
|
||||
if (!rawVal) continue;
|
||||
const lower = property.toLowerCase();
|
||||
|
||||
if (Number(cipher.type) === 1) {
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
if (!txt(login.username) && lower === 'username') {
|
||||
login.username = rawVal;
|
||||
continue;
|
||||
}
|
||||
if (!txt(login.password) && lower === 'password') {
|
||||
login.password = rawVal;
|
||||
continue;
|
||||
}
|
||||
if ((!Array.isArray(login.uris) || !login.uris.length) && (lower === 'url' || lower === 'website')) {
|
||||
const uri = normalizeUri(rawVal);
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
continue;
|
||||
}
|
||||
} else if (Number(cipher.type) === 3 && cipher.card) {
|
||||
const card = cipher.card as Record<string, unknown>;
|
||||
if (!txt(card.number) && lower.includes('number')) {
|
||||
card.number = rawVal;
|
||||
card.brand = cardBrand(rawVal);
|
||||
continue;
|
||||
}
|
||||
if (!txt(card.code) && lower.includes('verification number')) {
|
||||
card.code = rawVal;
|
||||
continue;
|
||||
}
|
||||
if (!txt(card.cardholderName) && lower.includes('cardholder name')) {
|
||||
card.cardholderName = rawVal;
|
||||
continue;
|
||||
}
|
||||
if ((!txt(card.expMonth) || !txt(card.expYear)) && lower.includes('expiry date')) {
|
||||
const { month, year } = parseCardExpiry(rawVal);
|
||||
card.expMonth = month;
|
||||
card.expYear = year;
|
||||
continue;
|
||||
}
|
||||
} else if (Number(cipher.type) === 4 && cipher.identity) {
|
||||
const identity = cipher.identity as Record<string, unknown>;
|
||||
if (!txt(identity.firstName) && lower.includes('first name')) {
|
||||
identity.firstName = rawVal;
|
||||
continue;
|
||||
}
|
||||
if (!txt(identity.middleName) && lower.includes('initial')) {
|
||||
identity.middleName = rawVal;
|
||||
continue;
|
||||
}
|
||||
if (!txt(identity.lastName) && lower.includes('last name')) {
|
||||
identity.lastName = rawVal;
|
||||
continue;
|
||||
}
|
||||
if (!txt(identity.username) && lower.includes('username')) {
|
||||
identity.username = rawVal;
|
||||
continue;
|
||||
}
|
||||
if (!txt(identity.email) && lower.includes('email')) {
|
||||
identity.email = rawVal;
|
||||
continue;
|
||||
}
|
||||
if (!txt(identity.phone) && lower.includes('default phone')) {
|
||||
identity.phone = rawVal;
|
||||
continue;
|
||||
}
|
||||
if (!txt(identity.company) && lower.includes('company')) {
|
||||
identity.company = rawVal;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ignored.has(lower) && !lower.startsWith('section:') && !lower.startsWith('section ')) {
|
||||
if (!altUsername && lower === 'email') altUsername = rawVal;
|
||||
if (lower === 'created date' || lower === 'modified date') {
|
||||
const readable = parseEpochMaybe(rawVal);
|
||||
processKvp(cipher, `1Password ${property}`, readable || rawVal, false);
|
||||
} else {
|
||||
const hidden = lower.includes('password') || lower.includes('key') || lower.includes('secret');
|
||||
processKvp(cipher, property, rawVal, hidden);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Number(cipher.type) === 1 && !txt((cipher.login as Record<string, unknown>).username) && altUsername && !altUsername.includes('://')) {
|
||||
(cipher.login as Record<string, unknown>).username = altUsername;
|
||||
}
|
||||
convertToNoteIfNeeded(cipher);
|
||||
result.ciphers.push(cipher);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseOnePasswordFieldsIntoCipher(
|
||||
cipher: Record<string, unknown>,
|
||||
fields: any[],
|
||||
designationKey: string,
|
||||
valueKey: string,
|
||||
nameKey: string
|
||||
): void {
|
||||
for (const field of fields || []) {
|
||||
const raw = field?.[valueKey];
|
||||
if (raw === null || raw === undefined || txt(raw) === '') continue;
|
||||
const designation = txt(field?.[designationKey]).toLowerCase();
|
||||
const k = txt(field?.k).toLowerCase();
|
||||
const fieldName = txt(field?.[nameKey] ?? field?.t ?? field?.title) || 'no_name';
|
||||
let value = txt(raw);
|
||||
if (k === 'date') {
|
||||
const asDate = parseEpochMaybe(raw);
|
||||
value = asDate ? new Date(asDate).toUTCString() : value;
|
||||
}
|
||||
if (Number(cipher.type) === 1) {
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
if (!txt(login.username) && designation === 'username') {
|
||||
login.username = value;
|
||||
continue;
|
||||
}
|
||||
if (!txt(login.password) && designation === 'password') {
|
||||
login.password = value;
|
||||
continue;
|
||||
}
|
||||
if (!txt(login.totp) && designation.startsWith('totp_')) {
|
||||
login.totp = value;
|
||||
continue;
|
||||
}
|
||||
} else if (Number(cipher.type) === 3 && cipher.card) {
|
||||
const card = cipher.card as Record<string, unknown>;
|
||||
if (!txt(card.number) && designation === 'ccnum') {
|
||||
card.number = value;
|
||||
card.brand = cardBrand(value);
|
||||
continue;
|
||||
}
|
||||
if (!txt(card.code) && designation === 'cvv') {
|
||||
card.code = value;
|
||||
continue;
|
||||
}
|
||||
if (!txt(card.cardholderName) && designation === 'cardholder') {
|
||||
card.cardholderName = value;
|
||||
continue;
|
||||
}
|
||||
if ((!txt(card.expMonth) || !txt(card.expYear)) && designation === 'expiry') {
|
||||
const { month, year } = parseCardExpiry(value);
|
||||
card.expMonth = month;
|
||||
card.expYear = year;
|
||||
continue;
|
||||
}
|
||||
if (designation === 'type') continue;
|
||||
} else if (Number(cipher.type) === 4 && cipher.identity) {
|
||||
const identity = cipher.identity as Record<string, unknown>;
|
||||
if (!txt(identity.firstName) && designation === 'firstname') {
|
||||
identity.firstName = value;
|
||||
continue;
|
||||
}
|
||||
if (!txt(identity.lastName) && designation === 'lastname') {
|
||||
identity.lastName = value;
|
||||
continue;
|
||||
}
|
||||
if (!txt(identity.middleName) && designation === 'initial') {
|
||||
identity.middleName = value;
|
||||
continue;
|
||||
}
|
||||
if (!txt(identity.phone) && designation === 'defphone') {
|
||||
identity.phone = value;
|
||||
continue;
|
||||
}
|
||||
if (!txt(identity.company) && designation === 'company') {
|
||||
identity.company = value;
|
||||
continue;
|
||||
}
|
||||
if (!txt(identity.email) && designation === 'email') {
|
||||
identity.email = value;
|
||||
continue;
|
||||
}
|
||||
if (!txt(identity.username) && designation === 'username') {
|
||||
identity.username = value;
|
||||
continue;
|
||||
}
|
||||
if (designation === 'address' && raw && typeof raw === 'object') {
|
||||
const addr = raw as Record<string, unknown>;
|
||||
identity.address1 = val(addr.street);
|
||||
identity.city = val(addr.city);
|
||||
identity.country = txt(addr.country) ? txt(addr.country).toUpperCase() : null;
|
||||
identity.postalCode = val(addr.zip);
|
||||
identity.state = val(addr.state);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
processKvp(cipher, fieldName, value, k === 'concealed');
|
||||
}
|
||||
}
|
||||
|
||||
function parseOnePasswordPasswordHistory(cipher: Record<string, unknown>, history: any[]): void {
|
||||
const parsed = (history || [])
|
||||
.map((h) => ({ password: val(h?.value), lastUsedDate: parseEpochMaybe(h?.time) }))
|
||||
.filter((x) => !!x.password && !!x.lastUsedDate)
|
||||
.sort((a, b) => String(b.lastUsedDate).localeCompare(String(a.lastUsedDate)))
|
||||
.slice(0, 5);
|
||||
cipher.passwordHistory = parsed.length ? parsed : null;
|
||||
}
|
||||
|
||||
export function parseOnePassword1Pif(textRaw: string): CiphersImportPayload {
|
||||
const lines = textRaw.split(/\r?\n/);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || !trimmed.startsWith('{')) continue;
|
||||
let item: any;
|
||||
try {
|
||||
item = JSON.parse(trimmed);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (item?.trashed === true) continue;
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.name = val(item?.title || item?.overview?.title, '--');
|
||||
cipher.favorite = !!item?.openContents?.faveIndex;
|
||||
|
||||
let type = onePasswordTypeHints(item?.typeName);
|
||||
const details = item?.details || item?.secureContents || {};
|
||||
if (details?.ccnum || details?.cvv) type = 3;
|
||||
if (details?.firstname || details?.address1) type = 4;
|
||||
if (type === 2) {
|
||||
cipher.type = 2;
|
||||
cipher.login = null;
|
||||
cipher.secureNote = { type: 0 };
|
||||
} else if (type === 3) {
|
||||
cipher.type = 3;
|
||||
cipher.login = null;
|
||||
cipher.card = { cardholderName: null, number: null, brand: null, expMonth: null, expYear: null, code: null };
|
||||
} else if (type === 4) {
|
||||
cipher.type = 4;
|
||||
cipher.login = null;
|
||||
cipher.identity = {
|
||||
firstName: null,
|
||||
middleName: null,
|
||||
lastName: null,
|
||||
phone: null,
|
||||
email: null,
|
||||
username: null,
|
||||
company: null,
|
||||
};
|
||||
}
|
||||
|
||||
const uris: string[] = [];
|
||||
const locationUri = normalizeUri(item?.location || '');
|
||||
if (locationUri) uris.push(locationUri);
|
||||
for (const u of item?.URLs || item?.secureContents?.URLs || item?.overview?.URLs || []) {
|
||||
const uri = normalizeUri(u?.url || u?.u || '');
|
||||
if (uri) uris.push(uri);
|
||||
}
|
||||
if (Number(cipher.type) === 1) {
|
||||
(cipher.login as Record<string, unknown>).uris = uris.length ? uris.map((uri) => ({ uri, match: null })) : null;
|
||||
(cipher.login as Record<string, unknown>).password = val(details?.password);
|
||||
}
|
||||
cipher.notes = val(details?.notesPlain);
|
||||
parseOnePasswordPasswordHistory(cipher, details?.passwordHistory || []);
|
||||
parseOnePasswordFieldsIntoCipher(cipher, details?.fields || [], 'designation', 'value', 'name');
|
||||
for (const section of details?.sections || []) {
|
||||
parseOnePasswordFieldsIntoCipher(cipher, section?.fields || [], 'n', 'v', 't');
|
||||
}
|
||||
convertToNoteIfNeeded(cipher);
|
||||
result.ciphers.push(cipher);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseOnePassword1PuxJson(textRaw: string): CiphersImportPayload {
|
||||
const parsed = JSON.parse(textRaw) as { accounts?: any[] };
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
const accounts = Array.isArray(parsed?.accounts) ? parsed.accounts : [];
|
||||
for (const account of accounts) {
|
||||
for (const vault of account?.vaults || []) {
|
||||
const vaultName = txt(vault?.attrs?.name);
|
||||
for (const item of vault?.items || []) {
|
||||
if (txt(item?.state) === 'archived') continue;
|
||||
const cipher = makeLoginCipher();
|
||||
const categoryType = onePasswordCategoryType(item?.categoryUuid);
|
||||
if (categoryType === 2) {
|
||||
cipher.type = 2;
|
||||
cipher.login = null;
|
||||
cipher.secureNote = { type: 0 };
|
||||
} else if (categoryType === 3) {
|
||||
cipher.type = 3;
|
||||
cipher.login = null;
|
||||
cipher.card = { cardholderName: null, number: null, brand: null, expMonth: null, expYear: null, code: null };
|
||||
} else if (categoryType === 4) {
|
||||
cipher.type = 4;
|
||||
cipher.login = null;
|
||||
cipher.identity = {
|
||||
firstName: null,
|
||||
middleName: null,
|
||||
lastName: null,
|
||||
phone: null,
|
||||
email: null,
|
||||
username: null,
|
||||
company: null,
|
||||
address1: null,
|
||||
city: null,
|
||||
state: null,
|
||||
postalCode: null,
|
||||
country: null,
|
||||
passportNumber: null,
|
||||
ssn: null,
|
||||
licenseNumber: null,
|
||||
};
|
||||
}
|
||||
cipher.favorite = Number(item?.favIndex) === 1;
|
||||
cipher.name = val(item?.overview?.title, '--');
|
||||
cipher.notes = val(item?.details?.notesPlain);
|
||||
|
||||
if (Number(cipher.type) === 1) {
|
||||
const urls: string[] = [];
|
||||
for (const u of item?.overview?.urls || []) {
|
||||
const uri = normalizeUri(u?.url || '');
|
||||
if (uri) urls.push(uri);
|
||||
}
|
||||
const fallbackUrl = normalizeUri(item?.overview?.url || '');
|
||||
if (fallbackUrl) urls.push(fallbackUrl);
|
||||
(cipher.login as Record<string, unknown>).uris = urls.length ? urls.map((uri) => ({ uri, match: null })) : null;
|
||||
}
|
||||
|
||||
for (const loginField of item?.details?.loginFields || []) {
|
||||
const lv = txt(loginField?.value);
|
||||
if (!lv) continue;
|
||||
const designation = txt(loginField?.designation).toLowerCase();
|
||||
const fieldName = txt(loginField?.name);
|
||||
const fieldType = txt(loginField?.fieldType);
|
||||
if (Number(cipher.type) === 1) {
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
if (designation === 'username') {
|
||||
login.username = lv;
|
||||
continue;
|
||||
}
|
||||
if (designation === 'password') {
|
||||
login.password = lv;
|
||||
continue;
|
||||
}
|
||||
if (designation.includes('totp') || fieldName.toLowerCase().includes('totp')) {
|
||||
login.totp = lv;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
processKvp(cipher, fieldName || designation || 'field', lv, fieldType === 'P');
|
||||
}
|
||||
|
||||
for (const section of item?.details?.sections || []) {
|
||||
const fieldTitle = txt(section?.title);
|
||||
for (const field of section?.fields || []) {
|
||||
const fieldId = txt(field?.id);
|
||||
const fieldType = txt(field?.value?.fieldType).toLowerCase();
|
||||
const fieldTitleLocal = txt(field?.title) || fieldTitle;
|
||||
const fieldValueObj = field?.value?.value;
|
||||
let fieldValue = txt(fieldValueObj);
|
||||
if (!fieldValue && typeof fieldValueObj === 'number') {
|
||||
const iso = parseEpochMaybe(fieldValueObj);
|
||||
fieldValue = iso ? new Date(iso).toUTCString() : String(fieldValueObj);
|
||||
}
|
||||
if (!fieldValue && !(fieldValueObj && typeof fieldValueObj === 'object')) continue;
|
||||
|
||||
if (Number(cipher.type) === 3 && cipher.card) {
|
||||
const card = cipher.card as Record<string, unknown>;
|
||||
if (fieldId === 'creditCardNumber' || fieldType === 'creditcardnumber') {
|
||||
card.number = fieldValue;
|
||||
card.brand = cardBrand(fieldValue);
|
||||
continue;
|
||||
}
|
||||
if (fieldId === 'creditCardVerificationNumber') {
|
||||
card.code = fieldValue;
|
||||
continue;
|
||||
}
|
||||
if (fieldId === 'creditCardCardholder') {
|
||||
card.cardholderName = fieldValue;
|
||||
continue;
|
||||
}
|
||||
if (fieldId === 'creditCardExpiry') {
|
||||
const { month, year } = parseCardExpiry(fieldValue);
|
||||
card.expMonth = month;
|
||||
card.expYear = year;
|
||||
continue;
|
||||
}
|
||||
} else if (Number(cipher.type) === 4 && cipher.identity) {
|
||||
const identity = cipher.identity as Record<string, unknown>;
|
||||
if (fieldId === 'firstName') {
|
||||
identity.firstName = fieldValue;
|
||||
continue;
|
||||
}
|
||||
if (fieldId === 'lastName') {
|
||||
identity.lastName = fieldValue;
|
||||
continue;
|
||||
}
|
||||
if (fieldId === 'initial') {
|
||||
identity.middleName = fieldValue;
|
||||
continue;
|
||||
}
|
||||
if (fieldId === 'company') {
|
||||
identity.company = fieldValue;
|
||||
continue;
|
||||
}
|
||||
if (fieldId === 'email') {
|
||||
identity.email = fieldValue;
|
||||
continue;
|
||||
}
|
||||
if (fieldId === 'phone') {
|
||||
identity.phone = fieldValue;
|
||||
continue;
|
||||
}
|
||||
if (fieldId === 'username') {
|
||||
identity.username = fieldValue;
|
||||
continue;
|
||||
}
|
||||
if (fieldId === 'address' && fieldValueObj && typeof fieldValueObj === 'object') {
|
||||
const addr = fieldValueObj as Record<string, unknown>;
|
||||
identity.address1 = val(addr.street);
|
||||
identity.city = val(addr.city);
|
||||
identity.state = val(addr.state);
|
||||
identity.postalCode = val(addr.zip);
|
||||
identity.country = txt(addr.country) ? txt(addr.country).toUpperCase() : null;
|
||||
continue;
|
||||
}
|
||||
if (fieldId === 'socialSecurityNumber') {
|
||||
identity.ssn = fieldValue;
|
||||
continue;
|
||||
}
|
||||
if (fieldId === 'passportNumber') {
|
||||
identity.passportNumber = fieldValue;
|
||||
continue;
|
||||
}
|
||||
if (fieldId === 'licenseNumber') {
|
||||
identity.licenseNumber = fieldValue;
|
||||
continue;
|
||||
}
|
||||
} else if (Number(cipher.type) === 1) {
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
if (fieldId === 'url') {
|
||||
const uri = normalizeUri(fieldValue);
|
||||
if (uri) {
|
||||
const uris = Array.isArray(login.uris) ? login.uris : [];
|
||||
uris.push({ uri, match: null });
|
||||
login.uris = uris;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (fieldId === 'username' && !txt(login.username)) {
|
||||
login.username = fieldValue;
|
||||
continue;
|
||||
}
|
||||
if (fieldId === 'password' && !txt(login.password)) {
|
||||
login.password = fieldValue;
|
||||
continue;
|
||||
}
|
||||
if ((fieldId === 'oneTimePassword' || fieldId === 'totp') && !txt(login.totp)) {
|
||||
login.totp = fieldValue;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const hidden = fieldType === 'concealed' || fieldType === 'otp';
|
||||
processKvp(cipher, fieldTitleLocal || fieldId || 'field', fieldValue, hidden);
|
||||
}
|
||||
}
|
||||
convertToNoteIfNeeded(cipher);
|
||||
const idx = result.ciphers.push(cipher) - 1;
|
||||
if (vaultName) addFolder(result, vaultName, idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||
import {
|
||||
addFolder,
|
||||
cardBrand,
|
||||
makeLoginCipher,
|
||||
normalizeUri,
|
||||
parseCardExpiry,
|
||||
parseCsv,
|
||||
parseCsvRows,
|
||||
processKvp,
|
||||
splitFullName,
|
||||
txt,
|
||||
val,
|
||||
} from '@/lib/import-format-shared';
|
||||
|
||||
export function parseEnpassCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsvRows(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
let first = true;
|
||||
for (const r of rows) {
|
||||
if (r.length < 2 || (first && (r[0] === 'Title' || r[0] === 'title'))) {
|
||||
first = false;
|
||||
continue;
|
||||
}
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.name = val(r[0], '--');
|
||||
cipher.notes = val(r[r.length - 1]);
|
||||
const hasLoginHints = r.some((x) => ['username', 'password', 'email', 'url'].includes(txt(x).toLowerCase()));
|
||||
const hasCardHints = r.some((x) => ['cardholder', 'number', 'expiry date'].includes(txt(x).toLowerCase()));
|
||||
if (r.length === 2 || !hasLoginHints) {
|
||||
cipher.type = 2;
|
||||
cipher.login = null;
|
||||
cipher.secureNote = { type: 0 };
|
||||
}
|
||||
if (hasCardHints) {
|
||||
cipher.type = 3;
|
||||
cipher.login = null;
|
||||
cipher.card = { cardholderName: null, number: null, brand: null, expMonth: null, expYear: null, code: null };
|
||||
}
|
||||
if (r.length > 2 && r.length % 2 === 0) {
|
||||
for (let i = 0; i < r.length - 2; i += 2) {
|
||||
const fieldName = txt(r[i + 1]);
|
||||
const fieldValue = txt(r[i + 2]);
|
||||
if (!fieldValue) continue;
|
||||
const low = fieldName.toLowerCase();
|
||||
if (cipher.type === 1) {
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
if (low === 'url' && !Array.isArray(login.uris)) {
|
||||
const uri = normalizeUri(fieldValue);
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
continue;
|
||||
}
|
||||
if ((low === 'username' || low === 'email') && !txt(login.username)) {
|
||||
login.username = fieldValue;
|
||||
continue;
|
||||
}
|
||||
if (low === 'password' && !txt(login.password)) {
|
||||
login.password = fieldValue;
|
||||
continue;
|
||||
}
|
||||
if (low === 'totp' && !txt(login.totp)) {
|
||||
login.totp = fieldValue;
|
||||
continue;
|
||||
}
|
||||
} else if (cipher.type === 3 && cipher.card) {
|
||||
const card = cipher.card as Record<string, unknown>;
|
||||
if (low === 'cardholder' && !txt(card.cardholderName)) {
|
||||
card.cardholderName = fieldValue;
|
||||
continue;
|
||||
}
|
||||
if (low === 'number' && !txt(card.number)) {
|
||||
card.number = fieldValue;
|
||||
card.brand = cardBrand(fieldValue);
|
||||
continue;
|
||||
}
|
||||
if (low === 'cvc' && !txt(card.code)) {
|
||||
card.code = fieldValue;
|
||||
continue;
|
||||
}
|
||||
if (low === 'expiry date' && !txt(card.expMonth) && !txt(card.expYear)) {
|
||||
const m = fieldValue.match(/^0?([1-9]|1[0-2])\/((?:[1-2][0-9])?[0-9]{2})$/);
|
||||
if (m) {
|
||||
card.expMonth = m[1];
|
||||
card.expYear = m[2].length === 2 ? `20${m[2]}` : m[2];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (low === 'type') continue;
|
||||
}
|
||||
processKvp(cipher, fieldName, fieldValue, false);
|
||||
}
|
||||
}
|
||||
result.ciphers.push(cipher);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseEnpassJson(textRaw: string): CiphersImportPayload {
|
||||
const parsed = JSON.parse(textRaw) as { folders?: any[]; items?: any[] };
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
const folderTitleById = new Map<string, string>();
|
||||
for (const f of parsed.folders || []) {
|
||||
if (f?.uuid && f?.title) folderTitleById.set(String(f.uuid), String(f.title).trim());
|
||||
}
|
||||
|
||||
for (const item of parsed.items || []) {
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.name = val(item?.title, '--');
|
||||
cipher.favorite = Number(item?.favorite || 0) > 0;
|
||||
cipher.notes = val(item?.note);
|
||||
const templateType = txt(item?.template_type);
|
||||
const fields = Array.isArray(item?.fields) ? item.fields : [];
|
||||
|
||||
if (templateType.startsWith('creditcard.')) {
|
||||
cipher.type = 3;
|
||||
cipher.login = null;
|
||||
const card: Record<string, unknown> = {
|
||||
cardholderName: null,
|
||||
number: null,
|
||||
code: null,
|
||||
expMonth: null,
|
||||
expYear: null,
|
||||
brand: null,
|
||||
};
|
||||
for (const field of fields) {
|
||||
const t = txt(field?.type);
|
||||
const v = txt(field?.value);
|
||||
if (!v || t === 'section' || t === 'ccType') continue;
|
||||
if (t === 'ccName' && !txt(card.cardholderName)) card.cardholderName = v;
|
||||
else if (t === 'ccNumber' && !txt(card.number)) {
|
||||
card.number = v;
|
||||
card.brand = cardBrand(v);
|
||||
} else if (t === 'ccCvc' && !txt(card.code)) card.code = v;
|
||||
else if (t === 'ccExpiry' && !txt(card.expYear)) {
|
||||
const m = v.match(/^0?([1-9]|1[0-2])\/((?:[1-2][0-9])?[0-9]{2})$/);
|
||||
if (m) {
|
||||
card.expMonth = m[1];
|
||||
card.expYear = m[2].length === 2 ? `20${m[2]}` : m[2];
|
||||
} else {
|
||||
processKvp(cipher, txt(field?.label), v, Number(field?.sensitive || 0) === 1);
|
||||
}
|
||||
} else {
|
||||
processKvp(cipher, txt(field?.label), v, Number(field?.sensitive || 0) === 1);
|
||||
}
|
||||
}
|
||||
cipher.card = card;
|
||||
} else if (
|
||||
templateType.startsWith('login.') ||
|
||||
templateType.startsWith('password.') ||
|
||||
fields.some((f: any) => txt(f?.type) === 'password' && txt(f?.value))
|
||||
) {
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
const urls: string[] = [];
|
||||
for (const field of fields) {
|
||||
const t = txt(field?.type);
|
||||
const v = txt(field?.value);
|
||||
if (!v || t === 'section') continue;
|
||||
if ((t === 'username' || t === 'email') && !txt(login.username)) login.username = v;
|
||||
else if (t === 'password' && !txt(login.password)) login.password = v;
|
||||
else if (t === 'totp' && !txt(login.totp)) login.totp = v;
|
||||
else if (t === 'url') {
|
||||
const n = normalizeUri(v);
|
||||
if (n) urls.push(n);
|
||||
} else if (t === '.Android#') {
|
||||
let cleaned = v.startsWith('androidapp://') ? v : `androidapp://${v}`;
|
||||
cleaned = cleaned.replace('android://', '').replace(/androidapp:\/\/.*==@/g, 'androidapp://');
|
||||
const n = normalizeUri(cleaned) || cleaned;
|
||||
urls.push(n);
|
||||
} else {
|
||||
processKvp(cipher, txt(field?.label), v, Number(field?.sensitive || 0) === 1);
|
||||
}
|
||||
}
|
||||
login.uris = urls.length ? urls.map((u) => ({ uri: u, match: null })) : null;
|
||||
} else {
|
||||
cipher.type = 2;
|
||||
cipher.login = null;
|
||||
cipher.secureNote = { type: 0 };
|
||||
for (const field of fields) {
|
||||
const v = txt(field?.value);
|
||||
if (!v || txt(field?.type) === 'section') continue;
|
||||
processKvp(cipher, txt(field?.label), v, Number(field?.sensitive || 0) === 1);
|
||||
}
|
||||
}
|
||||
|
||||
const idx = result.ciphers.push(cipher) - 1;
|
||||
const folderId = Array.isArray(item?.folders) && item.folders.length ? String(item.folders[0]) : '';
|
||||
if (folderId && folderTitleById.has(folderId)) addFolder(result, folderTitleById.get(folderId) || '', idx);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseKeeperCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsvRows(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
for (const row of rows) {
|
||||
if (row.length < 6) continue;
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.name = val(row[1], '--');
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
login.username = val(row[2]);
|
||||
login.password = val(row[3]);
|
||||
const uri = normalizeUri(row[4] || '');
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
cipher.notes = val(row[5]);
|
||||
if (row.length > 7) {
|
||||
for (let i = 7; i < row.length; i += 2) {
|
||||
const k = txt(row[i]);
|
||||
const v = txt(row[i + 1]);
|
||||
if (!k) continue;
|
||||
if (k === 'TFC:Keeper') (cipher.login as Record<string, unknown>).totp = val(v);
|
||||
else processKvp(cipher, k, v, false);
|
||||
}
|
||||
}
|
||||
const idx = result.ciphers.push(cipher) - 1;
|
||||
addFolder(result, row[0], idx);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseKeeperJson(textRaw: string): CiphersImportPayload {
|
||||
const parsed = JSON.parse(textRaw) as { records?: any[] };
|
||||
const records = Array.isArray(parsed.records) ? parsed.records : [];
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
for (const record of records) {
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.name = val(record.title, '--');
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
login.username = val(record.login);
|
||||
login.password = val(record.password);
|
||||
const uri = normalizeUri(record.login_url || '');
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
cipher.notes = val(record.notes);
|
||||
const cf = record.custom_fields || {};
|
||||
if (cf['TFC:Keeper']) login.totp = val(cf['TFC:Keeper']);
|
||||
for (const key of Object.keys(cf)) {
|
||||
if (key === 'TFC:Keeper') continue;
|
||||
processKvp(cipher, key, String(cf[key] ?? ''), false);
|
||||
}
|
||||
if (Array.isArray(record.folders)) {
|
||||
const idx = result.ciphers.push(cipher) - 1;
|
||||
for (const f of record.folders) {
|
||||
const folderName = f?.folder || f?.shared_folder;
|
||||
if (folderName) addFolder(result, String(folderName), idx);
|
||||
}
|
||||
} else {
|
||||
result.ciphers.push(cipher);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseLogMeOnceCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsvRows(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
for (const row of rows) {
|
||||
if (row.length < 4) continue;
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.name = val(row[0], '--');
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
login.username = val(row[2]);
|
||||
login.password = val(row[3]);
|
||||
const uri = normalizeUri(row[1] || '');
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
result.ciphers.push(cipher);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseMeldiumCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsv(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
for (const row of rows) {
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.name = val(row.DisplayName, '--');
|
||||
cipher.notes = val(row.Notes);
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
login.username = val(row.UserName);
|
||||
login.password = val(row.Password);
|
||||
const uri = normalizeUri(row.Url || '');
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
result.ciphers.push(cipher);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseProtonPassJson(textRaw: string): CiphersImportPayload {
|
||||
const parsed = JSON.parse(textRaw) as { encrypted?: boolean; vaults?: Record<string, any> };
|
||||
if (parsed?.encrypted) throw new Error('Unable to import an encrypted Proton Pass export.');
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
const vaults = parsed?.vaults && typeof parsed.vaults === 'object' ? parsed.vaults : {};
|
||||
for (const vault of Object.values(vaults)) {
|
||||
const vaultName = txt((vault as Record<string, unknown>).name);
|
||||
const items = Array.isArray((vault as Record<string, unknown>).items) ? ((vault as Record<string, unknown>).items as any[]) : [];
|
||||
for (const item of items) {
|
||||
if (Number(item?.state) === 2) continue;
|
||||
const itemType = txt(item?.data?.type);
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.name = val(item?.data?.metadata?.name, '--');
|
||||
cipher.notes = val(item?.data?.metadata?.note);
|
||||
cipher.favorite = !!item?.pinned;
|
||||
|
||||
if (itemType === 'login') {
|
||||
const content = item?.data?.content || {};
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
const urls: string[] = [];
|
||||
for (const u of content?.urls || []) {
|
||||
const uri = normalizeUri(u || '');
|
||||
if (uri) urls.push(uri);
|
||||
}
|
||||
login.uris = urls.length ? urls.map((uri) => ({ uri, match: null })) : null;
|
||||
const username = val(content?.itemUsername);
|
||||
const email = val(content?.itemEmail);
|
||||
login.username = username || email;
|
||||
if (username && email) processKvp(cipher, 'email', email, false);
|
||||
login.password = val(content?.password);
|
||||
login.totp = val(content?.totpUri);
|
||||
for (const extra of item?.data?.extraFields || []) {
|
||||
const t = txt(extra?.type);
|
||||
const fieldValue = t === 'totp' ? val(extra?.data?.totpUri) : val(extra?.data?.content);
|
||||
processKvp(cipher, txt(extra?.fieldName), fieldValue || '', t !== 'text');
|
||||
}
|
||||
} else if (itemType === 'note') {
|
||||
cipher.type = 2;
|
||||
cipher.login = null;
|
||||
cipher.secureNote = { type: 0 };
|
||||
} else if (itemType === 'creditCard') {
|
||||
const content = item?.data?.content || {};
|
||||
const { month, year } = parseCardExpiry(txt(content?.expirationDate));
|
||||
cipher.type = 3;
|
||||
cipher.login = null;
|
||||
cipher.card = {
|
||||
cardholderName: val(content?.cardholderName),
|
||||
number: val(content?.number),
|
||||
brand: cardBrand(val(content?.number)),
|
||||
code: val(content?.verificationNumber),
|
||||
expMonth: month,
|
||||
expYear: year,
|
||||
};
|
||||
if (txt(content?.pin)) processKvp(cipher, 'PIN', txt(content.pin), true);
|
||||
} else if (itemType === 'identity') {
|
||||
const content = item?.data?.content || {};
|
||||
const name = splitFullName(val(content?.fullName));
|
||||
cipher.type = 4;
|
||||
cipher.login = null;
|
||||
cipher.identity = {
|
||||
firstName: val(content?.firstName) || name.firstName,
|
||||
middleName: val(content?.middleName) || name.middleName,
|
||||
lastName: val(content?.lastName) || name.lastName,
|
||||
email: val(content?.email),
|
||||
phone: val(content?.phoneNumber),
|
||||
company: val(content?.company),
|
||||
ssn: val(content?.socialSecurityNumber),
|
||||
passportNumber: val(content?.passportNumber),
|
||||
licenseNumber: val(content?.licenseNumber),
|
||||
address1: val(content?.organization),
|
||||
address2: val(content?.streetAddress),
|
||||
address3: `${txt(content?.floor)} ${txt(content?.county)}`.trim() || null,
|
||||
city: val(content?.city),
|
||||
state: val(content?.stateOrProvince),
|
||||
postalCode: val(content?.zipOrPostalCode),
|
||||
country: val(content?.countryOrRegion),
|
||||
};
|
||||
for (const key of Object.keys(content || {})) {
|
||||
if (
|
||||
[
|
||||
'fullName',
|
||||
'firstName',
|
||||
'middleName',
|
||||
'lastName',
|
||||
'email',
|
||||
'phoneNumber',
|
||||
'company',
|
||||
'socialSecurityNumber',
|
||||
'passportNumber',
|
||||
'licenseNumber',
|
||||
'organization',
|
||||
'streetAddress',
|
||||
'floor',
|
||||
'county',
|
||||
'city',
|
||||
'stateOrProvince',
|
||||
'zipOrPostalCode',
|
||||
'countryOrRegion',
|
||||
].includes(key)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (key === 'extraSections' && Array.isArray(content[key])) {
|
||||
for (const section of content[key]) {
|
||||
for (const extra of section?.sectionFields || []) {
|
||||
processKvp(cipher, txt(extra?.fieldName), txt(extra?.data?.content), txt(extra?.type) === 'hidden');
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(content[key])) {
|
||||
for (const extra of content[key]) {
|
||||
processKvp(cipher, txt(extra?.fieldName), txt(extra?.data?.content), txt(extra?.type) === 'hidden');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
processKvp(cipher, key, txt(content[key]), false);
|
||||
}
|
||||
for (const extra of item?.data?.extraFields || []) {
|
||||
processKvp(cipher, txt(extra?.fieldName), txt(extra?.data?.content), txt(extra?.type) === 'hidden');
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
const idx = result.ciphers.push(cipher) - 1;
|
||||
if (vaultName) addFolder(result, vaultName, idx);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
+46
-2500
File diff suppressed because it is too large
Load Diff
+40
-4
@@ -21,10 +21,46 @@ export default defineConfig({
|
||||
target: 'esnext',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ['preact', 'preact/hooks', 'preact/jsx-runtime'],
|
||||
query: ['@tanstack/react-query'],
|
||||
icons: ['lucide-preact'],
|
||||
manualChunks(id) {
|
||||
if (id.includes('/node_modules/')) {
|
||||
return 'vendor';
|
||||
}
|
||||
|
||||
const normalized = id.replace(/\\/g, '/');
|
||||
|
||||
if (
|
||||
normalized.includes('/src/components/AuthViews.tsx') ||
|
||||
normalized.includes('/src/components/PublicSendPage.tsx') ||
|
||||
normalized.includes('/src/components/RecoverTwoFactorPage.tsx') ||
|
||||
normalized.includes('/src/components/JwtWarningPage.tsx') ||
|
||||
normalized.includes('/src/lib/app-auth.ts')
|
||||
) {
|
||||
return 'auth-suite';
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('/src/components/ImportPage.tsx') ||
|
||||
normalized.includes('/src/lib/import-') ||
|
||||
normalized.includes('/src/lib/export-formats.ts') ||
|
||||
normalized.includes('/src/components/VaultPage.tsx') ||
|
||||
normalized.includes('/src/components/SendsPage.tsx') ||
|
||||
normalized.includes('/src/components/TotpCodesPage.tsx') ||
|
||||
normalized.includes('/src/components/vault/')
|
||||
) {
|
||||
return 'workspace-suite';
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('/src/components/BackupCenterPage.tsx') ||
|
||||
normalized.includes('/src/components/backup-center/') ||
|
||||
normalized.includes('/src/components/SettingsPage.tsx') ||
|
||||
normalized.includes('/src/components/SecurityDevicesPage.tsx') ||
|
||||
normalized.includes('/src/components/AdminPage.tsx')
|
||||
) {
|
||||
return 'management-suite';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user