mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 13:53:34 +00:00
* clean up readability * fix ts-strict violations * fix consistency with uncertain cases in isCardExpired
331 lines
11 KiB
TypeScript
331 lines
11 KiB
TypeScript
import {
|
|
DelimiterPatternExpression,
|
|
ExpiryFullYearPattern,
|
|
ExpiryFullYearPatternExpression,
|
|
IrrelevantExpiryCharactersPatternExpression,
|
|
MonthPatternExpression,
|
|
} from "@bitwarden/common/autofill/constants";
|
|
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
|
|
|
type NonZeroIntegers = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
|
|
type Year = `${NonZeroIntegers}${NonZeroIntegers}${0 | NonZeroIntegers}${0 | NonZeroIntegers}`;
|
|
|
|
/**
|
|
* Takes a string or number value and returns a string value formatted as a valid 4-digit year
|
|
*
|
|
* @param {(string | number)} yearInput
|
|
* @return {*} {(Year | null)}
|
|
*/
|
|
export function normalizeExpiryYearFormat(yearInput: string | number): Year | null {
|
|
// The input[type="number"] is returning a number, convert it to a string
|
|
// An empty field returns null, avoid casting `"null"` to a string
|
|
const yearInputIsEmpty = yearInput == null || yearInput === "";
|
|
let expirationYear = yearInputIsEmpty ? null : `${yearInput}`;
|
|
|
|
// Exit early if year is already formatted correctly or empty
|
|
if (yearInputIsEmpty || (expirationYear && /^[1-9]{1}\d{3}$/.test(expirationYear))) {
|
|
return expirationYear as Year;
|
|
}
|
|
|
|
expirationYear = (expirationYear || "")
|
|
// For safety, because even input[type="number"] will allow decimals
|
|
.replace(/[^\d]/g, "")
|
|
// remove any leading zero padding (leave the last leading zero if it ends the string)
|
|
.replace(/^[0]+(?=.)/, "");
|
|
|
|
if (expirationYear === "") {
|
|
expirationYear = null;
|
|
}
|
|
|
|
// given the context of payment card expiry, a year character length of 3, or over 4
|
|
// is more likely to be a mistake than an intentional value for the far past or far future.
|
|
if (expirationYear && expirationYear.length !== 4) {
|
|
const paddedYear = ("00" + expirationYear).slice(-2);
|
|
const currentCentury = `${new Date().getFullYear()}`.slice(0, 2);
|
|
|
|
expirationYear = currentCentury + paddedYear;
|
|
}
|
|
|
|
return expirationYear as Year | null;
|
|
}
|
|
|
|
/**
|
|
* Takes a cipher card view and returns "true" if the month and year affirmativey indicate
|
|
* the card is expired. Uncertain cases return "false".
|
|
*
|
|
* @param {CardView} cipherCard
|
|
* @return {*} {boolean}
|
|
*/
|
|
export function isCardExpired(cipherCard: CardView): boolean {
|
|
if (cipherCard) {
|
|
const { expMonth = null, expYear = null } = cipherCard;
|
|
|
|
if (!expYear) {
|
|
return false;
|
|
}
|
|
|
|
const now = new Date();
|
|
const normalizedYear = normalizeExpiryYearFormat(expYear);
|
|
const parsedYear = normalizedYear ? parseInt(normalizedYear, 10) : NaN;
|
|
|
|
const expiryYearIsBeforeCurrentYear = parsedYear < now.getFullYear();
|
|
const expiryYearIsAfterCurrentYear = parsedYear > now.getFullYear();
|
|
|
|
// If the expiry year is before the current year, skip checking the month, since it must be expired
|
|
if (normalizedYear && expiryYearIsBeforeCurrentYear) {
|
|
return true;
|
|
}
|
|
|
|
// If the expiry year is after the current year, skip checking the month, since it cannot be expired
|
|
if (normalizedYear && expiryYearIsAfterCurrentYear) {
|
|
return false;
|
|
}
|
|
|
|
if (normalizedYear && expMonth) {
|
|
const parsedMonthInteger = parseInt(expMonth, 10);
|
|
const parsedMonthIsValid = parsedMonthInteger && !isNaN(parsedMonthInteger);
|
|
|
|
// If the parsed month value is 0, we don't know when the expiry passes this year, so do not treat it as expired
|
|
if (!parsedMonthIsValid) {
|
|
return false;
|
|
}
|
|
|
|
// `Date` months are zero-indexed
|
|
const parsedMonth = parsedMonthInteger - 1;
|
|
|
|
// First day of the next month
|
|
const cardExpiry = new Date(parsedYear, parsedMonth + 1, 1);
|
|
|
|
return cardExpiry <= now;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Attempt to split a string into date segments on the basis of expected formats and delimiter symbols.
|
|
*
|
|
* @param {string} combinedExpiryValue
|
|
* @return {*} {string[]}
|
|
*/
|
|
function splitCombinedDateValues(combinedExpiryValue: string): string[] {
|
|
let sanitizedValue = combinedExpiryValue
|
|
.replace(IrrelevantExpiryCharactersPatternExpression, "")
|
|
.trim();
|
|
|
|
// Do this after initial value replace to avoid identifying leading whitespace as delimiter
|
|
const parsedDelimiter = sanitizedValue.match(DelimiterPatternExpression)?.[0] || null;
|
|
|
|
let dateParts = [sanitizedValue];
|
|
|
|
if (parsedDelimiter?.length) {
|
|
// If the parsed delimiter is a whitespace character, assign 's' (character class) instead
|
|
const delimiterPattern = /\s/.test(parsedDelimiter) ? "\\s" : "\\" + parsedDelimiter;
|
|
|
|
sanitizedValue = sanitizedValue
|
|
// Remove all other delimiter characters not identified as the delimiter
|
|
.replace(new RegExp(`[^\\d${delimiterPattern}]`, "g"), "")
|
|
// Also de-dupe the delimiter character
|
|
.replace(new RegExp(`[${delimiterPattern}]{2,}`, "g"), parsedDelimiter);
|
|
|
|
dateParts = sanitizedValue.split(parsedDelimiter);
|
|
}
|
|
|
|
return (
|
|
dateParts
|
|
// remove values that have no length
|
|
.filter((splitValue) => splitValue?.length)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Given an array of split card expiry date parts,
|
|
* returns an array of those values ordered by year then month
|
|
*
|
|
* @param {string[]} splitDateInput
|
|
* @return {*} {([string | null, string | null])}
|
|
*/
|
|
function parseDelimitedYearMonthExpiry([firstPart, secondPart]: string[]): [string, string] {
|
|
// Conditionals here are structured to avoid unnecessary evaluations and are ordered
|
|
// from more authoritative checks to checks yielding increasingly inferred conclusions
|
|
|
|
// If a 4-digit value is found (when there are multiple parts), it can't be month
|
|
if (ExpiryFullYearPatternExpression.test(firstPart)) {
|
|
return [firstPart, secondPart];
|
|
}
|
|
|
|
// If a 4-digit value is found (when there are multiple parts), it can't be month
|
|
if (ExpiryFullYearPatternExpression.test(secondPart)) {
|
|
return [secondPart, firstPart];
|
|
}
|
|
|
|
// If it's a two digit value that doesn't match against month pattern, assume it's a year
|
|
if (/\d{2}/.test(firstPart) && !MonthPatternExpression.test(firstPart)) {
|
|
return [firstPart, secondPart];
|
|
}
|
|
|
|
// If it's a two digit value that doesn't match against month pattern, assume it's a year
|
|
if (/\d{2}/.test(secondPart) && !MonthPatternExpression.test(secondPart)) {
|
|
return [secondPart, firstPart];
|
|
}
|
|
|
|
// Values are too ambiguous (e.g. "12/09"). For the most part,
|
|
// a month-looking value likely is, at the time of writing (year 2024).
|
|
let parsedYear = firstPart;
|
|
let parsedMonth = secondPart;
|
|
|
|
if (MonthPatternExpression.test(firstPart)) {
|
|
parsedYear = secondPart;
|
|
parsedMonth = firstPart;
|
|
}
|
|
|
|
return [parsedYear, parsedMonth];
|
|
}
|
|
|
|
/**
|
|
* Given a single string of integers, attempts to identify card expiry date portions within
|
|
* and return values ordered by year then month
|
|
*
|
|
* @param {string} dateInput
|
|
* @return {*} {([string | null, string | null])}
|
|
*/
|
|
function parseNonDelimitedYearMonthExpiry(dateInput: string): [string | null, string | null] {
|
|
if (dateInput.length > 4) {
|
|
// e.g.
|
|
// "052024"
|
|
// "202405"
|
|
// "20245"
|
|
// "52024"
|
|
|
|
// If the value is over 5-characters long, it likely has a full year format in it
|
|
const [parsedYear, parsedMonth] = dateInput
|
|
.split(new RegExp(`(?=${ExpiryFullYearPattern})|(?<=${ExpiryFullYearPattern})`, "g"))
|
|
.sort((current: string, next: string) => (current.length > next.length ? -1 : 1));
|
|
|
|
return [parsedYear, parsedMonth];
|
|
}
|
|
|
|
if (dateInput.length === 4) {
|
|
// e.g.
|
|
// "0524"
|
|
// "2405"
|
|
|
|
// If the `sanitizedFirstPart` value is a length of 4, it must be split in half, since
|
|
// neither a year or month will be represented with three characters
|
|
const splitFirstPartFirstHalf = dateInput.slice(0, 2);
|
|
const splitFirstPartSecondHalf = dateInput.slice(-2);
|
|
|
|
let parsedYear = splitFirstPartSecondHalf;
|
|
let parsedMonth = splitFirstPartFirstHalf;
|
|
|
|
// If the first part doesn't match a month pattern, assume it's a year
|
|
if (!MonthPatternExpression.test(splitFirstPartFirstHalf)) {
|
|
parsedYear = splitFirstPartFirstHalf;
|
|
parsedMonth = splitFirstPartSecondHalf;
|
|
}
|
|
|
|
return [parsedYear, parsedMonth];
|
|
}
|
|
|
|
// e.g.
|
|
// "245"
|
|
// "202"
|
|
// "212"
|
|
// "022"
|
|
// "111"
|
|
|
|
// A valid year representation here must be two characters so try to find it first.
|
|
|
|
let parsedYear = null;
|
|
let parsedMonth = null;
|
|
|
|
// Split if there is a digit with a leading zero
|
|
const splitFirstPartOnLeadingZero = dateInput.split(/(?<=0[1-9]{1})|(?=0[1-9]{1})/);
|
|
|
|
// Assume a leading zero indicates a month in ambiguous cases (e.g. "202"), since we're
|
|
// dealing with expiry dates and the next two-digit year with a leading zero will be 2100
|
|
if (splitFirstPartOnLeadingZero.length > 1) {
|
|
parsedYear = splitFirstPartOnLeadingZero[0];
|
|
parsedMonth = splitFirstPartOnLeadingZero[1];
|
|
|
|
if (splitFirstPartOnLeadingZero[0].startsWith("0")) {
|
|
parsedMonth = splitFirstPartOnLeadingZero[0];
|
|
parsedYear = splitFirstPartOnLeadingZero[1];
|
|
}
|
|
} else {
|
|
// Here, a year has to be two-digits, and a month can't be more than one, so assume the first two digits that are greater than the current year is the year representation.
|
|
parsedYear = dateInput.slice(0, 2);
|
|
parsedMonth = dateInput.slice(-1);
|
|
|
|
const currentYear = new Date().getFullYear();
|
|
const normalizedYearFormat = normalizeExpiryYearFormat(parsedYear);
|
|
const normalizedParsedYear = normalizedYearFormat && parseInt(normalizedYearFormat, 10);
|
|
const normalizedExpiryYearFormat = normalizeExpiryYearFormat(dateInput.slice(-2));
|
|
const normalizedParsedYearAlternative =
|
|
normalizedExpiryYearFormat && parseInt(normalizedExpiryYearFormat, 10);
|
|
|
|
if (
|
|
normalizedParsedYear &&
|
|
normalizedParsedYear < currentYear &&
|
|
normalizedParsedYearAlternative &&
|
|
normalizedParsedYearAlternative >= currentYear
|
|
) {
|
|
parsedYear = dateInput.slice(-2);
|
|
parsedMonth = dateInput.slice(0, 1);
|
|
}
|
|
}
|
|
|
|
return [parsedYear, parsedMonth];
|
|
}
|
|
|
|
/**
|
|
* Attempt to parse year and month parts of a combined expiry date value.
|
|
*
|
|
* @param {string} combinedExpiryValue
|
|
* @return {*} {([string | null, string | null])}
|
|
*/
|
|
export function parseYearMonthExpiry(combinedExpiryValue: string): [Year | null, string | null] {
|
|
let parsedYear = null;
|
|
let parsedMonth = null;
|
|
|
|
const dateParts = splitCombinedDateValues(combinedExpiryValue);
|
|
|
|
if (dateParts.length < 1) {
|
|
return [null, null];
|
|
}
|
|
|
|
const sanitizedFirstPart =
|
|
dateParts[0]?.replace(IrrelevantExpiryCharactersPatternExpression, "") || "";
|
|
const sanitizedSecondPart =
|
|
dateParts[1]?.replace(IrrelevantExpiryCharactersPatternExpression, "") || "";
|
|
|
|
// If there is only one date part, no delimiter was found in the passed value
|
|
if (dateParts.length === 1) {
|
|
const [parsedNonDelimitedYear, parsedNonDelimitedMonth] =
|
|
parseNonDelimitedYearMonthExpiry(sanitizedFirstPart);
|
|
|
|
parsedYear = parsedNonDelimitedYear;
|
|
parsedMonth = parsedNonDelimitedMonth;
|
|
}
|
|
// There are multiple date parts
|
|
else {
|
|
const [parsedDelimitedYear, parsedDelimitedMonth] = parseDelimitedYearMonthExpiry([
|
|
sanitizedFirstPart,
|
|
sanitizedSecondPart,
|
|
]);
|
|
|
|
parsedYear = parsedDelimitedYear;
|
|
parsedMonth = parsedDelimitedMonth;
|
|
}
|
|
|
|
const normalizedParsedYear = parsedYear ? normalizeExpiryYearFormat(parsedYear) : null;
|
|
const normalizedParsedMonth = parsedMonth?.replace(/^0+/, "").slice(0, 2);
|
|
|
|
// Set "empty" values to null
|
|
parsedYear = normalizedParsedYear?.length ? normalizedParsedYear : null;
|
|
parsedMonth = normalizedParsedMonth?.length ? normalizedParsedMonth : null;
|
|
|
|
return [parsedYear, parsedMonth];
|
|
}
|