diff --git a/app/routes/index.ts b/app/routes/index.ts
index 46c2e1f8..9153c0aa 100644
--- a/app/routes/index.ts
+++ b/app/routes/index.ts
@@ -9,7 +9,7 @@ import {
checkModeratorPartial,
} from './helpers';
import env from '../../env';
-import { URLSearchParams } from 'url';
+import QueryString from 'qs';
let usage = { test: '2023-01-02' };
if (['production', 'development'].includes(process.env.NODE_ENV)) {
import('../../usage.yml').then((usage_yml) => (usage = usage_yml.default));
@@ -74,9 +74,12 @@ router.get('/healthz', (req, res) => {
router.get('*', (req, res, next) => {
if (env.NODE_ENV === 'development') {
// Prevent proxy recursion
- const p = new URLSearchParams(req.params);
- p.append('devproxy', '1');
- return res.redirect(env.FRONTEND_URL + req.path + '?' + p.toString());
+ (req.query as any).devproxy = true;
+ return res.redirect(
+ env.FRONTEND_URL +
+ req.path +
+ QueryString.stringify(req.query, { addQueryPrefix: true })
+ );
}
next();
});
diff --git a/package.json b/package.json
index 8a37b0a7..ac1cfbd1 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,7 @@
},
"license": "MIT",
"dependencies": {
+ "@types/dom-serial": "1.0.3",
"@types/node": "18.15.3",
"@types/qrcode": "1.5.0",
"@types/sortablejs": "1.15.1",
@@ -51,7 +52,7 @@
"connect-mongo": "4.6.0",
"cookie-parser": "1.4.6",
"crypto-browserify": "3.12.0",
- "crypto-random-string": "1.0.0",
+ "crypto-random-string": "5.0.0",
"csrf-sync": "4.0.1",
"css-loader": "6.3.0",
"express": "4.18.2",
@@ -72,6 +73,7 @@
"promptly": "2.1.0",
"qr-scanner": "1.4.2",
"qrcode": "1.3.4",
+ "qs": "6.11.2",
"raven": "2.6.4",
"redlock": "5.0.0-beta.2",
"serve-favicon": "2.5.0",
@@ -107,6 +109,7 @@
"@types/raven": "2.5.4",
"@types/serve-favicon": "2.5.3",
"@types/socket.io": "3.0.2",
+ "@types/w3c-web-nfc": "1.0.0",
"@types/webpack": "5.28.0",
"@types/yaml": "1.9.7",
"@typescript-eslint/eslint-plugin": "5.50.0",
diff --git a/src/lib/assets/ding.mp3 b/src/lib/assets/ding.mp3
new file mode 100644
index 00000000..c148fd47
Binary files /dev/null and b/src/lib/assets/ding.mp3 differ
diff --git a/src/lib/assets/error.mp3 b/src/lib/assets/error.mp3
new file mode 100644
index 00000000..4b7bed54
Binary files /dev/null and b/src/lib/assets/error.mp3 differ
diff --git a/src/lib/components/Navbar.svelte b/src/lib/components/Navbar.svelte
index 90195a35..1c4bb991 100644
--- a/src/lib/components/Navbar.svelte
+++ b/src/lib/components/Navbar.svelte
@@ -15,25 +15,27 @@
{:else if $page.url.pathname.includes('moderator')}
{:else if !$page.url.pathname.includes('login')}
diff --git a/src/lib/stores.ts b/src/lib/stores.ts
index ef552fa7..100430af 100644
--- a/src/lib/stores.ts
+++ b/src/lib/stores.ts
@@ -1,10 +1,11 @@
import short from 'short-uuid';
-import { writable } from 'svelte/store';
+import { get, writable } from 'svelte/store';
const xsrf = writable('');
const createAlerts = () => {
- const { subscribe, set, update } = writable([]);
+ const alertList = writable([]);
+ const { subscribe, set, update } = alertList;
const FADE_DURATION = 500;
const CLOSE_DELAY = 5000;
@@ -38,6 +39,9 @@ const createAlerts = () => {
removeAll: () => {
set([]);
},
+ getLastAlert: () => {
+ return get(alertList).slice().pop();
+ },
FADE_DURATION,
CLOSE_DELAY,
};
diff --git a/src/lib/utils/cardKeyScanStore.ts b/src/lib/utils/cardKeyScanStore.ts
new file mode 100644
index 00000000..c248ca0f
--- /dev/null
+++ b/src/lib/utils/cardKeyScanStore.ts
@@ -0,0 +1,212 @@
+import { alerts } from '$lib/stores';
+import { onMount } from 'svelte';
+import { get, writable } from 'svelte/store';
+
+// Stateless helper functions
+const checksum = (data: number[]) =>
+ data.reduce((previousValue, currentValue) => previousValue ^ currentValue);
+
+const createMessage = (command: number, data: number[]) => {
+ const payload = [data.length + 1, command, ...data];
+ payload.push(checksum(payload));
+
+ return new Uint8Array([0xaa, 0x00, ...payload, 0xbb]).buffer;
+};
+
+const convertUID = (data: string[]) => {
+ const reversed = data
+ .join('')
+ .match(/.{1,2}/g)
+ .reverse()
+ .join('');
+ return parseInt(reversed, 16);
+};
+
+const validate = (data: string[], receivedChecksum: string) => {
+ const dataDecimal = data.map((item) => parseInt(item, 16));
+ const calculatedChecksum = checksum(dataDecimal);
+ return Math.abs(calculatedChecksum % 255) === parseInt(receivedChecksum, 16);
+};
+
+// prettier-ignore
+const replies = {
+ '00': 'OK',
+ '01': 'ERROR',
+ '83': 'NO CARD',
+ '87': 'UNKNOWN INTERNAL ERROR',
+ '85': 'UNKNOWN COMMAND',
+ '84': 'RESPONSE ERROR',
+ '82': 'READER TIMEOUT',
+ '90': 'CARD DOES NOT SUPPORT THIS COMMAND',
+ '8f': 'UNSUPPORTED CARD IN NFC WRITE MODE',
+};
+
+const readCardCommand = createMessage(0x25, [0x26, 0x00]);
+
+const parseData = (response: number[]) => {
+ const hexValues = [];
+ for (let i = 0; i < response.length; i += 1) {
+ hexValues.push((response[i] < 16 ? '0' : '') + response[i].toString(16));
+ }
+ const stationId = hexValues[1];
+ const length = hexValues[2];
+ const status: keyof typeof replies = hexValues[3] as keyof typeof replies;
+ const flag = hexValues[4];
+ const data = hexValues.slice(5, hexValues.length - 1);
+ const checksum = hexValues[hexValues.length - 1];
+ const valid = validate([stationId, length, status, flag, ...data], checksum);
+
+ const statusReply = replies[status];
+ return {
+ valid: valid,
+ data: valid && statusReply === 'OK' ? convertUID(data) : data,
+ status: statusReply,
+ };
+};
+
+const DUMMY_READER_TEXT = `VOTE DUMMY READER MODE
+
+You are now in dummy reader mode of VOTE. Use the global function "scanCard" to scan a card. The function takes the card UID as the first (and only) parameter, and the UID can be both a string or a number.
+
+Usage: scanCard(123) // where 123 is the cardId `;
+
+// Writable svelte store: https://svelte.dev/docs#run-time-svelte-store-writable
+export const cardKeyScanStore = writable<{ cardKey: number; time: number }>(
+ { cardKey: null, time: null },
+ (set) => {
+ // Called whenever number of subscribers goes from zero to one
+
+ let ndef: NDEFReader = null;
+ let serialDevice: {
+ writer: WritableStreamDefaultWriter;
+ reader: ReadableStreamDefaultReader;
+ } = null;
+ const readerBusy = writable(false);
+ const serialTimeout = writable(null);
+
+ // The scanner depends on values from window
+ onMount(async () => {
+ // Check first if dummyReader was requirested
+ if (window.location.href.includes('dummyReader')) {
+ window.scanCard = (cardKey: number) =>
+ set({ cardKey, time: Date.now() });
+ console.error(DUMMY_READER_TEXT);
+ } else {
+ // Attempt to open a connection
+ try {
+ if (
+ window.navigator.userAgent.includes('Android') &&
+ 'NDEFReader' in window &&
+ (!window.navigator.serial ||
+ window.confirm(
+ 'You are using an Android device that (might) support web nfc. Click OK to use web nfc, and cancel to fallback to using a usb serial device.'
+ ))
+ ) {
+ const ndefReader = new NDEFReader();
+ await ndefReader.scan();
+ ndef = ndefReader;
+ } else {
+ const port = await window.navigator.serial.requestPort();
+ await port.open({ baudRate: 9600 });
+ serialDevice = {
+ writer: port.writable.getWriter(),
+ reader: port.readable.getReader(),
+ };
+ }
+ } catch (e) {
+ if (window.navigator.userAgent.includes('Android')) {
+ alerts.push(e, 'ERROR');
+ }
+ window.location.assign('/moderator/serial_error');
+ console.error(e);
+ }
+ }
+
+ // Poll open connections (if one was created)
+ if (ndef) {
+ ndef.onreading = ({ message, serialNumber }) => {
+ const data = convertUID(serialNumber.split(':'));
+ set({ cardKey: data, time: Date.now() });
+ };
+ } else if (serialDevice && !get(serialTimeout)) {
+ // Stateful helper functions for serial device
+ const onComplete = (input: number[]) => {
+ const { valid, status, data } = parseData(input);
+ if (valid && status == 'OK' && typeof data === 'number') {
+ // Debounce
+ if (
+ data !== get(cardKeyScanStore).cardKey ||
+ Date.now() - get(cardKeyScanStore).time > 2000
+ ) {
+ // data = card key
+ set({ cardKey: data, time: Date.now() });
+ }
+ }
+ };
+ const readResult = async () => {
+ const message = [];
+ let finished = false;
+ let isReaderBusy = true;
+ // Keep reading bytes until the "end" byte is sent
+ // The "end" byte is 0xbb
+ while (!finished) {
+ // Stop the read if the device is busy somewhere else
+ isReaderBusy = true;
+ readerBusy.update((readerBusy) => {
+ isReaderBusy = readerBusy;
+ return true;
+ });
+ if (isReaderBusy) break;
+ const { value } = await serialDevice.reader.read();
+ readerBusy.set(false);
+ for (let i = 0; i < value.length; i++) {
+ // First byte in a message should be 170, otherwise ignore and keep on going
+ if (message.length === 0 && value[i] !== 170) {
+ continue;
+ }
+ // Second byte in a message should be 255, otherwise discard and keep on going
+ if (message.length === 1 && value[i] !== 255) {
+ // If value is 170, treat it as the first value, and keep on. Otherwise discard
+ if (value[i] !== 170) {
+ message.length = 0;
+ }
+ continue;
+ }
+
+ if (message.length > 3 && message.length >= message[2] + 4) {
+ finished = true;
+ break;
+ }
+ message.push(value[i]);
+ }
+ }
+ onComplete(message);
+ };
+
+ // Constantly send the readCardCommand and read the result.
+ // If there is no card, the result will be an error status,
+ // which is handled in the onComplete function
+ const runPoll = async () => {
+ try {
+ serialDevice.writer.write(readCardCommand);
+ await readResult();
+ } catch (e) {
+ console.error('Error doing card stuff', e);
+ readerBusy.set(false);
+ } finally {
+ serialTimeout.set(setTimeout(runPoll, 150));
+ }
+ };
+ runPoll();
+ }
+ });
+
+ return () => {
+ // Called when number of subscribers goes to zero
+ serialTimeout.update((timeout) => {
+ clearTimeout(timeout);
+ return null;
+ });
+ };
+ }
+);
diff --git a/src/lib/utils/userApi.ts b/src/lib/utils/userApi.ts
new file mode 100644
index 00000000..2253d4f9
--- /dev/null
+++ b/src/lib/utils/userApi.ts
@@ -0,0 +1,36 @@
+import callApi from './callApi';
+
+export const toggleUser = (cardKey: number | string) => {
+ return callApi('/user/' + cardKey + '/toggle_active', 'POST');
+};
+
+export const createUser = (user: Record) => {
+ return callApi('/user', 'POST', user);
+};
+
+export const generateUser = (user: Record) => {
+ return callApi('/user/generate', 'POST', user);
+};
+
+export const changeCard = (user: Record) => {
+ return callApi('/user/' + user.username + '/change_card', 'PUT', user);
+};
+
+export const countActiveUsers = () => {
+ return callApi('/user/count?active=true');
+};
+
+export const deactivateNonAdminUsers = () => {
+ return callApi('/user/deactivate', 'POST');
+};
+
+const userApi = {
+ toggleUser,
+ createUser,
+ generateUser,
+ changeCard,
+ countActiveUsers,
+ deactivateNonAdminUsers,
+};
+
+export default userApi;
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 89b0e88a..64646abd 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -17,7 +17,9 @@
-
+
+
+
diff --git a/src/routes/moderator/(useCardKey)/+layout.svelte b/src/routes/moderator/(useCardKey)/+layout.svelte
new file mode 100644
index 00000000..bec31f3e
--- /dev/null
+++ b/src/routes/moderator/(useCardKey)/+layout.svelte
@@ -0,0 +1,11 @@
+
+
+
diff --git a/src/routes/moderator/(useCardKey)/activate_user/+page.svelte b/src/routes/moderator/(useCardKey)/activate_user/+page.svelte
new file mode 100644
index 00000000..a19b9610
--- /dev/null
+++ b/src/routes/moderator/(useCardKey)/activate_user/+page.svelte
@@ -0,0 +1,59 @@
+
+
+
+
Vennligst skann kortet ditt
+
diff --git a/src/routes/moderator/(useCardKey)/qr/+page.svelte b/src/routes/moderator/(useCardKey)/qr/+page.svelte
new file mode 100644
index 00000000..37a74ba6
--- /dev/null
+++ b/src/routes/moderator/(useCardKey)/qr/+page.svelte
@@ -0,0 +1,82 @@
+
+
+
diff --git a/src/routes/moderator/(useCardKey)/showqr/+page.svelte b/src/routes/moderator/(useCardKey)/showqr/+page.svelte
new file mode 100644
index 00000000..ab15c9c9
--- /dev/null
+++ b/src/routes/moderator/(useCardKey)/showqr/+page.svelte
@@ -0,0 +1,64 @@
+
+
+
+
+
Scan QR-Kode!
+
+
+
+ {
+ goto('/moderator/qr?status=success');
+ }}>Lukk
+ {
+ const res = await userApi.toggleUser(cardKey);
+ if (res.status === 201) {
+ goto('/moderator/qr?status=fail');
+ } else {
+ goto('/moderator/qr?status=success');
+ }
+ }}>Lukk og deaktiver bruker
+
+
diff --git a/src/routes/moderator/activate_user/+page.svelte b/src/routes/moderator/activate_user/+page.svelte
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/routes/moderator/qr/+page.svelte b/src/routes/moderator/qr/+page.svelte
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/routes/moderator/serial_error/+page.svelte b/src/routes/moderator/serial_error/+page.svelte
new file mode 100644
index 00000000..f9202947
--- /dev/null
+++ b/src/routes/moderator/serial_error/+page.svelte
@@ -0,0 +1,43 @@
+
+
+
Serial error
+
The card reader is not fully linked with the browser
+
+
+
+
Tips
+
+
+ 1) Make sure you have enabled Experimental Web Platform features and are
+ using Google Chrome. Learn how to enable this feature by going to README.md
+
+
+ 2) Please check that the USB card reader is connected. When prompted for
+ permissions, please select the card reader (CP210x).
+
+
+ 3) If you are running Linux, run Google Chrome as root to gain access to
+ tty. See README.md
+
+
+
+
+
Dummy card reader
+
+ devtools and click
+ here
+
+
+
diff --git a/tsconfig.json b/tsconfig.json
index 342f116a..7ea4afce 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -8,7 +8,7 @@
"moduleResolution": "node",
"isolatedModules": true,
"module": "esnext",
- "types": ["node", "cypress", "@4tw/cypress-drag-drop"],
+ "types": ["node", "cypress", "@4tw/cypress-drag-drop", "@types/w3c-web-nfc", "@types/dom-serial"],
"paths": {
"$backend*": ["./app*"],
"$lib": ["./src/lib"],
diff --git a/types.d.ts b/types.d.ts
index 7f72569c..2ceffdbf 100644
--- a/types.d.ts
+++ b/types.d.ts
@@ -10,4 +10,8 @@ declare global {
election?: HydratedDocument;
}
}
+
+ interface Window {
+ scanCard?: (cardKey: number) => void;
+ }
}
diff --git a/yarn.lock b/yarn.lock
index 2a647147..bdc39aa6 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2527,6 +2527,11 @@
dependencies:
"@types/node" "*"
+"@types/dom-serial@1.0.3":
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/@types/dom-serial/-/dom-serial-1.0.3.tgz#d4eae01b547a3f05f260fac194d79d0ccb365dd9"
+ integrity sha512-iuuXTHSUQfosDJTN3AxTHfYtDZVehzAc07P0d6Y6/D0mNMoAzSQcAB8cf3glzH8MfMpZx8JDQv9C+PBpILVI3g==
+
"@types/eslint-scope@^3.7.3":
version "3.7.4"
resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.4.tgz#37fc1223f0786c39627068a12e94d6e6fc61de16"
@@ -2818,6 +2823,11 @@
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc"
integrity sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==
+"@types/w3c-web-nfc@1.0.0":
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/@types/w3c-web-nfc/-/w3c-web-nfc-1.0.0.tgz#cfca9ebfc0d14c9dec28264ab0d35ab282b54c10"
+ integrity sha512-y/vUdANaNMqnghzboC9hzTxuCu2mtW3KDZZaqD3ubPmfiAh4CM9kDV74U7IS+fc8ZffTZvY3Gx39bCEPFz9+cw==
+
"@types/webidl-conversions@*":
version "7.0.0"
resolved "https://registry.yarnpkg.com/@types/webidl-conversions/-/webidl-conversions-7.0.0.tgz#2b8e60e33906459219aa587e9d1a612ae994cfe7"
@@ -4519,10 +4529,12 @@ crypto-browserify@3.12.0:
randombytes "^2.0.0"
randomfill "^1.0.3"
-crypto-random-string@1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e"
- integrity sha512-GsVpkFPlycH7/fRR7Dhcmnoii54gV1nz7y4CWyeFS14N+JVBBhY+r8amRHE4BwSYal7BPTDp8isvAlCxyFt3Hg==
+crypto-random-string@5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-5.0.0.tgz#12b4ca8ba936c36d757b65b71a7d85a69a02c18a"
+ integrity sha512-KWjTXWwxFd6a94m5CdRGW/t82Tr8DoBc9dNnPCAbFI1EBweN6v1tv8y4Y1m7ndkp/nkIBRxUxAzpaBnR2k3bcQ==
+ dependencies:
+ type-fest "^2.12.2"
csrf-sync@4.0.1:
version "4.0.1"
@@ -8152,6 +8164,13 @@ qs@6.11.0, qs@^6.11.0:
dependencies:
side-channel "^1.0.4"
+qs@6.11.2:
+ version "6.11.2"
+ resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.2.tgz#64bea51f12c1f5da1bc01496f48ffcff7c69d7d9"
+ integrity sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==
+ dependencies:
+ side-channel "^1.0.4"
+
qs@~6.10.3:
version "6.10.5"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.5.tgz#974715920a80ff6a262264acd2c7e6c2a53282b4"
@@ -9691,6 +9710,11 @@ type-fest@^0.8.0:
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d"
integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==
+type-fest@^2.12.2:
+ version "2.19.0"
+ resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b"
+ integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==
+
type-is@~1.6.18:
version "1.6.18"
resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"