import { Api, AuthTicket, CalendarItem, Classmate, CookieManager, EtjanstChild, Fetch, Fetcher, FetcherOptions, FrejaLoginStatusChecker, LoginStatusChecker, MenuItem, NewsItem, Notification, RequestInit, Response, ScheduleItem, Skola24Child, SSOSystem, Teacher, TimetableEntry, SchoolContact, URLSearchParams, User, wrap, } from '@skolplattformen/api' 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 { checkStatus, DummyStatusChecker } from './loginStatusChecker' import { checkStatus as checkFrejaStatus } from './frejaLoginStatusChecker' import * as parse from './parse/index' import queueFetcherWrapper from './queueFetcherWrapper' import * as routes from './routes' const fakeResponse = (data: T): Promise => 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 ApiSkolplattformen extends EventEmitter implements Api { private fetch: Fetcher private personalNumber?: string // eslint-disable-next-line @typescript-eslint/no-explicit-any private headers: any private cookieManager: CookieManager public isLoggedIn = false public isFake = false private authorizedSystems: SSOSystems = {} constructor( fetch: Fetch, // typeof global.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 getSessionHeaders( url: string ): Promise<{ [index: string]: string }> { const init = this.getRequestInit() const cookie = await this.cookieManager.getCookieString(url) return { ...init.headers, cookie, } } public async getSession( url: string, options?: RequestInit ): Promise { const init = this.getRequestInit(options) const cookie = await this.cookieManager.getCookieString(url) return { ...init, headers: { ...init.headers, cookie, }, } } private async clearSession(): Promise { this.headers = {} await this.cookieManager.clearAll() } private addHeader(name: string, value: string): void { this.headers[name] = value } public async login(personalNumber?: string): Promise { 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() const [user] = await Promise.all([ this.getUser(), this.retrieveXsrfToken(), ]) this.personalNumber = user.personalNumber this.isLoggedIn = true this.emit('login') }) status.on('ERROR', () => { this.personalNumber = undefined }) return status } public async loginFreja(): Promise { await this.clearSession() const loginUrl = routes.frejaLogin const loginResponse = await this.fetch('auth-ticket', loginUrl) // if (!ticketResponse.ok) { // throw new Error( // `Server Error [${ticketResponse.status}] [${ticketResponse.statusText}] [${ticketUrl}]` // ) // } const appSwitchUrl: string = await loginResponse.text() const cleanAppSwitchUrl = this.cleanFrejaAppSwitchUrl(appSwitchUrl) console.log('getting freja login url: ' + cleanAppSwitchUrl) const checkStatusSession = await this.getSession(loginUrl, { redirect: 'manual', }) const status = checkFrejaStatus( this.fetch, cleanAppSwitchUrl, checkStatusSession ) status.on('APPROVED', async () => { await this.retrieveFrejaSessionCookie() const [user] = await Promise.all([ this.getUser(), this.retrieveXsrfToken(), ]) this.personalNumber = user.personalNumber this.isLoggedIn = true this.emit('login') }) return status } private cleanFrejaAppSwitchUrl(url: string): string { const parts = url.split('&') return parts[0] } public async setSessionCookie(sessionCookie: string): Promise { // 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 { const url = routes.loginCookie await this.fetch('login-cookie', url) } private async retrieveFrejaSessionCookie(): Promise { try { const url = routes.frejaReturnUrl await this.fetch('freja-login-return-url', url) } catch (error) { console.log(JSON.stringify(error)) } try { const url2 = routes.frejaLoginCookie await this.fetch('freja-login-cookie', url2) } catch (error2) { console.log(JSON.stringify(error2)) } } private async retrieveXsrfToken(): Promise { 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 { this.isFake = true setTimeout(() => { this.isLoggedIn = true this.emit('login') }, 50) const emitter = new DummyStatusChecker() emitter.token = 'fake' return emitter } public async getUser(): Promise { 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 { 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() const parsed = parse.children(data) const useSpecialQueueModeForFSChildren = parsed.some((c) => (c.status || '').includes('FS') ) if (useSpecialQueueModeForFSChildren) { this.fetch = queueFetcherWrapper(this.fetch, (childId) => this.selectChildById(childId) ) } return parsed } public async getCalendar(child: EtjanstChild): Promise { 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, child.id) const data = await response.json() return parse.calendar(data) } public async getClassmates(child: EtjanstChild): Promise { 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 getTeachers(child: EtjanstChild): Promise { if (this.isFake) return fakeResponse(fake.teachers(child)) const session = this.getRequestInit() const schoolForms = (child.status || '').split(';') let teachers: Teacher[] = [] for (let i = 0; i < schoolForms.length; i += 1) { const url = routes.teachers(child.sdsId, schoolForms[i]) const response = await this.fetch( `teachers_${schoolForms[i]}`, url, session ) const data = await response.json() teachers = [...teachers, ...parse.teachers(data)] } return teachers } public async getSchoolContacts( child: EtjanstChild ): Promise { if (this.isFake) return fakeResponse(fake.schoolContacts(child)) const url = routes.schoolContacts(child.sdsId, child.schoolId || '') const session = this.getRequestInit() const response = await this.fetch('schoolContacts', url, session) const data = await response.json() return parse.schoolContacts(data) } public async getSchedule( child: EtjanstChild, from: DateTime, to: DateTime ): Promise { 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 { 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, child.id) this.CheckResponseForCorrectChildStatus(response, child) const data = await response.json() return parse.news(data) } private CheckResponseForCorrectChildStatus( response: Response, child: EtjanstChild ) { const setCookieResp = response.headers.get('Set-Cookie') if ( child.status !== 'FS' && setCookieResp && setCookieResp.includes('Status=FS') ) { throw new Error('Wrong child in response') } } public async getNewsDetails( child: EtjanstChild, item: NewsItem ): Promise { if (this.isFake) { return fakeResponse( fake.news(child).find((ni) => ni.id === item.id) || { id: '', published: '', } ) } const url = routes.newsDetails(child.id, item.id) const session = this.getRequestInit() const response = await this.fetch(`news_${item.id}`, url, session, child.id) this.CheckResponseForCorrectChildStatus(response, child) const data = await response.json() return parse.newsItemDetails(data) } public async getMenu(child: EtjanstChild): Promise { 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, child.id) this.CheckResponseForCorrectChildStatus(response, child) 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, child.id) this.CheckResponseForCorrectChildStatus(response, child) const data = await response.json() return parse.menuList(data) } private async getMenuChoice(child: EtjanstChild): Promise { const url = routes.menuChoice(child.id) const session = this.getRequestInit() const response = await this.fetch('menu-choice', url, session, child.id) this.CheckResponseForCorrectChildStatus(response, child) const data = await response.json() const etjanstResponse = parse.etjanst(data) return etjanstResponse } public async getNotifications(child: EtjanstChild): Promise { 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 { 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 { 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 { 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 { 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 { 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 { if (this.isFake) return fakeResponse(fake.timetable(child)) if (!child.timetableID) { return new Array() } 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 selectChild(child: EtjanstChild): Promise { const response = await this.selectChildById(child.id) const data = await response.json() return parse.child(parse.etjanst(data)) } private async selectChildById(childId: string) { const requestInit = this.getRequestInit({ method: 'POST', headers: { host: 'etjanst.stockholm.se', accept: 'application/json, text/plain, */*', 'accept-Encoding': 'gzip, deflate', 'content-Type': 'application/json;charset=UTF-8', origin: 'https://etjanst.stockholm.se', referer: 'https://etjanst.stockholm.se/vardnadshavare/inloggad2/hem', }, body: JSON.stringify({ id: childId, }), }) const response = await this.fetch( 'selectChild', routes.selectChild, requestInit ) return response } public async logout() { this.isFake = false this.personalNumber = undefined this.isLoggedIn = false this.authorizedSystems = {} this.emit('logout') await this.clearSession() } }