diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a93fed2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Common files +.DS_Store +node_modules/ +npm-debug.log + +# Build products +dist/ + +# Temporary files +oid_settings.json +nginx.conf diff --git a/Gruntfile.js b/Gruntfile.js index 5603698..8596904 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -47,6 +47,12 @@ module.exports = function (grunt) { cwd: './node_modules/bootstrap/', src: ['dist/**'], dest: './dist/app/assets/bootstrap/' + }, + { + expand: true, + cwd: './node_modules/oidc-client/', + src: ['dist/**'], + dest: './dist/app/assets/oidc-client/' } ] } diff --git a/app/components/input.jsx b/app/components/input.jsx new file mode 100644 index 0000000..5d924c6 --- /dev/null +++ b/app/components/input.jsx @@ -0,0 +1,27 @@ +import React from 'react'; + +// eslint-disable-next-line react/prefer-stateless-function +export default class Input extends React.Component { + render() { + return ( + + ); + } +} + +Input.propTypes = { + type: React.PropTypes.string, + placeholder: React.PropTypes.string, + value: React.PropTypes.string, +}; + +Input.defaultProps = { + type: 'text', + placeholder: '', + value: '', +}; diff --git a/app/index.html b/app/index.html index a2fa9e0..7f6808d 100644 --- a/app/index.html +++ b/app/index.html @@ -7,6 +7,7 @@
- + + diff --git a/app/pages/login.jsx b/app/pages/login.jsx index 2697c23..315f5e0 100644 --- a/app/pages/login.jsx +++ b/app/pages/login.jsx @@ -9,7 +9,7 @@ export default class PageLogin extends React.Component { return ( ); diff --git a/package.json b/package.json index 9ee9f16..e5aab65 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "bootstrap": "^4.0.0-alpha.6", "express": "^4.14.1", "mysql": "^2.13.0", + "oidc-client": "^1.3.0-beta.3", "react": "^15.4.2", "react-dom": "^15.4.2", "react-router-dom": "^4.0.0-beta.6", diff --git a/server/api.js b/server/api.js index 76123a8..abd3e73 100644 --- a/server/api.js +++ b/server/api.js @@ -1,20 +1,78 @@ import Router from 'express'; +import { WebError, UnauthenticatedError } from './errors'; export default class API { - constructor() { + constructor(database) { + this.database = database; + + // Binds + this.auth = this.auth.bind(this); + this.router = Router({ strict: true, }); + // Routes + this.router.get('/', (req, res) => { res.end('API v1'); }); - this.router.all('/*', this.constructor.auth); + // Schools + this.router.get('/schools/', (req, res, next) => { + this.database.getSchools() + .then((data) => { + res.json(data); + }).catch(next); + }); + this.router.get('/schools/:school', (req, res, next) => { + this.database.getSchool(req.params.school) + .then((data) => { + res.json(data); + }).catch(next); + }); + + // Users + this.router.get('/schools/:school/users/', this.auth, (req, res, next) => { + this.database.getUsers(req.params.school) + .then((data) => { + res.json(data); + }).catch(next); + }); + this.router.get('/schools/:school/users/:id', this.auth, (req, res, next) => { + this.database.getUser(req.params.school, req.params.id) + .then((data) => { + res.json(data); + }).catch(next); + }); + + // this.router.all('/*', this.auth); + this.router.use(API.error); } - static auth(req, res, next) { - res.end('not implemented'); + auth(req, res, next) { + // res.end('not implemented'); + if (this.validate(req.get('FakeAuth'))) { + return next(); + } + return next(new UnauthenticatedError()); + } + + // eslint-disable-next-line class-methods-use-this + validate(token) { + return !!token; + } + + // eslint-disable-next-line no-unused-vars + static error(err, req, res, next) { + if (err instanceof WebError) { + err.responseTo(res); + } else if (err) { + console.error(err); + res.status(500).json({ + error: 'Unknown error', + }); + } next(); } } diff --git a/server/database.js b/server/database.js index 89d16d0..3a72921 100644 --- a/server/database.js +++ b/server/database.js @@ -1,6 +1,7 @@ import mysql from 'mysql'; import semver from 'semver'; import { fatal, getVersion } from './utils'; +import { NotFoundError } from './errors'; const DATABASE = 'chronos'; @@ -12,12 +13,63 @@ export default class Database { this.checkAndMigrate(); } - query(query, values) { + async getSchools() { + return this.query(` + SELECT id, name, domain + FROM school + `); + } + async getSchool(id) { + return this.query(` + SELECT * + FROM auth RIGHT JOIN school + ON auth.school = school.id + WHERE school.${isNaN(parseInt(id, 10)) ? 'domain' : 'id'} = ? + `, [id], { + required: true, + }) + .then(results => ({ + id: results[0].id, + name: results[0].name, + domain: results[0].domain, + auth: results.map(r => Object.assign(r, { + school: undefined, + name: undefined, + domain: undefined, + oid_csecret: undefined, + })), + })) + .catch(err => err.withNoun('School')); + } + + // eslint-disable-next-line class-methods-use-this + async getUsers(school) { + return this.query(` + SELECT id, name + FROM user + WHERE user.school = ? + `, [school]); + } + async getUser(school, id) { + return this.query(` + SELECT id, name, email, role + FROM user + WHERE user.school = ? + AND user.id = ? + `, [school, id], { + required: true, + }) + .catch(err => err.withNoun('User')); + } + + query(query, values, options = {}) { console.log('QUERY:', query.replace(/[\n\t]+/g, ' ').replace(/^ /g, '').replace(/ $/g, '')); return new Promise((resolve, reject) => { this.connection.query(query, values, (err, results, fields) => { if (err) { reject(err); + } else if (options.required === true && results.length < 1) { + reject(new NotFoundError()); } resolve(results, fields); }); @@ -40,7 +92,7 @@ export default class Database { } else if (semver.satisfies(oldVersion, '~0')) { // lmao forces database migration // database needs to be updated return this.query(`DROP DATABASE ${DATABASE}`) - .then(() => this.checkAndMigrate()); + .then(() => this.checkAndMigrate()); } // database does not exist @@ -100,7 +152,7 @@ export default class Database { `CREATE TABLE group_mentor ( id INT NOT NULL, level TINYINT NOT NULL, - year YEAR NOT NULL, + year YEAR(4) NOT NULL, PRIMARY KEY (id), FOREIGN KEY (id) REFERENCES group_(id) ON DELETE CASCADE ON UPDATE CASCADE )`, @@ -153,12 +205,21 @@ export default class Database { VALUES ('version', '${getVersion()}') `); + // TODO: Build admin interface to add schools // add first school - await this.query(` + const firstSchool = await this.query(` INSERT INTO school (name, domain) VALUES (?, ?) `, ['NUS High School', 'nushigh.edu.sg']); + // eslint-disable-next-line global-require + const fs = require('fs'); + const tmpsettings = JSON.parse(fs.readFileSync('oid_settings.json', 'utf8')); + await this.query(` + INSERT INTO auth (school, type, oid_meta, oid_cid, oid_csecret) + VALUES (?, ?, ?, ?, ?) + `, [firstSchool.insertId, 'OID', tmpsettings.oid_meta, tmpsettings.oid_cid, tmpsettings.oid_csecret]); + return true; } } diff --git a/server/errors.js b/server/errors.js new file mode 100644 index 0000000..de126a2 --- /dev/null +++ b/server/errors.js @@ -0,0 +1,34 @@ +export class WebError extends Error { + responseTo(res) { + return res.status(this.code).json(this.toJSON()); + } + toJSON() { + return { + type: this.name, + code: this.code, + error: this.message, + }; + } +} + +export class NotFoundError extends WebError { + constructor(noun = 'Resource') { + super(); + this.name = this.constructor.name; + this.message = `${noun} not found`; + this.code = 404; + } + withNoun(noun) { + this.message = `${noun} not found`; + return this; + } +} + +export class UnauthenticatedError extends WebError { + constructor() { + super(); + this.name = this.constructor.name; + this.message = 'User not authenticated'; + this.code = 403; + } +}