/* This code is part of Freenet. It is distributed under the GNU General
* Public License, version 2 (or at your option any later version). See
* http://www.gnu.org/ for further details of the GPL. */
package plugins.Freetalk;
import java.io.UnsupportedEncodingException;
import java.util.Arrays;
import java.util.Date;
import java.util.Iterator;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Set;
import plugins.Freetalk.exceptions.InvalidParameterException;
import plugins.Freetalk.exceptions.NoSuchMessageException;
import com.db4o.ext.ExtObjectContainer;
import com.db4o.query.Query;
import freenet.keys.FreenetURI;
import freenet.support.Base64;
import freenet.support.Logger;
import freenet.support.StringValidityChecker;
/**
* A Freetalk message. This class is supposed to be used by the UI directly for reading messages. The UI can obtain message objects by querying
* them from a MessageManager. A message has the usual attributes (author, boards to which it is posted, etc.) one would assume.
* There are two unique ways to reference a message: It's URI and it's message ID. The URI is to be given to the user if he wants to tell other
* people about the message, the message ID is to be used for querying the database for a message in a fast way.
*
* @author saces, xor
*/
public abstract class Message implements Comparable {
/* Public constants */
public final static int MAX_MESSAGE_TITLE_TEXT_LENGTH = 256; // String.length()
public final static int MAX_MESSAGE_TEXT_LENGTH = 64*1024;
public final static int MAX_MESSAGE_TEXT_BYTE_LENGTH = 64*1024; // byte[].length
/* Attributes, stored in the database */
/**
* The URI of this message.
*/
protected MessageURI mURI; /* Not final because for OwnMessages it is set after the MessageList was inserted */
/**
* The CHK URI of the message. Null until the message was inserted and the URI is known.
*/
/* FIXME: Rename to "mUnsignedURI" so that we do not need to explain that messages are inserted as CHK in this class. We do not want to
* explain that here because this class is abstract and could be implemented with mRealURI being the same as mURI */
protected FreenetURI mRealURI; /* Not final because for OwnMessages it is set after the Message was inserted */
/**
* The ID of the message. Format: Hex encoded author routing key + "@" + hex encoded random UUID.
*/
protected final String mID;
protected MessageList mMessageList; /* Not final because OwnMessages are assigned to message lists after a certain delay */
/**
* The URI of the thread this message belongs to.
* We do not need it to construct the thread-tree from messages, but it boosts performance of thread-tree-construction:
* Thread-size (amount of replies) is usually infinitesimal compared to the size of a FTBoard (amount of threads).
* We receive messages in random order, therefore we will usually have orphan messages of which we need to find the parents.
* If we receive the parent messages of those messages, we will be able to find their orphan children faster if we only need to search in
* the thread they belong to and not in the whole FTBoard - which may contain many thousands of messages.
*/
protected MessageURI mThreadURI;
protected String mThreadID;
/**
* The URI of the message to which this message is a reply. Null if it is a thread.
*/
protected final MessageURI mParentURI;
protected final String mParentID;
/**
* The boards to which this message was posted, in alphabetical order.
*/
protected final Board[] mBoards;
protected final Board mReplyToBoard;
protected final FTIdentity mAuthor;
protected final String mTitle;
/**
* The date when the message was written in UTC time.
*/
protected final Date mDate;
/**
* The date when the message was downloaded.
*/
protected final Date mFetchDate;
protected final String mText;
/**
* The attachments of this message, in the order in which they were received in the original message.
*/
protected final Attachment[] mAttachments;
public static class Attachment {
private final FreenetURI mURI;
private final long mSize; /* Size in bytes */
public Attachment(FreenetURI myURI, long mySize) {
mURI = myURI;
mSize = mySize;
}
public FreenetURI getURI() {
return mURI;
}
public long getSize() {
return mSize;
}
}
/**
* The thread to which this message is a reply.
*/
private Message mThread = null;
/**
* The message to which this message is a reply.
*/
private Message mParent = null;
/* References to objects of the plugin, not stored in the database. */
protected transient ExtObjectContainer db;
protected transient MessageManager mMessageManager;
/**
* Get a list of fields which the database should create an index on.
*/
public static String[] getIndexedFields() {
return new String[] {
"mID", /* Indexed because it is our primary key */
"mThreadID", /* Indexed for being able to query all messages of a thread */
"mParentID", /* Indexed for being able to get all replies to a message */
"mFetchDate", /* Indexed because Frost needs to query for all messages after the time it has last done so */
};
}
protected Message(MessageURI newURI, FreenetURI newRealURI, String newID, MessageList newMessageList, MessageURI newThreadURI, MessageURI newParentURI, Set newBoards, Board newReplyToBoard, FTIdentity newAuthor, String newTitle, Date newDate, String newText, List newAttachments) throws InvalidParameterException {
/* We only assert() instead of throwing because the constructor is protected and the construct() function should verify this */
assert(newURI == null || Arrays.equals(newURI.getFreenetURI().getRoutingKey(), newAuthor.getRequestURI().getRoutingKey()));
/* FIXME: assert(newMessageList.getAuthor() == newAuthor); */
/* FIXME: assert(newRealURI == null || newMessageList.contains(newRealURI)); */
verifyID(newAuthor, newID);
if(newParentURI != null && newThreadURI == null)
Logger.error(this, "Message with parent URI but without thread URI created: " + newURI);
if (newBoards.isEmpty())
throw new InvalidParameterException("No boards in message " + newURI);
if (newReplyToBoard != null && !newBoards.contains(newReplyToBoard)) {
Logger.error(this, "Message created with replyToBoard not being in newBoards: " + newURI);
newBoards.add(newReplyToBoard);
}
mURI = newURI;
mRealURI = newRealURI;
mMessageList = newMessageList;
mAuthor = newAuthor;
mID = newID;
mParentURI = newParentURI;
mParentID = mParentURI != null ? mParentURI.getMessageID() : null;
/* If a message has no thread URI specified (this is a bug in the client which inserted it) we store the parent URI as thread URI instead.
* This will be corrected later by setParent(). */
mThreadURI = newThreadURI != null ? newThreadURI : mParentURI;
mThreadID = mThreadURI != null ? mThreadURI.getMessageID() : mParentID;
mBoards = newBoards.toArray(new Board[newBoards.size()]);
Arrays.sort(mBoards);
mReplyToBoard = newReplyToBoard;
mTitle = makeTitleValid(newTitle);
mDate = newDate; // TODO: Check out whether Date provides a function for getting the timezone and throw an Exception if not UTC.
mFetchDate = CurrentTimeUTC.get();
mText = makeTextValid(newText);
if (!isTitleValid(mTitle))
throw new InvalidParameterException("Invalid message title in message " + newURI);
if (!isTextValid(mText))
throw new InvalidParameterException("Invalid message text in message " + newURI);
mAttachments = newAttachments == null ? null : newAttachments.toArray(new Attachment[newAttachments.size()]);
}
/**
* Has to be used after loading a FTBoard object from the database to initialize the transient fields.
*/
public void initializeTransient(ExtObjectContainer myDB, MessageManager myMessageManager) {
assert(myDB != null);
assert(myMessageManager != null);
db = myDB;
mMessageManager = myMessageManager;
}
/**
* Verifies that the given message ID begins with the routing key of the author.
* @throws InvalidParameterException If the ID is not valid.
*/
public static void verifyID(FTIdentity author, String id) throws InvalidParameterException {
if(id.endsWith(Base64.encode(author.getRequestURI().getRoutingKey())) == false)
throw new InvalidParameterException("Illegal id:" + id);
}
/**
* Get the URI of the message.
*/
public MessageURI getURI() { /* Not synchronized because only OwnMessage might change the URI */
return mURI;
}
public String getID() { /* Not synchronized because only OwnMessage might change the ID */
return mID;
}
/**
* Get the MessageURI of the thread this message belongs to.
* @throws NoSuchMessageException
*/
public synchronized MessageURI getThreadURI() throws NoSuchMessageException {
if(mThreadURI == null)
throw new NoSuchMessageException();
return mThreadURI;
}
/**
* Get the ID of the thread this message belongs to. Should not be used by the user interface for querying the database as the parent
* thread might not have been downloaded yet. Use getThread().getID() instead.
* @return The ID of the message's parent thread.
* @throws NoSuchMessageException If the message is a thread itself.
*/
public synchronized String getThreadID() throws NoSuchMessageException {
if(mThreadID == null)
throw new NoSuchMessageException();
return mThreadID;
}
/**
* Used internally for correcting the thread URIs of messages which have specified them wrong in the XML.
*/
protected synchronized void setThreadURIAndID(MessageURI newThreadURI) {
mThreadURI = newThreadURI;
mThreadID = mThreadURI.getMessageID();
store();
}
/**
* Get the MessageURI to which this message is a reply. Null if the message is a thread.
*/
public MessageURI getParentURI() throws NoSuchMessageException {
if(mParentURI == null)
throw new NoSuchMessageException();
return mParentURI;
}
public String getParentID() throws NoSuchMessageException {
if(mParentID == null)
throw new NoSuchMessageException();
return mParentID;
}
public boolean isThread() {
return mParentURI == null;
}
/**
* Get the boards to which this message was posted. The boards are returned in alphabetical order.
* The transient fields of the returned boards are initialized already.
*/
public Board[] getBoards() {
for(Board b : mBoards)
b.initializeTransient(db, mMessageManager);
return mBoards;
}
public Board getReplyToBoard() {
return mReplyToBoard;
}
/**
* Get the author of the message.
*/
public FTIdentity getAuthor() {
mAuthor.initializeTransient(db, mMessageManager.getIdentityManager());
return mAuthor;
}
/**
* Get the title of the message.
*/
public String getTitle() {
return mTitle;
}
/**
* Get the date when the message was written in UTC time.
*/
public Date getDate() {
return mDate;
}
/**
* Get the date when the message was fetched by Freetalk.
*/
public Date getFetchDate() {
return mFetchDate;
}
/**
* Get the text of the message.
*/
public String getText() {
return mText;
}
/**
* Get the attachments of the message, in the order in which they were received.
*/
public Attachment[] getAttachments() {
return mAttachments;
}
/**
* Get the thread to which this message belongs. The transient fields of the returned message will be initialized already.
* This might not always return the real parent thread, it will return the topmost parent message if the parent thread has not been
* downloaded yet.
*/
public synchronized Message getThread() throws NoSuchMessageException {
/* TODO: Find all usages of this function and check whether we should put the activate() here and what the fitting depth is */
db.activate(this, 3);
if(mThread == null)
throw new NoSuchMessageException();
mThread.initializeTransient(db, mMessageManager);
return mThread;
}
public synchronized void setThread(Message newParentThread) {
assert(mThread == null);
if(!newParentThread.getID().equals(mThreadID))
throw new IllegalArgumentException("Trying to set a message as thread which has the wrong ID: " + newParentThread.getID());
if(!newParentThread.isThread())
throw new IllegalArgumentException("Trying to setThread(not a thread).");
mThread = newParentThread;
store();
}
/**
* Get the message to which this message is a reply. The transient fields of the returned message will be initialized already.
*/
public synchronized Message getParent() throws NoSuchMessageException {
/* TODO: Find all usages of this function and check whether we should put the activate() here and what the fitting depth is */
db.activate(this, 3);
if(mParent == null)
throw new NoSuchMessageException();
mParent.initializeTransient(db, mMessageManager);
return mParent;
}
public synchronized void setParent(Message newParent) {
if(!newParent.getID().equals(mParentID))
throw new IllegalArgumentException("Trying to set a message as parent which has the wrong ID: " + newParent.getID());
/* TODO: assert(newParent contains at least one board which mBoards contains) */
mParent = newParent;
/* Check whether mThreadURI/ID is correct. If it is not, adopt the thread URI/ID of the parent message and also set this on
* all our children and their children. I thought about how to handle incorrect thread URI/ID for about 3 hours and it seems to me
* like this is the way to go... requires lots of brainwork to imagine what side effects can happen due to incorrect thread URI/ID
* and the conclusion is that they MUST be corrected, otherwise those messages will disappear in getAllThreadReplies() */
synchronized(mParent) {
String realThreadID = newParent.isThread() ? newParent.getID() : mParent.mThreadID;
if(!realThreadID.equals(mThreadID)) {
Logger.error(this, "Correcting thread URI/ID of " + getURI());
/* It is unpredictable what messages will be affected by this code because we modify all children, so unfortunately we should
* probably lock all changes to messages here :| */
synchronized(Message.class) {
if(mParent.isThread()) {
setThreadURIAndID(mParent.getURI());
setThread(mParent);
} else
setThreadURIAndID(mParent.mThreadURI);
/* Use depth-first-search for setting the URI/ID on the children
* This is dangerous: An attacker might cause very high memory usage if we do not ensure that we use DFS properly so
* that only 1 message must be in memory at once */
Message child = this;
boolean foundChild;
do {
foundChild = false;
for(Message c : child.getChildren(null)) {
if(!mThreadID.equals(c.mThreadID)) {
db.deactivate(child, 1);
child = c;
child.setThreadURIAndID(mThreadURI);
foundChild = true; break;
}
}
if(!foundChild && child != this) {
try {
child = child.getParent();
foundChild = true;
} catch (NoSuchMessageException e) {
Logger.error(this, "Should never happen!");
}
}
} while(foundChild);
}
}
}
store();
}
/**
* Returns an iterator over the children of the message, sorted descending by date.
* The transient fields of the children will be initialized already.
*/
@SuppressWarnings("unchecked")
public synchronized Iterable getChildren(final Board targetBoard) {
return new Iterable() {
public Iterator iterator() {
return new Iterator() {
private Iterator iter;
private Board board;
private Message next;
{
board = targetBoard;
/* TODO: Accelerate this query: configure db4o to keep a per-message date-sorted index of children.
* - Not very important for now since threads are usually small. */
Query q = db.query();
q.constrain(Message.class);
q.descend("mParent").constrain(this);
q.descend("mDate").orderDescending();
iter = q.execute().iterator();
next = iter.hasNext() ? iter.next() : null;
}
public boolean hasNext() {
if(board == null)
return next != null;
while(next != null) {
if(Arrays.binarySearch(next.getBoards(), board) >= 0)
return true;
next = iter.hasNext() ? iter.next() : null;
}
return false;
}
public Message next() {
if(!hasNext()) { /* We have to call hasNext() to ignore messages which do not belong to the selected board */
assert(false); /* However, the users of the function should do this for us */
throw new NoSuchElementException();
}
Message child = next;
next = iter.hasNext() ? iter.next() : null;
child.initializeTransient(db, mMessageManager);
return child;
}
public void remove() {
throw new UnsupportedOperationException("Use child.setParent(null) instead.");
}
};
}
};
}
/**
* Checks whether the title of the message is valid. Validity conditions:
* - Not empty
* - No line breaks, tabs, or any other control characters.
* - No invalid characters.
* - No invalid formatting (unpaired direction or annotation characters.)
*/
static public boolean isTitleValid(String title) {
return (title != null
&& title.length() > 0
&& StringValidityChecker.containsNoInvalidCharacters(title)
&& StringValidityChecker.containsNoLinebreaks(title)
&& StringValidityChecker.containsNoControlCharacters(title)
&& StringValidityChecker.containsNoInvalidFormatting(title));
}
/**
* Checks whether the text of the message is valid. Validity conditions:
* - ...
*/
static public boolean isTextValid(String text) {
if (text == null) {
return false;
}
if (text.length() > MAX_MESSAGE_TEXT_LENGTH) {
return false;
}
try {
if (text.getBytes("UTF-8").length > MAX_MESSAGE_TEXT_BYTE_LENGTH) {
return false;
}
} catch(UnsupportedEncodingException e) {
return false;
}
return true;
}
/**
* Makes the passed title valid in means of isTitleValid()
* @see isTitleValid
*/
static public String makeTitleValid(String title) {
// FIXME: the newline handling here is based on the RFC 822
// format (newline + linear white space = single space). If
// necessary, we could move that part of the cleaning-up to
// ui.NNTP.ArticleParser, but the same algorithm should work
// fine in the general case.
StringBuilder result = new StringBuilder();
boolean replacingNewline = false;
int dirCount = 0;
boolean inAnnotatedText = false;
boolean inAnnotation = false;
for (int i = 0; i < title.length(); ) {
int c = title.codePointAt(i);
i += Character.charCount(c);
if (c == '\r' || c == '\n') {
if (!replacingNewline) {
replacingNewline = true;
result.append(' ');
}
}
else if (c == '\t' || c == ' ') {
if (!replacingNewline)
result.append(' ');
}
else if (c == 0x202A // LEFT-TO-RIGHT EMBEDDING
|| c == 0x202B // RIGHT-TO-LEFT EMBEDDING
|| c == 0x202D // LEFT-TO-RIGHT OVERRIDE
|| c == 0x202E) { // RIGHT-TO-LEFT OVERRIDE
dirCount++;
result.appendCodePoint(c);
}
else if (c == 0x202C) { // POP DIRECTIONAL FORMATTING
if (dirCount > 0) {
dirCount--;
result.appendCodePoint(c);
}
}
else if (c == 0xFFF9) { // INTERLINEAR ANNOTATION ANCHOR
if (!inAnnotatedText && !inAnnotation) {
result.appendCodePoint(c);
inAnnotatedText = true;
}
}
else if (c == 0xFFFA) { // INTERLINEAR ANNOTATION SEPARATOR
if (inAnnotatedText) {
result.appendCodePoint(c);
inAnnotatedText = false;
inAnnotation = true;
}
}
else if (c == 0xFFFB) { // INTERLINEAR ANNOTATION TERMINATOR
if (inAnnotation) {
result.appendCodePoint(c);
inAnnotation = false;
}
}
else if ((c & 0xFFFE) == 0xFFFE) {
// invalid character, ignore
}
else {
replacingNewline = false;
switch (Character.getType(c)) {
case Character.CONTROL:
case Character.SURROGATE:
case Character.LINE_SEPARATOR:
case Character.PARAGRAPH_SEPARATOR:
break;
default:
result.appendCodePoint(c);
}
}
}
if (inAnnotatedText) {
result.appendCodePoint(0xFFFA);
result.appendCodePoint(0xFFFB);
}
else if (inAnnotation) {
result.appendCodePoint(0xFFFB);
}
while (dirCount > 0) {
result.appendCodePoint(0x202C);
dirCount--;
}
return result.toString();
}
/**
* Makes the passed text valid in means of isTextValid()
* @see isTextValid
*/
static public String makeTextValid(String text) {
return text.replace("\r\n", "\n");
}
public synchronized void store() {
/* FIXME: Check for duplicates. Also notice that an OwnMessage which is equal might exist */
synchronized(db.lock()) {
if(db.ext().isStored(this) && !db.ext().isActive(this))
throw new RuntimeException("Trying to store a non-active Message object");
if(mAuthor == null)
throw new RuntimeException("Trying to store a message with mAuthor == null");
if(mURI != null)
db.store(mURI);
if(mRealURI != null)
db.store(mRealURI);
if(mThreadURI != null)
db.store(mThreadURI);
if(mParentURI != null)
db.store(mParentURI);
// db.store(mBoards); /* Not stored because it is a primitive for db4o */
// db.store(mDate); /* Not stored because it is a primitive for db4o */
// db.store(mAttachments); /* Not stored because it is a primitive for db4o */
db.store(this);
db.commit();
}
}
public int compareTo(Message other) {
return mDate.compareTo(other.mDate);
}
}