mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
417 lines
16 KiB
TypeScript
417 lines
16 KiB
TypeScript
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;
|
|
}
|