import { Api , CalendarItem , Classmate , CookieManager , EtjanstChild , Fetch , Fetcher , FetcherOptions , LoginStatusChecker , MenuItem , NewsItem , Notification , ScheduleItem , SchoolContact , Skola24Child , Teacher , TimetableEntry , toMarkdown , URLSearchParams , 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 , parseCalendarItem } from './parse/parsers' ; import { beginBankIdUrl , beginLoginUrl , calendarEventUrl , calendarsUrl , currentUserUrl , fullImageUrl , hjarntorgetEventsUrl , hjarntorgetUrl , infoSetReadUrl , infoUrl , initBankIdUrl , lessonsUrl , membersWithRoleUrl , mvghostUrl , myChildrenUrl , rolesInEventUrl , shibbolethLoginUrl , shibbolethLoginUrlBase , verifyUrlBase , wallMessagesUrl , } from './routes' ; import parse from '@skolplattformen/curriculum' ; function getDateOfISOWeek ( week , year ) { 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 = false ; private _isFake = false ; public set isFake ( fake ) { this . _isFake = fake ; if ( this . _isFake ) { this . fetch = fakeFetcher ; } else { this . fetch = this . realFetcher ; } } public get isFake ( ) { return this . _isFake ; } constructor ( fetch , cookieManager , options ? ) { super ( ) ; this . fetch = wrap ( fetch , options ) ; this . realFetcher = this . fetch ; this . cookieManager = cookieManager ; } public replaceFetcher ( fetcher ) { this . fetch = fetcher ; } async getSchedule ( child , from , to ) { const lessonParams = { forUser : child . id , startDateIso : from . toISODate ( ) , endDateIso : to . toISODate ( ) , } ; const lessonsResponse = await this . fetch ( `lessons-${ lessonParams . forUser }` , lessonsUrl ( lessonParams ) ) ; const lessonsResponseJson = 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 ( ) { return this . personalNumber ; } public async getSessionHeaders ( url ) { const cookie = await this . cookieManager . getCookieString ( url ) ; return { cookie , } ; } async setSessionCookie ( sessionCookie ) { this . cookieManager . setCookieString ( sessionCookie , hjarntorgetUrl ) ; const user = await this . getUser ( ) ; if ( ! user . isAuthenticated ) { throw new Error ( 'Session cookie is expired' ) ; } this . isLoggedIn = true ; this . emit ( 'login' ) ; } async getUser ( ) { console . log ( 'fetching 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 ( ) { if ( ! this . isLoggedIn ) { throw new Error ( 'Not logged in...' ) ; } console . log ( 'fetching children' ) ; const myChildrenResponse = await this . fetch ( 'my-children' , myChildrenUrl ) ; const myChildrenResponseJson = 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 ) { const childEventsAndMembers = await this . getChildEventsWithAssociatedMembers ( child ) ; 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 = [ ] ; 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 = calendarRows . map ( parseCalendarItem ) ; calendarItems = calendarItems . concat ( items ) ; } } return calendarItems ; } getClassmates ( _child ) { if ( ! this . isLoggedIn ) { throw new Error ( 'Not logged in...' ) ; } return Promise . resolve ( [ ] ) ; } public async getTeachers ( child ) { if ( ! this . isLoggedIn ) { throw new Error ( 'Not logged in...' ) ; } return Promise . resolve ( [ ] ) ; } public async getSchoolContacts ( child ) { if ( ! this . isLoggedIn ) { throw new Error ( 'Not logged in...' ) ; } return Promise . resolve ( [ ] ) ; } async getNews ( _child ) { if ( ! this . isLoggedIn ) { throw new Error ( 'Not logged in...' ) ; } const children = await this . getChildren ( ) ; const eventsAndMembersForChildren = await this . getEventsWithAssociatedMembersForChildren ( children ) ; const membersInChildensEvents = eventsAndMembersForChildren . reduce ( ( acc , e ) => acc . concat ( e . eventMembers ) , [ ] as any [ ] ) ; const wallMessagesResponse = await this . fetch ( 'wall-events' , wallMessagesUrl ) ; const wallMessagesResponseJson = await wallMessagesResponse . json ( ) ; const nonChildSpecificMessages = wallMessagesResponseJson . filter ( ( message ) => message . creator . id !== '__system$virtual$calendar__' && ! membersInChildensEvents . some ( ( member ) => member . id === message . creator . id ) ) . map ( message => { const createdDate = new Date ( message . created . ts ) ; const body = message . body as string ; const trimmedBody = body . trim ( ) ; const firstNewline = trimmedBody . indexOf ( '\n' ) ; const title = trimmedBody . substring ( 0 , firstNewline ) . trim ( ) || message . title ; const intro = trimmedBody . substring ( firstNewline ) . trim ( ) ; return { id : message . id , author : message . creator && `${ message . creator . firstName } ${ message . creator . lastName }` , header : title , intro : intro , body : body , published : createdDate . toISOString ( ) , modified : createdDate . toISOString ( ) , fullImageUrl : message . creator && fullImageUrl ( message . creator . imagePath ) , timestamp : message . created . ts , } ; } ) ; const infoResponse = await this . fetch ( 'info' , infoUrl ) ; const infoResponseJson = await infoResponse . json ( ) ; const officialInfoMessages = 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 ) , timestamp : i . created . ts , } ; } ) ; const newsMessages = officialInfoMessages . concat ( nonChildSpecificMessages ) ; newsMessages . sort ( ( a , b ) => b . timestamp - a . timestamp ) ; return newsMessages ; } async getNewsDetails ( _child , item ) { return { ... item } ; } getMenu ( _child ) { if ( ! this . isLoggedIn ) { throw new Error ( 'Not logged in...' ) ; } return Promise . resolve ( [ ] ) ; } async getChildEventsWithAssociatedMembers ( child ) { return this . getEventsWithAssociatedMembersForChildren ( [ child ] ) ; } async getEventsWithAssociatedMembersForChildren ( children ) { const hjarntorgetEventsResponse = await this . fetch ( 'events' , hjarntorgetEventsUrl ) ; const hjarntorgetEventsResponseJson = 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 = 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 = 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 ) => children . some ( c => c . id === p . id ) ) ) ; } async getNotifications ( child ) { 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 = 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 ( ) { if ( ! this . isLoggedIn ) { throw new Error ( 'Not logged in...' ) ; } return [ ] ; } async getTimetable ( child , week , year , _lang ) { const startDate = DateTime . fromJSDate ( getDateOfISOWeek ( week , year ) ) ; const endDate = startDate . plus ( { days : 7 } ) ; const lessonParams = { forUser : child . personGuid ! , startDateIso : startDate . toISODate ( ) , endDateIso : endDate . toISODate ( ) , } ; const lessonsResponse = await this . fetch ( `lessons-${ lessonParams . forUser }` , lessonsUrl ( lessonParams ) ) ; const lessonsResponseJson = 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 { ... parse ( l . title , _lang ) , 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 : end . toISODate ( ) , } as TimetableEntry ; } ) ; } async logout ( ) { this . isLoggedIn = false ; this . personalNumber = undefined ; this . cookieManager . clearAll ( ) ; this . emit ( 'logout' ) ; } public async login ( personalNumber ? ) { 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' , } ) ; if ( ( beginLoginRedirectResponse as any ) . url . endsWith ( "startPage.do" ) ) { const emitter = new DummyStatusChecker ( ) ; setTimeout ( ( ) => { this . isLoggedIn = true ; emitter . emit ( 'OK' ) ; this . emit ( 'login' ) ; } , 50 ) ; return emitter as LoginStatusChecker ; } console . log ( 'prepping??? shibboleth' ) ; const shibbolethLoginResponse = await this . fetch ( 'init-shibboleth-login' , shibbolethLoginUrl ( shibbolethLoginUrlBase ( ( beginLoginRedirectResponse as any ) . url ) ) , { 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' ) ; 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 ( ) => { this . isLoggedIn = true ; this . personalNumber = personalNumber ; this . emit ( 'login' ) ; } ) ; statusChecker . on ( 'ERROR' , ( ) => { this . personalNumber = undefined ; } ) ; return statusChecker ; } private async fakeMode ( ) { this . isFake = true ; setTimeout ( ( ) => { this . isLoggedIn = true ; this . emit ( 'login' ) ; } , 50 ) ; const emitter = new EventEmitter ( ) ; emitter . token = 'fake' ; return emitter ; } }