diff --git a/js/Makefile.files b/js/Makefile.files index e8aa030420..919df4269c 100644 --- a/js/Makefile.files +++ b/js/Makefile.files @@ -59,6 +59,10 @@ JAVASCRIPT_JSLINT = \ \ `find @srcdir@/js/apps/system/cerberus -name "*.js"` \ `find @srcdir@/js/apps/system/gharial -name "*.js"` \ + `find @srcdir@/js/apps/system/oauth2 -name "*.js"` \ + `find @srcdir@/js/apps/system/sessions -name "*.js"` \ + `find @srcdir@/js/apps/system/simple-auth -name "*.js"` \ + `find @srcdir@/js/apps/system/users -name "*.js"` \ \ `find @srcdir@/js/apps/system/aardvark/frontend/js/models -name "*.js"` \ `find @srcdir@/js/apps/system/aardvark/frontend/js/views -name "*.js"` \ diff --git a/js/apps/system/oauth2/errors.js b/js/apps/system/oauth2/errors.js new file mode 100644 index 0000000000..6ad3ce55ff --- /dev/null +++ b/js/apps/system/oauth2/errors.js @@ -0,0 +1,12 @@ +/*jslint indent: 2, nomen: true, maxlen: 120, white: true, plusplus: true, unparam: true, regexp: true, vars: true */ +/*global require, exports */ +(function () { + 'use strict'; + + function ProviderNotFound(key) { + this.message = 'Provider with key ' + key + ' not found.'; + } + ProviderNotFound.prototype = new Error(); + + exports.ProviderNotFound = ProviderNotFound; +}()); \ No newline at end of file diff --git a/js/apps/system/oauth2/manifest.json b/js/apps/system/oauth2/manifest.json new file mode 100644 index 0000000000..b1a4db8fdf --- /dev/null +++ b/js/apps/system/oauth2/manifest.json @@ -0,0 +1,17 @@ +{ + "name": "oauth2", + "author": "Alan Plum", + "version": "0.1", + "isSystem": true, + "description": "OAuth2 authentication for Foxx.", + + "exports": { + "providers": "providers.js" + }, + + "defaultDocument": "", + + "lib": ".", + + "setup": "setup.js" +} diff --git a/js/apps/system/oauth2/providers.js b/js/apps/system/oauth2/providers.js new file mode 100644 index 0000000000..698f934824 --- /dev/null +++ b/js/apps/system/oauth2/providers.js @@ -0,0 +1,214 @@ +/*jslint indent: 2, nomen: true, maxlen: 120, vars: true, es5: true */ +/*global require, exports, applicationContext */ +(function () { + 'use strict'; + var _ = require('underscore'); + var url = require('url'); + var querystring = require('querystring'); + var internal = require('internal'); + var arangodb = require('org/arangodb'); + var db = arangodb.db; + var Foxx = require('org/arangodb/foxx'); + var errors = require('./errors'); + + var Provider = Foxx.Model.extend({}, { + attributes: { + _key: {type: 'string', required: true}, + label: {type: 'string', required: true}, + authEndpoint: {type: 'string', required: true}, + tokenEndpoint: {type: 'string', required: true}, + refreshEndpoint: {type: 'string', required: false}, + activeUserEndpoint: {type: 'string', required: false}, + usernameTemplate: {type: 'string', required: false}, + clientId: {type: 'string', required: true}, + clientSecret: {type: 'string', required: true} + } + }); + + var providers = new Foxx.Repository( + applicationContext.collection('providers'), + {model: Provider} + ); + + function listProviders() { + return providers.collection.all().toArray().forEach(function (provider) { + return _.pick(provider, '_key', 'label', 'clientId'); + }); + } + + function createProvider(data) { + var provider = new Provider(data); + providers.save(provider); + return provider; + } + + function getProvider(key) { + var provider; + try { + provider = providers.byId(key); + } catch (err) { + if ( + err instanceof arangodb.ArangoError && + err.errorNum === arangodb.ERROR_ARANGO_DOCUMENT_NOT_FOUND + ) { + throw new errors.ProviderNotFound(key); + } else { + throw err; + } + } + return provider; + } + + function deleteProvider(key) { + try { + providers.removeById(key); + } catch (err) { + if ( + err instanceof arangodb.ArangoError + && err.errorNum === arangodb.ERROR_ARANGO_DOCUMENT_NOT_FOUND + ) { + throw new errors.ProviderNotFound(key); + } else { + throw err; + } + } + return null; + } + + _.extend(Provider.prototype, { + getAuthUrl: function (redirect_uri, args) { + if (redirect_uri && typeof redirect_uri === 'object') { + args = redirect_uri; + redirect_uri = undefined; + } + var endpoint = url.parse(this.get('authEndpoint')); + args = _.extend(querystring.parse(endpoint.query), args); + if (redirect_uri) { + args.redirect_uri = redirect_uri; + } + if (!args.response_type) { + args.response_type = 'code'; + } + args.client_id = this.get('clientId'); + endpoint.search = '?' + querystring.stringify(args); + return url.format(endpoint); + }, + _getTokenRequest: function (code, redirect_uri, args) { + if (code && typeof code === 'object') { + args = code; + code = undefined; + redirect_uri = undefined; + } else if (redirect_uri && typeof redirect_uri === 'object') { + args = redirect_uri; + redirect_uri = undefined; + } + var endpoint = url.parse(this.get('tokenEndpoint')); + args = _.extend(querystring.parse(endpoint.query), args); + if (code) { + args.code = code; + } + if (redirect_uri) { + args.redirect_uri = redirect_uri; + } + if (!args.grant_type) { + args.grant_type = 'authorization_code'; + } + args.client_id = this.get('clientId'); + args.client_secret = this.get('clientSecret'); + delete endpoint.search; + delete endpoint.query; + return {url: url.format(endpoint), body: args}; + }, + getActiveUserUrl: function (access_token, args) { + var endpoint = this.get('activeUserEndpoint'); + if (!endpoint) { + return null; + } + if (access_token && typeof access_token === 'object') { + args = access_token; + access_token = undefined; + } + args = _.extend(querystring.parse(endpoint.query), args); + if (access_token) { + args.access_token = access_token; + } + endpoint = url.parse(endpoint); + args = _.extend(querystring.parse(endpoint.query), args); + endpoint.search = '?' + querystring.stringify(args); + return url.format(endpoint); + }, + getUsername: function (obj) { + var tpl = this.get('usernameTemplate'); + if (!tpl) { + tpl = '<%= id %>'; + } + return _.template(tpl)(obj); + }, + exchangeGrantToken: function (code, redirect_uri) { + var request = this._getTokenRequest(code, redirect_uri); + var response = internal.download( + request.url, + querystring.stringify(request.body), + { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/x-www-form-urlencoded' + } + } + ); + if (!response.body) { + throw new Error('OAuth provider ' + this.get('_key') + ' returned HTTP ' + response.code); + } + try { + return JSON.parse(response.body); + } catch (err) { + if (err instanceof SyntaxError) { + return querystring.parse(response.body); + } + throw err; + } + }, + fetchActiveUser: function (access_token) { + var url = this.getActiveUserUrl(access_token); + if (!url) { + throw new Error('Provider ' + this.get('_key') + ' does not support active user lookup'); + } + var response = internal.download(url); + if (!response.body) { + throw new Error('OAuth provider ' + this.get('_key') + ' returned HTTP ' + response.code); + } + try { + return JSON.parse(response.body); + } catch (err) { + if (err instanceof SyntaxError) { + return querystring.parse(response.body); + } + throw err; + } + }, + save: function () { + var provider = this; + providers.replace(provider); + return provider; + }, + delete: function () { + try { + deleteProvider(this.get('_key')); + return true; + } catch (e) { + if (e instanceof errors.ProviderNotFound) { + return false; + } + throw e; + } + } + }); + + exports.list = listProviders; + exports.create = createProvider; + exports.get = getProvider; + exports.delete = deleteProvider; + exports.errors = errors; + exports.repository = providers; +}()); \ No newline at end of file diff --git a/js/apps/system/oauth2/setup.js b/js/apps/system/oauth2/setup.js new file mode 100644 index 0000000000..4dea8c6c26 --- /dev/null +++ b/js/apps/system/oauth2/setup.js @@ -0,0 +1,47 @@ +/*jslint indent: 2, nomen: true, maxlen: 120, white: true, plusplus: true, unparam: true, regexp: true, vars: true */ +/*global require, applicationContext */ +(function () { + 'use strict'; + var db = require('org/arangodb').db; + var providersName = applicationContext.collectionName('providers'); + + if (db._collection(providersName) === null) { + db._create(providersName); + } + + var providers = db._collection(providersName); + [ + { + _key: 'github', + label: 'GitHub', + authEndpoint: 'https://github.com/login/oauth/authorize?scope=user', + tokenEndpoint: 'https://github.com/login/oauth/access_token', + activeUserEndpoint: 'https://api.github.com/user', + clientId: null, + clientSecret: null + }, + { + _key: 'facebook', + label: 'Facebook', + authEndpoint: 'https://www.facebook.com/dialog/oauth', + tokenEndpoint: 'https://graph.facebook.com/oauth/access_token', + activeUserEndpoint: 'https://graph.facebook.com/v2.0/me', + clientId: null, + clientSecret: null + }, + { + _key: 'google', + label: 'Google', + authEndpoint: 'https://accounts.google.com/o/oauth2/auth?access_type=offline&scope=profile', + tokenEndpoint: 'https://accounts.google.com/o/oauth2/token', + refreshEndpoint: 'https://accounts.google.com/o/oauth2/token', + activeUserEndpoint: 'https://www.googleapis.com/plus/v1/people/me', + clientId: null, + clientSecret: null + } + ].forEach(function(provider) { + if (!providers.exists(provider._key)) { + providers.save(provider); + } + }); +}()); \ No newline at end of file diff --git a/js/apps/system/sessions/app.js b/js/apps/system/sessions/app.js new file mode 100644 index 0000000000..bc571422c9 --- /dev/null +++ b/js/apps/system/sessions/app.js @@ -0,0 +1,78 @@ +/*jslint indent: 2, nomen: true, maxlen: 120, vars: true, es5: true */ +/*global require, applicationContext */ +(function () { + 'use strict'; + var _ = require('underscore'); + var Foxx = require('org/arangodb/foxx'); + var errors = require('./errors'); + var controller = new Foxx.Controller(applicationContext); + var api = Foxx.requireApp('/sessions').sessionStorage; + + controller.post('/', function (req, res) { + var session = api.create(req.body()); + res.status(201); + res.json(session.forClient()); + }) + .errorResponse(SyntaxError, 400, 'Malformed or non-JSON session data.') + .summary('Create session') + .notes('Stores the given sessionData in a new session.'); + + controller.get('/:sid', function (req, res) { + var session = api.get(req.urlParameters.sid); + res.json(session.forClient()); + }) + .pathParam('sid', { + description: 'Session ID', + type: 'string' + }) + .errorResponse(errors.SessionExpired, 404, 'Session has expired') + .errorResponse(errors.SessionNotFound, 404, 'Session does not exist') + .summary('Read session') + .notes('Fetches the session with the given sid.'); + + controller.put('/:sid', function (req, res) { + var body = JSON.parse(req.rawBody()); + var session = api.get(req.urlParameters.sid); + session.set('sessionData', body); + session.save(); + res.json(session.forClient()); + }) + .pathParam('sid', { + description: 'Session ID', + type: 'string' + }) + .errorResponse(errors.SessionExpired, 404, 'Session has expired') + .errorResponse(errors.SessionNotFound, 404, 'Session does not exist') + .errorResponse(SyntaxError, 400, 'Malformed or non-JSON session data.') + .summary('Update session (replace)') + .notes('Updates the session with the given sid by replacing the sessionData.'); + + controller.patch('/:sid', function (req, res) { + var body = JSON.parse(req.rawBody()); + var session = api.get(req.urlParameters.sid); + _.extend(session.get('sessionData'), body); + session.save(); + res.json(session.forClient()); + }) + .pathParam('sid', { + description: 'Session ID', + type: 'string' + }) + .errorResponse(errors.SessionExpired, 404, 'Session has expired') + .errorResponse(errors.SessionNotFound, 404, 'Session does not exist') + .errorResponse(SyntaxError, 400, 'Malformed or non-JSON session data.') + .summary('Update session') + .notes('Updates the session with the given sid by merging its sessionData.'); + + controller.delete('/:sid', function (req, res) { + api.destroy(req.urlParameters.sid); + res.status(204); + }) + .pathParam('sid', { + description: 'Session ID', + type: 'string' + }) + .errorResponse(errors.SessionNotFound, 404, 'Session does not exist') + .summary('Delete session') + .notes('Removes the session with the given sid from the database.'); +}()); \ No newline at end of file diff --git a/js/apps/system/sessions/errors.js b/js/apps/system/sessions/errors.js new file mode 100644 index 0000000000..e9d79e6164 --- /dev/null +++ b/js/apps/system/sessions/errors.js @@ -0,0 +1,18 @@ +/*jslint indent: 2, nomen: true, maxlen: 120, vars: true, es5: true */ +/*global require, exports */ +(function () { + 'use strict'; + + function SessionNotFound(sid) { + this.message = 'Session with session id ' + sid + ' not found.'; + } + SessionNotFound.prototype = new Error(); + + function SessionExpired(sid) { + this.message = 'Session with session id ' + sid + ' has expired.'; + } + SessionExpired.prototype = Object.create(SessionNotFound.prototype); + + exports.SessionNotFound = SessionNotFound; + exports.SessionExpired = SessionExpired; +}()); \ No newline at end of file diff --git a/js/apps/system/sessions/manifest.json b/js/apps/system/sessions/manifest.json new file mode 100644 index 0000000000..ac6ca75d76 --- /dev/null +++ b/js/apps/system/sessions/manifest.json @@ -0,0 +1,44 @@ +{ + "name": "sessions", + "author": "Alan Plum", + "version": "0.1", + "isSystem": true, + "description": "Session storage for Foxx.", + + "controllers": { + "/": "app.js" + }, + + "exports": { + "sessionStorage": "storage.js" + }, + + "defaultDocument": "", + + "lib": ".", + + "setup": "setup.js", + + "configuration": { + "timeToLive": { + "description": "Session expiry timeout in milliseconds.", + "type": "integer", + "default": 604800000 + }, + "ttlType": { + "description": "Timestamp session expiry should be checked against.", + "type": "string", + "default": "created" + }, + "sidTimestamp": { + "description": "Append a timestamp to the session id.", + "type": "boolean", + "default": false + }, + "sidLength": { + "description": "Length of the random part of the session id", + "type": "integer", + "default": 20 + } + } +} diff --git a/js/apps/system/sessions/setup.js b/js/apps/system/sessions/setup.js new file mode 100644 index 0000000000..35a6ee8997 --- /dev/null +++ b/js/apps/system/sessions/setup.js @@ -0,0 +1,11 @@ +/*jslint indent: 2, nomen: true, maxlen: 120, vars: true, es5: true */ +/*global require, applicationContext */ +(function () { + 'use strict'; + var db = require('org/arangodb').db; + var sessionsName = applicationContext.collectionName('sessions'); + + if (db._collection(sessionsName) === null) { + db._create(sessionsName); + } +}()); \ No newline at end of file diff --git a/js/apps/system/sessions/storage.js b/js/apps/system/sessions/storage.js new file mode 100644 index 0000000000..c380bb8127 --- /dev/null +++ b/js/apps/system/sessions/storage.js @@ -0,0 +1,201 @@ +/*jslint indent: 2, nomen: true, maxlen: 120, vars: true, es5: true */ +/*global require, exports, applicationContext */ +(function () { + 'use strict'; + var _ = require('underscore'); + var internal = require('internal'); + var arangodb = require('org/arangodb'); + var db = arangodb.db; + var addCookie = require('org/arangodb/actions').addCookie; + var crypto = require('org/arangodb/crypto'); + var Foxx = require('org/arangodb/foxx'); + var errors = require('./errors'); + + var cfg = applicationContext.configuration; + + var Session = Foxx.Model.extend({}, { + attributes: { + _key: {type: 'string', required: true}, + uid: {type: 'string', required: false}, + sessionData: {type: 'object', required: true}, + userData: {type: 'object', required: true}, + created: {type: 'integer', required: true}, + lastAccess: {type: 'integer', required: true}, + lastUpdate: {type: 'integer', required: true} + } + }); + + var sessions = new Foxx.Repository( + applicationContext.collection('sessions'), + {model: Session} + ); + + function generateSessionId() { + var sid = ''; + if (cfg.sidTimestamp) { + sid = internal.base64Encode(Number(new Date())); + if (cfg.sidLength === 0) { + return sid; + } + sid += '-'; + } + return sid + internal.genRandomAlphaNumbers(cfg.sidLength || 10); + } + + function createSession(sessionData) { + var sid = generateSessionId(cfg); + var now = Number(new Date()); + var session = new Session({ + _key: sid, + sid: sid, + sessionData: sessionData || {}, + userData: {}, + created: now, + lastAccess: now, + lastUpdate: now + }); + sessions.save(session); + return session; + } + + function getSession(sid) { + var session; + db._executeTransaction({ + collections: { + read: [sessions.collection.name()], + write: [sessions.collection.name()] + }, + action: function () { + try { + session = sessions.byId(sid); + session.enforceTimeout(); + } catch (err) { + if ( + err instanceof arangodb.ArangoError + && err.errorNum === arangodb.ERROR_ARANGO_DOCUMENT_NOT_FOUND + ) { + throw new errors.SessionNotFound(sid); + } else { + throw err; + } + } + var now = Number(new Date()); + sessions.collection.update(session.forDB(), { + lastAccess: now + }); + session.set('lastAccess', now); + } + }); + return session; + } + + function deleteSession(sid) { + try { + sessions.removeById(sid); + } catch (err) { + if ( + err instanceof arangodb.ArangoError + && err.errorNum === arangodb.ERROR_ARANGO_DOCUMENT_NOT_FOUND + ) { + throw new errors.SessionNotFound(sid); + } else { + throw err; + } + } + return null; + } + + function fromCookie(req, cookieName, secret) { + var session = null; + var value = req.cookies[cookieName]; + if (value) { + if (secret) { + var signature = req.cookies[cookieName + '_sig'] || ''; + if (!crypto.constantEquals(signature, crypto.hmac(secret, value))) { + return null; + } + } + try { + session = getSession(value); + } catch (e) { + if (!(e instanceof errors.SessionNotFound)) { + throw e; + } + } + } + return session; + } + + _.extend(Session.prototype, { + enforceTimeout: function () { + if (!cfg.timeToLive) { + return; + } + var now = Number(new Date()); + var prop = cfg.ttlType; + if (!prop || !this.get(prop)) { + prop = 'created'; + } + if (cfg.timeToLive < (now - this.get(prop))) { + throw new errors.SessionExpired(this.get('_key')); + } + }, + addCookie: function (res, cookieName, secret) { + var value = this.get('_key'); + var ttl = cfg.timeToLive; + ttl = ttl ? Math.floor(ttl / 1000) : undefined; + addCookie(res, cookieName, value, ttl); + if (secret) { + addCookie(res, cookieName + '_sig', crypto.hmac(secret, value), ttl); + } + }, + clearCookie: function (res, cookieName, secret) { + addCookie(res, cookieName, '', -(7 * 24 * 60 * 60)); + if (secret) { + addCookie(res, cookieName + '_sig', '', -(7 * 24 * 60 * 60)); + } + }, + setUser: function (user) { + var session = this; + if (user) { + session.set('uid', user.get('_key')); + session.set('userData', user.get('userData')); + } else { + delete session.attributes.uid; + session.set('userData', {}); + } + return session; + }, + save: function () { + var session = this; + var now = Number(new Date()); + session.set('lastAccess', now); + session.set('lastUpdate', now); + sessions.replace(session); + return session; + }, + delete: function () { + var session = this; + var now = Number(new Date()); + session.set('lastAccess', now); + session.set('lastUpdate', now); + try { + deleteSession(session.get('_key')); + return true; + } catch (e) { + if (e instanceof errors.SessionNotFound) { + return false; + } + throw e; + } + } + }); + + exports.fromCookie = fromCookie; + exports.create = createSession; + exports.get = getSession; + exports.delete = deleteSession; + exports.errors = errors; + exports.repository = sessions; + exports._generateSessionId = generateSessionId; +}()); \ No newline at end of file diff --git a/js/apps/system/simple-auth/auth.js b/js/apps/system/simple-auth/auth.js new file mode 100644 index 0000000000..cb40930ad2 --- /dev/null +++ b/js/apps/system/simple-auth/auth.js @@ -0,0 +1,29 @@ +/*jslint indent: 2, nomen: true, maxlen: 120, vars: true, es5: true */ +/*global require, exports, applicationContext */ +(function () { + 'use strict'; + var crypto = require('org/arangodb/crypto'); + var cfg = applicationContext.configuration; + + function verifyPassword(authData, password) { + if (!authData) { + authData = {}; + } + var hashMethod = authData.method || cfg.hashMethod; + var salt = authData.salt || ''; + var storedHash = authData.hash || ''; + var generatedHash = crypto[hashMethod](salt + password); + // non-lazy comparison to avoid timing attacks + return crypto.constantEquals(storedHash, generatedHash); + } + + function hashPassword(password) { + var hashMethod = cfg.hashMethod; + var salt = crypto.genRandomAlphaNumbers(cfg.saltLength); + var hash = crypto[hashMethod](salt + password); + return {method: hashMethod, salt: salt, hash: hash}; + } + + exports.verifyPassword = verifyPassword; + exports.hashPassword = hashPassword; +}()); \ No newline at end of file diff --git a/js/apps/system/simple-auth/manifest.json b/js/apps/system/simple-auth/manifest.json new file mode 100644 index 0000000000..1f803ba56a --- /dev/null +++ b/js/apps/system/simple-auth/manifest.json @@ -0,0 +1,28 @@ +{ + "name": "simple-auth", + "author": "Alan Plum", + "version": "0.1", + "isSystem": true, + "description": "Simple password-based authentication for Foxx.", + + "exports": { + "auth": "auth.js" + }, + + "defaultDocument": "", + + "lib": ".", + + "configuration": { + "hashMethod": { + "description": "Cryptographic hash function to use for new passwords.", + "type": "string", + "default": "sha256" + }, + "saltLength": { + "description": "Length of new salts.", + "type": "integer", + "default": 16 + } + } +} diff --git a/js/apps/system/users/errors.js b/js/apps/system/users/errors.js new file mode 100644 index 0000000000..bcc31bf1a7 --- /dev/null +++ b/js/apps/system/users/errors.js @@ -0,0 +1,18 @@ +/*jslint indent: 2, nomen: true, maxlen: 120, vars: true, es5: true */ +/*global require, exports */ +(function () { + 'use strict'; + + function UserNotFound(uid) { + this.message = 'User with user id ' + uid + ' not found.'; + } + UserNotFound.prototype = new Error(); + + function UsernameNotAvailable(username) { + this.message = 'The username ' + username + ' is not available or already taken.'; + } + UsernameNotAvailable.prototype = new Error(); + + exports.UserNotFound = UserNotFound; + exports.UsernameNotAvailable = UsernameNotAvailable; +}()); \ No newline at end of file diff --git a/js/apps/system/users/manifest.json b/js/apps/system/users/manifest.json new file mode 100644 index 0000000000..b0e437bd65 --- /dev/null +++ b/js/apps/system/users/manifest.json @@ -0,0 +1,17 @@ +{ + "name": "users", + "author": "Alan Plum", + "version": "0.1", + "isSystem": true, + "description": "Username-based user storage for Foxx.", + + "exports": { + "userStorage": "storage.js" + }, + + "defaultDocument": "", + + "lib": ".", + + "setup": "setup.js" +} diff --git a/js/apps/system/users/setup.js b/js/apps/system/users/setup.js new file mode 100644 index 0000000000..d6417b2345 --- /dev/null +++ b/js/apps/system/users/setup.js @@ -0,0 +1,11 @@ +/*jslint indent: 2, nomen: true, maxlen: 120, vars: true, es5: true */ +/*global require, applicationContext */ +(function () { + 'use strict'; + var db = require('org/arangodb').db; + var usersName = applicationContext.collectionName('users'); + + if (db._collection(usersName) === null) { + db._create(usersName); + } +}()); \ No newline at end of file diff --git a/js/apps/system/users/storage.js b/js/apps/system/users/storage.js new file mode 100644 index 0000000000..16a753e8ff --- /dev/null +++ b/js/apps/system/users/storage.js @@ -0,0 +1,121 @@ +/*jslint indent: 2, nomen: true, maxlen: 120, vars: true, es5: true */ +/*global require, exports, applicationContext */ +(function () { + 'use strict'; + var _ = require('underscore'); + var arangodb = require('org/arangodb'); + var db = arangodb.db; + var Foxx = require('org/arangodb/foxx'); + var errors = require('./errors'); + + var User = Foxx.Model.extend({}, { + attributes: { + authData: {type: 'object', required: true}, + userData: {type: 'object', required: true} + } + }); + + var users = new Foxx.Repository( + applicationContext.collection('users'), + {model: User} + ); + + function resolve(username) { + var user = users.firstExample({'userData.username': username}); + if (!user.get('_key')) { + return null; + } + return user; + } + + function listUsers() { + return users.collection.all().toArray().map(function (user) { + return user.userData ? user.userData.username : null; + }).filter(Boolean); + } + + function createUser(userData) { + if (!userData) { + userData = {}; + } + if (!userData.username) { + throw new Error('Must provide username!'); + } + var user; + db._executeTransaction({ + collections: { + read: [users.collection.name()], + write: [users.collection.name()] + }, + action: function () { + if (resolve(userData.username)) { + throw new errors.UsernameNotAvailable(userData.username); + } + user = new User({ + userData: userData, + authData: {} + }); + users.save(user); + } + }); + return user; + } + + function getUser(uid) { + var user; + try { + user = users.byId(uid); + } catch (err) { + if ( + err instanceof arangodb.ArangoError + && err.errorNum === arangodb.ERROR_ARANGO_DOCUMENT_NOT_FOUND + ) { + throw new errors.UserNotFound(uid); + } + throw err; + } + return user; + } + + function deleteUser(uid) { + try { + users.removeById(uid); + } catch (err) { + if ( + err instanceof arangodb.ArangoError + && err.errorNum === arangodb.ERROR_ARANGO_DOCUMENT_NOT_FOUND + ) { + throw new errors.UserNotFound(uid); + } + throw err; + } + return null; + } + + _.extend(User.prototype, { + save: function () { + var user = this; + users.replace(user); + return user; + }, + delete: function () { + try { + deleteUser(this.get('_key')); + return true; + } catch (e) { + if (e instanceof errors.UserNotFound) { + return false; + } + throw e; + } + } + }); + + exports.resolve = resolve; + exports.list = listUsers; + exports.create = createUser; + exports.get = getUser; + exports.delete = deleteUser; + exports.errors = errors; + exports.repository = users; +}()); \ No newline at end of file