antville/code/User/User.js

755 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// The Antville Project
// http://code.google.com/p/antville
//
// Copyright 20012014 by the Workers of Antville.
//
// Licensed under the Apache License, Version 2.0 (the ``License'');
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an ``AS IS'' BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* @fileOverview Defines the User prototype.
*/
markgettext('Account');
markgettext('account');
markgettext('a account // accusative');
this.handleMetadata('accepted');
this.handleMetadata('deleted');
this.handleMetadata('export');
this.handleMetadata('hash');
this.handleMetadata('job');
this.handleMetadata('notes');
this.handleMetadata('salt');
this.handleMetadata('url');
disableMacro(User, 'hash');
disableMacro(User, 'salt');
/** @constant */
User.COOKIE = getProperty('userCookie', 'antvilleUser');
/** @constant */
User.HASHCOOKIE = getProperty('hashCookie', 'antvilleHash');
/**
* @param {Object} data
* @returns {User}
*/
User.add = function(data) {
HopObject.confirmConstructor(this);
var user = new User;
var now = new Date;
user.map({
created: now,
modified: now,
email: data.email,
hash: data.hash,
name: data.name,
salt: session.data.token,
status: User.REGULAR,
url: data.url
});
root.users.add(user);
return user;
}
User.remove = function() {
if (this.constructor !== User) return;
const deleteMetadata = (type, table, id) => {
app.log('Removing ' + table + ' metadata for User #' + id);
sql.execute("delete from metadata where parent_type = '$0' and parent_id in (select id from $1 where creator_id = $2)", type, table, id);
};
const getNewCreator = site => {
if (!site) return null;
const owners = site.members.owners;
if (owners.size() < 1) return null;
return owners.list().filter(owner => owner.name !== this.name)[0].creator;
};
const sql = new Sql();
const id = this._id;
app.log('Removing data from account #' + id);
sql.execute("delete from metadata where parent_type = 'User' and parent_id = $0", id);
// Remove sites
this.ownerships.forEach(function() {
const owners = this.site.members.owners;
if (owners.size() < 2) {
// Delegate deletion of Sites which user is the only owner of
Site.remove.call(this.site);
}
});
// Re-assign site creators and modifiers
sql.retrieve('select id from site where creator_id = $0', id);
sql.traverse(function() {
const site = Site.getById(this.id);
const creator = getNewCreator(site);
sql.execute('update site set creator_id = $0 where creator_id = $1', creator._id, id);
});
sql.execute('update site set modifier_id = creator_id where modifier_id = $0', id);
// Remove and re-assign memberships
sql.execute("delete from membership where name = '$0'", this.name);
sql.execute('update membership set modifier_id = creator_id where modifier_id = $0', id);
// Remove tags
sql.execute("delete from tag_hub where tagged_type = 'Story' and tagged_id in (select id from content where creator_id = $0)", id);
sql.execute("delete from tag_hub where tagged_type = 'Image' and tagged_id in (select id from image where creator_id = $0)", id);
sql.execute('delete from tag where id not in (select tag_id from tag_hub)');
// Remove and re-assign polls, choices and votes
let subQuery = 'select id from poll where creator_id';
sql.execute('delete from vote where choice_id in (select id from choice where poll_id in ($0 = $1))', subQuery, id);
sql.execute('delete from choice where poll_id in ($0 = $1)', subQuery, id);
sql.execute('delete from poll where creator_id = $0', id);
sql.execute('update poll set modifier_id = creator_id where modifier_id = $0', id);
// Remove and re-assign stories and comments
deleteMetadata('Story', 'content', id);
sql.execute('delete from content where creator_id = $0', id);
sql.execute('update content set modifier_id = creator_id where modifier_id = $0', id);
// Remove and re-assign files
sql.retrieve('select id from file where creator_id = $0', id);
sql.traverse(function() {
app.log('Removing file #' + this.id + ' from file system');
const file = File.getById(this.id);
file.getFile().remove();
});
deleteMetadata('File', 'file', id);
sql.execute('delete from file where creator_id = $0', id);
sql.execute('update file set modifier_id = creator_id where modifier_id = $0', id);
// Remove and re-assign images
sql.retrieve('select id from image where creator_id = $0', id);
sql.traverse(function() {
const image = Image.getById(this.id);
if (image.parent_type === 'Layout') {
if (!image.parent) return;
// Instead of deleting, assign layout images to another site owner
app.log('Assigning new creator: layout image #' + this.id);
const creator = getNewCreator(image.parent.site);
sql.execute('update image set creator_id = $0 where creator_id = $1', creator._id, id);
} else {
app.log('Removing image #' + this.id + ' from file system')
image.getFile().remove();
}
});
deleteMetadata('Image', 'image', id);
sql.execute('delete from image where creator_id = $0', id);
sql.execute('update image set modifier_id = creator_id where modifier_id = $0', id);
// Remove and re-assign skins
sql.retrieve('select id from skin where creator_id = $0', id);
sql.traverse(function() {
// Instead of deleting, assign skins to another site owner
app.log('Assigning new creator: skin #' + this.id);
const skin = Skin.getById(this.id);
const creator = getNewCreator(skin.layout.site);
sql.execute('update skin set creator_id = $0 where creator_id = $1', creator._id, id);
});
sql.execute('update skin set modifier_id = creator_id where modifier_id = $0', id);
// Remove and re-assign layouts
sql.retrieve('select id from layout where creator_id = $0', id);
sql.traverse(function() {
// Instead of deleting, assign layouts to another site owner
app.log('Assigning new creator: layout #' + this.id);
const layout = Layout.getById(this.id);
const creator = getNewCreator(layout.site);
sql.execute('update layout set creator_id = $0 where creator_id = $1', creator._id, id);
});
sql.execute('update layout set modifier_id = creator_id where modifier_id = $0', id);
this.deleted = new Date();
this.email = String.EMPTY;
return User.require(User.PRIVILEGED) ? this.href('edit') : root.href();
};
/**
*
* @param {String} name
* @returns {User}
*/
User.getByName = function(name) {
return root.users.get(name);
}
/**
* @function
* @returns {String[]}
* @see defineConstants
*/
User.getStatus = defineConstants(User, markgettext('Deleted'), markgettext('Blocked'), markgettext('Regular'), markgettext('Trusted'), markgettext('Privileged'));
/**
* @returns {String}
*/
User.getSalt = function() {
var salt = java.lang.reflect.Array.newInstance(java.lang.Byte.TYPE, 8);
var random = java.security.SecureRandom.getInstance('SHA1PRNG');
random.nextBytes(salt);
return new java.lang.String(java.util.Base64.getMimeEncoder().encode(salt), 'utf-8');
}
/**
*
* @param {Object} data
* @throws {Error}
* @returns {User}
*/
User.register = function(data) {
if (!data.name) {
throw Error(gettext('Please enter a username.'));
}
data.name = data.name.trim();
if (data.name.length > 30) {
throw Error(gettext('Sorry, the username you entered is too long. Please choose a shorter one.'));
} else if (data.name !== stripTags(data.name) || NAMEPATTERN.test(data.name)) {
throw Error(gettext('Please avoid special characters or HTML code in the name field.'));
} else if (data.name !== root.users.getAccessName(data.name)) {
throw Error(gettext('Sorry, the username you entered already exists. Please enter a different one.'));
}
data.email && (data.email = data.email.trim());
if (!validateEmail(data.email)) {
throw Error(gettext('Please enter a valid e-mail address'));
}
if (User.isBlacklisted(data)) {
throw Error('Sequere pecuniam ad meliora.');
}
// Create hash from password for JavaScript-disabled browsers
if (!data.hash) {
if (!data.password) {
throw Error(gettext('Could not verify your password. Please repeat your input.'))
}
data.hash = (data.password + session.data.token).md5();
}
var user = User.add(data);
// grant trust and sysadmin-rights if there's no sysadmin 'til now
if (root.admins.size() < 1) {
user.status = User.PRIVILEGED;
}
session.login(user);
return user;
}
/**
*
* @param {Object} data
* @returns {Boolean}
*/
User.isBlacklisted = function(data) {
if (getProperty('enableBlacklistChecks') !== 'true') return false;
var url, mime;
var key = getProperty('botscout.apikey');
var email = encodeURIComponent(data.email);
var ip = encodeURIComponent(data.http_remotehost);
if (key) {
url = ['http://botscout.com/test/?multi', '&key=', key, '&mail=', email, '&ip=', ip];
try {
mime = getURL(url.join(String.EMPTY));
if (mime && mime.text && mime.text.startsWith('Y')) {
return true;
}
} catch (ex) {
app.log('Exception while trying to check blacklist URL ' + url);
app.log(ex);
}
}
// We only get here if botscout.com does not already blacklist the ip or email address
url = ['http://www.stopforumspam.com/api?f=json', '&email=', email];
if (ip.match(/^(?:\d{1,3}\.){3}\d{1,3}$/)) url.push('&ip=', ip);
try {
mime = getURL(url.join(String.EMPTY));
} catch (ex) {
app.log('Exception while trying to check blacklist URL ' + url);
app.log(ex);
}
if (mime && mime.content) {
var result = JSON.parse(new java.lang.String(mime.content));
if (result.success) {
return !!(result.email.appears || (result.ip && result.ip.appears));
}
}
return false;
};
/**
*
*/
User.autoLogin = function() {
if (session.user) {
return;
}
var name = req.cookies[User.COOKIE];
var hash = req.cookies[User.HASHCOOKIE];
if (!name || !hash) {
return;
}
var user = User.getByName(name);
if (!user) {
return;
}
var ip = req.data.http_remotehost.clip(getProperty('cookieLevel', '4'),
String.EMPTY, '\\.');
if ((user.hash + ip).md5() !== hash) {
return;
}
session.login(user);
user.touch();
res.message = gettext('Welcome to {0}, {1}. Have fun!',
res.handlers.site.title, user.name);
return;
}
/**
*
* @param {Object} data
* @returns {User}
*/
User.login = function(data) {
var user = User.getByName(data.name);
if (!user) {
throw Error(gettext('Unfortunately, your login failed. Maybe a typo?'));
}
var digest = data.digest;
// Calculate digest for JavaScript-disabled browsers
if (!digest) {
app.logger.warn('Received clear text password from ' + req.data.http_referer);
digest = ((data.password + user.salt).md5() + session.data.token).md5();
}
// Check if login is correct
if (digest !== user.getDigest(session.data.token)) {
throw Error(gettext('Unfortunately, your login failed. Maybe a typo?'))
}
if (data.remember) {
// Set long running cookies for automatic login
res.setCookie(User.COOKIE, user.name, 365);
var ip = req.data.http_remotehost.clip(getProperty('cookieLevel', '4'), String.EMPTY, '\\.');
res.setCookie(User.HASHCOOKIE, (user.hash + ip).md5(), 365);
}
user.touch();
session.login(user);
return user;
}
/**
*
*/
User.logout = function() {
session.logout();
res.unsetCookie(User.COOKIE);
res.unsetCookie(User.HASHCOOKIE);
Layout.sandbox(false);
User.getLocation();
return;
}
/**
*
* @param {String} requiredStatus
* @returns {Boolean}
*/
User.require = function(requiredStatus, user) {
if (!user) user = session.user;
var status = [User.BLOCKED, User.REGULAR, User.DELETED, User.TRUSTED, User.PRIVILEGED];
if (requiredStatus && user) {
return status.indexOf(user.status) >= status.indexOf(requiredStatus);
}
return false;
}
/**
* @returns {String}
*/
User.getCurrentStatus = function() {
if (session.user) {
return session.user.status;
}
return null;
}
/**
* @returns {Membership}
*/
User.getMembership = function() {
var membership;
if (session.user) {
membership = Membership.getByName(session.user.name);
}
HopObject.confirmConstructor(Membership);
return membership || new Membership();
};
User.prototype.getMembership = function () {
var membership = Membership.getByName(this.name);
if (!membership) {
HopObject.confirmConstructor(Membership);
membership = new Membership();
var site = res.handlers.site;
// FIXME: Should we do this also for stories, images etc.?
membership.comments.subnodeRelation = "where prototype = 'Comment' and status <> 'deleted' and site_id = " + site._id + " and creator_id = " + this._id + " order by created desc";
}
return membership;
};
/**
*
* @param {String} url
*/
User.setLocation = function(url) {
session.data.location = url || req.data.http_referer;
//app.debug('Pushed location ' + session.data.location);
return;
}
/**
* @returns {String}
*/
User.getLocation = function() {
var url = session.data.location;
delete session.data.location;
//app.debug('Popped location ' + url);
return url;
}
/**
* Rename a user account.
* @param {String} currentName The current name of the user account.
* @param {String} newName The desired name of the user account.
*/
User.rename = function(currentName, newName) {
var user = User.getByName(currentName);
if (user) {
if (user.name === newName) {
return newName;
}
user.name = root.users.getAccessName(newName);
return user.name;
}
return currentName;
}
/**
* A User object represents a login to Antville.
* @name User
* @constructor
* @extends HopObject
* @property {Membership[]} _children
* @property {Date} created
* @property {Comment[]} comments
* @property {String} email
* @property {File[]} files
* @property {String} hash
* @property {Image[]} images
* @property {Membership[]} memberships
* @property {Metadata} metadata
* @property {Date} modified
* @property {String} name
* @property {String} salt
* @property {Site[]} sites
* @property {Membership[]} subscriptions
* @property {String} status
* @property {Story[]} stories
* @property {String} url
* @extends HopObject
*/
User.prototype.constructor = function(data) {
HopObject.confirmConstructor(User);
return this;
}
/**
*
*/
User.prototype.onLogout = function() { /* ... */ }
/**
*
* @param {String} action
* @returns {Boolean}
*/
User.prototype.getPermission = function(action) {
switch (action) {
case 'delete':
return this.status !== User.PRIVILEGED && this.status !== User.DELETED;
default:
return User.require(User.PRIVILEGED);
}
}
User.prototype.edit_action = function () {
if (!res.handlers.context) res.handlers.context = this;
if (req.postParams.save) {
try {
this.update(req.postParams);
res.message = gettext('The changes were saved successfully.');
res.redirect(res.handlers.context.href(req.action));
} catch (err) {
res.message = err.toString();
}
}
session.data.token = User.getSalt();
session.data.salt = this.salt;
res.data.title = this.name;
res.data.body = this.renderSkinAsString('$User#edit');
res.handlers.site.renderSkin('Site#page');
};
User.prototype.block_action = function () {
this.status = User.BLOCKED;
res.redirect(req.data.http_referer);
};
User.prototype.export_action = function() {
if (!res.handlers.context) res.handlers.context = this;
const data = req.postParams;
const param = {};
const href = res.handlers.context.href(req.action);
let job = new Admin.Job(this.job || {});
if (data.submit === 'export') {
try {
if (job.method && job.method !== 'export') {
throw Error(gettext('There is already another job queued for this account: {0}', job.method));
}
this.job = Admin.queue(this, 'export');
res.message = gettext('The account is queued for export.');
} catch (ex) {
res.message = ex.toString();
app.log(res.message);
}
res.redirect(href);
} else if (data.submit === 'cancel') {
this.job = job.remove();
res.redirect(href);
}
if (job.method === 'export') {
param.status = gettext('The account data will be available for download from here within the next days.');
}
res.data.title = 'Export Account ' + this.name;
res.data.body = this.renderSkinAsString('$User#export', param);
res.handlers.site.renderSkin('Site#page');
};
User.prototype.timeline_action = function() {
if (!res.handlers.context) res.handlers.context = this;
const collection = [];
const sql = new Sql();
const page = req.queryParams.page;
const pageSize = 25;
const offset = (req.data.page || 0) * pageSize;
let count = 0;
sql.retrieve("select (select count(*) from content where creator_id = $0) + (select count(*) from image where creator_id = $0) + (select count(*) from file where creator_id = $0) + (select count(*) from poll where creator_id = $0) as count", this._id);
sql.traverse(function() {
count = this.count;
});
// MySQL needs the limit parameter -> https://stackoverflow.com/questions/255517/mysql-offset-infinite-rows
sql.retrieve("select created, id, 'Story' as prototype from content where creator_id = $0 union select created, id, 'Image' as prototype from image where creator_id = $0 union select created, id, 'File' as prototype from file where creator_id = $0 union select created, id, 'Poll' as prototype from poll where creator_id = $0 order by created desc limit $1 offset $2", this._id, pageSize, offset);
console.log(sql);
sql.traverse(function() {
const object = HopObject.getById(this.id, this.prototype);
collection.push(object);
});
res.data.list = renderList(collection, this.renderTimelineItem, null, page);
res.data.pager = renderPager(count, this.href(req.action), pageSize, page);
res.data.title = gettext('Timeline');
res.data.body = this.renderSkinAsString('$User#timeline');
root.renderSkin('Site#page');
};
User.prototype.delete_action = function() {
if (!res.handlers.context) res.handlers.context = this;
res.data.action = res.handlers.context.href(req.action);
if (req.postParams.proceed) {
this.hash = String.EMPTY;
this.status = User.DELETED;
this.deleted = null;
if (this.countContributions() < 1) {
// If an account contains no content, delete it immediately
HopObject.prototype.delete_action.call(this);
} else {
// Otherwise, queue for deletion
res.message = gettext('The account {0} is being deleted.', this.name);
}
this.log(root, 'Deleted account ' + this.name);
res.redirect(User.require(User.PRIVILEGED) ? res.handlers.context.href('edit') : root.href());
} else {
HopObject.prototype.delete_action.call(this);
}
};
User.prototype.getConfirmText = function () {
return gettext('You are about to delete the account {0}.', this.getTitle());
};
User.prototype.getConfirmExtra = function () {
return this.renderSkinAsString('$User#delete');
};
User.prototype.renderTimelineItem = function(item) {
Admin.prototype.renderActivity(item, '$Admin#timelineItem');
};
User.prototype.countContributions = function() {
return [this.stories, this.images, this.files, this.polls, this.comments, this.votes].reduce((total, collection) => {
return total + collection.size();
}, 0);
};
/**
*
* @param {Object} data
*/
User.prototype.update = function(data) {
if (!data.hash && data.password) {
data.hash = (data.password + session.data.token).md5();
}
if (data.hash) {
this.hash = data.hash;
this.salt = session.data.token;
}
if (!(data.email = validateEmail(data.email))) {
throw Error(gettext('Please enter a valid e-mail address'));
}
if (data.url && !(data.url = validateUrl(data.url))) {
throw Error(gettext('Please enter a valid URL'));
}
if (this.getPermission('edit')) {
if (this.status === User.PRIVILEGED && data.status !== User.PRIVILEGED && root.admins.count() < 2) {
throw Error(gettext('You cannot revoke permissions from the only privileged account.'));
}
if (this.status === User.PRIVILEGED && data.status === User.DELETED) {
throw Error(gettext('You cannot delete a privileged account.'));
}
// Remove the corresponding job if site deletion is cancelled
if (this.job && this.status === User.DELETED && this.status !== data.status) {
let job = new Admin.Job(this.job);
if (job.method === 'remove') job.remove();
this.deleted = null;
this.created = new Date();
}
this.status = data.status;
this.notes = data.notes;
}
this.email = data.email;
this.url = data.url;
if (this === session.user) this.touch();
return this;
}
/**
*
*/
User.prototype.touch = function() {
this.modified = new Date;
if (session.user) this.modifier = session.
return;
}
/**
*
* @param {String} token
* @returns {String}
*/
User.prototype.getDigest = function(token) {
token || (token = String.EMPTY);
return (this.hash + token).md5();
}
/**
*
* @param {String} name
* @returns {Object}
*/
User.prototype.getFormOptions = function(name) {
switch (name) {
case 'status':
return User.getStatus();
}
}
/**
* Enable <% user.email %> macro for privileged users only
*/
User.prototype.email_macro = function() {
if (User.require(User.PRIVILEGED)) {
res.write(this.email);
}
return;
}
/**
*
* @param {Object} param
* @param {String} type
*/
User.prototype.list_macro = function(param, type) {
switch (type) {
case 'sites':
var memberships = session.user.list();
memberships.sort(function(a, b) {
return b.site.modified - a.site.modified;
});
memberships.forEach(function(membership) {
var site;
if (site = membership.get('site')) {
site.renderSkin('$Site#listItem');
}
return;
});
}
return;
};
User.prototype.gravatar_macro = function () {
res.write('https://secure.gravatar.com/avatar/');
this.email && res.write(this.email.trim().toLowerCase().md5());
}