541 lines
15 KiB
TypeScript
541 lines
15 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
import {
|
|
Api,
|
|
CalendarItem,
|
|
Classmate,
|
|
CookieManager,
|
|
EtjanstChild,
|
|
Fetch,
|
|
Fetcher,
|
|
FetcherOptions,
|
|
FrejaLoginStatusChecker,
|
|
LoginStatusChecker,
|
|
MenuItem,
|
|
NewsItem,
|
|
Notification,
|
|
Response,
|
|
ScheduleItem,
|
|
SchoolContact,
|
|
Skola24Child,
|
|
Teacher,
|
|
TimetableEntry,
|
|
toMarkdown,
|
|
User,
|
|
wrap,
|
|
} from '@skolplattformen/api'
|
|
import { EventEmitter } from 'events'
|
|
import { decode } from 'he'
|
|
import { DateTime, FixedOffsetZone } from 'luxon'
|
|
import * as html from 'node-html-parser'
|
|
import { fakeFetcher } from './fake/fakeFetcher'
|
|
import { checkStatus, DummyStatusChecker } from './loginStatus'
|
|
import {
|
|
extractMvghostRequestBody,
|
|
parseTimetableData,
|
|
parseScheduleEventData,
|
|
parseBreaksData,
|
|
parseNewsData,
|
|
} from './parse/parsers'
|
|
import {
|
|
bankIdInitUrl,
|
|
bankIdCheckUrl,
|
|
bankIdSessionUrl,
|
|
bankIdCallbackUrl,
|
|
redirectLocomotive,
|
|
apiUrls,
|
|
} from './routes'
|
|
import parse from '@skolplattformen/curriculum'
|
|
|
|
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 ApiAdmentum extends EventEmitter implements Api {
|
|
private fetch: Fetcher
|
|
private realFetcher: Fetcher
|
|
|
|
private personalNumber?: string
|
|
private userId: string
|
|
|
|
private cookieManager: CookieManager
|
|
|
|
public isLoggedIn = false
|
|
|
|
private _isFake = 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
|
|
this.userId = ''
|
|
}
|
|
|
|
public replaceFetcher(fetcher: Fetcher) {
|
|
this.fetch = fetcher
|
|
}
|
|
|
|
async getSchedule(
|
|
child: EtjanstChild,
|
|
from: DateTime,
|
|
to: DateTime
|
|
): Promise<(CalendarItem & ScheduleItem)[]> {
|
|
const lessonsResponseJson: any[] = []
|
|
|
|
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
|
|
}
|
|
|
|
public async getSessionHeaders(
|
|
url: string
|
|
): Promise<{ [index: string]: string }> {
|
|
const cookie = await this.cookieManager.getCookieString(url)
|
|
return {
|
|
cookie,
|
|
}
|
|
}
|
|
|
|
async setSessionCookie(sessionCookie: string): Promise<void> {
|
|
if (!sessionCookie) throw Error('cookie required')
|
|
this.cookieManager.setCookieString(
|
|
`sessionid=${sessionCookie}; Path=/;`,
|
|
'skola.admentum.se'
|
|
)
|
|
|
|
//const user = await this.getUser()
|
|
//if (!user.isAuthenticated) {
|
|
// throw new Error('Session cookie is expired')
|
|
// }
|
|
}
|
|
|
|
async getUser(): Promise<User> {
|
|
const user = await this.fetch('fetch-me', apiUrls.me)
|
|
const userJson = await user.json()
|
|
this.userId = userJson.user?.id
|
|
console.log('userId: ', this.userId)
|
|
console.log('fetching user')
|
|
const currentUserResponse = await this.fetch(
|
|
'current-user',
|
|
apiUrls.user(this.userId)
|
|
)
|
|
console.log('current-user', currentUserResponse)
|
|
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...')
|
|
}
|
|
console.log('get children')
|
|
const fetchUrl = apiUrls.user(this.userId)
|
|
const currentUserResponse = await this.fetch('current-user', fetchUrl, {
|
|
method: 'GET',
|
|
headers: {
|
|
Accept: 'application/json, text/plain, */*',
|
|
},
|
|
})
|
|
|
|
if (currentUserResponse.status !== 200) {
|
|
console.error('Error headers', currentUserResponse.headers)
|
|
throw new Error(
|
|
'Could not fetch children. Response code: ' + currentUserResponse.status
|
|
)
|
|
}
|
|
const myChildrenResponseJson = await currentUserResponse.json()
|
|
return myChildrenResponseJson.students.map(
|
|
(student: { id: any; first_name: any; last_name: any }) =>
|
|
({
|
|
id: student.id,
|
|
sdsId: student.id,
|
|
personGuid: student.id,
|
|
firstName: student.first_name,
|
|
lastName: student.last_name,
|
|
name: `${student.first_name} ${student.last_name}`,
|
|
} as Skola24Child & EtjanstChild)
|
|
)
|
|
}
|
|
|
|
async getCalendar(child: EtjanstChild): Promise<CalendarItem[]> {
|
|
try {
|
|
if (!this.isLoggedIn) {
|
|
throw new Error('Not logged in...')
|
|
}
|
|
const now = DateTime.local()
|
|
const [year, week] = now.toISOWeekDate().split('-')
|
|
const isoWeek = week.replace('W', '')
|
|
|
|
const fetchUrl = apiUrls.overview(
|
|
'get-week-data',
|
|
year.toString(),
|
|
isoWeek.toString()
|
|
)
|
|
console.log('fetching calendar', fetchUrl)
|
|
const overviewResponse = await this.fetch('get-week-data', fetchUrl, {
|
|
headers: {
|
|
'x-requested-with': 'XMLHttpRequest',
|
|
},
|
|
})
|
|
const calendarItems: CalendarItem[] = []
|
|
|
|
const overviewJson = await overviewResponse.json()
|
|
|
|
const scheduleEventJson = (await overviewJson)?.data?.schedule_events // .breaks: [] | .assignments: []
|
|
const schedule_events = parseScheduleEventData(scheduleEventJson)
|
|
calendarItems.push(...schedule_events)
|
|
|
|
const breaks = (await overviewJson)?.data?.breaks
|
|
const break_events = parseBreaksData(breaks);
|
|
calendarItems.push(...break_events)
|
|
|
|
return calendarItems
|
|
} catch (e) {
|
|
console.error('Error fetching overview', e)
|
|
return Promise.resolve([])
|
|
}
|
|
}
|
|
|
|
// 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...
|
|
/*
|
|
GET /api/v1/schedule_groups/423145/
|
|
{
|
|
"url": "https://skola.admentum.se/api/v1/schedule_groups/423145/",
|
|
"id": 423145,
|
|
"eid": null,
|
|
"schedule": {
|
|
"url": "https://skola.admentum.se/api/v1/schedules/4385/",
|
|
"id": 4385,
|
|
"school_year": "23/24"
|
|
},
|
|
"name": "1 A SV",
|
|
"guid": null,
|
|
"users": [
|
|
{
|
|
"url": "https://skola.admentum.se/api/v1/users/436741/",
|
|
"id": 436741,
|
|
"email": null,
|
|
"first_name": "Arvid",
|
|
"last_name": "Forslin",
|
|
"role": 1
|
|
},
|
|
{
|
|
"url": "https://skola.admentum.se/api/v1/users/436747/",
|
|
"id": 436747,
|
|
"email": null,
|
|
"first_name": "Emmy",
|
|
"last_name": "Granström",
|
|
"role": 1
|
|
}
|
|
...
|
|
*/
|
|
if (!this.isLoggedIn) {
|
|
throw new Error('Not logged in...')
|
|
}
|
|
return Promise.resolve([])
|
|
}
|
|
|
|
public async getTeachers(child: EtjanstChild): Promise<Teacher[]> {
|
|
if (!this.isLoggedIn) {
|
|
throw new Error('Not logged in...')
|
|
}
|
|
return Promise.resolve([])
|
|
}
|
|
|
|
public async getSchoolContacts(
|
|
child: EtjanstChild
|
|
): Promise<SchoolContact[]> {
|
|
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 token = await this.fetch('get-token', apiUrls.messages)
|
|
.then((res) => res.text())
|
|
.then((html) => /token:\s+'(.*)'/.exec(html)?.pop()) // HACK: this could probably be found at a better place than the html code..
|
|
|
|
console.log('token', token)
|
|
|
|
const fetchUrl = apiUrls.conversations(this.userId, '1')
|
|
console.log('fetching messages', fetchUrl)
|
|
const messagesResponse = await this.fetch('get-messages', fetchUrl, {
|
|
headers: {
|
|
'x-requested-with': 'XMLHttpRequest',
|
|
'user-info': token,
|
|
},
|
|
})
|
|
const messagesResponseJson = await messagesResponse.json()
|
|
console.log('messages response', messagesResponseJson)
|
|
|
|
const newsItems = parseNewsData(messagesResponseJson)
|
|
console.log('newsItems', newsItems)
|
|
return newsItems
|
|
}
|
|
|
|
async getNewsDetails(_child: EtjanstChild, item: NewsItem): Promise<any> {
|
|
return { ...item }
|
|
}
|
|
/*
|
|
"data": {
|
|
"food_week": {
|
|
"id": 12846,
|
|
"week": 38,
|
|
"year": 2023,
|
|
"food_days": [
|
|
{
|
|
"id": 60620,
|
|
"date": "2023-09-18",
|
|
"menu": "Förrätt: Morotssoppa med knäckebröd\r\nHuvudrätt: Kycklinggryta med ris och grönsaker\r\nEfterrätt: Fruktkompott",
|
|
"weekday": "Måndag",
|
|
"weekday_nbr": 0
|
|
},
|
|
{
|
|
"id": 60621,
|
|
"date": "2023-09-19",
|
|
"menu": "Förrätt: Gurksallad\
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
async getMenu(_child: EtjanstChild): Promise<MenuItem[]> {
|
|
try {
|
|
if (!this.isLoggedIn) {
|
|
throw new Error('Not logged in...')
|
|
}
|
|
const now = DateTime.local()
|
|
const [year, week] = now.toISOWeekDate().split('-')
|
|
const isoWeek = week.replace('W', '')
|
|
|
|
const fetchUrl = apiUrls.menu(year.toString(), isoWeek.toString())
|
|
console.log('fetching menu', fetchUrl)
|
|
const menuResponse = await this.fetch('get-menu', fetchUrl)
|
|
const menuResponseJson = await menuResponse.json()
|
|
console.log('menu response', menuResponseJson)
|
|
const days = (await menuResponseJson)?.data?.food_week?.food_days
|
|
if (!days) {
|
|
return Promise.resolve([])
|
|
}
|
|
return Promise.resolve(
|
|
days.map(({ menu, date }: any) => ({
|
|
title: date,
|
|
description: menu,
|
|
}))
|
|
)
|
|
} catch (e) {
|
|
console.error('Error fetching menu', e)
|
|
return Promise.resolve([])
|
|
}
|
|
}
|
|
|
|
async getChildEventsWithAssociatedMembers(child: EtjanstChild) {
|
|
return this.getEventsWithAssociatedMembersForChildren([child])
|
|
}
|
|
|
|
async getEventsWithAssociatedMembersForChildren(children: EtjanstChild[]) {
|
|
return Promise.resolve([])
|
|
}
|
|
|
|
async getNotifications(child: EtjanstChild): Promise<Notification[]> {
|
|
return Promise.resolve([])
|
|
}
|
|
|
|
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 fetchUrl = apiUrls.schedule(year.toString(), week.toString())
|
|
console.log('fetching timetable', fetchUrl)
|
|
const calendarResponse = await this.fetch('get-calendar', fetchUrl)
|
|
const calendarResponseJson = await calendarResponse.json()
|
|
const timetableEntries = parseTimetableData(calendarResponseJson)
|
|
return timetableEntries
|
|
}
|
|
|
|
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()
|
|
|
|
console.log('login adentum', personalNumber)
|
|
this.isFake = false
|
|
|
|
const authenticatedUser = await this.getUser()
|
|
if (authenticatedUser && authenticatedUser.isAuthenticated) {
|
|
console.log('already logged in to admentum')
|
|
this.isLoggedIn = true
|
|
this.personalNumber = personalNumber
|
|
this.emit('login')
|
|
return new DummyStatusChecker()
|
|
}
|
|
|
|
const url = await this.fetch('get-session', bankIdSessionUrl('')).then(
|
|
(res) => {
|
|
console.log('got res', res, (res as any).url, res.headers)
|
|
return (res as any).url
|
|
}
|
|
)
|
|
// https://login.grandid.com/?sessionid=234324
|
|
// => 234324
|
|
console.log('url', url)
|
|
|
|
// Logged in: https://skola.admentum.se/overview
|
|
if (url.includes('overview')) {
|
|
console.log('already logged in to admentum')
|
|
this.isLoggedIn = true
|
|
this.personalNumber = personalNumber
|
|
this.emit('login')
|
|
return new DummyStatusChecker()
|
|
}
|
|
|
|
const sessionId = url.split('=').pop()
|
|
console.log('sessionId', sessionId)
|
|
console.log('adentum session id', sessionId)
|
|
if (!sessionId) throw new Error('No session provided')
|
|
|
|
console.log('url', bankIdInitUrl(sessionId))
|
|
await this.fetch('bankid-init', bankIdInitUrl(sessionId), {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
body: 'ssn=' + personalNumber,
|
|
})
|
|
|
|
console.log('start polling', sessionId)
|
|
const statusChecker = checkStatus(this.fetch, bankIdCheckUrl(sessionId))
|
|
|
|
statusChecker.on('OK', async () => {
|
|
this.isLoggedIn = true
|
|
this.personalNumber = personalNumber
|
|
|
|
const locomotiveUrl = redirectLocomotive(sessionId)
|
|
console.log('calling locomotive url: ', locomotiveUrl)
|
|
const callbackResponse = await this.followRedirects(locomotiveUrl)
|
|
console.log('final response:', callbackResponse)
|
|
this.emit('login')
|
|
})
|
|
statusChecker.on('ERROR', () => {
|
|
this.personalNumber = undefined
|
|
})
|
|
|
|
return statusChecker
|
|
}
|
|
|
|
async followRedirects(initialUrl: string): Promise<Response> {
|
|
let currentUrl = initialUrl
|
|
let redirectCount = 0
|
|
const maxRedirects = 10
|
|
|
|
while (redirectCount < maxRedirects) {
|
|
console.log(
|
|
'fetching (redirect number ' + redirectCount + ')',
|
|
currentUrl
|
|
)
|
|
const response = await this.fetch('follow-redirect', currentUrl, {
|
|
method: 'GET',
|
|
redirect: 'manual', // Disable automatic redirects
|
|
})
|
|
console.log('follow-redirect response', response)
|
|
if (response.status >= 300 && response.status < 400) {
|
|
console.log('response status:', response.status)
|
|
const newLocation = response.headers.get('location')
|
|
if (!newLocation) {
|
|
throw new Error('Redirect response missing location header')
|
|
}
|
|
currentUrl = newLocation
|
|
redirectCount++
|
|
} else {
|
|
console.log('response status, not reidrect:', response.status)
|
|
// The response is not a redirect, return it
|
|
return response
|
|
}
|
|
}
|
|
throw new Error('Max redirects reached')
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
async loginFreja(): Promise<FrejaLoginStatusChecker> {
|
|
throw new Error('Not implemented...')
|
|
}
|
|
}
|