460 lines
15 KiB
TypeScript
460 lines
15 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
import { DateTime, FixedOffsetZone } from 'luxon'
|
|
import { EventEmitter } from 'events'
|
|
import * as html from 'node-html-parser'
|
|
import { decode } from 'he'
|
|
import { toMarkdown, Api, URLSearchParams, LoginStatusChecker, CalendarItem,
|
|
Classmate,
|
|
CookieManager,
|
|
EtjanstChild,
|
|
Fetch,
|
|
MenuItem,
|
|
NewsItem,
|
|
Notification,
|
|
ScheduleItem,
|
|
Skola24Child,
|
|
TimetableEntry,
|
|
User,
|
|
Fetcher,
|
|
FetcherOptions,
|
|
wrap
|
|
} from '@skolplattformen/api'
|
|
import { checkStatus } from './loginStatus'
|
|
import { extractMvghostRequestBody, parseCalendarItem } from './parse/parsers'
|
|
import {
|
|
beginLoginUrl,
|
|
beginBankIdUrl,
|
|
currentUserUrl,
|
|
initBankIdUrl,
|
|
fullImageUrl,
|
|
hjarntorgetEventsUrl,
|
|
hjarntorgetUrl,
|
|
infoSetReadUrl,
|
|
infoUrl,
|
|
lessonsUrl,
|
|
membersWithRoleUrl,
|
|
mvghostUrl,
|
|
myChildrenUrl,
|
|
shibbolethLoginUrlBase,
|
|
rolesInEventUrl,
|
|
shibbolethLoginUrl,
|
|
verifyUrlBase,
|
|
wallMessagesUrl,
|
|
calendarsUrl,
|
|
calendarEventUrl
|
|
} from './routes'
|
|
import { fakeFetcher } from './fake/fakeFetcher'
|
|
|
|
|
|
function getDateOfISOWeek(week: number, year: number,) {
|
|
const simple = new Date(year, 0, 1 + (week - 1) * 7)
|
|
const dow = simple.getDay()
|
|
const ISOweekStart = simple
|
|
if (dow <= 4)
|
|
ISOweekStart.setDate(simple.getDate() - simple.getDay() + 1)
|
|
else
|
|
ISOweekStart.setDate(simple.getDate() + 8 - simple.getDay())
|
|
return ISOweekStart
|
|
}
|
|
|
|
|
|
export class ApiHjarntorget extends EventEmitter implements Api {
|
|
private fetch: Fetcher
|
|
private realFetcher: Fetcher
|
|
|
|
private personalNumber?: string
|
|
|
|
private cookieManager: CookieManager
|
|
|
|
public isLoggedIn: boolean = false
|
|
|
|
private _isFake: boolean = false;
|
|
|
|
public set isFake(fake: boolean) {
|
|
this._isFake = fake
|
|
if(this._isFake) {
|
|
this.fetch = fakeFetcher
|
|
} else {
|
|
this.fetch = this.realFetcher
|
|
}
|
|
}
|
|
|
|
public get isFake() {
|
|
return this._isFake
|
|
}
|
|
|
|
constructor(
|
|
fetch: Fetch,
|
|
cookieManager: CookieManager,
|
|
options?: FetcherOptions
|
|
) {
|
|
super()
|
|
this.fetch = wrap(fetch, options);
|
|
this.realFetcher = this.fetch;
|
|
this.cookieManager = cookieManager
|
|
}
|
|
|
|
public replaceFetcher(fetcher: Fetcher) {
|
|
this.fetch = fetcher;
|
|
}
|
|
|
|
async getSchedule(child: EtjanstChild, from: DateTime, to: DateTime): Promise<(CalendarItem & ScheduleItem)[]> {
|
|
|
|
const lessonParams = {
|
|
forUser: child.id,
|
|
startDateIso: from.toISODate(),
|
|
endDateIso: to.toISODate(),
|
|
}
|
|
const lessonsResponse = await this.fetch(`lessons-${lessonParams.forUser}`, lessonsUrl(lessonParams))
|
|
const lessonsResponseJson: any[] = await lessonsResponse.json()
|
|
|
|
return lessonsResponseJson.map(l => {
|
|
const start = DateTime.fromMillis(l.startDate.ts, {
|
|
zone: FixedOffsetZone.instance(l.startDate.timezoneOffsetMinutes)
|
|
})
|
|
const end = DateTime.fromMillis(l.endDate.ts, {
|
|
zone: FixedOffsetZone.instance(l.endDate.timezoneOffsetMinutes)
|
|
})
|
|
return {
|
|
id: l.id,
|
|
title: l.title,
|
|
description: l.note,
|
|
location: l.location,
|
|
startDate: start.toISO(),
|
|
endDate: end.toISO(),
|
|
oneDayEvent: false,
|
|
allDayEvent: false,
|
|
}
|
|
})
|
|
}
|
|
|
|
getPersonalNumber(): string | undefined {
|
|
return this.personalNumber
|
|
}
|
|
|
|
async setSessionCookie(sessionCookie: string): Promise<void> {
|
|
|
|
await this.fetch('login-cookie', hjarntorgetUrl, {
|
|
headers: {
|
|
cookie: sessionCookie,
|
|
},
|
|
redirect: 'manual',
|
|
})
|
|
|
|
const user = await this.getUser()
|
|
if (!user.isAuthenticated) {
|
|
throw new Error('Session cookie is expired')
|
|
}
|
|
|
|
this.isLoggedIn = true
|
|
this.emit('login')
|
|
}
|
|
|
|
async getUser(): Promise<User> {
|
|
|
|
const currentUserResponse = await this.fetch('current-user', currentUserUrl)
|
|
if (currentUserResponse.status !== 200) {
|
|
return { isAuthenticated: false }
|
|
}
|
|
|
|
const retrivedUser = await currentUserResponse.json()
|
|
return { ...retrivedUser, isAuthenticated: true }
|
|
}
|
|
|
|
async getChildren(): Promise<(Skola24Child & EtjanstChild)[]> {
|
|
if (!this.isLoggedIn) {
|
|
throw new Error('Not logged in...')
|
|
}
|
|
|
|
|
|
const myChildrenResponse = await this.fetch('my-children', myChildrenUrl)
|
|
const myChildrenResponseJson: any[] = await myChildrenResponse.json()
|
|
|
|
return myChildrenResponseJson.map(c => ({
|
|
id: c.id,
|
|
sdsId: c.id,
|
|
personGuid: c.id,
|
|
firstName: c.firstName,
|
|
lastName: c.lastName,
|
|
name: `${c.firstName} ${c.lastName}`,
|
|
} as (Skola24Child & EtjanstChild)))
|
|
}
|
|
|
|
async getCalendar(child: EtjanstChild): Promise<CalendarItem[]> {
|
|
const childEventsAndMembers = await this.getChildEventsWithAssociatedMembers(child)
|
|
|
|
// This fetches the calendars search page on Hjärntorget.
|
|
// It is used (at least at one school) for homework schedule
|
|
// The Id for the "event" that the calendar belongs to is not the same as the ones
|
|
// fetched using the API... So we match them by name :/
|
|
const calendarsResponse = await this.fetch('calendars', calendarsUrl)
|
|
const calendarsResponseText = await calendarsResponse.text()
|
|
const calendarsDoc = html.parse(decode(calendarsResponseText))
|
|
const calendarCheckboxes = Array.from(calendarsDoc.querySelectorAll('.calendarPageContainer input.checkbox'))
|
|
|
|
let calendarItems: CalendarItem[] = []
|
|
for (let i = 0; i < calendarCheckboxes.length; i++) {
|
|
const calendarId = calendarCheckboxes[i].getAttribute('value') || ""
|
|
|
|
const today = DateTime.fromJSDate(new Date())
|
|
const start = today.toISODate()
|
|
const end = today.plus({ days: 30 }).toISODate()
|
|
const calendarResponse = await this.fetch(`calendar-${calendarId}`, calendarEventUrl(calendarId, start, end))
|
|
const calendarResponseText = await calendarResponse.text()
|
|
const calendarDoc = html.parse(decode(calendarResponseText))
|
|
|
|
const calendarRows = Array.from(calendarDoc.querySelectorAll('.default-table tr'))
|
|
if (!calendarRows.length) {
|
|
continue
|
|
}
|
|
|
|
calendarRows.shift()
|
|
const eventName = calendarRows.shift()?.textContent
|
|
if (childEventsAndMembers.some(e => e.name === eventName)) {
|
|
|
|
const items: CalendarItem[] = calendarRows.map(parseCalendarItem)
|
|
|
|
calendarItems = calendarItems.concat(items)
|
|
}
|
|
}
|
|
|
|
return calendarItems;
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
getClassmates(_child: EtjanstChild): Promise<Classmate[]> {
|
|
// TODO: We could get this from the events a child is associated with...
|
|
if (!this.isLoggedIn) {
|
|
throw new Error('Not logged in...')
|
|
}
|
|
return Promise.resolve([])
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
async getNews(_child: EtjanstChild): Promise<NewsItem[]> {
|
|
if (!this.isLoggedIn) {
|
|
throw new Error('Not logged in...')
|
|
}
|
|
|
|
const infoResponse = await this.fetch('info', infoUrl)
|
|
const infoResponseJson: any[] = await infoResponse.json()
|
|
// TODO: Filter out read messages?
|
|
return infoResponseJson.map(i => {
|
|
const body = html.parse(decode(i.body || ""))
|
|
const bodyText = toMarkdown(i.body)
|
|
|
|
const introText = body.innerText || ""
|
|
const publishedDate = new Date(i.created.ts)
|
|
|
|
return {
|
|
id: i.id,
|
|
author: i.creator && `${i.creator.firstName} ${i.creator.lastName}`,
|
|
header: i.title,
|
|
intro: introText,
|
|
body: bodyText,
|
|
published: publishedDate.toISOString(),
|
|
modified: publishedDate.toISOString(),
|
|
fullImageUrl: i.creator && fullImageUrl(i.creator.imagePath)
|
|
}
|
|
})
|
|
}
|
|
|
|
async getNewsDetails(_child: EtjanstChild, item: NewsItem): Promise<any> {
|
|
|
|
await this.fetch('infoSetReadUrl', infoSetReadUrl(item), {
|
|
method: 'POST',
|
|
})
|
|
|
|
return { ...item }
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
getMenu(_child: EtjanstChild): Promise<MenuItem[]> {
|
|
if (!this.isLoggedIn) {
|
|
throw new Error('Not logged in...')
|
|
}
|
|
// Have not found this available on hjärntorget. Perhaps do a mapping to https://www.skolmaten.se/ ?
|
|
return Promise.resolve([])
|
|
}
|
|
|
|
async getChildEventsWithAssociatedMembers(child: EtjanstChild) {
|
|
const hjarntorgetEventsResponse = await this.fetch('events', hjarntorgetEventsUrl)
|
|
const hjarntorgetEventsResponseJson: any[] = await hjarntorgetEventsResponse.json()
|
|
const membersInEvents = await Promise.all(hjarntorgetEventsResponseJson.filter(e => e.state === 'ONGOING')
|
|
.map(async e => {
|
|
const eventId = e.id as number
|
|
|
|
const rolesInEvenResponse = await this.fetch(`roles-in-event-${eventId}`, rolesInEventUrl(eventId))
|
|
const rolesInEvenResponseJson: any[] = await rolesInEvenResponse.json()
|
|
|
|
const eventMembers = await Promise.all(rolesInEvenResponseJson.map(async r => {
|
|
const roleId = r.id
|
|
const membersWithRoleResponse = await this.fetch(`event-role-members-${eventId}-${roleId}`, membersWithRoleUrl(eventId, roleId))
|
|
const membersWithRoleResponseJson: any[] = await membersWithRoleResponse.json()
|
|
return membersWithRoleResponseJson
|
|
}))
|
|
return { eventId, name: e.name as string, eventMembers: ([] as any[]).concat(...eventMembers) }
|
|
}))
|
|
return membersInEvents
|
|
.filter(e => e.eventMembers.find(p => p.id === child.id))
|
|
}
|
|
|
|
async getNotifications(child: EtjanstChild): Promise<Notification[]> {
|
|
|
|
const childEventsAndMembers = await this.getChildEventsWithAssociatedMembers(child)
|
|
const membersInChildsEvents = childEventsAndMembers.reduce((acc, e) => acc.concat(e.eventMembers), ([] as any[]))
|
|
|
|
const wallMessagesResponse = await this.fetch('wall-events', wallMessagesUrl)
|
|
const wallMessagesResponseJson: any[] = await wallMessagesResponse.json()
|
|
return wallMessagesResponseJson.filter(message =>
|
|
membersInChildsEvents.find(member => member.id === message.creator.id))
|
|
.map(message => {
|
|
const createdDate = new Date(message.created.ts)
|
|
return {
|
|
id: message.id,
|
|
sender: message.creator && `${message.creator.firstName} ${message.creator.lastName}`,
|
|
dateCreated: createdDate.toISOString(),
|
|
message: message.body,
|
|
url: message.url,
|
|
category: message.title,
|
|
type: message.type,
|
|
dateModified: createdDate.toISOString(),
|
|
}
|
|
})
|
|
}
|
|
|
|
async getSkola24Children(): Promise<(Skola24Child)[]> {
|
|
if (!this.isLoggedIn) {
|
|
throw new Error('Not logged in...')
|
|
}
|
|
return []
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
async getTimetable(child: Skola24Child, week: number, year: number, _lang: string): Promise<TimetableEntry[]> {
|
|
|
|
const startDate = DateTime.fromJSDate(getDateOfISOWeek(week, year))
|
|
const endDate = startDate.plus({ days: 7 })
|
|
|
|
const lessonParams = {
|
|
forUser: child.personGuid!, // This is a bit of a hack due to how we map things...
|
|
startDateIso: startDate.toISODate(),
|
|
endDateIso: endDate.toISODate(),
|
|
}
|
|
const lessonsResponse = await this.fetch(`lessons-${lessonParams.forUser}`, lessonsUrl(lessonParams))
|
|
const lessonsResponseJson: any[] = await lessonsResponse.json()
|
|
|
|
return lessonsResponseJson.map(l => {
|
|
const start = DateTime.fromMillis(l.startDate.ts, {
|
|
zone: FixedOffsetZone.instance(l.startDate.timezoneOffsetMinutes)
|
|
})
|
|
const end = DateTime.fromMillis(l.endDate.ts, {
|
|
zone: FixedOffsetZone.instance(l.endDate.timezoneOffsetMinutes)
|
|
})
|
|
return {
|
|
id: l.id,
|
|
teacher: l.bookedTeacherNames && l.bookedTeacherNames[0],
|
|
location: l.location,
|
|
timeStart: start.toISOTime().substring(0, 5),
|
|
timeEnd: end.toISOTime().substring(0, 5),
|
|
dayOfWeek: start.toJSDate().getDay(),
|
|
blockName: l.title,
|
|
dateStart: start.toISODate(),
|
|
dateEnd: start.toISODate(),
|
|
} as TimetableEntry
|
|
})
|
|
|
|
}
|
|
|
|
async logout(): Promise<void> {
|
|
this.isLoggedIn = false
|
|
this.personalNumber = undefined
|
|
this.cookieManager.clearAll()
|
|
this.emit('logout')
|
|
}
|
|
|
|
public async login(personalNumber?: string): Promise<LoginStatusChecker> {
|
|
// short circut the bank-id login if in fake mode
|
|
if (personalNumber !== undefined && personalNumber.endsWith('1212121212')) return this.fakeMode()
|
|
|
|
this.isFake = false
|
|
|
|
console.log("initiating login to hjarntorget")
|
|
const beginLoginRedirectResponse = await this.fetch('begin-login', beginLoginUrl, {
|
|
redirect: 'follow'
|
|
})
|
|
|
|
const shibbolethLoginParam = {
|
|
entityID: 'https://auth.goteborg.se/FIM/sps/HjarntorgetEID/saml20'
|
|
}
|
|
|
|
console.log("prepping??? shibboleth")
|
|
const shibbolethLoginResponse = await this.fetch('init-shibboleth-login', shibbolethLoginUrl(shibbolethLoginUrlBase((beginLoginRedirectResponse as any).url), shibbolethLoginParam), {
|
|
redirect: 'follow'
|
|
})
|
|
|
|
const shibbolethRedirectUrl = (shibbolethLoginResponse as any).url
|
|
console.log("initiating bankid...")
|
|
const initBankIdResponse = await this.fetch('init-bankId', initBankIdUrl(shibbolethRedirectUrl), {
|
|
redirect: 'follow'
|
|
})
|
|
|
|
const initBankIdResponseText = await initBankIdResponse.text()
|
|
const mvghostRequestBody = extractMvghostRequestBody(initBankIdResponseText)
|
|
|
|
console.log("picking auth server???")
|
|
const mvghostResponse = await this.fetch('pick-mvghost', mvghostUrl, {
|
|
redirect: 'follow',
|
|
method: 'POST',
|
|
body: mvghostRequestBody,
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded'
|
|
}
|
|
})
|
|
|
|
console.log("start bankid sign in")
|
|
// We may get redirected to some other subdomain i.e. not 'm00-mg-local':
|
|
// https://mNN-mg-local.idp.funktionstjanster.se/mg-local/auth/ccp11/grp/other
|
|
|
|
|
|
const ssnBody = new URLSearchParams({ ssn: personalNumber }).toString()
|
|
const beginBankIdResponse = await this.fetch('start-bankId', beginBankIdUrl((mvghostResponse as any).url), {
|
|
redirect: 'follow',
|
|
method: 'POST',
|
|
body: ssnBody,
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded'
|
|
}
|
|
})
|
|
|
|
console.log("start polling")
|
|
|
|
const statusChecker = checkStatus(this.fetch, verifyUrlBase((beginBankIdResponse as any).url))
|
|
|
|
statusChecker.on('OK', async () => {
|
|
// setting these similar to how the sthlm api does it
|
|
// not sure if it is needed or if the cookies are enough for fetching all info...
|
|
this.isLoggedIn = true
|
|
this.personalNumber = personalNumber
|
|
this.emit('login')
|
|
})
|
|
statusChecker.on('ERROR', () => {
|
|
this.personalNumber = undefined
|
|
})
|
|
|
|
return statusChecker
|
|
}
|
|
|
|
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
|
|
}
|
|
} |