├── .dockerignore
├── .gitignore
├── .gitmodules
├── LICENSE
├── README.md
├── babel.config.js
├── example.env.local
├── package.json
├── public
├── OpenGraphTICEimg.png
├── _headers
├── android-chrome-192x192.png
├── android-chrome-512x512.png
├── app-store-download.png
├── apple-app-site-association
├── apple-touch-icon.png
├── browserconfig.xml
├── de
│ └── index.html
├── en
│ └── index.html
├── favicon-16x16.png
├── favicon-32x32.png
├── favicon.ico
├── logo-vstack-wob.png
├── mapbg.jpg
├── mstile-150x150.png
├── safari-pinned-tab.svg
├── site.webmanifest
├── tice_featured.png
└── tice_logo_hstack.png
├── src
├── App.vue
├── components
│ ├── About.vue
│ ├── Chat.vue
│ ├── GroupInfo.vue
│ ├── Map.vue
│ ├── MapMarkers.vue
│ ├── ShareLocationButton.vue
│ ├── TitleBar.vue
│ └── WelcomeBox.vue
├── lang
│ ├── de.json
│ └── en.json
├── main.js
└── utils
│ ├── APIRequestManager.js
│ ├── Beekeeper.js
│ ├── CryptoManager.js
│ ├── FlowManager.js
│ ├── GroupMemberManager.js
│ ├── Logger.js
│ ├── countriesBoundingBoxes.json
│ ├── i18n.js
│ └── iso3316.json
├── test
├── README.md
├── local.test.js
├── remote.test.js
├── scripts
│ ├── startServer.sh
│ └── stopServer.sh
└── test.js
├── vue.config.js
├── web.Dockerfile
└── yarn.lock
/.dockerignore:
--------------------------------------------------------------------------------
1 | .git
2 | !.git/HEAD
3 | !.git/refs
4 | *.Dockerfile
5 | .idea
6 | dist
7 | node_modules
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /dist
4 |
5 | # local env files
6 | .env.local
7 | .env.*.local
8 |
9 | # Log files
10 | npm-debug.log*
11 | yarn-debug.log*
12 | yarn-error.log*
13 |
14 | # Editor directories and files
15 | .idea
16 | .vscode
17 | *.suo
18 | *.ntvs*
19 | *.njsproj
20 | *.sln
21 | *.sw?
22 |
23 | AnbionBot
24 | .poeditor.json
25 |
26 | # CI test files
27 | cnc_server.*
28 | server.*
29 | web_app.*
30 | test-results.xml
31 |
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "CnC"]
2 | path = CnC
3 | url = git@github.com:TICESoftware/tice-cnc-server.git
4 | [submodule "Server"]
5 | path = Server
6 | url = git@github.com:TICESoftware/tice-server.git
7 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | # TICE – Locate Friends & Meet Up
4 |  
5 |
6 | The secure app for meeting up, sharing locations and locating friends and family in real-time. TICE allows live location sharing on iOS, Android and the Web. Privacy by Design and modern end-to-end-encryption protect the sensitive location data from others.
7 |
8 | ## Download
9 |
10 | To use the web app, one of you needs to have the [Android](https://play.google.com/store/apps/details?id=app.tice.TICE.production) or [iOS](https://apps.apple.com/us/app/tice-secure-location-sharing/id1494324936) app to create a group with a link. You can either open this link in your mobile app or in the browser to use the web app.
11 |
12 | # Open Source Development Goals
13 |
14 | TICE is a simple but powerful tool to share you location in real time with friends, family and others. Knowing the location of others can be an efficient way for meeting up, it can help find each other and provides a safe way to know, a close friend or family reaches their destination safely.
15 |
16 | ## 1. Security and transparency
17 |
18 | As location information tells a lot about the person, access to it needs to be safeguarded. TICE therefor tries to find a good balance between a practical tool and a safe place for its users location, messaging and meta data. TICE follows the privacy by design path. This means, that we collect only the minimal amount of information needed, encrypting sensitive data in a way that we don't have access to it and be transparent by disclosing the source code behind TICE.
19 |
20 | ## 2. Grow further
21 |
22 | We put a lot of effort into TICE. By open sourcing it, we want it to grow even further – instead of getting stuck. As the company behind TICE, we will focus on other projects in the future. That is why TICE needs you and your contribution.
23 |
24 | ## 3. Feature rich & living
25 |
26 | TICE should be a living project and improve over time. The distributed apps over the app stores should always be up to date and accompany the operating system development. There are a lot of features missing from TICE and we want to build those together with the open source community.
27 |
28 | # Contribute to TICE
29 |
30 | This section explains, where and how you can contribute to TICE.
31 |
32 | ## Build instructions
33 |
34 | TICE web app has several dependencies. It is using yarn for the dependency management.
35 |
36 | ### Install all dependencies
37 | ```bash
38 | $ yarn install
39 | ```
40 |
41 | Now you can open the project directory in your favourite editor and you can start right away. Unfortunately, the (for now private) submodules `Server` and `CnC` are needed for testing, but not to build or run the application.
42 |
43 | ### Run TICE on your device
44 | In order to run TICE you need access to a TICE server, which is not public yet.
45 |
46 | ## Architecture
47 |
48 | TICE web represents the web app for TICE and is built using [Vue.js](https://vuejs.org). It interacts with the TICE server (backend) by using a JSON REST API.
49 |
50 | The UI is based on [Element](https://element.eleme.io/#/en-US), using [Mapbox](https://mapbox.com) to display a map.
51 |
52 | All UI components are defined in the `src/components` folder or in the `src/App.vue` file. Further helper functions are defined in `src/utils`, e.g. the encryption, server requests etc.
53 |
54 | This version of the web app was written as a prototype and proof-of-concept, so the quality of the code varies a lot. The architecture is not optimal and some code is quite bad, so a complete rewrite will probably be necessary in the future. Another goal is to transform the app to using TypeScript.
55 |
56 | ## Bugs
57 |
58 | File any bugs you find by filing a GitHub issue with:
59 | - Device and browser information
60 | - Repro steps
61 | - Observed behavior (including screenshot / video when possible)
62 | - Timestamp and email address used to send the logs (see below)
63 | - Expected behavior
64 |
65 | and **send the logs** via email in the web app by clicking on the logo at the bottom left and on the button `Give Feedback`.
66 |
67 | # License
68 |
69 | ## Core Collaborators
70 |
71 | - [Andreas Ganske](https://github.com/ChaosCoder)
72 | - [Fabio Tacke](https://github.com/FabioTacke)
73 | - [Simon Kempendorf](https://github.com/code28)
74 |
75 | ## Copyright
76 |
77 | Copyright © 2019 [TICE Software UG (haftungsbeschränkt)](https://tice.software). All rights reserved.
78 |
79 | The source code is licensed under [GNU General Public License v3.0](LICENSE). TICE is a [registered trademark](https://euipo.europa.eu/eSearch/#details/trademarks/018132140) of the TICE Software UG (haftungsbeschränkt).
80 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | '@vue/app',
4 | ],
5 | plugins: [['component', {
6 | libraryName: 'element-ui',
7 | styleLibraryName: 'theme-chalk',
8 | },
9 | ]],
10 | };
11 |
--------------------------------------------------------------------------------
/example.env.local:
--------------------------------------------------------------------------------
1 | VUE_APP_API_URL=url-to-api-server.com
2 | VUE_APP_USE_TLS=true
3 | VUE_APP_MAPBOX_API_KEY=mapbox-api-key
4 | VUE_APP_BEEKEEPER_PRODUCT=beekeeper-product
5 | VUE_APP_BEEKEEPER_SECRET=beekeeper-secret
6 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tice-web",
3 | "version": "2.1.0",
4 | "private": true,
5 | "repository": {
6 | "type": "git",
7 | "url": "https://github.com/TICESoftware/tice-web.git"
8 | },
9 | "author": {
10 | "name": "TICE Software UG (haftungsbeschraenkt)",
11 | "email": "contact@tice-software.com",
12 | "url": "https://tice.software"
13 | },
14 | "scripts": {
15 | "serve": "vue-cli-service serve",
16 | "build": "vue-cli-service build && cp dist/en/index.html dist/",
17 | "lint:vue": "vue-cli-service lint --no-fix",
18 | "lint:tests": "eslint --no-fix test",
19 | "lint": "yarn run lint:vue && yarn run lint:tests",
20 | "test:local": "mocha test/local.test.js --timeout 60000",
21 | "test:remote": "mocha test/remote.test.js",
22 | "test:ci": "test/scripts/startServer.sh && yarn run test:local --reporter mocha-junit-reporter && test/scripts/stopServer.sh || test/scripts/stopServer.sh",
23 | "docker": "docker build -t tice-web -f web.Dockerfile .",
24 | "audit": "yarn audit --frozen-lockfile --level high --groups dependencies"
25 | },
26 | "dependencies": {
27 | "@fortawesome/fontawesome-svg-core": "^1.3.0",
28 | "@fortawesome/free-brands-svg-icons": "^6.2.0",
29 | "@fortawesome/vue-fontawesome": "^2.0.8",
30 | "core-js": "^3.26.0",
31 | "date-fns": "^2.29.3",
32 | "double-ratchet-ts": "^1.0.1",
33 | "element-ui": "^2.15.10",
34 | "elliptic": "^6.5.4",
35 | "jssha": "^3.3.0",
36 | "key-encoder": "^2.0.3",
37 | "libsodium-wrappers": "^0.7.10",
38 | "mapbox-gl": "^2.10.0",
39 | "sodium-hkdf": "^1.0.0",
40 | "vue": "^2.7.13",
41 | "vue-beautiful-chat": "^2.5.0",
42 | "vue-i18n": "^8.27.2",
43 | "vue-mapbox": "^0.4.1",
44 | "vue-timeago": "^5.1.3",
45 | "x3dh": "^1.0.0"
46 | },
47 | "devDependencies": {
48 | "@types/libsodium-wrappers": "^0.7.10",
49 | "@vue/cli-plugin-babel": "^4.5.19",
50 | "@vue/cli-plugin-eslint": "^4.5.19",
51 | "@vue/cli-service": "^4.5.19",
52 | "@vue/eslint-config-airbnb": "^5.3.0",
53 | "axios": "^0.27.2",
54 | "babel-eslint": "^10.1.0",
55 | "babel-plugin-component": "^1.1.1",
56 | "chai": "^4.3.6",
57 | "chromedriver": "^106.0.1",
58 | "eslint": "^8.26.0",
59 | "eslint-plugin-import": "^2.26.0",
60 | "eslint-plugin-vue": "^9.6.0",
61 | "geckodriver": "^3.2.0",
62 | "html-webpack-plugin": "^4.5.2",
63 | "mocha": "^10.1.0",
64 | "mocha-junit-reporter": "^2.1.0",
65 | "node-sass": "^7.0.3",
66 | "sass-loader": "^13.1.0",
67 | "selenium-webdriver": "^4.5.0",
68 | "vue-template-compiler": "^2.7.13"
69 | },
70 | "eslintConfig": {
71 | "root": true,
72 | "env": {
73 | "node": true
74 | },
75 | "extends": [
76 | "plugin:vue/essential",
77 | "eslint:recommended",
78 | "airbnb-base"
79 | ],
80 | "rules": {
81 | "indent": [
82 | "error",
83 | 4
84 | ],
85 | "max-len": "off",
86 | "import/no-unresolved": "off",
87 | "no-param-reassign": "off"
88 | },
89 | "parserOptions": {
90 | "parser": "babel-eslint"
91 | }
92 | },
93 | "postcss": {
94 | "plugins": {
95 | "autoprefixer": {}
96 | }
97 | },
98 | "browserslist": [
99 | "> 1%",
100 | "last 2 versions"
101 | ]
102 | }
103 |
--------------------------------------------------------------------------------
/public/OpenGraphTICEimg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TICESoftware/tice-web/8f3cfcd5e0c0b85b017cc6e9558539d49a52f490/public/OpenGraphTICEimg.png
--------------------------------------------------------------------------------
/public/_headers:
--------------------------------------------------------------------------------
1 | /apple-app-site-association
2 | Content-Type: application/json
3 |
--------------------------------------------------------------------------------
/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TICESoftware/tice-web/8f3cfcd5e0c0b85b017cc6e9558539d49a52f490/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TICESoftware/tice-web/8f3cfcd5e0c0b85b017cc6e9558539d49a52f490/public/android-chrome-512x512.png
--------------------------------------------------------------------------------
/public/app-store-download.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TICESoftware/tice-web/8f3cfcd5e0c0b85b017cc6e9558539d49a52f490/public/app-store-download.png
--------------------------------------------------------------------------------
/public/apple-app-site-association:
--------------------------------------------------------------------------------
1 | {
2 | "applinks": {
3 | "details": [
4 | {
5 | "appIDs": [
6 | "6YK3KTLWQR.app.tice.TICE.production",
7 | "6YK3KTLWQR.app.tice.TICE.preview",
8 | "6YK3KTLWQR.app.tice.TICE.development",
9 | "6YK3KTLWQR.app.tice.TICE.testing"
10 | ],
11 | "/": [
12 | "/group/*"
13 | ]
14 | }
15 | ]
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TICESoftware/tice-web/8f3cfcd5e0c0b85b017cc6e9558539d49a52f490/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | #2980b9
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/public/de/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | TICE – Teile deinen Live-Standort sicher
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
37 |
38 |
39 |
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/public/en/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | TICE – Live & Secure Location Sharing
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
37 |
38 |
39 |
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TICESoftware/tice-web/8f3cfcd5e0c0b85b017cc6e9558539d49a52f490/public/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TICESoftware/tice-web/8f3cfcd5e0c0b85b017cc6e9558539d49a52f490/public/favicon-32x32.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TICESoftware/tice-web/8f3cfcd5e0c0b85b017cc6e9558539d49a52f490/public/favicon.ico
--------------------------------------------------------------------------------
/public/logo-vstack-wob.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TICESoftware/tice-web/8f3cfcd5e0c0b85b017cc6e9558539d49a52f490/public/logo-vstack-wob.png
--------------------------------------------------------------------------------
/public/mapbg.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TICESoftware/tice-web/8f3cfcd5e0c0b85b017cc6e9558539d49a52f490/public/mapbg.jpg
--------------------------------------------------------------------------------
/public/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TICESoftware/tice-web/8f3cfcd5e0c0b85b017cc6e9558539d49a52f490/public/mstile-150x150.png
--------------------------------------------------------------------------------
/public/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "TICE",
3 | "short_name": "TICE",
4 | "icons": [
5 | {
6 | "src": "/android-chrome-192x192.png?v=9B9XQqKnx0",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/android-chrome-512x512.png?v=9B9XQqKnx0",
12 | "sizes": "512x512",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#ffffff",
17 | "background_color": "#ffffff",
18 | "display": "standalone"
19 | }
20 |
--------------------------------------------------------------------------------
/public/tice_featured.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TICESoftware/tice-web/8f3cfcd5e0c0b85b017cc6e9558539d49a52f490/public/tice_featured.png
--------------------------------------------------------------------------------
/public/tice_logo_hstack.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TICESoftware/tice-web/8f3cfcd5e0c0b85b017cc6e9558539d49a52f490/public/tice_logo_hstack.png
--------------------------------------------------------------------------------
/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
355 |
356 |
437 |
--------------------------------------------------------------------------------
/src/components/About.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{ $tt("about.madeby") }}
4 |
TICE Software UG (haftungsbeschränkt)
5 | {{ $tt("about.version") }} {{ this.appVersion }}
6 |
7 | Impressum
8 | {{ $tt("about.sendFeedback") }}
9 |
10 |
11 |
12 |
13 |
19 |
20 |
21 |
22 | {{ $tt("about.licenseHeading", { licenseName: type.name }) }}
23 |
24 |
{{ library.name }}
25 |
26 |
27 |
{{ $tt("about.permissionNotice", { licenseName: type.name }) }}
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
117 |
118 |
140 |
--------------------------------------------------------------------------------
/src/components/Chat.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
18 |
19 |
20 | {{ user.initials }}
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
100 |
101 |
108 |
--------------------------------------------------------------------------------
/src/components/GroupInfo.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
13 |
14 | {{ $tt("groupInfo.members") }} ({{ groupMemberNames.length }})
15 |
16 |
17 |
18 |
19 | {{ member.name }}
20 |
21 |
22 |
23 |
24 |
25 |
26 |
50 |
51 |
68 |
--------------------------------------------------------------------------------
/src/components/Map.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | {{ $t("map.myLocation") }}
11 |
12 |
13 |
14 |
15 |
16 |
17 |
170 |
171 |
212 |
--------------------------------------------------------------------------------
/src/components/MapMarkers.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
31 |
48 |
63 |
85 |
86 |
87 |
88 |
173 |
174 |
176 |
--------------------------------------------------------------------------------
/src/components/ShareLocationButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
38 |
39 |
65 |
--------------------------------------------------------------------------------
/src/components/TitleBar.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{ groupname }}
4 |
5 | {{ initials }}
6 | {{ username }}
7 |
8 |
9 |
10 |
11 |
109 |
110 |
162 |
--------------------------------------------------------------------------------
/src/components/WelcomeBox.vue:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
Loading...
7 |
{{ title }}
8 |

9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | {{ $t("welcome.switch.shareLocation") }}
18 |
19 | {{ $t("welcome.button.reload") }}
20 | {{ $t("welcome.button.join") }}
21 |
22 |
23 |
24 | {{ $t("welcome.cookies") }} {{ $t("welcome.privacyNotice") }}
25 |
26 |
27 |
28 |
29 |
30 | {{ $t("welcome.openInApp") }}
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
203 |
204 |
224 |
225 |
251 |
--------------------------------------------------------------------------------
/src/lang/de.json:
--------------------------------------------------------------------------------
1 | {
2 | "welcome.button.reload": "Neu laden",
3 | "welcome.button.join": "Beitreten",
4 | "welcome.cookies": "Mit dem Beitritt werden Cookies akzeptiert, die ausschließlich der Funktionalität dienen.",
5 | "welcome.openInApp": "App installiert? In App öffnen",
6 | "about.title": "Über TICE",
7 | "about.madeby": "Made with ♥ in Berlin by",
8 | "about.version": "Version",
9 | "about.licenseHeading": "Diese Anwendung nutzt folgende Programmbibliotheken, die jeweils unter der {licenseName}-Lizenz lizensiert sind (Lizenztext folgt darunter).",
10 | "about.permissionNotice": "{licenseName}-Lizenztext",
11 | "map.myLocation": "Mein Standort",
12 | "welcome.incorrectURL": "URL ist fehlerhaft.",
13 | "welcome.groupDoesNotExist": "Diese Gruppe existiert anscheinend nicht oder nicht mehr.",
14 | "welcome.error": "Fehler, bitte erneut probieren!",
15 | "welcome.errorOccured": "Ein Fehler ist aufgetreten:",
16 | "welcome.groupChanged": "Die Gruppe hat sich geändert. Bitte Beitritt bestätigen!",
17 | "app.meetingPoint": "Treffpunkt",
18 | "app.locationCoordinates": "Standortkoordinaten",
19 | "app.removedFromGroup": "Ein Gruppenadmin hat dich aus der Gruppe entfernt.",
20 | "app.groupDeleted": "Die Gruppe wurde gelöscht.",
21 | "app.closeWindow": "Das Fenster kann geschlossen werden.",
22 | "app.finished": "Ende",
23 | "about.moreInformation": "Weitere Informationen",
24 | "about.acknowledgements": "Danksagungen",
25 | "welcome.groupName": "{ownerName}s Gruppe",
26 | "welcome.groupName.s": "{ownerName}’ Gruppe",
27 | "chat.placeholder": "Nachricht verfassen …",
28 | "chat.title": "Chat",
29 | "about.sendFeedback": "Feedback geben",
30 | "about.appendLogsToFeedback": "Wenn du uns von einem Problem berichten möchtest, können Logs sehr hilfreich bei der Problemlösung sein. Diese Logs beinhalten keine privaten Informationen wie Standortdaten, Inhalte von Chatnachrichten oder geheime kryptographische Schlüssel. Sie enthalten aber zum Beispiel Informationen darüber, mit welchen Nutzern du deinen Standort teilst. Möchtest du uns helfen, dir zu helfen, indem du die letzten Logs an das Feedback anhängst?",
31 | "error.locationTrackingDenied": "Standortfreigabe vom Browser abgelehnt",
32 | "welcome.name.required": "Bitte einen Namen eingeben.",
33 | "shareLocationButton.shareLocationText.notSharing": "Standort teilen",
34 | "shareLocationButton.shareLocationText.sharing": "Standort wird geteilt",
35 | "shareLocationButton.shareLocationSubtext.stopSharing": "Teilen beenden",
36 | "shareLocationButton.shareLocationSubtext.oneSharing": "{userName} teilt ihn",
37 | "shareLocationButton.shareLocationSubtext.moreSharing": "Andere teilen ihn",
38 | "welcome.switch.shareLocation": "Teile meinen Standort",
39 | "welcome.privacyNotice": "Weitere Informationen",
40 | "groupInfo.members": "Mitglieder",
41 | "groupInfo.you": "Du",
42 | "titleBar.settings.publicName": "Mein Name",
43 | "titleBar.settings.title": "Mein Profil",
44 | "titleBar.settings.deleteData": "Alle meine Daten löschen …"
45 | }
46 |
--------------------------------------------------------------------------------
/src/lang/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "welcome.button.reload": "Refresh",
3 | "welcome.button.join": "Join Group",
4 | "welcome.cookies": "When joining you accept the usage of cookies. Those cookies are only meant for functionality.",
5 | "welcome.privacyNotice": "Further Information",
6 | "welcome.openInApp": "Already got the iOS-App? Open in App",
7 | "groupInfo.members": "Members",
8 | "groupInfo.you": "You",
9 | "about.title": "About TICE",
10 | "about.madeby": "Made with ♥ in Berlin by",
11 | "about.version": "Version",
12 | "about.licenseHeading": "This application makes use of the following third party libraries, each licensed under the {licenseName} license (permission notice see below).",
13 | "about.permissionNotice": "{licenseName} license permission notice",
14 | "map.myLocation": "My Location",
15 | "titleBar.settings.publicName": "My name",
16 | "titleBar.settings.title": "My profile",
17 | "welcome.incorrectURL": "URL is incorrect.",
18 | "welcome.groupDoesNotExist": "This group doesn’t exist (anymore).",
19 | "welcome.groupMemberLimitExceeded": "This group has exceeded the member limit.",
20 | "welcome.error": "Error, please try again!",
21 | "welcome.errorOccured": "An error occured:",
22 | "welcome.groupChanged": "The group has changed. Please confirm that you want to join!",
23 | "app.meetingPoint": "Meeting Point",
24 | "app.locationCoordinates": "Location",
25 | "app.removedFromGroup": "A group admin removed you from this group.",
26 | "app.groupDeleted": "The group was deleted.",
27 | "app.closeWindow": "You can close this window now.",
28 | "app.finished": "Finished",
29 | "about.moreInformation": "More information",
30 | "about.acknowledgements": "Acknowledgements",
31 | "welcome.groupName": "{ownerName}’s Group",
32 | "welcome.groupName.s": "{ownerName}’ Group",
33 | "titleBar.settings.deleteData": "Delete all my data …",
34 | "chat.placeholder": "Write a message …",
35 | "chat.title": "Chat",
36 | "about.sendFeedback": "Give feedback",
37 | "about.appendLogsToFeedback": "If you're reporting an issue, app logs are very helpful tracking the bug. Logs don't contain sensitive information like your location data, contents of chat messages or private cryptographic keys. However they contain information like the users you are sharing your location with. Do you want to help us helping you by attaching recent logs to your feedback?",
38 | "error.locationTrackingDenied": "Location tracking denied by browser",
39 | "welcome.name.required": "Please enter a name.",
40 | "shareLocationButton.shareLocationText.notSharing": "Share location",
41 | "shareLocationButton.shareLocationText.sharing": "Sharing location",
42 | "shareLocationButton.shareLocationSubtext.stopSharing": "Stop sharing",
43 | "shareLocationButton.shareLocationSubtext.oneSharing": "{userName} is sharing",
44 | "shareLocationButton.shareLocationSubtext.moreSharing": "Others are sharing",
45 | "welcome.switch.shareLocation": "Share my location",
46 | "background.title": "TICE in background",
47 | "background.text": "The TICE web app can only transmit your location as long as the page is in the foreground. Keep this page in the foreground so that your location is still visible to others or deactivate location sharing.",
48 | "background.close": "OK"
49 | }
50 |
--------------------------------------------------------------------------------
/src/main.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/order, import/first, import/newline-after-import */
2 | import Vue from 'vue';
3 | import App from './App.vue';
4 |
5 | import { i18n } from './utils/i18n';
6 |
7 | import 'element-ui/lib/theme-chalk/index.css';
8 | import {
9 | Collapse, CollapseItem, Row, Col, Switch, Dialog, Button, Message, MessageBox, Drawer, Loading, Input, FormItem, Form,
10 | } from 'element-ui';
11 | Vue.use(Collapse);
12 | Vue.use(CollapseItem);
13 | Vue.use(Row);
14 | Vue.use(Col);
15 | Vue.use(Switch);
16 | Vue.use(Dialog);
17 | Vue.use(Button);
18 | Vue.use(Drawer);
19 | Vue.use(Loading);
20 | Vue.use(Input);
21 | Vue.use(FormItem);
22 | Vue.use(Form);
23 | Vue.prototype.$msgbox = MessageBox;
24 | Vue.prototype.$alert = MessageBox.alert;
25 | Vue.prototype.$message = Message;
26 |
27 | import Chat from 'vue-beautiful-chat';
28 | Vue.use(Chat);
29 |
30 | import VueTimeago from 'vue-timeago';
31 | import dateFnsDE from 'date-fns/locale/de';
32 | Vue.use(VueTimeago, { locale: 'de', autoUpdate: true, locales: { de: dateFnsDE } });
33 |
34 | import GroupInfo from './components/GroupInfo.vue';
35 | Vue.component('group-info', GroupInfo);
36 |
37 | import api from './utils/APIRequestManager';
38 | Vue.prototype.$api = api;
39 |
40 | import beekeeper from './utils/Beekeeper';
41 | Vue.prototype.$tracking = beekeeper;
42 | beekeeper.sessionStart(navigator.language);
43 | beekeeper.pageView();
44 |
45 | import crypto from './utils/CryptoManager';
46 | Vue.prototype.$crypto = crypto;
47 |
48 | import flow from './utils/FlowManager';
49 | Vue.prototype.$flow = flow;
50 |
51 | import groupmembers from './utils/GroupMemberManager';
52 | Vue.prototype.$groupmembers = groupmembers;
53 |
54 | import Logger from './utils/Logger';
55 | Vue.prototype.$log = Logger;
56 |
57 | Vue.config.productionTip = false;
58 | new Vue({
59 | render: (h) => h(App),
60 | i18n,
61 | }).$mount('#app');
62 |
--------------------------------------------------------------------------------
/src/utils/APIRequestManager.js:
--------------------------------------------------------------------------------
1 | import Logger from './Logger';
2 | /* eslint-disable import/no-cycle */
3 | import crypto from './CryptoManager';
4 |
5 | const useTLS = process.env.VUE_APP_USE_TLS === 'true' ? 's' : '';
6 | const apiBaseURL = process.env.VUE_APP_API_URL;
7 |
8 | const httpBaseURL = `http${useTLS}://${apiBaseURL}/`;
9 | const wsBaseURL = `ws${useTLS}://${apiBaseURL}/`;
10 |
11 | let user;
12 | const headers = { 'X-Platform': 'web', 'X-Build': '1', 'Content-Type': 'application/json' };
13 |
14 | async function requestAPI(method, url, data) {
15 | if (user) {
16 | headers['X-Authorization'] = await crypto.user(user).authHeader();
17 | }
18 | const init = {
19 | method,
20 | headers,
21 | body: JSON.stringify(data),
22 | };
23 | const res = await fetch(httpBaseURL + url, init);
24 | const body = await res.json();
25 | if (body.success === true) {
26 | return body.result;
27 | } if (res.ok === true) {
28 | throw new Error(`${body.error.type}:${body.error.description}`);
29 | } else {
30 | throw new Error(`Unknown error: ${JSON.stringify(body)}`);
31 | }
32 | }
33 |
34 | export default {
35 | httpBaseURL,
36 | setAuthHeader(newUser) {
37 | user = newUser;
38 | },
39 | openWebsocket(onmessage) {
40 | const socket = new WebSocket(wsBaseURL, headers['X-Authorization']);
41 | socket.onmessage = (e) => { onmessage(e.data); };
42 | socket.onclose = () => { Logger.debug('WebSocket closed'); };
43 | socket.onopen = () => { Logger.debug('WebSocket opened'); };
44 | return socket;
45 | },
46 | createUser(data) {
47 | return requestAPI('post', 'user/web', data);
48 | },
49 | getMessages() {
50 | return requestAPI('get', 'message');
51 | },
52 | sendMessage(sendMessageRequest) {
53 | return requestAPI('post', 'message', sendMessageRequest);
54 | },
55 | user(userId) {
56 | return {
57 | getInfo() {
58 | return requestAPI('get', `user/${userId}`);
59 | },
60 | update(data) {
61 | return requestAPI('put', `user/${userId}`, data);
62 | },
63 | getPublicKeys() {
64 | return requestAPI('post', `user/${userId}/keys`);
65 | },
66 | };
67 | },
68 | group(groupId, groupTag) {
69 | headers['X-GroupTag'] = groupTag;
70 | return {
71 | create(data) {
72 | return requestAPI('post', 'group', data);
73 | },
74 | getInfo() {
75 | return requestAPI('get', `group/${groupId}`);
76 | },
77 | join(selfSignedMembershipCertificate) {
78 | return requestAPI('post', `group/${groupId}/request`, { selfSignedMembershipCertificate });
79 | },
80 | };
81 | },
82 | groupInternal(groupId, groupTag, serverSignedMembershipCertificate) {
83 | headers['X-GroupTag'] = groupTag;
84 | headers['X-ServerSignedMembershipCertificate'] = serverSignedMembershipCertificate;
85 | return {
86 | getInternals() {
87 | return requestAPI('get', `group/${groupId}/internals`);
88 | },
89 | addMember(data) {
90 | return requestAPI('post', `group/${groupId}/member`, data);
91 | },
92 | delete(data) {
93 | return requestAPI('delete', `group/${groupId}`, data);
94 | },
95 | };
96 | },
97 | };
98 |
--------------------------------------------------------------------------------
/src/utils/Beekeeper.js:
--------------------------------------------------------------------------------
1 | import JSSHA from 'jssha';
2 | import crypto from './CryptoManager';
3 | import Logger from './Logger';
4 |
5 | const beekeeperBaseURL = 'https://beekeeper.tice.app/';
6 | const product = process.env.VUE_APP_BEEKEEPER_PRODUCT;
7 | const secret = process.env.VUE_APP_BEEKEEPER_SECRET;
8 |
9 | let cookiesAllowed = false;
10 |
11 | function sha1hex(valueString) {
12 | const shaObj = new JSSHA('SHA-1', 'TEXT');
13 | shaObj.update(valueString);
14 | return shaObj.getHash('HEX');
15 | }
16 | function sha256hmac64(secretString, valueString) {
17 | const shaObj = new JSSHA('SHA-256', 'TEXT');
18 | shaObj.setHMACKey(secretString, 'TEXT');
19 | shaObj.update(valueString);
20 | return shaObj.getHMAC('B64');
21 | }
22 |
23 | let eventQueue = [];
24 | let flushTimeout;
25 | async function flushEventQueue() {
26 | clearTimeout(flushTimeout);
27 | flushTimeout = setTimeout(flushEventQueue, 30000);
28 |
29 | if (eventQueue.length === 0) {
30 | return;
31 | }
32 | const method = 'POST';
33 | const contentType = 'application/json';
34 | const body = JSON.stringify(eventQueue);
35 | const contentHash = sha1hex(body);
36 | const dateString = (new Date()).toISOString();
37 | const string = `${method}\n${contentHash}\n${contentType}\n${dateString}\n/${product}`;
38 | const signature = sha256hmac64(secret, string);
39 | const init = {
40 | method,
41 | headers: {
42 | 'Content-Type': contentType,
43 | 'authorization-date': dateString,
44 | authorization: signature,
45 | },
46 | body,
47 | };
48 | try {
49 | await fetch(beekeeperBaseURL + product, init);
50 | eventQueue = [];
51 | } catch (error) {
52 | Logger.error(`Couldn't send to beekeeper: ${error}`);
53 | }
54 | }
55 | flushTimeout = setTimeout(flushEventQueue, 30000);
56 |
57 | function getToday() {
58 | const date = new Date();
59 | const zeroPad = (num) => String(num).padStart(2, '0');
60 | return `${date.getFullYear()}-${zeroPad(date.getMonth() + 1)}-${zeroPad(date.getDate())}`;
61 | }
62 |
63 | const lastTimestamp = {};
64 | function getLastTimestamp(name) {
65 | if (lastTimestamp[name] !== undefined) {
66 | return lastTimestamp[name];
67 | }
68 | const stored = localStorage.getItem(`tice.beekeeper.${name}`);
69 | if (stored !== null) {
70 | return stored;
71 | }
72 | return undefined;
73 | }
74 | function updateLastTimestamp(name) {
75 | const newValue = getToday();
76 | if (cookiesAllowed) {
77 | localStorage.setItem(`tice.beekeeper.${name}`, newValue);
78 | }
79 | lastTimestamp[name] = newValue;
80 | }
81 | let previousEvent;
82 | function updatePreviousEvent(newValue) {
83 | const old = previousEvent;
84 | previousEvent = newValue;
85 | if (cookiesAllowed) {
86 | localStorage.setItem('tice.beekeeper.previousEvent', newValue);
87 | }
88 | if (old === undefined) {
89 | const stored = localStorage.getItem('tice.beekeeper.previousEvent');
90 | if (stored !== null) {
91 | return stored;
92 | }
93 | }
94 | return old;
95 | }
96 | let sessionStart;
97 |
98 | async function track(name, group = undefined, detail = undefined, value = undefined) {
99 | Logger.trace(`Beekeeper: ${[name, group, detail, value].join(' ')}`);
100 | const last = getLastTimestamp(name);
101 | updateLastTimestamp(name);
102 | const installDay = localStorage.getItem('tice.beekeeper.installday') === null ? getToday() : localStorage.getItem('tice.beekeeper.installday');
103 |
104 | const event = {
105 | id: crypto.generateUUID().replace(/-/g, '').toUpperCase(),
106 | p: product,
107 | t: (new Date()).toISOString(),
108 | name,
109 | group,
110 | detail,
111 | value,
112 | prev: updatePreviousEvent(name),
113 | last,
114 | install: installDay,
115 | custom: [`web-${process.env.VUE_APP_VERSION}`],
116 | };
117 | eventQueue.push(event);
118 | if (name === 'SessionEnd') {
119 | flushEventQueue();
120 | }
121 | }
122 |
123 | export default {
124 | allowCookies() {
125 | cookiesAllowed = true;
126 | if (localStorage.getItem('tice.beekeeper.installday') === null) {
127 | localStorage.setItem('tice.beekeeper.installday', getToday());
128 | }
129 | },
130 | sessionStart(lang) {
131 | sessionStart = Date.now();
132 | track('SessionStart', 'App', lang);
133 | },
134 | sessionEnd() {
135 | const sessionLength = (Date.now() - sessionStart) / 1000;
136 | track('SessionEnd', 'App', undefined, sessionLength);
137 | },
138 | locationAuthorization(value) {
139 | const detail = value ? 'AUTHORIZED' : 'DENIED';
140 | track('LocationAuthorization', 'App', detail);
141 | },
142 | changeLocationTracking(value) {
143 | const detail = value ? 'YES' : 'NO';
144 | track('ChangeLocationTracking', 'Settings', detail);
145 | },
146 | changeName() {
147 | track('ChangeName', 'Settings');
148 | },
149 | screen(name, detail = undefined) {
150 | track(name, 'Screen', detail);
151 | },
152 | registerComplete() {
153 | track('RegisterComplete', 'MainFlow');
154 | },
155 | loadFromStorage() {
156 | track('SignIn', 'MainFlow');
157 | },
158 | pageView() {
159 | track('PageView', 'App', navigator.userAgent);
160 | },
161 | error(name, detail = undefined) {
162 | track(name, 'Error', detail);
163 | },
164 | };
165 |
--------------------------------------------------------------------------------
/src/utils/CryptoManager.js:
--------------------------------------------------------------------------------
1 | import _sodium from 'libsodium-wrappers';
2 | import { DoubleRatchet, Header } from 'double-ratchet-ts';
3 | import { deriveHKDFKey } from 'sodium-hkdf';
4 | import { X3DH } from 'x3dh';
5 | import { ec as EC } from 'elliptic';
6 | import KeyEncoder from 'key-encoder';
7 | /* eslint-disable import/no-cycle */
8 | import api from './APIRequestManager';
9 | import Logger from './Logger';
10 |
11 | const info = 'TICE';
12 | const maxSkip = 5000;
13 | const maxCache = 100;
14 | let cookiesAllowed = false;
15 |
16 | let groupId;
17 |
18 | let handshake; // X3DH
19 | let signingKey; // JWK.Key
20 | const ec = new EC('p521');
21 |
22 | function generateUUID() {
23 | let d = new Date().getTime();
24 | let d2 = (performance && performance.now && (performance.now() * 1000)) || 0;
25 | /* eslint-disable no-bitwise, no-mixed-operators */
26 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
27 | let r = Math.random() * 16;
28 | if (d > 0) {
29 | r = (d + r) % 16 | 0;
30 | d = Math.floor(d / 16);
31 | } else {
32 | r = (d2 + r) % 16 | 0;
33 | d2 = Math.floor(d2 / 16);
34 | }
35 | return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
36 | });
37 | /* eslint-enable no-bitwise, no-mixed-operators */
38 | }
39 |
40 | const sessionCollapseId = generateUUID();
41 |
42 | const encoderOptions = {
43 | curveParameters: [1, 3, 132, 0, 35],
44 | privatePEMOptions: { label: 'EC PRIVATE KEY' },
45 | publicPEMOptions: { label: 'PUBLIC KEY' },
46 | curve: ec,
47 | };
48 | const keyEncoder = new KeyEncoder(encoderOptions);
49 |
50 | function stringifyKeyPair(keyPair) {
51 | const item = { publicKey: Array.from(keyPair.publicKey), privateKey: Array.from(keyPair.privateKey), keyType: keyPair.keyType };
52 | return JSON.stringify(item);
53 | }
54 | function parseKeyPair(stringified) {
55 | const item = JSON.parse(stringified);
56 | return { publicKey: Uint8Array.from(item.publicKey), privateKey: Uint8Array.from(item.privateKey), keyType: item.keyType };
57 | }
58 | function saveHandshake() {
59 | if (cookiesAllowed) {
60 | const handshakeStore = {
61 | identityKeyPair: stringifyKeyPair(handshake.keyMaterial.identityKeyPair),
62 | signedPrekeyPair: stringifyKeyPair(handshake.keyMaterial.signedPrekeyPair),
63 | oneTimePrekeyPairs: handshake.keyMaterial.oneTimePrekeyPairs.map((otp) => stringifyKeyPair(otp)),
64 | };
65 | localStorage.setItem(`tice.handshake.${groupId}`, JSON.stringify(handshakeStore));
66 | }
67 | }
68 |
69 | function addOrUpdateDR(senderId, collapsing, userDR) {
70 | const conversationId = collapsing ? 0 : 1;
71 | const sessionState = DoubleRatchet.sessionStateBlob(userDR.sessionState);
72 | localStorage.setItem(`tice.doubleratchet.${conversationId}${senderId}`, sessionState);
73 | }
74 | function getDR(senderId, collapsing) {
75 | const conversationId = collapsing ? 0 : 1;
76 | const storedItem = localStorage.getItem(`tice.doubleratchet.${conversationId}${senderId}`);
77 | if (storedItem === null) { return undefined; }
78 | return DoubleRatchet.initSessionStateBlob(storedItem);
79 | }
80 |
81 | function addSeenConversationInvitations(senderId, collapsing, ciFingerprint, timestamp) {
82 | const conversationId = collapsing ? 0 : 1;
83 | localStorage.setItem(`tice.seenconversation.${conversationId}${senderId}`, JSON.stringify({ fingerprint: ciFingerprint, timestamp }));
84 | }
85 | function getSeenConversationInvitations(senderId, collapsing) {
86 | const conversationId = collapsing ? 0 : 1;
87 | const item = localStorage.getItem(`tice.seenconversation.${conversationId}${senderId}`);
88 | return item === null ? undefined : JSON.parse(item);
89 | }
90 |
91 | function addSendingConversationInvitation(senderId, collapsing, conversationInvitation) {
92 | const conversationId = collapsing ? 0 : 1;
93 | localStorage.setItem(`tice.sendingconversation.${conversationId}${senderId}`, JSON.stringify(conversationInvitation));
94 | }
95 | function getSendingConversationInvitation(senderId, collapsing) {
96 | const conversationId = collapsing ? 0 : 1;
97 | const item = localStorage.getItem(`tice.sendingconversation.${conversationId}${senderId}`);
98 | return item === null ? undefined : JSON.parse(item);
99 | }
100 | function removeSendingConversationInvitation(senderId, collapsing) {
101 | const conversationId = collapsing ? 0 : 1;
102 | localStorage.removeItem(`tice.sendingconversation.${conversationId}${senderId}`);
103 | }
104 |
105 | async function dataFromBase64(b64EncodedString) {
106 | await _sodium.ready;
107 | const sodium = _sodium;
108 | return sodium.from_base64(b64EncodedString, sodium.base64_variants.ORIGINAL);
109 | }
110 | async function base64EncodedString(data) {
111 | await _sodium.ready;
112 | const sodium = _sodium;
113 | return sodium.to_base64(data, sodium.base64_variants.ORIGINAL);
114 | }
115 |
116 | async function encryptSymmetric(key64, plaintextString) {
117 | await _sodium.ready;
118 | const sodium = _sodium;
119 |
120 | const key = await dataFromBase64(key64);
121 | const plaintext = sodium.from_string(plaintextString);
122 | const nonce = sodium.randombytes_buf(sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
123 | const ciphertext = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(plaintext, null, null, nonce, key);
124 |
125 | const nonceAndCiphertext = new Uint8Array(nonce.length + ciphertext.length);
126 | nonceAndCiphertext.set(nonce);
127 | nonceAndCiphertext.set(ciphertext, nonce.length);
128 |
129 | return base64EncodedString(nonceAndCiphertext);
130 | }
131 | async function decryptSymmetric(key64, nonceAndCiphertext64, enc) {
132 | await _sodium.ready;
133 | const sodium = _sodium;
134 |
135 | const key = await dataFromBase64(key64);
136 | const nonceAndCiphertext = await dataFromBase64(nonceAndCiphertext64);
137 | const nonce = nonceAndCiphertext.slice(0, sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
138 | const ciphertext = nonceAndCiphertext.slice(sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
139 |
140 | const decrypted = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(null, ciphertext, null, nonce, key);
141 | if (enc === 'base64') {
142 | return base64EncodedString(decrypted);
143 | }
144 | return sodium.to_string(decrypted);
145 | }
146 |
147 | async function encryptPayloadContainer(payloadContainer) {
148 | await _sodium.ready;
149 | const sodium = _sodium;
150 |
151 | const key = sodium.crypto_aead_xchacha20poly1305_ietf_keygen();
152 | const plaintext = JSON.stringify(payloadContainer);
153 | const ciphertext = await encryptSymmetric(await base64EncodedString(key), plaintext);
154 |
155 | return { ciphertext, secretKey: key };
156 | }
157 |
158 | function sign(privateSigningKey, payload) {
159 | const hash = ec.hash().update(payload).digest();
160 | const signature = privateSigningKey.sign(hash);
161 | return signature.toDER('hex');
162 | }
163 | function verify(publicSigningKey, payload, signature) {
164 | const hash = ec.hash().update(payload).digest();
165 | const keypair = ec.keyFromPublic(publicSigningKey, 'hex');
166 | return ec.verify(hash, signature, keypair);
167 | }
168 | async function jwt(privateSigningKey, payload) {
169 | await _sodium.ready;
170 | const sodium = _sodium;
171 | const header64 = sodium.to_base64(JSON.stringify({ typ: 'JWT', alg: 'ES512' }));
172 | const payload64 = sodium.to_base64(JSON.stringify(payload));
173 |
174 | const signatureHex = sign(privateSigningKey, `${header64}.${payload64}`);
175 | const signature64 = sodium.to_base64(sodium.from_hex(signatureHex));
176 | return `${header64}.${payload64}.${signature64}`;
177 | }
178 | async function selfSignedMembershipCertificate(user) {
179 | const iat = new Date();
180 | const exp = new Date();
181 | exp.setSeconds(iat.getSeconds() + 60 * 60 * 24 * 30 * 6);
182 | const certificatePayload = {
183 | iat: iat.getTime() / 1000,
184 | exp: exp.getTime() / 1000,
185 | admin: false,
186 | jti: generateUUID(),
187 | groupId,
188 | sub: user.userId,
189 | iss: { user: user.userId },
190 | };
191 | return jwt(user.keys.signingKey, certificatePayload);
192 | }
193 | async function generateMembership(user, group) {
194 | Logger.trace('Generate membership');
195 |
196 | const selfSignedMembership = await selfSignedMembershipCertificate(user);
197 | const joinGroupRequest = await api.group(group.groupId, group.groupTag).join(selfSignedMembership);
198 |
199 | return {
200 | userId: user.userId,
201 | groupId: group.groupId,
202 | admin: false,
203 | publicSigningKey: user.keys.publicSigningKey,
204 | selfSignedMembershipCertificate: selfSignedMembership,
205 | serverSignedMembershipCertificate: joinGroupRequest.serverSignedMembershipCertificate,
206 | };
207 | }
208 |
209 | async function loadMembership(user, group) {
210 | Logger.trace('Load membership');
211 | const storedMembership = localStorage.getItem(`tice.membership.${group.groupId}`);
212 | if (storedMembership !== null && storedMembership.userId === user.userId) {
213 | Logger.trace('Loaded membership from cookie');
214 | return JSON.parse(storedMembership);
215 | }
216 | Logger.trace('Could not load membership');
217 | return null;
218 | }
219 |
220 | async function getMembership(user, group) {
221 | return (await loadMembership(user, group)) ?? generateMembership(user, group);
222 | }
223 |
224 | async function createPrekeyBundle() {
225 | await _sodium.ready;
226 | const sodium = _sodium;
227 |
228 | const publicKeyMaterial = await handshake.createPrekeyBundle(100, false, (publicKey) => {
229 | const signatureHex = sign(signingKey, publicKey);
230 | return sodium.from_hex(signatureHex);
231 | });
232 | saveHandshake();
233 | const oneTimePrekeysPromises = publicKeyMaterial.oneTimePrekeyPairs.map(async (otp) => base64EncodedString(otp));
234 | return { publicKeyMaterial, oneTimePrekeys: await Promise.all(oneTimePrekeysPromises) };
235 | }
236 |
237 | function conversationInvitationFingerprint(conversationInvitation) {
238 | return conversationInvitation.identityKey + conversationInvitation.ephemeralKey + conversationInvitation.usedOneTimePrekey;
239 | }
240 |
241 | export default {
242 | allowCookies() {
243 | cookiesAllowed = true;
244 | saveHandshake();
245 | },
246 | generateUUID,
247 | async generateKeys() {
248 | signingKey = ec.genKeyPair();
249 | const publicPEM = keyEncoder.encodePublic(signingKey.getPublic('hex'), 'raw', 'pem');
250 | const base64PublicSigningKey = await base64EncodedString(publicPEM);
251 |
252 | handshake = await X3DH.init();
253 | const { publicKeyMaterial, oneTimePrekeys } = await createPrekeyBundle();
254 | saveHandshake();
255 |
256 | return {
257 | signingKey,
258 | publicSigningKey: base64PublicSigningKey,
259 | userPublicKeys: {
260 | signingKey: base64PublicSigningKey,
261 | identityKey: await base64EncodedString(publicKeyMaterial.identityKey),
262 | signedPrekey: await base64EncodedString(publicKeyMaterial.signedPrekey),
263 | prekeySignature: await base64EncodedString(publicKeyMaterial.prekeySignature),
264 | oneTimePrekeys,
265 | },
266 | };
267 | },
268 | user(user) {
269 | return {
270 | async updatePrekeyBundle() {
271 | const { publicKeyMaterial, oneTimePrekeys } = await createPrekeyBundle();
272 | user.keys.userPublicKeys = {
273 | signingKey: user.keys.publicSigningKey,
274 | identityKey: await base64EncodedString(publicKeyMaterial.identityKey),
275 | signedPrekey: await base64EncodedString(publicKeyMaterial.signedPrekey),
276 | prekeySignature: await base64EncodedString(publicKeyMaterial.prekeySignature),
277 | oneTimePrekeys,
278 | };
279 | return user;
280 | },
281 | async authHeader() {
282 | await _sodium.ready;
283 | const sodium = _sodium;
284 | const now = new Date();
285 | const validUntil = new Date();
286 | validUntil.setSeconds(now.getSeconds() + 120);
287 | const nonce = await base64EncodedString(sodium.randombytes_buf(16));
288 | return jwt(user.keys.signingKey, {
289 | iss: user.userId, iat: now.getTime() / 1000, exp: validUntil.getTime() / 1000, nonce,
290 | });
291 | },
292 | };
293 | },
294 | group(groupKey) {
295 | return {
296 | async decrypt(ciphertext, enc) {
297 | return decryptSymmetric(groupKey, ciphertext, enc);
298 | },
299 | async decryptAndParse(ciphertext) {
300 | return JSON.parse(await decryptSymmetric(groupKey, ciphertext));
301 | },
302 | async encrypt(plaintext) {
303 | return encryptSymmetric(groupKey, JSON.stringify(plaintext));
304 | },
305 | async generateTokenKey(userPublicSigningKey64) {
306 | const userPublicSigningKey = await dataFromBase64(userPublicSigningKey64);
307 | const groupKeyBytes = await dataFromBase64(groupKey);
308 | const inputKeyingMaterial = new Uint8Array(groupKeyBytes.length + userPublicSigningKey.length);
309 | inputKeyingMaterial.set(groupKeyBytes);
310 | inputKeyingMaterial.set(userPublicSigningKey, groupKeyBytes.length);
311 | const tokenKey = await deriveHKDFKey(inputKeyingMaterial, 32);
312 | await _sodium.ready;
313 | const sodium = _sodium;
314 | return sodium.to_base64(tokenKey);
315 | },
316 | };
317 | },
318 | membership: getMembership,
319 | async decryptPayloadContainer(envelope) {
320 | let doubleRatchet = getDR(envelope.senderId, envelope.collapseId !== undefined);
321 | if (envelope.conversationInvitation) {
322 | const ciFingerprint = conversationInvitationFingerprint(envelope.conversationInvitation);
323 | const lastConversationInvitation = getSeenConversationInvitations(envelope.senderId, envelope.collapseId !== undefined);
324 | if (!lastConversationInvitation || (lastConversationInvitation.fingerprint !== ciFingerprint && lastConversationInvitation.timestamp < envelope.timestamp)) {
325 | const identityKey = await dataFromBase64(envelope.conversationInvitation.identityKey);
326 | const ephemeralKey = await dataFromBase64(envelope.conversationInvitation.ephemeralKey);
327 | const usedOneTimePrekey = envelope.conversationInvitation.usedOneTimePrekey ? await dataFromBase64(envelope.conversationInvitation.usedOneTimePrekey) : undefined;
328 | try {
329 | const sharedSecret = await handshake.sharedSecretFromKeyAgreement(info, identityKey, ephemeralKey, usedOneTimePrekey);
330 | saveHandshake();
331 | doubleRatchet = await DoubleRatchet.init(info, maxCache, maxSkip, sharedSecret, undefined, handshake.signedPrekeyPair());
332 | addOrUpdateDR(envelope.senderId, envelope.collapseId !== undefined, doubleRatchet);
333 | addSeenConversationInvitations(envelope.senderId, envelope.collapseId !== undefined, ciFingerprint, envelope.timestamp);
334 | } catch (error) {
335 | Logger.warning("Couldn't get sharedSecret/DoubleRatchet init failed. Resetting.");
336 | return 'RESET';
337 | }
338 | }
339 | }
340 | if (!doubleRatchet) {
341 | Logger.warning('No DR initiated, no conversation invitation. Resetting.');
342 | return 'RESET';
343 | }
344 |
345 | // remove sending conversation invitations for this user to stop sending them
346 | removeSendingConversationInvitation(envelope.senderId, envelope.collapseId !== undefined);
347 |
348 | const rawMessage = JSON.parse(atob(envelope.payloadContainer.payload.encryptedKey));
349 | const message = {
350 | cipher: Uint8Array.from(rawMessage.cipher),
351 | header: new Header(Uint8Array.from(rawMessage.header.publicKey), rawMessage.header.numberOfMessagesInPreviousSendingChain, rawMessage.header.messageNumber),
352 | };
353 | try {
354 | const key64 = await base64EncodedString(await doubleRatchet.decrypt(message));
355 | addOrUpdateDR(envelope.senderId, envelope.collapseId !== undefined, doubleRatchet);
356 | const plaintext = await decryptSymmetric(key64, envelope.payloadContainer.payload.ciphertext);
357 | return JSON.parse(plaintext);
358 | } catch (error) {
359 | Logger.warning("Couldn't decrypt message. Resetting.");
360 | return 'RESET';
361 | }
362 | },
363 | async createSendMessageRequest(user, memberships, senderServerSignedMembershipCertificate, payloadContainer, messagePriority = 'deferred', collapsing = false) {
364 | const { ciphertext, secretKey } = await encryptPayloadContainer(payloadContainer);
365 |
366 | const recipientsPromises = memberships.filter((mbrshp) => mbrshp.userId !== user.userId).map(async (membership) => {
367 | let doubleRatchet = getDR(membership.userId, collapsing);
368 | let fulfillReset = false;
369 | if (payloadContainer.payloadType === 'resetConversation/v1' && doubleRatchet !== undefined) {
370 | const oldPubKey = doubleRatchet.publicKey().join('');
371 | const milliseconds = Math.round(Math.random() * 3000);
372 | await (new Promise((resolve) => setTimeout(resolve, milliseconds)));
373 | doubleRatchet = getDR(membership.userId, collapsing);
374 | if (oldPubKey === doubleRatchet.publicKey().join('')) {
375 | fulfillReset = true;
376 | }
377 | }
378 | if (!doubleRatchet || fulfillReset) {
379 | const userPublicKeys = await api.user(membership.userId).getPublicKeys();
380 | const publicSigningKey = keyEncoder.encodePublic(atob(userPublicKeys.signingKey), 'pem', 'raw');
381 | // TODO: guard membership.publicSigningKey == userPublicKeys.signingKey
382 | const signaturePayload = await dataFromBase64(userPublicKeys.signedPrekey);
383 |
384 | const keyAgreement = await handshake.initiateKeyAgreement({
385 | identityKey: await dataFromBase64(userPublicKeys.identityKey),
386 | signedPrekey: await dataFromBase64(userPublicKeys.signedPrekey),
387 | prekeySignature: await dataFromBase64(userPublicKeys.prekeySignature),
388 | oneTimePrekey: await dataFromBase64(userPublicKeys.oneTimePrekey),
389 | }, (signature) => verify(publicSigningKey, signaturePayload, signature), info);
390 |
391 | doubleRatchet = await DoubleRatchet.init(info, maxCache, maxSkip, keyAgreement.sharedSecret, await dataFromBase64(userPublicKeys.signedPrekey), handshake.signedPrekeyPair());
392 | addOrUpdateDR(membership.userId, collapsing, doubleRatchet);
393 | addSendingConversationInvitation(membership.userId, collapsing, { identityKey: await base64EncodedString(keyAgreement.identityPublicKey), ephemeralKey: await base64EncodedString(keyAgreement.ephemeralPublicKey), usedOneTimePrekey: await base64EncodedString(keyAgreement.usedOneTimePrekey) });
394 | }
395 | const encryptedMessageKey = await doubleRatchet.encrypt(secretKey);
396 | addOrUpdateDR(membership.userId, collapsing, doubleRatchet);
397 | encryptedMessageKey.cipher = Array.from(encryptedMessageKey.cipher);
398 | encryptedMessageKey.header = { publicKey: Array.from(encryptedMessageKey.header.publicKey), numberOfMessagesInPreviousSendingChain: encryptedMessageKey.header.numberOfMessagesInPreviousSendingChain, messageNumber: encryptedMessageKey.header.messageNumber };
399 | const encryptedMessageKey64 = btoa(JSON.stringify(encryptedMessageKey));
400 |
401 | const conversationInvitation = getSendingConversationInvitation(membership.userId, collapsing);
402 |
403 | const recipient = {
404 | userId: membership.userId,
405 | serverSignedMembershipCertificate: membership.serverSignedMembershipCertificate,
406 | encryptedMessageKey: encryptedMessageKey64,
407 | };
408 | if (conversationInvitation) {
409 | recipient.conversationInvitation = conversationInvitation;
410 | }
411 | return recipient;
412 | });
413 | const recipients = await Promise.all(recipientsPromises);
414 |
415 | const request = {
416 | id: generateUUID(),
417 | senderId: user.userId,
418 | timestamp: new Date(),
419 | encryptedMessage: ciphertext,
420 | serverSignedMembershipCertificate: senderServerSignedMembershipCertificate,
421 | recipients,
422 | priority: messagePriority,
423 | messageTimeToLive: 1800.0,
424 | };
425 | if (collapsing) {
426 | request.collapseId = sessionCollapseId;
427 | }
428 | return request;
429 | },
430 | encryptSymmetric,
431 | decryptSymmetric,
432 | async prepareGroupKey(groupKey) {
433 | await _sodium.ready;
434 | const sodium = _sodium;
435 | let groupKeyData;
436 | try {
437 | groupKeyData = sodium.from_base64(groupKey);
438 | } catch (err) {
439 | groupKeyData = sodium.from_base64(groupKey, sodium.base64_variants.URLSAFE);
440 | }
441 | return base64EncodedString(groupKeyData);
442 | },
443 |
444 | async migrateStorage(gId) {
445 | groupId = gId;
446 | const oldUserData = localStorage.getItem('tice.user');
447 | const oldHandshakeData = localStorage.getItem('tice.handshake');
448 | const oldChatData = localStorage.getItem('tice.chat');
449 | localStorage.removeItem('tice.user');
450 | localStorage.removeItem('tice.handshake');
451 | localStorage.removeItem('tice.chat');
452 | if (oldUserData === null || oldHandshakeData === null) {
453 | return;
454 | }
455 | Logger.debug('Migrating old user data');
456 | localStorage.setItem(`tice.user.${groupId}`, oldUserData);
457 | localStorage.setItem(`tice.handshake.${groupId}`, oldHandshakeData);
458 |
459 | if (oldChatData !== null) {
460 | Logger.debug('Migrating old chat data');
461 | localStorage.setItem(`tice.chat.${groupId}`, oldChatData);
462 | }
463 | },
464 |
465 | // eslint-disable-next-line no-shadow
466 | async loadFromStorage(gId) {
467 | groupId = gId;
468 | const storedUserData = localStorage.getItem(`tice.user.${groupId}`);
469 | const storedHandshakeData = localStorage.getItem(`tice.handshake.${groupId}`);
470 | if (storedUserData === null || storedHandshakeData === null) {
471 | return null;
472 | }
473 |
474 | const user = JSON.parse(storedUserData);
475 | signingKey = ec.keyFromPrivate(user.keys.signingKey.priv);
476 | user.keys.signingKey = signingKey;
477 |
478 | const storedHandshake = JSON.parse(storedHandshakeData);
479 | const identityKeyPair = parseKeyPair(storedHandshake.identityKeyPair);
480 | const signedPrekeyPair = parseKeyPair(storedHandshake.signedPrekeyPair);
481 | const oneTimePrekeyPairs = storedHandshake.oneTimePrekeyPairs.map((keyPair) => parseKeyPair(keyPair));
482 | handshake = new X3DH(identityKeyPair, signedPrekeyPair, oneTimePrekeyPairs);
483 |
484 | return user;
485 | },
486 | };
487 |
--------------------------------------------------------------------------------
/src/utils/FlowManager.js:
--------------------------------------------------------------------------------
1 | import api from './APIRequestManager';
2 | import crypto from './CryptoManager';
3 | import Logger from './Logger';
4 |
5 | const userlist = {};
6 |
7 | async function updateInternals(group) {
8 | group.internals = await api.groupInternal(group.groupId, '', group.membership.serverSignedMembershipCertificate).getInternals();
9 | group.groupTag = group.internals.groupTag;
10 | return group;
11 | }
12 | async function updateSettings(group) {
13 | group = await updateInternals(group);
14 | group.internalSettings = await crypto.group(group.groupKey).decryptAndParse(group.internals.encryptedInternalSettings);
15 | group.settings = await crypto.group(group.groupKey).decryptAndParse(group.internals.encryptedSettings);
16 | return group;
17 | }
18 | async function fetchGroup(user, groupId) {
19 | let group = {};
20 | group.groupId = groupId;
21 | // Get public group information
22 | group.info = await api.group(group.groupId, '').getInfo();
23 | delete group.info.groupId;
24 | group.groupTag = group.info.groupTag;
25 | delete group.info.groupTag;
26 |
27 | // Get internal group information
28 | group.membership = await crypto.membership(user, group);
29 | group = await updateInternals(group);
30 | return group;
31 | }
32 | async function decryptGroup(group) {
33 | group.internalSettings = await crypto.group(group.groupKey).decryptAndParse(group.internals.encryptedInternalSettings);
34 | const membershipPromises = group.internals.encryptedMemberships.map(crypto.group(group.groupKey).decryptAndParse);
35 | group.memberships = await Promise.all(membershipPromises);
36 | group.settings = await crypto.group(group.groupKey).decryptAndParse(group.info.encryptedSettings);
37 | delete group.info.encryptedSettings;
38 | return group;
39 | }
40 | async function addOrUpdateUserInfo(userId) {
41 | if (!(userId in userlist)) {
42 | userlist[userId] = { info: undefined, memberships: undefined };
43 | }
44 | try {
45 | userlist[userId].info = await api.user(userId).getInfo();
46 | } catch (error) {
47 | userlist[userId].info = { userId };
48 | }
49 | return userlist[userId].info;
50 | }
51 | async function fetchGroupMembers(group) {
52 | const memberPromises = group.memberships.map(async (membership) => {
53 | const { userId } = membership;
54 | if (userId in userlist && userlist[userId].memberships === undefined) {
55 | await (new Promise((resolve) => setTimeout(resolve, 2000)));
56 | }
57 | if (!(userId in userlist) || userlist[userId].memberships === undefined) {
58 | userlist[userId] = {};
59 | await addOrUpdateUserInfo(userId);
60 | const memberships = {};
61 | memberships[membership.groupId] = membership;
62 | userlist[userId].memberships = memberships;
63 | } else {
64 | userlist[userId].memberships[membership.groupId] = membership;
65 | }
66 | return userlist[userId];
67 | });
68 | const membersArr = await Promise.all(memberPromises);
69 | return membersArr.reduce((result, member) => {
70 | result[member.info.userId] = member;
71 | return result;
72 | }, {});
73 | }
74 | async function fetchChildren(user, group) {
75 | const childGroupPromises = group.internals.children.map(async (childId) => {
76 | let childGroup = await fetchGroup(user, childId);
77 | childGroup.groupKey = await crypto.group(group.groupKey).decrypt(childGroup.internals.parentEncryptedGroupKey, 'base64');
78 | childGroup = await decryptGroup(childGroup);
79 | childGroup.members = await fetchGroupMembers(childGroup);
80 | return childGroup;
81 | });
82 | return Promise.all(childGroupPromises);
83 | }
84 | async function addUserToGroup(user, group) {
85 | const notificationRecipients = group.memberships.map((membership) => ({ userId: membership.userId, serverSignedMembershipCertificate: membership.serverSignedMembershipCertificate }));
86 | const encryptedMembership = await crypto.group(group.groupKey).encrypt(group.membership);
87 | const tokenKey = await crypto.group(group.groupKey).generateTokenKey(user.keys.publicSigningKey);
88 | const addGroupMemberRequest = await api.groupInternal(group.groupId, group.groupTag, group.membership.serverSignedMembershipCertificate).addMember({
89 | encryptedMembership, userId: user.userId, newTokenKey: tokenKey, notificationRecipients,
90 | });
91 | group.groupTag = addGroupMemberRequest.groupTag;
92 | group.members[user.userId] = { info: user };
93 | return group;
94 | }
95 |
96 | export default {
97 | async createUser() {
98 | const user = {};
99 | user.keys = await crypto.generateKeys();
100 |
101 | const createUser = await api.createUser({
102 | publicKeys: user.keys.userPublicKeys,
103 | });
104 | user.userId = createUser.userId;
105 | api.setAuthHeader(user);
106 | return user;
107 | },
108 | async prepareGroup(user, groupId, groupKey) {
109 | let group = await fetchGroup(user, groupId);
110 | group.groupKey = await groupKey;
111 | group = await decryptGroup(group);
112 | group.members = await fetchGroupMembers(group);
113 | group.children = await fetchChildren(user, group);
114 | return group;
115 | },
116 | addUserToGroup,
117 | addOrUpdateUserInfo,
118 | async teardown(user, group) {
119 | if (user.userId != null) {
120 | const groups = [];
121 | if (group != null) {
122 | const tokenKey = await crypto.group(group.groupKey).generateTokenKey(user.keys.publicSigningKey);
123 | const notificationRecipients = group.memberships.map((membership) => ({ userId: membership.userId, serverSignedMembershipCertificate: membership.serverSignedMembershipCertificate }));
124 | groups.push({
125 | groupId: group.groupId,
126 | serverSignedMembershipCertificate: group.membership.serverSignedMembershipCertificate,
127 | tokenKey,
128 | notificationRecipients,
129 | groupTag: group.groupTag,
130 | });
131 | }
132 | const data = { userId: user.userId, authHeader: await crypto.user(user).authHeader(), groups };
133 | navigator.sendBeacon(`${api.httpBaseURL}/user/${user.userId}/teardown`, JSON.stringify(data));
134 | localStorage.clear();
135 | }
136 | },
137 | async handleGroupUpdate(user, group, payload) {
138 | if (payload.groupId !== group.groupId) {
139 | Logger.info('Got group update for unknown group. Ignoring.');
140 | return null;
141 | }
142 |
143 | switch (payload.action) {
144 | case 'groupDeleted':
145 | group = null;
146 | break;
147 | case 'settingsUpdated':
148 | group = await updateSettings(group);
149 | break;
150 | case 'memberAdded':
151 | case 'memberUpdated':
152 | case 'memberDeleted':
153 | group = await updateInternals(group);
154 | group.memberships = await Promise.all(group.internals.encryptedMemberships.map(crypto.group(group.groupKey).decryptAndParse));
155 | group.members = await fetchGroupMembers(group);
156 | break;
157 | default:
158 | Logger.warning(`Unknown group update type: ${payload.action}`);
159 | }
160 |
161 | return group;
162 | },
163 | };
164 |
--------------------------------------------------------------------------------
/src/utils/GroupMemberManager.js:
--------------------------------------------------------------------------------
1 | const locations = {};
2 | const colors = ['#fc5c65', '#eb3b5a', '#fd9644', '#fa8231', '#fed330', '#f7b731', '#26de81', '#20bf6b', '#2bcbba', '#0fb9b1', '#45aaf2', '#2d98da', '#4b7bec', '#3867d6', '#a55eea', '#8854d0', '#1e3799', '#0c2461', '#3c6382', '#0a3d62'];
3 | const pseudonyms = ['Red Uakari', 'Narwhal', 'Nudibranch', 'Horseshoe Crab', 'Coelacanth', 'Giant Clam', 'Chicken', 'Flying Fish', 'Eastern Mole', 'Marine Iguana', 'Pallid Sturgeon', 'Opossum', 'Snail', 'Damselfish', 'Pygmy Octopus', 'Barramundi', 'Giant Squid', 'June Bug', 'Maned Wolf', 'Genet', 'Horned Lizard', 'Box Jellyfish', 'Cormorant', 'Starfish', 'Swordfish', 'Gecko', 'Turkey', 'Whale Shark', 'Wolverine', 'Cattle', 'Warthog', 'Walrus', 'Ostrich', 'Piglet Squid', 'Humpback Whale', 'Ladybug', 'Butterfly', 'Glass Frog', 'Elephants', 'Toucan', 'Elephant Seal', 'Teacup Pig', 'Octopus', 'Seahorse', 'Firefly', 'Leafy Seadragon', 'Musk Ox', 'Chameleon', 'Cuttlefish', 'Puffer Fish', 'Zebra Finch', 'Kuhli Loach', 'Lionfish', 'Argonaut', 'Dingo', 'Quetzal', 'Skunk', 'Butterflyfish', 'Hippopotamus', 'Impala', 'White Rhinoceros', 'Proboscis Monkey', 'Snowy Owl', 'Peacock', 'Llama', 'Domesticated Duck', 'Grizzly Bear', 'Kangaroo Rat', 'Tawny Frogmouth', 'Aardvark', 'Axolotl', 'Plover', 'Boar', 'Black Rhinoceros', 'Bison', 'Arabian camel', 'Emu', 'Vole', 'Panther', 'Reindeer', 'Banteng', 'Echidna', 'Tapir', 'Flamingo', 'Takin', 'Okapi', 'Cape Buffalo', 'Bengal Tiger', 'Galapagos Tortoise', 'Great Egret', 'Addax', 'Wildebeest', 'Sumatran Tiger', 'Snares Penguin', 'Pangolin', 'European Wolf', 'Oryx', 'Dwarf Zebu', 'Lion', 'Serow', 'Bactrian Camel', 'Sloth', 'Jaguarundi', 'Weasel', 'Cougars', 'Kudu', 'Donkey', 'Baboon', 'Rockhopper Penguin', 'Coyote', 'Alpaca', 'Mole rat', 'Domestic goat', 'Topi', 'Moose', 'Golden Mole', 'Transcaspian Urial', 'Eland', 'Marbled Polecat', 'Ross Seal', 'Cheetah', 'Spectacled Bear', 'Red Fox', 'Pronghorn', 'Bobcat', 'Horse', 'Tenrec', 'Black Bear', 'Sifaka', 'Mountain Beaver', 'Pig', 'Bearded Pig', 'Puma', 'Manatee', 'Lemming', 'Gorilla', 'Iberian Mole', 'Polar Bear', 'Mallard', 'Mountain Bongo', 'River Dolphin', 'Spotted Hyena', 'Musk Deer', 'American Wolf', 'Liger', 'Orca', 'Mountain Goat', 'Spotted Seal', 'Onager', 'Sitatunga', 'Olingo', 'Colugo', 'African Linsang', 'Numbat', 'Coypu', 'Spinner Dolphin', 'Peccary', 'Dugong', 'Elephant Shrew', 'Barbirusa Pig', 'Muntjac', 'Nyala', 'Owl', 'Chuditch', 'Springhaas', 'Porcupine', 'Kouprey', 'Sloth Bear', 'Dibbler', 'Robin', 'Nilgai', 'Gerenuk', 'Yak', 'Tamandua', 'Klipspringer', 'Southern Viscacha', 'Tahr', 'Mouflon', 'Weddell Seal', 'Miniature Horse', 'Badger', 'Blackbuck', 'Goral', 'Sandpipers', 'Argali', 'Quoll', 'Baikal Seal', 'Pademelon', 'Dibatag', 'Brocket Deer', 'Emperor Tamarin', 'Bighorn Sheep', 'Hartebeest', 'Aoudad', 'Marmot', 'Hog Deer', 'Norway Rat', 'Gaur', 'Caspian Seal', 'Iriomote Cat', 'Anoa', 'Hutia', 'Ibex', 'Ocelot', 'Rhebok', 'Fishing Cat', 'Markhor', 'Kinkajou', 'Paca', 'Caracal', 'Bearded Seal', 'Tayra', 'Pudu', 'Pampas Cat', 'Guanaco', 'Anteater', 'Wallaroo', 'Taruca', 'Lesser Grison', 'Chiru', 'Sable', 'Zebra', 'Pygmy Anteater', 'Cuscus', 'Common Seal', 'Saola', 'Oncilla', 'Snow Leopard', 'Suni', 'Lynx', 'Antelope', 'King Penguin', 'Muskrat', 'Mouse Lemur', 'Greater Bilby', 'Mink', 'Black Squirrel', 'Ribbon Seal', 'Gibbon', 'Zorilla', 'Agouti', 'Springbok', 'Andean Bear', 'Fiordland Penguin', 'Platypus', 'Patgonian Cavy', 'Giant Otter', 'Macaroni Penguin', 'Woodchuck', 'Aardwolf', 'Bush Dog', 'Margay', 'Kob', 'Dhole', 'Crabeater Seal', 'Saiga', 'Ringed Seal', 'Lechwe', 'Beira', 'Tasmanian Devil', 'Elk', 'Humboldt Penguin', 'Puku', 'Galago', 'Gazelle', 'Magellanic Penguin', 'African Buffalo', 'Eurasian Otter', 'Raccoon Dog', 'Tarsier', 'Kowari', 'Fallow Deer', 'Royal Penguin', 'Agile Mangabey', 'Snow Leopard', 'Suslik', 'Blue Monkey', 'Beaver', 'Solenodon', 'Potto', 'Jerboa', 'Pine Marten', 'Chevrotain', 'Arctic Fox', 'Sun Bear', 'Pocket Gopher', 'Fisher', 'Orangutan', 'Stoat', 'Patagonian Opossum', 'Chickadee', 'Langur', 'Gelada Baboon', 'Emperor Penguin', 'Leopard Seal', 'Domestic Sheep', 'Wildcat', 'Tree Hyrax', 'Pygmy Marmoset', 'Kodkod', 'Gundi', 'Zokor', 'Titi', 'Grivet', 'Bearded Saki', 'Pygmy Hog', 'Gelada Monkey', 'Phascogale', 'Pika', 'Mandrill', 'Squirrel Monkey', 'Culpeo', 'Vervet', 'Pygmy Hippopotamus', 'Gerbil', 'Coati', 'Tur', 'Black Stork', 'Giraffe', 'Surili', 'Guenon', 'Honey Badger', 'Tree Squirrel', 'Hedgehog', 'Canary', 'Flying Squirrel', 'Hamster', 'Drill', 'Eastern Cottontail', 'Bonobo', 'Wombat', 'Saki Monkey', 'Jackrabbit', 'Malbrouck', 'Dwarf Mongoose', 'Chipmunk', 'Snowshoe Hare', 'Serval', 'Deer', 'Hummingbird', 'Rabbit', 'Quokka', 'Dwarf Rabbit', 'Aye Aye', 'Gray Seal', 'Chinchilla', 'Duiker', 'Mouse', 'Kangaroo', 'Puffin', 'Bush Baby', 'Sand Cat', 'Kultarr', 'Beluga Whale', 'Monkey', 'Koala', 'Slow Loris', 'Kiwi', 'Bottlenose Dolphin', 'Civet Cat', 'Degu', 'Oribi', 'Lutung', 'Chinstrap Penguin', 'Siamang', 'False Antechinus', 'Elephant Seal', 'Talapoin', 'Gymnure', 'Kipunji', 'Douc', 'Desman', 'Chimpanzee', 'Guinea Pig', 'Colubus', 'Blue Penguin', 'Gentoo Penguin', 'Common Planigale', 'Sugar Glider', 'Mulgara', 'Dormouse', 'Wallaby', 'Clown Fish', 'Clouded Leopard', 'Prairie Dog', 'Antechinus', 'Capybara', 'Macaque', 'Adelie Penguin', 'Harp Seal', 'Binturong', 'Meerkat', 'Dunnart', 'Red Panda', 'Rock Hyrax', 'Dog', 'Fennec Fox', 'Giant Panda', 'Cat', 'Sea Otter'];
4 |
5 | function getPseudonym(userId) {
6 | const pseudonymId = Math.abs(userId.hashCode()) % pseudonyms.length;
7 | return pseudonyms[pseudonymId];
8 | }
9 | function getUsername(group, userId) {
10 | let name;
11 | if (userId in group.members && group.members[userId].info !== undefined) {
12 | name = group.members[userId].info.publicName;
13 | }
14 | if (name === undefined) {
15 | name = getPseudonym(userId);
16 | }
17 | return name;
18 | }
19 | function getColor(userId) {
20 | const colorId = Math.abs(userId.hashCode()) % colors.length;
21 | return colors[colorId];
22 | }
23 | function getInitials(username) {
24 | const nameparts = username.split(' ');
25 | let initials = nameparts[0].substr(0, 2);
26 | if (nameparts.length > 1) {
27 | initials = initials[0] + nameparts[nameparts.length - 1].substr(0, 1);
28 | }
29 | return initials;
30 | }
31 |
32 | export default {
33 | updateLocation(group, envelope, container) {
34 | const { senderId } = envelope;
35 | const location = { senderId };
36 | if (container !== undefined) {
37 | location.timestamp = container.payload.location.timestamp;
38 | location.coordinates = [container.payload.location.longitude, container.payload.location.latitude];
39 | location.hAccuracy = container.payload.location.horizontalAccuracy;
40 | } else {
41 | location.timestamp = new Date().toISOString();
42 | }
43 | location.color = getColor(senderId);
44 | location.name = getUsername(group, senderId);
45 | location.initials = getInitials(location.name);
46 | locations[senderId] = location;
47 | return locations;
48 | },
49 | updateUsername(userId, group) {
50 | if (locations[userId] !== undefined) {
51 | locations[userId].name = getUsername(group, userId);
52 | }
53 | return locations;
54 | },
55 | filterLocations(group) {
56 | const memberIds = group.memberships.map((member) => member.userId);
57 | const locationKeys = Object.keys(locations);
58 | locationKeys.forEach((key) => {
59 | if (memberIds.indexOf(key) === -1) {
60 | delete locations[key];
61 | }
62 | const tenMinutesAgo = new Date();
63 | tenMinutesAgo.setMinutes(tenMinutesAgo.getMinutes() - 10);
64 | if (new Date(locations[key].timestamp) < tenMinutesAgo) {
65 | delete locations[key];
66 | }
67 | });
68 | return locations;
69 | },
70 | deleteLocation(userId) {
71 | delete locations[userId];
72 | return locations;
73 | },
74 | getUsername,
75 | getColor,
76 | getInitials,
77 | };
78 |
79 | /* eslint-disable no-extend-native, no-bitwise, no-plusplus, func-names */
80 | String.prototype.hashCode = function () {
81 | let hash = 0; let i; let chr;
82 | if (this.length === 0) return hash;
83 | for (i = 0; i < this.length; i++) {
84 | chr = this.charCodeAt(i);
85 | hash = ((hash << 5) - hash) + chr;
86 | hash |= 0;
87 | }
88 | return hash;
89 | };
90 | /* eslint-enable no-extend-native, no-bitwise, no-plusplus, func-names */
91 |
--------------------------------------------------------------------------------
/src/utils/Logger.js:
--------------------------------------------------------------------------------
1 | const logs = [];
2 | const debugMode = window.location.href.indexOf('/tice.app/') === -1;
3 |
4 | function log(logLevel, msg) {
5 | let logMsg = `[${(new Date()).toLocaleString()} `;
6 | switch (logLevel) {
7 | case 'WARNING':
8 | logMsg += 'WRNNG';
9 | break;
10 | case 'INFO':
11 | logMsg += 'INFOS';
12 | break;
13 | default:
14 | logMsg += logLevel;
15 | }
16 | logMsg += `] ${msg}`;
17 | logs.push(logMsg);
18 |
19 | /* eslint-disable no-console */
20 | switch (logLevel) {
21 | case 'ERROR':
22 | console.error(logMsg);
23 | break;
24 | case 'WARNING':
25 | console.warn(logMsg);
26 | break;
27 | case 'INFO':
28 | console.info(logMsg);
29 | break;
30 | case 'TRACE':
31 | if (debugMode) {
32 | console.debug(logMsg);
33 | }
34 | break;
35 | default:
36 | if (debugMode) {
37 | console.log(logMsg);
38 | }
39 | }
40 | /* eslint-enable no-console */
41 | }
42 |
43 | export default {
44 | trace: (msg) => log('TRACE', msg),
45 | debug: (msg) => log('DEBUG', msg),
46 | info: (msg) => log('INFO', msg),
47 | warning: (msg) => log('WARNING', msg),
48 | error: (msg) => log('ERROR', msg),
49 | };
50 |
51 | export function getLogs() {
52 | if (logs.length > 0) {
53 | return `Logs:\n\n${logs.join('\n')}`;
54 | }
55 | return '';
56 | }
57 |
--------------------------------------------------------------------------------
/src/utils/i18n.js:
--------------------------------------------------------------------------------
1 | import VueI18n from 'vue-i18n';
2 | import Vue from 'vue';
3 | import Logger from './Logger';
4 | import messagesEN from '../lang/en.json';
5 | import messagesDE from '../lang/de.json';
6 |
7 | Vue.use(VueI18n);
8 | const messages = { en: messagesEN, de: messagesDE };
9 |
10 | export const i18n = new VueI18n({
11 | locale: 'en',
12 | fallbackLocale: 'en',
13 | messages,
14 | });
15 |
16 | const loadedLanguages = ['en', 'de'];
17 | function setI18nLanguage(lang) {
18 | i18n.locale = lang;
19 | Vue.prototype.$timeago.locale = lang;
20 | document.querySelector('html').setAttribute('lang', lang);
21 | return lang;
22 | }
23 |
24 | export function setLanguage(lang) {
25 | Logger.debug(`Set language to ${lang}`);
26 | // If the same language
27 | if (i18n.locale === lang) {
28 | return Promise.resolve(setI18nLanguage(lang));
29 | }
30 |
31 | // If the language was already loaded
32 | if (loadedLanguages.includes(lang)) {
33 | return Promise.resolve(setI18nLanguage(lang));
34 | }
35 | Logger.warning(`Can't set language to ${lang}, so fall back to english.`);
36 | return Promise.resolve();
37 |
38 | // If the language hasn't been loaded yet
39 | // return import(/* webpackChunkName: "lang-[request]" */ `@/lang/${lang}`).then(
40 | // (newMessages) => {
41 | // i18n.setLocaleMessage(lang, newMessages.default);
42 | // loadedLanguages.push(lang);
43 | // return setI18nLanguage(lang);
44 | // },
45 | // );
46 | }
47 |
--------------------------------------------------------------------------------
/src/utils/iso3316.json:
--------------------------------------------------------------------------------
1 | {"IM":"IMN","HR":"HRV","GW":"GNB","IN":"IND","KE":"KEN","LA":"LAO","IO":"IOT","HT":"HTI","LB":"LBN","GY":"GUY","KG":"KGZ","HU":"HUN","LC":"LCA","IQ":"IRQ","KH":"KHM","JM":"JAM","IR":"IRN","KI":"KIR","IS":"ISL","MA":"MAR","JO":"JOR","IT":"ITA","JP":"JPN","MC":"MCO","KM":"COM","MD":"MDA","LI":"LIE","KN":"KNA","ME":"MNE","NA":"NAM","MF":"MAF","LK":"LKA","KP":"PRK","MG":"MDG","NC":"NCL","MH":"MHL","KR":"KOR","NE":"NER","NF":"NFK","MK":"MKD","NG":"NGA","ML":"MLI","MM":"MMR","LR":"LBR","NI":"NIC","KW":"KWT","MN":"MNG","LS":"LSO","PA":"PAN","MO":"MAC","LT":"LTU","KY":"CYM","MP":"MNP","LU":"LUX","NL":"NLD","KZ":"KAZ","MQ":"MTQ","LV":"LVA","MR":"MRT","PE":"PER","MS":"MSR","QA":"QAT","NO":"NOR","PF":"PYF","MT":"MLT","LY":"LBY","NP":"NPL","PG":"PNG","MU":"MUS","PH":"PHL","MV":"MDV","OM":"OMN","NR":"NRU","MW":"MWI","MX":"MEX","PK":"PAK","MY":"MYS","NU":"NIU","PL":"POL","MZ":"MOZ","PM":"SPM","PN":"PCN","RE":"REU","SA":"SAU","SB":"SLB","NZ":"NZL","SC":"SYC","SD":"SDN","PR":"PRI","SE":"SWE","PS":"PSE","PT":"PRT","SG":"SGP","TC":"TCA","SH":"SHN","TD":"TCD","SI":"SVN","PW":"PLW","SJ":"SJM","UA":"UKR","RO":"ROU","TF":"ATF","SK":"SVK","PY":"PRY","TG":"TGO","SL":"SLE","TH":"THA","SM":"SMR","SN":"SEN","RS":"SRB","TJ":"TJK","VA":"VAT","SO":"SOM","TK":"TKL","UG":"UGA","RU":"RUS","TL":"TLS","VC":"VCT","TM":"TKM","SR":"SUR","RW":"RWA","TN":"TUN","VE":"VEN","SS":"SSD","TO":"TON","ST":"STP","VG":"VGB","SV":"SLV","UM":"UMI","TR":"TUR","VI":"VIR","SX":"SXM","WF":"WLF","TT":"TTO","SY":"SYR","SZ":"SWZ","TV":"TUV","TW":"TWN","VN":"VNM","US":"USA","TZ":"TZA","YE":"YEM","ZA":"ZAF","UY":"URY","VU":"VUT","UZ":"UZB","WS":"WSM","ZM":"ZMB","AD":"AND","YT":"MYT","AE":"ARE","BA":"BIH","AF":"AFG","BB":"BRB","AG":"ATG","BD":"BGD","AI":"AIA","BE":"BEL","CA":"CAN","BF":"BFA","BG":"BGR","ZW":"ZWE","AL":"ALB","CC":"CCK","BH":"BHR","AM":"ARM","CD":"COD","BI":"BDI","BJ":"BEN","AO":"AGO","CF":"CAF","CG":"COG","BL":"BLM","AQ":"ATA","CH":"CHE","BM":"BMU","AR":"ARG","CI":"CIV","BN":"BRN","DE":"DEU","AS":"ASM","BO":"BOL","AT":"AUT","CK":"COK","AU":"AUS","CL":"CHL","EC":"ECU","BQ":"BES","CM":"CMR","BR":"BRA","AW":"ABW","CN":"CHN","EE":"EST","BS":"BHS","DJ":"DJI","AX":"ALA","CO":"COL","BT":"BTN","DK":"DNK","EG":"EGY","AZ":"AZE","EH":"ESH","BV":"BVT","DM":"DMA","CR":"CRI","BW":"BWA","GA":"GAB","DO":"DOM","BY":"BLR","GB":"GBR","CU":"CUB","BZ":"BLZ","CV":"CPV","GD":"GRD","FI":"FIN","CW":"CUW","GE":"GEO","FJ":"FJI","CX":"CXR","GF":"GUF","FK":"FLK","CY":"CYP","GG":"GGY","CZ":"CZE","GH":"GHA","FM":"FSM","ER":"ERI","GI":"GIB","ES":"ESP","FO":"FRO","ET":"ETH","GL":"GRL","DZ":"DZA","GM":"GMB","ID":"IDN","FR":"FRA","GN":"GIN","IE":"IRL","HK":"HKG","GP":"GLP","GQ":"GNQ","HM":"HMD","GR":"GRC","HN":"HND","JE":"JEY","GS":"SGS","GT":"GTM","GU":"GUM","IL":"ISR"}
2 |
--------------------------------------------------------------------------------
/test/README.md:
--------------------------------------------------------------------------------
1 | # Development to run tests
2 |
3 | To run the tests, the (for now private) submodules `Server` and `CnC` are needed.
4 |
5 | ## Project setup
6 | ```
7 | yarn install
8 | ```
9 |
10 | ### Compiles and hot-reloads for development
11 | ```
12 | yarn run serve
13 | ```
14 |
15 | ### Compiles and minifies for production
16 | ```
17 | yarn run build
18 | ```
19 |
20 | ### Run your tests
21 |
22 | To run selenium tests locally run:
23 | ```
24 | yarn run test:local
25 | ```
26 | `local` needs firefox and a geckodriver to be installed (e.g. `brew cask install firefox` and `brew install geckodriver`)
27 |
28 | To run tests on BrowserStack, configure the env variables `BROWSERSTACK_USERNAME` and `BROWSERSTACK_ACCESS_KEY` and run
29 | ```
30 | yarn run test:remote
31 | ```
32 |
--------------------------------------------------------------------------------
/test/local.test.js:
--------------------------------------------------------------------------------
1 | const {
2 | describe, beforeEach, afterEach, it,
3 | } = require('mocha');
4 | const { Builder } = require('selenium-webdriver');
5 | const firefox = require('selenium-webdriver/firefox');
6 | const http = require('http');
7 |
8 | const {
9 | testLoadGroup, testChangeName, testShareLocation, testMe, testSendMessage, testReceiveMessage, testCnCNameChange, testDeleteData, testCookies,
10 | testNoUsernameAtLogin, testMoreInformation, testFeedbackAtLogin, testAboutTICESoftware, testAboutImpressum, testAboutTICEApp, testAboutInstagram,
11 | testAboutFacebook, testAboutTwitter,
12 | } = require('./test');
13 |
14 | const httpAgent = new http.Agent({ keepAlive: true });
15 |
16 | describe('Local', () => {
17 | let localDriver;
18 |
19 | beforeEach(() => {
20 | localDriver = new Builder()
21 | .usingHttpAgent(httpAgent)
22 | .forBrowser('firefox')
23 | .setFirefoxOptions(new firefox.Options().setPreference('general.useragent.override', 'Test'))
24 | .build();
25 | });
26 | afterEach(async () => { await localDriver.quit(); });
27 |
28 | it('should join group', async () => {
29 | await testLoadGroup(localDriver);
30 | }).timeout(60000);
31 | it('should change name', async () => {
32 | await testChangeName(localDriver);
33 | }).timeout(60000);
34 | it('should share location', async () => {
35 | await testShareLocation(localDriver);
36 | }).timeout(60000);
37 | it('should know me', async () => {
38 | await testMe(localDriver);
39 | }).timeout(60000);
40 | it('should send message', async () => {
41 | await testSendMessage(localDriver);
42 | }).timeout(60000);
43 | it.skip('should receive message', async () => {
44 | await testReceiveMessage(localDriver);
45 | }).timeout(60000);
46 | it.skip('should receive changed name', async () => {
47 | await testCnCNameChange(localDriver);
48 | }).timeout(60000);
49 | it('should delete data', async () => {
50 | await testDeleteData(localDriver);
51 | }).timeout(60000);
52 | it('should load Cookies', async () => {
53 | await testCookies(localDriver);
54 | }).timeout(60000);
55 | it('should throw error if username is empty', async () => {
56 | await testNoUsernameAtLogin(localDriver);
57 | }).timeout(60000);
58 | it('should show more information page', async () => {
59 | await testMoreInformation(localDriver);
60 | }).timeout(60000);
61 | it('should forward to feedback popup', async () => {
62 | await testFeedbackAtLogin(localDriver);
63 | }).timeout(60000);
64 | it('should open TICE software page', async () => {
65 | await testAboutTICESoftware(localDriver);
66 | }).timeout(60000);
67 | it('should open Impressum page', async () => {
68 | await testAboutImpressum(localDriver);
69 | }).timeout(60000);
70 | it('should open TICE App page', async () => {
71 | await testAboutTICEApp(localDriver);
72 | }).timeout(60000);
73 | it('should open TICE Instagram page', async () => {
74 | await testAboutInstagram(localDriver);
75 | }).timeout(60000);
76 | it('should open TICE Facebook page', async () => {
77 | await testAboutFacebook(localDriver);
78 | }).timeout(60000);
79 | it('should open TICE Twitter page', async () => {
80 | await testAboutTwitter(localDriver);
81 | }).timeout(60000);
82 | });
83 |
--------------------------------------------------------------------------------
/test/remote.test.js:
--------------------------------------------------------------------------------
1 | const { describe, beforeEach, it } = require('mocha');
2 | const { Builder } = require('selenium-webdriver');
3 | const http = require('http');
4 | const gitRevision = require('child_process')
5 | .execSync('git rev-parse HEAD')
6 | .toString().trim();
7 | const {
8 | testLoadGroup, testChangeName, testShareLocation, testMe, testAboutImpressum, testAboutTwitter, testAboutFacebook, testAboutInstagram, testAboutTICEApp,
9 | testAboutTICESoftware, testFeedbackAtLogin, testNoUsernameAtLogin, testMoreInformation, testCookies, testDeleteData, testCnCNameChange, testReceiveMessage,
10 | testSendMessage,
11 | } = require('./test');
12 |
13 | const httpAgent = new http.Agent({ keepAlive: true });
14 |
15 | const BROWSERSTACK_USERNAME = process.env.BROWSERSTACK_USERNAME || 'BROWSERSTACK_USERNAME';
16 | const BROWSERSTACK_ACCESS_KEY = process.env.BROWSERSTACK_ACCESS_KEY || 'BROWSERSTACK_ACCESS_KEY';
17 |
18 | const mobileDeviceConfigurations = [
19 | {
20 | browserName: 'android',
21 | device: 'Google Pixel 4',
22 | os_version: '10.0',
23 | realMobile: 'true',
24 | },
25 | {
26 | browserName: 'android',
27 | device: 'Motorola Moto X 2nd Gen',
28 | os_version: '6.0',
29 | realMobile: 'true',
30 | },
31 | {
32 | browserName: 'android',
33 | device: 'Samsung Galaxy S6',
34 | os_version: '5.0',
35 | realMobile: 'true',
36 | },
37 | ];
38 |
39 | const defaultCapabilities = {
40 | environment: 'develop',
41 | project: 'TICE',
42 | 'browserstack.console': 'verbose',
43 | build: gitRevision,
44 | };
45 |
46 | const remoteTest = function describeTest(deviceConfiguration) {
47 | describe(deviceConfiguration.device, () => {
48 | const capabilities = { ...defaultCapabilities, ...deviceConfiguration };
49 | let driver;
50 |
51 | beforeEach(() => {
52 | driver = new Builder()
53 | .usingHttpAgent(httpAgent)
54 | .usingServer(`http://${BROWSERSTACK_USERNAME}:${BROWSERSTACK_ACCESS_KEY}@hub-cloud.browserstack.com/wd/hub`)
55 | .withCapabilities(capabilities)
56 | .build();
57 | });
58 |
59 | it('should join and leave group', async () => {
60 | this.timeout(240000);
61 | await testLoadGroup(driver);
62 | await driver.quit();
63 | }).timeout(240000);
64 | it('should change and delete name', async () => {
65 | this.timeout(240000);
66 | await testChangeName(driver);
67 | await driver.quit();
68 | }).timeout(240000);
69 | it('should check and stop share Location', async () => {
70 | this.timeout(240000);
71 | await testShareLocation(driver);
72 | await driver.quit();
73 | }).timeout(240000);
74 | it('should check who I am', async () => {
75 | this.timeout(240000);
76 | await testMe(driver);
77 | await driver.quit();
78 | }).timeout(240000);
79 | it('should send a message', async () => {
80 | this.timeout(240000);
81 | await testSendMessage(driver);
82 | await driver.quit();
83 | }).timeout(240000);
84 | it('should receive a message', async () => {
85 | this.timeout(240000);
86 | await testReceiveMessage(driver);
87 | await driver.quit();
88 | }).timeout(240000);
89 | it('should recieve a changed name', async () => {
90 | this.timeout(240000);
91 | await testCnCNameChange(driver);
92 | await driver.quit();
93 | }).timeout(240000);
94 | it('should delete data', async () => {
95 | this.timeout(240000);
96 | await testDeleteData(driver);
97 | await driver.quit();
98 | }).timeout(240000);
99 | it('should load Cookies at refresh', async () => {
100 | this.timeout(240000);
101 | await testCookies(driver);
102 | await driver.quit();
103 | }).timeout(240000);
104 | it('should throw error if username is empty', async () => {
105 | this.timeout(240000);
106 | await testNoUsernameAtLogin(driver);
107 | await driver.quit();
108 | }).timeout(240000);
109 | it('should show more information page', async () => {
110 | this.timeout(240000);
111 | await testMoreInformation(driver);
112 | await driver.quit();
113 | }).timeout(240000);
114 | it('should forward to feedback popup', async () => {
115 | this.timeout(240000);
116 | await testFeedbackAtLogin(driver);
117 | await driver.quit();
118 | }).timeout(240000);
119 | it('should open TICE software page', async () => {
120 | this.timeout(240000);
121 | await testAboutTICESoftware(driver);
122 | await driver.quit();
123 | }).timeout(240000);
124 | it('should open Impressum page', async () => {
125 | this.timeout(240000);
126 | await testAboutImpressum(driver);
127 | await driver.quit();
128 | }).timeout(240000);
129 | it('should open TICE App page', async () => {
130 | this.timeout(240000);
131 | await testAboutTICEApp(driver);
132 | await driver.quit();
133 | }).timeout(240000);
134 | it('should open TICE Instagram page', async () => {
135 | this.timeout(240000);
136 | await testAboutInstagram(driver);
137 | await driver.quit();
138 | }).timeout(240000);
139 | it('should open TICE Facebook page', async () => {
140 | this.timeout(240000);
141 | await testAboutFacebook(driver);
142 | await driver.quit();
143 | }).timeout(240000);
144 | it('should open TICE Twitter page', async () => {
145 | this.timeout(240000);
146 | await testAboutTwitter(driver);
147 | await driver.quit();
148 | }).timeout(240000);
149 | });
150 | };
151 |
152 | mobileDeviceConfigurations.forEach(remoteTest);
153 |
--------------------------------------------------------------------------------
/test/scripts/startServer.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | export SERVER_PORT=1680
4 | export SERVER_HOST="127.0.0.1"
5 | export CNC_SERVER_PORT=1650
6 | export CNC_SERVER_HOST="127.0.0.1"
7 | export WEB_APP_PORT=1600
8 | export WEB_APP_HOST="127.0.0.1"
9 |
10 | # CnC expects server backend at BACKEND_HOST:BACKEND_PORT
11 | export BACKEND_PORT=${SERVER_PORT}
12 | export BACKEND_HOST=${SERVER_HOST}
13 |
14 | RED=''
15 | GREEN=''
16 | RESET=''
17 |
18 | START_SERVER=1
19 | START_CNC_SERVER=1
20 | START_WEB_APP=1
21 |
22 | if nc -z ${SERVER_HOST} ${SERVER_PORT} > /dev/null 2>&1; then
23 | echo "${RED}WARNING: Server - An unknown server listens on port ${SERVER_PORT}. Not starting the Server.${RESET}"
24 | START_SERVER=0
25 | fi
26 |
27 | if nc -z ${CNC_SERVER_HOST} ${CNC_SERVER_PORT} > /dev/null 2>&1; then
28 | echo "${RED}WARNING: CnC Server - An unknown server listens on port ${CNC_SERVER_PORT}. Not starting the CnC Server.${RESET}"
29 | START_CNC_SERVER=0
30 | fi
31 |
32 | if nc -z ${WEB_APP_HOST} ${WEB_APP_PORT} > /dev/null 2>&1; then
33 | echo "${RED}WARNING: Web App - An unknown server listens on port ${WEB_APP_PORT}. Not starting the Web App.${RESET}"
34 | START_WEB_APP=0
35 | fi
36 |
37 | rm server.log
38 | rm cnc_server.log
39 | rm web_app.log
40 |
41 | if [ $START_SERVER -eq 1 ]; then
42 | echo "Building and starting Server on port ${SERVER_PORT}"
43 | make -C Server build
44 | nohup make -C Server run > server.log 2>&1 &
45 | SERVER_PID=$!
46 | echo $SERVER_PID > server.pid
47 | echo "${GREEN}Started Server on port ${SERVER_PORT} with PID ${SERVER_PID}.${RESET}"
48 | else
49 | echo "${RED}WARNING: Using unknown Server on port ${SERVER_PORT}${RESET}"
50 | fi
51 |
52 | if [ $START_CNC_SERVER -eq 1 ]; then
53 | echo "Building and starting CnC Server on port ${CNC_SERVER_PORT}"
54 | make -C CnC build
55 | nohup make -C CnC run > cnc_server.log 2>&1 &
56 | CNC_SERVER_PID=$!
57 | echo $CNC_SERVER_PID > cnc_server.pid
58 | echo "${GREEN}Started CnC Server on port ${CNC_SERVER_PORT} with PID ${CNC_SERVER_PID}.${RESET}"
59 | else
60 | echo "${RED}WARNING: Using unknown CnC Server on port ${CNC_SERVER_PORT}${RESET}"
61 | fi
62 |
63 | if [ $START_WEB_APP -eq 1 ]; then
64 | echo "Building and starting Web App on port ${WEB_APP_PORT}"
65 | export VUE_APP_USE_TLS=""
66 | export VUE_APP_API_URL="${SERVER_HOST}:${SERVER_PORT}"
67 | nohup yarn run serve --port ${WEB_APP_PORT} > web_app.log 2>&1 &
68 | WEB_APP_PID=$!
69 | echo $WEB_APP_PID > web_app.pid
70 | echo "${GREEN}Started Web App on port ${WEB_APP_PORT} with PID ${WEB_APP_PID}.${RESET}"
71 | else
72 | echo "${RED}WARNING: Using unknown Web App on port ${WEB_APP_PORT}${RESET}"
73 | fi
74 |
75 | echo $SERVER_PORT > server.port
76 | echo $CNC_SERVER_PORT > cnc_server.port
77 | echo $WEB_APP_PORT > web_app.port
78 |
79 | echo "Waiting for the servers to be serving…"
80 |
81 | while ! nc -z ${SERVER_HOST} ${SERVER_PORT}; do
82 | sleep 1
83 | if ! [ $START_SERVER ] && kill -0 $SERVER_PID; then
84 | echo "${RED}ERROR: Server was terminated before it was up and running.${RESET}"
85 | echo "Contents of server.log:"
86 | cat server.log
87 | exit 1
88 | fi
89 | done
90 |
91 | while ! nc -z ${CNC_SERVER_HOST} ${CNC_SERVER_PORT}; do
92 | sleep 1
93 | if ! [ $START_CNC_SERVER ] && kill -0 $CNC_SERVER_PID; then
94 | echo "${RED}ERROR: CnC Server was terminated before it was up and running.${RESET}"
95 | echo "Contents of cnc_server.log:"
96 | cat cnc_server.log
97 | exit 1
98 | fi
99 | done
100 |
101 | while ! nc -z ${WEB_APP_HOST} ${WEB_APP_PORT}; do
102 | sleep 1
103 | if ! [ $START_WEB_APP ] && kill -0 $WEB_APP_PID; then
104 | echo "${RED}ERROR: Web App was terminated before it was up and running.${RESET}"
105 | echo "Contents of web_app.log:"
106 | cat web_app.log
107 | exit 1
108 | fi
109 | done
110 |
111 | echo "${GREEN}All servers are reachable.${RESET}"
112 |
--------------------------------------------------------------------------------
/test/scripts/stopServer.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | if [ -f server.port ]
4 | then
5 | rm server.port
6 | fi
7 |
8 | if [ -f cnc_server.port ]
9 | then
10 | rm cnc_server.port
11 | fi
12 |
13 | if [ -f web_app.port ]
14 | then
15 | rm web_app.port
16 | fi
17 |
18 | if [ -f server.pid ]
19 | then
20 | kill -TERM $(cat server.pid) || true
21 | rm server.pid
22 | echo "Stopped Server"
23 | else
24 | echo "Server not running"
25 | fi
26 |
27 | if [ -f Server/db.sqlite ]
28 | then
29 | rm Server/db.sqlite
30 | echo "Deleted server database"
31 | else
32 | echo "No server database found"
33 | fi
34 |
35 | if [ -f cnc_server.pid ]
36 | then
37 | kill -TERM $(cat cnc_server.pid) || true
38 | rm cnc_server.pid
39 | echo "Stopped CnC Server"
40 | else
41 | echo "CnC Server not running"
42 | fi
43 |
44 | if [ -f web_app.pid ]
45 | then
46 | kill -TERM $(cat web_app.pid) || true
47 | rm web_app.pid
48 | echo "Stopped Web App"
49 | else
50 | echo "Web App not running"
51 | fi
52 |
--------------------------------------------------------------------------------
/test/test.js:
--------------------------------------------------------------------------------
1 | const { By, until } = require('selenium-webdriver');
2 | const { expect } = require('chai');
3 | const axios = require('axios');
4 |
5 | const cncApi = axios.create({
6 | baseURL: 'http://localhost:1650',
7 | });
8 |
9 | async function requestCNC(method, url, data) {
10 | const call = cncApi.request({ method, url, data });
11 | return call.then((res) => {
12 | if (res.data.success === true) {
13 | return res.data.result;
14 | } if (res.data.error !== undefined) {
15 | throw new Error(`${res.data.error.type}:${res.data.error.description}`);
16 | } else {
17 | throw new Error(`Unknown error: ${JSON.stringify(res.data)}`);
18 | }
19 | });
20 | }
21 |
22 | const groupName = 'Test';
23 | const userName = 'Testuser';
24 | let user;
25 | let group;
26 |
27 | async function initialize(driver) {
28 | user = await requestCNC('POST', '/user');
29 | group = await requestCNC('POST', `/user/${user.userId}/group`, {
30 | type: 'team',
31 | joinMode: 'open',
32 | permissionMode: 'everyone',
33 | settings: { owner: user.userId, name: groupName },
34 | });
35 | const url = `http://localhost:1600/group/${group.groupId}#${group.groupKey}`;
36 | await driver.get(url);
37 | }
38 |
39 | async function loadUntilWelcomeBox(driver) {
40 | await initialize(driver);
41 | await driver.wait(until.elementLocated(By.className('el-button--primary')));
42 | }
43 |
44 | async function register(driver, dialog) {
45 | await dialog.findElement(By.className('el-input__inner')).sendKeys(userName);
46 | await dialog.findElement(By.className('el-button--primary')).click();
47 | const mapbox = await driver.wait(until.elementLocated(By.className('mapboxgl-map')));
48 | await driver.wait(until.elementIsVisible(mapbox));
49 | }
50 |
51 | async function loadUntilMap(driver) {
52 | await loadUntilWelcomeBox(driver);
53 | const dialog = await driver.findElement(By.className('el-dialog__wrapper'));
54 | await register(driver, dialog);
55 | }
56 |
57 | async function getGroupmembers(driver) {
58 | const titlebar = await driver.findElement(By.id('titlebar'));
59 | await titlebar.findElement(By.className('groupname')).click();
60 | const msgBox = await driver.findElement(By.className('el-message-box__wrapper'));
61 | const row = await msgBox.findElement(By.className('el-row'));
62 | const spans = await row.findElements(By.tagName('span'));
63 | return {
64 | titlebar,
65 | msgBox,
66 | spans,
67 | };
68 | }
69 |
70 | async function searchNameInGroupinfo(driver, searchedName) {
71 | const members = (await getGroupmembers(driver)).spans;
72 | for (let i = 0; i < members.length; i += 1) {
73 | // eslint-disable-next-line no-await-in-loop
74 | const name = await members[i].getText();
75 | if (name === searchedName || name === `${searchedName} (Du)`) {
76 | return name;
77 | }
78 | }
79 | return '';
80 | }
81 |
82 | async function waitAndSwitch(driver) {
83 | await driver.wait(
84 | async () => (await driver.getAllWindowHandles()).length === 2,
85 | 10000,
86 | );
87 | const windows = await driver.getAllWindowHandles();
88 | await driver.switchTo().window(windows[1]);
89 | }
90 |
91 | async function testAbout(driver, type, name, title) {
92 | await loadUntilWelcomeBox(driver);
93 | const dialog = driver.findElement(By.className('el-dialog__wrapper'));
94 | await dialog.findElement(By.tagName('img')).click();
95 | const msgBox = await driver.findElement(By.className('el-message-box'));
96 | const originalWindow = await driver.getWindowHandle();
97 | expect((await driver.getAllWindowHandles()).length === 1);
98 | switch (type) {
99 | case 'link': await msgBox.findElement(By.linkText(name)).click(); break;
100 | case 'class': await msgBox.findElement(By.className(name)).click(); break;
101 | case 'tag': await msgBox.findElement(By.className('el-message-box__close el-icon-close')).click();
102 | await driver.wait(until.elementIsNotVisible(msgBox));
103 | await dialog.findElement(By.tagName(name)).click();
104 | break;
105 | default: break;
106 | }
107 | await waitAndSwitch(driver);
108 | await driver.wait(until.titleContains(title), 10000);
109 | const newTitle = await driver.getTitle();
110 | await driver.close();
111 | await driver.switchTo().window(originalWindow);
112 | return newTitle;
113 | }
114 |
115 | /* * * * * * * * * * * * * * * Tests * * * * * * * * * * * * * */
116 |
117 | async function testLoadGroup(driver) {
118 | await initialize(driver);
119 | const title = await driver.getTitle();
120 | expect(title).to.contain('TICE');
121 |
122 | const dialog = driver.findElement(By.className('el-dialog__wrapper'));
123 |
124 | await driver.wait(until.elementLocated(By.className('el-button--primary')));
125 |
126 | const dialogTitle = await dialog.findElement(By.className('dialog-title')).getText();
127 | expect(dialogTitle).to.equal(groupName);
128 |
129 | const membersTitle = await dialog.findElement(By.className('groupinfo-title')).getText();
130 |
131 | await dialog.findElement(By.className('el-input__inner')).sendKeys('Testuser');
132 |
133 | await dialog.findElement(By.className('el-button--primary')).click();
134 |
135 | await driver.wait(until.elementIsNotVisible(dialog));
136 |
137 | const mapbox = await driver.wait(until.elementLocated(By.className('mapboxgl-map')));
138 | await driver.wait(until.elementIsVisible(mapbox));
139 |
140 | const titleBar = await driver.findElement(By.id('titlebar'));
141 | await titleBar.findElement(By.className('username')).click();
142 |
143 | const messageBox = await driver.findElement(By.className('el-message-box'));
144 | await messageBox.findElement(By.className('el-button')).click();
145 |
146 | await driver.wait(until.elementLocated(By.className('el-button--primary')));
147 |
148 | const newDialog = driver.findElement(By.className('el-dialog__wrapper'));
149 | const newDialogTitle = await newDialog.findElement(By.className('dialog-title')).getText();
150 |
151 | expect(newDialogTitle).to.equal(groupName);
152 |
153 | const newMembersTitle = await newDialog.findElement(By.className('groupinfo-title')).getText();
154 |
155 | expect(newMembersTitle).to.equal(membersTitle);
156 | }
157 |
158 | async function testChangeName(driver) {
159 | await loadUntilMap(driver);
160 | const titleBar = await driver.findElement(By.id('titlebar'));
161 | const actualUserName = await titleBar.findElement(By.className('username')).getText();
162 | expect(actualUserName).to.equal(`${userName.substr(0, 2)}\n${userName}`);
163 | const changeName = 'Tester';
164 | await titleBar.findElement(By.className('username')).click();
165 | const msgBox = driver.findElement(By.className('el-message-box__wrapper'));
166 | await msgBox.findElement(By.className('el-input__inner')).sendKeys(changeName);
167 | await msgBox.findElement(By.className('el-message-box__close')).click();
168 |
169 | await driver.wait(until.elementIsNotVisible(msgBox));
170 | const newActualUserName = await titleBar.findElement(By.className('username')).getText();
171 | expect(newActualUserName).to.equal(`${changeName.substr(0, 2)}\n${changeName}`);
172 | expect(await searchNameInGroupinfo(driver, changeName)).to.equal(`${changeName} (Du)`);
173 | }
174 |
175 | async function testShareLocation(driver) {
176 | await loadUntilMap(driver);
177 | const groupinfo = await getGroupmembers(driver);
178 | expect(await groupinfo.spans[1].getCssValue('font-weight')).to.equal('700');
179 | await groupinfo.msgBox.findElement(By.className('el-message-box__headerbtn')).click();
180 | await driver.wait(until.elementIsNotVisible(groupinfo.msgBox));
181 | const shareLocationText = await driver.findElement(By.id('shareLocationText')).getText();
182 | expect(shareLocationText).to.equal('Standort wird geteilt');
183 | const shareLocationSubText = await driver.findElement(By.id('shareLocationSubText')).getText();
184 | expect(shareLocationSubText).to.equal('Teilen beenden');
185 | await driver.findElement(By.id('shareLocationButton')).click();
186 | const shareLocationTextNow = await driver.findElement(By.id('shareLocationText')).getText();
187 | expect(shareLocationTextNow).to.equal('Standort teilen');
188 | const shareLocationSubTextNow = await driver.findElement(By.id('shareLocationSubText')).getText();
189 | expect(shareLocationSubTextNow).to.equal('');
190 | await groupinfo.titlebar.findElement(By.className('groupname')).click();
191 | expect(await groupinfo.spans[1].getCssValue('font-weight')).to.equal((400).toString());
192 | }
193 |
194 | async function testMe(driver) {
195 | await loadUntilMap(driver);
196 | const name = await searchNameInGroupinfo(driver, userName);
197 | expect(name).to.equal(`${userName} (Du)`);
198 | }
199 |
200 | async function testSendMessage(driver) {
201 | await loadUntilMap(driver);
202 | const sendMessage = 'Hallo Welt';
203 | await driver.findElement(By.className('sc-open-icon')).click();
204 | const chatWindow = await driver.findElement(By.className('sc-chat-window opened'));
205 | await chatWindow.findElement(By.className('sc-user-input--text')).sendKeys(sendMessage);
206 | await chatWindow.findElement(By.className('sc-user-input--button-icon-wrapper')).click();
207 | await driver.wait(until.elementLocated(By.className('sc-message--content sent')));
208 | const sendMessages = await chatWindow.findElement(By.className('sc-message--content sent'));
209 | const sentText = await sendMessages.findElement(By.className('sc-message--text-content')).getText();
210 | expect(sentText).to.equal(sendMessage);
211 | }
212 |
213 | // TODO: beenden wenn Bug gefixed ist
214 | async function testReceiveMessage(driver) {
215 | await loadUntilWelcomeBox(driver);
216 | const dialog = driver.findElement(By.className('el-dialog__wrapper'));
217 | await register(driver, dialog);
218 | const message = 'Hallo Du';
219 | await requestCNC('POST', `/user/${user.userId}/chatMessage`, {
220 | groupId: group.groupId,
221 | text: message,
222 | });
223 | await driver.findElement(By.className('sc-open-icon')).click();
224 | }
225 |
226 | // TODO: wenn Bug gefixed ist beenden und überarbeiten - derzeit könnte bei der Überprüfung Tom richtig sein obwohl der gesuchte Name Tomas ist
227 | async function testCnCNameChange(driver) {
228 | await loadUntilWelcomeBox(driver);
229 | const dialog = driver.findElement(By.className('el-dialog__wrapper'));
230 | await register(driver, dialog);
231 | const changeName = 'Changed';
232 | await requestCNC('POST', `/user/${user.userId}/changeName`, {
233 | publicName: changeName,
234 | });
235 | const name = await searchNameInGroupinfo(driver, changeName);
236 | expect(name).to.equal(changeName);
237 | }
238 |
239 | async function testDeleteData(driver) {
240 | await loadUntilMap(driver);
241 |
242 | // check number of groupmembers in groupinfo
243 | const groupinfo = await getGroupmembers(driver);
244 | const length = groupinfo.spans.length.toString();
245 | const groupmembers = await groupinfo.msgBox.findElement(By.className('groupinfo-title')).getText();
246 | expect(groupmembers.match(/\d+/)[0]).to.equal(length);
247 |
248 | // clear data
249 | await groupinfo.msgBox.findElement(By.className('el-message-box__headerbtn')).click();
250 | await driver.wait(until.elementIsNotVisible(groupinfo.msgBox));
251 | await groupinfo.titlebar.findElement(By.className('username')).click();
252 | const messageBox = driver.findElement(By.className('el-message-box__wrapper'));
253 | await messageBox.findElement(By.className('el-button')).click();
254 |
255 | // welcomeBox
256 | const dialog = await driver.findElement(By.className('el-dialog__wrapper'));
257 | await driver.wait(until.elementLocated(By.className('el-button el-button--primary')));
258 | const startScreen = dialog;
259 |
260 | // check if number of names equals new groupmember amount
261 | const memberRow = await startScreen.findElement(By.className('groupinfo-body'));
262 | const memberSpan = await memberRow.findElements(By.tagName('span'));
263 | expect(memberSpan.length.toString()).to.equal((length - 1).toString());
264 |
265 | // check if the groupmember amount number (members (xx)) equals new groupmember amount
266 | const groupmembersNew = await startScreen.findElement(By.className('groupinfo-title')).getText();
267 | expect(groupmembersNew.match(/\d+/)[0]).to.equal((length - 1).toString());
268 | }
269 |
270 | async function testNoUsernameAtLogin(driver) {
271 | await loadUntilWelcomeBox(driver);
272 | const dialog = driver.findElement(By.className('el-dialog__wrapper'));
273 | await dialog.findElement(By.className('el-button--primary')).click();
274 | const failText = await dialog.findElement(By.className('el-form-item__error')).getText();
275 | expect(failText).to.equal('Bitte einen Namen eingeben.');
276 | }
277 |
278 | async function testMoreInformation(driver) {
279 | const title = 'Datenschutzerklärung - TICE App';
280 | const newTitle = await testAbout(driver, 'tag', 'a', title);
281 | expect(newTitle).to.equal(title);
282 | }
283 |
284 | async function testFeedbackAtLogin(driver) {
285 | await loadUntilWelcomeBox(driver);
286 | const dialog = driver.findElement(By.className('el-dialog__wrapper'));
287 | await dialog.findElement(By.tagName('img')).click();
288 | const aboutWrapper = await driver.findElement(By.className('el-message-box__wrapper'));
289 | const msgBoxContainer = await aboutWrapper.findElement(By.className('el-message-box__message'));
290 | await msgBoxContainer.findElement(By.className('el-button el-button--warning el-button--medium is-plain')).click();
291 | await driver.wait(until.alertIsPresent());
292 | const alert = await driver.switchTo().alert();
293 | const alertText = await alert.getText();
294 | await alert.dismiss();
295 | expect(alertText).to.contain('Wenn du uns von einem Problem berichten');
296 | }
297 |
298 | async function testAboutTICESoftware(driver) {
299 | const title = 'TICE Software';
300 | const newTitle = await testAbout(driver, 'link', 'TICE Software UG (haftungsbeschränkt)', title);
301 | expect(newTitle).to.equal(title);
302 | }
303 |
304 | async function testAboutImpressum(driver) {
305 | const title = 'Impressum - TICE App';
306 | const newTitle = await testAbout(driver, 'link', 'Impressum', title);
307 | expect(newTitle).to.equal(title);
308 | }
309 |
310 | async function testAboutTICEApp(driver) {
311 | const title = 'Standort teilen, Familie orten & Freunde treffen - TICE App';
312 | const newTitle = await testAbout(driver, 'link', 'ticeapp.com', title);
313 | expect(newTitle).to.equal(title);
314 | }
315 |
316 | async function testAboutInstagram(driver) {
317 | const title = 'TICE - Secure Location Sharing (@ticeapp)';
318 | const newTitle = await testAbout(driver, 'class', 'svg-inline--fa fa-instagram fa-w-14 fa-lg', title);
319 | expect(newTitle).to.include(title);
320 | }
321 |
322 | async function testAboutFacebook(driver) {
323 | const title = 'TICE App - Startseite | Facebook';
324 | const newTitle = await testAbout(driver, 'class', 'svg-inline--fa fa-facebook-square fa-w-14 fa-lg', title);
325 | expect(newTitle).to.equal(title);
326 | }
327 |
328 | async function testAboutTwitter(driver) {
329 | const title = 'TICE (@ticeapp) / Twitter';
330 | const newTitle = await testAbout(driver, 'class', 'svg-inline--fa fa-twitter fa-w-16 fa-lg', title);
331 | expect(newTitle).to.equal(title);
332 | }
333 |
334 | async function testCookies(driver) {
335 | await loadUntilMap(driver);
336 | const titlebar = await driver.findElement(By.id('titlebar'));
337 | const actualName = await titlebar.findElement(By.className('username')).getText();
338 | const actualGroup = await titlebar.findElement(By.className('groupname')).getText();
339 | await driver.navigate().refresh();
340 | await driver.wait(until.elementLocated(By.className('mapboxgl-map')));
341 | const newTb = await driver.findElement(By.id('titlebar'));
342 | const refreshedName = await newTb.findElement(By.className('username')).getText();
343 | const refreshedGroup = await newTb.findElement(By.className('groupname')).getText();
344 | expect(refreshedName).to.equal(actualName);
345 | expect(refreshedGroup).to.equal(actualGroup);
346 | }
347 |
348 | module.exports = {
349 | testLoadGroup,
350 | testChangeName,
351 | testShareLocation,
352 | testMe,
353 | testSendMessage,
354 | testReceiveMessage,
355 | testCnCNameChange,
356 | testDeleteData,
357 | testCookies,
358 | testNoUsernameAtLogin,
359 | testMoreInformation,
360 | testFeedbackAtLogin,
361 | testAboutTICESoftware,
362 | testAboutImpressum,
363 | testAboutTICEApp,
364 | testAboutInstagram,
365 | testAboutFacebook,
366 | testAboutTwitter,
367 | };
368 |
--------------------------------------------------------------------------------
/vue.config.js:
--------------------------------------------------------------------------------
1 | const HtmlWebpackPlugin = require('html-webpack-plugin');
2 |
3 | process.env.VUE_APP_VERSION = require('./package.json').version;
4 |
5 | module.exports = {
6 | indexPath: 'en/index.html',
7 | lintOnSave: false,
8 | configureWebpack: {
9 | plugins: [
10 | new HtmlWebpackPlugin({
11 | filename: 'index.html',
12 | template: 'public/en/index.html',
13 | }),
14 | new HtmlWebpackPlugin({
15 | filename: 'de/index.html',
16 | template: 'public/de/index.html',
17 | }),
18 | new HtmlWebpackPlugin({
19 | filename: 'en/index.html',
20 | template: 'public/en/index.html',
21 | }),
22 | ],
23 | },
24 | };
25 |
--------------------------------------------------------------------------------
/web.Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:lts-alpine as build-stage
2 | WORKDIR /app
3 | RUN apk add --no-cache git
4 |
5 | COPY package.json yarn.lock ./
6 | RUN yarn install --no-cache --frozen-lockfile
7 | COPY . .
8 | RUN yarn run build
9 |
10 | FROM nginx:stable-alpine as production-stage
11 | COPY --from=build-stage /app/dist /usr/share/nginx/html
12 | RUN echo "server { listen 80; listen [::]:80; server_name localhost; root /usr/share/nginx/html; index index.html; location ~* apple-app-site-association$ { try_files /apple-app-site-association $uri =404; default_type application/json; } location / { try_files \$uri \$uri/ /index.html; } }" > /etc/nginx/conf.d/default.conf
13 | EXPOSE 80
14 | CMD ["nginx", "-g", "daemon off;"]
15 |
--------------------------------------------------------------------------------