mirror of
https://github.com/kremalicious/umami.git
synced 2024-12-22 01:03:39 +01:00
Switch to json web tokens.
This commit is contained in:
parent
5219582803
commit
cb0c912c5b
@ -1,31 +1,38 @@
|
||||
import crypto from 'crypto';
|
||||
import { v5 as uuid, v4 } from 'uuid';
|
||||
import Cryptr from 'cryptr';
|
||||
import { v5 } from 'uuid';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
const cryptr = new Cryptr(hash(process.env.HASH_SALT, process.env.DATABASE_URL));
|
||||
|
||||
export function md5(s) {
|
||||
return crypto.createHash('md5').update(s).digest('hex');
|
||||
export function sha256(...args) {
|
||||
return crypto.createHash('sha256').update(args.join('')).digest('hex');
|
||||
}
|
||||
|
||||
export function hash(...args) {
|
||||
return uuid(args.join(''), md5(process.env.HASH_SALT));
|
||||
export function secret() {
|
||||
return sha256(process.env.HASH_SALT);
|
||||
}
|
||||
|
||||
export function validHash(s) {
|
||||
export function uuid(...args) {
|
||||
return v5(args.join(''), v5(process.env.HASH_SALT, v5.DNS));
|
||||
}
|
||||
|
||||
export function random(n = 64) {
|
||||
return crypto.randomBytes(n).toString('hex');
|
||||
}
|
||||
|
||||
export function isValidHash(s) {
|
||||
return UUID_REGEX.test(s);
|
||||
}
|
||||
|
||||
export function encrypt(s) {
|
||||
return cryptr.encrypt(s);
|
||||
export async function createToken(payload, options) {
|
||||
return jwt.sign(payload, secret(), options);
|
||||
}
|
||||
|
||||
export function decrypt(s) {
|
||||
return cryptr.decrypt(s);
|
||||
export async function parseToken(token, options) {
|
||||
return jwt.verify(token, secret(), options);
|
||||
}
|
||||
|
||||
export function random() {
|
||||
return v4();
|
||||
export function checkPassword(password, hash) {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
10
lib/db.js
10
lib/db.js
@ -95,3 +95,13 @@ export async function saveEvent(website_id, session_id, url, event_type, event_v
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAccount(username = '') {
|
||||
return runQuery(
|
||||
prisma.account.findOne({
|
||||
where: {
|
||||
username,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
@ -1,50 +1,52 @@
|
||||
import { getWebsite, getSession, createSession } from 'lib/db';
|
||||
import { getCountry, getDevice, getIpAddress, isValidSession } from 'lib/utils';
|
||||
import { hash } from 'lib/crypto';
|
||||
import { getCountry, getDevice, getIpAddress } from 'lib/utils';
|
||||
import { uuid, parseToken, isValidHash } from 'lib/crypto';
|
||||
|
||||
export default async req => {
|
||||
const { payload } = req.body;
|
||||
const { session } = payload;
|
||||
const { website: website_uuid, hostname, screen, language, session } = payload;
|
||||
|
||||
if (isValidSession(session)) {
|
||||
return session;
|
||||
if (!isValidHash(website_uuid)) {
|
||||
throw new Error(`Invalid website: ${website_uuid}`);
|
||||
}
|
||||
|
||||
const ip = getIpAddress(req);
|
||||
const { userAgent, browser, os } = getDevice(req);
|
||||
const country = await getCountry(req, ip);
|
||||
const { website: website_uuid, hostname, screen, language } = payload;
|
||||
try {
|
||||
return await parseToken(session);
|
||||
} catch {
|
||||
const ip = getIpAddress(req);
|
||||
const { userAgent, browser, os } = getDevice(req);
|
||||
const country = await getCountry(req, ip);
|
||||
|
||||
if (website_uuid) {
|
||||
const website = await getWebsite(website_uuid);
|
||||
if (website_uuid) {
|
||||
const website = await getWebsite(website_uuid);
|
||||
|
||||
if (website) {
|
||||
const { website_id } = website;
|
||||
const session_uuid = hash(website_id, hostname, ip, userAgent, os);
|
||||
if (website) {
|
||||
const { website_id } = website;
|
||||
const session_uuid = uuid(website_id, hostname, ip, userAgent, os);
|
||||
|
||||
let session = await getSession(session_uuid);
|
||||
let session = await getSession(session_uuid);
|
||||
|
||||
if (!session) {
|
||||
session = await createSession(website_id, {
|
||||
if (!session) {
|
||||
session = await createSession(website_id, {
|
||||
session_uuid,
|
||||
hostname,
|
||||
browser,
|
||||
os,
|
||||
screen,
|
||||
language,
|
||||
country,
|
||||
});
|
||||
}
|
||||
|
||||
const { session_id } = session;
|
||||
|
||||
return {
|
||||
website_id,
|
||||
website_uuid,
|
||||
session_id,
|
||||
session_uuid,
|
||||
hostname,
|
||||
browser,
|
||||
os,
|
||||
screen,
|
||||
language,
|
||||
country,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const { session_id } = session;
|
||||
|
||||
return [
|
||||
website_id,
|
||||
website_uuid,
|
||||
session_id,
|
||||
session_uuid,
|
||||
hash(website_id, website_uuid, session_id, session_uuid),
|
||||
].join(':');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
20
lib/utils.js
20
lib/utils.js
@ -1,9 +1,8 @@
|
||||
import requestIp from 'request-ip';
|
||||
import { browserName, detectOS } from 'detect-browser';
|
||||
import isLocalhost from 'is-localhost-ip';
|
||||
import maxmind from 'maxmind';
|
||||
import geolite2 from 'geolite2-redist';
|
||||
import isLocalhost from 'is-localhost-ip';
|
||||
import { hash } from './crypto';
|
||||
|
||||
export function getIpAddress(req) {
|
||||
// Cloudflare
|
||||
@ -44,20 +43,3 @@ export async function getCountry(req, ip) {
|
||||
|
||||
return result.country.iso_code;
|
||||
}
|
||||
|
||||
export function parseSession(session) {
|
||||
const [website_id, website_uuid, session_id, session_uuid, sig] = (session || '').split(':');
|
||||
return {
|
||||
website_id: parseInt(website_id),
|
||||
website_uuid,
|
||||
session_id: parseInt(session_id),
|
||||
session_uuid,
|
||||
sig,
|
||||
};
|
||||
}
|
||||
|
||||
export function isValidSession(session) {
|
||||
const { website_id, website_uuid, session_id, session_uuid, sig } = parseSession(session);
|
||||
|
||||
return hash(website_id, website_uuid, session_id, session_uuid) === sig;
|
||||
}
|
||||
|
@ -29,17 +29,18 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "2.2.2",
|
||||
"base64url": "^3.0.1",
|
||||
"bcrypt": "^5.0.0",
|
||||
"chart.js": "^2.9.3",
|
||||
"classnames": "^2.2.6",
|
||||
"cookie": "^0.4.1",
|
||||
"cors": "^2.8.5",
|
||||
"cryptr": "^6.0.2",
|
||||
"date-fns": "^2.14.0",
|
||||
"detect-browser": "^5.1.1",
|
||||
"dotenv": "^8.2.0",
|
||||
"geolite2-redist": "^1.0.7",
|
||||
"is-localhost-ip": "^1.4.0",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"maxmind": "^4.1.3",
|
||||
"next": "9.3.5",
|
||||
"node-fetch": "^2.6.0",
|
||||
|
@ -1,18 +1,21 @@
|
||||
import { serialize } from 'cookie';
|
||||
import { hash, random, encrypt } from 'lib/crypto';
|
||||
import { checkPassword, createToken, secret } from 'lib/crypto';
|
||||
import { getAccount } from 'lib/db';
|
||||
|
||||
export default (req, res) => {
|
||||
const { password } = req.body;
|
||||
export default async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (password === process.env.PASSWORD) {
|
||||
const account = await getAccount(username);
|
||||
|
||||
if (account && (await checkPassword(password, account.password))) {
|
||||
const { user_id, username, is_admin } = account;
|
||||
const token = await createToken({ user_id, username, is_admin });
|
||||
const expires = new Date(Date.now() + 31536000000);
|
||||
const id = random();
|
||||
const value = encrypt(`${id}:${hash(id)}`);
|
||||
|
||||
const cookie = serialize('umami.auth', value, { expires, httpOnly: true });
|
||||
const cookie = serialize('umami.auth', token, { expires, httpOnly: true });
|
||||
|
||||
res.setHeader('Set-Cookie', [cookie]);
|
||||
res.status(200).send('');
|
||||
|
||||
res.status(200).send({ token });
|
||||
} else {
|
||||
res.status(401).send('');
|
||||
}
|
||||
|
@ -1,14 +1,14 @@
|
||||
import { parseSession } from 'lib/utils';
|
||||
import { savePageView, saveEvent } from 'lib/db';
|
||||
import { allowPost } from 'lib/middleware';
|
||||
import checkSession from 'lib/session';
|
||||
import { createToken } from 'lib/crypto';
|
||||
|
||||
export default async (req, res) => {
|
||||
await allowPost(req, res);
|
||||
|
||||
const session = await checkSession(req);
|
||||
|
||||
const { website_id, session_id } = parseSession(session);
|
||||
const { website_id, session_id } = session;
|
||||
const { type, payload } = req.body;
|
||||
let ok = 1;
|
||||
|
||||
@ -26,5 +26,7 @@ export default async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).json({ ok, session });
|
||||
const token = await createToken(session);
|
||||
|
||||
res.status(200).json({ ok, session: token });
|
||||
};
|
||||
|
@ -7,6 +7,16 @@ datasource db {
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model account {
|
||||
created_at DateTime? @default(now())
|
||||
is_admin Boolean @default(false)
|
||||
password String
|
||||
updated_at DateTime? @default(now())
|
||||
user_id Int @default(autoincrement()) @id
|
||||
username String @unique
|
||||
website website[]
|
||||
}
|
||||
|
||||
model event {
|
||||
created_at DateTime? @default(now())
|
||||
event_id Int @default(autoincrement()) @id
|
||||
@ -19,6 +29,8 @@ model event {
|
||||
website website @relation(fields: [website_id], references: [website_id])
|
||||
|
||||
@@index([created_at], name: "event_created_at_idx")
|
||||
@@index([session_id], name: "event_session_id_idx")
|
||||
@@index([website_id], name: "event_website_id_idx")
|
||||
}
|
||||
|
||||
model pageview {
|
||||
@ -32,6 +44,8 @@ model pageview {
|
||||
website website @relation(fields: [website_id], references: [website_id])
|
||||
|
||||
@@index([created_at], name: "pageview_created_at_idx")
|
||||
@@index([session_id], name: "pageview_session_id_idx")
|
||||
@@index([website_id], name: "pageview_website_id_idx")
|
||||
}
|
||||
|
||||
model session {
|
||||
@ -50,13 +64,16 @@ model session {
|
||||
pageview pageview[]
|
||||
|
||||
@@index([created_at], name: "session_created_at_idx")
|
||||
@@index([website_id], name: "session_website_id_idx")
|
||||
}
|
||||
|
||||
model website {
|
||||
created_at DateTime? @default(now())
|
||||
hostname String
|
||||
user_id Int
|
||||
website_id Int @default(autoincrement()) @id
|
||||
website_uuid String @unique
|
||||
account account @relation(fields: [user_id], references: [user_id])
|
||||
event event[]
|
||||
pageview pageview[]
|
||||
session session[]
|
||||
|
@ -1,6 +1,16 @@
|
||||
create table account (
|
||||
user_id serial primary key,
|
||||
username varchar(255) unique not null,
|
||||
password varchar(60) not null,
|
||||
is_admin bool not null default false,
|
||||
created_at timestamp with time zone default current_timestamp,
|
||||
updated_at timestamp with time zone default current_timestamp
|
||||
);
|
||||
|
||||
create table website (
|
||||
website_id serial primary key,
|
||||
website_uuid uuid unique not null,
|
||||
user_id int not null references account(user_id) on delete cascade,
|
||||
hostname varchar(100) not null,
|
||||
created_at timestamp with time zone default current_timestamp
|
||||
);
|
||||
@ -37,6 +47,8 @@ create table event (
|
||||
event_value varchar(50) not null
|
||||
);
|
||||
|
||||
create index on account(username);
|
||||
|
||||
create index on session(created_at);
|
||||
create index on session(website_id);
|
||||
|
||||
|
90
yarn.lock
90
yarn.lock
@ -1937,6 +1937,11 @@ base64-js@^1.0.2:
|
||||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1"
|
||||
integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==
|
||||
|
||||
base64url@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d"
|
||||
integrity sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==
|
||||
|
||||
base@^0.11.1:
|
||||
version "0.11.2"
|
||||
resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f"
|
||||
@ -2129,6 +2134,11 @@ buble@^0.20.0:
|
||||
minimist "^1.2.5"
|
||||
regexpu-core "4.5.4"
|
||||
|
||||
buffer-equal-constant-time@1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
|
||||
integrity sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=
|
||||
|
||||
buffer-from@^1.0.0:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
|
||||
@ -2759,11 +2769,6 @@ crypto-browserify@^3.11.0:
|
||||
randombytes "^2.0.0"
|
||||
randomfill "^1.0.3"
|
||||
|
||||
cryptr@^6.0.2:
|
||||
version "6.0.2"
|
||||
resolved "https://registry.yarnpkg.com/cryptr/-/cryptr-6.0.2.tgz#3f9e97f825ffb93f425eb24068efbb6a652bf947"
|
||||
integrity sha512-1TRHI4bmuLIB8WgkH9eeYXzhEg1T4tonO4vVaMBKKde8Dre51J68nAgTVXTwMYXAf7+mV2gBCkm/9wksjSb2sA==
|
||||
|
||||
css-blank-pseudo@^0.1.4:
|
||||
version "0.1.4"
|
||||
resolved "https://registry.yarnpkg.com/css-blank-pseudo/-/css-blank-pseudo-0.1.4.tgz#dfdefd3254bf8a82027993674ccf35483bfcb3c5"
|
||||
@ -3207,6 +3212,13 @@ duplexify@^3.4.2, duplexify@^3.6.0:
|
||||
readable-stream "^2.0.0"
|
||||
stream-shift "^1.0.0"
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
version "1.0.11"
|
||||
resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf"
|
||||
integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==
|
||||
dependencies:
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
electron-to-chromium@^1.3.322, electron-to-chromium@^1.3.488:
|
||||
version "1.3.496"
|
||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.496.tgz#3f43d32930481d82ad3663d79658e7c59a58af0b"
|
||||
@ -4790,6 +4802,22 @@ json5@^2.1.0, json5@^2.1.2:
|
||||
dependencies:
|
||||
minimist "^1.2.5"
|
||||
|
||||
jsonwebtoken@^8.5.1:
|
||||
version "8.5.1"
|
||||
resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d"
|
||||
integrity sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==
|
||||
dependencies:
|
||||
jws "^3.2.2"
|
||||
lodash.includes "^4.3.0"
|
||||
lodash.isboolean "^3.0.3"
|
||||
lodash.isinteger "^4.0.4"
|
||||
lodash.isnumber "^3.0.3"
|
||||
lodash.isplainobject "^4.0.6"
|
||||
lodash.isstring "^4.0.1"
|
||||
lodash.once "^4.0.0"
|
||||
ms "^2.1.1"
|
||||
semver "^5.6.0"
|
||||
|
||||
jsx-ast-utils@^2.4.1:
|
||||
version "2.4.1"
|
||||
resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-2.4.1.tgz#1114a4c1209481db06c690c2b4f488cc665f657e"
|
||||
@ -4798,6 +4826,23 @@ jsx-ast-utils@^2.4.1:
|
||||
array-includes "^3.1.1"
|
||||
object.assign "^4.1.0"
|
||||
|
||||
jwa@^1.4.1:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a"
|
||||
integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==
|
||||
dependencies:
|
||||
buffer-equal-constant-time "1.0.1"
|
||||
ecdsa-sig-formatter "1.0.11"
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
jws@^3.2.2:
|
||||
version "3.2.2"
|
||||
resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304"
|
||||
integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==
|
||||
dependencies:
|
||||
jwa "^1.4.1"
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
kind-of@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-2.0.1.tgz#018ec7a4ce7e3a86cb9141be519d24c8faa981b5"
|
||||
@ -4979,6 +5024,36 @@ lodash._reinterpolate@^3.0.0:
|
||||
resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
|
||||
integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=
|
||||
|
||||
lodash.includes@^4.3.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
|
||||
integrity sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=
|
||||
|
||||
lodash.isboolean@^3.0.3:
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6"
|
||||
integrity sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=
|
||||
|
||||
lodash.isinteger@^4.0.4:
|
||||
version "4.0.4"
|
||||
resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343"
|
||||
integrity sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=
|
||||
|
||||
lodash.isnumber@^3.0.3:
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc"
|
||||
integrity sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=
|
||||
|
||||
lodash.isplainobject@^4.0.6:
|
||||
version "4.0.6"
|
||||
resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
|
||||
integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=
|
||||
|
||||
lodash.isstring@^4.0.1:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451"
|
||||
integrity sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=
|
||||
|
||||
lodash.memoize@^4.1.2:
|
||||
version "4.1.2"
|
||||
resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
|
||||
@ -4989,6 +5064,11 @@ lodash.merge@^4.6.0:
|
||||
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
|
||||
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
|
||||
|
||||
lodash.once@^4.0.0:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
|
||||
integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=
|
||||
|
||||
lodash.template@^4.5.0:
|
||||
version "4.5.0"
|
||||
resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.5.0.tgz#f976195cf3f347d0d5f52483569fe8031ccce8ab"
|
||||
|
Loading…
Reference in New Issue
Block a user