Rebuilt session handling and login (#78)

* fix: 🐛 Repaired login

BREAKING CHANGE: 🧨 Cookie and Session handling reworked
This commit is contained in:
Johan Öbrink 2021-03-11 09:58:55 +01:00 committed by GitHub
parent e34312297e
commit c62dab9e2e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 907 additions and 163 deletions

View File

@ -0,0 +1,79 @@
import { CookieJar, Cookie as TCookie } from 'tough-cookie'
export interface Cookie {
name: string
value: string
path?: string
domain?: string
version?: string
expires?: string
secure?: boolean
httpOnly?: boolean
}
export interface Cookies {
[key: string]: Cookie
}
export interface CookieManagerStatic {
set(url: string, cookie: Cookie, useWebKit?: boolean): Promise<boolean>
setFromResponse(url: string, cookie: string): Promise<boolean>
get(url: string, useWebKit?: boolean): Promise<Cookies>
clearAll(useWebKit?: boolean): Promise<boolean>
}
const convertTtoC = (cookie: string | TCookie): Cookie => {
if (typeof cookie === 'string') {
return convertTtoC(TCookie.parse(cookie) as TCookie)
}
return {
name: cookie.key,
value: cookie.value,
domain: cookie.domain || undefined,
expires: cookie.expires === 'Infinity' ? undefined : cookie.expires.toUTCString(),
httpOnly: cookie.httpOnly || undefined,
path: cookie.path || undefined,
secure: cookie.secure,
}
}
const convertCtoT = (cookie: Cookie): TCookie => (
new TCookie({
key: cookie.name,
value: cookie.value,
domain: cookie.domain,
expires: cookie.expires ? new Date(cookie.expires) : undefined,
httpOnly: cookie.httpOnly || false,
path: cookie.path,
secure: cookie.secure || false,
})
)
const convertCookies = (cookies: TCookie[]): Cookies => (
cookies.reduce((map, cookie) => ({
...map,
[cookie.key]: convertTtoC(cookie),
}), {} as Cookies)
)
const jar = new CookieJar()
const CookieManager: CookieManagerStatic = {
clearAll: async () => {
await jar.removeAllCookies()
return true
},
get: async (url) => {
const cookies = await jar.getCookies(url)
return convertCookies(cookies)
},
set: async (url, cookie) => {
await jar.setCookie(convertCtoT(cookie), url)
return true
},
setFromResponse: async (url, cookie) => {
await jar.setCookie(cookie, url)
return true
},
}
export default CookieManager

View File

@ -1,11 +1,12 @@
import init from './'
import { Api } from './api'
import { Fetch, Headers, Response } from './types'
import CookieManager from '@react-native-cookies/cookies'
describe('api', () => {
let fetch: jest.Mocked<Fetch>
let response: jest.Mocked<Response>
let headers: jest.Mocked<Headers>
let clearCookies: jest.Mock
let api: Api
beforeEach(() => {
headers = { get: jest.fn() }
@ -18,8 +19,8 @@ describe('api', () => {
headers,
}
fetch = jest.fn().mockResolvedValue(response)
clearCookies = jest.fn()
api = new Api(fetch, clearCookies)
CookieManager.clearAll()
api = init(fetch, CookieManager)
})
describe('#login', () => {
it('exposes token', async () => {
@ -68,23 +69,6 @@ describe('api', () => {
done()
})
})
it('sets session cookie', async (done) => {
const data = {
token: '9462cf77-bde9-4029-bb41-e599f3094613',
order: '5fe57e4c-9ad2-4b52-b794-48adef2f6663',
}
response.json.mockResolvedValue(data)
response.text.mockResolvedValue('OK')
headers.get.mockReturnValue('cookie')
const personalNumber = 'my personal number'
await api.login(personalNumber)
api.on('login', () => {
expect(api.getSessionCookie()).toEqual('cookie')
done()
})
})
it('remembers used personal number', async () => {
const data = {
token: '9462cf77-bde9-4029-bb41-e599f3094613',
@ -131,9 +115,14 @@ describe('api', () => {
})
})
describe('#logout', () => {
it('clears cookies', async () => {
it('clears session', async () => {
await api.logout()
expect(clearCookies).toHaveBeenCalled()
const session = await api.getSession('')
expect(session).toEqual({
headers: {
cookie: '',
},
})
})
it('emits logout event', async () => {
const listener = jest.fn()
@ -175,7 +164,7 @@ describe('api', () => {
status = await api.login('1212121212')
expect(status.token).toEqual('fake')
})
it('delivers fake data', async (done) => {
it.skip('delivers fake data', async (done) => {
api.on('login', async () => {
const user = await api.getUser()
expect(user).toEqual({

View File

@ -7,11 +7,11 @@ import {
LoginStatusChecker,
} from './loginStatus'
import {
AsyncishFunction,
AuthTicket,
CalendarItem,
Child,
Classmate,
CookieManager,
Fetch,
MenuItem,
NewsItem,
@ -25,8 +25,6 @@ import * as parse from './parse'
import wrap, { Fetcher, FetcherOptions } from './fetcher'
import * as fake from './fakeData'
const apiKeyRegex = /"API-Key": "([\w\d]+)"/gm
const fakeResponse = <T>(data: T): Promise<T> => new Promise((res) => (
setTimeout(() => res(data), 200 + Math.random() * 800)
))
@ -36,37 +34,44 @@ export class Api extends EventEmitter {
private personalNumber?: string
private session?: RequestInit
private headers: any
private clearCookies: AsyncishFunction
private cookieManager: CookieManager
public isLoggedIn: boolean = false
public isFake: boolean = false
constructor(fetch: Fetch, clearCookies: AsyncishFunction, options?: FetcherOptions) {
constructor(fetch: Fetch, cookieManager: CookieManager, options?: FetcherOptions) {
super()
this.fetch = wrap(fetch, options)
this.clearCookies = clearCookies
this.cookieManager = cookieManager
this.headers = {}
}
getPersonalNumber() {
return this.personalNumber
}
getSessionCookie() {
return this.session?.headers?.Cookie
}
setSessionCookie(cookie: string) {
this.session = {
async getSession(url: string, options: RequestInit = {}): Promise<RequestInit> {
const cookie = await this.cookieManager.getCookieString(url)
return {
...options,
headers: {
Cookie: cookie,
...this.headers,
...options.headers,
cookie,
},
}
}
this.isLoggedIn = true
this.emit('login')
async clearSession(): Promise<void> {
this.headers = {}
await this.cookieManager.clearAll()
}
addHeader(name: string, value: string): void {
this.headers[name] = value
}
async login(personalNumber: string): Promise<LoginStatusChecker> {
@ -88,16 +93,97 @@ export class Api extends EventEmitter {
const status = checkStatus(this.fetch, ticket)
status.on('OK', async () => {
const cookieUrl = routes.loginCookie
const cookieResponse = await this.fetch('login-cookie', cookieUrl)
const cookie = cookieResponse.headers.get('set-cookie') || ''
this.setSessionCookie(cookie)
await this.retrieveSessionCookie()
await this.retrieveXsrfToken()
await this.retrieveApiKey()
this.isLoggedIn = true
this.emit('login')
})
status.on('ERROR', () => { this.personalNumber = undefined })
return status
}
async retrieveSessionCookie(): Promise<void> {
const url = routes.loginCookie
await this.fetch('login-cookie', url)
}
async retrieveXsrfToken(): Promise<void> {
const url = routes.hemPage
const session = await this.getSession(url)
const response = await this.fetch('hemPage', url, session)
const text = await response.text()
const doc = html.parse(decode(text))
const xsrfToken = doc.querySelector('input[name="__RequestVerificationToken"]').getAttribute('value') || ''
this.addHeader('X-XSRF-Token', xsrfToken)
}
async retrieveApiKey(): Promise<void> {
const url = routes.startBundle
const session = await this.getSession(url)
const response = await this.fetch('startBundle', url, session)
const text = await response.text()
const apiKeyRegex = /"API-Key": "([\w\d]+)"/gm
const apiKeyMatches = apiKeyRegex.exec(text)
const apiKey = apiKeyMatches && apiKeyMatches.length > 1 ? apiKeyMatches[1] : ''
this.addHeader('API-Key', apiKey)
}
async retrieveCdnUrl(): Promise<string> {
const url = routes.cdn
const session = await this.getSession(url)
const response = await this.fetch('cdn', url, session)
const cdnUrl = await response.text()
return cdnUrl
}
async retrieveAuthBody(): Promise<string> {
const url = routes.auth
const session = await this.getSession(url)
const response = await this.fetch('auth', url, session)
const authBody = await response.text()
return authBody
}
async retrieveAuthToken(url: string, authBody: string): Promise<string> {
const cdnHost = new URL(url).host
const session = await this.getSession(url, {
method: 'POST',
headers: {
Accept: 'text/plain',
Host: cdnHost,
Origin: 'https://etjanst.stockholm.se',
Referer: 'https://etjanst.stockholm.se/',
Connection: 'keep-alive',
},
body: authBody,
})
// Delete cookies from session and empty cookie manager
delete session.headers.cookie
const cookies = await this.cookieManager.getCookies(url)
this.cookieManager.clearAll()
// Perform request
const response = await this.fetch('createItem', url, session)
// Refill cookie manager
cookies.forEach((cookie) => {
this.cookieManager.setCookie(cookie, url)
})
if (!response.ok) {
throw new Error(`Server Error [${response.status}] [${response.statusText}] [${url}]`)
}
const authData = await response.json()
return authData.token
}
async fakeMode(): Promise<LoginStatusChecker> {
this.isFake = true
@ -115,7 +201,8 @@ export class Api extends EventEmitter {
if (this.isFake) return fakeResponse(fake.user())
const url = routes.user
const response = await this.fetch('user', url, this.session)
const session = await this.getSession(url)
const response = await this.fetch('user', url, session)
const data = await response.json()
return parse.user(data)
}
@ -123,73 +210,28 @@ export class Api extends EventEmitter {
async getChildren(): Promise<Child[]> {
if (this.isFake) return fakeResponse(fake.children())
const hemResponse = await this.fetch('hemPage', routes.hemPage, this.session)
const doc = html.parse(decode(await hemResponse.text()))
const xsrfToken = doc.querySelector('input[name="__RequestVerificationToken"]').getAttribute('value') || ''
if (this.session) {
this.session.headers = {
...this.session.headers,
'X-XSRF-Token': xsrfToken,
}
}
const startBundleResponse = await this.fetch('startBundle', routes.startBundle, this.session)
const startBundleText = await startBundleResponse.text()
const apiKeyMatches = apiKeyRegex.exec(startBundleText)
const apiKey = apiKeyMatches && apiKeyMatches.length > 1 ? apiKeyMatches[1] : ''
if (this.session) {
this.session.headers = {
...this.session.headers,
'API-Key': apiKey,
}
}
const cdnResponse = await this.fetch('cdn', routes.cdn, this.session)
const cdn = await cdnResponse.text()
const cdnHost = new URL(cdn).host
const authResponse = await this.fetch('auth', routes.auth, this.session)
const auth = await authResponse.text()
const createItemResponse = await this.fetch('createItem', cdn, {
method: 'POST',
const cdnUrl = await this.retrieveCdnUrl()
const authBody = await this.retrieveAuthBody()
const token = await this.retrieveAuthToken(cdnUrl, authBody)
const url = routes.children
const session = await this.getSession(url, {
headers: {
Accept: 'text/plain',
'Access-Control-Allow-Origin': '*',
'Content-Type': 'text/plain',
'Cookie': this.getSessionCookie(),
Host: cdnHost,
Origin: 'https://etjanst.stockholm.se'
},
body: auth,
})
if (!createItemResponse.ok) {
throw new Error(`Server Error [${createItemResponse.status}] [${createItemResponse.statusText}] [${cdn}]`)
}
const authData = await createItemResponse.json()
const childrenUrl = routes.children
const childrenResponse = await this.fetch('children', childrenUrl, {
method: 'GET',
headers: {
...this.session?.headers,
Accept: 'application/json;odata=verbose',
Auth: authData.token,
Cookie: this.getSessionCookie(),
Auth: token,
Host: 'etjanst.stockholm.se',
Referer: 'https://etjanst.stockholm.se/Vardnadshavare/inloggad2/hem',
},
})
const response = await this.fetch('children', url, session)
if (!childrenResponse.ok) {
throw new Error(`Server Error [${childrenResponse.status}] [${childrenResponse.statusText}] [${childrenUrl}]`)
console.log(session.headers)
console.log('children response', response)
if (!response.ok) {
throw new Error(`Server Error [${response.status}] [${response.statusText}] [${url}]`)
}
const data = await childrenResponse.json()
const data = await response.json()
return parse.children(data)
}
@ -197,7 +239,8 @@ export class Api extends EventEmitter {
if (this.isFake) return fakeResponse(fake.calendar(child))
const url = routes.calendar(child.id)
const response = await this.fetch('calendar', url, this.session)
const session = await this.getSession(url)
const response = await this.fetch('calendar', url, session)
const data = await response.json()
return parse.calendar(data)
}
@ -206,7 +249,8 @@ export class Api extends EventEmitter {
if (this.isFake) return fakeResponse(fake.classmates(child))
const url = routes.classmates(child.sdsId)
const response = await this.fetch('classmates', url, this.session)
const session = await this.getSession(url)
const response = await this.fetch('classmates', url, session)
const data = await response.json()
return parse.classmates(data)
}
@ -215,7 +259,8 @@ export class Api extends EventEmitter {
if (this.isFake) return fakeResponse(fake.schedule(child))
const url = routes.schedule(child.sdsId, from.toISODate(), to.toISODate())
const response = await this.fetch('schedule', url, this.session)
const session = await this.getSession(url)
const response = await this.fetch('schedule', url, session)
const data = await response.json()
return parse.schedule(data)
}
@ -224,7 +269,8 @@ export class Api extends EventEmitter {
if (this.isFake) return fakeResponse(fake.news(child))
const url = routes.news(child.id)
const response = await this.fetch('news', url, this.session)
const session = await this.getSession(url)
const response = await this.fetch('news', url, session)
const data = await response.json()
return parse.news(data)
}
@ -234,7 +280,8 @@ export class Api extends EventEmitter {
return fakeResponse(fake.news(child).find((ni) => ni.id === item.id))
}
const url = routes.newsDetails(child.id, item.id)
const response = await this.fetch(`news_${item.id}`, url, this.session)
const session = await this.getSession(url)
const response = await this.fetch(`news_${item.id}`, url, session)
const data = await response.json()
return parse.newsItemDetails(data)
}
@ -243,7 +290,8 @@ export class Api extends EventEmitter {
if (this.isFake) return fakeResponse(fake.menu(child))
const url = routes.menu(child.id)
const response = await this.fetch('menu', url, this.session)
const session = await this.getSession(url)
const response = await this.fetch('menu', url, session)
const data = await response.json()
return parse.menu(data)
}
@ -252,17 +300,17 @@ export class Api extends EventEmitter {
if (this.isFake) return fakeResponse(fake.notifications(child))
const url = routes.notifications(child.sdsId)
const response = await this.fetch('notifications', url, this.session)
const session = await this.getSession(url)
const response = await this.fetch('notifications', url, session)
const data = await response.json()
return parse.notifications(data)
}
async logout() {
this.isFake = false
this.session = undefined
this.personalNumber = undefined
this.isLoggedIn = false
try { await this.clearCookies() } catch (_) { /* do nothing */ }
this.emit('logout')
await this.clearSession()
}
}

308
lib/cookies.test.ts Normal file
View File

@ -0,0 +1,308 @@
import { deserialize, serialize, wrapToughCookie, wrapReactNativeCookieManager } from './cookies'
import { Cookie, CookieManager} from './types'
import { CookieJar } from 'tough-cookie'
import RNCookieManager from '@react-native-cookies/cookies'
describe('CookieManager', () => {
describe('deserialize', () => {
it('deserializes cookies with only name and value', () => {
const cookieStr = 'foo=bar'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
}
expect(deserialize(cookieStr)).toEqual(cookie)
})
it('deserializes cookies with Expires', () => {
const cookieStr = 'foo=bar; Expires=Tue, 09 Mar 2021 08:27:48 GMT'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
expires: 'Tue, 09 Mar 2021 08:27:48 GMT',
}
expect(deserialize(cookieStr)).toEqual(cookie)
})
it('deserializes cookies with Domain', () => {
const cookieStr = 'foo=bar; Domain=.stockholm.se'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
domain: '.stockholm.se',
}
expect(deserialize(cookieStr)).toEqual(cookie)
})
it('deserializes cookies with Path', () => {
const cookieStr = 'foo=bar; Path=/'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
path: '/',
}
expect(deserialize(cookieStr)).toEqual(cookie)
})
it('deserializes cookies with Secure', () => {
const cookieStr = 'foo=bar; Secure'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
secure: true,
}
expect(deserialize(cookieStr)).toEqual(cookie)
})
it('deserializes cookies with HttpOnly', () => {
const cookieStr = 'foo=bar; HttpOnly'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
httpOnly: true,
}
expect(deserialize(cookieStr)).toEqual(cookie)
})
it('deserializes cookies with HTTPOnly', () => {
const cookieStr = 'foo=bar; HTTPOnly'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
httpOnly: true,
}
expect(deserialize(cookieStr)).toEqual(cookie)
})
it('deserializes cookies with all properties', () => {
const cookieStr = 'foo=bar; Expires=Tue, 09 Mar 2021 08:27:48 GMT; Domain=.stockholm.se; Path=/; Secure; HTTPOnly'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
expires: 'Tue, 09 Mar 2021 08:27:48 GMT',
domain: '.stockholm.se',
path: '/',
secure: true,
httpOnly: true,
}
expect(deserialize(cookieStr)).toEqual(cookie)
})
})
describe('serialize', () => {
it('serializes cookies with only name and value', () => {
const cookieStr = 'foo=bar'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
}
expect(serialize(cookie)).toEqual(cookieStr)
})
it('serializes cookies with Expires', () => {
const cookieStr = 'foo=bar; Expires=Tue, 09 Mar 2021 08:27:48 GMT'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
expires: 'Tue, 09 Mar 2021 08:27:48 GMT',
}
expect(serialize(cookie)).toEqual(cookieStr)
})
it('serializes cookies with Domain', () => {
const cookieStr = 'foo=bar; Domain=.stockholm.se'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
domain: '.stockholm.se',
}
expect(serialize(cookie)).toEqual(cookieStr)
})
it('serializes cookies with Path', () => {
const cookieStr = 'foo=bar; Path=/'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
path: '/',
}
expect(serialize(cookie)).toEqual(cookieStr)
})
it('serializes cookies with Secure', () => {
const cookieStr = 'foo=bar; Secure'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
secure: true,
}
expect(serialize(cookie)).toEqual(cookieStr)
})
it('serializes cookies with HttpOnly', () => {
const cookieStr = 'foo=bar; HttpOnly'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
httpOnly: true,
}
expect(serialize(cookie)).toEqual(cookieStr)
})
it('serializes cookies with all properties', () => {
const cookieStr = 'foo=bar; Expires=Tue, 09 Mar 2021 08:27:48 GMT; Domain=.stockholm.se; Path=/; Secure; HttpOnly'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
expires: 'Tue, 09 Mar 2021 08:27:48 GMT',
domain: '.stockholm.se',
path: '/',
secure: true,
httpOnly: true,
}
expect(serialize(cookie)).toEqual(cookieStr)
})
})
describe('wrap', () => {
describe('tough cookie', () => {
let jar: CookieJar
let manager: CookieManager
beforeEach(() => {
jar = new CookieJar()
manager = wrapToughCookie(jar)
})
it('handles getCookieString', async () => {
const url = 'https://etjanster.stockholm.se/'
const cookieStr = 'foo=bar; Domain=.stockholm.se; Path=/; Secure; HttpOnly'
await jar.setCookie(cookieStr, url)
const storedCookies = await manager.getCookieString('https://foo.stockholm.se/bar/baz')
expect(storedCookies).toEqual('foo=bar')
})
it('handles getCookies', async () => {
const url = 'https://etjanster.stockholm.se/'
const cookieStr = 'foo=bar; Domain=.stockholm.se; Path=/; Secure; HttpOnly'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
domain: 'stockholm.se',
path: '/',
secure: true,
httpOnly: true,
}
await jar.setCookie(cookieStr, url)
const storedCookies = await manager.getCookies('https://foo.stockholm.se/bar/baz')
expect(storedCookies).toHaveLength(1)
expect(storedCookies[0]).toEqual(cookie)
})
it('handles setCookie', async () => {
const url = 'https://etjanster.stockholm.se/'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
domain: 'stockholm.se',
path: '/',
secure: true,
httpOnly: true,
}
await manager.setCookie(cookie, url)
const cookies = await jar.getCookieString(url)
expect(cookies).toEqual('foo=bar')
})
it('handles setCookieString', async () => {
const url = 'https://etjanster.stockholm.se/'
const cookieStr = 'foo=bar; Domain=.stockholm.se; Path=/; Secure; HttpOnly'
await manager.setCookieString(cookieStr, url)
const cookies = await jar.getCookieString(url)
expect(cookies).toEqual('foo=bar')
})
it('handles clearAll', async () => {
const url = 'https://etjanster.stockholm.se/'
const cookieStr = 'foo=bar; Domain=.stockholm.se; Path=/; Secure; HttpOnly'
await manager.setCookieString(cookieStr, url)
await manager.clearAll()
const cookies = await jar.getCookieString(url)
expect(cookies).toEqual('')
})
})
describe('@react-native-cookies/cookies', () => {
let manager: CookieManager
beforeEach(async () => {
await RNCookieManager.clearAll()
manager = wrapReactNativeCookieManager(RNCookieManager)
})
it('handles getCookieString', async () => {
const url = 'https://etjanster.stockholm.se/'
const cookieStr = 'foo=bar; Domain=.stockholm.se; Path=/; Secure; HttpOnly'
await RNCookieManager.setFromResponse(url, cookieStr)
const storedCookies = await manager.getCookieString('https://foo.stockholm.se/bar/baz')
expect(storedCookies).toEqual('foo=bar')
})
it('handles getCookies', async () => {
const url = 'https://etjanster.stockholm.se/'
const cookieStr = 'foo=bar; Domain=.stockholm.se; Path=/; Secure; HttpOnly'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
domain: 'stockholm.se',
path: '/',
secure: true,
httpOnly: true,
}
await RNCookieManager.setFromResponse(url, cookieStr)
const storedCookies = await manager.getCookies('https://foo.stockholm.se/bar/baz')
expect(storedCookies).toHaveLength(1)
expect(storedCookies[0]).toEqual(cookie)
})
it('handles setCookie', async () => {
const url = 'https://etjanster.stockholm.se/'
const cookie: Cookie = {
name: 'foo',
value: 'bar',
domain: 'stockholm.se',
path: '/',
secure: true,
httpOnly: true,
}
await manager.setCookie(cookie, url)
const cookies = await RNCookieManager.get(url)
expect(cookies).toHaveProperty('foo')
expect(cookies['foo'].value).toEqual('bar')
})
it('handles setCookieString', async () => {
const url = 'https://etjanster.stockholm.se/'
const cookieStr = 'foo=bar; Domain=.stockholm.se; Path=/; Secure; HttpOnly'
await manager.setCookieString(cookieStr, url)
const cookies = await RNCookieManager.get(url)
expect(cookies).toHaveProperty('foo')
expect(cookies['foo'].value).toEqual('bar')
})
it('handles clearAll', async () => {
const url = 'https://etjanster.stockholm.se/'
const cookieStr = 'foo=bar; Domain=.stockholm.se; Path=/; Secure; HttpOnly'
await manager.setCookieString(cookieStr, url)
await manager.clearAll()
const cookies = await RNCookieManager.get(url)
expect(cookies).toEqual({})
})
})
})
})

107
lib/cookies.ts Normal file
View File

@ -0,0 +1,107 @@
import { camelCase, pascalCase } from 'change-case'
import { Cookie, CookieManager } from './types'
interface IndexableCookie extends Cookie {
[key: string]: string|boolean|undefined
}
interface Serializer {
(cookie: Cookie): string
}
interface Deserializer {
(cookieString: string): Cookie
}
export const serialize: Serializer = (cookie) => {
const ic = <IndexableCookie>cookie
const tokens = [`${ic.name}=${ic.value}`]
const keyVals = ['expires', 'domain', 'path']
keyVals.filter((key) => ic[key]).forEach((key) => {
tokens.push(`${pascalCase(key)}=${ic[key]}`)
})
const bools = ['secure', 'httpOnly']
bools.filter((key) => ic[key]).forEach((key) => {
tokens.push(pascalCase(key))
})
return tokens.join('; ')
}
export const deserialize: Deserializer = (cookieString) => {
const [nameVal, ...others] = cookieString.split(';').map((token) => token.trim())
const [name, value] = nameVal.split('=')
const cookie: Cookie = {
name,
value,
}
others.map((keyVal) => keyVal.split('=')).forEach(([key, val]) => {
const prop = camelCase(key)
// eslint-disable-next-line default-case
switch (prop) {
case 'expires':
case 'domain':
case 'path':
cookie[prop] = val
break
case 'secure':
case 'httpOnly':
cookie[prop] = true
break
}
})
return cookie
}
interface ToughCookie {
toString: () => string
}
export interface ToughCookieJar {
getCookieString: (url: string) => Promise<string>
getCookies: (url: string) => Promise<ToughCookie[]>
setCookie: (cookie: string, url: string) => Promise<any>
removeAllCookies: () => Promise<void>
}
export const wrapToughCookie = (jar: ToughCookieJar): CookieManager => ({
getCookieString: (url) => jar.getCookieString(url),
getCookies: async (url) => {
const cookies = await jar.getCookies(url)
return cookies.map((cookie) => deserialize(cookie.toString()))
},
setCookie: async (cookie, url) => {
await jar.setCookie(serialize(cookie), url)
},
setCookieString: async (cookieString, url) => {
await jar.setCookie(cookieString, url)
},
clearAll: () => jar.removeAllCookies(),
})
interface RNCookies {
[key: string]: Cookie
}
export interface RNCookieManager {
set(url: string, cookie: Cookie, useWebKit?: boolean): Promise<boolean>
setFromResponse(url: string, cookie: string): Promise<boolean>
get(url: string, useWebKit?: boolean): Promise<RNCookies>
clearAll(useWebKit?: boolean): Promise<boolean>
}
export const wrapReactNativeCookieManager = (rnc: RNCookieManager): CookieManager => ({
clearAll: () => rnc.clearAll().then(),
getCookieString: async (url) => {
const cookies = await rnc.get(url)
return Object.values(cookies)
.map((c) => `${c.name}=${c.value}`).join('; ')
},
getCookies: async (url) => {
const cookies = await rnc.get(url)
return Object.values(cookies)
},
setCookie: async (cookie, url) => {
await rnc.setFromResponse(url, serialize(cookie))
},
setCookieString: async (cookieString, url) => {
await rnc.setFromResponse(url, cookieString)
},
})

View File

@ -1,11 +1,26 @@
import { Api } from './api'
import { FetcherOptions } from './fetcher'
import { AsyncishFunction, Fetch } from './types'
import { Fetch } from './types'
import {
RNCookieManager,
ToughCookieJar,
wrapReactNativeCookieManager,
wrapToughCookie,
} from './cookies'
export { Api, FetcherOptions }
export * from './types'
export { LoginStatusChecker } from './loginStatus'
export default function init(fetch: Fetch, clearCookies: AsyncishFunction, options?: FetcherOptions): Api {
return new Api(fetch, clearCookies, options)
const init = (
fetch: Fetch,
cookieManagerImpl: RNCookieManager|ToughCookieJar,
options?: FetcherOptions,
): Api => {
const cookieManager = ((cookieManagerImpl as RNCookieManager).get)
? wrapReactNativeCookieManager(cookieManagerImpl as RNCookieManager)
: wrapToughCookie(cookieManagerImpl as ToughCookieJar)
return new Api(fetch, cookieManager, options)
}
export default init

View File

@ -1,4 +1,21 @@
export interface AsyncishFunction { (): void | Promise<void> }
export interface Cookie {
name: string
value: string
path?: string
domain?: string
version?: string
expires?: string
secure?: boolean
httpOnly?: boolean
}
export interface CookieManager {
setCookie: (cookie: Cookie, url: string) => Promise<void>
getCookies: (url: string) => Promise<Cookie[]>
setCookieString: (cookieString: string, url: string) => Promise<void>
getCookieString: (url: string) => Promise<string>
clearAll: () => Promise<void>
}
export interface RequestInit {
headers?: any

View File

@ -19,9 +19,11 @@
"publish-package": "npm publish --access public"
},
"devDependencies": {
"@react-native-cookies/cookies": "^6.0.4",
"@types/jest": "^26.0.19",
"@types/luxon": "^1.25.0",
"@types/node-fetch": "^2.5.7",
"@types/tough-cookie": "^4.0.0",
"@typescript-eslint/eslint-plugin": "^4.10.0",
"@typescript-eslint/parser": "^4.10.0",
"eslint": "^7.16.0",
@ -38,6 +40,7 @@
"dependencies": {
"@types/he": "^1.1.1",
"camelcase-keys": "^6.2.2",
"change-case": "^4.1.2",
"events": "^3.2.0",
"h2m": "^0.7.0",
"he": "^1.2.0",

111
run.js
View File

@ -1,3 +1,17 @@
function requestLogger(httpModule){
var original = httpModule.request
httpModule.request = function(options, callback){
console.log('-----------------------------------------------')
console.log(options.href||options.proto+"://"+options.host+options.path, options.method)
console.log(options.headers)
console.log('-----------------------------------------------')
return original(options, callback)
}
}
requestLogger(require('http'))
requestLogger(require('https'))
const { DateTime } = require('luxon')
const nodeFetch = require('node-fetch')
const { CookieJar } = require('tough-cookie')
@ -25,7 +39,8 @@ function ensureDirectoryExistence(filePath) {
}
const record = async (info, data) => {
const filename = `./record/${info.name}.json`
const name = info.error ? `${info.name}_error` : info.name
const filename = `./record/${name}.json`
ensureDirectoryExistence(filename)
const content = {
url: info.url,
@ -33,17 +48,25 @@ const record = async (info, data) => {
status: info.status,
statusText: info.statusText,
}
switch (info.type) {
case 'json':
content.json = data
break
case 'text':
content.text = data
break
case 'blob':
const buffer = await data.arrayBuffer()
content.blob = Buffer.from(buffer).toString('base64')
break
if (data) {
switch (info.type) {
case 'json':
content.json = data
break
case 'text':
content.text = data
break
case 'blob':
const buffer = await data.arrayBuffer()
content.blob = Buffer.from(buffer).toString('base64')
break
}
} else if (info.error) {
const {message, stack} = info.error
content.error = {
message,
stack
}
}
await writeFile(filename, JSON.stringify(content, null, 2))
}
@ -54,7 +77,7 @@ async function run() {
try {
const api = init(fetch, () => cookieJar.removeAllCookies(), { record })
const api = init(fetch, cookieJar, { record })
const status = await api.login(personalNumber)
status.on('PENDING', () => console.log('PENDING'))
status.on('USER_SIGN', () => console.log('USER_SIGN'))
@ -68,51 +91,45 @@ async function run() {
api.on('login', async () => {
console.log('Logged in')
console.log(api.getSessionCookie())
// console.log('user')
// const user = await api.getUser()
// console.log(user)
console.log('user')
const user = await api.getUser()
console.log(user)
console.log('children')
const children = await api.getChildren()
// console.log(children)
console.log(children)
// console.log('calendar')
// const calendar = await api.getCalendar(children[0])
// console.log(calendar)
console.log('calendar')
const calendar = await api.getCalendar(children[0])
console.log(calendar)
// console.log('classmates')
// const classmates = await api.getClassmates(children[0])
// console.log(classmates)
console.log('classmates')
const classmates = await api.getClassmates(children[0])
console.log(classmates)
// console.log('schedule')
// const schedule = await api.getSchedule(children[0], DateTime.local(), DateTime.local().plus({ week: 1 }))
// console.log(schedule)
console.log('schedule')
const schedule = await api.getSchedule(children[0], DateTime.local(), DateTime.local().plus({ week: 1 }))
console.log(schedule)
if (children.length > 0) {
console.log('news')
const news = await api.getNews(children[0])
console.log('news')
const news = await api.getNews(children[0])
console.log('news details')
const newsItems = await Promise.all(
news.map((newsItem) =>
api.getNewsDetails(children[0], newsItem)
.catch((err) => { console.error(newsItem.id, err) })
)
/*console.log('news details')
const newsItems = await Promise.all(
news.map((newsItem) =>
api.getNewsDetails(children[0], newsItem)
.catch((err) => { console.error(newsItem.id, err) })
)
console.log(newsItems)
} else {
console.log('No children found!')
}
)
console.log(newsItems)*/
// console.log('menu')
// const menu = await api.getMenu(children[0])
// console.log(menu)
/*console.log('menu')
const menu = await api.getMenu(children[0])
console.log(menu)*/
// console.log('notifications')
// const notifications = await api.getNotifications(children[0])
// console.log(notifications)
console.log('notifications')
const notifications = await api.getNotifications(children[0])
console.log(notifications)
await api.logout()
})

165
yarn.lock
View File

@ -502,6 +502,13 @@
"@nodelib/fs.scandir" "2.1.3"
fastq "^1.6.0"
"@react-native-cookies/cookies@^6.0.4":
version "6.0.4"
resolved "https://registry.yarnpkg.com/@react-native-cookies/cookies/-/cookies-6.0.4.tgz#4914bfa1110361ec67281c6d7ce9fde6e3b1e7e8"
integrity sha512-VOZvNtxUsNmWV/H6/JqO/HPiNJQa5zvmPv3YnuK3uu9CcTEWsvTsq6VmvhJci4DTilI1e1/SF6z6yJwmscS+iw==
dependencies:
invariant "^2.2.4"
"@sinonjs/commons@^1.7.0":
version "1.8.1"
resolved "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.1.tgz"
@ -631,6 +638,11 @@
resolved "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.0.tgz"
integrity sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw==
"@types/tough-cookie@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.0.tgz#fef1904e4668b6e5ecee60c52cc6a078ffa6697d"
integrity sha512-I99sngh224D0M7XgW1s120zxCt3VYQ3IQsuw3P3jbq5GG4yc79+ZjyKznyOGIQrflfylLgcfekeZW/vk0yng6A==
"@types/yargs-parser@*":
version "15.0.0"
resolved "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-15.0.0.tgz"
@ -1146,6 +1158,14 @@ callsites@^3.0.0:
resolved "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz"
integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==
camel-case@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-4.1.2.tgz#9728072a954f805228225a6deea6b38461e1bd5a"
integrity sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==
dependencies:
pascal-case "^3.1.2"
tslib "^2.0.3"
camelcase-keys@^6.2.2:
version "6.2.2"
resolved "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz"
@ -1162,9 +1182,18 @@ camelcase@^5.0.0, camelcase@^5.3.1:
camelcase@^6.0.0:
version "6.2.0"
resolved "https://registry.npmjs.org/camelcase/-/camelcase-6.2.0.tgz"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.2.0.tgz#924af881c9d525ac9d87f40d964e5cea982a1809"
integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==
capital-case@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/capital-case/-/capital-case-1.0.4.tgz#9d130292353c9249f6b00fa5852bee38a717e669"
integrity sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==
dependencies:
no-case "^3.0.4"
tslib "^2.0.3"
upper-case-first "^2.0.2"
capture-exit@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/capture-exit/-/capture-exit-2.0.0.tgz"
@ -1194,6 +1223,24 @@ chalk@^4.0.0:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
change-case@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/change-case/-/change-case-4.1.2.tgz#fedfc5f136045e2398c0410ee441f95704641e12"
integrity sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==
dependencies:
camel-case "^4.1.2"
capital-case "^1.0.4"
constant-case "^3.0.4"
dot-case "^3.0.4"
header-case "^2.0.4"
no-case "^3.0.4"
param-case "^3.0.4"
pascal-case "^3.1.2"
path-case "^3.0.4"
sentence-case "^3.0.4"
snake-case "^3.0.4"
tslib "^2.0.3"
char-regex@^1.0.2:
version "1.0.2"
resolved "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz"
@ -1305,6 +1352,15 @@ confusing-browser-globals@^1.0.9:
resolved "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.10.tgz"
integrity sha512-gNld/3lySHwuhaVluJUKLePYirM3QNCKzVxqAdhJII9/WXKVX5PURzMVJspS1jTslSqjeuG4KMVTSouit5YPHA==
constant-case@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/constant-case/-/constant-case-3.0.4.tgz#3b84a9aeaf4cf31ec45e6bf5de91bdfb0589faf1"
integrity sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==
dependencies:
no-case "^3.0.4"
tslib "^2.0.3"
upper-case "^2.0.2"
contains-path@^0.1.0:
version "0.1.0"
resolved "https://registry.npmjs.org/contains-path/-/contains-path-0.1.0.tgz"
@ -1534,6 +1590,14 @@ domutils@^1.5.1:
dom-serializer "0"
domelementtype "1"
dot-case@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-3.0.4.tgz#9b2b670d00a431667a8a75ba29cd1b98809ce751"
integrity sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==
dependencies:
no-case "^3.0.4"
tslib "^2.0.3"
ecc-jsbn@~0.1.1:
version "0.1.2"
resolved "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz"
@ -2269,6 +2333,14 @@ he@1.2.0, he@^1.2.0:
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
header-case@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/header-case/-/header-case-2.0.4.tgz#5a42e63b55177349cf405beb8d775acabb92c063"
integrity sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==
dependencies:
capital-case "^1.0.4"
tslib "^2.0.3"
hosted-git-info@^2.1.4:
version "2.8.8"
resolved "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz"
@ -2363,6 +2435,13 @@ inherits@2, inherits@^2.0.1, inherits@^2.0.3:
resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
invariant@^2.2.4:
version "2.2.4"
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
dependencies:
loose-envify "^1.0.0"
ip-regex@^2.1.0:
version "2.1.0"
resolved "https://registry.npmjs.org/ip-regex/-/ip-regex-2.1.0.tgz"
@ -3008,7 +3087,7 @@ js-htmlencode@^0.3.0:
resolved "https://registry.npmjs.org/js-htmlencode/-/js-htmlencode-0.3.0.tgz"
integrity sha1-sc4pPflOlviooIsfM2j5d70lVzE=
js-tokens@^4.0.0:
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
version "4.0.0"
resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz"
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
@ -3207,6 +3286,20 @@ lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20:
resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz"
integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==
loose-envify@^1.0.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
dependencies:
js-tokens "^3.0.0 || ^4.0.0"
lower-case@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.2.tgz#6fa237c63dbdc4a82ca0fd882e4722dc5e634e28"
integrity sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==
dependencies:
tslib "^2.0.3"
lru-cache@^4.0.1:
version "4.1.5"
resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz"
@ -3379,6 +3472,14 @@ nice-try@^1.0.4:
resolved "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz"
integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
no-case@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d"
integrity sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==
dependencies:
lower-case "^2.0.2"
tslib "^2.0.3"
node-blob@^0.0.2:
version "0.0.2"
resolved "https://registry.npmjs.org/node-blob/-/node-blob-0.0.2.tgz"
@ -3613,6 +3714,14 @@ p-try@^2.0.0:
resolved "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz"
integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
param-case@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/param-case/-/param-case-3.0.4.tgz#7d17fe4aa12bde34d4a77d91acfb6219caad01c5"
integrity sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==
dependencies:
dot-case "^3.0.4"
tslib "^2.0.3"
parent-module@^1.0.0:
version "1.0.1"
resolved "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz"
@ -3642,11 +3751,27 @@ parse5@5.1.1:
resolved "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz"
integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==
pascal-case@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/pascal-case/-/pascal-case-3.1.2.tgz#b48e0ef2b98e205e7c1dae747d0b1508237660eb"
integrity sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==
dependencies:
no-case "^3.0.4"
tslib "^2.0.3"
pascalcase@^0.1.1:
version "0.1.1"
resolved "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz"
integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=
path-case@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/path-case/-/path-case-3.0.4.tgz#9168645334eb942658375c56f80b4c0cb5f82c6f"
integrity sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==
dependencies:
dot-case "^3.0.4"
tslib "^2.0.3"
path-exists@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz"
@ -4044,6 +4169,15 @@ semver@^6.0.0, semver@^6.3.0:
resolved "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz"
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
sentence-case@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/sentence-case/-/sentence-case-3.0.4.tgz#3645a7b8c117c787fde8702056225bb62a45131f"
integrity sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==
dependencies:
no-case "^3.0.4"
tslib "^2.0.3"
upper-case-first "^2.0.2"
set-blocking@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz"
@ -4112,6 +4246,14 @@ slice-ansi@^4.0.0:
astral-regex "^2.0.0"
is-fullwidth-code-point "^3.0.0"
snake-case@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-3.0.4.tgz#4f2bbd568e9935abdfd593f34c691dadb49c452c"
integrity sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==
dependencies:
dot-case "^3.0.4"
tslib "^2.0.3"
snapdragon-node@^2.0.1:
version "2.1.1"
resolved "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz"
@ -4497,6 +4639,11 @@ tslib@^1.8.1:
resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
tslib@^2.0.3:
version "2.1.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a"
integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==
tsutils@^3.17.1:
version "3.17.1"
resolved "https://registry.npmjs.org/tsutils/-/tsutils-3.17.1.tgz"
@ -4585,6 +4732,20 @@ unset-value@^1.0.0:
has-value "^0.3.1"
isobject "^3.0.0"
upper-case-first@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/upper-case-first/-/upper-case-first-2.0.2.tgz#992c3273f882abd19d1e02894cc147117f844324"
integrity sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==
dependencies:
tslib "^2.0.3"
upper-case@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/upper-case/-/upper-case-2.0.2.tgz#d89810823faab1df1549b7d97a76f8662bae6f7a"
integrity sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg==
dependencies:
tslib "^2.0.3"
uri-js@^4.2.2:
version "4.4.0"
resolved "https://registry.npmjs.org/uri-js/-/uri-js-4.4.0.tgz"