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/app/types/types.ts b/app/types/types.ts index 4481aa15..a4a1f0d9 100644 --- a/app/types/types.ts +++ b/app/types/types.ts @@ -79,8 +79,7 @@ export interface IUserMethods { } export type UserType = IUser; -export interface UserModel - extends Model, IUserMethods> { +export type UserModel = Model, IUserMethods> & { // statics authenticate( username: string, @@ -93,7 +92,7 @@ export interface UserModel body: IUser, password: string ): Promise>; -} +}; interface IVote { _id: string; diff --git a/package.json b/package.json index 8a37b0a7..61fcabbd 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", @@ -61,7 +62,7 @@ "ioredis": "5.2.4", "lodash": "4.17.21", "method-override": "3.0.0", - "mongoose": "6.8.3", + "mongoose": "6.11.5", "nib": "1.1.2", "nodemailer": "6.8.0", "nyc": "15.1.0", @@ -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 @@ - + + +