diff --git a/client/src/Dashboard.js b/client/src/Dashboard.js index b5eefd7..c32c34e 100644 --- a/client/src/Dashboard.js +++ b/client/src/Dashboard.js @@ -1,5 +1,6 @@ import React, { useState, useEffect, useContext } from "react"; import Paper from '@mui/material/Paper'; +import Button from '@mui/material/Button'; import Moment from 'react-moment'; import { @@ -12,6 +13,7 @@ import { GlobalContext } from "./GlobalContext.js"; import { base } from "./Common.js"; function Dashboard() { + const navigate = useNavigate(); const { csrfToken, user, setUser } = useContext(GlobalContext); const [ groups, setGroups ] = useState([]); const [ error, setError ] = useState(null); @@ -55,19 +57,22 @@ function Dashboard() { }}> { groups.map((group) => { - return
+ return
; + ; }) } diff --git a/client/src/Group.js b/client/src/Group.js index a852aa2..c94ebd1 100644 --- a/client/src/Group.js +++ b/client/src/Group.js @@ -42,11 +42,33 @@ function Group() { } setLocations(data.map(location => { const fields = Object.getOwnPropertyNames(location) - .map(field =>
-
{field}
-
{location[field]}
-
); - return
{ fields }
; + .map(field =>
+
{field}
+
+ {location[field]} +
+
+ ); + return
{ fields }
; })); }; effect(); @@ -61,7 +83,7 @@ function Group() { const effect = async () => { const res = await window.fetch( `${base}/api/v1/groups/${groupId}/events`, { - method: 'POST', + method: 'GET', cache: 'no-cache', credentials: 'same-origin', headers: { @@ -83,11 +105,17 @@ function Group() { }, [user, setGroup, groupId, csrfToken]); return ( - + - Group: {groupId} -
Locations
- { locations } + { error &&
{error}
} + { !error && <> +
Group: {groupId}
+
Locations
+ { locations } + }
); diff --git a/conv b/conv new file mode 100755 index 0000000..2a3e374 --- /dev/null +++ b/conv @@ -0,0 +1,289 @@ +#!/usr/bin/awk -f + +# Authors: @esperlu, @artemyk, @gkuenning, @dumblob + +# FIXME detect empty input file and issue a warning + +function printerr( s ){ print s | "cat >&2" } + +BEGIN { + if( ARGC != 2 ){ + printerr( \ + "USAGE:\n"\ + " mysql2sqlite dump_mysql.sql > dump_sqlite3.sql\n" \ + " OR\n" \ + " mysql2sqlite dump_mysql.sql | sqlite3 sqlite.db\n" \ + "\n" \ + "NOTES:\n" \ + " Dash in filename is not supported, because dash (-) means stdin." ) + no_END = 1 + exit 1 + } + + # Find INT_MAX supported by both this AWK (usually an ISO C signed int) + # and SQlite. + # On non-8bit-based architectures, the additional bits are safely ignored. + + # 8bit (lower precision should not exist) + s="127" + # "63" + 0 avoids potential parser misbehavior + if( (s + 0) "" == s ){ INT_MAX_HALF = "63" + 0 } + # 16bit + s="32767" + if( (s + 0) "" == s ){ INT_MAX_HALF = "16383" + 0 } + # 32bit + s="2147483647" + if( (s + 0) "" == s ){ INT_MAX_HALF = "1073741823" + 0 } + # 64bit (as INTEGER in SQlite3) + s="9223372036854775807" + if( (s + 0) "" == s ){ INT_MAX_HALF = "4611686018427387904" + 0 } +# # 128bit +# s="170141183460469231731687303715884105728" +# if( (s + 0) "" == s ){ INT_MAX_HALF = "85070591730234615865843651857942052864" + 0 } +# # 256bit +# s="57896044618658097711785492504343953926634992332820282019728792003956564819968" +# if( (s + 0) "" == s ){ INT_MAX_HALF = "28948022309329048855892746252171976963317496166410141009864396001978282409984" + 0 } +# # 512bit +# s="6703903964971298549787012499102923063739682910296196688861780721860882015036773488400937149083451713845015929093243025426876941405973284973216824503042048" +# if( (s + 0) "" == s ){ INT_MAX_HALF = "3351951982485649274893506249551461531869841455148098344430890360930441007518386744200468574541725856922507964546621512713438470702986642486608412251521024" + 0 } +# # 1024bit +# s="89884656743115795386465259539451236680898848947115328636715040578866337902750481566354238661203768010560056939935696678829394884407208311246423715319737062188883946712432742638151109800623047059726541476042502884419075341171231440736956555270413618581675255342293149119973622969239858152417678164812112068608" +# if( (s + 0) "" == s ){ INT_MAX_HALF = "44942328371557897693232629769725618340449424473557664318357520289433168951375240783177119330601884005280028469967848339414697442203604155623211857659868531094441973356216371319075554900311523529863270738021251442209537670585615720368478277635206809290837627671146574559986811484619929076208839082406056034304" + 0 } +# # higher precision probably not needed + + FS=",$" + print "PRAGMA synchronous = OFF;" + print "PRAGMA journal_mode = MEMORY;" + print "BEGIN TRANSACTION;" +} + +# historically 3 spaces separate non-argument local variables +function bit_to_int( str_bit, powtwo, i, res, bit, overflow ){ + powtwo = 1 + overflow = 0 + # 011101 = 1*2^0 + 0*2^1 + 1*2^2 ... + for( i = length( str_bit ); i > 0; --i ){ + bit = substr( str_bit, i, 1 ) + if( overflow || ( bit == 1 && res > INT_MAX_HALF ) ){ + printerr( \ + NR ": WARN Bit field overflow, number truncated (LSBs saved, MSBs ignored)." ) + break + } + res = res + bit * powtwo + # no warning here as it might be the last iteration + if( powtwo > INT_MAX_HALF ){ overflow = 1; continue } + powtwo = powtwo * 2 + } + return res +} + +# CREATE TRIGGER statements have funny commenting. Remember we are in trigger. +/^\/\*.*(CREATE.*TRIGGER|create.*trigger)/ { + gsub( /^.*(TRIGGER|trigger)/, "CREATE TRIGGER" ) + print + inTrigger = 1 + next +} +# The end of CREATE TRIGGER has a stray comment terminator +/(END|end) \*\/;;/ { gsub( /\*\//, "" ); print; inTrigger = 0; next } +# The rest of triggers just get passed through +inTrigger != 0 { print; next } + +# CREATE VIEW looks like a TABLE in comments +/^\/\*.*(CREATE.*TABLE|create.*table)/ { + inView = 1 + next +} +# end of CREATE VIEW +/^(\).*(ENGINE|engine).*\*\/;)/ { + inView = 0 + next +} +# content of CREATE VIEW +inView != 0 { next } + +# skip comments +/^\/\*/ { next } + +# skip PARTITION statements +/^ *[(]?(PARTITION|partition) +[^ ]+/ { next } + +# print all INSERT lines +( /^ *\(/ && /\) *[,;] *$/ ) || /^(INSERT|insert|REPLACE|replace)/ { + prev = "" + + # first replace \\ by \_ that mysqldump never generates to deal with + # sequnces like \\n that should be translated into \n, not \. + # After we convert all escapes we replace \_ by backslashes. + gsub( /\\\\/, "\\_" ) + + # single quotes are escaped by another single quote + gsub( /\\'/, "''" ) + gsub( /\\n/, "\n" ) + gsub( /\\r/, "\r" ) + gsub( /\\"/, "\"" ) + gsub( /\\\032/, "\032" ) # substitute char + + gsub( /\\_/, "\\" ) + + # sqlite3 is limited to 16 significant digits of precision + while( match( $0, /0x[0-9a-fA-F]{17}/ ) ){ + hexIssue = 1 + sub( /0x[0-9a-fA-F]+/, substr( $0, RSTART, RLENGTH-1 ), $0 ) + } + if( hexIssue ){ + printerr( \ + NR ": WARN Hex number trimmed (length longer than 16 chars)." ) + hexIssue = 0 + } + print + next +} + +# CREATE DATABASE is not supported +/^(CREATE DATABASE|create database)/ { next } + +# print the CREATE line as is and capture the table name +/^(CREATE|create)/ { + if( $0 ~ /IF NOT EXISTS|if not exists/ || $0 ~ /TEMPORARY|temporary/ ){ + caseIssue = 1 + printerr( \ + NR ": WARN Potential case sensitivity issues with table/column naming\n" \ + " (see INFO at the end)." ) + } + if( match( $0, /`[^`]+/ ) ){ + tableName = substr( $0, RSTART+1, RLENGTH-1 ) + } + aInc = 0 + prev = "" + firstInTable = 1 + print + next +} + +# Replace `FULLTEXT KEY` (probably other `XXXXX KEY`) +/^ (FULLTEXT KEY|fulltext key)/ { gsub( /[A-Za-z ]+(KEY|key)/, " KEY" ) } + +# Get rid of field lengths in KEY lines +/ (PRIMARY |primary )?(KEY|key)/ { gsub( /\([0-9]+\)/, "" ) } + +aInc == 1 && /PRIMARY KEY|primary key/ { next } + +# Replace COLLATE xxx_xxxx_xx statements with COLLATE BINARY +/ (COLLATE|collate) [a-z0-9_]*/ { gsub( /(COLLATE|collate) [a-z0-9_]*/, "COLLATE BINARY" ) } + +# Print all fields definition lines except the `KEY` lines. +/^ / && !/^( (KEY|key)|\);)/ { + if( match( $0, /[^"`]AUTO_INCREMENT|auto_increment[^"`]/) ){ + aInc = 1 + gsub( /AUTO_INCREMENT|auto_increment/, "PRIMARY KEY AUTOINCREMENT" ) + } + gsub( /(UNIQUE KEY|unique key) (`.*`|".*") /, "UNIQUE " ) + gsub( /(CHARACTER SET|character set) [^ ]+[ ,]/, "" ) + # FIXME + # CREATE TRIGGER [UpdateLastTime] + # AFTER UPDATE + # ON Package + # FOR EACH ROW + # BEGIN + # UPDATE Package SET LastUpdate = CURRENT_TIMESTAMP WHERE ActionId = old.ActionId; + # END + gsub( /(ON|on) (UPDATE|update) (CURRENT_TIMESTAMP|current_timestamp)(\(\))?/, "" ) + gsub( /(DEFAULT|default) (CURRENT_TIMESTAMP|current_timestamp)(\(\))?/, "DEFAULT current_timestamp") + gsub( /(COLLATE|collate) [^ ]+ /, "" ) + gsub( /(ENUM|enum)[^)]+\)/, "text " ) + gsub( /(SET|set)\([^)]+\)/, "text " ) + gsub( /UNSIGNED|unsigned/, "" ) + gsub( /_utf8mb3/, "" ) + gsub( /` [^ ]*(INT|int|BIT|bit)[^ ]*/, "` integer" ) + gsub( /" [^ ]*(INT|int|BIT|bit)[^ ]*/, "\" integer" ) + ere_bit_field = "[bB]'[10]+'" + if( match($0, ere_bit_field) ){ + sub( ere_bit_field, bit_to_int( substr( $0, RSTART +2, RLENGTH -2 -1 ) ) ) + } + + # remove USING BTREE and other suffixes for USING, for example: "UNIQUE KEY + # `hostname_domain` (`hostname`,`domain`) USING BTREE," + gsub( / USING [^, ]+/, "" ) + + # field comments are not supported + gsub( / (COMMENT|comment).+$/, "" ) + # Get commas off end of line + gsub( /,.?$/, "" ) + if( prev ){ + if( firstInTable ){ + print prev + firstInTable = 0 + } + else { + print "," prev + } + } + else { + # FIXME check if this is correct in all cases + if( match( $1, + /(CONSTRAINT|constraint) ["].*["] (FOREIGN KEY|foreign key)/ ) ){ + print "," + } + } + prev = $1 +} + +/ ENGINE| engine/ { + if( prev ){ + if( firstInTable ){ + print prev + firstInTable = 0 + } + else { + print "," prev + } + } + prev="" + print ");" + next +} +# `KEY` lines are extracted from the `CREATE` block and stored in array for later print +# in a separate `CREATE KEY` command. The index name is prefixed by the table name to +# avoid a sqlite error for duplicate index name. +/^( (KEY|key)|\);)/ { + if( prev ){ + if( firstInTable ){ + print prev + firstInTable = 0 + } + else { + print "," prev + } + } + prev = "" + if( $0 == ");" ){ + print + } + else { + if( match( $0, /`[^`]+/ ) ){ + indexName = substr( $0, RSTART+1, RLENGTH-1 ) + } + if( match( $0, /\([^()]+/ ) ){ + indexKey = substr( $0, RSTART+1, RLENGTH-1 ) + } + # idx_ prefix to avoid name clashes (they really happen!) + key[tableName] = key[tableName] "CREATE INDEX \"idx_" \ + tableName "_" indexName "\" ON \"" tableName "\" (" indexKey ");\n" + } +} + +END { + if( no_END ){ exit 1} + # print all KEY creation lines. + for( table in key ){ printf key[table] } + + print "END TRANSACTION;" + + if( caseIssue ){ + printerr( \ + "INFO Pure sqlite identifiers are case insensitive (even if quoted\n" \ + " or if ASCII) and doesnt cross-check TABLE and TEMPORARY TABLE\n" \ + " identifiers. Thus expect errors like \"table T has no column named F\".") + } +} diff --git a/server/routes/events.js b/server/routes/events.js new file mode 100644 index 0000000..e5e47a0 --- /dev/null +++ b/server/routes/events.js @@ -0,0 +1,337 @@ +'use strict'; + +const express = require('express'), + { sendVerifyMail, sendPasswordChangedMail, sendVerifiedMail } = + require('../lib/mail'), + crypto = require('crypto'); + +const router = express.Router(); +const autoAuthenticate = 1; + +router.get('/', (req, res/*, next*/) => { + console.log('GET /users/'); + + return getSessionUser(req).then((user) => { + if (typeof user === 'string') { + return res.status(403).send({ message: user }); + } + return res.status(200).send(user); + }); +}); + +router.put('/password', async (req, res) => { + console.log('/users/password'); + const db = req.app.locals.db; + + const changes = { + currentPassword: req.query.c || req.body.c, + newPassword: req.query.n || req.body.n + }; + + if (!changes.currentPassword || !changes.newPassword) { + return res.status(400).send( + 'Missing current password and/or new password.'); + } + + if (changes.currentPassword == changes.newPassword) { + return res.status(400).send( + 'Attempt to set new password to current password.'); + } + + const user = await getSessionUser(req); + if (typeof user === 'string') { + return res.status(403).send({ message: user }); + } + return db.sequelize.query('SELECT id FROM users ' + + 'WHERE uid=:username AND password=:password', { + replacements: { + username: user.username, + password: crypto.createHash('sha256').update(changes.currentPassword).digest('base64') + }, + type: db.Sequelize.QueryTypes.SELECT, + raw: true + }).then(function(users) { + if (users.length != 1) { + return null; + } + return user; + }).then(function(user) { + if (!user) { + console.log('Invalid password'); + /* Invalid password */ + res.status(401).send('Invalid password'); + return null; + } + + return db.sequelize.query('UPDATE users SET password=:password WHERE uid=:username', { + replacements: { + username: user.username, + password: crypto.createHash('sha256').update(changes.newPassword).digest('base64') + } + }).then(function() { + console.log('Password changed for user ' + user.username + ' to \'' + changes.newPassword + '\'.'); + + res.status(200).send(user); + user.id = req.session.userId; + return sendPasswordChangedMail(db, req, user); + }); + }); +}); + +router.get('/csrf', (req, res) => { + const token = req.csrfToken(); + console.log('/users/csrf', token); + res.json({ csrfToken: token }); +}); + +router.post('/signup', function(req, res) { + console.log('/users/signup'); + const db = req.app.locals.db; + + const user = { + uid: req.body.email, + familyName: req.body.familyName, + firstName: req.body.firstName, + password: req.body.password, + email: req.body.email, + }; + + if (!user.uid + || !user.email + || !user.password + || !user.familyName + || !user.firstName) { + return res.status(400).send({ + message: 'Missing email address, password, and/or name.' + }); + } + + user.password = crypto.createHash('sha256') + .update(user.password).digest('base64'); + user.md5 = crypto.createHash('md5') + .update(user.email).digest('base64'); + + return db.sequelize.query('SELECT * FROM users WHERE uid=:uid', { + replacements: user, + type: db.Sequelize.QueryTypes.SELECT, + raw: true + }).then(async function(results) { + if (results.length != 0 && results[0].mailVerified) { + return res.status(400).send({ + message: 'Email address already used.' + }); + } + + let re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + if (!re.exec(user.email)) { + const error = `Invalid email address: ${user.email}.`; + console.log(error); + return res.status(401).send({ + message: error + }); + } + + try { + if (results.length != 0) { + await db.sequelize.query('UPDATE users ' + + 'SET mailVerified=0'); + req.session.userId = results[0].id; + } else { + let [, metadata] = await db.sequelize.query('INSERT INTO users ' + + '(uid,firstName,familyName,password,email,memberSince,' + + 'authenticated,md5) ' + + 'VALUES(:uid,:firstName,:familyName,:password,' + + `:email,CURRENT_TIMESTAMP,${autoAuthenticate},:md5)`, { + replacements: user + }); + req.session.userId = metadata.lastID; + } + return getSessionUser(req).then(function(user) { + if (typeof user === 'string') { + return res.status(403).send({ message: user }); + } + res.status(200).send(user); + user.id = req.session.userId; + return sendVerifyMail(db, req, user); + }); + } catch (error) { + console.error(error); + } + }); +}); + +const getSessionUser = async (req) => { + const db = req.app.locals.db; + + console.log(req.session); + if (!req.session || !req.session.userId) { + return 'Unauthorized. You must be logged in.'; + } + + let query = 'SELECT ' + + 'uid AS username,firstName,familyName,mailVerified,authenticated,memberSince,email,md5 ' + + 'FROM users WHERE id=:id'; + const results = await db.sequelize.query(query, { + replacements: { + id: req.session.userId + }, + type: db.Sequelize.QueryTypes.SELECT, + raw: true + }); + + if (results.length != 1) { + return 'Invalid account.'; + } + + const user = results[0]; + + if (!user.mailVerified) { + user.restriction = user.restriction || 'Email address not verified.'; + } else if (!user.authenticated) { + user.restriction = user.restriction || 'Accout not authorized.'; + } + + /* Strip out any fields that shouldn't be there. + * The allowed fields are: */ + const allowed = [ + 'maintainer', 'username', 'firstName', 'familyName', 'mailVerified', 'authenticated', 'name', 'email', 'restriction', 'md5' + ]; + for (let field in user) { + if (allowed.indexOf(field) == -1) { + delete user[field]; + } + } + return user; +}; + +router.post('/verify-email', async (req, res) => { + console.log('/users/verify-email'); + const key = req.body.token; + const db = req.app.locals.db; + + let results = await db.sequelize.query( + 'SELECT * FROM authentications WHERE key=:key', { + replacements: { key }, + type: db.sequelize.QueryTypes.SELECT + } + ); + + let token; + if (results.length == 0) { + console.log('Invalid key. Ignoring.'); + return res.status(400).send({ + message: 'Invalid authentication token.' + }); + } + + token = results[0]; + console.log(token); + console.log('Matched token: ' + JSON.stringify(token, null, 2)); + switch (token.type) { + case 'account-setup': + return db.sequelize.query( + 'UPDATE users SET mailVerified=1 WHERE id=:userId', { + replacements: token + } + ).then(function () { + return db.sequelize.query( + 'DELETE FROM authentications WHERE key=:key', { + replacements: { key } + } + ); + }).then(function () { + return db.sequelize.query( + 'SELECT * FROM users WHERE id=:userId', { + replacements: token, + type: db.sequelize.QueryTypes.SELECT + } + ); + }).then(function (results) { + if (results.length == 0) { + return res.status(500).send({ + message: 'Internal authentication error.' + }); + } + return results[0]; + }).then((user) => { + sendVerifiedMail(db, req, user); + req.session.userId = user.id; + }).then(() => { + return getSessionUser(req).then((user) => { + if (typeof user === 'string') { + return res.status(403).send({ message: user }); + } + return res.status(200).send(user); + }); + }); + } +}); + +router.post('/signin', (req, res) => { + console.log('/users/signin'); + const db = req.app.locals.db; + + let { email, password } = req.body; + + if (!email || !password) { + return res.status(400).send({ + message: 'Missing email and/or password' + }); + } + + console.log('Looking up user in DB.'); + + let query = 'SELECT ' + + 'id,mailVerified,authenticated,' + + 'uid AS username,' + + 'familyName,firstName,email ' + + 'FROM users WHERE uid=:username AND password=:password'; + return db.sequelize.query(query, { + replacements: { + username: email, + password: crypto.createHash('sha256').update(password).digest('base64') + }, + type: db.Sequelize.QueryTypes.SELECT + }).then(function(users) { + if (users.length != 1) { + return null; + } + let user = users[0]; + req.session.userId = user.id; + return user; + }).then(function(user) { + if (!user) { + console.log(email + ' not found (or invalid password.)'); + req.session.userId = null; + return res.status(401).send({ + message: 'Invalid sign in credentials' + }); + } + + let message = 'Logged in as ' + user.email + ' (' + user.id + ')'; + if (!user.mailVerified) { + console.log(message + ', who is not verified email.'); + } else if (!user.authenticated) { + console.log(message + ', who is not authenticated.'); + } else { + console.log(message); + } + + return getSessionUser(req).then((user) => { + if (typeof user === 'string') { + return res.status(403).send({ message: user }); + } + return res.status(200).send(user); + }); + }); +}); + +router.post('/signout', (req, res) => { + console.log('/users/signout'); + if (req.session && req.session.userId) { + req.session.userId = null; + } + res.status(200).send({}); +}); + +module.exports = router; diff --git a/server/routes/groups.js b/server/routes/groups.js index 39626ca..35f71d3 100755 --- a/server/routes/groups.js +++ b/server/routes/groups.js @@ -7,19 +7,31 @@ router.get('/', async (req, res/*, next*/) => { id: 1, ownerId: 1, name: 'Beer Tuesday', + group: 'beer-tuesday', nextEvent: Date.now() + 86400 * 14 * 1000 /* 2 weeks from now */ } ] ); }); -router.post('/:id', async (req, res) => { - const { id } = req.params; - if (!id) { + +router.get('/:groupId/events', async (req, res/*, next*/) => { + return res.status(200).send( + [{ + id: 1, + name: 'Tuesday', + date: Date.now() + 86400 * 14 * 1000 /* 2 weeks from now */ + }] + ); +}); + +router.post('/:groupId', async (req, res) => { + const { groupId } = req.params; + if (!groupId) { return res.status(400).send({ message: `Invalid group.`}); } const group = { - id + id: groupId }; return res.status(200).send({ id: group.id }); diff --git a/server/routes/locations.js b/server/routes/locations.js new file mode 100644 index 0000000..147915a --- /dev/null +++ b/server/routes/locations.js @@ -0,0 +1,60 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); + +const originalLocations = require('../location-data.js'); + + +router.put('/', (req, res) => { + const location = req.body; + location.id = originalLocations.length; + originalLocations.push(location); + res.status(200).send([ location ]); +}); + +router.get('/:locationId?', (req, res) => { + const { locationId } = req.params; + if (locationId) { + const location = originalLocations.find( + item => item.id === locationId + ); + if (!location) { + res.status(404).send({ message: `Location ${locationId} not found.`}); + } + res.status(200).send([ location ]); + } + + return res.status(200).send(originalLocations); +}); + +router.post('/:locationId', (req, res) => { + const { locationId } = req.params; + if (!locationId) { + return res.status(400).send({ message: `Invalid location.`}); + } + const location = originalLocations.find( + item => item.id === locationId + ); + if (!location) { + res.status(404).send({ message: `Location ${locationId} not found.` }); + } + res.status(200).send([location]); +}); + +router.delete('/:locationId', (req, res) => { + const { locationId } = req.params; + if (!locationId) { + return res.status(400).send({ message: `Invalid location.` }); + } + const locationIndex = originalLocations.findIndex( + item => item.id === locationId + ); + if (locationIndex === -1) { + res.status(404).send({ message: `Location ${locationId} not found.` }); + } + originalLocations.splice(locationIndex, 1); + res.status(200).send({ message: `Location ${locationId} deleted.`}); +}); + +module.exports = router;