Merge pull request #527 from kolplattformen/feature/hjarntorget

Feature/hjarntorget
This commit is contained in:
Viktor Sarström 2021-11-30 14:30:41 +01:00 committed by GitHub
commit 22d099b5d3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
122 changed files with 15774 additions and 285 deletions

View File

@ -26,7 +26,9 @@ The respective README files there contain more detailed descriptions.
* [skolplattformen-sthlm](#skolplattformen-sthlm)
* [website](#website)
* [Libs](#embedded-api)
* [api](#api)
* [api-skolplattformen](#api-skolplattformen)
* [api-hjarntorget](#api-hjarntorget)
* [curriculum](#curriculum)
* [hooks](#hooks)
* [Development](#development)
@ -48,6 +50,7 @@ The project consists of several apps and libraries inside [a NX](https://nx.dev/
The central part of the project is the app itself. It is written in [TypeScript](https://www.typescriptlang.org/) using [React Native](https://reactnative.dev/) and [React Native Kitten](https://akveo.github.io/react-native-ui-kitten/).
Our main goal with the app is to make it as fast and easy to use as possible. \
We're starting small, with more features being added over time.
For more information, check out the [source code](apps/skolplattformen-sthlm).
@ -60,9 +63,16 @@ For more information, check out the [source code](apps/website).
### Libs
/libs/ contains the library projects. There are many different kinds of libraries, and each library defines its own external API so that boundaries between libraries remain clear.
#### api-skolplattformen
(renamed from embedded-api)
#### api
The base for all api implementations
#### api-hjarntorget
The implementation for the school platform in Gothenburg called Hjärntorget
#### api-skolplattformen
By not having to worry about the complex nature of the official API, the app becomes light-weight. \
It also makes it easier for others to develop their own applications for the Skolplattformen API.
@ -73,6 +83,7 @@ Check out the documentation [here](libs/api-skolplattformen).
#### curriculum
Translations of curriculum codes (sv: ämneskoder på schemat) to clear text descriptions
#### hooks
To make it easier to use the the api in the app, we also created a set of React hooks.
@ -83,7 +94,6 @@ Check out the documentation [here](libs/hooks).
To clone and build the project, you first need to install the required dependencies:
```bash
$ sudo apt install git npm
$ npx lerna bootstrap
```
Clone the repo with
@ -91,6 +101,31 @@ Clone the repo with
$ git clone https://github.com/kolplattformen/skolplattformen.git
```
Install dependencies
```
cd skolplattformen && yarn
```
Start the iOS app
```
yarn run ios
```
Start the Android app
```
yarn run android
```
Run all tests
```
yarn run test
```
Run a specific test
```
yarn run test:api-skolplattformen
```
The README files for the [app](apps/skolplattformen-sthlm) and [website](apps/website) contain further instructions.
## Contributions
@ -128,6 +163,8 @@ If you're offended by this initiative, rest assured there is no reason to be —
- [Andreas Eriksson](https://github.com/whyer)
- [Kajetan Kazimierczak](https://github.com/kajetan-kazimierczak)
- [Karin Nygårds (artwork)](https://github.com/grishund)
- [Jonathan Edenström](https://github.com/edenstrom)
- [Emil Hellman](https://github.com/archevel)
- You?
## License

View File

@ -1,84 +0,0 @@
import * as eva from '@eva-design/eva'
import AsyncStorage from '@react-native-async-storage/async-storage'
import CookieManager from '@react-native-cookies/cookies'
import init from '@skolplattformen/api-skolplattformen'
import { ApiProvider } from '@skolplattformen/hooks'
import { ApplicationProvider, IconRegistry } from '@ui-kitten/components'
import { EvaIconsPack } from '@ui-kitten/eva-icons'
import React from 'react'
import { StatusBar, useColorScheme } from 'react-native'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { AppNavigator } from './components/navigation.component'
import { LanguageProvider } from './context/language/languageContext'
import { default as customMapping } from './design/mapping.json'
import { darkTheme, lightTheme } from './design/themes'
import useSettingsStorage from './hooks/useSettingsStorage'
import { translations } from './utils/translation'
const api = init(fetch, CookieManager)
const reporter = __DEV__
? {
log: (message) => console.log(message),
error: (error, label) => console.error(label, error),
}
: undefined
if (__DEV__) {
const DevMenu = require('react-native-dev-menu')
DevMenu.addItem('Log AsyncStorage contents', () => logAsyncStorage())
}
const safeJsonParse = (maybeJson) => {
if (maybeJson) {
try {
return JSON.parse(maybeJson)
} catch (error) {
return maybeJson
}
}
return 'null'
}
const logAsyncStorage = async () => {
const allKeys = await AsyncStorage.getAllKeys()
const keysAndValues = await AsyncStorage.multiGet(allKeys)
console.log('*** AsyncStorage contents:')
keysAndValues.forEach((keyAndValue) => {
console.log(
keyAndValue[0],
'=>',
keyAndValue[1] ? safeJsonParse(keyAndValue[1]) : 'null'
)
})
console.log('***')
}
export default () => {
const [usingSystemTheme] = useSettingsStorage('usingSystemTheme')
const [theme] = useSettingsStorage('theme')
const systemTheme = useColorScheme()
const colorScheme = usingSystemTheme ? systemTheme : theme
return (
<ApiProvider api={api} storage={AsyncStorage} reporter={reporter}>
<SafeAreaProvider>
<StatusBar
backgroundColor={colorScheme === 'dark' ? '#2E3137' : '#FFF'}
barStyle={colorScheme === 'dark' ? 'light-content' : 'dark-content'}
translucent
/>
<IconRegistry icons={EvaIconsPack} />
<ApplicationProvider
{...eva}
customMapping={customMapping}
theme={colorScheme === 'dark' ? darkTheme : lightTheme}
>
<LanguageProvider cache={true} data={translations}>
<AppNavigator />
</LanguageProvider>
</ApplicationProvider>
</SafeAreaProvider>
</ApiProvider>
)
}

View File

@ -0,0 +1,105 @@
import * as eva from '@eva-design/eva'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { ApiProvider, Reporter } from '@skolplattformen/hooks'
import { ApplicationProvider, IconRegistry, Text } from '@ui-kitten/components'
import { EvaIconsPack } from '@ui-kitten/eva-icons'
import React from 'react'
import { StatusBar, useColorScheme, View } from 'react-native'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { AppNavigator } from './components/navigation.component'
import { FeatureProvider } from './context/feature/featureContext'
import { LanguageProvider } from './context/language/languageContext'
import { SchoolPlatformProvider } from './context/schoolPlatform/schoolPlatformContext'
import { schoolPlatforms } from './data/schoolPlatforms'
import { default as customMapping } from './design/mapping.json'
import { darkTheme, lightTheme } from './design/themes'
import useSettingsStorage from './hooks/useSettingsStorage'
import { translations } from './utils/translation'
const reporter: Reporter | undefined = __DEV__
? {
log: (message: string) => console.log(message),
error: (error: Error, label?: string) => console.error(label, error),
}
: undefined
if (__DEV__) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const DevMenu = require('react-native-dev-menu')
DevMenu.addItem('Log AsyncStorage contents', () => logAsyncStorage())
}
const safeJsonParse = (maybeJson: string) => {
if (maybeJson) {
try {
return JSON.parse(maybeJson)
} catch (error) {
return maybeJson
}
}
return 'null'
}
const logAsyncStorage = async () => {
const allKeys = await AsyncStorage.getAllKeys()
const keysAndValues = await AsyncStorage.multiGet(allKeys)
console.log('*** AsyncStorage contents:')
keysAndValues.forEach((keyAndValue) => {
console.log(
keyAndValue[0],
'=>',
keyAndValue[1] ? safeJsonParse(keyAndValue[1]) : 'null'
)
})
console.log('***')
}
export default () => {
const [usingSystemTheme] = useSettingsStorage('usingSystemTheme')
const [currentSchoolPlatform] = useSettingsStorage('currentSchoolPlatform')
const [theme] = useSettingsStorage('theme')
const systemTheme = useColorScheme()
const colorScheme = usingSystemTheme ? systemTheme : theme
const platform = schoolPlatforms.find((pf) => pf.id === currentSchoolPlatform)
if (!platform)
return (
<View>
<Text>ERROR</Text>
</View>
)
return (
<FeatureProvider features={platform.features}>
<SchoolPlatformProvider>
<ApiProvider
api={platform.api}
storage={AsyncStorage}
reporter={reporter}
>
<SafeAreaProvider>
<StatusBar
backgroundColor={colorScheme === 'dark' ? '#2E3137' : '#FFF'}
barStyle={
colorScheme === 'dark' ? 'light-content' : 'dark-content'
}
translucent
/>
<IconRegistry icons={EvaIconsPack} />
<ApplicationProvider
{...eva}
// @ts-expect-error Unknown error
customMapping={customMapping}
theme={colorScheme === 'dark' ? darkTheme : lightTheme}
>
<LanguageProvider cache={true} data={translations}>
<AppNavigator />
</LanguageProvider>
</ApplicationProvider>
</SafeAreaProvider>
</ApiProvider>
</SchoolPlatformProvider>
</FeatureProvider>
)
}

View File

@ -134,7 +134,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20000
versionName "2.0.4"
versionName "3.0.0"
}
splits {
abi {

View File

@ -7,6 +7,8 @@
package com.app;
import android.content.Context;
import android.util.Log;
import com.facebook.flipper.android.AndroidFlipperClient;
import com.facebook.flipper.android.utils.FlipperUtils;
import com.facebook.flipper.core.FlipperClient;
@ -22,6 +24,13 @@ import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPl
import com.facebook.react.ReactInstanceManager;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.modules.network.NetworkingModule;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import okhttp3.Cookie;
import okhttp3.CookieJar;
import okhttp3.OkHttpClient;
public class ReactNativeFlipper {
@ -40,6 +49,7 @@ public class ReactNativeFlipper {
new NetworkingModule.CustomClientBuilder() {
@Override
public void apply(OkHttpClient.Builder builder) {
builder.callTimeout(5000, TimeUnit.MILLISECONDS);
builder.addNetworkInterceptor(new FlipperOkhttpInterceptor(networkFlipperPlugin));
}
});

View File

@ -0,0 +1,79 @@
package org.skolplattformen.app;
import android.util.Log;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import kotlin.Pair;
import okhttp3.Cookie;
import okhttp3.CookieJar;
import okhttp3.HttpUrl;
import okhttp3.Interceptor;
import okhttp3.Request;
import okhttp3.Response;
class CookieInterceptor implements Interceptor {
private final List<Cookie> cookies;
private final CookieJar cookieJar;
public CookieInterceptor(CookieJar cookieJar) {
this.cookies = new ArrayList<>();
this.cookieJar = cookieJar;
}
@NotNull
@Override
public Response intercept(@NotNull Chain chain) throws IOException {
// TODO: Clean up the code so only the necessary feeding of cookies to
// the cookie jar remains. That is needed because of:
// https://reactnative.dev/docs/0.64/network#known-issues-with-fetch-and-cookie-based-authentication
// Specifically react native's fetch does not respect multiple `set-cookie` headers and only
// seem to set one cookie per request. Some of the login calls in the api-hjarntorget lib
// receives multiple `set-cookie` headers.
String domain = chain.request().url().topPrivateDomain();
Log.d("Skolplattformen", "requseting resource on domain: " + domain);
if(domain == null || !domain.contains("goteborg.se") && !domain.contains("funktionstjanster.se")) {
return chain.proceed(chain.request());
}
Log.d("Skolplattformen", "\n\n<<<<<<<<<<<<<<<<<<<<< BEGIN >>>>>>>>>>>>>>>>>>");
Log.d("Skolplattformen", "" + chain.request().method() + " " + chain.request().url());
Log.d("Skolplattformen", "url have length: " + chain.request().url().toString().length());
Iterator<Pair<String, String>> iterator = chain.request().headers().iterator();
while (iterator.hasNext()) {
Pair<String, String> header = iterator.next();
Log.d("Skolplattformen", "SENT " + header.getFirst() + ": " + header.getSecond() + "");
}
Request request = chain.request();
Response response = chain.proceed(request);
String location = response.header("Location");
location = location != null ? location : "";
Log.d("Skolplattformen", "url=" + response.request().url());
Log.d("Skolplattformen", "isRedirect=" + response.isRedirect());
Log.d("Skolplattformen", "responseCode=" + response.code());
Log.d("Skolplattformen", "redirectUri has length=" + location.length());
iterator = response.headers().iterator();
cookies.clear();
while (iterator.hasNext()) {
Pair<String, String> header = iterator.next();
Log.d("Skolplattformen", "RECEIVED " + header.getFirst() + ": " + header.getSecond() + "");
if (header.getFirst().equals("Set-Cookie")) {
Cookie c = Cookie.parse(response.request().url(), header.getSecond());
cookies.add(c);
}
}
HttpUrl url = new HttpUrl.Builder().host(request.url().host()).scheme("https").build();
cookieJar.saveFromResponse(url, cookies);
Log.d("Skolplattformen", "<<<<<<<<<<<<<<<<<<<<< END >>>>>>>>>>>>>>>>>>\n\n");
return response;
}
}

View File

@ -9,6 +9,9 @@ import com.facebook.react.ReactApplication;
import com.facebook.react.ReactInstanceManager;
import com.facebook.react.ReactNativeHost;
import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.modules.network.OkHttpClientProvider;
import com.facebook.react.modules.network.ReactCookieJarContainer;
import com.facebook.soloader.SoLoader;
import java.io.IOException;
@ -16,6 +19,7 @@ import java.lang.reflect.InvocationTargetException;
import java.util.Collections;
import java.util.List;
import okhttp3.CookieJar;
import okhttp3.Interceptor;
import okhttp3.OkHttpClient;
import okhttp3.Request;
@ -54,6 +58,14 @@ public class MainApplication extends Application implements ReactApplication {
super.onCreate();
SoLoader.init(this, /* native exopackage */ false);
initializeFlipper(this, getReactNativeHost().getReactInstanceManager());
OkHttpClientProvider.setOkHttpClientFactory(() -> {
ReactContext currentReactContext = mReactNativeHost.getReactInstanceManager().getCurrentReactContext();
OkHttpClient.Builder builder = OkHttpClientProvider.createClientBuilder(currentReactContext);
@SuppressWarnings("KotlinInternalInJava") CookieJar cookieJar = builder.getCookieJar$okhttp();
builder.addNetworkInterceptor(new CookieInterceptor(cookieJar));
return builder.build();
});
}
/**

View File

@ -1,24 +0,0 @@
import { useApi } from '@skolplattformen/hooks'
import React from 'react'
import { render } from '../../utils/testHelpers'
import { Auth } from '../auth.component'
const setup = () => {
useApi.mockReturnValue({
api: { on: jest.fn(), off: jest.fn() },
isLoggedIn: false,
})
const navigation = {
navigate: jest.fn(),
}
return render(<Auth navigation={navigation} />)
}
test('renders a random fun argument state', () => {
const screen = setup()
expect(screen.getByText(/öppna skolplattformen/i)).toBeTruthy()
expect(screen.getByText(/det [a-zåäö]+ alternativet/i)).toBeTruthy()
})

View File

@ -22,6 +22,7 @@ import { NewsList } from './newsList.component'
import { NotificationsList } from './notificationsList.component'
import { Classmates } from './classmates.component'
import { TabBarLabel } from './tabBarLabel.component'
import { useFeature } from '../hooks/useFeature'
type ChildNavigationProp = StackNavigationProp<RootStackParamList, 'Child'>
type ChildRouteProps = RouteProp<RootStackParamList, 'Child'>
@ -47,10 +48,20 @@ const CalendarScreen = () => <Calendar />
const MenuScreen = () => <Menu />
const ClassmatesScreen = () => <Classmates />
interface ScreenSettings {
NEWS_SCREEN: boolean
NOTIFICATIONS_SCREEN: boolean
CALENDER_SCREEN: boolean
MENU_SCREEN: boolean
CLASSMATES_SCREEN: boolean
}
const TabNavigator = ({
initialRouteName = 'News',
screenSettings,
}: {
initialRouteName?: keyof ChildTabParamList
screenSettings: ScreenSettings
}) => (
<Navigator
initialRouteName={initialRouteName}
@ -80,31 +91,41 @@ const TabNavigator = ({
}
}}
>
<Screen
name="News"
component={NewsScreen}
options={{ title: translate('navigation.news') }}
/>
<Screen
name="Notifications"
component={NotificationsScreen}
options={{ title: translate('navigation.notifications') }}
/>
<Screen
name="Calendar"
component={CalendarScreen}
options={{ title: translate('navigation.calender') }}
/>
<Screen
name="Menu"
component={MenuScreen}
options={{ title: translate('navigation.menu') }}
/>
<Screen
name="Classmates"
component={ClassmatesScreen}
options={{ title: translate('navigation.classmates') }}
/>
{screenSettings.NEWS_SCREEN && (
<Screen
name="News"
component={NewsScreen}
options={{ title: translate('navigation.news') }}
/>
)}
{screenSettings.NOTIFICATIONS_SCREEN && (
<Screen
name="Notifications"
component={NotificationsScreen}
options={{ title: translate('navigation.notifications') }}
/>
)}
{screenSettings.CALENDER_SCREEN && (
<Screen
name="Calendar"
component={CalendarScreen}
options={{ title: translate('navigation.calender') }}
/>
)}
{screenSettings.MENU_SCREEN && (
<Screen
name="Menu"
component={MenuScreen}
options={{ title: translate('navigation.menu') }}
/>
)}
{screenSettings.CLASSMATES_SCREEN && (
<Screen
name="Classmates"
component={ClassmatesScreen}
options={{ title: translate('navigation.classmates') }}
/>
)}
</Navigator>
)
@ -153,6 +174,8 @@ export const childRouteOptions =
export const Child = () => {
const route = useRoute<ChildRouteProps>()
const { child, initialRouteName } = route.params
const useFoodMenu = useFeature('FOOD_MENU')
const useClassList = useFeature('CLASS_LIST')
const navigation = useNavigation()
@ -160,9 +183,17 @@ export const Child = () => {
navigation.setOptions({ title: getHeaderTitle(route) })
}, [navigation, route])
const screenSettings: ScreenSettings = {
NEWS_SCREEN: true,
NOTIFICATIONS_SCREEN: true,
CALENDER_SCREEN: true,
MENU_SCREEN: useFoodMenu,
CLASSMATES_SCREEN: useClassList,
}
return (
<ChildProvider child={child}>
<TabNavigator
screenSettings={screenSettings}
initialRouteName={initialRouteName as keyof ChildTabParamList}
/>
</ChildProvider>

View File

@ -13,7 +13,7 @@ import {
useStyleSheet,
} from '@ui-kitten/components'
import Personnummer from 'personnummer'
import React, { useEffect, useState } from 'react'
import React, { useContext, useEffect, useState } from 'react'
import {
Image,
Linking,
@ -22,6 +22,9 @@ import {
View,
} from 'react-native'
import { schema } from '../app.json'
import { SchoolPlatformContext } from '../context/schoolPlatform/schoolPlatformContext'
import { schoolPlatforms } from '../data/schoolPlatforms'
import { useFeature } from '../hooks/useFeature'
import useSettingsStorage from '../hooks/useSettingsStorage'
import { useTranslation } from '../hooks/useTranslation'
import { Layout } from '../styles'
@ -40,6 +43,18 @@ const BankId = () => (
/>
)
interface Logins {
BANKID_SAME_DEVICE: number
BANKID_ANOTHER_DEVICE: number
TEST_USER: number
}
const LoginMethods: Logins = {
BANKID_SAME_DEVICE: 0,
BANKID_ANOTHER_DEVICE: 2,
TEST_USER: 3,
}
export const Login = () => {
const { api } = useApi()
const [cancelLoginRequest, setCancelLoginRequest] = useState<
@ -47,21 +62,30 @@ export const Login = () => {
>(() => () => null)
const [visible, showModal] = useState(false)
const [showLoginMethod, setShowLoginMethod] = useState(false)
const [showSchoolPlatformPicker, setShowSchoolPlatformPicker] =
useState(false)
const [error, setError] = useState<string | null>(null)
const [personalIdNumber, setPersonalIdNumber] = useSettingsStorage(
'cachedPersonalIdentityNumber'
)
const [loginMethodIndex, setLoginMethodIndex] =
useSettingsStorage('loginMethodIndex')
const [loginMethodId, setLoginMethodId] = useSettingsStorage('loginMethodId')
const loginBankIdSameDeviceWithoutId = useFeature(
'LOGIN_BANK_ID_SAME_DEVICE_WITHOUT_ID'
)
const { currentSchoolPlatform, changeSchoolPlatform } = useContext(
SchoolPlatformContext
)
const { t } = useTranslation()
const valid = Personnummer.valid(personalIdNumber)
const loginMethods = [
t('auth.bankid.OpenOnThisDevice'),
t('auth.bankid.OpenOnAnotherDevice'),
t('auth.loginAsTestUser'),
]
{ id: 'thisdevice', title: t('auth.bankid.OpenOnThisDevice') },
{ id: 'otherdevice', title: t('auth.bankid.OpenOnAnotherDevice') },
{ id: 'testuser', title: t('auth.loginAsTestUser') },
] as const
const loginHandler = async () => {
showModal(false)
@ -74,14 +98,15 @@ export const Login = () => {
}
}, [api])
/* Helpers */
const handleInput = (text: string) => {
setPersonalIdNumber(text)
const getSchoolPlatformName = () => {
return schoolPlatforms.find((item) => item.id === currentSchoolPlatform)
?.displayName
}
const openBankId = (token: string) => {
try {
const redirect = loginMethodIndex === 0 ? encodeURIComponent(schema) : ''
const redirect =
loginMethodId === 'thisdevice' ? encodeURIComponent(schema) : ''
const bankIdUrl =
Platform.OS === 'ios'
? `https://app.bankid.com/?autostarttoken=${token}&redirect=${redirect}`
@ -92,19 +117,24 @@ export const Login = () => {
}
}
const isUsingPersonalIdNumber =
loginMethodId === 'otherdevice' ||
(loginMethodId === 'thisdevice' && !loginBankIdSameDeviceWithoutId)
const startLogin = async (text: string) => {
if (loginMethodIndex < 2) {
if (loginMethodId === 'thisdevice' || loginMethodId === 'otherdevice') {
showModal(true)
let ssn
if (loginMethodIndex === 1) {
if (isUsingPersonalIdNumber) {
ssn = Personnummer.parse(text).format(true)
setPersonalIdNumber(ssn)
}
const status = await api.login(ssn)
setCancelLoginRequest(() => () => status.cancel())
if (status.token !== 'fake' && loginMethodIndex === 0) {
if (status.token !== 'fake' && loginMethodId === 'thisdevice') {
openBankId(status.token)
}
status.on('PENDING', () => console.log('BankID app not yet opened'))
@ -125,10 +155,14 @@ export const Login = () => {
const styles = useStyleSheet(themedStyles)
const currentLoginMethod =
loginMethods.find((method) => method.id === loginMethodId) ||
loginMethods[0]
return (
<>
<View style={styles.loginForm}>
{loginMethodIndex === 1 && (
{isUsingPersonalIdNumber && (
<Input
accessible={true}
label={t('general.socialSecurityNumber')}
@ -139,12 +173,10 @@ export const Login = () => {
accessoryRight={(props) => (
<TouchableWithoutFeedback
accessible={true}
onPress={() => handleInput('')}
onPress={() => setPersonalIdNumber('')}
accessibilityHint={t(
'login.a11y_clear_social_security_input_field',
{
defaultValue: 'Rensa fältet för personnummer',
}
{ defaultValue: 'Rensa fältet för personnummer' }
)}
>
<CloseOutlineIcon {...props} />
@ -153,7 +185,7 @@ export const Login = () => {
keyboardType="numeric"
onSubmitEditing={(event) => startLogin(event.nativeEvent.text)}
caption={error || ''}
onChangeText={(text) => handleInput(text)}
onChangeText={setPersonalIdNumber}
placeholder={t('auth.placeholder_SocialSecurityNumber')}
/>
)}
@ -163,12 +195,12 @@ export const Login = () => {
onPress={() => startLogin(personalIdNumber)}
style={styles.loginButton}
appearance="ghost"
disabled={loginMethodIndex === 1 && !valid}
disabled={isUsingPersonalIdNumber && !valid}
status="primary"
accessoryLeft={BankId}
size="medium"
>
{loginMethods[loginMethodIndex]}
{currentLoginMethod.title}
</Button>
<Button
accessible={true}
@ -185,6 +217,19 @@ export const Login = () => {
})}
/>
</ButtonGroup>
<View style={styles.platformPicker}>
<Button
appearance="ghost"
status="basic"
size="small"
accessoryRight={SelectIcon}
onPress={() => {
setShowSchoolPlatformPicker(true)
}}
>
{getSchoolPlatformName()}
</Button>
</View>
</View>
<Modal
visible={showLoginMethod}
@ -201,13 +246,13 @@ export const Login = () => {
ItemSeparatorComponent={Divider}
renderItem={({ item, index }) => (
<ListItem
title={item}
title={item.title}
accessible={true}
accessoryRight={
loginMethodIndex === index ? CheckIcon : undefined
loginMethodId === item.id ? CheckIcon : undefined
}
onPress={() => {
setLoginMethodIndex(index)
setLoginMethodId(item.id)
setShowLoginMethod(false)
}}
/>
@ -245,6 +290,42 @@ export const Login = () => {
</Button>
</Card>
</Modal>
<Modal
visible={showSchoolPlatformPicker}
style={styles.modal}
onBackdropPress={() => setShowSchoolPlatformPicker(false)}
backdropStyle={styles.backdrop}
>
<Card>
<Text category="h5" style={styles.bankIdLoading}>
{t('auth.chooseSchoolPlatform')}
</Text>
<List
data={schoolPlatforms}
ItemSeparatorComponent={Divider}
renderItem={({ item }) => (
<ListItem
title={item.displayName}
accessible={true}
accessoryRight={
currentSchoolPlatform === item.id ? CheckIcon : undefined
}
onPress={() => {
changeSchoolPlatform(item.id)
setShowSchoolPlatformPicker(false)
}}
/>
)}
/>
<Button
status="basic"
style={styles.cancelButtonStyle}
onPress={() => setShowSchoolPlatformPicker(false)}
>
{t('general.cancel')}
</Button>
</Card>
</Modal>
</>
)
}
@ -271,4 +352,7 @@ const themedStyles = StyleService.create({
width: 20,
height: 20,
},
platformPicker: {
width: '100%',
},
})

View File

@ -14,7 +14,11 @@ interface NavigationTitleProps {
export const NavigationTitle = ({ title, subtitle }: NavigationTitleProps) => {
return (
<View style={styles.container}>
<Text style={styles.title}>{title}</Text>
{title && (
<Text style={styles.title} numberOfLines={1} ellipsizeMode="tail">
{title}
</Text>
)}
<Text style={styles.subtitle}>{subtitle}</Text>
</View>
)
@ -25,7 +29,7 @@ const styles = StyleSheet.create({
...Layout.center,
},
title: {
...fontSize.base,
...fontSize.sm,
fontWeight: '500',
},
subtitle: { ...fontSize.xxs },

View File

@ -38,10 +38,7 @@ export const newsItemRouteOptions =
return {
...defaultStackStyling(darkMode),
headerCenter: () => (
<NavigationTitle
title={newsItem.header}
subtitle={studentName(child?.name)}
/>
<NavigationTitle subtitle={studentName(child?.name)} />
),
headerLargeTitle: false,
}
@ -58,6 +55,9 @@ export const NewsItem = ({ route }: NewsItemProps) => {
contentContainerStyle={styles.article}
style={styles.scrollView}
>
<Text maxFontSizeMultiplier={2} style={styles.title}>
{newsItem.header}
</Text>
{dateIsValid(newsItem.published) && (
<Text
maxFontSizeMultiplier={2}

View File

@ -20,7 +20,9 @@ export const Notification = ({ item }: NotificationProps) => {
const displayDate = date ? moment(date).fromNow() : null
const sharedCookiesEnabled = Boolean(
item.url && item.url.startsWith('https://start.unikum.net/')
item.url &&
(item.url.startsWith('https://start.unikum.net/') ||
item.url.startsWith('https://hjarntorget.goteborg.se'))
)
return (

View File

@ -70,7 +70,12 @@ const LessonList = ({ lessons, header, lunch }: LessonListProps) => {
>{`${timeStart.slice(0, 5)}-${timeEnd.slice(0, 5)} ${
location === '' ? '' : '(' + location + ')'
} `}</Text>
<Text style={styles.lessonDescription} maxFontSizeMultiplier={1}>
<Text
style={styles.lessonDescription}
maxFontSizeMultiplier={1}
numberOfLines={2}
ellipsizeMode="tail"
>
{code?.toUpperCase() === 'LUNCH' ? lunch?.description : teacher}
</Text>
</View>
@ -178,7 +183,7 @@ const themedStyles = StyleService.create({
padding: 0,
},
item: {
height: 55,
height: 90,
backgroundColor: 'background-basic-color-2',
paddingHorizontal: 0,
borderRadius: 2,

View File

@ -0,0 +1,19 @@
import { Features, FeatureType } from '@skolplattformen/api'
import React from 'react'
export const FeatureFlagsContext = React.createContext<Features>({
LOGIN_BANK_ID_SAME_DEVICE: false,
FOOD_MENU: false,
})
interface Props {
features: Features
}
export const FeatureProvider: React.FC<Props> = (props) => {
return (
<FeatureFlagsContext.Provider value={props.features} {...props}>
{props.children}
</FeatureFlagsContext.Provider>
)
}

View File

@ -20,7 +20,7 @@ export const LanguageContext = React.createContext<LanguageContextProps>({
interface Props {
children: ReactNode
data: any
initialLanguageCode: string
initialLanguageCode?: string
cache: any
}

View File

@ -0,0 +1,36 @@
import useSettingsStorage from '../../hooks/useSettingsStorage'
import React, { createContext } from 'react'
interface SchoolPlatformProps {
currentSchoolPlatform?: string
changeSchoolPlatform: (platform: string) => void
}
const defaultState: SchoolPlatformProps = {
changeSchoolPlatform: (platform: string) =>
console.log('DEBUG ONLY: changing to', platform),
}
export const SchoolPlatformProvider: React.FC = ({ children }) => {
const [currentSchoolPlatform, setCurrentSchoolPlatform] = useSettingsStorage(
'currentSchoolPlatform'
)
const changeSchoolPlatform = (platform: string) => {
setCurrentSchoolPlatform(platform)
}
return (
<SchoolPlatformContext.Provider
value={{
currentSchoolPlatform,
changeSchoolPlatform,
}}
>
{children}
</SchoolPlatformContext.Provider>
)
}
export const SchoolPlatformContext =
createContext<SchoolPlatformProps>(defaultState)

View File

@ -0,0 +1,22 @@
import CookieManager from '@react-native-cookies/cookies'
import initHjarntorget, {
features as featuresHjarntorget,
} from '@skolplattformen/api-hjarntorget'
import initSkolplattformen, {
features as featuresSkolPlattformen,
} from '@skolplattformen/api-skolplattformen'
export const schoolPlatforms = [
{
id: 'stockholm-skolplattformen',
displayName: 'Stockholm stad (Skolplattformen)',
api: initSkolplattformen(fetch, CookieManager),
features: featuresSkolPlattformen,
},
{
id: 'goteborg-hjarntorget',
displayName: 'Göteborg stad (Hjärntorget)',
api: initHjarntorget(fetch, CookieManager),
features: featuresHjarntorget,
},
]

View File

@ -0,0 +1,12 @@
import { Features, FeatureType } from '@skolplattformen/api'
import React from 'react'
import { FeatureFlagsContext } from '../context/feature/featureContext'
export const useFeature = (name: FeatureType) => {
const features = React.useContext<Features>(FeatureFlagsContext)
if (features === null) {
throw new Error('You must wrap your components in a FeatureProvider.')
}
return features[name]
}

View File

@ -5,10 +5,13 @@ import AppStorage from '../services/appStorage'
export const settingsState = proxy({
hydrated: false,
settings: {
loginMethodIndex: 0,
loginMethodId: 'thisdevice' as 'thisdevice' | 'otherdevice' | 'testuser',
usingSystemTheme: true,
theme: 'light',
cachedPersonalIdentityNumber: '',
currentSchoolPlatform: 'stockholm-skolplattformen' as
| 'stockholm-skolplattformen'
| 'goteborg-hjarntorget',
},
})

View File

@ -669,4 +669,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 85f5a2dfa1de342b427eecb6e9652410ad153247
COCOAPODS: 1.11.2
COCOAPODS: 1.10.1

View File

@ -793,7 +793,7 @@
ENABLE_BITCODE = NO;
INFOPLIST_FILE = app/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 2.0.4;
MARKETING_VERSION = 3.0.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@ -822,7 +822,7 @@
DEVELOPMENT_TEAM = "";
INFOPLIST_FILE = app/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 2.0.4;
MARKETING_VERSION = 3.0.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",

View File

@ -24,6 +24,7 @@
"Waiting": "Waiting for BankID…"
},
"chooseLoginMethod": "Choose login method",
"chooseSchoolPlatform": "Choose platform",
"loginAsTestUser": "Log in as a test user",
"loginFailed": "Could not log in. Please try again.",
"placeholder_SocialSecurityNumber": "Your personal identity number",

View File

@ -24,6 +24,7 @@
"Waiting": "Väntar på BankID…"
},
"chooseLoginMethod": "Välj inloggningsmetod",
"chooseSchoolPlatform": "Välj plattform",
"loginAsTestUser": "Logga in som testanvändare",
"loginFailed": "Inloggningen misslyckades, försök igen!",
"placeholder_SocialSecurityNumber": "Ditt personnummer",

View File

@ -0,0 +1,6 @@
module.exports = {
presets: [
['@babel/preset-env', {targets: {node: 'current'}}],
'@babel/preset-typescript',
],
}

View File

@ -0,0 +1,23 @@
{
"extends": ["plugin:@nrwl/nx/react", "../../.eslintrc.json"],
"ignorePatterns": ["!**/*", "public", ".cache", "node_modules"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {
"@typescript-eslint/ban-ts-comment": "off"
}
},
{
"files": ["*.js", "*.jsx"],
"rules": {
"@typescript-eslint/no-var-requires": "off"
}
}
]
}

108
libs/api-hjarntorget/.gitignore vendored Normal file
View File

@ -0,0 +1,108 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
record
dist/*

View File

@ -0,0 +1,10 @@
{
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"semi": false,
"singleQuote": true,
"trailingComma": "es5",
"bracketSpacing": true,
"jsxBracketSameLine": false
}

View File

@ -0,0 +1,3 @@
{
"branches": ["main"]
}

View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -0,0 +1,81 @@
import { CookieJar, Cookie as TCookie } from 'tough-cookie'
export interface Cookie {
name: string
value: string
path?: string
domain?: string
version?: string
expires?: string
secure?: boolean
httpOnly?: boolean
}
export interface Cookies {
[key: string]: Cookie
}
export interface CookieManagerStatic {
set(url: string, cookie: Cookie, useWebKit?: boolean): Promise<boolean>
setFromResponse(url: string, cookie: string): Promise<boolean>
get(url: string, useWebKit?: boolean): Promise<Cookies>
clearAll(useWebKit?: boolean): Promise<boolean>
}
const convertTtoC = (cookie: string | TCookie): Cookie => {
if (typeof cookie === 'string') {
return convertTtoC(TCookie.parse(cookie) as TCookie)
}
return {
name: cookie.key,
value: cookie.value,
domain: cookie.domain || undefined,
expires:
cookie.expires === 'Infinity' ? undefined : cookie.expires.toUTCString(),
httpOnly: cookie.httpOnly || undefined,
path: cookie.path || undefined,
secure: cookie.secure,
}
}
const convertCtoT = (cookie: Cookie): TCookie =>
new TCookie({
key: cookie.name,
value: cookie.value,
domain: cookie.domain,
expires: cookie.expires ? new Date(cookie.expires) : undefined,
httpOnly: cookie.httpOnly || false,
path: cookie.path,
secure: cookie.secure || false,
})
const convertCookies = (cookies: TCookie[]): Cookies =>
cookies.reduce(
(map, cookie) => ({
...map,
[cookie.key]: convertTtoC(cookie),
}),
{} as Cookies
)
const jar = new CookieJar()
const CookieManager: CookieManagerStatic = {
clearAll: async () => {
await jar.removeAllCookies()
return true
},
get: async (url) => {
const cookies = await jar.getCookies(url)
return convertCookies(cookies)
},
set: async (url, cookie) => {
await jar.setCookie(convertCtoT(cookie), url)
return true
},
setFromResponse: async (url, cookie) => {
await jar.setCookie(cookie, url)
return true
},
}
export default CookieManager

View File

@ -0,0 +1,25 @@
{
"headers": {
"accept": "text/plain",
"accept-language": "en-GB,en-SE;q=0.9,en;q=0.8,sv-SE;q=0.7,sv;q=0.6,en-US;q=0.5",
"access-control-allow-origin": "*",
"cache-control": "no-cache",
"content-type": "text/plain",
"pragma": "no-cache",
"sec-ch-ua": "\"Google Chrome\";v=\"89\", \"Chromium\";v=\"89\", \";Not A Brand\";v=\"99\"",
"sec-ch-ua-mobile": "?0",
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-site",
"x-xsrf-token": "SfONpuvKXD1XHML3Kelvm3easB6Xn3xtbVPG52jdpc3Q7sRxJv7_6wfjo1qS3NOQWkfCvfPkJpJg0QIBmo358o7FdQY2aWvUOxA9MU2Fl0E1",
"y-xsrf-token11": "FyXUbtZUE2iT09J7FOLTpfZ_onjbj3WEIO6jOY9B1KaZzMrAs4WS03AuWbQhmKyCEX2inTPVDzyPc58tN2EM4L1vYD6aH_zhlc7gVo9jaPdLKQc4qnE6ue184cSamKE0",
"topology-key": "labor matter federal|",
"topology-short-key": "assumeoutside",
"topology-base64-iterations": "8"
},
"referrer": "https://etjanst.stockholm.se/",
"referrerPolicy": "strict-origin-when-cross-origin",
"body": "XVDf/EliJ/oZH9BRlRCMNds2jCRcTL8/isnpuj2wD6wH1lxX/cHY/AM6XJ8nweGne+FAPgcpj+blQ+dQvvmiJfK4t0u66tg8L60ysfDs/eBeoA794lvvtwRwJ946VUahZG89Al7UFkx5Ew1AGp4yuJ38drNDK4J5RAUGvzOWTmniZnSYs9P5UR2SWP39NcOoovwZsce7tRigdusI8sSDSUh+lVDkwfERfQqe3oG+FPiGQsfeFd2y/5f8chxU9VQz4oF7BLiP69xBPJr2KFkQc7MJaqEQy87loe0vwehRN/lOP858pPiVfc96M2jc0+yQEgnUBXPgQmFVC6CIHfQ0Mg==",
"method": "POST",
"mode": "cors"
}

View File

@ -0,0 +1,9 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
transform: {
'.(ts|tsx)': 'ts-jest',
},
testRegex: '(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$',
moduleFileExtensions: ['ts', 'tsx', 'js'],
}

View File

@ -0,0 +1,262 @@
import { ApiHjarntorget } from './apiHjarntorget'
import { checkStatus } from './loginStatus'
import { wrapToughCookie } from '@skolplattformen/api'
import { CookieJar } from 'tough-cookie'
const setupSuccessfullLoginInitiation = (fetcherMock: jest.Mock) => {
// 'begin-login'
fetcherMock.mockReturnValueOnce(Promise.resolve({
url: "some url with url encoded at the end?return=hello"
}))
// 'init-shibboleth-login'
fetcherMock.mockReturnValueOnce(Promise.resolve({
url: "some url with url encoded at the end?Target=hello"
}))
// 'init-bankId'
fetcherMock.mockReturnValueOnce(Promise.resolve({
text: jest.fn().mockReturnValue(Promise.resolve(`
<html>
<body>
<input name="RelayState" value="aUUID"></input>
<input name="SAMLRequest" value="somebase64value"></input>
</body>
</html>`))
}))
// 'pick-mvghost'
fetcherMock.mockReturnValueOnce(Promise.resolve({
url: "some url to a mvghost"
}))
// 'start-bankId'
fetcherMock.mockReturnValueOnce(Promise.resolve({
url: "some base url to a mvghost to use when polling status"
}))
}
const setupSuccessfullBankIdLogin = (fetcherMock: jest.Mock) => {
// 'poll-bankid-status'
fetcherMock.mockReturnValueOnce(Promise.resolve({
json: jest.fn().mockReturnValue(Promise.resolve({
infotext: "",
location: "an url to go to confirm the login"
}))
}))
// 'confirm-signature-redirect'
fetcherMock.mockReturnValueOnce(Promise.resolve({
text: jest.fn().mockReturnValue(Promise.resolve(`
<html>
<body>
<textarea name="RelayState">relay state probably same uuid as before</textarea>
<textarea name="SAMLResponse">base64 encoded saml response</textarea>
</body>
</html>`))
}))
// 'authgbg-saml-login'
fetcherMock.mockReturnValueOnce(Promise.resolve({
text: jest.fn().mockReturnValue(Promise.resolve(`
<html>
<body>
<input name="RelayState" value="aUUID"></input>
<input name="SAMLResponse" value="somebase64value"></input>
</body>
</html>`))
}))
// 'hjarntorget-saml-login'
fetcherMock.mockReturnValueOnce(Promise.resolve({ status: 200 }))
}
describe('api', () => {
let fetcherMock: jest.Mock
let api: ApiHjarntorget
beforeEach(() => {
const fetcher = jest.fn()
fetcherMock = fetcher as jest.Mock
const cookieManager = wrapToughCookie(new CookieJar())
cookieManager.clearAll();
api = new ApiHjarntorget(jest.fn(), cookieManager)
api.replaceFetcher(fetcher)
})
// describe('#login', () => {
// it('goes through single sing-on steps', async (done) => {
// setupSuccessfullLoginInitiation(fetcherMock)
// setupSuccessfullBankIdLogin(fetcherMock)
// const personalNumber = 'my personal number'
// const loginComplete = new Promise((resolve, reject) => {
// api.on('login', () => done())
// });
// await api.login(personalNumber)
// })
// it('checker emits PENDING', async (done) => {
// // 'poll-bankid-status'
// fetcherMock.mockReturnValueOnce(Promise.resolve({
// json: jest.fn().mockReturnValue(Promise.resolve({
// infotext: "some prompt to do signing in app",
// location: ""
// }))
// }))
// const status = checkStatus(fetcherMock, "some url")
// status.on('PENDING', () => {
// status.cancel()
// done()
// })
// })
// it('checker emits ERROR', async (done) => {
// // 'poll-bankid-status'
// fetcherMock.mockReturnValueOnce(Promise.resolve({
// json: jest.fn().mockReturnValue(Promise.resolve({
// infotext: "some prompt to do signing in app",
// location: "url with error in the name"
// }))
// }))
// const status = checkStatus(fetcherMock, "some url")
// status.on('ERROR', () => {
// status.cancel()
// done()
// })
// })
// it('checker emits ERROR when an exception occurs', async (done) => {
// // 'poll-bankid-status'
// fetcherMock.mockReturnValueOnce(Promise.resolve({
// json: jest.fn().mockReturnValue(Promise.resolve({
// infotext: undefined,
// location: undefined
// }))
// }))
// const status = checkStatus(fetcherMock, "some url")
// status.on('ERROR', () => {
// status.cancel()
// done()
// })
// })
// it('remembers used personal number', async (done) => {
// setupSuccessfullLoginInitiation(fetcherMock)
// setupSuccessfullBankIdLogin(fetcherMock)
// const personalNumber = 'my personal number'
// await api.login(personalNumber)
// api.on('login', () => {
// expect(api.getPersonalNumber()).toEqual(personalNumber)
// done()
// })
// })
// it('forgets used personal number if sign in is unsuccessful', async (done) => {
// setupSuccessfullLoginInitiation(fetcherMock)
// // 'poll-bankid-status'
// fetcherMock.mockReturnValueOnce(Promise.resolve({
// json: jest.fn().mockReturnValue(Promise.resolve({
// infotext: "",
// location: "an url to go to confirm the login"
// }))
// }))
// // 'confirm-signature-redirect'
// fetcherMock.mockReturnValueOnce(Promise.resolve({
// text: Promise.resolve("some error occured")
// }))
// const personalNumber = 'my personal number'
// const status = await api.login(personalNumber)
// status.on('ERROR', () => {
// expect(api.getPersonalNumber()).toEqual(undefined)
// done()
// })
// })
// // TODO: Possibly rewrite the mocking so we mock the responses more properly,
// // that way it would be possible to implement a throwIfNotOk wrapper for the
// // fetch calls.
// // it('throws error on external api error', async () => {
// // const personalNumber = 'my personal number'
// // try {
// // await api.login(personalNumber)
// // } catch (error: any) {
// // expect(error.message).toEqual(expect.stringContaining('Server Error'))
// // }
// // })
// })
// describe('#logout', () => {
// // it('clears session', async () => {
// // await api.logout()
// // const session = await api.getSession('')
// // expect(session).toEqual({
// // headers: {
// // cookie: '',
// // },
// // })
// // })
// it('emits logout event', async () => {
// const listener = jest.fn()
// api.on('logout', listener)
// await api.logout()
// expect(listener).toHaveBeenCalled()
// })
// it('sets .isLoggedIn', async () => {
// api.isLoggedIn = true
// await api.logout()
// expect(api.isLoggedIn).toBe(false)
// })
// it('forgets personalNumber', async () => {
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
// (api as any).personalNumber = 'my personal number'
// api.isLoggedIn = true
// await api.logout()
// expect(api.getPersonalNumber()).toEqual(undefined)
// })
// })
/*
describe('fake', () => {
it('sets fake mode for the correct pnr:s', async () => {
let status
status = await api.login('121212121212')
expect(status.token).toEqual('fake')
status = await api.login('201212121212')
expect(status.token).toEqual('fake')
status = await api.login('1212121212')
expect(status.token).toEqual('fake')
})
it('delivers fake data', async (done) => {
api.on('login', async () => {
const user = await api.getUser()
expect(user).toEqual({
firstName: 'Namn',
lastName: 'Namnsson',
isAuthenticated: true,
personalNumber: "195001182046",
})
const children = await api.getChildren()
expect(children).toHaveLength(2)
const calendar1 = await api.getCalendar(children[0])
expect(calendar1).toHaveLength(20)
const calendar2 = await api.getCalendar(children[1])
expect(calendar2).toHaveLength(18)
const skola24Children = await api.getSkola24Children()
expect(skola24Children).toHaveLength(1)
const timetable = await api.getTimetable(skola24Children[0], 2021, 15, 'sv')
expect(timetable).toHaveLength(32)
done()
})
await api.login('121212121212')
})
})*/
})

View File

@ -0,0 +1,584 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
Api,
CalendarItem,
Classmate,
CookieManager,
EtjanstChild,
Fetcher,
FetcherOptions,
LoginStatusChecker,
MenuItem,
NewsItem,
Notification,
ScheduleItem,
Skola24Child,
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 } 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'
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 = 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: typeof global.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> {
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(): Promise<(Skola24Child & EtjanstChild)[]> {
if (!this.isLoggedIn) {
throw new Error('Not logged in...')
}
console.log('fetching children')
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 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: any[] = await wallMessagesResponse.json()
const nonChildSpecificMessages = wallMessagesResponseJson
.filter((message) =>
// Ignore "Alarm" messages from the calendar
message.creator.id !== '__system$virtual$calendar__' &&
// Only include messages that can not reliably be associated with one of the children
!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: any[] = await infoResponse.json()
// TODO: Filter out read messages?
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: EtjanstChild, item: NewsItem): Promise<any> {
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) {
return this.getEventsWithAssociatedMembersForChildren([child])
}
async getEventsWithAssociatedMembersForChildren(children: 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) => children.some(c => c.id === p.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',
}
)
if((beginLoginRedirectResponse as any).url.endsWith("startPage.do")) {
// already logged in!
const emitter = new EventEmitter()
setTimeout(() => {
this.isLoggedIn = true
emitter.emit('OK')
this.emit('login')
}, 50)
return emitter;
}
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')
// 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
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,18 @@
export const currentUser = () => ({
"url": "https://hjarntorget.goteborg.se/api/core/current-user",
"headers": {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36",
"cookie": "REMOVED"
},
"status": 200,
"statusText": "200",
"json": () => Promise.resolve({
"id": "889911_goteborgsstad",
"firstName": "TOLV",
"lastName": "TOLVAN",
"email": null,
"online": true,
"imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png",
"extraInfoInCatalog": ""
})
}) as any as Response

View File

@ -0,0 +1,213 @@
export const eventRoleMembers21 = () => ({
"url": "https://hjarntorget.goteborg.se/api/event-members/members-having-role?eventId=21&roleId=821",
"headers": {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36",
"cookie": "REMOVED"
},
"status": 200,
"statusText": "200",
"json": () => Promise.resolve([
{
"id": "__system$virtual$calendar__",
"firstName": "Kalendern",
"lastName": "i PING PONG",
"email": null,
"online": false,
"imagePath": "/pp/lookAndFeel/skins/default/icons/monalisa_large.png",
"extraInfoInCatalog": ""
},
{
// Klass: 8B
"id": "133700_goteborgsstad",
"firstName": "Azra",
"lastName": "Göransson",
"email": null,
"online": false,
"imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png",
"extraInfoInCatalog": ""
},
])
}) as any as Response
export const eventRoleMembers14 = () => ({
"url": "https://hjarntorget.goteborg.se/api/event-members/members-having-role?eventId=14&roleId=821",
"headers": {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36",
"cookie": "REMOVED"
},
"status": 200,
"statusText": "200",
"json": () => Promise.resolve([
{
// Klass: 8B
"id": "133700_goteborgsstad",
"firstName": "Azra",
"lastName": "Göransson",
"email": null,
"online": false,
"imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png",
"extraInfoInCatalog": ""
},
{
"id": "362119_goteborgsstad",
"firstName": "Elina",
"lastName": "Cocolis",
"email": null,
"online": false,
"imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png",
"extraInfoInCatalog": ""
},
{
"id": "999999_goteborgsstad",
"firstName": "Sanne",
"lastName": "Berggren",
"email": null,
"online": false,
"imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png",
"extraInfoInCatalog": ""
},
{
"id": "168925_goteborgsstad",
"firstName": "Teddy",
"lastName": "Karlsson",
"email": null,
"online": false,
"imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png",
"extraInfoInCatalog": ""
},
{
"id": "494949_goteborgsstad",
"firstName": "Fideli",
"lastName": "Sundström",
"email": null,
"online": false,
"imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png",
"extraInfoInCatalog": ""
},
])
}) as any as Response
export const eventRoleMembers18 = () => ({
"url": "https://hjarntorget.goteborg.se/api/event-members/members-having-role?eventId=18&roleId=821",
"headers": {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36",
"cookie": "REMOVED"
},
"status": 200,
"statusText": "200",
"json": () => Promise.resolve([
{
"id": "776655_goteborgsstad",
"firstName": "Walid",
"lastName": "Söderström",
"email": null,
"online": false,
"imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png",
"extraInfoInCatalog": ""
},
{
"id": "388601_goteborgsstad",
"firstName": "Rosa",
"lastName": "Fredriksson",
"email": null,
"online": false,
"imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png",
"extraInfoInCatalog": ""
},
{
"id": "654654_goteborgsstad",
"firstName": "Moses",
"lastName": "Johansson",
"email": null,
"online": false,
"imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png",
"extraInfoInCatalog": ""
},
{
"id": "1313131_goteborgsstad",
"firstName": "Haris",
"lastName": "Jonsson",
"email": null,
"online": false,
"imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png",
"extraInfoInCatalog": ""
},
{
"id": "887766_goteborgsstad",
"firstName": "Neo",
"lastName": "Lundström",
"email": null,
"online": false,
"imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png",
"extraInfoInCatalog": ""
},
{
// Klass: 5A
"id": "123456_goteborgsstad",
"firstName": "Jon",
"lastName": "Göransson",
"email": null,
"online": false,
"imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png",
"extraInfoInCatalog": ""
},
])
}) as any as Response
export const eventRoleMembers24 = () => ({
"url": "https://hjarntorget.goteborg.se/api/event-members/members-having-role?eventId=24&roleId=821",
"headers": {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36",
"cookie": "REMOVED"
},
"status": 200,
"statusText": "200",
"json": () => Promise.resolve([
{
"id": "393939_goteborgsstad",
"firstName": "Malik Maria",
"lastName": "Henriksson",
"email": null,
"online": false,
"imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png",
"extraInfoInCatalog": ""
},
{
"id": "444444_goteborgsstad",
"firstName": "Idas",
"lastName": "Svensson",
"email": null,
"online": false,
"imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png",
"extraInfoInCatalog": ""
},
{
"id": "818181_goteborgsstad",
"firstName": "Nadja",
"lastName": "Ekström",
"email": null,
"online": false,
"imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png",
"extraInfoInCatalog": ""
},
{
"id": "919191_goteborgsstad",
"firstName": "Karim",
"lastName": "Fakir",
"email": null,
"online": false,
"imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png",
"extraInfoInCatalog": ""
},
{
// Klass: Förskola
"id": "133737_goteborgsstad",
"firstName": "Havin",
"lastName": "Göransson",
"email": null,
"online": false,
"imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png",
"extraInfoInCatalog": ""
},
])
}) as any as Response

View File

@ -0,0 +1,35 @@
export const events = () => ({
"url": "https://hjarntorget.goteborg.se/api/events/events-sorted-by-name?offset=0&limit=100",
"headers": {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36",
"cookie": "REMOVED"
},
"status": 200,
"statusText": "200",
"json": () => Promise.resolve([
{
"id": 18,
"name": "138JÄTS 21/22 5A",
"url": "https://hjarntorget.goteborg.se/o/apiAccessWithKey.do?forwardUrl=%2FlaunchCourse.do%3Fid%3D12",
"state": "ONGOING"
},
{
"id": 14,
"name": "138JÄTS 21/22 8B",
"url": "https://hjarntorget.goteborg.se/o/apiAccessWithKey.do?forwardUrl=%2FlaunchCourse.do%3Fid%3D14",
"state": "ONGOING"
},
{
"id": 21,
"name": "138JÄTS Provschema år 8",
"url": "https://hjarntorget.goteborg.se/o/apiAccessWithKey.do?forwardUrl=%2FlaunchCourse.do%3Fid%3D21",
"state": "ONGOING"
},
{
"id": 24,
"name": "139SS27F Södra Bangatan förskola",
"url": "https://hjarntorget.goteborg.se/o/apiAccessWithKey.do?forwardUrl=%2FlaunchCourse.do%3Fid%3D24",
"state": "ONGOING"
}
])
}) as any as Response

View File

@ -0,0 +1,37 @@
import { Fetcher, Response } from '@skolplattformen/api'
import { calendars, calendar_14241345 } from './calendars';
import { currentUser } from './current-user';
import { events } from './events';
import { lessons_123456_goteborgsstad, lessons_133700_goteborgsstad, lessons_133737_goteborgsstad } from './lessons';
import { myChildren } from './my-children';
import { wallEvents } from './wall-events';
import { information } from './information'
import { genericRolesInEvent } from './roles-in-event';
import { eventRoleMembers14, eventRoleMembers18, eventRoleMembers21, eventRoleMembers24 } from './event-role-members';
const fetchMappings: { [name:string]: () => Response} = {
'current-user': currentUser,
'events': events,
'my-children': myChildren,
'wall-events': wallEvents,
'lessons-133700_goteborgsstad': lessons_133700_goteborgsstad,
'lessons-133737_goteborgsstad': lessons_133737_goteborgsstad,
'lessons-123456_goteborgsstad': lessons_123456_goteborgsstad,
'info': information,
'roles-in-event-14': genericRolesInEvent,
'roles-in-event-18': genericRolesInEvent,
'roles-in-event-21': genericRolesInEvent,
'roles-in-event-24': genericRolesInEvent,
'event-role-members-14-821': eventRoleMembers14,
'event-role-members-18-821': eventRoleMembers18,
'event-role-members-21-821': eventRoleMembers21,
'event-role-members-24-821': eventRoleMembers24,
'calendars': calendars,
'calendar-14241345': calendar_14241345,
}
export const fakeFetcher: Fetcher = (name: string, url: string, init?: any): Promise<Response> => {
const responder = fetchMappings[name] ?? (() => {throw new Error("Request not faked for name: " + name)})
return Promise.resolve(responder());
}

View File

@ -0,0 +1,111 @@
/* eslint-disable no-useless-escape */
export const information = () => ({
"url": "https://hjarntorget.goteborg.se/api/information/messages-by-date-desc?messageStatus=CURRENT&offset=0&limit=10&language=en",
"headers": {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36",
"cookie": "REMOVED"
},
"status": 200,
"statusText": "200",
"json": () => Promise.resolve([
{
"id": 3276034,
"title": "Nu får du och ditt barn tillgång till Polyglutt hemma",
"body": "<p><strong>Nu f&aring;r alla barn som g&aring;r i kommunal f&ouml;rskola i G&ouml;teborg tillg&aring;ng till bilderboksappen Polyglutt hemifr&aring;n! Det inneb&auml;r att du som v&aring;rdnadshavare och barn kan ta del av ett bibliotek av b&ouml;cker p&aring; b&aring;de svenska och 60 andra spr&aring;k, inklusive TAKK och teckenspr&aring;k via telefon eller l&auml;splatta.<\/strong><\/p>\r\n<p>Polyglutt &auml;r en app med bilderb&ouml;cker som fungerar som ett verktyg f&ouml;r att arbeta med spr&aring;kutveckling och litteratur i f&ouml;rskolan och hemma.<\/p>\r\n<p>Polyglutt Home Access &auml;r en tj&auml;nst som inneb&auml;r att alla barn som g&aring;r i kommunal f&ouml;rskola i G&ouml;teborg f&aring;r tillg&aring;ng till ett bibliotek av b&ouml;cker p&aring; b&aring;de svenska och 60 andra spr&aring;k, inklusive TAKK och teckenspr&aring;k hemifr&aring;n. Varje f&ouml;rskola kan ocks&aring; skapa egna bokhyllor med boktips i appen som du och ditt barn kan l&auml;sa hemma.<\/p>\r\n<p>Tj&auml;nsten fungerar p&aring; iPad, Androidplattor och i mobilen.<\/p>\r\n<p>Vill du veta mer om tj&auml;nsten, kontakta pedagogerna p&aring; ditt barns f&ouml;rskola.<\/p>",
"creator": {
"id": "501747_goteborgsstad",
"firstName": "Information Digitalisering",
"lastName": "Innovation",
"email": "information.digitaliseringochinnovation@forskola.goteborg.se",
"online": false,
"imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png",
"extraInfoInCatalog": ""
},
"recipientGroups": [
{
"id": 1121821,
"name": "DL Göteborg Vhavare förskolor"
}
],
"created": {
"ts": 1629970713111,
"timezoneOffsetMinutes": 120
},
"attachments": [],
"readByUser": false,
"archivedByUser": false
},
{
"id": 3270718,
"title": "Information från grundskoleförvaltningen",
"body": "<p>Till v&aring;rdnadshavare med barn p&aring; G&ouml;teborgs Stads grundskolor och grunds&auml;rskolor.<\/p>\r\n<p>Spridningen av covid-19 har &ouml;kat. D&auml;rf&ouml;r &auml;r det viktigt att alla hj&auml;lper till att minska spridningen av smitta.<\/p>\r\n<h2>Vi forts&auml;tter h&aring;lla avst&aring;nd<\/h2>\r\n<ul>\r\n<li>Om du vill ha kontakt med n&aring;gon p&aring; ditt barns skola vill vi g&auml;rna att du ringer eller skickar e-post.<\/li>\r\n<li>L&auml;mna och h&auml;mta ditt barn utomhus p&aring; skolg&aring;rden.<\/li>\r\n<li>En del m&ouml;ten som skolan har kommer att vara digitala.<\/li>\r\n<li>Uppmuntra ditt barn att promenera till och fr&aring;n skolan f&ouml;r att minska tr&auml;ngseln i kollektivtrafiken.<\/li>\r\n<\/ul>\r\n<h2>Detta g&auml;ller n&auml;r ditt barn &auml;r sjukt<\/h2>\r\n<ul>\r\n<li>Barn som bara &auml;r lite sjuka, som till exempel &auml;r snuviga eller har ont i halsen, ska stanna hemma.<\/li>\r\n<li>Ber&auml;tta alltid f&ouml;r skolan om ditt barn har konstaterad covid-19.<\/li>\r\n<\/ul>\r\n<p><a href=\"https://goteborg.se/wps/wcm/connect/a515d17c-7078-4663-8493-d1900b78cfb3/Om+ditt+barn+%C3%A4r+sjukt+eller+borta+fr%C3%A5n+skolan_information+till+v%C3%A5rdnadshavare_uppdaterad+13+augusti+2021.pdf?MOD=AJPERES\">H&auml;r hittar du mer information om vad som g&auml;ller n&auml;r ditt barn &auml;r sjukt.<\/a><\/p>\r\n<h2>Om ditt barn har varit p&aring; resa utomlands<\/h2>\r\n<p>Folkh&auml;lsomyndigheten rekommenderar alla som har varit i l&auml;nder utanf&ouml;r Norden att ta ett test f&ouml;r covid-19 n&auml;r de kommer tillbaka Sverige. Detta g&auml;ller oavsett om man har symtom eller inte.<\/p>\r\n<p>L&auml;s mer p&aring; Krisinformation.se om vad som g&auml;ller f&ouml;r resor fr&aring;n olika l&auml;nder: <br /><a href=\"https://www.krisinformation.se/detta-kan-handa/handelser-och-storningar/20192/myndigheterna-om-det-nya-coronaviruset/reseinformation-med-anledning-av-det-nya-coronaviruset\">Utrikesresor och att vistas utomlands - Krisinformation.se<\/a><\/p>\r\n<h2>Undervisning p&aring; skolan<\/h2>\r\n<p>Fr&aring;n och med h&ouml;stterminen 2021 har alla skolor undervisning p&aring; plats i skolan. Detta g&auml;ller &auml;ven f&ouml;r &aring;rskurs 7-9.<\/p>\r\n<p>F&ouml;r f&ouml;rskoleklass till och med &aring;rskurs 9 finns det fortfarande m&ouml;jlighet att f&aring; undervisning p&aring; distans om:<\/p>\r\n<ul>\r\n<li>M&aring;nga av de som jobbar p&aring; skolan &auml;r fr&aring;nvarande p&aring; grund av covid-19 och det inte g&aring;r att ha undervisning i skolan.<\/li>\r\n<li>Det &auml;r stor spridningen av covid-19 bland elever och medarbetare.<\/li>\r\n<\/ul>\r\n<h2>Nytt test f&ouml;r covid-19 p&aring; skolorna<\/h2>\r\n<p>Inom kort b&ouml;rjar V&auml;stra G&ouml;talandsregionen med ett nytt test f&ouml;r covid-19 riktat mot elever. &nbsp;Om ditt barn har haft n&auml;ra kontakt med en person p&aring; skolan som har konstaterad covid-19 f&aring;r ni med ett paket hem med ett test.&nbsp;<\/p>\r\n<p>Du som v&aring;rdnadshavare hj&auml;lper ditt barn att ta testet. Testet l&auml;mnar du som v&aring;rdnadshavare sedan till en utvald v&aring;rdcentral.<\/p>\r\n<p>Om ditt barn ska ta ett test f&aring;r du mer information fr&aring;n ditt barns skola om hur testet g&aring;r till och vilken v&aring;rdcentral du ska l&auml;mna det till.<\/p>\r\n<h2>Kontakt<\/h2>\r\n<p>Har du fr&aring;gor eller funderingar kontaktar du ditt barns skola.<\/p>\r\n<p><a href=\"https://goteborg.se/wps/portal/press-och-media/aktuelltarkivet/aktuellt/18b9930e-d34c-4d6a-817a-c1b8e74e5f9f#Z7_42G01J41KGV2F0ALK2K1SN1M75\">L&auml;s mer om covid-19 och vad som g&auml;ller f&ouml;r grundskolef&ouml;rvaltningen.<\/a><\/p>\r\n<p>&nbsp;<\/p>",
"creator": {
"id": "486497_goteborgsstad",
"firstName": "Grundskola",
"lastName": "Informerar",
"email": null,
"online": false,
"imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png",
"extraInfoInCatalog": ""
},
"recipientGroups": [
{
"id": 4925595,
"name": "DL Göteborg Grundskola Vhavare Alla"
},
{
"id": 4525636,
"name": "Grundskola - informationskonto"
},
{
"id": 4925600,
"name": "DL Göteborg Grundsärskola Vhavare Alla"
}
],
"created": {
"ts": 1629096850743,
"timezoneOffsetMinutes": 120
},
"attachments": [
{
"id": 67888219,
"name": "Om ditt barn är sjukt eller borta från skolan_information till vårdnadshavare_uppdaterad 13 augusti 2021.pdf",
"size": 70466
}
],
"readByUser": false,
"archivedByUser": false
},
{
"id": 2982365,
"title": "Nya regler för skolplacering i förskoleklass och grundskola",
"body": "<p>Grundskolen&auml;mnden har beslutat om nya regler f&ouml;r skolplacering i f&ouml;rskoleklass och grundskola. Reglerna ska st&auml;rka elevernas r&auml;tt till en skola n&auml;ra hemmet och b&ouml;rjar g&auml;lla 1 januari 2021.<\/p>\r\n<p>Du kan l&auml;sa mer p&aring; sidan <a href=\"https://goteborg.se/wps/portal/press-och-media/aktuelltarkivet/aktuellt/e45ce367-4d46-48b4-936d-900a3e45e490\">Nya regler f&ouml;r skolplacering i f&ouml;rskoleklass och grundskola<\/a>.&nbsp;<\/p>\r\n<p>Om du har fr&aring;gor kan du kontakta grundskolef&ouml;rvaltningen p&aring; telefon: 031-365 09 60 eller e-post:&nbsp;<a href=\"mailto:grundskola@grundskola.goteborg.se\">grundskola@grundskola.goteborg.se<\/a>.&nbsp;<\/p>\r\n<p><em>Observera att detta meddelande inte g&aring;r att svara p&aring;.&nbsp;<\/em><\/p>\r\n<p>&nbsp;<\/p>",
"creator": {
"id": "486497_goteborgsstad",
"firstName": "Grundskola",
"lastName": "Informerar",
"email": null,
"online": false,
"imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png",
"extraInfoInCatalog": ""
},
"recipientGroups": [
{
"id": 4925595,
"name": "DL Göteborg Grundskola Vhavare Alla"
},
{
"id": 4525636,
"name": "Grundskola - informationskonto"
}
],
"created": {
"ts": 1603974943027,
"timezoneOffsetMinutes": 60
},
"attachments": [],
"readByUser": false,
"archivedByUser": false
}
])
}) as any as Response

View File

@ -0,0 +1,458 @@
// TODO: fix the startDate/endDate of all lessons
export const lessons_133700_goteborgsstad = () => {
const baseTime = 1636357800000;
const baseDate = new Date(baseTime)
const today = new Date()
today.setHours(baseDate.getHours())
today.setMinutes(baseDate.getMinutes())
today.setSeconds(0)
const offset = Math.abs(baseTime - today.getTime())
return {
"url": "https://hjarntorget.goteborg.se/api/schema/lessons?forUser=133700_goteborgsstad&startDateIso=2021-11-01&endDateIso=2021-11-08",
"headers": {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36",
"cookie": "REMOVED"
},
"status": 200,
"statusText": "200",
"json": () => Promise.resolve([
{
"id": "36080472:1",
"title": "HKK",
"location": "A402",
"calendars": [
"138JÄTS 21/22 8B/HKK"
],
"startDate": {
"ts": offset + 1636357800000,
"timezoneOffsetMinutes": 60
},
"endDate": {
"ts": offset + 1636360500000,
"timezoneOffsetMinutes": 60
},
"ownPlannings": null,
"teacherPlannings": null,
"teacherAndStudentPlannings": null,
"ownGeneralPlannings": null,
"teacherGeneralPlannings": null,
"teacherAndStudentGeneralPlannings": null,
"bookedResourceNames": [],
"bookedTeacherNames": [
"Noel Nyström (NNM)"
],
"hasTest": false,
"hasHomework": false,
"hasAssignment": false,
"url": null,
"note": ""
},
{
"id": "36080497:1",
"title": "BL",
"location": "B260",
"calendars": [
"138JÄTS 21/22 8B/BL"
],
"startDate": {
"ts": offset + 1636361700000,
"timezoneOffsetMinutes": 60
},
"endDate": {
"ts": offset + 1636365000000,
"timezoneOffsetMinutes": 60
},
"ownPlannings": null,
"teacherPlannings": null,
"teacherAndStudentPlannings": null,
"ownGeneralPlannings": null,
"teacherGeneralPlannings": null,
"teacherAndStudentGeneralPlannings": null,
"bookedResourceNames": [],
"bookedTeacherNames": [
"Joseph Ekström (JHE)"
],
"hasTest": false,
"hasHomework": false,
"hasAssignment": false,
"url": null,
"note": ""
},
{
"id": "37164864:1",
"title": "IDH",
"location": "IDH Ute",
"calendars": [
"138JÄTS 21/22 8B/IDH"
],
"startDate": {
"ts": offset + 1636365600000,
"timezoneOffsetMinutes": 60
},
"endDate": {
"ts": offset + 1636369800000,
"timezoneOffsetMinutes": 60
},
"ownPlannings": null,
"teacherPlannings": null,
"teacherAndStudentPlannings": null,
"ownGeneralPlannings": null,
"teacherGeneralPlannings": null,
"teacherAndStudentGeneralPlannings": null,
"bookedResourceNames": [],
"bookedTeacherNames": [
"Katja Fransson (KAF)"
],
"hasTest": false,
"hasHomework": false,
"hasAssignment": false,
"url": null,
"note": ""
},
{
"id": "36080557:1",
"title": "LUNCH",
"location": "-",
"calendars": [
"138JÄTS 21/22 8B"
],
"startDate": {
"ts": offset + 1636370700000,
"timezoneOffsetMinutes": 60
},
"endDate": {
"ts": offset + 1636372800000,
"timezoneOffsetMinutes": 60
},
"ownPlannings": null,
"teacherPlannings": null,
"teacherAndStudentPlannings": null,
"ownGeneralPlannings": null,
"teacherGeneralPlannings": null,
"teacherAndStudentGeneralPlannings": null,
"bookedResourceNames": [],
"bookedTeacherNames": [],
"hasTest": false,
"hasHomework": false,
"hasAssignment": false,
"url": null,
"note": ""
},
{
"id": "36080576:1",
"title": "EN",
"location": "A402",
"calendars": [
"138JÄTS 21/22 8B/EN"
],
"startDate": {
"ts": offset + 1636372800000,
"timezoneOffsetMinutes": 60
},
"endDate": {
"ts": offset + 1636376400000,
"timezoneOffsetMinutes": 60
},
"ownPlannings": null,
"teacherPlannings": null,
"teacherAndStudentPlannings": null,
"ownGeneralPlannings": null,
"teacherGeneralPlannings": null,
"teacherAndStudentGeneralPlannings": null,
"bookedResourceNames": [],
"bookedTeacherNames": [
"Henrietta Fransson (HAF)"
],
"hasTest": false,
"hasHomework": false,
"hasAssignment": false,
"url": null,
"note": ""
},
{
"id": "36080591:1",
"title": "MA",
"location": "A402",
"calendars": [
"138JÄTS 21/22 8B/MA"
],
"startDate": {
"ts": offset + 1636377000000,
"timezoneOffsetMinutes": 60
},
"endDate": {
"ts": offset + 1636380600000,
"timezoneOffsetMinutes": 60
},
"ownPlannings": null,
"teacherPlannings": null,
"teacherAndStudentPlannings": null,
"ownGeneralPlannings": null,
"teacherGeneralPlannings": null,
"teacherAndStudentGeneralPlannings": null,
"bookedResourceNames": [],
"bookedTeacherNames": [
"Amin Månsson (ANM)"
],
"hasTest": false,
"hasHomework": false,
"hasAssignment": false,
"url": null,
"note": ""
}
])
} as any as Response
}
export const lessons_123456_goteborgsstad = () => {
const baseTime = 1636355400000;
const baseDate = new Date(baseTime)
const today = new Date()
today.setHours(baseDate.getHours())
today.setMinutes(baseDate.getMinutes())
today.setSeconds(0)
const offset = Math.abs(baseTime - today.getTime())
return {
"url": "https://hjarntorget.goteborg.se/api/schema/lessons?forUser=123456_goteborgsstad&startDateIso=2021-11-01&endDateIso=2021-11-08",
"headers": {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36",
"cookie": "REMOVED"
},
"status": 200,
"statusText": "200",
"json": () => [
{
"id": "36080454:1",
"title": "EV",
"location": "P18",
"calendars": [
"138JÄTS 21/22 5A"
],
"startDate": {
"ts": offset + 1636355400000,
"timezoneOffsetMinutes": 60
},
"endDate": {
"ts": offset + 1636357500000,
"timezoneOffsetMinutes": 60
},
"ownPlannings": null,
"teacherPlannings": null,
"teacherAndStudentPlannings": null,
"ownGeneralPlannings": null,
"teacherGeneralPlannings": null,
"teacherAndStudentGeneralPlannings": null,
"bookedResourceNames": [],
"bookedTeacherNames": [
"Petra Modin (PMO)",
"Joakim Ness (JNE)"
],
"hasTest": false,
"hasHomework": false,
"hasAssignment": false,
"url": null,
"note": ""
},
{
"id": "36080467:1",
"title": "MENT",
"location": "P18",
"calendars": [
"138JÄTS 21/22 5A"
],
"startDate": {
"ts": offset + 1636357500000,
"timezoneOffsetMinutes": 60
},
"endDate": {
"ts": offset + 1636358100000,
"timezoneOffsetMinutes": 60
},
"ownPlannings": null,
"teacherPlannings": null,
"teacherAndStudentPlannings": null,
"ownGeneralPlannings": null,
"teacherGeneralPlannings": null,
"teacherAndStudentGeneralPlannings": null,
"bookedResourceNames": [],
"bookedTeacherNames": [
"Petra Modin (PMO)",
"Joakim Ness (JNE)"
],
"hasTest": false,
"hasHomework": false,
"hasAssignment": false,
"url": null,
"note": ""
},
{
"id": "36080474:1",
"title": "EN",
"location": "P18",
"calendars": [
"138JÄTS 21/22 5A"
],
"startDate": {
"ts": offset + 1636358400000,
"timezoneOffsetMinutes": 60
},
"endDate": {
"ts": offset + 1636362000000,
"timezoneOffsetMinutes": 60
},
"ownPlannings": null,
"teacherPlannings": null,
"teacherAndStudentPlannings": null,
"ownGeneralPlannings": null,
"teacherGeneralPlannings": null,
"teacherAndStudentGeneralPlannings": null,
"bookedResourceNames": [],
"bookedTeacherNames": [
"Petra Modin (PMO)"
],
"hasTest": false,
"hasHomework": false,
"hasAssignment": false,
"url": null,
"note": ""
},
{
"id": "36080502:1",
"title": "SV",
"location": "P18",
"calendars": [
"138JÄTS 21/22 5A"
],
"startDate": {
"ts": offset + 1636362900000,
"timezoneOffsetMinutes": 60
},
"endDate": {
"ts": offset + 1636366500000,
"timezoneOffsetMinutes": 60
},
"ownPlannings": null,
"teacherPlannings": null,
"teacherAndStudentPlannings": null,
"ownGeneralPlannings": null,
"teacherGeneralPlannings": null,
"teacherAndStudentGeneralPlannings": null,
"bookedResourceNames": [],
"bookedTeacherNames": [
"Joakim Ness (JNE)"
],
"hasTest": false,
"hasHomework": false,
"hasAssignment": false,
"url": null,
"note": ""
},
{
"id": "36080529:1",
"title": "LUNCH",
"location": "-",
"calendars": [
"138JÄTS 21/22 5A"
],
"startDate": {
"ts": offset + 1636366500000,
"timezoneOffsetMinutes": 60
},
"endDate": {
"ts": offset + 1636368300000,
"timezoneOffsetMinutes": 60
},
"ownPlannings": null,
"teacherPlannings": null,
"teacherAndStudentPlannings": null,
"ownGeneralPlannings": null,
"teacherGeneralPlannings": null,
"teacherAndStudentGeneralPlannings": null,
"bookedResourceNames": [],
"bookedTeacherNames": [],
"hasTest": false,
"hasHomework": false,
"hasAssignment": false,
"url": null,
"note": ""
},
{
"id": "36080545:1",
"title": "MA",
"location": "P18",
"calendars": [
"138JÄTS 21/22 5A"
],
"startDate": {
"ts": offset + 1636369200000,
"timezoneOffsetMinutes": 60
},
"endDate": {
"ts": offset + 1636372800000,
"timezoneOffsetMinutes": 60
},
"ownPlannings": null,
"teacherPlannings": null,
"teacherAndStudentPlannings": null,
"ownGeneralPlannings": null,
"teacherGeneralPlannings": null,
"teacherAndStudentGeneralPlannings": null,
"bookedResourceNames": [],
"bookedTeacherNames": [
"Ali Gupta (AGU)"
],
"hasTest": false,
"hasHomework": false,
"hasAssignment": false,
"url": null,
"note": ""
},
{
"id": "36080578:1",
"title": "NO",
"location": "P18",
"calendars": [
"138JÄTS 21/22 5A"
],
"startDate": {
"ts": offset + 1636373400000,
"timezoneOffsetMinutes": 60
},
"endDate": {
"ts": offset + 1636376400000,
"timezoneOffsetMinutes": 60
},
"ownPlannings": null,
"teacherPlannings": null,
"teacherAndStudentPlannings": null,
"ownGeneralPlannings": null,
"teacherGeneralPlannings": null,
"teacherAndStudentGeneralPlannings": null,
"bookedResourceNames": [],
"bookedTeacherNames": [
"Ali Gupta (AGU)"
],
"hasTest": false,
"hasHomework": false,
"hasAssignment": false,
"url": null,
"note": ""
}
]
} as any as Response
}
export const lessons_133737_goteborgsstad = () => (
{
"url": "https://hjarntorget.goteborg.se/api/schema/lessons?forUser=133737_goteborgsstad&startDateIso=2021-11-01&endDateIso=2021-11-08",
"headers": {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36",
"cookie": "REMOVED"
},
"status": 200,
"statusText": "200",
"json": () => Promise.resolve([] as any[])
}
) as any as Response

View File

@ -0,0 +1,41 @@
export const myChildren = () => ({
"url": "https://hjarntorget.goteborg.se/api/person/children",
"headers": {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36",
"cookie": "REMOVED"
},
"status": 200,
"statusText": "200",
"json": () => Promise.resolve([
{
// Klass: Förskola
"id": "133737_goteborgsstad",
"firstName": "Havin",
"lastName": "Göransson",
"email": null,
"online": false,
"imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png",
"extraInfoInCatalog": ""
},
{
// Klass: 8B
"id": "133700_goteborgsstad",
"firstName": "Azra",
"lastName": "Göransson",
"email": null,
"online": false,
"imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png",
"extraInfoInCatalog": ""
},
{
// Klass: 5A
"id": "123456_goteborgsstad",
"firstName": "Jon",
"lastName": "Göransson",
"email": null,
"online": false,
"imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png",
"extraInfoInCatalog": ""
}
])
}) as any as Response

View File

@ -0,0 +1,15 @@
export const genericRolesInEvent = () => ({
"url": "https://hjarntorget.goteborg.se/api/event-members/roles?eventId=XXX&language=en",
"headers": {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36",
"cookie": "REMOVED"
},
"status": 200,
"statusText": "200",
"json": () => Promise.resolve([
{
"id": 821,
"name": "SINGLE ROLE"
},
])
}) as any as Response

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
import { Features } from '@skolplattformen/api'
export const features: Features = {
LOGIN_BANK_ID_SAME_DEVICE_WITHOUT_ID: false,
FOOD_MENU: false,
CLASS_LIST: false,
}

View File

@ -0,0 +1,25 @@
import {
Api,
Fetch,
FetcherOptions,
RNCookieManager,
ToughCookieJar,
wrapReactNativeCookieManager,
wrapToughCookie,
} from '@skolplattformen/api'
import { ApiHjarntorget } from './apiHjarntorget'
export { features } from './features'
const init = (
fetchImpl: Fetch,
cookieManagerImpl: RNCookieManager | ToughCookieJar,
options?: FetcherOptions
): Api => {
// prettier-ignore
const cookieManager = ((cookieManagerImpl as RNCookieManager).get)
? wrapReactNativeCookieManager(cookieManagerImpl as RNCookieManager)
: wrapToughCookie(cookieManagerImpl as ToughCookieJar)
return new ApiHjarntorget(fetchImpl as any, cookieManager, options)
}
export default init

View File

@ -0,0 +1,122 @@
import { Fetcher, LoginStatusChecker } from '@skolplattformen/api'
import { EventEmitter } from 'events'
import {
extractAuthGbgLoginRequestBody,
extractHjarntorgetSAMLLogin,
} from './parse/parsers'
import {
authGbgLoginUrl,
hjarntorgetSAMLLoginUrl,
pollStatusUrl,
} from './routes'
export class HjarntorgetChecker extends EventEmitter {
private fetcher: Fetcher
private basePollingUrl: string
public token: string
private cancelled = false
constructor(fetcher: Fetcher, basePollingUrl: string) {
super()
this.token = '' // not used, but needed for compatability with the LoginStatusChecker
this.fetcher = fetcher
this.basePollingUrl = basePollingUrl
this.check()
}
async check(): Promise<void> {
try {
console.log('polling bankid signature')
// https://mNN-mg-local.idp.funktionstjanster.se/mg-local/auth/ccp11/grp/pollstatus
const pollStatusResponse = await this.fetcher(
'poll-bankid-status',
pollStatusUrl(this.basePollingUrl)
)
console.log('poll-bankid-status')
const pollStatusResponseJson = await pollStatusResponse.json()
const keepPolling = pollStatusResponseJson.infotext !== ''
const isError = pollStatusResponseJson.location.indexOf('error') >= 0
if (!keepPolling && !isError) {
console.log('bankid successfull! follow to location...')
// follow response location to get back to auth.goteborg.se
// r.location is something like:
// 'https://mNN-mg-local.idp.funktionstjanster.se/mg-local/auth/ccp11/grp/signature'
const signatureResponse = await this.fetcher(
'confirm-signature-redirect',
pollStatusResponseJson.location,
{
redirect: 'follow',
}
)
if (!signatureResponse.ok) {
throw new Error('Bad signature response')
}
const signatureResponseText = await signatureResponse.text()
const authGbgLoginBody = extractAuthGbgLoginRequestBody(
signatureResponseText
)
console.log('authGbg saml login')
const authGbgLoginResponse = await this.fetcher(
'authgbg-saml-login',
authGbgLoginUrl,
{
redirect: 'follow',
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: authGbgLoginBody,
}
)
if (!authGbgLoginResponse.ok) {
throw new Error('Bad augGbgLogin response')
}
const authGbgLoginResponseText = await authGbgLoginResponse.text()
const hjarntorgetSAMLLoginBody = extractHjarntorgetSAMLLogin(
authGbgLoginResponseText
)
console.log('hjarntorget saml login')
const hjarntorgetSAMLLoginResponse = await this.fetcher(
'hjarntorget-saml-login',
hjarntorgetSAMLLoginUrl,
{
method: 'POST',
redirect: 'follow',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: hjarntorgetSAMLLoginBody,
}
)
if (!hjarntorgetSAMLLoginResponse.ok) {
throw new Error('Bad hjarntorgetSAMLLogin response')
}
// TODO: add more checks above between calls to see if everything is actually 'OK'...
this.emit('OK')
} else if (isError) {
console.log('polling error')
this.emit('ERROR')
} else if (!this.cancelled && keepPolling) {
console.log('keep on polling...')
this.emit('PENDING')
setTimeout(() => this.check(), 3000)
}
} catch (er) {
console.log('Error validating login to Hjärntorget', er)
this.emit('ERROR')
}
}
async cancel(): Promise<void> {
this.cancelled = true
}
}
export const checkStatus = (
fetch: Fetcher,
basePollingUrl: string
): LoginStatusChecker => new HjarntorgetChecker(fetch, basePollingUrl)

View File

@ -0,0 +1 @@
declare module 'h2m'

View File

@ -0,0 +1,58 @@
import * as html from 'node-html-parser'
import { decode } from 'he'
// TODO: Move this into the parse folder and convert it to follow the pattern of other parsers (include tests).
export const extractInputField = (sought: string, attrs: string[]) => {
// there must be a better way to do this...
const s = attrs.find(e => e.indexOf(sought) >= 0) || ""
const v = s.substring(s.indexOf('value="') + 'value="'.length)
return v.substring(0, v.length - 2)
}
export function extractMvghostRequestBody(initBankIdResponseText: string) {
const doc = html.parse(decode(initBankIdResponseText))
const inputAttrs = doc.querySelectorAll('input').map(i => (i as any).rawAttrs)
const relayState = extractInputField('RelayState', inputAttrs)
const samlRequest = extractInputField("SAMLRequest", inputAttrs)
const mvghostRequestBody = `RelayState=${encodeURIComponent(relayState)}&SAMLRequest=${encodeURIComponent(samlRequest)}`
return mvghostRequestBody
}
export function extractHjarntorgetSAMLLogin(authGbgLoginResponseText: string) {
const authGbgLoginDoc = html.parse(decode(authGbgLoginResponseText))
const inputAttrs = authGbgLoginDoc.querySelectorAll('input').map(i => (i as any).rawAttrs)
const RelayStateText = extractInputField('RelayState', inputAttrs)
const SAMLResponseText = extractInputField("SAMLResponse", inputAttrs)
return `SAMLResponse=${encodeURIComponent(SAMLResponseText || '')}&RelayState=${encodeURIComponent(RelayStateText || '')}`
}
export function extractAuthGbgLoginRequestBody(signatureResponseText: string) {
const signatureResponseDoc = html.parse(decode(signatureResponseText))
const signatureResponseTextAreas = signatureResponseDoc.querySelectorAll('textarea')
const SAMLResponseElem = signatureResponseTextAreas.find(ta => {
const nameAttr = ta.getAttribute("name")
return nameAttr === 'SAMLResponse'
})
const SAMLResponseText = SAMLResponseElem?.rawText
const RelayStateElem = signatureResponseTextAreas.find(ta => {
const nameAttr = ta.getAttribute("name")
return nameAttr === 'RelayState'
})
const RelayStateText = RelayStateElem?.rawText
const authGbgLoginBody = `SAMLResponse=${encodeURIComponent(SAMLResponseText || '')}&RelayState=${encodeURIComponent(RelayStateText || '')}`
return authGbgLoginBody
}
export const parseCalendarItem = (x: html.HTMLElement): { id: number; title: string; startDate: string; endDate: string } => {
const info = Array.from(x.querySelectorAll('a'))
// TODO: the identifier is realy on this format: '\d+:\d+' currently we only take the first part so Id will clash between items
const id = info[0].getAttribute("onClick")?.replace(new RegExp("return viewEvent\\('(\\d+).+"), "$1") || NaN
const day = info[1].textContent
const timeSpan = info[2].textContent
const [startTime, endTime] = timeSpan.replace(".", ":").split("-")
return { id: +id, title: info[0].textContent, startDate: `${day} ${startTime}`, endDate: `${day} ${endTime}` }
}

View File

@ -0,0 +1,49 @@
export const lessonsUrl = (lessonParams: {forUser: string, startDateIso: string, endDateIso: string}) => {
const urlEncodedParams = new URLSearchParams(lessonParams).toString()
return `https://hjarntorget.goteborg.se/api/schema/lessons?${urlEncodedParams}`
}
export const hjarntorgetUrl = 'https://hjarntorget.goteborg.se'
export const currentUserUrl = 'https://hjarntorget.goteborg.se/api/core/current-user'
export const myChildrenUrl = 'https://hjarntorget.goteborg.se/api/person/children'
export const infoUrl = 'https://hjarntorget.goteborg.se/api/information/messages-by-date-desc?messageStatus=CURRENT&offset=0&limit=10&language=en'
export const fullImageUrl = (imagePath: string) => hjarntorgetUrl + imagePath;
export const infoSetReadUrl = (item: {id: string}) => `https://hjarntorget.goteborg.se/api/information/set-message-read?messageId=${item.id}`
export const hjarntorgetEventsUrl = 'https://hjarntorget.goteborg.se/api/events/events-sorted-by-name?offset=0&limit=100'
export const rolesInEventUrl = (eventId: number) => `https://hjarntorget.goteborg.se/api/event-members/roles?eventId=${eventId}&language=en`
export const membersWithRoleUrl = (eventId: number, roleId: string) => `https://hjarntorget.goteborg.se/api/event-members/members-having-role?eventId=${eventId}&roleId=${roleId}`
export const wallMessagesUrl = 'https://hjarntorget.goteborg.se/api/wall/events?language=en&limit=500'
export const beginLoginUrl = 'https://hjarntorget.goteborg.se'
export const calendarsUrl = 'https://hjarntorget.goteborg.se/pp/system/calendar/cal_events.jsp'
export const calendarEventUrl = (calendarId: string, startDate: string, endDate: string) => `${calendarsUrl}?order_by=start_date&show_cal_ids=${calendarId}&mode=separate&filter_start_date=${startDate}&filter_end_date=${endDate}&search_for=`
export const shibbolethLoginUrlBase = (beginLoginRedirectUrl: string) => {
const returnUrlStart = beginLoginRedirectUrl.indexOf('return=') + 'return='.length
return decodeURIComponent(beginLoginRedirectUrl.substring(returnUrlStart))
}
export const shibbolethLoginUrl = (returnUrl: string) => {
return `${returnUrl}&entityID=${encodeURIComponent('https://auth.goteborg.se/FIM/sps/HjarntorgetEID/saml20')}`
}
export const initBankIdUrl = (shibbolethRedirectUrl: any) => {
const targetParamIndex = shibbolethRedirectUrl.indexOf('Target=') + 'Target='.length
const targetParam = decodeURIComponent(shibbolethRedirectUrl.substring(targetParamIndex))
const initBankIdUrl = 'https://auth.goteborg.se/FIM/sps/BankID/saml20/logininitial?'
const initBankIdParams = `ITFIM_WAYF_IDP=${encodeURIComponent('https://m00-mg-local.idp.funktionstjanster.se/samlv2/idp/metadata/0/34')}` +
`&submit=Mobilt+BankID` +
`&ResponseBinding=HTTPPost` +
`&RequestBinding=HTTPPost` +
`&Target=${encodeURIComponent(targetParam)}`
return initBankIdUrl + initBankIdParams
}
export const mvghostUrl = 'https://m00-mg-local.idp.funktionstjanster.se/samlv2/idp/req/0/34?mgvhostparam=0'
export const beginBankIdUrl = (beingBankIdUrlBase: string) => `${beingBankIdUrlBase}/ssn`
export const verifyUrlBase = (verifyUrl: string) => verifyUrl.substring(0, verifyUrl.length - 'verify'.length)
export const pollStatusUrl = (basePollingUrl: string) => `${basePollingUrl}pollstatus`
export const authGbgLoginUrl = 'https://auth.goteborg.se/FIM/sps/BankID/saml20/login'
export const hjarntorgetSAMLLoginUrl = 'https://hjarntorget.goteborg.se/Shibboleth.sso/SAML2/POST'

View File

@ -0,0 +1,60 @@
{
"name": "@skolplattformen/api-hjarntorget",
"version": "0.15.0",
"description": "Since the proxy was blocked (and also deemed a bad idea by some), this is a reboot of the API running in process in the app(s).",
"main": "lib/index.ts",
"files": [
"dist/**/*"
],
"repository": "git@github.com:kolplattformen/embedded-api.git",
"author": "Johan Öbrink <johan.obrink@gmail.com>",
"license": "Apache-2.0",
"private": false,
"scripts": {
"lint": "eslint 'lib/**/*.{js,ts}' --quiet --fix",
"test": "jest",
"build": "tsc --build",
"prepare": "yarn build",
"run-dev": "yarn run build && node run",
"publish-package": "npm publish --access public"
},
"peerDependencies": {
"@skolplattformen/curriculum": "^1.3.0"
},
"devDependencies": {
"@react-native-cookies/cookies": "^6.0.7",
"@skolplattformen/curriculum": "^1.4.2",
"@types/base-64": "^1.0.0",
"@types/he": "^1.1.1",
"@types/jest": "^26.0.22",
"@types/luxon": "^1.26.4",
"@types/node-fetch": "^2.5.10",
"@types/tough-cookie": "^4.0.0",
"@typescript-eslint/eslint-plugin": "^4.22.0",
"@typescript-eslint/parser": "^4.22.0",
"eslint": "^7.24.0",
"eslint-config-airbnb-typescript": "^12.3.1",
"eslint-config-prettier": "^8.2.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-prettier": "^3.4.0",
"fetch-cookie": "^0.11.0",
"https-proxy-agent": "^5.0.0",
"jest": "^26.6.3",
"node-blob": "^0.0.2",
"node-fetch": "^2.6.1",
"prettier": "^2.2.1",
"tough-cookie": "^4.0.0",
"ts-jest": "^26.5.5",
"typescript": "^4.2.4"
},
"dependencies": {
"camelcase-keys": "^6.2.2",
"change-case": "^4.1.2",
"events": "^3.3.0",
"h2m": "^0.7.0",
"he": "^1.2.0",
"js-htmlencode": "^0.3.0",
"luxon": "^1.26.0",
"node-html-parser": "^2.1.0"
}
}

126
libs/api-hjarntorget/run.js Normal file
View File

@ -0,0 +1,126 @@
function requestLogger(httpModule) {
var original = httpModule.request
httpModule.request = function (options, callback) {
console.log('-----------------------------------------------')
console.log(
options.href || options.proto + '://' + options.host + options.path,
options.method
)
console.log(options.headers)
console.log('-----------------------------------------------')
return original(options, callback)
}
}
requestLogger(require('http'))
requestLogger(require('https'))
const { DateTime } = require('luxon')
const nodeFetch = require('node-fetch')
const { CookieJar } = require('tough-cookie')
const fetchCookie = require('fetch-cookie/node-fetch')
const { writeFile } = require('fs/promises')
const path = require('path')
const fs = require('fs')
const { inspect } = require('util')
const init = require('./dist/libs/api-hjarntorget/lib').default
const [, , personalNumber] = process.argv
if (!personalNumber) {
console.error(
'You must pass in a valid personal number, eg `node run 197001011111`'
)
process.exit(1)
}
function ensureDirectoryExistence(filePath) {
var dirname = path.dirname(filePath)
if (fs.existsSync(dirname)) {
return true
}
ensureDirectoryExistence(dirname)
fs.mkdirSync(dirname)
}
const record = async (info, data) => {
const name = info.error ? `${info.name}_error` : info.name
const filename = `./record/${name}.json`
ensureDirectoryExistence(filename)
const content = {
url: info.url,
headers: info.headers,
status: info.status,
statusText: info.statusText,
}
if (data) {
switch (info.type) {
case 'json':
content.json = data
break
case 'text':
content.text = data
break
case 'blob':
// eslint-disable-next-line no-case-declarations
const buffer = await data.arrayBuffer()
content.blob = Buffer.from(buffer).toString('base64')
break
}
} else if (info.error) {
const { message, stack } = info.error
content.error = {
message,
stack,
}
}
await writeFile(filename, JSON.stringify(content, null, 2))
}
async function run() {
const cookieJar = new CookieJar()
const fetch = fetchCookie(nodeFetch, cookieJar)
try {
const api = init(fetch, cookieJar, { record })
console.log("inited...")
api.on('login', async () => {
console.log("Logged in!")
await api.getUser()
const children = await api.getChildren()
const now = DateTime.fromJSDate(new Date)
for (let i = 0; i < children.length; i++) {
const c = children[i];
await api.getCalendar(c)
await api.getNotifications(c)
await api.getTimetable(c, 44, 2021, 'ignored')
}
const news = await api.getNews()
// const news = await api.getNews()
// //console.table(news.map(n => ({ id: n.id, author: n.author, published: n.published})))
// //news.length && console.log(news[0])
// const notifications = await api.getNotifications(children[2])
// //const ns = notifications.map(n => ({id: n.id, sender: n.sender, type: n.type}))
// //console.table(ns)
// console.log("notifications count", notifications.length)
// notifications.slice(0, 10).forEach(console.log)
// await api.getCalendar(children[1])
// await api.getTimetable(children[1], 38, 2021, "en")
// await api.getClassmates()
// console.table(schema)
});
const res = await api.login(personalNumber)
console.log(res)
} catch (err) {
console.error(err)
}
}
run()

View File

@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["**/*.ts", "**/*.js"],
"exclude": ["node_modules"]
}

View File

@ -0,0 +1,20 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES6",
"module": "CommonJS",
"declaration": true,
"outDir": "./dist",
"strict": true,
"allowSyntheticDefaultImports": true,
"sourceMap": true
},
"include": [
"lib"
],
"exclude": [
"node_modules",
"**/__tests__/*",
"**/__mocks__/*"
]
}

File diff suppressed because it is too large Load Diff

View File

@ -104,3 +104,5 @@ dist
.tern-port
record
dist/*

View File

@ -0,0 +1,81 @@
import { CookieJar, Cookie as TCookie } from 'tough-cookie'
export interface Cookie {
name: string
value: string
path?: string
domain?: string
version?: string
expires?: string
secure?: boolean
httpOnly?: boolean
}
export interface Cookies {
[key: string]: Cookie
}
export interface CookieManagerStatic {
set(url: string, cookie: Cookie, useWebKit?: boolean): Promise<boolean>
setFromResponse(url: string, cookie: string): Promise<boolean>
get(url: string, useWebKit?: boolean): Promise<Cookies>
clearAll(useWebKit?: boolean): Promise<boolean>
}
const convertTtoC = (cookie: string | TCookie): Cookie => {
if (typeof cookie === 'string') {
return convertTtoC(TCookie.parse(cookie) as TCookie)
}
return {
name: cookie.key,
value: cookie.value,
domain: cookie.domain || undefined,
expires:
cookie.expires === 'Infinity' ? undefined : cookie.expires.toUTCString(),
httpOnly: cookie.httpOnly || undefined,
path: cookie.path || undefined,
secure: cookie.secure,
}
}
const convertCtoT = (cookie: Cookie): TCookie =>
new TCookie({
key: cookie.name,
value: cookie.value,
domain: cookie.domain,
expires: cookie.expires ? new Date(cookie.expires) : undefined,
httpOnly: cookie.httpOnly || false,
path: cookie.path,
secure: cookie.secure || false,
})
const convertCookies = (cookies: TCookie[]): Cookies =>
cookies.reduce(
(map, cookie) => ({
...map,
[cookie.key]: convertTtoC(cookie),
}),
{} as Cookies
)
const jar = new CookieJar()
const CookieManager: CookieManagerStatic = {
clearAll: async () => {
await jar.removeAllCookies()
return true
},
get: async (url) => {
const cookies = await jar.getCookies(url)
return convertCookies(cookies)
},
set: async (url, cookie) => {
await jar.setCookie(convertCtoT(cookie), url)
return true
},
setFromResponse: async (url, cookie) => {
await jar.setCookie(cookie, url)
return true
},
}
export default CookieManager

View File

@ -19,7 +19,7 @@ const path = require('path')
const fs = require('fs')
const HttpProxyAgent = require('https-proxy-agent')
const agentWrapper = require('./agentFetchWrapper')
const init = require('./dist').default
const init = require('./dist/api-skolplattformen/lib').default
const [, , personalNumber] = process.argv
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'

View File

@ -1,13 +1,15 @@
import CookieManager from '@react-native-cookies/cookies'
import init from './'
import { Api } from './api'
import { Fetch, Headers, Response } from './types'
import { ApiSkolplattformen } from './api'
import { Fetch, Headers, Response } from '@skolplattformen/api'
import CookieManager from '@react-native-cookies/cookies'
jest.mock('@react-native-cookies/cookies')
describe('api', () => {
let fetch: jest.Mocked<Fetch>
let response: jest.Mocked<Response>
let headers: jest.Mocked<Headers>
let api: Api
let api: ApiSkolplattformen
beforeEach(() => {
headers = { get: jest.fn() }
response = {
@ -21,7 +23,7 @@ describe('api', () => {
fetch = jest.fn().mockResolvedValue(response)
response.text.mockResolvedValue('<html></html>')
CookieManager.clearAll()
api = init(fetch, CookieManager)
api = init(fetch, CookieManager) as ApiSkolplattformen
})
describe('#login', () => {
it('exposes token', async () => {
@ -110,6 +112,7 @@ describe('api', () => {
const personalNumber = 'my personal number'
try {
await api.login(personalNumber)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
expect(error.message).toEqual(expect.stringContaining('Server Error'))
}

View File

@ -1,32 +1,36 @@
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 {
Api,
AuthTicket,
CalendarItem,
Classmate,
CookieManager,
EtjanstChild,
Fetch,
Fetcher,
FetcherOptions,
LoginStatusChecker,
MenuItem,
NewsItem,
Notification,
RequestInit,
Response,
ScheduleItem,
Skola24Child,
SSOSystem,
TimetableEntry,
URLSearchParams,
User,
Response,
} from './types'
import { URLSearchParams } from './URLSearchParams'
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 } from './loginStatusChecker'
import * as parse from './parse/index'
import * as routes from './routes'
const fakeResponse = <T>(data: T): Promise<T> =>
new Promise((res) => setTimeout(() => res(data), 200 + Math.random() * 800))
@ -49,11 +53,12 @@ interface SSOSystems {
[name: string]: boolean | undefined
}
export class Api extends EventEmitter {
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
@ -65,7 +70,7 @@ export class Api extends EventEmitter {
private authorizedSystems: SSOSystems = {}
constructor(
fetch: Fetch,
fetch: Fetch, // typeof global.fetch,
cookieManager: CookieManager,
options?: FetcherOptions
) {
@ -198,6 +203,7 @@ export class Api extends EventEmitter {
this.emit('login')
}, 50)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const emitter: any = new EventEmitter()
emitter.token = 'fake'
return emitter
@ -284,10 +290,17 @@ export class Api extends EventEmitter {
}
// eslint-disable-next-line class-methods-use-this
private CheckResponseForCorrectChildStatus(response: Response, child: EtjanstChild) {
const setCookieResp = response.headers.get("Set-Cookie")
private CheckResponseForCorrectChildStatus(
response: Response,
child: EtjanstChild
) {
const setCookieResp = response.headers.get('Set-Cookie')
if (child.status !== 'FS' && setCookieResp && setCookieResp.includes("Status=FS")) {
if (
child.status !== 'FS' &&
setCookieResp &&
setCookieResp.includes('Status=FS')
) {
throw new Error('Wrong child in response')
}
}
@ -295,7 +308,7 @@ export class Api extends EventEmitter {
public async getNewsDetails(
child: EtjanstChild,
item: NewsItem
): Promise<NewsItem> {
): Promise<NewsItem | undefined> {
if (this.isFake) {
return fakeResponse(fake.news(child).find((ni) => ni.id === item.id) || {id: "", published: ""})
}

View File

@ -11,7 +11,7 @@ import {
Skola24Child,
TimetableEntry,
User,
} from './types'
} from '@skolplattformen/api';
const data: any = {
'39b59e-bf4b9f-f68ac25321-977218-bf0': {
@ -1234,7 +1234,7 @@ export const timetable = (child: Skola24Child): TimetableEntry[] => {
category: 'Moderna språk, språkval',
blockName: '',
dayOfWeek: 2,
location: '302,Fjärr',
location: '302,Fjärr asd asdasd asdad aasdds',
teacher: 'DNi',
timeEnd: '09:50:00',
timeStart: '09:05:00',

View File

@ -0,0 +1,7 @@
import { Features } from '@skolplattformen/api'
export const features: Features = {
LOGIN_BANK_ID_SAME_DEVICE_WITHOUT_ID: true,
FOOD_MENU: true,
CLASS_LIST: true,
}

View File

@ -1,16 +1,13 @@
import { Api } from './api'
import {
Api,
FetcherOptions,
RNCookieManager,
ToughCookieJar,
wrapReactNativeCookieManager,
wrapToughCookie,
} from './cookies'
import { FetcherOptions } from './fetcher'
import { Fetch } from './types'
export { LoginStatusChecker } from './loginStatus'
export * from './types'
export { Api, FetcherOptions }
} from '@skolplattformen/api'
import { ApiSkolplattformen } from './api'
export { features } from './features'
const init = (
fetchImpl: Fetch,
@ -21,7 +18,7 @@ const init = (
const cookieManager = ((cookieManagerImpl as RNCookieManager).get)
? wrapReactNativeCookieManager(cookieManagerImpl as RNCookieManager)
: wrapToughCookie(cookieManagerImpl as ToughCookieJar)
return new Api(fetchImpl, cookieManager, options)
return new ApiSkolplattformen(fetchImpl as any, cookieManager, options)
}
export default init

View File

@ -1,7 +1,6 @@
import { EventEmitter } from 'events'
import { Fetcher } from './fetcher'
import { loginStatus } from './routes'
import { AuthTicket } from './types'
import { Fetcher, AuthTicket } from '@skolplattformen/api'
/*
export enum LoginEvent {

View File

@ -0,0 +1,43 @@
import { EventEmitter } from 'events';
import { loginStatus } from './routes';
import { AuthTicket, Fetcher, LoginStatusChecker } from '@skolplattformen/api';
export class Checker extends EventEmitter {
public token: string;
private fetcher: Fetcher;
private url: string;
private cancelled = false;
constructor(fetcher: Fetcher, ticket: AuthTicket) {
super();
this.fetcher = fetcher;
this.url = loginStatus(ticket.order);
this.token = ticket.token;
this.check();
}
async check(): Promise<void> {
const response = await this.fetcher('login-status', this.url);
const status = await response.text();
this.emit(status);
if (!this.cancelled &&
status !== 'OK' &&
status !== 'ERROR!' &&
status !== 'CANCELLED') {
setTimeout(() => this.check(), 1000);
}
}
async cancel(): Promise<void> {
this.cancelled = true;
}
}
export const checkStatus = (
fetch: Fetcher,
ticket: AuthTicket
): LoginStatusChecker => new Checker(fetch, ticket)

View File

@ -1,6 +1,5 @@
import { etjanst } from './etjanst'
import { CalendarItem } from '../types'
import { parseDate } from '../utils/dateHandling'
import { CalendarItem, parseDate } from '@skolplattformen/api'
export const calendarItem = ({
id,

View File

@ -1,5 +1,5 @@
import { Child } from '@skolplattformen/api'
import { etjanst } from './etjanst'
import { Child } from '../types'
export const child = ({ id, sdsId, name, status, schoolId }: any): Child => ({
id,

View File

@ -1,5 +1,5 @@
import { Classmate, Guardian } from '@skolplattformen/api'
import { etjanst } from './etjanst'
import { Classmate, Guardian } from '../types'
export const guardian = ({
emailhome,

View File

@ -1,6 +1,6 @@
import { MenuItem, MenuList, toMarkdown } from '@skolplattformen/api'
import { etjanst } from './etjanst'
import { toMarkdown } from '../parseHtml'
import { MenuItem, MenuList } from '../types'
export const menuItem = ({ title, description }: any): MenuItem => ({
title,

View File

@ -1,7 +1,6 @@
import { NewsItem, parseDate, toMarkdown } from '@skolplattformen/api';
import { etjanst } from './etjanst'
import { toMarkdown } from '../parseHtml'
import { NewsItem } from '../types'
import { parseDate } from '../utils/dateHandling'
const IMAGE_HOST =
'https://etjanst.stockholm.se/Vardnadshavare/inloggad2/NewsBanner?url='

View File

@ -1,6 +1,5 @@
import { parseDate, Notification } from '@skolplattformen/api'
import { etjanst } from './etjanst'
import { parseDate } from '../utils/dateHandling'
import { Notification } from '../types'
export const notification = ({
notification: { messageid, dateCreated, dateModified },

View File

@ -1,6 +1,5 @@
import { parseDate, ScheduleItem } from '@skolplattformen/api'
import { etjanst } from './etjanst'
import { ScheduleItem } from '../types'
import { parseDate } from '../utils/dateHandling'
export const scheduleItem = ({
title,

View File

@ -1,6 +1,6 @@
import { TimetableEntry } from '@skolplattformen/api'
import parse, { Language } from '@skolplattformen/curriculum'
import { DateTime } from 'luxon'
import { TimetableEntry } from '../types'
const calculateDate = (
year: number,

View File

@ -1,4 +1,4 @@
import { User } from '../types'
import { User } from "@skolplattformen/api";
export const user = ({
socialSecurityNumber,

View File

@ -6,14 +6,14 @@
"files": [
"dist/**/*"
],
"repository": "git@github.com:kolplattformen/embedded-api.git",
"author": "Johan Öbrink <johan.obrink@gmail.com>",
"repository": "git@github.com:kolplattformen/skolplattformen.git",
"author": "ÖS <info@skolplattformen.org>",
"license": "Apache-2.0",
"private": false,
"scripts": {
"lint": "eslint 'lib/**/*.{js,ts}' --quiet --fix",
"test": "jest",
"build": "tsc",
"build": "tsc --build",
"prepare": "yarn build",
"run-dev": "yarn run build && node run",
"publish-package": "npm publish --access public"

View File

@ -24,7 +24,7 @@ const path = require('path')
const fs = require('fs')
const { inspect } = require('util')
const init = require('./dist').default
const init = require('./dist/api-skolplattformen/lib').default
const [, , personalNumber] = process.argv

View File

@ -1,23 +1,20 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"allowJs": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"target": "ES6",
"module": "CommonJS",
"declaration": true,
"outDir": "./dist",
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
"allowSyntheticDefaultImports": true,
"sourceMap": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
"include": [
"lib"
],
"exclude": [
"node_modules",
"**/__tests__/*",
"**/__mocks__/*",
]
}
}

View File

@ -2,7 +2,7 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["node"]
"types": ["node"],
},
"exclude": ["**/*.spec.ts", "**/*.spec.tsx"],
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]

View File

@ -3,7 +3,8 @@
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
"types": ["jest", "node"],
"composite": true
},
"include": [
"**/*.spec.ts",

6
libs/api/.babelrc.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
presets: [
['@babel/preset-env', {targets: {node: 'current'}}],
'@babel/preset-typescript',
],
}

23
libs/api/.eslintrc Normal file
View File

@ -0,0 +1,23 @@
{
"extends": ["plugin:@nrwl/nx/react", "../../.eslintrc.json"],
"ignorePatterns": ["!**/*", "public", ".cache", "node_modules"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {
"@typescript-eslint/ban-ts-comment": "off"
}
},
{
"files": ["*.js", "*.jsx"],
"rules": {
"@typescript-eslint/no-var-requires": "off"
}
}
]
}

108
libs/api/.gitignore vendored Normal file
View File

@ -0,0 +1,108 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
record
dist/*

10
libs/api/.prettierrc Normal file
View File

@ -0,0 +1,10 @@
{
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"semi": false,
"singleQuote": true,
"trailingComma": "es5",
"bracketSpacing": true,
"jsxBracketSameLine": false
}

3
libs/api/.releaserc Normal file
View File

@ -0,0 +1,3 @@
{
"branches": ["main"]
}

201
libs/api/LICENSE Normal file
View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -0,0 +1,81 @@
import { CookieJar, Cookie as TCookie } from 'tough-cookie'
export interface Cookie {
name: string
value: string
path?: string
domain?: string
version?: string
expires?: string
secure?: boolean
httpOnly?: boolean
}
export interface Cookies {
[key: string]: Cookie
}
export interface CookieManagerStatic {
set(url: string, cookie: Cookie, useWebKit?: boolean): Promise<boolean>
setFromResponse(url: string, cookie: string): Promise<boolean>
get(url: string, useWebKit?: boolean): Promise<Cookies>
clearAll(useWebKit?: boolean): Promise<boolean>
}
const convertTtoC = (cookie: string | TCookie): Cookie => {
if (typeof cookie === 'string') {
return convertTtoC(TCookie.parse(cookie) as TCookie)
}
return {
name: cookie.key,
value: cookie.value,
domain: cookie.domain || undefined,
expires:
cookie.expires === 'Infinity' ? undefined : cookie.expires.toUTCString(),
httpOnly: cookie.httpOnly || undefined,
path: cookie.path || undefined,
secure: cookie.secure,
}
}
const convertCtoT = (cookie: Cookie): TCookie =>
new TCookie({
key: cookie.name,
value: cookie.value,
domain: cookie.domain,
expires: cookie.expires ? new Date(cookie.expires) : undefined,
httpOnly: cookie.httpOnly || false,
path: cookie.path,
secure: cookie.secure || false,
})
const convertCookies = (cookies: TCookie[]): Cookies =>
cookies.reduce(
(map, cookie) => ({
...map,
[cookie.key]: convertTtoC(cookie),
}),
{} as Cookies
)
const jar = new CookieJar()
const CookieManager: CookieManagerStatic = {
clearAll: async () => {
await jar.removeAllCookies()
return true
},
get: async (url) => {
const cookies = await jar.getCookies(url)
return convertCookies(cookies)
},
set: async (url, cookie) => {
await jar.setCookie(convertCtoT(cookie), url)
return true
},
setFromResponse: async (url, cookie) => {
await jar.setCookie(cookie, url)
return true
},
}
export default CookieManager

25
libs/api/config.json Normal file
View File

@ -0,0 +1,25 @@
{
"headers": {
"accept": "text/plain",
"accept-language": "en-GB,en-SE;q=0.9,en;q=0.8,sv-SE;q=0.7,sv;q=0.6,en-US;q=0.5",
"access-control-allow-origin": "*",
"cache-control": "no-cache",
"content-type": "text/plain",
"pragma": "no-cache",
"sec-ch-ua": "\"Google Chrome\";v=\"89\", \"Chromium\";v=\"89\", \";Not A Brand\";v=\"99\"",
"sec-ch-ua-mobile": "?0",
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-site",
"x-xsrf-token": "SfONpuvKXD1XHML3Kelvm3easB6Xn3xtbVPG52jdpc3Q7sRxJv7_6wfjo1qS3NOQWkfCvfPkJpJg0QIBmo358o7FdQY2aWvUOxA9MU2Fl0E1",
"y-xsrf-token11": "FyXUbtZUE2iT09J7FOLTpfZ_onjbj3WEIO6jOY9B1KaZzMrAs4WS03AuWbQhmKyCEX2inTPVDzyPc58tN2EM4L1vYD6aH_zhlc7gVo9jaPdLKQc4qnE6ue184cSamKE0",
"topology-key": "labor matter federal|",
"topology-short-key": "assumeoutside",
"topology-base64-iterations": "8"
},
"referrer": "https://etjanst.stockholm.se/",
"referrerPolicy": "strict-origin-when-cross-origin",
"body": "XVDf/EliJ/oZH9BRlRCMNds2jCRcTL8/isnpuj2wD6wH1lxX/cHY/AM6XJ8nweGne+FAPgcpj+blQ+dQvvmiJfK4t0u66tg8L60ysfDs/eBeoA794lvvtwRwJ946VUahZG89Al7UFkx5Ew1AGp4yuJ38drNDK4J5RAUGvzOWTmniZnSYs9P5UR2SWP39NcOoovwZsce7tRigdusI8sSDSUh+lVDkwfERfQqe3oG+FPiGQsfeFd2y/5f8chxU9VQz4oF7BLiP69xBPJr2KFkQc7MJaqEQy87loe0vwehRN/lOP858pPiVfc96M2jc0+yQEgnUBXPgQmFVC6CIHfQ0Mg==",
"method": "POST",
"mode": "cors"
}

9
libs/api/jest.config.js Normal file
View File

@ -0,0 +1,9 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
transform: {
'.(ts|tsx)': 'ts-jest',
},
testRegex: '(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$',
moduleFileExtensions: ['ts', 'tsx', 'js'],
}

36
libs/api/lib/api.ts Normal file
View File

@ -0,0 +1,36 @@
import { Language } from '@skolplattformen/curriculum'
import { EventEmitter } from 'events'
import { DateTime } from 'luxon'
import { LoginStatusChecker } from './loginStatus'
import {
CalendarItem,
Classmate,
MenuItem,
NewsItem,
Notification,
User,
Skola24Child,
EtjanstChild,
TimetableEntry,
ScheduleItem,
} from './types'
export interface Api extends EventEmitter {
isFake: boolean
isLoggedIn: boolean
getPersonalNumber(): string | undefined
login(personalNumber?: string): Promise<LoginStatusChecker>
setSessionCookie(sessionCookie: string): Promise<void>
getUser(): Promise<User>
getChildren(): Promise<EtjanstChild[]>
getCalendar(child: EtjanstChild): Promise<CalendarItem[]>
getClassmates(child: EtjanstChild): Promise<Classmate[]>
getNews(child: EtjanstChild): Promise<NewsItem[]>
getNewsDetails(child: EtjanstChild, item: NewsItem): Promise<any>
getMenu(child: EtjanstChild): Promise<MenuItem[]>
getNotifications(child: EtjanstChild): Promise<Notification[]>
getSchedule(child: EtjanstChild, from: DateTime, to: DateTime): Promise<ScheduleItem[]>
getSkola24Children(): Promise<Skola24Child[]>
getTimetable(child: Skola24Child, week: number, year: number, lang: Language): Promise<TimetableEntry[]>
logout(): Promise<void>
}

7
libs/api/lib/features.ts Normal file
View File

@ -0,0 +1,7 @@
export interface Features {
LOGIN_BANK_ID_SAME_DEVICE_WITHOUT_ID: boolean
FOOD_MENU: boolean,
CLASS_LIST: boolean
}
export type FeatureType = keyof Features

View File

@ -1,4 +1,4 @@
import { Fetch, RequestInit, Response } from './types'
import { Response, Fetch, RequestInit } from './types'
export interface CallInfo extends RequestInit {
name: string

18
libs/api/lib/index.ts Normal file
View File

@ -0,0 +1,18 @@
import wrap from './fetcher'
export { toMarkdown } from './parseHtml'
export * from './types'
export { LoginStatusChecker } from './loginStatus'
export { Api } from './api'
export { FetcherOptions, Fetcher } from './fetcher'
export {
RNCookieManager,
ToughCookieJar,
wrapReactNativeCookieManager,
wrapToughCookie,
} from './cookies'
export { URLSearchParams } from './URLSearchParams'
export { wrap };
export { FeatureType, Features } from './features'
export * from './utils/dateHandling';

View File

@ -0,0 +1,17 @@
/*
export enum LoginEvent {
OK = 'OK',
PENDING = 'PENDING',
ERROR = 'ERROR',
USER_SIGN = 'USER_SIGN',
}
*/
export interface LoginStatusChecker {
token: string
on: (
event: 'OK' | 'PENDING' | 'ERROR' | 'USER_SIGN' | 'CANCELLED',
listener: (...args: any[]) => void
) => LoginStatusChecker
cancel: () => Promise<void>
}

Some files were not shown because too many files have changed in this diff Show More