diff src/nabble/model/UserImpl.java @ 0:7ecd1a4ef557

add content
author Franklin Schmidt <fschmidt@gmail.com>
date Thu, 21 Mar 2019 19:15:52 -0600
parents
children cc5b7d515580
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/nabble/model/UserImpl.java	Thu Mar 21 19:15:52 2019 -0600
@@ -0,0 +1,1355 @@
+/*
+
+Copyright (C) 2003  Franklin Schmidt <frank@gustos.com>
+
+*/
+
+package nabble.model;
+
+import fschmidt.db.DbDatabase;
+import fschmidt.db.DbNull;
+import fschmidt.db.DbObjectFactory;
+import fschmidt.db.DbRecord;
+import fschmidt.db.DbTable;
+import fschmidt.db.DbUtils;
+import fschmidt.db.Listener;
+import fschmidt.db.ListenerList;
+import fschmidt.db.LongKey;
+import fschmidt.db.postgres.DbDatabaseImpl;
+import fschmidt.util.java.Computable;
+import fschmidt.util.java.Memoizer;
+import fschmidt.util.java.ObjectUtils;
+import fschmidt.util.java.SimpleCache;
+import fschmidt.util.java.TimedCacheMap;
+import fschmidt.util.mail.MailAddress;
+import org.jasypt.digest.PooledStringDigester;
+import org.jasypt.salt.FixedByteArraySaltGenerator;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.awt.image.BufferedImage;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.WeakHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+
+final class UserImpl extends PersonImpl implements User {
+	private static final Logger logger = LoggerFactory.getLogger(UserImpl.class);
+
+	final SiteKey siteKey;
+	private final DbRecord<LongKey,UserImpl> record;
+	private String email;
+	private String passwordDigest;
+	private String name;
+	private Date registered;
+	private boolean noArchive;
+	private Message signature = null;
+	private int bounces;
+
+	private UserImpl(SiteKey siteKey,LongKey key,ResultSet rs)
+		throws SQLException
+	{
+		this.siteKey = siteKey;
+		record = table(siteKey).newRecord(this,key);
+		email = rs.getString("email");
+		passwordDigest = rs.getString("password_digest");
+		name = rs.getString("name");
+		registered = DbUtils.getDate(rs,"registered");
+		noArchive = rs.getBoolean("no_archive");
+		String signatureRaw = rs.getString("signature");
+		String signatureFormatS = rs.getString("signature_format");
+		if( signatureRaw!=null && signatureFormatS!=null ) {
+			Message.Format signatureFormat = Message.Format.getMessageFormat( signatureFormatS.charAt(0) );
+			signature = new Message(signatureRaw,signatureFormat);
+		}
+		bounces = rs.getInt("bounces");
+		for( ExtensionFactory<User,?> factory : extensionFactories ) {
+			Object obj = factory.construct(this,rs);
+			if( obj != null )
+				getExtensionMap().put(factory,obj);
+		}
+	}
+
+	private UserImpl(SiteImpl site) {
+		this.siteKey = site.siteKey;
+		record = table(siteKey).newRecord(this);
+	}
+
+
+	public DbRecord<LongKey,UserImpl> getDbRecord() {
+		return record;
+	}
+
+	private DbTable<LongKey,UserImpl> table() {
+		return record.getDbTable();
+	}
+
+	private DbDatabase db() {
+		return table().getDbDatabase();
+	}
+
+	public long getId() {
+		return record.getPrimaryKey().value();
+	}
+
+	SiteImpl getSiteImpl() {
+		return siteKey.site();
+	}
+
+	public Site getSite() {
+		return getSiteImpl();
+	}
+
+	public boolean isDeactivated() {
+		return !isRegistered() && isNoArchive();
+	}
+
+	boolean isNoArchive() {
+		return noArchive;
+	}
+
+	public void setNoArchive(boolean noArchive) {
+		if( this.noArchive == noArchive )
+			return;
+
+		if( !db().isInTransaction() ) {
+			db().beginTransaction();
+			try {
+				UserImpl user = DbUtils.getGoodCopy(this);
+				user.setNoArchive(noArchive);
+				user.getDbRecord().update();
+				db().commitTransaction();
+				return;
+			} finally {
+				db().endTransaction();
+			}
+		}
+		this.noArchive = noArchive;
+		record.fields().put("no_archive",DbNull.fix(noArchive));
+	}
+
+	public String getEmail() {
+		return email;
+	}
+
+	static void validateEmail(String email) throws ModelException.EmailFormat {
+		if (!new MailAddress(email).isValid()) {
+			throw new ModelException.EmailFormat(email);
+		}
+	}
+
+	public void setEmail(String email) throws ModelException {
+		if( !db().isInTransaction() ) {
+			db().beginTransaction();
+			try {
+				UserImpl user = DbUtils.getGoodCopy(this);
+				user.setEmail(email);
+				user.getDbRecord().update();
+				db().commitTransaction();
+				return;
+			} finally {
+				db().endTransaction();
+			}
+		}
+		validateEmail(email);
+		setEmail2(email);
+	}
+
+	private void setEmail2(String email) throws ModelException {
+		if( email.equals(this.email) )
+			return;
+		SiteImpl site = getSiteImpl();
+		if( site.getUserImplFromEmail(email) != null )
+			throw ModelException.newInstance("email_already_in_user","Email already in use");
+		this.email = email;
+		record.fields().put("email",email);
+	}
+
+	public String getPasswordDigest() {
+		return passwordDigest;
+	}
+
+	public void setPassword(String password) throws ModelException {
+		if( "".equals(password) )
+			throw ModelException.newInstance("empty_password","Password cannot be empty");
+		setPasswordDigest(digestPassword(password));
+	}
+
+	public void setPasswordDigest(String passwordDigest) {
+		if( ObjectUtils.equals(passwordDigest,this.passwordDigest) )
+			return;
+		this.passwordDigest = passwordDigest;
+		record.fields().put("password_digest",DbNull.fix(passwordDigest));
+		synchronized (passcookieLock) {
+			this.passcookie = null;
+		}
+	}
+
+	private volatile String passcookie = null;
+	private Object passcookieLock = new Object();
+
+	public String getPasscookie() {
+		String p = passcookie;
+		if (p==null) {
+			synchronized (passcookieLock) {
+				p = passcookie;
+				if (p==null) {
+					p = calcPasscookie();
+					passcookie = p;
+				}
+			}
+		}
+		return p;
+	}
+
+	public String getName() {
+		return name;
+	}
+
+	public void setName(String name) throws ModelException {
+		setName(name,true);
+	}
+
+	private void setName(String name,boolean replaceUnregistered) throws ModelException {
+		name = name.trim();
+		if( name.equals("") )
+			throw ModelException.newInstance("empty_user_name","User name cannot be empty.");
+		if( name.equals(this.name) )
+			return;
+		if( !name.equalsIgnoreCase(this.name) ) {
+			UserImpl user = getSiteImpl().getUserImplFromName(name);
+			if( user != null ) {
+				if( !replaceUnregistered || user.isRegistered() )
+					throw ModelException.newInstance("user_name_already_in_use","User name '"+name+"' already in use");
+				user.setNameLike2(name);
+				user.update();
+			}
+			try {
+				Connection con = db().getConnection();
+				PreparedStatement stmt = con.prepareStatement(
+					"select 'x' from registration where email!=? and name=?"
+				);
+				stmt.setString(1,this.email);
+				stmt.setString(2,name);
+				try {
+					if( stmt.executeQuery().next() )
+						throw ModelException.newInstance("user_name_already_in_use","User name '"+name+"' already in use");
+				} finally {
+					stmt.close();
+					con.close();
+				}
+			} catch(SQLException e) {
+				throw new RuntimeException(e);
+			}
+		}
+		this.name = name;
+		record.fields().put("name",name);
+	}
+
+	void setNameLike(String name,boolean replaceUnregistered) {
+		try {
+			setName(name,replaceUnregistered);
+		} catch(ModelException e) {
+			setNameLike2(name);
+		}
+	}
+
+	private void setNameLike2(String name) {
+		for( int i=2; true; i++ ) {
+			try {
+				setName(name+"-"+i,false);
+				break;
+			} catch(ModelException e2) {}
+		}
+	}
+
+	/* To be called from the shell */
+	public void changeNameTo(String newName) {
+		db().beginTransaction();
+		try {
+			UserImpl u = (UserImpl) getGoodCopy();
+			u.setName(newName);
+			u.update();
+			db().commitTransaction();
+			DbUtils.uncache(u);
+		} catch (ModelException e) {
+			throw new RuntimeException(e);
+		} finally {
+			db().endTransaction();
+		}
+	}
+
+	public Date getRegistered() {
+		return registered;
+	}
+
+	void setRegistered(Date registered) {
+		if( ObjectUtils.equals(registered,this.registered) )
+			return;
+		this.registered = registered;
+		record.fields().put("registered",DbNull.fix(registered));
+	}
+
+	public boolean equals(Object obj) {
+		return obj instanceof User && ((User)obj).getId()==getId();
+	}
+
+	public int hashCode() {
+		return (int)getId();
+	}
+
+	public String toString() {
+		return record.isInDb() ? "user-"+getId() : "user-new";
+	}
+
+	public void register() throws ModelException {
+		register(new Date());
+	}
+
+	public void register(Date registerDate) throws ModelException {
+		if( !db().isInTransaction() ) {
+			db().beginTransaction();
+			try {
+				UserImpl user;
+				if( record.isInDb() ) {
+					user = DbUtils.getGoodCopy(this);
+					user.setEmail(email);
+					user.setName(name);
+					user.setPasswordDigest(passwordDigest);
+				} else {
+					user = this;
+				}
+				user.register();
+				db().commitTransaction();
+			} finally {
+				db().endTransaction();
+			}
+			return;
+		}
+		if( passwordDigest==null )
+			throw new RuntimeException();
+		setRegistered( registerDate );
+		if( record.isInDb() ) {
+			if( isNoArchive() )
+				setNoArchive(false);
+			record.update();
+		} else {
+			insert();
+		}
+	}
+
+	public boolean isRegistered() {
+		return record.isInDb() && registered!=null;
+	}
+
+	void insert() {
+		if( email==null || name==null )
+			throw new RuntimeException();
+		record.insert();
+	}
+
+	public void update() {
+		if( !db().isInTransaction() )
+			throw new RuntimeException("this should be done in a transaction");
+		Set<String> keys = record.fields().keySet();
+		if( keys.contains("name") || keys.contains("signature") ) {
+			getSiteImpl().update();  // fire change listeners
+		}
+		getDbRecord().update();
+	}
+
+	public User getGoodCopy() {
+		return DbUtils.getGoodCopy(this);
+	}
+
+
+
+
+	public int getExternalHash(String url) {
+		return (url.toLowerCase() + getId()).hashCode();
+	}
+
+
+	static final ListenerList<UserImpl> preUpdateListeners = new ListenerList<UserImpl>();
+	static final ListenerList<UserImpl> postInsertListeners = new ListenerList<UserImpl>();
+
+	private static Computable<SiteKey,DbTable<LongKey,UserImpl>> tables = new SimpleCache<SiteKey,DbTable<LongKey,UserImpl>>(new WeakHashMap<SiteKey,DbTable<LongKey,UserImpl>>(), new Computable<SiteKey,DbTable<LongKey,UserImpl>>() {
+		public DbTable<LongKey,UserImpl> get(SiteKey siteKey) {
+			DbDatabase db = siteKey.getDb();
+			final long siteId = siteKey.getId();
+			DbTable<LongKey,UserImpl> table = db.newTable("user_",db.newIdentityLongKeySetter("user_id")
+				, new DbObjectFactory<LongKey,UserImpl>() {
+					public UserImpl makeDbObject(LongKey key,ResultSet rs,String tableName)
+						throws SQLException
+					{
+						SiteKey siteKey = SiteKey.getInstance(siteId);
+						return new UserImpl(siteKey,key,rs);
+					}
+				}
+			);
+			table.getPreUpdateListeners().add(preUpdateListeners);
+			table.getPostInsertListeners().add(postInsertListeners);
+			return table;
+		}
+	});
+
+	private static DbTable<LongKey,UserImpl> table(SiteKey siteKey) {
+		return tables.get(siteKey);
+	}
+
+	static UserImpl getUser(SiteKey siteKey,long id) {
+		UserImpl user = table(siteKey).findByPrimaryKey(new LongKey(id));
+		if( user==null )
+			logger.warn("user "+id+" not found");
+		return user;
+	}
+
+	static Collection<UserImpl> getUsers(SiteKey siteKey,Collection<Long> ids) {
+		List<LongKey> list = new ArrayList<LongKey>();
+		for( long id : ids ) {
+			list.add( new LongKey(id) );
+		}
+		return table(siteKey).findByPrimaryKey(list).values();
+	}
+
+	static UserImpl getUser(SiteKey siteKey,ResultSet rs)
+		throws SQLException
+	{
+		return table(siteKey).getDbObject(rs);
+	}
+
+	static void getUsers(SiteKey siteKey,PreparedStatement stmt,List<? super UserImpl> list)
+		throws SQLException
+	{
+		ResultSet rs = stmt.executeQuery();
+		while( rs.next() ) {
+			UserImpl user = getUser(siteKey,rs);
+			list.add(user);
+		}
+		rs.close();
+		stmt.close();
+	}
+
+	static List<UserImpl> getUsers(SiteKey siteKey,PreparedStatement stmt)
+		throws SQLException
+	{
+		List<UserImpl> list = new ArrayList<UserImpl>();
+		getUsers(siteKey,stmt,list);
+		return list;
+	}
+
+	private static UserImpl getUser(SiteImpl site,String val,String sql) {
+		try {
+			SiteKey siteKey = site.siteKey;
+			Connection con = siteKey.getDb().getConnection();
+			PreparedStatement stmt = con.prepareStatement(sql);
+			stmt.setString(1,val);
+			ResultSet rs = stmt.executeQuery();
+			UserImpl user = rs.next() ? getUser(siteKey,rs) : null;
+			rs.close();
+			stmt.close();
+			con.close();
+			return user;
+		} catch(SQLException e) {
+			throw new RuntimeException(e);
+		}
+	}
+
+	static UserImpl getUserFromEmail(SiteImpl site,String email) {
+		return getUser( site, email.toLowerCase(),
+			"select * from user_"
+			+" where lower(email)=?"
+		);
+	}
+
+	static UserImpl getUserFromName(SiteImpl site,String name) {
+		return getUser( site, name.toLowerCase(),
+			"select * from user_"
+			+" where lower(name)=?"
+		);
+	}
+
+	static UserImpl createGhost(SiteImpl site,String email) {
+		UserImpl user = new UserImpl(site);
+		try {
+			user.setEmail2(email);
+		} catch(ModelException e) {
+			throw new RuntimeException(e);
+		}
+		return user;
+	}
+
+	// Subscriptions -----------------------------------------------------------
+
+	public boolean isSubscribed(Node node) {
+		return SubscriptionImpl.isSubscribed(this, (NodeImpl) node);
+	}
+
+	public Subscription getSubscription(Node node) {
+		return SubscriptionImpl.getSubscription( this, (NodeImpl)node );
+	}
+
+	public Subscription subscribe(Node node,Subscription.To to,Subscription.Type type) {
+		clearBounces();
+		Subscription subscription = getSubscription(node);
+		if( subscription != null ) {
+			subscription.setTo(to);
+			subscription.setType(type);
+			return subscription;
+		} else {
+			return SubscriptionImpl.insert( this, (NodeImpl)node, to, type );
+		}
+	}
+
+	/*10 posts in 5 minutes */
+	private static final RecentPostLimit postLimit1 = new RecentPostLimit(5 * 60 * 1000L, 10);
+
+	/* 30 posts in 15 minutes */
+	private static final RecentPostLimit postLimit2 = new RecentPostLimit(15 * 60 * 1000L, 30);
+
+	void updateNewPostLimit() {
+		String key = siteKey.getId() + "-" + record.getPrimaryKey().value();
+		postLimit1.insert(key);
+		postLimit2.insert(key);
+	}
+
+	public boolean hasTooManyPosts() {
+		String key = siteKey.getId() + "-" + record.getPrimaryKey().value();
+		return postLimit1.hasTooManyPosts(key) || postLimit2.hasTooManyPosts(key);
+	}
+
+	private static class RecentPostLimit {
+		private final long timeLimit;
+		private final int postLimit;
+		private final Map<String,long[]> floodMap;
+
+		private RecentPostLimit(long timeLimit, int postLimit) {
+			this.timeLimit = timeLimit;
+			this.postLimit = postLimit;
+			this.floodMap = new TimedCacheMap<String,long[]>(timeLimit);
+		}
+
+		public void insert(String key) {
+			long[] recentPostTimes;
+			synchronized(floodMap) {
+				recentPostTimes = floodMap.get(key);
+				if( recentPostTimes==null ) {
+					recentPostTimes = new long[postLimit];
+					floodMap.put(key,recentPostTimes);
+				}
+			}
+			long now = System.currentTimeMillis();
+			long recently = now - timeLimit;
+			synchronized(recentPostTimes) {
+				for( int i=0; i<recentPostTimes.length; i++ ) {
+					if( recentPostTimes[i] < recently ) {
+						recentPostTimes[i] = now;
+						return;
+					}
+				}
+			}
+		}
+
+		public boolean hasTooManyPosts(String key) {
+			long[] recentPostTimes;
+			synchronized(floodMap) {
+				recentPostTimes = floodMap.get(key);
+				if (recentPostTimes==null)
+					return false;
+			}
+			long now = System.currentTimeMillis();
+			long recently = now - timeLimit;
+			synchronized(recentPostTimes) {
+				for (long time : recentPostTimes) {
+					if (time < recently) {
+						return false;
+					}
+				}
+			}
+			return true;
+		}
+	}
+
+
+	static UserImpl getOrCreateUnregisteredUser(SiteImpl site,String email,String name)
+		throws ModelException
+	{
+		DbDatabase db = site.getDb();
+		if( !db.isInTransaction() ) {
+			db.beginTransaction();
+			try {
+				UserImpl user = getOrCreateUnregisteredUser(site,email,name);
+				db.commitTransaction();
+				return user;
+			} finally {
+				db.endTransaction();
+			}
+		}
+		UserImpl user = site.getUserImplFromEmail(email);
+		if( user==null ) {
+			user = new UserImpl(site);
+			user.setEmail(email);
+		} else {
+			if( user.isRegistered() )
+				throw ModelException.newInstance("email_already_registered","This email is already registered");
+			validateEmail(user.getEmail());
+		}
+		user.setName(name);
+		if( !user.record.isInDb() ) {
+			user.insert();
+		} else if( !user.record.fields().isEmpty() ) {
+			user.update();
+		}
+		return user;
+	}
+
+	// registration
+
+	static UserImpl createUser(SiteImpl site,String email,String password,String name) throws ModelException {
+		return createUser2(site, email, digestPassword(password), name);
+	}
+
+	private static UserImpl createUser2(SiteImpl site,String email,String passwordDigest,String name) throws ModelException {
+		// transaction used because setName() may update user
+		DbDatabase db = site.getDb();
+		if( !db.isInTransaction() ) {
+			db.beginTransaction();
+			try {
+				UserImpl user = createUser2(site,email,passwordDigest,name);
+				db.commitTransaction();
+				return user;
+			} finally {
+				db.endTransaction();
+			}
+		}
+		if (!new MailAddress(email).isValid()) {
+			throw new ModelException.EmailFormat("invalid_email");
+		}
+		UserImpl user = site.getUserImplFromEmail(email);
+		if( user==null ) {
+			user = new UserImpl(site);
+			user.setEmail(email);
+		} else {
+			if( user.isRegistered() )
+				throw ModelException.newInstance("user_already_registered","User is already registered");
+			validateEmail(user.getEmail());
+		}
+		user.setPasswordDigest(passwordDigest);
+		user.setName(name);
+		return user;
+	}
+
+	static UserImpl getOrCreateUser(SiteImpl site,String email) {
+		UserImpl user = site.getUserImplFromEmail(email);
+		if (user == null) {
+			String username = email.substring(0, email.indexOf('@'));
+			user = createGhost(site,email);
+			user.setNameLike(username, false);
+			user.insert();
+		}
+		return user;
+	}
+
+	private static final Object regLock = new Object();
+
+	String newRegistration(String nextUrl) {
+		if( nextUrl.equals("null") )
+			throw new RuntimeException("nextUrl is \"null\"");
+		synchronized(regLock) {
+			String key;
+			try {
+				Connection con = db().getConnection();
+				{
+					PreparedStatement stmt = con.prepareStatement(
+						"select 'x' from registration where key_=?"
+					);
+					do {
+						key = Double.toString(Math.random());
+						stmt.setString(1,key);
+					} while( stmt.executeQuery().next() );
+					stmt.close();
+				}
+				{
+					PreparedStatement stmt = con.prepareStatement(
+						"insert into registration"
+						+" ( key_, email, password_digest, name, next_url ) values (?,?,?,?,?)"
+					);
+					int i = 0;
+					stmt.setString(++i,key);
+					stmt.setString(++i,getEmail());
+					stmt.setString(++i,getPasswordDigest());
+					stmt.setString(++i,getName());
+					stmt.setString(++i,nextUrl);
+					stmt.executeUpdate();
+					stmt.close();
+				}
+				{
+					Statement stmt = con.createStatement();
+					stmt.executeUpdate(
+						"delete from registration where date_<" + Db.arcana.dateSub("now()",7,"day")
+					);
+					stmt.close();
+				}
+				con.close();
+			} catch(SQLException e) {
+				throw new RuntimeException(e);
+			}
+			return key;
+		}
+	}
+
+	static User getRegistration(SiteImpl site,String registrationKey)
+		throws ModelException
+	{
+		try {
+			DbDatabase db = site.getDb();
+			Connection con = db.getConnection();
+			PreparedStatement stmt = con.prepareStatement(
+				"select * from registration where key_=?"
+			);
+			stmt.setString(1,registrationKey);
+			ResultSet rs = stmt.executeQuery();
+			try {
+				if( !rs.next() )
+					return null;
+				String email = rs.getString("email");
+				String passwordDigest = rs.getString("password_digest");
+				String name = rs.getString("name");
+				return createUser2(site,email,passwordDigest,name);
+			} finally {
+				rs.close();
+				stmt.close();
+				con.close();
+			}
+		} catch(SQLException e) {
+			throw new RuntimeException(e);
+		}
+	}
+
+	static String getNextUrl(SiteKey siteKey,String registrationKey)
+	{
+		try {
+			Connection con = siteKey.getDb().getConnection();
+			PreparedStatement stmt = con.prepareStatement(
+				"select next_url from registration where key_=?"
+			);
+			stmt.setString(1,registrationKey);
+			ResultSet rs = stmt.executeQuery();
+			try {
+				if( !rs.next() )
+					return null;
+				return rs.getString("next_url");
+			} finally {
+				rs.close();
+				stmt.close();
+				con.close();
+			}
+		} catch(SQLException e) {
+			throw new RuntimeException(e);
+		}
+	}
+
+	// Called from beanshell
+	private static void deletePendingRegistration(Site site,String email, String username) {
+		try {
+			Connection con = site.getDb().getConnection();
+			PreparedStatement stmt = con.prepareStatement(
+				"delete from registration where email=? or name = ?"
+			);
+			stmt.setString(1,email);
+			stmt.setString(2,username);
+			stmt.executeUpdate();
+			stmt.close();
+			con.close();
+		} catch(SQLException e) {
+			throw new RuntimeException(e);
+		}
+	}
+
+	public void deactivate() {
+		db().beginTransaction();
+		try {
+			UserImpl user = DbUtils.getGoodCopy(this);
+			user.setNoArchive(true);
+			user.setRegistered(null);
+			user.setPasswordDigest(null);
+			user.record.update();
+			db().commitTransaction();
+			logger.info("User removed his/her account: " + getEmail());
+		} finally {
+			db().endTransaction();
+		}
+	}
+
+	private DbParamSetter simpleParamSetter() {
+		return new DbParamSetter() {
+			public void setParams(PreparedStatement stmt) throws SQLException {
+				stmt.setLong( 1, getId() );
+			}
+		};
+	}
+
+	public NodeIterator<? extends Node> getPendingPosts() {
+		return new CursorNodeIterator( siteKey,
+				"select *"
+				+" from node"
+				+" where owner_id = ?"
+				+" and when_sent is not null"
+				+" order by when_sent"
+			, simpleParamSetter()
+		);
+	}
+
+	public Message getSignature() {
+		return signature;
+	}
+
+	public User setSignature( String signatureRaw, Message.Format signatureFormat )
+		throws ModelException
+	{
+		if( !db().isInTransaction() ) {
+			db().beginTransaction();
+			try {
+				UserImpl user = DbUtils.getGoodCopy(this);
+				user.setSignature(signatureRaw,signatureFormat);
+				user.getDbRecord().update();
+				db().commitTransaction();
+				return DbUtils.getGoodCopy(user);
+			} finally {
+				db().endTransaction();
+			}
+		}
+		if( signatureRaw==null || signatureRaw.trim().length()==0 ) {
+			if( signature != null ) {
+				signature = null;
+				record.fields().put("signature",DbNull.STRING);
+				record.fields().put("signature_format",DbNull.STRING);
+			}
+		} else {
+			Message newSignature = new Message(signatureRaw,signatureFormat);
+			if( !newSignature.equals(signature) ) {
+				signature = newSignature;
+				record.fields().put("signature",signatureRaw);
+				record.fields().put("signature_format",Character.toString(signatureFormat.getCode()));
+			}
+		}
+		return this;
+	}
+
+
+	public String getDecoratedAddress(Node node) {
+		return PostByEmail.getMailAddress(this, node);
+	}
+
+	public void saveAvatar(BufferedImage smallImage,BufferedImage bigImage) throws ModelException {
+		if( !db().isInTransaction() ) {
+			db().beginTransaction();
+			try {
+				DbUtils.getGoodCopy(this).saveAvatar(smallImage,bigImage);
+				db().commitTransaction();
+			} finally {
+				db().endTransaction();
+			}
+			return;
+		}
+		Message.AvatarSource as = new Message.AvatarSource(this);
+		FileUpload.saveImage(smallImage,ModelHome.AVATAR_SMALL,as);
+		FileUpload.saveImage(bigImage,ModelHome.AVATAR_BIG,as);
+		getSiteImpl().update();  // fire change listeners
+		DbUtils.uncache(this);
+	}
+
+	public void deleteAvatar() {
+		if( !db().isInTransaction() ) {
+			db().beginTransaction();
+			try {
+				DbUtils.getGoodCopy(this).deleteAvatar();
+				db().commitTransaction();
+			} finally {
+				db().endTransaction();
+			}
+			return;
+		}
+		final Message.AvatarSource as = new Message.AvatarSource(this);
+		FileUpload.deleteFile(ModelHome.AVATAR_SMALL,as);
+		FileUpload.deleteFile(ModelHome.AVATAR_BIG,as);
+		getSiteImpl().update();  // fire change listeners
+		db().runAfterCommit(new Runnable(){public void run(){
+			FileUpload.fireFileUpdateListeners(as);
+		}});
+		DbUtils.uncache(this);
+	}
+
+	private boolean hasAvatar;
+	private boolean checkedAvatar = false;
+
+	public synchronized boolean hasAvatar() {
+		if( !checkedAvatar ) {
+			Message.AvatarSource as = new Message.AvatarSource(this);
+			hasAvatar = FileUpload.hasFile(as,ModelHome.AVATAR_SMALL) && FileUpload.hasFile(as,ModelHome.AVATAR_BIG);
+			checkedAvatar = true;
+		}
+		return hasAvatar;
+	}
+
+
+
+	public Node newRootNode(Node.Kind kind,String subject,String message,Message.Format msgFmt,Site site,String type) throws ModelException {
+		return NodeImpl.newRootNode(kind,this,subject,message,msgFmt,(SiteImpl)site,type);
+	}
+
+	public Node newChildNode(Node.Kind kind,String subject,String message,Message.Format msgFmt,Node parent) throws ModelException {
+		return NodeImpl.newChildNode(kind,this,subject,message,msgFmt,(NodeImpl)parent);
+	}
+
+	public String getSearchId() {
+		return Long.toString(getId());
+	}
+
+	public String getIdString() {
+		return Long.toString(getId());
+	}
+
+	private void clearBounces() {
+		if( bounces==0 )
+			return;
+		bounces = 0;
+		record.fields().put("bounces",DbNull.INTEGER);
+		record.update();
+	}
+
+	void bounced() {
+		record.fields().put("bounces",++bounces);
+		record.update();
+	}
+
+	int getBounces() {
+		return bounces;
+	}
+
+	private static final int bounceLimit = Init.get("bounceLimit",100);
+
+	boolean isAutoUnsubscribe() {
+		return isDeactivated() || bounces > bounceLimit;
+	}
+
+
+
+
+	private volatile Map<String, Integer> nodeCount = new HashMap<String, Integer>();
+
+	public final int getNodeCount(String cnd) {
+		String key = cnd == null? "none" : cnd;
+		if (!nodeCount.containsKey(key)) {
+			try {
+				Connection con = db().getConnection();
+				PreparedStatement stmt = con.prepareStatement(
+					"select count(*) as n from node where owner_id = ?" +
+					(cnd == null? "" : " and " + cnd)
+				);
+				stmt.setLong(1,getId());
+				ResultSet rs = stmt.executeQuery();
+				rs.next();
+				nodeCount.put(key, rs.getInt("n"));
+				rs.close();
+				stmt.close();
+				con.close();
+			} catch(SQLException e) {
+				throw new RuntimeException(e);
+			}
+		}
+		return nodeCount.get(key);
+	}
+
+	void setNodeCount(int nodeCount) {
+		this.nodeCount.put("none", nodeCount);
+	}
+
+	static {
+		Listener<NodeImpl> listener = new Listener<NodeImpl>() {
+			public void event(NodeImpl node) {
+				table(node.siteKey).uncache(new LongKey(node.getOwnerId()));
+			}
+		};
+		NodeImpl.postInsertListeners.add(listener);
+		NodeImpl.postDeleteListeners.add(listener);
+	}
+
+
+	public void moveToRegisteredAccount(final String cookie) {
+		List<NodeImpl> nodes = new CursorNodeIterator( siteKey,
+				"select * from node where cookie=?"
+			,
+				new DbParamSetter() {
+					public void setParams(PreparedStatement stmt) throws SQLException {
+						stmt.setString(1,cookie);
+					}
+				}
+		).asList();
+		for( NodeImpl n : nodes ) {
+			n.setOwner(this);
+			n.update();
+		}
+	}
+
+
+	public NodeIterator<? extends Node> getNodesByDateDesc(String cnd) {
+		return new CursorNodeIterator( siteKey,
+				"select * from node where owner_id = ?" +
+				(cnd == null? "" : " and " + cnd) +
+				" order by when_created desc"
+			,
+				new DbParamSetter() {
+					public void setParams(PreparedStatement stmt) throws SQLException {
+						stmt.setLong( 1, getId() );
+					}
+				}
+		);
+	}
+
+
+	public int deleteNodes() {
+		List<NodeImpl> nodes = new CursorNodeIterator( siteKey,
+				"select *"
+				+" from node"
+				+" where owner_id = ?"
+			, simpleParamSetter()
+		).asList();
+		int n = 0;
+		for( NodeImpl node : nodes ) {
+			db().beginTransaction();
+			try {
+				DbUtils.getGoodCopy(node).deleteMessageOrNode();
+				db().commitTransaction();
+				n++;
+			} finally {
+				db().endTransaction();
+			}
+		}
+		return n;
+	}
+
+	public int deleteNodesRecursively() {
+		List<NodeImpl> nodes = new CursorNodeIterator( siteKey,
+				"select *"
+				+" from node"
+				+" where owner_id = ?"
+			, simpleParamSetter()
+		).asList();
+		int n = 0;
+		for( NodeImpl node : nodes ) {
+			db().beginTransaction();
+			try {
+				DbUtils.getGoodCopy(node).deleteRecursively();
+				db().commitTransaction();
+				n++;
+			} finally {
+				db().endTransaction();
+			}
+		}
+		return n;
+	}
+
+
+	private Map<ExtensionFactory<User,?>,Object> extensionMap;
+
+	private synchronized Map<ExtensionFactory<User, ?>, Object> getExtensionMap() {
+		if (extensionMap == null)
+			extensionMap = new HashMap<ExtensionFactory<User, ?>, Object>();
+		return extensionMap;
+	}
+
+	public <T> T getExtension(ExtensionFactory<User,T> factory) {
+		synchronized(getExtensionMap()) {
+			Object obj = extensionMap.get(factory);
+			if( obj == null ) {
+				obj = factory.construct(this);
+				if( obj != null )
+					extensionMap.put(factory,obj);
+			}
+			return factory.extensionClass().cast(obj);
+		}
+	}
+
+	private static Collection<ExtensionFactory<User,?>> extensionFactories = new CopyOnWriteArrayList<ExtensionFactory<User,?>>();
+
+	static <T> void addExtensionFactory(ExtensionFactory<User,T> factory) {
+		extensionFactories.add(factory);
+		Db.clearCache();
+	}
+
+
+
+
+	// visited node
+
+	private final Map<Long,Long> visitedNodeCache = new HashMap<Long,Long>();
+
+	public Long lastVisitedNodeId(long nodeId) {
+		synchronized(visitedNodeCache) {
+			return visitedNodeCache.containsKey(nodeId) ? visitedNodeCache.get(nodeId) : lastVisitedNodeIds(Collections.singletonList(nodeId)).get(nodeId);
+		}
+	}
+
+	public Map<Long,Long> lastVisitedNodeIds(Collection<Long> nodeIds) {
+		synchronized(visitedNodeCache) {
+			Set<Long> notCached = new HashSet<Long>();
+			for( Long nodeId : nodeIds ) {
+				if( !visitedNodeCache.containsKey(nodeId) )
+					notCached.add(nodeId);
+			}
+			if( !notCached.isEmpty() ) {
+				StringBuilder sql = new StringBuilder();
+				sql
+					.append( "select node_id, last_node_id from visited where user_id = " )
+					.append( getId() )
+					.append( " and node_id in (" )
+				;
+				Iterator<Long> iter = notCached.iterator();
+				sql.append( iter.next() );
+				while( iter.hasNext() ) {
+					sql.append( ',' ).append( iter.next() );
+				}
+				sql.append( ")" );
+				try {
+					Connection con = db().getConnection();
+					Statement stmt = con.createStatement();
+					ResultSet rs = stmt.executeQuery(sql.toString());
+					while( rs.next() ) {
+						Long nodeId = rs.getLong("node_id");
+						Long lastNodeId = rs.getLong("last_node_id");
+						visitedNodeCache.put(nodeId,lastNodeId);
+						notCached.remove(nodeId);
+					}
+					rs.close();
+					stmt.close();
+					con.close();
+				} catch(SQLException e) {
+					throw new RuntimeException(e);
+				}
+				for( Long nodeId : notCached ) {
+					visitedNodeCache.put(nodeId,null);
+				}
+			}
+			Map<Long,Long> map = new HashMap<Long,Long>();
+			for( Long nodeId : nodeIds ) {
+				map.put( nodeId, visitedNodeCache.get(nodeId) );
+			}
+			return map;
+		}
+	}
+
+	public void markVisited(Node topic, long lastNodeId) {
+		NodeImpl topicNode = (NodeImpl)topic;
+		long nodeId = topicNode.getId();
+		boolean updated = false;
+		try {
+			Connection con = db().getConnection();
+			try {
+				Long persistedLastVisitedNodeId = lastVisitedNodeId(nodeId);
+				if( persistedLastVisitedNodeId == null ) {
+					PreparedStatement stmt = con.prepareStatement(
+						"insert into visited (user_id, node_id, last_node_id)"
+						+" values (?, ?, ?)"
+					);
+					stmt.setLong( 1, getId() );
+					stmt.setLong( 2, nodeId );
+					stmt.setLong( 3, lastNodeId );
+					DbDatabaseImpl.executeUpdateIgnoringDuplicateKeys(stmt);
+					stmt.close();
+					updated = true;
+				} else if (lastNodeId > persistedLastVisitedNodeId) {
+					PreparedStatement stmt = con.prepareStatement(
+						"update visited set last_node_id = ?"
+						+" where user_id = ? and node_id = ?"
+					);
+					stmt.setLong( 1, lastNodeId );
+					stmt.setLong( 2, getId() );
+					stmt.setLong( 3, nodeId );
+					stmt.executeUpdate();
+					stmt.close();
+					updated = true;
+				}
+			} finally {
+				con.close();
+			}
+		} catch(SQLException e) {
+			if( !e.getMessage().contains("violates foreign key constraint \"visited_last_node_id_fkey\"") )
+				throw new RuntimeException(e);
+		}
+		if (updated) {
+			synchronized(visitedNodeCache) {
+				visitedNodeCache.remove(nodeId);
+			}
+		}
+	}
+
+	public void unmarkVisited(Node node) {
+		long nodeId = node.getId();
+		try {
+			Connection con = db().getConnection();
+			PreparedStatement stmt = con.prepareStatement(
+				"delete from visited"
+				+" where user_id = ? and node_id = ?"
+			);
+			stmt.setLong( 1, getId() );
+			stmt.setLong( 2, nodeId );
+			stmt.executeUpdate();
+			stmt.close();
+			con.close();
+		} catch(SQLException e) {
+			throw new RuntimeException(e);
+		}
+		synchronized(visitedNodeCache) {
+			visitedNodeCache.remove(nodeId);
+		}
+	}
+
+
+	static void addPostInsertListener(final Listener<? super UserImpl> listener) {
+		postInsertListeners.add(listener);
+	}
+
+
+
+	private final Memoizer<String,String> propertyCache = new Memoizer<String,String>(new Computable<String,String>() {
+		public String get(String key) {
+			try {
+				Connection con = db().getConnection();
+				PreparedStatement stmt = con.prepareStatement(
+					"select value from user_property where user_id = ? and key = ?"
+				);
+				stmt.setLong( 1, getId() );
+				stmt.setString( 2, key );
+				ResultSet rs = stmt.executeQuery();
+				try {
+					return rs.next() ? rs.getString("value") : null;
+				} finally {
+					rs.close();
+					stmt.close();
+					con.close();
+				}
+			} catch(SQLException e) {
+				throw new RuntimeException(e);
+			}
+		}
+	});
+
+	public String getProperty(String key) {
+		return propertyCache.get(key);
+	}
+
+	public void setProperty(String key,String value) {
+		try {
+			Connection con = db().getConnection();
+			PreparedStatement stmt = con.prepareStatement(
+				"delete from user_property where user_id = ? and key = ?"
+			);
+			stmt.setLong( 1, getId() );
+			stmt.setString( 2, key );
+			stmt.executeUpdate();
+			stmt.close();
+			if( value != null ) {
+				stmt = con.prepareStatement(
+					"insert into user_property (user_id,key,value) values (?,?,?)"
+				);
+				stmt.setLong( 1, getId() );
+				stmt.setString( 2, key );
+				stmt.setString( 3, value );
+				stmt.executeUpdate();
+				stmt.close();
+			}
+			con.close();
+		} catch(SQLException e) {
+			throw new RuntimeException(e);
+		} finally {
+			propertyCache.remove(key);
+		}
+	}
+
+
+	final Memoizer<String,Boolean> tagCache = new Memoizer<String,Boolean>(new Computable<String,Boolean>() {
+		public Boolean get(String sqlCondition) {
+			return TagImpl.countTags(siteKey,sqlCondition) > 0;
+		}
+	});
+
+
+
+
+
+	private final static PooledStringDigester passwordDigester = new PooledStringDigester();
+
+	static {
+        passwordDigester.setAlgorithm(Init.get("passwordDigestAlgorithm","SHA-256"));
+        passwordDigester.setIterations(Init.get("passwordDigestIterations",100000));
+        passwordDigester.setSaltSizeBytes(Init.get("passwordDigestSaltSize",16));
+        passwordDigester.setPoolSize(Init.get("passwordDigestPoolSize",4));
+        passwordDigester.initialize();
+	}
+
+	private final static PooledStringDigester passcookieDigester = new PooledStringDigester();
+
+	static {
+        passcookieDigester.setAlgorithm(Init.get("passcookieDigestAlgorithm","SHA-256"));
+        passcookieDigester.setIterations(Init.get("passcookieDigestIterations",100000));
+        FixedByteArraySaltGenerator sg = new FixedByteArraySaltGenerator();
+        // this fixed salt needs to be kept secret
+        sg.setSalt(Init.get("passcookieSalt", new byte[]{105, 4, 40, 78, 24, 46, 30, 100, 18, -27, 114, -21, -44, -59, 103, 43}));
+        passcookieDigester.setSaltGenerator(sg);
+        passcookieDigester.setPoolSize(Init.get("passcookieDigestPoolSize",4));
+        passcookieDigester.initialize();
+	}
+
+	private final static PooledStringDigester resetcodeDigester = new PooledStringDigester();
+
+	static {
+        resetcodeDigester.setAlgorithm(Init.get("resetcodeDigestAlgorithm","SHA-256"));
+        resetcodeDigester.setIterations(Init.get("resetcodeDigestIterations",100000));
+        FixedByteArraySaltGenerator sg = new FixedByteArraySaltGenerator();
+        // this fixed salt needs to be kept secret
+        sg.setSalt(Init.get("resetcodeSalt", new byte[]{-47, 9, -128, 109, 112, -88, -91, 39, 77, 111, 57, -102, 120, 12, 54, 16}));
+        resetcodeDigester.setSaltGenerator(sg);
+        resetcodeDigester.setPoolSize(Init.get("resetcodeDigestPoolSize",4));
+        resetcodeDigester.initialize();
+	}
+
+	public boolean checkPassword(String password) {
+		return passwordDigest!=null && passwordDigester.matches(password, passwordDigest);
+	}
+
+	private String calcPasscookie() {
+		return passcookieDigester.digest(passwordDigest);
+	}
+
+	public boolean checkPasscookie(String passcookie) {
+		return passwordDigest!=null && getPasscookie().equals(passcookie);
+	}
+
+	public String getResetcode() {
+		return resetcodeDigester.digest(passwordDigest);
+	}
+
+	public boolean checkResetcode(String resetcode) {
+		return passwordDigest!=null && getResetcode().equals(resetcode);
+	}
+
+	private static String digestPassword(String password) {
+		return passwordDigester.digest(password);
+	}
+
+}