mirror of
https://github.com/Paris-est-Ludique/intranet.git
synced 2025-06-08 08:34:20 +02:00
Secure with jwt
This commit is contained in:
parent
976fbded67
commit
a84c329040
2
.gitignore
vendored
2
.gitignore
vendored
@ -13,7 +13,7 @@ public/*
|
||||
|
||||
# Access
|
||||
access/gsheets.json
|
||||
access/token.json
|
||||
access/jwt_secret.json
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
|
@ -93,6 +93,7 @@
|
||||
"hpp": "^0.2.3",
|
||||
"html-minifier": "^4.0.0",
|
||||
"https": "^1.0.0",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"lodash": "^4.17.21",
|
||||
"morgan": "^1.10.0",
|
||||
"normalize.css": "^8.0.1",
|
||||
@ -125,11 +126,12 @@
|
||||
"@types/compression": "^1.7.1",
|
||||
"@types/compression-webpack-plugin": "^6.0.6",
|
||||
"@types/css-minimizer-webpack-plugin": "^3.0.2",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/express": "4.17.1",
|
||||
"@types/google-spreadsheet": "^3.1.5",
|
||||
"@types/hpp": "^0.2.1",
|
||||
"@types/html-minifier": "^4.0.1",
|
||||
"@types/jest": "^26.0.24",
|
||||
"@types/jsonwebtoken": "^8.5.6",
|
||||
"@types/loadable__component": "^5.13.4",
|
||||
"@types/loadable__server": "^5.12.6",
|
||||
"@types/loadable__webpack-plugin": "^5.7.3",
|
||||
|
@ -15,4 +15,5 @@ export default {
|
||||
},
|
||||
],
|
||||
},
|
||||
JWT_SECRET: "RblQqA6uF#msq2312bebf2FLFn4XzWQ6dttXSJwBX#?gL2JWf!",
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import path from "path"
|
||||
import express from "express"
|
||||
import express, { RequestHandler } from "express"
|
||||
import logger from "morgan"
|
||||
import compression from "compression"
|
||||
import helmet from "helmet"
|
||||
@ -15,6 +15,7 @@ import devServer from "./devServer"
|
||||
import ssr from "./ssr"
|
||||
|
||||
import certbotRouter from "../routes/certbot"
|
||||
import { secure } from "./secure"
|
||||
import { jeuJavListGet } from "./gsheets/jeuJav"
|
||||
import { envieListGet, envieAdd } from "./gsheets/envies"
|
||||
import { membreGet, membreSet } from "./gsheets/membres"
|
||||
@ -43,21 +44,23 @@ app.use(express.static(path.resolve(process.cwd(), "public")))
|
||||
// Enable dev-server in development
|
||||
if (__DEV__) devServer(app)
|
||||
|
||||
/**
|
||||
* APIs
|
||||
*/
|
||||
|
||||
// Google Sheets API
|
||||
app.use(express.json())
|
||||
app.get("/JeuJavListGet", jeuJavListGet)
|
||||
app.get("/EnvieListGet", envieListGet)
|
||||
app.get("/MembreGet", membreGet)
|
||||
app.post("/MembreSet", membreSet)
|
||||
app.post("/EnvieAdd", envieAdd)
|
||||
|
||||
// Sign in & up API
|
||||
app.post("/api/user/login", loginHandler)
|
||||
|
||||
/**
|
||||
* APIs
|
||||
*/
|
||||
// Google Sheets API
|
||||
app.get("/JeuJavListGet", jeuJavListGet)
|
||||
app.get("/EnvieListGet", envieListGet)
|
||||
app.post("/EnvieAdd", envieAdd)
|
||||
|
||||
// Secured APIs
|
||||
app.get("/MembreGet", secure as RequestHandler, membreGet)
|
||||
app.post("/MembreSet", secure as RequestHandler, membreSet)
|
||||
|
||||
// Use React server-side rendering middleware
|
||||
app.get("*", ssr)
|
||||
|
||||
|
58
src/server/secure.ts
Normal file
58
src/server/secure.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { NextFunction, Request, Response } from "express"
|
||||
import path from "path"
|
||||
import { promises as fs } from "fs"
|
||||
import { verify, sign } from "jsonwebtoken"
|
||||
import { canonicalEmail } from "../utils/standardization"
|
||||
|
||||
import config from "../config"
|
||||
|
||||
type AuthorizedRequest = Request & { headers: { authorization: string } }
|
||||
|
||||
let cachedSecret: string
|
||||
getSecret() // Necessary until we can make async express middleware
|
||||
|
||||
export function secure(request: AuthorizedRequest, response: Response, next: NextFunction): void {
|
||||
if (!cachedSecret) {
|
||||
response.status(408).json({
|
||||
error: "Server still loading",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const rawToken = request.headers.authorization
|
||||
const token = rawToken && rawToken.split(/\s/)[1]
|
||||
|
||||
verify(token, cachedSecret, (tokenError: any, decoded: any) => {
|
||||
if (tokenError) {
|
||||
response.status(403).json({
|
||||
error: "Invalid token, please Log in first, criterion auth",
|
||||
})
|
||||
return
|
||||
}
|
||||
decoded.user.replace(/@gmailcom$/, "@gmail.com")
|
||||
response.locals.jwt = decoded
|
||||
next()
|
||||
})
|
||||
}
|
||||
|
||||
async function getSecret() {
|
||||
if (!cachedSecret) {
|
||||
const SECRET_PATH = path.resolve(process.cwd(), "access/jwt_secret.json")
|
||||
|
||||
try {
|
||||
const secretContent = await fs.readFile(SECRET_PATH)
|
||||
cachedSecret = secretContent && JSON.parse(secretContent.toString()).secret
|
||||
} catch (e: any) {
|
||||
cachedSecret = config.JWT_SECRET
|
||||
}
|
||||
}
|
||||
|
||||
return cachedSecret
|
||||
}
|
||||
|
||||
export async function getJwt(email: string): Promise<string> {
|
||||
const jwt = sign({ user: canonicalEmail(email), permissions: [] }, await getSecret(), {
|
||||
expiresIn: "7d",
|
||||
})
|
||||
return jwt
|
||||
}
|
@ -2,6 +2,7 @@ import { Request, Response, NextFunction } from "express"
|
||||
import bcrypt from "bcrypt"
|
||||
import { Membre, MemberLogin, emailRegexp, passwordMinLength } from "../../services/membres"
|
||||
import getAccessors from "../gsheets/accessors"
|
||||
import { getJwt } from "../secure"
|
||||
|
||||
const { listGet } = getAccessors("Membres", new Membre())
|
||||
|
||||
@ -50,9 +51,12 @@ export async function login(rawEmail: string, rawPassword: string): Promise<Memb
|
||||
throw Error("Mauvais mot de passe pour cet email")
|
||||
}
|
||||
|
||||
const jwt = await getJwt(email)
|
||||
|
||||
return {
|
||||
membre: {
|
||||
prenom: membre.prenom,
|
||||
},
|
||||
jwt,
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import axios from "axios"
|
||||
|
||||
import config from "../config"
|
||||
import { axiosConfig } from "./auth"
|
||||
|
||||
export type ElementWithId = unknown & { id: number }
|
||||
|
||||
@ -15,6 +16,7 @@ export function get<Element>(elementName: string): (id: number) => Promise<{
|
||||
return async (id: number): Promise<ElementGetResponse> => {
|
||||
try {
|
||||
const { data } = await axios.get(`${config.API_URL}/${elementName}Get`, {
|
||||
...axiosConfig,
|
||||
params: { id },
|
||||
})
|
||||
return { data }
|
||||
@ -34,7 +36,7 @@ export function listGet<Element>(elementName: string): () => Promise<{
|
||||
}
|
||||
return async (): Promise<ElementListGetResponse> => {
|
||||
try {
|
||||
const { data } = await axios.get(`${config.API_URL}/${elementName}ListGet`)
|
||||
const { data } = await axios.get(`${config.API_URL}/${elementName}ListGet`, axiosConfig)
|
||||
return { data }
|
||||
} catch (error) {
|
||||
return { error: error as Error }
|
||||
@ -57,7 +59,8 @@ export function add<ElementNoId extends object, Element extends ElementNoId & El
|
||||
try {
|
||||
const { data } = await axios.post(
|
||||
`${config.API_URL}/${elementName}Add`,
|
||||
membreWithoutId
|
||||
membreWithoutId,
|
||||
axiosConfig
|
||||
)
|
||||
return { data }
|
||||
} catch (error) {
|
||||
@ -76,7 +79,11 @@ export function set<Element>(elementName: string): (membre: Element) => Promise<
|
||||
}
|
||||
return async (membre: Element): Promise<ElementGetResponse> => {
|
||||
try {
|
||||
const { data } = await axios.post(`${config.API_URL}/${elementName}Set`, membre)
|
||||
const { data } = await axios.post(
|
||||
`${config.API_URL}/${elementName}Set`,
|
||||
membre,
|
||||
axiosConfig
|
||||
)
|
||||
return { data }
|
||||
} catch (error) {
|
||||
return { error: error as Error }
|
||||
|
22
src/services/auth.ts
Normal file
22
src/services/auth.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { AxiosRequestConfig } from "axios"
|
||||
|
||||
const storage: any = localStorage
|
||||
|
||||
const jwt: string | null = storage?.getItem("id_token")
|
||||
if (jwt) {
|
||||
setJWT(jwt)
|
||||
}
|
||||
|
||||
export const axiosConfig: AxiosRequestConfig = {
|
||||
headers: {},
|
||||
}
|
||||
|
||||
export function setJWT(token: string): void {
|
||||
axiosConfig.headers.Authorization = `Bearer ${token}`
|
||||
storage?.setItem("id_token", token)
|
||||
}
|
||||
|
||||
export function unsetJWT(): void {
|
||||
delete axiosConfig.headers.Authorization
|
||||
storage?.removeItem("id_token")
|
||||
}
|
@ -36,6 +36,7 @@ export interface MemberLogin {
|
||||
membre?: {
|
||||
prenom: string
|
||||
}
|
||||
jwt?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
|
18
src/utils/standardization.ts
Normal file
18
src/utils/standardization.ts
Normal file
@ -0,0 +1,18 @@
|
||||
export function canonicalEmail(email: string): string {
|
||||
email = email.replace(/^\s+|\s+$/g, "")
|
||||
if (/@gmail.com$/.test(email)) {
|
||||
let domain = email.replace(/^.*@/, "")
|
||||
domain = domain.replace(/^googlemail%.com$/, "gmail.com")
|
||||
email = email
|
||||
.replace(/\./g, "")
|
||||
.replace(/^[^@]+/, (match) => match.toLowerCase())
|
||||
.replace(/@.*$/, `@${domain}`)
|
||||
}
|
||||
return email.toLowerCase()
|
||||
}
|
||||
|
||||
export function validEmail(email: string): boolean {
|
||||
return /^(?=.{1,254}$)(?=.{1,64}@)[-!#$%&'*+/0-9=?A-Z^_`a-z{|}~]+(\.[-!#$%&'*+/0-9=?A-Z^_`a-z{|}~]+)*@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/.test(
|
||||
email
|
||||
)
|
||||
}
|
@ -29,6 +29,9 @@ const config: Configuration = {
|
||||
banner: 'require("source-map-support").install();',
|
||||
raw: true,
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
localStorage: { getItem: () => null, setItem: () => null, removeItem: () => null },
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
|
90
yarn.lock
90
yarn.lock
@ -1581,6 +1581,15 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.50.tgz#1e0caa9364d3fccd2931c3ed96fdbeaa5d4cca83"
|
||||
integrity sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw==
|
||||
|
||||
"@types/express-serve-static-core@*":
|
||||
version "4.17.26"
|
||||
resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.26.tgz#5d9a8eeecb9d5f9d7fc1d85f541512a84638ae88"
|
||||
integrity sha512-zeu3tpouA043RHxW0gzRxwCHchMgftE8GArRsvYT0ByDMbn19olQHx5jLue0LxWY6iYtXb7rXmuVtSkhy9YZvQ==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
"@types/qs" "*"
|
||||
"@types/range-parser" "*"
|
||||
|
||||
"@types/express-serve-static-core@^4.17.18":
|
||||
version "4.17.25"
|
||||
resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.25.tgz#e42f7046adc65ece2eb6059b77aecfbe9e9f82e0"
|
||||
@ -1590,7 +1599,7 @@
|
||||
"@types/qs" "*"
|
||||
"@types/range-parser" "*"
|
||||
|
||||
"@types/express@*", "@types/express@^4.17.13":
|
||||
"@types/express@*":
|
||||
version "4.17.13"
|
||||
resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.13.tgz#a76e2995728999bab51a33fabce1d705a3709034"
|
||||
integrity sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==
|
||||
@ -1600,6 +1609,15 @@
|
||||
"@types/qs" "*"
|
||||
"@types/serve-static" "*"
|
||||
|
||||
"@types/express@4.17.1":
|
||||
version "4.17.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.1.tgz#4cf7849ae3b47125a567dfee18bfca4254b88c5c"
|
||||
integrity sha512-VfH/XCP0QbQk5B5puLqTLEeFgR8lfCJHZJKkInZ9mkYd+u8byX0kztXEQxEk4wZXJs8HI+7km2ALXjn4YKcX9w==
|
||||
dependencies:
|
||||
"@types/body-parser" "*"
|
||||
"@types/express-serve-static-core" "*"
|
||||
"@types/serve-static" "*"
|
||||
|
||||
"@types/glob@^7.1.1":
|
||||
version "7.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.2.0.tgz#bc1b5bf3aa92f25bd5dd39f35c57361bdce5b2eb"
|
||||
@ -1694,6 +1712,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
|
||||
integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
|
||||
|
||||
"@types/jsonwebtoken@^8.5.6":
|
||||
version "8.5.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-8.5.6.tgz#1913e5a61e70a192c5a444623da4901a7b1a9d42"
|
||||
integrity sha512-+P3O/xC7nzVizIi5VbF34YtqSonFsdnbXBnWUCYRiKOi1f9gA4sEFvXkrGr/QVV23IbMYvcoerI7nnhDUiWXRQ==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/loadable__component@^5.13.4":
|
||||
version "5.13.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/loadable__component/-/loadable__component-5.13.4.tgz#a4646b2406b1283efac1a9d9485824a905b33d4a"
|
||||
@ -6873,6 +6898,22 @@ jsonfile@^6.0.1:
|
||||
optionalDependencies:
|
||||
graceful-fs "^4.1.6"
|
||||
|
||||
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"
|
||||
|
||||
jsprim@^1.2.2:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
|
||||
@ -6896,6 +6937,15 @@ junk@^3.1.0:
|
||||
resolved "https://registry.yarnpkg.com/junk/-/junk-3.1.0.tgz#31499098d902b7e98c5d9b9c80f43457a88abfa1"
|
||||
integrity sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==
|
||||
|
||||
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"
|
||||
|
||||
jwa@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.0.tgz#a7e9c3f29dae94027ebcaf49975c9345593410fc"
|
||||
@ -6905,6 +6955,14 @@ jwa@^2.0.0:
|
||||
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"
|
||||
|
||||
jws@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.0.tgz#2d4e8cf6a318ffaa12615e9dec7e86e6c97310f4"
|
||||
@ -7103,16 +7161,41 @@ lodash.debounce@^4.0.8:
|
||||
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
|
||||
integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168=
|
||||
|
||||
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.isequalwith@^4.4.0:
|
||||
version "4.4.0"
|
||||
resolved "https://registry.yarnpkg.com/lodash.isequalwith/-/lodash.isequalwith-4.4.0.tgz#266726ddd528f854f21f4ea98a065606e0fbc6b0"
|
||||
integrity sha1-Jmcm3dUo+FTyH06pigZWBuD7xrA=
|
||||
|
||||
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.x, lodash.memoize@^4.1.2:
|
||||
version "4.1.2"
|
||||
resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
|
||||
@ -7123,6 +7206,11 @@ lodash.merge@^4.6.2:
|
||||
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.truncate@^4.4.2:
|
||||
version "4.4.2"
|
||||
resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193"
|
||||
|
Loading…
x
Reference in New Issue
Block a user