Skip to content

Commit

Permalink
Implement qr-scanner, /qr and /activate_user in svelte
Browse files Browse the repository at this point in the history
  • Loading branch information
PeterJFB committed May 11, 2023
1 parent c947734 commit 5837676
Show file tree
Hide file tree
Showing 18 changed files with 623 additions and 8 deletions.
1 change: 1 addition & 0 deletions app/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ router.get('*', (req, res, next) => {
if (env.NODE_ENV === 'development') {
// Prevent proxy recursion
const p = new URLSearchParams(req.params);
p.delete('0');
p.append('devproxy', '1');
return res.redirect(env.FRONTEND_URL + req.path + '?' + p.toString());
}
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Binary file added src/lib/assets/ding.mp3
Binary file not shown.
Binary file added src/lib/assets/error.mp3
Binary file not shown.
8 changes: 6 additions & 2 deletions src/lib/stores.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -38,6 +39,9 @@ const createAlerts = () => {
removeAll: () => {
set([]);
},
getLastAlert: () => {
return get(alertList).slice().pop();
},
FADE_DURATION,
CLOSE_DELAY,
};
Expand Down
213 changes: 213 additions & 0 deletions src/lib/utils/cardKeyScanStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
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 `;

// TODO:
// - Test ndef?

// 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<NodeJS.Timeout>(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) {
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) {
console.log('SCANNING');
// 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 {
console.log('NEXT');
serialTimeout.set(setTimeout(runPoll, 150));
}
};
runPoll();
}
});

return () => {
// Called when number of subscribers goes to zero
serialTimeout.update((timeout) => {
clearTimeout(timeout);
return null;
});
};
}
);
36 changes: 36 additions & 0 deletions src/lib/utils/userApi.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => {
return callApi('/user', 'POST', user);
};

export const generateUser = (user: Record<string, unknown>) => {
return callApi('/user/generate', 'POST', user);
};

export const changeCard = (user: Record<string, unknown>) => {
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;
4 changes: 3 additions & 1 deletion src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
</div>
</div>
</header>
<Alerts />
<span>
<Alerts />
</span>
<slot />
<Footer />

Expand Down
11 changes: 11 additions & 0 deletions src/routes/moderator/(useCardKey)/+layout.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<script lang="ts">
import { cardKeyScanStore } from '$lib/utils/cardKeyScanStore';
import { onDestroy } from 'svelte';
const unsubscribe = cardKeyScanStore.subscribe(() => {});
onDestroy(() => {
unsubscribe();
});
</script>

<slot />
59 changes: 59 additions & 0 deletions src/routes/moderator/(useCardKey)/activate_user/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<script lang="ts">
import { alerts } from '$lib/stores';
import { cardKeyScanStore } from '$lib/utils/cardKeyScanStore';
import userApi from '$lib/utils/userApi';
import { onDestroy, onMount } from 'svelte';
import ding from '$lib/assets/ding.mp3';
import error from '$lib/assets/error.mp3';
let dingAudio: HTMLAudioElement;
let errorAudio: HTMLAudioElement;
onMount(() => {
dingAudio = new Audio(ding);
errorAudio = new Audio(error);
});
let firstCall = true;
const unsubscribe = cardKeyScanStore.subscribe(async ({ cardKey }) => {
if (firstCall || !cardKey) return (firstCall = false);
const res = await userApi.toggleUser(cardKey);
if (res.status === 200) {
const lastAlert = alerts.getLastAlert();
if (dingAudio) dingAudio.play();
if (res.body.active) {
if (lastAlert && lastAlert.type !== 'SUCCESS') {
alerts.removeAll();
}
alerts.push('Kort aktivert, GÅ INN', 'SUCCESS', true);
} else {
if (lastAlert && lastAlert.type !== 'WARNING') {
alerts.removeAll();
}
alerts.push('Kort deaktivert, GÅ UT', 'WARNING', true);
}
} else {
if (errorAudio) errorAudio.play();
switch (res.body.name) {
case 'NotFoundError':
alerts.push(
'Uregistrert kort, vennligst lag en bruker først.',
'ERROR'
);
break;
default:
alerts.push('Noe gikk galt!', 'ERROR');
}
}
});
onDestroy(() => unsubscribe());
</script>

<div class="center text-center">
<h2>Vennligst skann kortet ditt</h2>
</div>

0 comments on commit 5837676

Please sign in to comment.