skolplattformen-backup/libs/api-skolplattformen/lib/api.ts

489 lines
14 KiB
TypeScript

import { Language } from '@skolplattformen/curriculum'
import { EventEmitter } from 'events'
import { decode } from 'he'
import { DateTime } from 'luxon'
import * as html from 'node-html-parser'
import * as fake from './fakeData'
import wrap, { Fetcher, FetcherOptions } from './fetcher'
import { checkStatus, LoginStatusChecker } from './loginStatus'
import * as parse from './parse/index'
import * as routes from './routes'
import {
AuthTicket,
CalendarItem,
Classmate,
CookieManager,
EtjanstChild,
Fetch,
MenuItem,
NewsItem,
Notification,
RequestInit,
ScheduleItem,
Skola24Child,
SSOSystem,
TimetableEntry,
User,
} from './types'
import { URLSearchParams } from './URLSearchParams'
const fakeResponse = <T>(data: T): Promise<T> =>
new Promise((res) => setTimeout(() => res(data), 200 + Math.random() * 800))
const s24Init = {
headers: {
accept: 'application/json, text/javascript, */*; q=0.01',
referer:
'https://fns.stockholm.se/ng/timetable/timetable-viewer/fns.stockholm.se/',
'accept-language': 'en-US,en;q=0.9,sv;q=0.8',
'cache-control': 'no-cache',
'content-type': 'application/json',
pragma: 'no-cache',
host: 'fns.stockholm.se',
'x-scope': '8a22163c-8662-4535-9050-bc5e1923df48',
},
}
interface SSOSystems {
[name: string]: boolean | undefined
}
export class Api extends EventEmitter {
private fetch: Fetcher
private personalNumber?: string
private headers: any
private cookieManager: CookieManager
public isLoggedIn = false
public isFake = false
private authorizedSystems: SSOSystems = {}
constructor(
fetch: Fetch,
cookieManager: CookieManager,
options?: FetcherOptions
) {
super()
this.fetch = wrap(fetch, options)
this.cookieManager = cookieManager
this.headers = {}
}
public getPersonalNumber(): string | undefined {
return this.personalNumber
}
private getRequestInit(options: RequestInit = {}): RequestInit {
return {
...options,
headers: {
...this.headers,
...options.headers,
},
}
}
public async getSession(
url: string,
options?: RequestInit
): Promise<RequestInit> {
const init = this.getRequestInit(options)
const cookie = await this.cookieManager.getCookieString(url)
return {
...init,
headers: {
...init.headers,
cookie,
},
}
}
private async clearSession(): Promise<void> {
this.headers = {}
await this.cookieManager.clearAll()
}
private addHeader(name: string, value: string): void {
this.headers[name] = value
}
public async login(personalNumber?: string): Promise<LoginStatusChecker> {
if (personalNumber !== undefined && personalNumber.endsWith('1212121212'))
return this.fakeMode()
this.isFake = false
const ticketUrl = routes.login(personalNumber)
const ticketResponse = await this.fetch('auth-ticket', ticketUrl)
if (!ticketResponse.ok) {
throw new Error(
`Server Error [${ticketResponse.status}] [${ticketResponse.statusText}] [${ticketUrl}]`
)
}
const ticket: AuthTicket = await ticketResponse.json()
// login was initiated - store personal number
this.personalNumber = personalNumber
const status = checkStatus(this.fetch, ticket)
status.on('OK', async () => {
await this.retrieveSessionCookie()
await this.retrieveXsrfToken()
this.isLoggedIn = true
this.emit('login')
})
status.on('ERROR', () => {
this.personalNumber = undefined
})
return status
}
public async setSessionCookie(sessionCookie: string): Promise<void> {
// Manually set cookie in this call and let the cookieManager
// handle it from here
// If we put it into the cookieManager manually, we get duplicate cookies
const url = routes.loginCookie
await this.fetch('login-cookie', url, {
headers: {
cookie: sessionCookie,
},
redirect: 'manual', // Important! Turn off redirect following. We can get into a redirect loop without this.
})
const user = await this.getUser()
if (!user.isAuthenticated) {
throw new Error('Session cookie is expired')
}
await this.retrieveXsrfToken()
this.isLoggedIn = true
this.emit('login')
}
private async retrieveSessionCookie(): Promise<void> {
const url = routes.loginCookie
await this.fetch('login-cookie', url)
}
private async retrieveXsrfToken(): Promise<void> {
const url = routes.hemPage
const session = this.getRequestInit()
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)
}
private async fakeMode(): Promise<LoginStatusChecker> {
this.isFake = true
setTimeout(() => {
this.isLoggedIn = true
this.emit('login')
}, 50)
const emitter: any = new EventEmitter()
emitter.token = 'fake'
return emitter
}
public async getUser(): Promise<User> {
if (this.isFake) return fakeResponse(fake.user())
const url = routes.user
const session = this.getRequestInit()
const response = await this.fetch('user', url, session)
const data = await response.json()
return parse.user(data)
}
public async getChildren(): Promise<EtjanstChild[]> {
if (this.isFake) return fakeResponse(fake.children())
const url = routes.children
const session = this.getRequestInit({
headers: {
Accept: 'application/json;odata=verbose',
Host: 'etjanst.stockholm.se',
Referer: 'https://etjanst.stockholm.se/vardnadshavare/inloggad2/hem',
},
})
const response = await this.fetch('children', url, session)
if (!response.ok) {
throw new Error(
`Server Error [${response.status}] [${response.statusText}] [${url}]`
)
}
const data = await response.json()
return parse.children(data)
}
public async getCalendar(child: EtjanstChild): Promise<CalendarItem[]> {
if (this.isFake) return fakeResponse(fake.calendar(child))
const url = routes.calendar(child.id)
const session = this.getRequestInit()
const response = await this.fetch('calendar', url, session)
const data = await response.json()
return parse.calendar(data)
}
public async getClassmates(child: EtjanstChild): Promise<Classmate[]> {
if (this.isFake) return fakeResponse(fake.classmates(child))
const url = routes.classmates(child.sdsId)
const session = this.getRequestInit()
const response = await this.fetch('classmates', url, session)
const data = await response.json()
return parse.classmates(data)
}
public async getSchedule(
child: EtjanstChild,
from: DateTime,
to: DateTime
): Promise<ScheduleItem[]> {
if (this.isFake) return fakeResponse(fake.schedule(child))
const url = routes.schedule(child.id, from.toISODate(), to.toISODate())
const session = this.getRequestInit()
const response = await this.fetch('schedule', url, session)
const data = await response.json()
return parse.schedule(data)
}
public async getNews(child: EtjanstChild): Promise<NewsItem[]> {
if (this.isFake) return fakeResponse(fake.news(child))
const url = routes.news(child.id)
const session = this.getRequestInit()
const response = await this.fetch('news', url, session)
const data = await response.json()
return parse.news(data)
}
public async getNewsDetails(
child: EtjanstChild,
item: NewsItem
): Promise<any> {
if (this.isFake) {
return fakeResponse(fake.news(child).find((ni) => ni.id === item.id))
}
const url = routes.newsDetails(child.id, item.id)
const session = this.getRequestInit()
const response = await this.fetch(`news_${item.id}`, url, session)
const data = await response.json()
return parse.newsItemDetails(data)
}
public async getMenu(child: EtjanstChild): Promise<MenuItem[]> {
if (this.isFake) return fakeResponse(fake.menu(child).map(parse.menuItem))
const menuService = await this.getMenuChoice(child)
if (menuService === 'rss') {
const url = routes.menuRss(child.id)
const session = this.getRequestInit()
const response = await this.fetch('menu-rss', url, session)
const data = await response.json()
return parse.menu(data)
}
const url = routes.menuList(child.id)
const session = this.getRequestInit()
const response = await this.fetch('menu-list', url, session)
const data = await response.json()
return parse.menuList(data)
}
private async getMenuChoice(child: EtjanstChild): Promise<string> {
const url = routes.menuChoice(child.id)
const session = this.getRequestInit()
const response = await this.fetch('menu-choice', url, session)
const data = await response.json()
const etjanstResponse = parse.etjanst(data)
return etjanstResponse
}
public async getNotifications(child: EtjanstChild): Promise<Notification[]> {
if (this.isFake) return fakeResponse(fake.notifications(child))
const url = routes.notifications(child.sdsId)
const session = this.getRequestInit()
const response = await this.fetch('notifications', url, session)
const data = await response.json()
return parse.notifications(data)
}
private async readSAMLRequest(targetSystem: string): Promise<string> {
const url = routes.ssoRequestUrl(targetSystem)
const session = this.getRequestInit({
redirect: 'follow',
})
const response = await this.fetch('samlRequest', url, session)
const text = await response.text()
const samlRequest = /name="SAMLRequest" value="(\S+)">/gm.exec(
text || ''
)?.[1]
if (!samlRequest) {
throw new Error('Could not parse SAML Request')
} else {
return samlRequest
}
}
private async submitSAMLRequest(samlRequest: string): Promise<string> {
const body = new URLSearchParams({ SAMLRequest: samlRequest }).toString()
const url = routes.ssoResponseUrl
const session = this.getRequestInit({
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
},
redirect: 'follow',
method: 'POST',
body,
})
const response = await this.fetch('samlResponse', url, session)
const text = await response.text()
const samlResponse = /name="SAMLResponse" value="(\S+)">/gm.exec(text)?.[1]
if (!samlResponse) {
throw new Error('Could not parse SAML Response')
} else {
return samlResponse
}
}
private async ssoAuthorize(targetSystem: SSOSystem): Promise<string> {
if (this.authorizedSystems[targetSystem]) {
return ''
}
const samlRequest = await this.readSAMLRequest(targetSystem)
const samlResponse = await this.submitSAMLRequest(samlRequest)
const body = new URLSearchParams({ SAMLResponse: samlResponse }).toString()
const url = routes.samlResponseUrl
const session = this.getRequestInit({
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
},
redirect: 'follow',
method: 'POST',
body,
})
const response = await this.fetch('samlAuthorize', url, session)
const text = await response.text()
this.authorizedSystems[targetSystem] = true
return text
}
public async getSkola24Children(): Promise<Skola24Child[]> {
if (this.isFake) return fakeResponse(fake.skola24Children())
await this.ssoAuthorize('TimetableViewer')
const body = {
getPersonalTimetablesRequest: {
hostName: 'fns.stockholm.se',
},
}
const session = this.getRequestInit({
...s24Init,
body: JSON.stringify(body),
method: 'POST',
})
const url = routes.timetables
const response = await this.fetch('s24children', url, session)
const {
data: {
getPersonalTimetablesResponse: { childrenTimetables },
},
} = await response.json()
return childrenTimetables as Skola24Child[]
}
private async getRenderKey(): Promise<string> {
const url = routes.renderKey
const session = this.getRequestInit(s24Init)
const response = await this.fetch('renderKey', url, session)
const {
data: { key },
} = await response.json()
return key as string
}
public async getTimetable(
child: Skola24Child,
week: number,
year: number,
lang: Language
): Promise<TimetableEntry[]> {
if (this.isFake) return fakeResponse(fake.timetable(child))
if (!child.timetableID) {
return new Array<TimetableEntry>()
}
const url = routes.timetable
const renderKey = await this.getRenderKey()
const params = {
blackAndWhite: false,
customerKey: '',
endDate: null,
height: 1063,
host: 'fns.stockholm.se',
periodText: '',
privateFreeTextMode: null,
privateSelectionMode: true,
renderKey,
scheduleDay: 0,
selection: child.personGuid,
selectionType: 5,
showHeader: false,
startDate: null,
unitGuid: child.unitGuid,
week,
width: 1227,
year,
}
const session = this.getRequestInit({
...s24Init,
method: 'POST',
body: JSON.stringify(params),
})
const response = await this.fetch(
`timetable_${child.personGuid}_${year}_${week}`,
url,
session
)
const json = await response.json()
return parse.timetable(json, year, week, lang)
}
public async logout() {
this.isFake = false
this.personalNumber = undefined
this.isLoggedIn = false
this.authorizedSystems = {}
this.emit('logout')
await this.clearSession()
}
}