/* 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.util.Arrays; import java.util.Date; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Locale; import plugins.Freetalk.exceptions.InvalidParameterException; import plugins.Freetalk.exceptions.NoSuchMessageException; import com.db4o.ObjectSet; import com.db4o.ext.ExtObjectContainer; import com.db4o.query.Query; import freenet.support.Logger; import freenet.support.StringValidityChecker; /** * Represents a forum / newsgroups / discussion board in Freetalk. Boards are created by the MessageManager on demand, you do * not need to manually create them. The MessageManager takes care of anything related to boards, to someone who just wants to * write a user interface this class can be considered as read-only. * * @author xor */ public final class Board implements Comparable { /* Constants */ private static transient final HashSet ISOLanguages = new HashSet(Arrays.asList(getAllowedLanguageCodes())); // Characters not allowed in board names: // ! , ? * [ \ ] (space) not allowed by NNTP // / : < > | " not allowed in filenames on certain platforms // (a problem for some newsreaders) private static final String DISALLOWED_NAME_CHARACTERS = "!,?*[\\] /:<>|\""; public static final int MAX_BOARDNAME_TEXT_LENGTH = 256; /* Attributes, stored in the database */ private final String mName; private final Date mFirstSeenDate; private Date mLatestMessageDate; /* References to objects of the plugin, not stored in the database. */ private transient ExtObjectContainer db; private transient MessageManager mMessageManager; /** * Get a list of fields which the database should create an index on. */ public static String[] getIndexedFields() { return new String[] {"mName"}; } public static String[] getBoardMessageLinkIndexedFields() { /* TODO: ugly! find a better way */ return new String[] { "mBoard", "mMessage", "mMessageIndex" }; } public static String[] getAllowedLanguageCodes() { return Locale.getISOLanguages(); } /** * Create a board. You have to store() it yourself after creation. * @param newName The name of the board. For restrictions, see isNameValid() * @throws InvalidParameterException If none or an invalid name is given. */ public Board(String newName) throws InvalidParameterException { if(newName==null || newName.length() == 0) throw new IllegalArgumentException("Empty board name."); if(!isNameValid(newName)) throw new InvalidParameterException("Board names have to be either in English or have an ISO language code at the beginning followed by a dot."); // FIXME: Validate name and description. mName = newName.toLowerCase(); mFirstSeenDate = CurrentTimeUTC.get(); mLatestMessageDate = null; } /** * 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; } /** * Store this object in the database. You have to initializeTransient() before. */ public synchronized void store() { /* FIXME: check for duplicates */ synchronized(db.lock()) { if(db.ext().isStored(this) && !db.ext().isActive(this)) throw new RuntimeException("Trying to store a non-active Board object"); db.store(this); db.commit(); } } /** * Check if a board name is valid. * * Board names are required to begin with a known language code, * and may not contain any blacklisted characters. Formatting * characters must be properly paired within each part of the name * (special formatting characters may be needed, e.g. for some * Arabic or Hebrew group names to be displayed properly.) */ public static boolean isNameValid(String name) { // paranoia checks if (name == null || name.length() == 0) { return false; } // check maximum length if (name.length() > MAX_BOARDNAME_TEXT_LENGTH) { return false; } // check for illegal characters if (!StringValidityChecker.containsNoLinebreaks(name) || !StringValidityChecker.containsNoInvalidCharacters(name) || !StringValidityChecker.containsNoControlCharacters(name) || !StringValidityChecker.containsNoIDNBlacklistCharacters(name)) return false; for (Character c : name.toCharArray()) { if (DISALLOWED_NAME_CHARACTERS.indexOf(c) != -1) return false; } // check for invalid formatting characters (each dot-separated // part of the input string must be valid on its own) String[] parts = name.split("\\."); if (parts.length < 2) return false; for (int i = 0; i < parts.length; i++) { if (parts[i].length() == 0 || !StringValidityChecker.containsNoInvalidFormatting(parts[i])) return false; } // first part of name must be a recognized language code return (ISOLanguages.contains(parts[0])); } /** * @return The name. */ public String getName() { return mName; } public Date getFirstSeenDate() { return mFirstSeenDate; } public synchronized Date getLatestMessageDate() { return mLatestMessageDate; } public synchronized String getDescription(FTOwnIdentity viewer) { /* FIXME: Implement */ return ""; } /** * @return An NNTP-conform representation of the name of the board. */ /* public String getNameNNTP() { // FIXME: Implement. return mName; } */ /** * Compare boards by comparing their names; provided so we can * sort an array of boards. */ public int compareTo(Board b) { return getName().compareTo(b.getName()); } /** * Called by the FTMessageManager to add a just received message to the board. * The job for this function is to find the right place in the thread-tree for the new message and to move around older messages * if a parent message of them is received. */ public synchronized void addMessage(Message newMessage) { synchronized(mMessageManager) { if(newMessage instanceof OwnMessage) { /* We do not add the message to the boards it is posted to because the user should only see the message if it has been downloaded * successfully. This helps the user to spot problems: If he does not see his own messages we can hope that he reports a bug */ throw new IllegalArgumentException("Adding OwnMessages to a board is not allowed."); } newMessage.initializeTransient(db, mMessageManager); newMessage.store(); synchronized(BoardMessageLink.class) { new BoardMessageLink(this, newMessage, getFreeMessageIndex()).store(db); } if(!newMessage.isThread()) { Message parentThread = null; try { parentThread = findParentThread(newMessage).getMessage(); newMessage.setThread(parentThread); } catch(NoSuchMessageException e) {} try { newMessage.setParent(mMessageManager.get(newMessage.getParentID())); /* TODO: This allows crossposting. Figure out whether we need to handle it specially */ } catch(NoSuchMessageException e) {/* The message is an orphan */ if(parentThread == null) { /* The message is an absolute orphan */ /* * FIXME: The MessageManager should try to download the parent message if it's poster has enough trust. * If it is programmed to do that, it will check its Hashtable whether the parent message already exists. * We also do that here, therefore, when implementing parent message downloading, please do the Hashtable checking only once. */ } } } linkOrphansToNewParent(newMessage); if(mLatestMessageDate == null || newMessage.getDate().after(mLatestMessageDate)) mLatestMessageDate = newMessage.getDate(); } } /** * Assumes that the transient fields of the newMessage are initialized already. */ private synchronized void linkOrphansToNewParent(Message newMessage) { if(newMessage.isThread()) { Iterator absoluteOrphans = absoluteOrphanIterator(newMessage.getID()); while(absoluteOrphans.hasNext()) { /* Search in the absolute orphans for messages which belong to this thread */ Message orphan = absoluteOrphans.next(); orphan.setThread(newMessage); try { if(orphan.getParentURI().equals(newMessage.getURI())) orphan.setParent(newMessage); } catch (NoSuchMessageException e) { Logger.error(this, "Message is reply to thread but parentURI == null: " + orphan.getURI()); } } } else { try { Message parentThread = newMessage.getThread(); /* Search in its parent thread for its children */ for(Message parentThreadChild : parentThread.getChildren(this)) { try { if(parentThreadChild.getParentURI().equals(newMessage.getURI())) /* We found its parent, yeah! */ parentThreadChild.setParent(newMessage); /* It's a child of the newMessage, not of the parentThread */ } catch(NoSuchMessageException e) { Logger.error(this, "Message is reply to thread but parentURI == null: " + parentThreadChild.getURI()); } } } catch(NoSuchMessageException e) { /* The new message is an absolute orphan, find its children amongst the other absolute orphans */ Iterator absoluteOrphans = absoluteOrphanIterator(newMessage.getID()); while(absoluteOrphans.hasNext()){ /* Search in the orphans for messages which belong to this message */ Message orphan = absoluteOrphans.next(); /* * The following if() could be joined into the db4o query in absoluteOrphanIterator(). I did not do it because we could * cache the list of absolute orphans locally. */ try { if(orphan.getParentURI().equals(newMessage.getURI())) orphan.setParent(newMessage); } catch(NoSuchMessageException error) { Logger.error(this, "Should not happen", error); } } } } } /** * Finds the parent thread of a message in the database. The transient fields of the returned message will be initialized already. * @throws NoSuchMessageException */ @SuppressWarnings("unchecked") private synchronized MessageReference findParentThread(Message m) throws NoSuchMessageException { Query q = db.query(); q.constrain(BoardMessageLink.class); /* FIXME: This query has to be optimized. Maybe we should store the thread ID in the BoardMessageLink ? * Or we could first just query for message objects with the given ID (ignoring BoardMessageLinks!) and then query for a BoardMessageLink * which links the resulting message to the target board? - This could be sufficiently fast as the number of messages which are * posted to multiple boards will be very small. */ q.descend("mBoard").constrain(this).identity(); q.descend("mMessage").descend("mID").constrain(m.getThreadID()); ObjectSet parents = q.execute(); assert(parents.size() <= 1); if(parents.size() == 0) throw new NoSuchMessageException(m.getThreadID()); else { MessageReference parentThread = parents.next(); assert(parentThread.getMessage().getID().equals(m.getThreadID())); /* The query works */ /* Important: It is possible that we receive a message which has a parent thread URI specified, but the message at that URI is not * really a thread but just a reply to a thread. We MUST NOT return the thread which is specified as thread in the referred URI * instead because the thread ID of that one is different to the thread ID which is calculated from the false thread URI! * To explain it in other words: The given message has a wrong thread URI stored (caused by a malfunction in the client which * inserted the message), and the thread ID which is stored in that message is calculated from the URI, therefore it is also wrong. * If this function did return the "real" thread for the given message, then it would return false information, because the ID of the * returned thread would not match the ID which is stored in the given message. Further, we also cannot return the message which * is referred by the false URI because it is NOT a thread - other functions in Freetalk rely on the fact that messages which * are said to be threads are really threads. * - so that is the reason why the following if()-statement is necessary. * Notice that this comment was written after I figured out that messages are NOT being displayed if the if() is left out, so * please do not remove it in the future. */ if(parentThread.getMessage().isThread() == false) throw new NoSuchMessageException(); return parentThread; } } /** * Get all threads in the board. The view is specified to the FTOwnIdentity displaying it, therefore you have to pass one as parameter. * The transient fields of the returned messages will be initialized already. * @param identity The identity viewing the board. * @return An iterator of the message which the identity will see (based on its trust levels). */ @SuppressWarnings("unchecked") public synchronized Iterator threadIterator(final FTOwnIdentity identity) { return new Iterator() { private final FTOwnIdentity mIdentity = identity; private final Iterator iter; private MessageReference next; { /* FIXME: If db4o supports precompiled queries, this one should be stored precompiled. * Reason: We sort the threads by date. * Maybe we can just keep the Query-object and call q.execute() as many times as we like to? * Or somehow tell db4o to keep a per-board thread index which is sorted by Date? - This would be the best solution */ Query q = db.query(); q.constrain(BoardMessageLink.class); q.descend("mBoard").constrain(Board.this); q.descend("mMessage").descend("mThread").constrain(null).identity(); /* We require mParent to be null because this allows discussions where only the head message is missing to be displayed as * a single thread instead of displaying a bunch of single messages where each would appear to be a thread. */ q.descend("mMessage").descend("mParent").constrain(null).identity(); q.descend("mMessage").descend("mDate").orderDescending(); iter = q.execute().iterator(); next = iter.hasNext() ? iter.next() : null; } public boolean hasNext() { for(; next != null; next = iter.hasNext() ? iter.next() : null) { if(mIdentity.wantsMessagesFrom(next.getMessage().getAuthor())) return true; } return false; } public MessageReference next() { MessageReference result = hasNext() ? next : null; next = iter.hasNext() ? iter.next() : null; return result; } public void remove() { throw new UnsupportedOperationException(); } }; } /** * Get an iterator over messages for which the parent thread with the given ID was not known. * The transient fields of the returned messages will be initialized already. */ @SuppressWarnings("unchecked") private synchronized Iterator absoluteOrphanIterator(final String threadID) { return new Iterator() { private final Iterator iter; { /* FIXME: This query should be accelerated. The amount of absolute orphans is very small usually, so we should configure db4o * to keep a separate list of those. */ Query q = db.query(); q.constrain(BoardMessageLink.class); q.descend("mBoard").constrain(Board.this).identity(); q.descend("mMessage").descend("mThreadID").constrain(threadID); q.descend("mMessage").descend("mThread").constrain(null).identity(); iter = q.execute().iterator(); } public boolean hasNext() { return iter.hasNext(); } public Message next() { return iter.next().getMessage(); } public void remove() { throw new UnsupportedOperationException(); } }; } /* FIXME: This function returns all messages, not only the ones which the viewer wants to see. Convert the function to an iterator * which picks threads chosen by the viewer, see threadIterator() for how to do this */ @SuppressWarnings("unchecked") public synchronized List getAllMessages(final boolean sortByMessageIndexAscending) { Query q = db.query(); q.constrain(BoardMessageLink.class); q.descend("mBoard").constrain(this).identity(); if (sortByMessageIndexAscending) { q.descend("mMessageIndex").orderAscending(); /* Needed for NNTP */ } return q.execute(); } @SuppressWarnings("unchecked") public synchronized int getMessageIndex(Message message) throws NoSuchMessageException { Query q = db.query(); q.constrain(BoardMessageLink.class); q.descend("mMessage").constrain(message).identity(); ObjectSet result = q.execute(); if(result.size() == 0) throw new NoSuchMessageException(message.getID()); return result.next().getIndex(); } /* FIXME: This function counts all messages, not only the ones which the viewer wants to see. */ public synchronized int getLastMessageIndex() { return getFreeMessageIndex() - 1; } @SuppressWarnings("unchecked") public synchronized Message getMessageByIndex(int index) throws NoSuchMessageException { Query q = db.query(); q.constrain(BoardMessageLink.class); q.descend("mBoard").constrain(this).identity(); q.descend("mMessageIndex").constrain(index); ObjectSet result = q.execute(); if(result.size() == 0) throw new NoSuchMessageException(); return result.next().getMessage(); } @SuppressWarnings("unchecked") public synchronized List getMessagesByMinimumIndex( int minimumIndex, final boolean sortByMessageIndexAscending, final boolean sortByMessageDateAscending) { final Query q = db.query(); q.constrain(BoardMessageLink.class); q.descend("mBoard").constrain(this).identity(); if (minimumIndex > 0) { minimumIndex--; // db4o provides no greaterEqual(), so we do it this way q.descend("mMessageIndex").constrain(minimumIndex).greater(); } if (sortByMessageIndexAscending) { q.descend("mMessageIndex").orderAscending(); } if (sortByMessageDateAscending) { q.descend("mMessage").descend("mDate").orderAscending(); } return q.execute(); } @SuppressWarnings("unchecked") public synchronized List getMessagesByMinimumDate( long minimumDate, final boolean sortByMessageIndexAscending, final boolean sortByMessageDateAscending) { final Query q = db.query(); q.constrain(BoardMessageLink.class); q.descend("mBoard").constrain(this).identity(); if (minimumDate > 0) { minimumDate--; // db4o provides no greaterEqual(), so we do it this way q.descend("mMessage").descend("mDate").constrain(minimumDate).greater(); } if (sortByMessageIndexAscending) { q.descend("mMessageIndex").orderAscending(); } if (sortByMessageDateAscending) { q.descend("mMessage").descend("mDate").orderAscending(); } return q.execute(); } /** * Get the next free NNTP index for a message. Please synchronize on BoardMessageLink.class when creating a message, this method * does not and cannot provide synchronization as creating a message is no atomic operation. */ @SuppressWarnings("unchecked") private int getFreeMessageIndex() { Query q = db.query(); q.constrain(BoardMessageLink.class); q.descend("mBoard").constrain(this).identity(); q.descend("mMessageIndex").orderDescending(); /* FIXME: Use a db4o native query to find the maximum instead of sorting. O(n) vs. O(n log(n))! */ ObjectSet result = q.execute(); return result.size() == 0 ? 1 : result.next().getIndex()+1; } /** * Get the number of messages in this board. */ /* FIXME: This function counts all messages, not only the ones which the viewer wants to see. */ public synchronized int messageCount() { Query q = db.query(); q.constrain(BoardMessageLink.class); q.descend("mBoard").constrain(this).identity(); return q.execute().size(); } /** * Get the number of replies to the given thread. */ /* FIXME: This function counts all replies, not only the ones which the viewer wants to see. */ public synchronized int threadReplyCount(FTOwnIdentity viewer, Message thread) { return getAllThreadReplies(thread, false).size(); } /** * Get all replies to the given thread, sorted ascending by date if requested */ /* FIXME: This function returns all replies, not only the ones which the viewer wants to see. Convert the function to an iterator * which picks threads chosen by the viewer, see threadIterator() for how to do this */ @SuppressWarnings("unchecked") public synchronized List getAllThreadReplies(Message thread, final boolean sortByDateAscending) { Query q = db.query(); /* FIXME: This query is inefficient. It should rather first query for objects of Message.class which have mThreadID == thread.getID() * and then check whether a BoardMessageLink to this board exists. */ q.constrain(BoardMessageLink.class); q.descend("mBoard").constrain(this).identity(); q.descend("mMessage").constrain(thread).identity().not(); try { q.descend("mMessage").descend("mThreadID").constrain(thread.isThread() ? thread.getID() : thread.getThreadID()); /* FIXME: For some reason mParent seems to stay null on some thread replies even though their parent is present in the database! */ //q.descend("mMessage").descend("mParent").constrain(null).identity().not(); } catch (NoSuchMessageException e) { throw new RuntimeException( "Message is no thread but parentThreadURI == null : " + thread.getURI()); } if (sortByDateAscending) { q.descend("mMessage").descend("mDate").orderAscending(); } return q.execute(); } public interface MessageReference { /** Get the message to which this reference points */ public Message getMessage(); /** Get an unique index number of this message in the board where which the query for the message was executed. * This index number is needed for NNTP. */ public int getIndex(); } /** * Helper class to associate messages with boards in the database */ public final class BoardMessageLink implements MessageReference { /* TODO: This is only public for configuring db4o. Find a better way */ private final Board mBoard; private final Message mMessage; private final int mMessageIndex; /* TODO: The NNTP server should maintain the index values itself maybe. */ public BoardMessageLink(Board myBoard, Message myMessage, int myIndex) { assert(myBoard != null && myMessage != null); mBoard = myBoard; mMessage = myMessage; mMessageIndex = myIndex; } public void store(ExtObjectContainer localDb) { synchronized(db.lock()) { if(localDb.ext().isStored(this) && !localDb.ext().isActive(this)) throw new RuntimeException("Trying to store a non-active BoardMessageLink object"); localDb.store(this); localDb.commit(); } } public int getIndex() { return mMessageIndex; } public Message getMessage() { /* We do not have to initialize mBoard and can assume that it is initialized because a BoardMessageLink will only be loaded * by the board it belongs to. */ mMessage.initializeTransient(mBoard.db, mBoard.mMessageManager); db.activate(mMessage, 2); /* FIXME: Figure out a reasonable depth */ return mMessage; } } }