@pequity/squirrel 4.0.1 → 4.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/chunks/p-link.js +37 -0
- package/dist/cjs/index.js +4 -0
- package/dist/cjs/link.js +25 -0
- package/dist/cjs/p-btn.js +8 -5
- package/dist/cjs/p-link.js +3 -0
- package/dist/cjs/sanitization.js +13 -0
- package/dist/es/chunks/p-link.js +38 -0
- package/dist/es/index.js +18 -14
- package/dist/es/link.js +25 -0
- package/dist/es/p-btn.js +8 -5
- package/dist/es/p-link.js +4 -0
- package/dist/es/sanitization.js +13 -0
- package/dist/squirrel/components/index.d.ts +2 -1
- package/dist/squirrel/components/p-btn/p-btn.vue.d.ts +4 -2
- package/dist/squirrel/components/p-link/p-link.vue.d.ts +22 -0
- package/dist/squirrel/components/p-table/usePTableRowVirtualizer.d.ts +1 -1
- package/dist/squirrel/utils/index.d.ts +2 -1
- package/dist/squirrel/utils/link.d.ts +1 -0
- package/dist/squirrel/utils/sanitization.d.ts +10 -0
- package/package.json +7 -7
- package/squirrel/components/index.ts +2 -0
- package/squirrel/components/p-btn/p-btn.spec.js +29 -1
- package/squirrel/components/p-btn/p-btn.vue +13 -4
- package/squirrel/components/p-close-btn/p-close-btn.spec.js +60 -0
- package/squirrel/components/p-link/p-link.spec.js +62 -0
- package/squirrel/components/p-link/p-link.stories.js +38 -0
- package/squirrel/components/p-link/p-link.vue +20 -0
- package/squirrel/utils/index.ts +2 -0
- package/squirrel/utils/link.spec.js +24 -0
- package/squirrel/utils/link.ts +36 -0
- package/squirrel/utils/sanitization.spec.js +57 -0
- package/squirrel/utils/sanitization.ts +55 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const vue = require("vue");
|
|
3
|
+
const vueRouter = require("vue-router");
|
|
4
|
+
const link = require("../link.js");
|
|
5
|
+
const sanitization = require("../sanitization.js");
|
|
6
|
+
const _hoisted_1 = ["href"];
|
|
7
|
+
const _sfc_main = /* @__PURE__ */ vue.defineComponent({
|
|
8
|
+
...{
|
|
9
|
+
name: "PLink"
|
|
10
|
+
},
|
|
11
|
+
__name: "p-link",
|
|
12
|
+
props: {
|
|
13
|
+
custom: { type: Boolean },
|
|
14
|
+
activeClass: {},
|
|
15
|
+
exactActiveClass: {},
|
|
16
|
+
ariaCurrentValue: {},
|
|
17
|
+
to: {},
|
|
18
|
+
replace: { type: Boolean }
|
|
19
|
+
},
|
|
20
|
+
setup(__props) {
|
|
21
|
+
return (_ctx, _cache) => {
|
|
22
|
+
return typeof _ctx.to === "string" && vue.unref(link.isExternalLink)(_ctx.to) ? (vue.openBlock(), vue.createElementBlock("a", {
|
|
23
|
+
key: 0,
|
|
24
|
+
href: vue.unref(sanitization.sanitizeUrl)(_ctx.to),
|
|
25
|
+
target: "_blank"
|
|
26
|
+
}, [
|
|
27
|
+
vue.renderSlot(_ctx.$slots, "default")
|
|
28
|
+
], 8, _hoisted_1)) : (vue.openBlock(), vue.createBlock(vue.unref(vueRouter.RouterLink), vue.normalizeProps(vue.mergeProps({ key: 1 }, _ctx.$props)), {
|
|
29
|
+
default: vue.withCtx(() => [
|
|
30
|
+
vue.renderSlot(_ctx.$slots, "default")
|
|
31
|
+
]),
|
|
32
|
+
_: 3
|
|
33
|
+
}, 16));
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
exports._sfc_main = _sfc_main;
|
package/dist/cjs/index.js
CHANGED
|
@@ -25,6 +25,7 @@ const pInput = require("./p-input.js");
|
|
|
25
25
|
const pInputNumber = require("./p-input-number.js");
|
|
26
26
|
const pInputPercent_vue_vue_type_script_setup_true_lang = require("./chunks/p-input-percent.js");
|
|
27
27
|
const pInputSearch = require("./p-input-search.js");
|
|
28
|
+
const pLink_vue_vue_type_script_setup_true_lang = require("./chunks/p-link.js");
|
|
28
29
|
const pLoading = require("./p-loading.js");
|
|
29
30
|
const pModal = require("./p-modal.js");
|
|
30
31
|
const pPagination_vue_vue_type_script_setup_true_lang = require("./chunks/p-pagination.js");
|
|
@@ -58,6 +59,7 @@ const inputClassesShared = require("./inputClassesShared.js");
|
|
|
58
59
|
const pagination = require("./pagination.js");
|
|
59
60
|
const dom = require("./dom.js");
|
|
60
61
|
const object = require("./object.js");
|
|
62
|
+
const sanitization = require("./sanitization.js");
|
|
61
63
|
const listKeyboardNavigation = require("./listKeyboardNavigation.js");
|
|
62
64
|
const number = require("./number.js");
|
|
63
65
|
const _hoisted_1$4 = { class: "flex h-12 w-max select-none items-center rounded-lg bg-p-purple-60 px-2 text-sm font-medium text-white" };
|
|
@@ -1060,6 +1062,7 @@ exports.PInput = pInput;
|
|
|
1060
1062
|
exports.PInputNumber = pInputNumber;
|
|
1061
1063
|
exports.PInputPercent = pInputPercent_vue_vue_type_script_setup_true_lang._sfc_main;
|
|
1062
1064
|
exports.PInputSearch = pInputSearch;
|
|
1065
|
+
exports.PLink = pLink_vue_vue_type_script_setup_true_lang._sfc_main;
|
|
1063
1066
|
exports.PLoading = pLoading;
|
|
1064
1067
|
exports.PModal = pModal;
|
|
1065
1068
|
exports.PPagination = pPagination_vue_vue_type_script_setup_true_lang._sfc_main;
|
|
@@ -1115,6 +1118,7 @@ exports.getNextActiveElement = dom.getNextActiveElement;
|
|
|
1115
1118
|
exports.isElement = dom.isElement;
|
|
1116
1119
|
exports.isVisible = dom.isVisible;
|
|
1117
1120
|
exports.isObject = object.isObject;
|
|
1121
|
+
exports.sanitizeUrl = sanitization.sanitizeUrl;
|
|
1118
1122
|
exports.setupListKeyboardNavigation = listKeyboardNavigation.setupListKeyboardNavigation;
|
|
1119
1123
|
exports.toNumberOrNull = number.toNumberOrNull;
|
|
1120
1124
|
exports.PActionBar = _sfc_main$4;
|
package/dist/cjs/link.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
const normalizeUrl = (url) => {
|
|
4
|
+
if (url.indexOf("//") === 0) {
|
|
5
|
+
url = location.protocol + url;
|
|
6
|
+
}
|
|
7
|
+
return url;
|
|
8
|
+
};
|
|
9
|
+
const isValidUrl = (url) => {
|
|
10
|
+
url = normalizeUrl(url);
|
|
11
|
+
try {
|
|
12
|
+
return Boolean(new URL(url));
|
|
13
|
+
} catch (e) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const checkDomain = function(url) {
|
|
18
|
+
url = normalizeUrl(url);
|
|
19
|
+
return url.toLowerCase().replace(/([a-z])?:\/\//, "$1").split("/")[0];
|
|
20
|
+
};
|
|
21
|
+
const isExternalLink = function(url) {
|
|
22
|
+
url = String(url);
|
|
23
|
+
return isValidUrl(url) && (url.indexOf(":") > -1 || url.indexOf("//") > -1) && checkDomain(location.href) !== checkDomain(url);
|
|
24
|
+
};
|
|
25
|
+
exports.isExternalLink = isExternalLink;
|
package/dist/cjs/p-btn.js
CHANGED
|
@@ -3,6 +3,8 @@ const pRingLoader_vue_vue_type_script_setup_true_lang = require("./chunks/p-ring
|
|
|
3
3
|
const tailwind = require("./tailwind.js");
|
|
4
4
|
const vue = require("vue");
|
|
5
5
|
const vueRouter = require("vue-router");
|
|
6
|
+
const link = require("./link.js");
|
|
7
|
+
const sanitization = require("./sanitization.js");
|
|
6
8
|
const _pluginVue_exportHelper = require("./chunks/_plugin-vue_export-helper.js");
|
|
7
9
|
const BUTTON_TYPES = {
|
|
8
10
|
PRIMARY: "primary",
|
|
@@ -128,17 +130,18 @@ const _sfc_main = vue.defineComponent({
|
|
|
128
130
|
loaderColor() {
|
|
129
131
|
const type = LOADER_COLORS[this.type];
|
|
130
132
|
return tailwind.getColorDeep(type);
|
|
131
|
-
},
|
|
132
|
-
isExternalLink() {
|
|
133
|
-
return typeof this.to === "string" && this.to.startsWith("http");
|
|
134
133
|
}
|
|
134
|
+
},
|
|
135
|
+
methods: {
|
|
136
|
+
isExternalLink: link.isExternalLink,
|
|
137
|
+
sanitizeUrl: sanitization.sanitizeUrl
|
|
135
138
|
}
|
|
136
139
|
});
|
|
137
140
|
const _hoisted_1 = ["href"];
|
|
138
141
|
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
|
|
139
142
|
const _component_PRingLoader = vue.resolveComponent("PRingLoader");
|
|
140
|
-
return _ctx.isExternalLink ? (vue.openBlock(), vue.createElementBlock("a", vue.mergeProps({ key: 0 }, _ctx.$attrs, {
|
|
141
|
-
href: _ctx.to,
|
|
143
|
+
return typeof _ctx.to === "string" && _ctx.isExternalLink(_ctx.to) ? (vue.openBlock(), vue.createElementBlock("a", vue.mergeProps({ key: 0 }, _ctx.$attrs, {
|
|
144
|
+
href: _ctx.sanitizeUrl(_ctx.to),
|
|
142
145
|
target: "_blank",
|
|
143
146
|
class: _ctx.classes
|
|
144
147
|
}), [
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
const XSS_SECURITY_URL = "https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html";
|
|
4
|
+
const SAFE_URL_PATTERN = /^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:\/?#]*(?:[\/?#]|$))/i;
|
|
5
|
+
const sanitizeUrl = (url) => {
|
|
6
|
+
url = String(url);
|
|
7
|
+
if (url.match(SAFE_URL_PATTERN)) {
|
|
8
|
+
return url;
|
|
9
|
+
}
|
|
10
|
+
console.warn(`WARNING: sanitizing unsafe URL value ${url} (see ${XSS_SECURITY_URL})`);
|
|
11
|
+
return "unsafe:" + url;
|
|
12
|
+
};
|
|
13
|
+
exports.sanitizeUrl = sanitizeUrl;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { defineComponent, unref, openBlock, createElementBlock, renderSlot, createBlock, normalizeProps, mergeProps, withCtx } from "vue";
|
|
2
|
+
import { RouterLink } from "vue-router";
|
|
3
|
+
import { isExternalLink } from "../link.js";
|
|
4
|
+
import { sanitizeUrl } from "../sanitization.js";
|
|
5
|
+
const _hoisted_1 = ["href"];
|
|
6
|
+
const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
7
|
+
...{
|
|
8
|
+
name: "PLink"
|
|
9
|
+
},
|
|
10
|
+
__name: "p-link",
|
|
11
|
+
props: {
|
|
12
|
+
custom: { type: Boolean },
|
|
13
|
+
activeClass: {},
|
|
14
|
+
exactActiveClass: {},
|
|
15
|
+
ariaCurrentValue: {},
|
|
16
|
+
to: {},
|
|
17
|
+
replace: { type: Boolean }
|
|
18
|
+
},
|
|
19
|
+
setup(__props) {
|
|
20
|
+
return (_ctx, _cache) => {
|
|
21
|
+
return typeof _ctx.to === "string" && unref(isExternalLink)(_ctx.to) ? (openBlock(), createElementBlock("a", {
|
|
22
|
+
key: 0,
|
|
23
|
+
href: unref(sanitizeUrl)(_ctx.to),
|
|
24
|
+
target: "_blank"
|
|
25
|
+
}, [
|
|
26
|
+
renderSlot(_ctx.$slots, "default")
|
|
27
|
+
], 8, _hoisted_1)) : (openBlock(), createBlock(unref(RouterLink), normalizeProps(mergeProps({ key: 1 }, _ctx.$props)), {
|
|
28
|
+
default: withCtx(() => [
|
|
29
|
+
renderSlot(_ctx.$slots, "default")
|
|
30
|
+
]),
|
|
31
|
+
_: 3
|
|
32
|
+
}, 16));
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
export {
|
|
37
|
+
_sfc_main as _
|
|
38
|
+
};
|
package/dist/es/index.js
CHANGED
|
@@ -24,14 +24,15 @@ import { default as default11 } from "./p-input.js";
|
|
|
24
24
|
import { default as default12 } from "./p-input-number.js";
|
|
25
25
|
import { _ as _3 } from "./chunks/p-input-percent.js";
|
|
26
26
|
import PInputSearch from "./p-input-search.js";
|
|
27
|
+
import { _ as _4 } from "./chunks/p-link.js";
|
|
27
28
|
import { default as default13 } from "./p-loading.js";
|
|
28
29
|
import { default as default14 } from "./p-modal.js";
|
|
29
|
-
import { _ as
|
|
30
|
-
import { _ as
|
|
30
|
+
import { _ as _5 } from "./chunks/p-pagination.js";
|
|
31
|
+
import { _ as _6 } from "./chunks/p-pagination-info.js";
|
|
31
32
|
import { default as default15 } from "./p-progress-bar.js";
|
|
32
|
-
import { _ as
|
|
33
|
-
import { _ as
|
|
34
|
-
import { _ as
|
|
33
|
+
import { _ as _7 } from "./chunks/p-ring-loader.js";
|
|
34
|
+
import { _ as _8 } from "./chunks/p-select.js";
|
|
35
|
+
import { _ as _9 } from "./chunks/p-select-btn.js";
|
|
35
36
|
import { SIZES } from "./p-select-list.js";
|
|
36
37
|
import { splitStringForHighlight } from "./text.js";
|
|
37
38
|
import { toString } from "./string.js";
|
|
@@ -42,10 +43,10 @@ import PTableHeaderCell from "./p-table-header-cell.js";
|
|
|
42
43
|
import { colsInjectionKey, isFirstColFixedInjectionKey, isLastColFixedInjectionKey, isColsResizableInjectionKey } from "./p-table.js";
|
|
43
44
|
import { MIN_WIDTH_COL_RESIZE } from "./p-table.js";
|
|
44
45
|
import { usePTableColResize } from "./usePTableColResize.js";
|
|
45
|
-
import { _ as
|
|
46
|
+
import { _ as _10 } from "./chunks/p-table-loader.js";
|
|
46
47
|
import { SORTING_TYPES } from "./p-table-sort.js";
|
|
47
48
|
import { default as default18 } from "./p-table-td.js";
|
|
48
|
-
import { _ as
|
|
49
|
+
import { _ as _11 } from "./chunks/p-tabs.js";
|
|
49
50
|
import { default as default19 } from "./p-textarea.js";
|
|
50
51
|
import { default as default20 } from "./p-toggle.js";
|
|
51
52
|
import { usePLoading } from "./usePLoading.js";
|
|
@@ -58,6 +59,7 @@ import { ERROR_MSG, INPUT_BASE, INPUT_ERROR, INPUT_NORMAL, INPUT_SIZES, LABEL_BA
|
|
|
58
59
|
import { createPagingRange } from "./pagination.js";
|
|
59
60
|
import { getNextActiveElement, isElement, isVisible } from "./dom.js";
|
|
60
61
|
import { isObject } from "./object.js";
|
|
62
|
+
import { sanitizeUrl } from "./sanitization.js";
|
|
61
63
|
import { setupListKeyboardNavigation } from "./listKeyboardNavigation.js";
|
|
62
64
|
import { toNumberOrNull } from "./number.js";
|
|
63
65
|
const _hoisted_1$4 = { class: "flex h-12 w-max select-none items-center rounded-lg bg-p-purple-60 px-2 text-sm font-medium text-white" };
|
|
@@ -1071,23 +1073,24 @@ export {
|
|
|
1071
1073
|
default12 as PInputNumber,
|
|
1072
1074
|
_3 as PInputPercent,
|
|
1073
1075
|
PInputSearch,
|
|
1076
|
+
_4 as PLink,
|
|
1074
1077
|
default13 as PLoading,
|
|
1075
1078
|
default14 as PModal,
|
|
1076
|
-
|
|
1077
|
-
|
|
1079
|
+
_5 as PPagination,
|
|
1080
|
+
_6 as PPaginationInfo,
|
|
1078
1081
|
default15 as PProgressBar,
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
+
_7 as PRingLoader,
|
|
1083
|
+
_8 as PSelect,
|
|
1084
|
+
_9 as PSelectBtn,
|
|
1082
1085
|
_sfc_main$2 as PSelectList,
|
|
1083
1086
|
default16 as PSelectPill,
|
|
1084
1087
|
default17 as PSkeletonLoader,
|
|
1085
1088
|
pTable as PTable,
|
|
1086
1089
|
PTableHeaderCell,
|
|
1087
|
-
|
|
1090
|
+
_10 as PTableLoader,
|
|
1088
1091
|
pTableSort as PTableSort,
|
|
1089
1092
|
default18 as PTableTd,
|
|
1090
|
-
|
|
1093
|
+
_11 as PTabs,
|
|
1091
1094
|
default19 as PTextarea,
|
|
1092
1095
|
default20 as PToggle,
|
|
1093
1096
|
SELECT_ARROW,
|
|
@@ -1113,6 +1116,7 @@ export {
|
|
|
1113
1116
|
isLastColFixedInjectionKey,
|
|
1114
1117
|
isObject,
|
|
1115
1118
|
isVisible,
|
|
1119
|
+
sanitizeUrl,
|
|
1116
1120
|
setupListKeyboardNavigation,
|
|
1117
1121
|
splitStringForHighlight,
|
|
1118
1122
|
toNumberOrNull,
|
package/dist/es/link.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const normalizeUrl = (url) => {
|
|
2
|
+
if (url.indexOf("//") === 0) {
|
|
3
|
+
url = location.protocol + url;
|
|
4
|
+
}
|
|
5
|
+
return url;
|
|
6
|
+
};
|
|
7
|
+
const isValidUrl = (url) => {
|
|
8
|
+
url = normalizeUrl(url);
|
|
9
|
+
try {
|
|
10
|
+
return Boolean(new URL(url));
|
|
11
|
+
} catch (e) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
const checkDomain = function(url) {
|
|
16
|
+
url = normalizeUrl(url);
|
|
17
|
+
return url.toLowerCase().replace(/([a-z])?:\/\//, "$1").split("/")[0];
|
|
18
|
+
};
|
|
19
|
+
const isExternalLink = function(url) {
|
|
20
|
+
url = String(url);
|
|
21
|
+
return isValidUrl(url) && (url.indexOf(":") > -1 || url.indexOf("//") > -1) && checkDomain(location.href) !== checkDomain(url);
|
|
22
|
+
};
|
|
23
|
+
export {
|
|
24
|
+
isExternalLink
|
|
25
|
+
};
|
package/dist/es/p-btn.js
CHANGED
|
@@ -2,6 +2,8 @@ import { _ as _sfc_main$1 } from "./chunks/p-ring-loader.js";
|
|
|
2
2
|
import { getColorDeep } from "./tailwind.js";
|
|
3
3
|
import { defineComponent, resolveComponent, openBlock, createElementBlock, mergeProps, renderSlot, createBlock, resolveDynamicComponent, withCtx, createElementVNode, normalizeClass, normalizeStyle, createCommentVNode } from "vue";
|
|
4
4
|
import { RouterLink } from "vue-router";
|
|
5
|
+
import { isExternalLink } from "./link.js";
|
|
6
|
+
import { sanitizeUrl } from "./sanitization.js";
|
|
5
7
|
import { _ as _export_sfc } from "./chunks/_plugin-vue_export-helper.js";
|
|
6
8
|
const BUTTON_TYPES = {
|
|
7
9
|
PRIMARY: "primary",
|
|
@@ -127,17 +129,18 @@ const _sfc_main = defineComponent({
|
|
|
127
129
|
loaderColor() {
|
|
128
130
|
const type = LOADER_COLORS[this.type];
|
|
129
131
|
return getColorDeep(type);
|
|
130
|
-
},
|
|
131
|
-
isExternalLink() {
|
|
132
|
-
return typeof this.to === "string" && this.to.startsWith("http");
|
|
133
132
|
}
|
|
133
|
+
},
|
|
134
|
+
methods: {
|
|
135
|
+
isExternalLink,
|
|
136
|
+
sanitizeUrl
|
|
134
137
|
}
|
|
135
138
|
});
|
|
136
139
|
const _hoisted_1 = ["href"];
|
|
137
140
|
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
|
|
138
141
|
const _component_PRingLoader = resolveComponent("PRingLoader");
|
|
139
|
-
return _ctx.isExternalLink ? (openBlock(), createElementBlock("a", mergeProps({ key: 0 }, _ctx.$attrs, {
|
|
140
|
-
href: _ctx.to,
|
|
142
|
+
return typeof _ctx.to === "string" && _ctx.isExternalLink(_ctx.to) ? (openBlock(), createElementBlock("a", mergeProps({ key: 0 }, _ctx.$attrs, {
|
|
143
|
+
href: _ctx.sanitizeUrl(_ctx.to),
|
|
141
144
|
target: "_blank",
|
|
142
145
|
class: _ctx.classes
|
|
143
146
|
}), [
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const XSS_SECURITY_URL = "https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html";
|
|
2
|
+
const SAFE_URL_PATTERN = /^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:\/?#]*(?:[\/?#]|$))/i;
|
|
3
|
+
const sanitizeUrl = (url) => {
|
|
4
|
+
url = String(url);
|
|
5
|
+
if (url.match(SAFE_URL_PATTERN)) {
|
|
6
|
+
return url;
|
|
7
|
+
}
|
|
8
|
+
console.warn(`WARNING: sanitizing unsafe URL value ${url} (see ${XSS_SECURITY_URL})`);
|
|
9
|
+
return "unsafe:" + url;
|
|
10
|
+
};
|
|
11
|
+
export {
|
|
12
|
+
sanitizeUrl
|
|
13
|
+
};
|
|
@@ -18,6 +18,7 @@ import PInput from './p-input/p-input.vue';
|
|
|
18
18
|
import PInputNumber from './p-input-number/p-input-number.vue';
|
|
19
19
|
import PInputPercent from './p-input-percent/p-input-percent.vue';
|
|
20
20
|
import PInputSearch from './p-input-search/p-input-search.vue';
|
|
21
|
+
import PLink from './p-link/p-link.vue';
|
|
21
22
|
import PLoading from './p-loading/p-loading.vue';
|
|
22
23
|
import PModal from './p-modal/p-modal.vue';
|
|
23
24
|
import PPagination from './p-pagination/p-pagination.vue';
|
|
@@ -47,4 +48,4 @@ import { usePModal } from './p-modal/usePModal';
|
|
|
47
48
|
import { usePTableColResize } from './p-table/usePTableColResize';
|
|
48
49
|
import { usePTableRowVirtualizer } from './p-table/usePTableRowVirtualizer';
|
|
49
50
|
import { useSelectList } from './p-select-list/useSelectList';
|
|
50
|
-
export { PActionBar, PActionBarAction, PAlert, PAvatar, PBtn, PCard, PCheckbox, PChips, PCloseBtn, PDatePicker, PDrawer, PDropdown, PDropdownSelect, PFileUpload, PFilterIcon, PInfoIcon, PInlineDatePicker, PInput, PInputNumber, PInputPercent, PInputSearch, PLoading, PModal, PPagination, PPaginationInfo, PProgressBar, PRingLoader, PSelect, PSelectBtn, PSelectList, PSelectPill, PSkeletonLoader, PTable, PTableHeaderCell, PTableLoader, PTableSort, PTableTd, PTabs, PTextarea, PToggle, SORTING_TYPES, MIN_WIDTH_COL_RESIZE, colsInjectionKey, isColsResizableInjectionKey, isFirstColFixedInjectionKey, isLastColFixedInjectionKey, usePModal, usePTableColResize, usePTableRowVirtualizer, useSelectList, usePLoading, SortingType, SortingTypeWithoutNoSorting, Size, FileUploadFile, HeaderCellAttrs, TableCol, ThAttrs, };
|
|
51
|
+
export { PActionBar, PActionBarAction, PAlert, PAvatar, PBtn, PCard, PCheckbox, PChips, PCloseBtn, PDatePicker, PDrawer, PDropdown, PDropdownSelect, PFileUpload, PFilterIcon, PInfoIcon, PInlineDatePicker, PInput, PInputNumber, PInputPercent, PInputSearch, PLink, PLoading, PModal, PPagination, PPaginationInfo, PProgressBar, PRingLoader, PSelect, PSelectBtn, PSelectList, PSelectPill, PSkeletonLoader, PTable, PTableHeaderCell, PTableLoader, PTableSort, PTableTd, PTabs, PTextarea, PToggle, SORTING_TYPES, MIN_WIDTH_COL_RESIZE, colsInjectionKey, isColsResizableInjectionKey, isFirstColFixedInjectionKey, isLastColFixedInjectionKey, usePModal, usePTableColResize, usePTableRowVirtualizer, useSelectList, usePLoading, SortingType, SortingTypeWithoutNoSorting, Size, FileUploadFile, HeaderCellAttrs, TableCol, ThAttrs, };
|
|
@@ -72,8 +72,10 @@ declare const _default: import("vue").DefineComponent<{
|
|
|
72
72
|
classes(): string;
|
|
73
73
|
loaderSize(): number;
|
|
74
74
|
loaderColor(): undefined;
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
}, {
|
|
76
|
+
isExternalLink: (url: string) => boolean;
|
|
77
|
+
sanitizeUrl: (url: string) => string;
|
|
78
|
+
}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
|
|
77
79
|
/**
|
|
78
80
|
* The button style e.g primary, secondary, primary-outline, secondary-outline, error, success, primary-link
|
|
79
81
|
*/
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type RouterLinkProps } from 'vue-router';
|
|
2
|
+
declare function __VLS_template(): {
|
|
3
|
+
default?(_: {}): any;
|
|
4
|
+
default?(_: {}): any;
|
|
5
|
+
};
|
|
6
|
+
declare const __VLS_component: import("vue").DefineComponent<__VLS_TypePropsToOption<RouterLinkProps>, {}, unknown, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<__VLS_TypePropsToOption<RouterLinkProps>>>, {}, {}>;
|
|
7
|
+
declare const _default: __VLS_WithTemplateSlots<typeof __VLS_component, ReturnType<typeof __VLS_template>>;
|
|
8
|
+
export default _default;
|
|
9
|
+
type __VLS_WithTemplateSlots<T, S> = T & {
|
|
10
|
+
new (): {
|
|
11
|
+
$slots: S;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
type __VLS_NonUndefinedable<T> = T extends undefined ? never : T;
|
|
15
|
+
type __VLS_TypePropsToOption<T> = {
|
|
16
|
+
[K in keyof T]-?: {} extends Pick<T, K> ? {
|
|
17
|
+
type: import('vue').PropType<__VLS_NonUndefinedable<T[K]>>;
|
|
18
|
+
} : {
|
|
19
|
+
type: import('vue').PropType<T[K]>;
|
|
20
|
+
required: true;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
@@ -17,7 +17,7 @@ export declare const usePTableRowVirtualizer: (options: Options) => {
|
|
|
17
17
|
measureElement: () => Ref<undefined>;
|
|
18
18
|
} | {
|
|
19
19
|
virtualizer: Ref<import("@tanstack/vue-virtual").Virtualizer<HTMLElement, Element>>;
|
|
20
|
-
virtualRows: ComputedRef<import("@tanstack/vue-virtual").VirtualItem
|
|
20
|
+
virtualRows: ComputedRef<import("@tanstack/vue-virtual").VirtualItem[]>;
|
|
21
21
|
paddingTop: ComputedRef<number>;
|
|
22
22
|
paddingBottom: ComputedRef<number>;
|
|
23
23
|
measureElement: (cmp: ComponentPublicInstance | Ref<HTMLElement>) => undefined;
|
|
@@ -5,8 +5,9 @@ import { ERROR_MSG, INPUT_BASE, INPUT_ERROR, INPUT_NORMAL, INPUT_SIZES, type Inp
|
|
|
5
5
|
import { createPagingRange } from './pagination';
|
|
6
6
|
import { getNextActiveElement, isElement, isVisible } from './dom';
|
|
7
7
|
import { isObject } from './object';
|
|
8
|
+
import { sanitizeUrl } from './sanitization';
|
|
8
9
|
import { setupListKeyboardNavigation } from './listKeyboardNavigation';
|
|
9
10
|
import { splitStringForHighlight } from './text';
|
|
10
11
|
import { toNumberOrNull } from './number';
|
|
11
12
|
import { toString } from './string';
|
|
12
|
-
export { inputClassesMixin, CURRENCY_INPUT_DEFAULTS, Color, getColor, getColorDeep, getScreen, ERROR_MSG, INPUT_BASE, INPUT_ERROR, INPUT_NORMAL, INPUT_SIZES, InputSize, LABEL_BASE, LABEL_REQUIRED, LABEL_SIZES, SELECT_ARROW, SELECT_BASE, SELECT_SIZES, SPACING_LEFT, SPACING_PREFIX, SPACING_RIGHT, SPACING_SUFFIX, TEXTAREA_BASE, createPagingRange, getNextActiveElement, isElement, isVisible, isObject, setupListKeyboardNavigation, splitStringForHighlight, toNumberOrNull, toString, };
|
|
13
|
+
export { inputClassesMixin, CURRENCY_INPUT_DEFAULTS, Color, getColor, getColorDeep, getScreen, ERROR_MSG, INPUT_BASE, INPUT_ERROR, INPUT_NORMAL, INPUT_SIZES, InputSize, LABEL_BASE, LABEL_REQUIRED, LABEL_SIZES, SELECT_ARROW, SELECT_BASE, SELECT_SIZES, SPACING_LEFT, SPACING_PREFIX, SPACING_RIGHT, SPACING_SUFFIX, TEXTAREA_BASE, createPagingRange, getNextActiveElement, isElement, isVisible, isObject, sanitizeUrl, setupListKeyboardNavigation, splitStringForHighlight, toNumberOrNull, toString, };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const isExternalLink: (url: string) => boolean;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This is a port of the Angular url_sanitizer module.
|
|
3
|
+
* https://github.com/angular/angular/blob/main/packages/core/src/sanitization/url_sanitizer.ts
|
|
4
|
+
*
|
|
5
|
+
* TL;DR
|
|
6
|
+
* The function sanitizeUrl is designed to ensure that a given URL is safe,
|
|
7
|
+
* by checking it against a regular expression pattern (SAFE_URL_PATTERN).
|
|
8
|
+
* If the URL is considered unsafe, it returns a version of the URL prefixed with "unsafe:".
|
|
9
|
+
*/
|
|
10
|
+
export declare const sanitizeUrl: (url: string) => string;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pequity/squirrel",
|
|
3
3
|
"description": "Squirrel component library",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.1.1",
|
|
5
5
|
"packageManager": "pnpm@9.7.1",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"scripts": {
|
|
@@ -51,9 +51,9 @@
|
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@babel/core": "^7.25.2",
|
|
54
|
-
"@babel/preset-env": "^7.25.
|
|
55
|
-
"@commitlint/cli": "^19.4.
|
|
56
|
-
"@commitlint/config-conventional": "^19.
|
|
54
|
+
"@babel/preset-env": "^7.25.4",
|
|
55
|
+
"@commitlint/cli": "^19.4.1",
|
|
56
|
+
"@commitlint/config-conventional": "^19.4.1",
|
|
57
57
|
"@pequity/eslint-config": "^0.0.13",
|
|
58
58
|
"@playwright/test": "^1.46.1",
|
|
59
59
|
"@popperjs/core": "2.11.8",
|
|
@@ -71,11 +71,11 @@
|
|
|
71
71
|
"@storybook/theming": "^8.2.9",
|
|
72
72
|
"@storybook/vue3": "^8.2.9",
|
|
73
73
|
"@storybook/vue3-vite": "^8.2.9",
|
|
74
|
-
"@tanstack/vue-virtual": "3.10.
|
|
74
|
+
"@tanstack/vue-virtual": "3.10.5",
|
|
75
75
|
"@types/jest": "^29.5.12",
|
|
76
76
|
"@types/jsdom": "^21.1.7",
|
|
77
77
|
"@types/lodash-es": "^4.17.12",
|
|
78
|
-
"@types/node": "^22.
|
|
78
|
+
"@types/node": "^22.5.1",
|
|
79
79
|
"@vitejs/plugin-vue": "^5.1.2",
|
|
80
80
|
"@vue/compiler-sfc": "3.4.38",
|
|
81
81
|
"@vue/test-utils": "^2.4.6",
|
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
"storybook": "^8.2.9",
|
|
106
106
|
"svgo": "^3.3.2",
|
|
107
107
|
"tailwindcss": "^3.4.10",
|
|
108
|
-
"ts-jest": "^29.2.
|
|
108
|
+
"ts-jest": "^29.2.5",
|
|
109
109
|
"typescript": "5.5.4",
|
|
110
110
|
"v-calendar": "3.1.2",
|
|
111
111
|
"vite": "^5.4.2",
|
|
@@ -18,6 +18,7 @@ import PInput from '@squirrel/components/p-input/p-input.vue';
|
|
|
18
18
|
import PInputNumber from '@squirrel/components/p-input-number/p-input-number.vue';
|
|
19
19
|
import PInputPercent from '@squirrel/components/p-input-percent/p-input-percent.vue';
|
|
20
20
|
import PInputSearch from '@squirrel/components/p-input-search/p-input-search.vue';
|
|
21
|
+
import PLink from '@squirrel/components/p-link/p-link.vue';
|
|
21
22
|
import PLoading from '@squirrel/components/p-loading/p-loading.vue';
|
|
22
23
|
import PModal from '@squirrel/components/p-modal/p-modal.vue';
|
|
23
24
|
import PPagination from '@squirrel/components/p-pagination/p-pagination.vue';
|
|
@@ -83,6 +84,7 @@ export {
|
|
|
83
84
|
PInputNumber,
|
|
84
85
|
PInputPercent,
|
|
85
86
|
PInputSearch,
|
|
87
|
+
PLink,
|
|
86
88
|
PLoading,
|
|
87
89
|
PModal,
|
|
88
90
|
PPagination,
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import PBtn from '@squirrel/components/p-btn/p-btn.vue';
|
|
2
2
|
import { createWrapperFor } from '@tests/jest.helpers';
|
|
3
|
+
import { sanitizeUrl } from '@squirrel/utils/sanitization';
|
|
4
|
+
|
|
5
|
+
jest.mock('@squirrel/utils/sanitization', () => {
|
|
6
|
+
return {
|
|
7
|
+
sanitizeUrl: jest.fn((str) => `sanitized-${str}`),
|
|
8
|
+
};
|
|
9
|
+
});
|
|
3
10
|
|
|
4
11
|
const ELEMENTS_MAP = {
|
|
5
12
|
button: undefined,
|
|
@@ -8,6 +15,10 @@ const ELEMENTS_MAP = {
|
|
|
8
15
|
};
|
|
9
16
|
|
|
10
17
|
describe('PBtn.vue', () => {
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
jest.clearAllMocks();
|
|
20
|
+
});
|
|
21
|
+
|
|
11
22
|
Object.keys(ELEMENTS_MAP).forEach((el) => {
|
|
12
23
|
const to = ELEMENTS_MAP[el];
|
|
13
24
|
|
|
@@ -147,7 +158,8 @@ describe('PBtn.vue', () => {
|
|
|
147
158
|
});
|
|
148
159
|
|
|
149
160
|
const a = await wrapper.find('a');
|
|
150
|
-
|
|
161
|
+
|
|
162
|
+
expect(a.attributes().href).toBe(`sanitized-https://pequity.com/`);
|
|
151
163
|
expect(a.attributes().target).toBe('_blank');
|
|
152
164
|
expect(a.text()).toBe('This is a button');
|
|
153
165
|
});
|
|
@@ -185,4 +197,20 @@ describe('PBtn.vue', () => {
|
|
|
185
197
|
|
|
186
198
|
expect(button.attributes()['aria-selected']).toBe('true');
|
|
187
199
|
});
|
|
200
|
+
|
|
201
|
+
it('it sanitizes an invalid link', async () => {
|
|
202
|
+
const wrapper = createWrapperFor(PBtn, {
|
|
203
|
+
props: {
|
|
204
|
+
to: 'javascript:evil()',
|
|
205
|
+
},
|
|
206
|
+
slots: {
|
|
207
|
+
default: `This is a button`,
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const a = await wrapper.find('a');
|
|
212
|
+
|
|
213
|
+
expect(a.text()).toBe('This is a button');
|
|
214
|
+
expect(sanitizeUrl).toHaveBeenCalledTimes(1);
|
|
215
|
+
});
|
|
188
216
|
});
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<a
|
|
2
|
+
<a
|
|
3
|
+
v-if="typeof to === 'string' && isExternalLink(to)"
|
|
4
|
+
v-bind="$attrs"
|
|
5
|
+
:href="sanitizeUrl(to)"
|
|
6
|
+
target="_blank"
|
|
7
|
+
:class="classes"
|
|
8
|
+
>
|
|
3
9
|
<slot></slot>
|
|
4
10
|
</a>
|
|
5
11
|
<Component
|
|
@@ -29,6 +35,8 @@ import { type Color, getColorDeep } from '@squirrel/utils/tailwind';
|
|
|
29
35
|
import { type PropType, defineComponent } from 'vue';
|
|
30
36
|
import { type RouteLocationRaw, RouterLink } from 'vue-router';
|
|
31
37
|
import { type Size } from '@squirrel/components/p-btn/p-btn.types';
|
|
38
|
+
import { isExternalLink } from '@squirrel/utils/link';
|
|
39
|
+
import { sanitizeUrl } from '@squirrel/utils/sanitization';
|
|
32
40
|
|
|
33
41
|
const BUTTON_TYPES = {
|
|
34
42
|
PRIMARY: 'primary',
|
|
@@ -173,9 +181,10 @@ export default defineComponent({
|
|
|
173
181
|
|
|
174
182
|
return getColorDeep(type);
|
|
175
183
|
},
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
184
|
+
},
|
|
185
|
+
methods: {
|
|
186
|
+
isExternalLink,
|
|
187
|
+
sanitizeUrl,
|
|
179
188
|
},
|
|
180
189
|
});
|
|
181
190
|
</script>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import PCloseBtn from '@squirrel/components/p-close-btn/p-close-btn.vue';
|
|
2
|
+
import { createWrapperFor } from '@tests/jest.helpers';
|
|
3
|
+
|
|
4
|
+
const buttonClasses = [
|
|
5
|
+
'inline-flex',
|
|
6
|
+
'h-8',
|
|
7
|
+
'w-8',
|
|
8
|
+
'cursor-pointer',
|
|
9
|
+
'items-center',
|
|
10
|
+
'justify-center',
|
|
11
|
+
'rounded',
|
|
12
|
+
'focus:outline-none',
|
|
13
|
+
'disabled:cursor-default',
|
|
14
|
+
'disabled:opacity-30',
|
|
15
|
+
'disabled:hover:bg-white',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const iconClasses = ['block', 'h-3', 'w-3', 'bg-center', 'bg-no-repeat'];
|
|
19
|
+
|
|
20
|
+
describe('PCloseBtn.vue', () => {
|
|
21
|
+
it('renders correctly', () => {
|
|
22
|
+
const wrapper = createWrapperFor(PCloseBtn);
|
|
23
|
+
|
|
24
|
+
const button = wrapper.find('button');
|
|
25
|
+
const i = wrapper.find('i');
|
|
26
|
+
|
|
27
|
+
expect(buttonClasses.every((c) => button.classes().includes(c))).toBe(true);
|
|
28
|
+
expect(iconClasses.every((c) => i.classes().includes(c))).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('button inherits attributes', async () => {
|
|
32
|
+
const wrapper = createWrapperFor(PCloseBtn, {
|
|
33
|
+
attrs: {
|
|
34
|
+
disabled: true,
|
|
35
|
+
'data-test': 'test',
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const button = await wrapper.find('button');
|
|
40
|
+
|
|
41
|
+
expect(button.attributes().disabled).toBeDefined();
|
|
42
|
+
expect(button.attributes('data-test')).toBe('test');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it.each([
|
|
46
|
+
['transparent', ['bg-transparent', 'hover:bg-p-gray-20'], ['x-black-icon']],
|
|
47
|
+
['gray', ['bg-p-gray-10', 'hover:bg-p-gray-20'], ['x-black-icon']],
|
|
48
|
+
['dark', ['bg-transparent'], ['x-white-icon']],
|
|
49
|
+
])('renders a PCloseBtn of variant %s', (variant, btnClasses, iClasses) => {
|
|
50
|
+
const wrapper = createWrapperFor(PCloseBtn, {
|
|
51
|
+
props: { variant },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const button = wrapper.find('button');
|
|
55
|
+
const i = wrapper.find('i');
|
|
56
|
+
|
|
57
|
+
expect(btnClasses.every((c) => button.classes().includes(c))).toBe(true);
|
|
58
|
+
expect(iClasses.every((c) => i.classes().includes(c))).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import PLink from '@squirrel/components/p-link/p-link.vue';
|
|
2
|
+
import { createWrapperFor } from '@tests/jest.helpers';
|
|
3
|
+
import { isExternalLink } from '@squirrel/utils/link';
|
|
4
|
+
import { sanitizeUrl } from '@squirrel/utils/sanitization';
|
|
5
|
+
|
|
6
|
+
jest.mock('@squirrel/utils/sanitization');
|
|
7
|
+
|
|
8
|
+
jest.mock('@squirrel/utils/link');
|
|
9
|
+
|
|
10
|
+
const createWrapper = (props, attrs) => {
|
|
11
|
+
return createWrapperFor(PLink, {
|
|
12
|
+
props,
|
|
13
|
+
attrs,
|
|
14
|
+
slots: {
|
|
15
|
+
default: 'Test link',
|
|
16
|
+
},
|
|
17
|
+
global: {
|
|
18
|
+
stubs: {
|
|
19
|
+
RouterLink: true,
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('PLink.vue', () => {
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
jest.clearAllMocks();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('renders a router link when the link is internal', () => {
|
|
31
|
+
isExternalLink.mockReturnValue(false);
|
|
32
|
+
|
|
33
|
+
const wrapper = createWrapper({ to: '/home' }, { class: 'p-link', 'data-test': 'test' });
|
|
34
|
+
|
|
35
|
+
const routerLink = wrapper.findComponent({ name: 'RouterLink' });
|
|
36
|
+
|
|
37
|
+
expect(routerLink.element).toBe(wrapper.element);
|
|
38
|
+
expect(routerLink.text()).toBe('Test link');
|
|
39
|
+
expect(routerLink.props().to).toBe('/home');
|
|
40
|
+
expect(routerLink.classes()).toContain('p-link');
|
|
41
|
+
expect(routerLink.attributes()['data-test']).toBe('test');
|
|
42
|
+
expect(isExternalLink).toHaveBeenCalledTimes(1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('renders an a href link when the link is external', () => {
|
|
46
|
+
isExternalLink.mockReturnValue(true);
|
|
47
|
+
sanitizeUrl.mockReturnValue('https://www.pequity.com');
|
|
48
|
+
|
|
49
|
+
const wrapper = createWrapper({ to: 'https://www.pequity.com' }, { class: 'p-link', 'data-test': 'test' });
|
|
50
|
+
|
|
51
|
+
const aLink = wrapper.find('a');
|
|
52
|
+
|
|
53
|
+
expect(aLink.element).toBe(wrapper.element);
|
|
54
|
+
expect(aLink.text()).toBe('Test link');
|
|
55
|
+
expect(aLink.attributes().href).toBe('https://www.pequity.com');
|
|
56
|
+
expect(wrapper.classes()).toContain('p-link');
|
|
57
|
+
expect(wrapper.attributes()['data-test']).toBe('test');
|
|
58
|
+
expect(wrapper.attributes().target).toBe('_blank');
|
|
59
|
+
expect(isExternalLink).toHaveBeenCalledTimes(1);
|
|
60
|
+
expect(sanitizeUrl).toHaveBeenCalledTimes(1);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import PLink from '@squirrel/components/p-link/p-link.vue';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
title: 'Components/PLink',
|
|
5
|
+
component: PLink,
|
|
6
|
+
tags: ['autodocs'],
|
|
7
|
+
parameters: {
|
|
8
|
+
docs: {
|
|
9
|
+
description: {
|
|
10
|
+
component: `The \`PLink\` component is a versatile link component designed to seamlessly handle both internal and external links.
|
|
11
|
+
It determines whether a given link is internal (for navigation within the app) or external (leading to a different website) and renders the appropriate link element accordingly.
|
|
12
|
+
In case of external links, it also sanitizes the URL to mitigate potential security risks like URL-based attacks.`,
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const InternalLink = {
|
|
19
|
+
render: (args) => ({
|
|
20
|
+
components: { PLink },
|
|
21
|
+
setup() {
|
|
22
|
+
return { args };
|
|
23
|
+
},
|
|
24
|
+
template: `<PLink v-bind="args" class="text-accent underline hover:text-accent">${args.default}</PLink>`,
|
|
25
|
+
}),
|
|
26
|
+
args: {
|
|
27
|
+
to: '/dummy',
|
|
28
|
+
default: 'Dummy internal link',
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const ExternalLink = {
|
|
33
|
+
...InternalLink,
|
|
34
|
+
args: {
|
|
35
|
+
to: 'https://www.pequity.com',
|
|
36
|
+
default: 'Link to Pequity website',
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<a v-if="typeof to === 'string' && isExternalLink(to)" :href="sanitizeUrl(to)" target="_blank">
|
|
3
|
+
<slot></slot>
|
|
4
|
+
</a>
|
|
5
|
+
<RouterLink v-else v-bind="$props">
|
|
6
|
+
<slot></slot>
|
|
7
|
+
</RouterLink>
|
|
8
|
+
</template>
|
|
9
|
+
|
|
10
|
+
<script setup lang="ts">
|
|
11
|
+
import { RouterLink, type RouterLinkProps } from 'vue-router';
|
|
12
|
+
import { isExternalLink } from '@squirrel/utils/link';
|
|
13
|
+
import { sanitizeUrl } from '@squirrel/utils/sanitization';
|
|
14
|
+
|
|
15
|
+
defineOptions({
|
|
16
|
+
name: 'PLink',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
defineProps<RouterLinkProps>();
|
|
20
|
+
</script>
|
package/squirrel/utils/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { createPagingRange } from '@squirrel/utils/pagination';
|
|
24
24
|
import { getNextActiveElement, isElement, isVisible } from '@squirrel/utils/dom';
|
|
25
25
|
import { isObject } from '@squirrel/utils/object';
|
|
26
|
+
import { sanitizeUrl } from '@squirrel/utils/sanitization';
|
|
26
27
|
import { setupListKeyboardNavigation } from '@squirrel/utils/listKeyboardNavigation';
|
|
27
28
|
import { splitStringForHighlight } from '@squirrel/utils/text';
|
|
28
29
|
import { toNumberOrNull } from '@squirrel/utils/number';
|
|
@@ -57,6 +58,7 @@ export {
|
|
|
57
58
|
isElement,
|
|
58
59
|
isVisible,
|
|
59
60
|
isObject,
|
|
61
|
+
sanitizeUrl,
|
|
60
62
|
setupListKeyboardNavigation,
|
|
61
63
|
splitStringForHighlight,
|
|
62
64
|
toNumberOrNull,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { isExternalLink } from '@squirrel/utils/link';
|
|
2
|
+
|
|
3
|
+
describe('isExternalLink', () => {
|
|
4
|
+
it.each(['https://www.example.com', 'http://www.example.com', '//www.example.com'])(
|
|
5
|
+
'should return true for external links (%s)',
|
|
6
|
+
(val) => {
|
|
7
|
+
expect(isExternalLink(val)).toBe(true);
|
|
8
|
+
}
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
it.each(['/home', 'home', '#home', '/home//1/', '/home:1/', '#home:', { name: 'home' }])(
|
|
12
|
+
'should return false for internal links (%s)',
|
|
13
|
+
(val) => {
|
|
14
|
+
expect(isExternalLink(val)).toBe(false);
|
|
15
|
+
}
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
it.each(['ftp://www.example.com', 'mailto:test@example.com', 'tel:+1234567890', 'sms:+1234567890'])(
|
|
19
|
+
'should handle different protocols (%s)',
|
|
20
|
+
(val) => {
|
|
21
|
+
expect(isExternalLink(val)).toBe(true);
|
|
22
|
+
}
|
|
23
|
+
);
|
|
24
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const normalizeUrl = (url: string) => {
|
|
2
|
+
if (url.indexOf('//') === 0) {
|
|
3
|
+
url = location.protocol + url;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
return url;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const isValidUrl = (url: string) => {
|
|
10
|
+
url = normalizeUrl(url);
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
return Boolean(new URL(url));
|
|
14
|
+
} catch (e) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const checkDomain = function (url: string) {
|
|
20
|
+
url = normalizeUrl(url);
|
|
21
|
+
|
|
22
|
+
return url
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.replace(/([a-z])?:\/\//, '$1')
|
|
25
|
+
.split('/')[0];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const isExternalLink = function (url: string) {
|
|
29
|
+
url = String(url);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
isValidUrl(url) &&
|
|
33
|
+
(url.indexOf(':') > -1 || url.indexOf('//') > -1) &&
|
|
34
|
+
checkDomain(location.href) !== checkDomain(url)
|
|
35
|
+
);
|
|
36
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { sanitizeUrl } from '@squirrel/utils/sanitization';
|
|
2
|
+
|
|
3
|
+
describe('sanitizeUrl', () => {
|
|
4
|
+
const consoleMock = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
5
|
+
|
|
6
|
+
afterAll(() => {
|
|
7
|
+
consoleMock.mockReset();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('reports unsafe URLs', () => {
|
|
11
|
+
const unsafeUrl = 'javascript:evil()';
|
|
12
|
+
|
|
13
|
+
expect(sanitizeUrl(unsafeUrl)).toBe(`unsafe:${unsafeUrl}`);
|
|
14
|
+
expect(consoleMock).toHaveBeenCalledWith(
|
|
15
|
+
expect.stringContaining(`WARNING: sanitizing unsafe URL value ${unsafeUrl}`)
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it.each([
|
|
20
|
+
'',
|
|
21
|
+
'http://abc',
|
|
22
|
+
'HTTP://abc',
|
|
23
|
+
'https://abc',
|
|
24
|
+
'HTTPS://abc',
|
|
25
|
+
'ftp://abc',
|
|
26
|
+
'FTP://abc',
|
|
27
|
+
'mailto:me@example.com',
|
|
28
|
+
'MAILTO:me@example.com',
|
|
29
|
+
'tel:123-123-1234',
|
|
30
|
+
'TEL:123-123-1234',
|
|
31
|
+
'#anchor',
|
|
32
|
+
'/page1.md',
|
|
33
|
+
'http://JavaScript/my.js',
|
|
34
|
+
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/', // Truncated.
|
|
35
|
+
'data:video/webm;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/',
|
|
36
|
+
'data:audio/opus;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/',
|
|
37
|
+
'unknown-scheme:abc',
|
|
38
|
+
])('returns the URL if it is valid (%s)', (urlVal) => {
|
|
39
|
+
expect(sanitizeUrl(urlVal)).toBe(urlVal);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it.each([
|
|
43
|
+
'javascript:evil()',
|
|
44
|
+
'JavaScript:abc',
|
|
45
|
+
' javascript:abc',
|
|
46
|
+
' \n Java\n Script:abc',
|
|
47
|
+
'javascript:',
|
|
48
|
+
'javascript:',
|
|
49
|
+
'j avascript:',
|
|
50
|
+
'javascript:',
|
|
51
|
+
'javascript:',
|
|
52
|
+
'jav	ascript:alert();',
|
|
53
|
+
'jav\u0000ascript:alert();',
|
|
54
|
+
])('it adds an "unsafe:" prefix if the URL is invalid (%s)', (urlVal) => {
|
|
55
|
+
expect(sanitizeUrl(urlVal)).toMatch(/^unsafe:/);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This is a port of the Angular url_sanitizer module.
|
|
3
|
+
* https://github.com/angular/angular/blob/main/packages/core/src/sanitization/url_sanitizer.ts
|
|
4
|
+
*
|
|
5
|
+
* TL;DR
|
|
6
|
+
* The function sanitizeUrl is designed to ensure that a given URL is safe,
|
|
7
|
+
* by checking it against a regular expression pattern (SAFE_URL_PATTERN).
|
|
8
|
+
* If the URL is considered unsafe, it returns a version of the URL prefixed with "unsafe:".
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A pattern that recognizes URLs that are safe wrt. XSS in URL navigation
|
|
13
|
+
* contexts.
|
|
14
|
+
*
|
|
15
|
+
* This regular expression matches a subset of URLs that will not cause script
|
|
16
|
+
* execution if used in URL context within a HTML document. Specifically, this
|
|
17
|
+
* regular expression matches if:
|
|
18
|
+
* (1) Either a protocol that is not javascript:, and that has valid characters
|
|
19
|
+
* (alphanumeric or [+-.]).
|
|
20
|
+
* (2) or no protocol. A protocol must be followed by a colon. The below
|
|
21
|
+
* allows that by allowing colons only after one of the characters [/?#].
|
|
22
|
+
* A colon after a hash (#) must be in the fragment.
|
|
23
|
+
* Otherwise, a colon after a (?) must be in a query.
|
|
24
|
+
* Otherwise, a colon after a single solidus (/) must be in a path.
|
|
25
|
+
* Otherwise, a colon after a double solidus (//) must be in the authority
|
|
26
|
+
* (before port).
|
|
27
|
+
*
|
|
28
|
+
* The pattern disallows &, used in HTML entity declarations before
|
|
29
|
+
* one of the characters in [/?#]. This disallows HTML entities used in the
|
|
30
|
+
* protocol name, which should never happen, e.g. "http" for "http".
|
|
31
|
+
* It also disallows HTML entities in the first path part of a relative path,
|
|
32
|
+
* e.g. "foo<bar/baz". Our existing escaping functions should not produce
|
|
33
|
+
* that. More importantly, it disallows masking of a colon,
|
|
34
|
+
* e.g. "javascript:...".
|
|
35
|
+
*
|
|
36
|
+
* This regular expression was taken from the Closure sanitization library.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const XSS_SECURITY_URL =
|
|
40
|
+
'https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html';
|
|
41
|
+
|
|
42
|
+
// eslint-disable-next-line no-useless-escape
|
|
43
|
+
const SAFE_URL_PATTERN = /^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:\/?#]*(?:[\/?#]|$))/i;
|
|
44
|
+
|
|
45
|
+
export const sanitizeUrl = (url: string) => {
|
|
46
|
+
url = String(url);
|
|
47
|
+
|
|
48
|
+
if (url.match(SAFE_URL_PATTERN)) {
|
|
49
|
+
return url;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.warn(`WARNING: sanitizing unsafe URL value ${url} (see ${XSS_SECURITY_URL})`);
|
|
53
|
+
|
|
54
|
+
return 'unsafe:' + url;
|
|
55
|
+
};
|