mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-21 05:10:41 +00:00
316 lines
8.9 KiB
TypeScript
316 lines
8.9 KiB
TypeScript
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 normalizeUriList(rawUris: string[]): string[] {
|
|
const seen = new Set<string>();
|
|
const out: string[] = [];
|
|
for (const raw of rawUris) {
|
|
const uri = normalizeUri(raw);
|
|
if (!uri) continue;
|
|
const key = uri.toLowerCase();
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
out.push(uri);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function setLoginUris(login: Record<string, unknown>, rawUris: string[]): void {
|
|
const uris = normalizeUriList(rawUris);
|
|
login.uris = uris.length ? uris.map((uri) => ({ uri, match: null })) : null;
|
|
}
|
|
|
|
export function addLoginUri(login: Record<string, unknown>, rawUri: string): void {
|
|
const existing = Array.isArray(login.uris)
|
|
? login.uris.map((entry) => txt((entry as Record<string, unknown>)?.uri)).filter(Boolean)
|
|
: [];
|
|
setLoginUris(login, [...existing, rawUri]);
|
|
}
|
|
|
|
export function isTotpFieldName(raw: unknown): boolean {
|
|
const name = txt(raw).toLowerCase().replace(/[\s_-]+/g, '');
|
|
if (!name) return false;
|
|
return [
|
|
'totp',
|
|
'totpuri',
|
|
'otp',
|
|
'otpuri',
|
|
'otpurl',
|
|
'otpauth',
|
|
'onetimepassword',
|
|
'onetimepasscode',
|
|
'2fa',
|
|
'twofactor',
|
|
'twofactorauthentication',
|
|
'authenticator',
|
|
'verificationcode',
|
|
].includes(name);
|
|
}
|
|
|
|
export function extractTotpValue(raw: unknown): string {
|
|
if (raw === null || raw === undefined) return '';
|
|
if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') return txt(raw);
|
|
if (Array.isArray(raw)) {
|
|
for (const item of raw) {
|
|
const value = extractTotpValue(item);
|
|
if (value) return value;
|
|
}
|
|
return '';
|
|
}
|
|
if (typeof raw !== 'object') return '';
|
|
const obj = raw as Record<string, unknown>;
|
|
for (const key of ['totpUri', 'otpAuth', 'otpauth', 'uri', 'url', 'secret', 'totp', 'otp', 'value', 'code']) {
|
|
const value = extractTotpValue(obj[key]);
|
|
if (value) return value;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
export function parseSerializedUris(raw: string): string[] {
|
|
const source = txt(raw);
|
|
if (!source) return [];
|
|
|
|
const newlineParts = source
|
|
.split(/\r?\n/)
|
|
.map((part) => txt(part))
|
|
.filter(Boolean);
|
|
|
|
const parts =
|
|
newlineParts.length > 1
|
|
? newlineParts
|
|
: source.includes(',')
|
|
? source
|
|
.split(/,(?=\s*(?:[a-z][a-z0-9+.-]*:\/\/|www\.|[a-z0-9.-]+\.[a-z]{2,}(?:[/:?#]|$)))/i)
|
|
.map((part) => txt(part))
|
|
.filter(Boolean)
|
|
: [source];
|
|
|
|
return normalizeUriList(parts);
|
|
}
|
|
|
|
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, index) => txt(index === 0 ? h.replace(/^\uFEFF/, '') : 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 (fields.some((field) => txt(field.name) === k && txt(field.value) === v)) return;
|
|
if (v.length > 200 || /\r\n|\r|\n/.test(v)) {
|
|
const existing = txt(cipher.notes);
|
|
const entry = `${k ? `${k}: ` : ''}${v}`;
|
|
if (existing.split('\n').some((line) => line === entry)) return;
|
|
cipher.notes = `${existing}${existing ? '\n' : ''}${entry}`;
|
|
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, 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;
|
|
}
|