Merge branch 'next' into dev/image
authorDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Tue, 20 Sep 2011 20:03:35 +0000 (22:03 +0200)
committerDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Tue, 20 Sep 2011 20:03:35 +0000 (22:03 +0200)
21 files changed:
1  2 
pom.xml
src/main/java/net/pterodactylus/sone/core/Core.java
src/main/java/net/pterodactylus/sone/core/CoreListener.java
src/main/java/net/pterodactylus/sone/core/CoreListenerManager.java
src/main/java/net/pterodactylus/sone/core/FreenetInterface.java
src/main/java/net/pterodactylus/sone/core/SoneDownloader.java
src/main/java/net/pterodactylus/sone/core/SoneException.java
src/main/java/net/pterodactylus/sone/core/SoneInserter.java
src/main/java/net/pterodactylus/sone/data/Sone.java
src/main/java/net/pterodactylus/sone/web/CreateAlbumPage.java
src/main/java/net/pterodactylus/sone/web/DeleteAlbumPage.java
src/main/java/net/pterodactylus/sone/web/DeleteImagePage.java
src/main/java/net/pterodactylus/sone/web/EditAlbumPage.java
src/main/java/net/pterodactylus/sone/web/EditImagePage.java
src/main/java/net/pterodactylus/sone/web/GetImagePage.java
src/main/java/net/pterodactylus/sone/web/ImageBrowserPage.java
src/main/java/net/pterodactylus/sone/web/UploadImagePage.java
src/main/java/net/pterodactylus/sone/web/WebInterface.java
src/main/resources/i18n/sone.en.properties
src/main/resources/static/css/sone.css
src/main/resources/templates/viewSone.html

diff --cc pom.xml
+++ b/pom.xml
@@@ -7,7 -7,7 +7,7 @@@
                <dependency>
                        <groupId>net.pterodactylus</groupId>
                        <artifactId>utils</artifactId>
-                       <version>0.9.4-SNAPSHOT</version>
 -                      <version>0.10.0</version>
++                      <version>0.10.1-SNAPSHOT</version>
                </dependency>
                <dependency>
                        <groupId>junit</groupId>
@@@ -31,15 -34,14 +34,17 @@@ import java.util.logging.Logger
  import net.pterodactylus.sone.core.Options.DefaultOption;
  import net.pterodactylus.sone.core.Options.Option;
  import net.pterodactylus.sone.core.Options.OptionWatcher;
 +import net.pterodactylus.sone.data.Album;
  import net.pterodactylus.sone.data.Client;
 +import net.pterodactylus.sone.data.Image;
  import net.pterodactylus.sone.data.Post;
  import net.pterodactylus.sone.data.Profile;
--import net.pterodactylus.sone.data.Profile.Field;
  import net.pterodactylus.sone.data.Reply;
  import net.pterodactylus.sone.data.Sone;
 +import net.pterodactylus.sone.data.TemporaryImage;
++import net.pterodactylus.sone.data.Profile.Field;
+ import net.pterodactylus.sone.fcp.FcpInterface;
+ import net.pterodactylus.sone.fcp.FcpInterface.FullAccessRequired;
  import net.pterodactylus.sone.freenet.wot.Identity;
  import net.pterodactylus.sone.freenet.wot.IdentityListener;
  import net.pterodactylus.sone.freenet.wot.IdentityManager;
@@@ -60,7 -67,7 +70,7 @@@ import freenet.keys.FreenetURI
   *
   * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
   */
- public class Core implements IdentityListener, UpdateListener, ImageInsertListener {
 -public class Core extends AbstractService implements IdentityListener, UpdateListener, SoneProvider, PostProvider, SoneInsertListener {
++public class Core extends AbstractService implements IdentityListener, UpdateListener, SoneProvider, PostProvider, SoneInsertListener, ImageInsertListener {
  
        /**
         * Enumeration for the possible states of a {@link Sone}.
        /** The Sone downloader. */
        private final SoneDownloader soneDownloader;
  
 +      /** The image inserter. */
 +      private final ImageInserter imageInserter;
 +
+       /** Sone downloader thread-pool. */
+       private final ExecutorService soneDownloaders = Executors.newFixedThreadPool(10);
        /** The update checker. */
        private final UpdateChecker updateChecker;
  
        /** Trusted identities, sorted by own identities. */
        private Map<OwnIdentity, Set<Identity>> trustedIdentities = Collections.synchronizedMap(new HashMap<OwnIdentity, Set<Identity>>());
  
 +      /** All known albums. */
 +      private Map<String, Album> albums = new HashMap<String, Album>();
 +
 +      /** All known images. */
 +      private Map<String, Image> images = new HashMap<String, Image>();
 +
 +      /** All temporary images. */
 +      private Map<String, TemporaryImage> temporaryImages = new HashMap<String, TemporaryImage>();
 +
+       /** Ticker for threads that mark own elements as known. */
+       private Ticker localElementTicker = new Ticker();
+       /** The time the configuration was last touched. */
+       private volatile long lastConfigurationUpdate;
        /**
         * Creates a new core.
         *
                        friends.add(friendId);
                }
  
 +              /* load albums. */
 +              List<Album> topLevelAlbums = new ArrayList<Album>();
 +              int albumCounter = 0;
 +              while (true) {
 +                      String albumPrefix = sonePrefix + "/Albums/" + albumCounter++;
 +                      String albumId = configuration.getStringValue(albumPrefix + "/ID").getValue(null);
 +                      if (albumId == null) {
 +                              break;
 +                      }
 +                      String albumTitle = configuration.getStringValue(albumPrefix + "/Title").getValue(null);
 +                      String albumDescription = configuration.getStringValue(albumPrefix + "/Description").getValue(null);
 +                      String albumParentId = configuration.getStringValue(albumPrefix + "/Parent").getValue(null);
 +                      if ((albumTitle == null) || (albumDescription == null)) {
 +                              logger.log(Level.WARNING, "Invalid album found, aborting load!");
 +                              return;
 +                      }
 +                      Album album = getAlbum(albumId).setSone(sone).setTitle(albumTitle).setDescription(albumDescription);
 +                      if (albumParentId != null) {
 +                              Album parentAlbum = getAlbum(albumParentId, false);
 +                              if (parentAlbum == null) {
 +                                      logger.log(Level.WARNING, "Invalid parent album ID: " + albumParentId);
 +                                      return;
 +                              }
 +                              parentAlbum.addAlbum(album);
 +                      } else {
 +                              topLevelAlbums.add(album);
 +                      }
 +              }
 +
 +              /* load images. */
 +              int imageCounter = 0;
 +              while (true) {
 +                      String imagePrefix = sonePrefix + "/Images/" + imageCounter++;
 +                      String imageId = configuration.getStringValue(imagePrefix + "/ID").getValue(null);
 +                      if (imageId == null) {
 +                              break;
 +                      }
 +                      String albumId = configuration.getStringValue(imagePrefix + "/Album").getValue(null);
 +                      String key = configuration.getStringValue(imagePrefix + "/Key").getValue(null);
 +                      String title = configuration.getStringValue(imagePrefix + "/Title").getValue(null);
 +                      String description = configuration.getStringValue(imagePrefix + "/Description").getValue(null);
 +                      Long creationTime = configuration.getLongValue(imagePrefix + "/CreationTime").getValue(null);
 +                      Integer width = configuration.getIntValue(imagePrefix + "/Width").getValue(null);
 +                      Integer height = configuration.getIntValue(imagePrefix + "/Height").getValue(null);
 +                      if ((albumId == null) || (key == null) || (title == null) || (description == null) || (creationTime == null) || (width == null) || (height == null)) {
 +                              logger.log(Level.WARNING, "Invalid image found, aborting load!");
 +                              return;
 +                      }
 +                      Album album = getAlbum(albumId, false);
 +                      if (album == null) {
 +                              logger.log(Level.WARNING, "Invalid album image encountered, aborting load!");
 +                              return;
 +                      }
 +                      Image image = getImage(imageId).setSone(sone).setCreationTime(creationTime).setKey(key);
 +                      image.setTitle(title).setDescription(description).setWidth(width).setHeight(height);
 +                      album.addImage(image);
 +              }
 +
                /* load options. */
-               sone.getOptions().addBooleanOption("AutoFollow", new DefaultOption<Boolean>(false));
                sone.getOptions().getBooleanOption("AutoFollow").set(configuration.getBooleanValue(sonePrefix + "/Options/AutoFollow").getValue(null));
+               sone.getOptions().getBooleanOption("EnableSoneInsertNotifications").set(configuration.getBooleanValue(sonePrefix + "/Options/EnableSoneInsertNotifications").getValue(null));
  
                /* if we’re still here, Sone was loaded successfully. */
                synchronized (sone) {
        }
  
        /**
 +       * Creates a new top-level album for the given Sone.
 +       *
 +       * @param sone
 +       *            The Sone to create the album for
 +       * @return The new album
 +       */
 +      public Album createAlbum(Sone sone) {
 +              return createAlbum(sone, null);
 +      }
 +
 +      /**
 +       * Creates a new album for the given Sone.
 +       *
 +       * @param sone
 +       *            The Sone to create the album for
 +       * @param parent
 +       *            The parent of the album (may be {@code null} to create a
 +       *            top-level album)
 +       * @return The new album
 +       */
 +      public Album createAlbum(Sone sone, Album parent) {
 +              Album album = new Album();
 +              synchronized (albums) {
 +                      albums.put(album.getId(), album);
 +              }
 +              album.setSone(sone);
 +              if (parent != null) {
 +                      parent.addAlbum(album);
 +              } else {
 +                      sone.addAlbum(album);
 +              }
 +              return album;
 +      }
 +
 +      /**
 +       * Deletes the given album. The owner of the album has to be a local Sone,
 +       * and the album has to be {@link Album#isEmpty() empty} to be deleted.
 +       *
 +       * @param album
 +       *            The album to remove
 +       */
 +      public void deleteAlbum(Album album) {
 +              Validation.begin().isNotNull("Album", album).check().is("Local Sone", isLocalSone(album.getSone())).check();
 +              if (!album.isEmpty()) {
 +                      return;
 +              }
 +              if (album.getParent() == null) {
 +                      album.getSone().removeAlbum(album);
 +              } else {
 +                      album.getParent().removeAlbum(album);
 +              }
 +              synchronized (albums) {
 +                      albums.remove(album.getId());
 +              }
 +              saveSone(album.getSone());
 +      }
 +
 +      /**
 +       * Creates a new image.
 +       *
 +       * @param sone
 +       *            The Sone creating the image
 +       * @param album
 +       *            The album the image will be inserted into
 +       * @param temporaryImage
 +       *            The temporary image to create the image from
 +       * @return The newly created image
 +       */
 +      public Image createImage(Sone sone, Album album, TemporaryImage temporaryImage) {
 +              Validation.begin().isNotNull("Sone", sone).isNotNull("Album", album).isNotNull("Temporary Image", temporaryImage).check().is("Local Sone", isLocalSone(sone)).check().isEqual("Owner and Album Owner", sone, album.getSone()).check();
 +              Image image = new Image(temporaryImage.getId()).setSone(sone).setCreationTime(System.currentTimeMillis());
 +              album.addImage(image);
 +              synchronized (images) {
 +                      images.put(image.getId(), image);
 +              }
 +              imageInserter.insertImage(temporaryImage, image);
 +              return image;
 +      }
 +
 +      /**
 +       * Deletes the given image. This method will also delete a matching
 +       * temporary image.
 +       *
 +       * @see #deleteTemporaryImage(TemporaryImage)
 +       * @param image
 +       *            The image to delete
 +       */
 +      public void deleteImage(Image image) {
 +              Validation.begin().isNotNull("Image", image).check().is("Local Sone", isLocalSone(image.getSone())).check();
 +              deleteTemporaryImage(image.getId());
 +              image.getAlbum().removeImage(image);
 +              synchronized (images) {
 +                      images.remove(image.getId());
 +              }
 +              saveSone(image.getSone());
 +      }
 +
 +      /**
 +       * Creates a new temporary image.
 +       *
 +       * @param mimeType
 +       *            The MIME type of the temporary image
 +       * @param imageData
 +       *            The encoded data of the image
 +       * @return The temporary image
 +       */
 +      public TemporaryImage createTemporaryImage(String mimeType, byte[] imageData) {
 +              TemporaryImage temporaryImage = new TemporaryImage();
 +              temporaryImage.setMimeType(mimeType).setImageData(imageData);
 +              synchronized (temporaryImages) {
 +                      temporaryImages.put(temporaryImage.getId(), temporaryImage);
 +              }
 +              return temporaryImage;
 +      }
 +
 +      /**
 +       * Deletes the given temporary image.
 +       *
 +       * @param temporaryImage
 +       *            The temporary image to delete
 +       */
 +      public void deleteTemporaryImage(TemporaryImage temporaryImage) {
 +              Validation.begin().isNotNull("Temporary Image", temporaryImage).check();
 +              deleteTemporaryImage(temporaryImage.getId());
 +      }
 +
 +      /**
 +       * Deletes the temporary image with the given ID.
 +       *
 +       * @param imageId
 +       *            The ID of the temporary image to delete
 +       */
 +      public void deleteTemporaryImage(String imageId) {
 +              Validation.begin().isNotNull("Temporary Image ID", imageId).check();
 +              synchronized (temporaryImages) {
 +                      temporaryImages.remove(imageId);
 +              }
 +              Image image = getImage(imageId, false);
 +              if (image != null) {
 +                      imageInserter.cancelImageInsert(image);
 +              }
 +      }
 +
 +      /**
+        * Notifies the core that the configuration, either of the core or of a
+        * single local Sone, has changed, and that the configuration should be
+        * saved.
+        */
+       public void touchConfiguration() {
+               lastConfigurationUpdate = System.currentTimeMillis();
+       }
+       //
+       // SERVICE METHODS
+       //
+       /**
         * Starts the core.
         */
-       public void start() {
+       @Override
+       public void serviceStart() {
                loadConfiguration();
                updateChecker.addUpdateListener(this);
                updateChecker.start();
                updateChecker.stop();
                updateChecker.removeUpdateListener(this);
                soneDownloader.stop();
-               saveConfiguration();
-               stopped = true;
+       }
+       //
+       // PRIVATE METHODS
+       //
+       /**
+        * Saves the given Sone. This will persist all local settings for the given
+        * Sone, such as the friends list and similar, private options.
+        *
+        * @param sone
+        *            The Sone to save
+        */
+       private synchronized void saveSone(Sone sone) {
+               if (!isLocalSone(sone)) {
+                       logger.log(Level.FINE, "Tried to save non-local Sone: %s", sone);
+                       return;
+               }
+               if (!(sone.getIdentity() instanceof OwnIdentity)) {
+                       logger.log(Level.WARNING, "Local Sone without OwnIdentity found, refusing to save: %s", sone);
+                       return;
+               }
+               logger.log(Level.INFO, "Saving Sone: %s", sone);
+               try {
+                       ((OwnIdentity) sone.getIdentity()).setProperty("Sone.LatestEdition", String.valueOf(sone.getLatestEdition()));
+                       /* save Sone into configuration. */
+                       String sonePrefix = "Sone/" + sone.getId();
+                       configuration.getLongValue(sonePrefix + "/Time").setValue(sone.getTime());
+                       configuration.getStringValue(sonePrefix + "/LastInsertFingerprint").setValue(soneInserters.get(sone).getLastInsertFingerprint());
+                       /* save profile. */
+                       Profile profile = sone.getProfile();
+                       configuration.getStringValue(sonePrefix + "/Profile/FirstName").setValue(profile.getFirstName());
+                       configuration.getStringValue(sonePrefix + "/Profile/MiddleName").setValue(profile.getMiddleName());
+                       configuration.getStringValue(sonePrefix + "/Profile/LastName").setValue(profile.getLastName());
+                       configuration.getIntValue(sonePrefix + "/Profile/BirthDay").setValue(profile.getBirthDay());
+                       configuration.getIntValue(sonePrefix + "/Profile/BirthMonth").setValue(profile.getBirthMonth());
+                       configuration.getIntValue(sonePrefix + "/Profile/BirthYear").setValue(profile.getBirthYear());
+                       /* save profile fields. */
+                       int fieldCounter = 0;
+                       for (Field profileField : profile.getFields()) {
+                               String fieldPrefix = sonePrefix + "/Profile/Fields/" + fieldCounter++;
+                               configuration.getStringValue(fieldPrefix + "/Name").setValue(profileField.getName());
+                               configuration.getStringValue(fieldPrefix + "/Value").setValue(profileField.getValue());
+                       }
+                       configuration.getStringValue(sonePrefix + "/Profile/Fields/" + fieldCounter + "/Name").setValue(null);
+                       /* save posts. */
+                       int postCounter = 0;
+                       for (Post post : sone.getPosts()) {
+                               String postPrefix = sonePrefix + "/Posts/" + postCounter++;
+                               configuration.getStringValue(postPrefix + "/ID").setValue(post.getId());
+                               configuration.getStringValue(postPrefix + "/Recipient").setValue((post.getRecipient() != null) ? post.getRecipient().getId() : null);
+                               configuration.getLongValue(postPrefix + "/Time").setValue(post.getTime());
+                               configuration.getStringValue(postPrefix + "/Text").setValue(post.getText());
+                       }
+                       configuration.getStringValue(sonePrefix + "/Posts/" + postCounter + "/ID").setValue(null);
+                       /* save replies. */
+                       int replyCounter = 0;
+                       for (Reply reply : sone.getReplies()) {
+                               String replyPrefix = sonePrefix + "/Replies/" + replyCounter++;
+                               configuration.getStringValue(replyPrefix + "/ID").setValue(reply.getId());
+                               configuration.getStringValue(replyPrefix + "/Post/ID").setValue(reply.getPost().getId());
+                               configuration.getLongValue(replyPrefix + "/Time").setValue(reply.getTime());
+                               configuration.getStringValue(replyPrefix + "/Text").setValue(reply.getText());
+                       }
+                       configuration.getStringValue(sonePrefix + "/Replies/" + replyCounter + "/ID").setValue(null);
+                       /* save post likes. */
+                       int postLikeCounter = 0;
+                       for (String postId : sone.getLikedPostIds()) {
+                               configuration.getStringValue(sonePrefix + "/Likes/Post/" + postLikeCounter++ + "/ID").setValue(postId);
+                       }
+                       configuration.getStringValue(sonePrefix + "/Likes/Post/" + postLikeCounter + "/ID").setValue(null);
+                       /* save reply likes. */
+                       int replyLikeCounter = 0;
+                       for (String replyId : sone.getLikedReplyIds()) {
+                               configuration.getStringValue(sonePrefix + "/Likes/Reply/" + replyLikeCounter++ + "/ID").setValue(replyId);
+                       }
+                       configuration.getStringValue(sonePrefix + "/Likes/Reply/" + replyLikeCounter + "/ID").setValue(null);
+                       /* save friends. */
+                       int friendCounter = 0;
+                       for (String friendId : sone.getFriends()) {
+                               configuration.getStringValue(sonePrefix + "/Friends/" + friendCounter++ + "/ID").setValue(friendId);
+                       }
+                       configuration.getStringValue(sonePrefix + "/Friends/" + friendCounter + "/ID").setValue(null);
++                      /* save albums. first, collect in a flat structure, top-level first. */
++                      List<Album> albums = Sone.flattenAlbums(sone.getAlbums());
++
++                      int albumCounter = 0;
++                      for (Album album : albums) {
++                              String albumPrefix = sonePrefix + "/Albums/" + albumCounter++;
++                              configuration.getStringValue(albumPrefix + "/ID").setValue(album.getId());
++                              configuration.getStringValue(albumPrefix + "/Title").setValue(album.getTitle());
++                              configuration.getStringValue(albumPrefix + "/Description").setValue(album.getDescription());
++                              configuration.getStringValue(albumPrefix + "/Parent").setValue(album.getParent() == null ? null : album.getParent().getId());
++                      }
++                      configuration.getStringValue(sonePrefix + "/Albums/" + albumCounter + "/ID").setValue(null);
++
++                      /* save images. */
++                      int imageCounter = 0;
++                      for (Album album : albums) {
++                              for (Image image : album.getImages()) {
++                                      if (!image.isInserted()) {
++                                              continue;
++                                      }
++                                      String imagePrefix = sonePrefix + "/Images/" + imageCounter++;
++                                      configuration.getStringValue(imagePrefix + "/ID").setValue(image.getId());
++                                      configuration.getStringValue(imagePrefix + "/Album").setValue(album.getId());
++                                      configuration.getStringValue(imagePrefix + "/Key").setValue(image.getKey());
++                                      configuration.getStringValue(imagePrefix + "/Title").setValue(image.getTitle());
++                                      configuration.getStringValue(imagePrefix + "/Description").setValue(image.getDescription());
++                                      configuration.getLongValue(imagePrefix + "/CreationTime").setValue(image.getCreationTime());
++                                      configuration.getIntValue(imagePrefix + "/Width").setValue(image.getWidth());
++                                      configuration.getIntValue(imagePrefix + "/Height").setValue(image.getHeight());
++                              }
++                      }
++                      configuration.getStringValue(sonePrefix + "/Images/" + imageCounter + "/ID").setValue(null);
++
+                       /* save options. */
+                       configuration.getBooleanValue(sonePrefix + "/Options/AutoFollow").setValue(sone.getOptions().getBooleanOption("AutoFollow").getReal());
+                       configuration.getBooleanValue(sonePrefix + "/Options/EnableSoneInsertNotifications").setValue(sone.getOptions().getBooleanOption("EnableSoneInsertNotifications").getReal());
+                       configuration.save();
+                       logger.log(Level.INFO, "Sone %s saved.", sone);
+               } catch (ConfigurationException ce1) {
+                       logger.log(Level.WARNING, "Could not save Sone: " + sone, ce1);
+               } catch (WebOfTrustException wote1) {
+                       logger.log(Level.WARNING, "Could not set WoT property for Sone: " + sone, wote1);
+               }
        }
  
        /**
        /**
         * {@inheritDoc}
         */
 +      @Override
+       public void insertStarted(Sone sone) {
+               coreListenerManager.fireSoneInserting(sone);
+       }
+       /**
+        * {@inheritDoc}
+        */
+       @Override
+       public void insertFinished(Sone sone, long insertDuration) {
+               coreListenerManager.fireSoneInserted(sone, insertDuration);
+       }
+       /**
+        * {@inheritDoc}
+        */
+       @Override
+       public void insertAborted(Sone sone, Throwable cause) {
+               coreListenerManager.fireSoneInsertAborted(sone, cause);
+       }
++      //
++      // SONEINSERTLISTENER METHODS
++      //
++
++      /**
++       * {@inheritDoc}
++       */
++      @Override
 +      public void imageInsertStarted(Image image) {
 +              logger.log(Level.WARNING, "Image insert started for " + image);
 +              coreListenerManager.fireImageInsertStarted(image);
 +      }
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
 +      public void imageInsertAborted(Image image) {
 +              logger.log(Level.WARNING, "Image insert aborted for " + image);
 +              coreListenerManager.fireImageInsertAborted(image);
 +      }
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
 +      public void imageInsertFinished(Image image, FreenetURI key) {
 +              logger.log(Level.WARNING, "Image insert finished for " + image + ": " + key);
 +              image.setKey(key.toString());
 +              deleteTemporaryImage(image.getId());
 +              saveSone(image.getSone());
 +              coreListenerManager.fireImageInsertFinished(image);
 +      }
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
 +      public void imageInsertFailed(Image image, Throwable cause) {
 +              logger.log(Level.WARNING, "Image insert failed for " + image, cause);
 +              coreListenerManager.fireImageInsertFailed(image, cause);
 +      }
 +
        /**
         * Convenience interface for external classes that want to access the core’s
         * configuration.
@@@ -26,11 -25,8 +26,10 @@@ import java.util.Set
  import java.util.logging.Level;
  import java.util.logging.Logger;
  
- import net.pterodactylus.sone.core.Core.Preferences;
  import net.pterodactylus.sone.core.Core.SoneStatus;
 +import net.pterodactylus.sone.data.Album;
  import net.pterodactylus.sone.data.Client;
 +import net.pterodactylus.sone.data.Image;
  import net.pterodactylus.sone.data.Post;
  import net.pterodactylus.sone.data.Profile;
  import net.pterodactylus.sone.data.Reply;
@@@ -266,11 -298,10 +298,11 @@@ public class SoneInserter extends Abstr
                        soneProperties.put("requestUri", sone.getRequestUri());
                        soneProperties.put("insertUri", sone.getInsertUri());
                        soneProperties.put("profile", sone.getProfile());
-                       soneProperties.put("posts", new ArrayList<Post>(sone.getPosts()));
-                       soneProperties.put("replies", new HashSet<Reply>(sone.getReplies()));
+                       soneProperties.put("posts", new ListBuilder<Post>(new ArrayList<Post>(sone.getPosts())).sort(Post.TIME_COMPARATOR).get());
+                       soneProperties.put("replies", new ListBuilder<Reply>(new ArrayList<Reply>(sone.getReplies())).sort(new ReverseComparator<Reply>(Reply.TIME_COMPARATOR)).get());
                        soneProperties.put("likedPostIds", new HashSet<String>(sone.getLikedPostIds()));
                        soneProperties.put("likedReplyIds", new HashSet<String>(sone.getLikedReplyIds()));
 +                      soneProperties.put("albums", Sone.flattenAlbums(sone.getAlbums()));
                }
  
                //
index afe95b7,0000000..a695952
mode 100644,000000..100644
--- /dev/null
@@@ -1,72 -1,0 +1,73 @@@
- import net.pterodactylus.sone.web.page.Page.Request.Method;
 +/*
 + * Sone - CreateAlbumPage.java - Copyright © 2011 David Roden
 + *
 + * This program is free software: you can redistribute it and/or modify
 + * it under the terms of the GNU General Public License as published by
 + * the Free Software Foundation, either version 3 of the License, or
 + * (at your option) any later version.
 + *
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + *
 + * You should have received a copy of the GNU General Public License
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 + */
 +
 +package net.pterodactylus.sone.web;
 +
 +import net.pterodactylus.sone.data.Album;
 +import net.pterodactylus.sone.data.Sone;
-       protected void processTemplate(Request request, TemplateContext templateContext) throws RedirectException {
++import net.pterodactylus.sone.web.page.FreenetRequest;
 +import net.pterodactylus.util.template.Template;
 +import net.pterodactylus.util.template.TemplateContext;
++import net.pterodactylus.util.web.Method;
 +
 +/**
 + * Page that lets the user create a new album.
 + *
 + * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
 + */
 +public class CreateAlbumPage extends SoneTemplatePage {
 +
 +      /**
 +       * Creates a new “create album” page.
 +       *
 +       * @param template
 +       *            The template to render
 +       * @param webInterface
 +       *            The Sone web interface
 +       */
 +      public CreateAlbumPage(Template template, WebInterface webInterface) {
 +              super("createAlbum.html", template, "Page.CreateAlbum.Title", webInterface, true);
 +      }
 +
 +      //
 +      // SONETEMPLATEPAGE METHODS
 +      //
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
-                       webInterface.getCore().saveSone(currentSone);
++      protected void processTemplate(FreenetRequest request, TemplateContext templateContext) throws RedirectException {
 +              super.processTemplate(request, templateContext);
 +              if (request.getMethod() == Method.POST) {
 +                      String name = request.getHttpRequest().getPartAsStringFailsafe("name", 64).trim();
 +                      if (name.length() == 0) {
 +                              templateContext.set("nameMissing", true);
 +                              return;
 +                      }
 +                      String description = request.getHttpRequest().getPartAsStringFailsafe("description", 256).trim();
 +                      Sone currentSone = getCurrentSone(request.getToadletContext());
 +                      String parentId = request.getHttpRequest().getPartAsStringFailsafe("parent", 36);
 +                      Album parent = webInterface.getCore().getAlbum(parentId, false);
 +                      Album album = webInterface.getCore().createAlbum(currentSone, parent);
 +                      album.setTitle(name).setDescription(description);
++                      webInterface.getCore().touchConfiguration();
 +                      throw new RedirectException("imageBrowser.html?album=" + album.getId());
 +              }
 +      }
 +
 +}
index 6855e38,0000000..d03e065
mode 100644,000000..100644
--- /dev/null
@@@ -1,77 -1,0 +1,78 @@@
- import net.pterodactylus.sone.web.page.Page.Request.Method;
 +/*
 + * Sone - DeleteAlbumPage.java - Copyright © 2011 David Roden
 + *
 + * This program is free software: you can redistribute it and/or modify
 + * it under the terms of the GNU General Public License as published by
 + * the Free Software Foundation, either version 3 of the License, or
 + * (at your option) any later version.
 + *
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + *
 + * You should have received a copy of the GNU General Public License
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 + */
 +
 +package net.pterodactylus.sone.web;
 +
 +import net.pterodactylus.sone.data.Album;
-       protected void processTemplate(Request request, TemplateContext templateContext) throws RedirectException {
++import net.pterodactylus.sone.web.page.FreenetRequest;
 +import net.pterodactylus.util.template.Template;
 +import net.pterodactylus.util.template.TemplateContext;
++import net.pterodactylus.util.web.Method;
 +
 +/**
 + * Page that lets the user delete an {@link Album}.
 + *
 + * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
 + */
 +public class DeleteAlbumPage extends SoneTemplatePage {
 +
 +      /**
 +       * Creates a new “delete album” page.
 +       *
 +       * @param template
 +       *            The template to render
 +       * @param webInterface
 +       *            The Sone web interface
 +       */
 +      public DeleteAlbumPage(Template template, WebInterface webInterface) {
 +              super("deleteAlbum.html", template, "Page.DeleteAlbum.Title", webInterface, true);
 +      }
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
++      protected void processTemplate(FreenetRequest request, TemplateContext templateContext) throws RedirectException {
 +              super.processTemplate(request, templateContext);
 +              if (request.getMethod() == Method.POST) {
 +                      String albumId = request.getHttpRequest().getPartAsStringFailsafe("album", 36);
 +                      Album album = webInterface.getCore().getAlbum(albumId, false);
 +                      if (album == null) {
 +                              throw new RedirectException("invalid.html");
 +                      }
 +                      if (!webInterface.getCore().isLocalSone(album.getSone())) {
 +                              throw new RedirectException("noPermission.html");
 +                      }
 +                      if (request.getHttpRequest().isPartSet("abortDelete")) {
 +                              throw new RedirectException("imageBrowser.html?album=" + album.getId());
 +                      }
 +                      Album parentAlbum = album.getParent();
 +                      webInterface.getCore().deleteAlbum(album);
 +                      if (parentAlbum == null) {
 +                              throw new RedirectException("imageBrowser.html?sone=" + album.getSone().getId());
 +                      }
 +                      throw new RedirectException("imageBrowser.html?album=" + parentAlbum.getId());
 +              }
 +              String albumId = request.getHttpRequest().getParam("album");
 +              Album album = webInterface.getCore().getAlbum(albumId, false);
 +              if (album == null) {
 +                      throw new RedirectException("invalid.html");
 +              }
 +              templateContext.set("album", album);
 +      }
 +
 +}
index 999a38a,0000000..66098ff
mode 100644,000000..100644
--- /dev/null
@@@ -1,72 -1,0 +1,73 @@@
- import net.pterodactylus.sone.web.page.Page.Request.Method;
 +/*
 + * Sone - DeleteImagePage.java - Copyright © 2011 David Roden
 + *
 + * This program is free software: you can redistribute it and/or modify
 + * it under the terms of the GNU General Public License as published by
 + * the Free Software Foundation, either version 3 of the License, or
 + * (at your option) any later version.
 + *
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + *
 + * You should have received a copy of the GNU General Public License
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 + */
 +
 +package net.pterodactylus.sone.web;
 +
 +import net.pterodactylus.sone.data.Image;
-       protected void processTemplate(Request request, TemplateContext templateContext) throws RedirectException {
++import net.pterodactylus.sone.web.page.FreenetRequest;
 +import net.pterodactylus.util.template.Template;
 +import net.pterodactylus.util.template.TemplateContext;
++import net.pterodactylus.util.web.Method;
 +
 +/**
 + * Page that lets the user delete an {@link Image}.
 + *
 + * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
 + */
 +public class DeleteImagePage extends SoneTemplatePage {
 +
 +      /**
 +       * Creates a new “delete image” page.
 +       *
 +       * @param template
 +       *            The template to render
 +       * @param webInterface
 +       *            The Sone web interface
 +       */
 +      public DeleteImagePage(Template template, WebInterface webInterface) {
 +              super("deleteImage.html", template, "Page.DeleteImage.Title", webInterface, true);
 +      }
 +
 +      //
 +      // SONETEMPLATEPAGE METHODS
 +      //
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
++      protected void processTemplate(FreenetRequest request, TemplateContext templateContext) throws RedirectException {
 +              super.processTemplate(request, templateContext);
 +              String imageId = (request.getMethod() == Method.POST) ? request.getHttpRequest().getPartAsStringFailsafe("image", 36) : request.getHttpRequest().getParam("image");
 +              Image image = webInterface.getCore().getImage(imageId, false);
 +              if (image == null) {
 +                      throw new RedirectException("invalid.html");
 +              }
 +              if (!webInterface.getCore().isLocalSone(image.getSone())) {
 +                      throw new RedirectException("noPermission.html");
 +              }
 +              if (request.getMethod() == Method.POST) {
 +                      if (request.getHttpRequest().isPartSet("abortDelete")) {
 +                              throw new RedirectException("imageBrowser.html?image=" + image.getId());
 +                      }
 +                      webInterface.getCore().deleteImage(image);
 +                      throw new RedirectException("imageBrowser.html?album=" + image.getAlbum().getId());
 +              }
 +              templateContext.set("image", image);
 +      }
 +
 +}
index eaf8bf6,0000000..c3c2525
mode 100644,000000..100644
--- /dev/null
@@@ -1,69 -1,0 +1,70 @@@
- import net.pterodactylus.sone.web.page.Page.Request.Method;
 +/*
 + * Sone - EditAlbumPage.java - Copyright © 2011 David Roden
 + *
 + * This program is free software: you can redistribute it and/or modify
 + * it under the terms of the GNU General Public License as published by
 + * the Free Software Foundation, either version 3 of the License, or
 + * (at your option) any later version.
 + *
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + *
 + * You should have received a copy of the GNU General Public License
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 + */
 +
 +package net.pterodactylus.sone.web;
 +
 +import net.pterodactylus.sone.data.Album;
-       protected void processTemplate(Request request, TemplateContext templateContext) throws RedirectException {
++import net.pterodactylus.sone.web.page.FreenetRequest;
 +import net.pterodactylus.util.template.Template;
 +import net.pterodactylus.util.template.TemplateContext;
++import net.pterodactylus.util.web.Method;
 +
 +/**
 + * TODO
 + *
 + * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
 + */
 +public class EditAlbumPage extends SoneTemplatePage {
 +
 +      /**
 +       * TODO
 +       *
 +       * @param template
 +       * @param webInterface
 +       */
 +      public EditAlbumPage(Template template, WebInterface webInterface) {
 +              super("editAlbum.html", template, "Page.EditAlbum.Title", webInterface, true);
 +      }
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
-                       webInterface.getCore().saveSone(album.getSone());
++      protected void processTemplate(FreenetRequest request, TemplateContext templateContext) throws RedirectException {
 +              super.processTemplate(request, templateContext);
 +              if (request.getMethod() == Method.POST) {
 +                      String albumId = request.getHttpRequest().getPartAsStringFailsafe("album", 36);
 +                      Album album = webInterface.getCore().getAlbum(albumId, false);
 +                      if (album == null) {
 +                              throw new RedirectException("invalid.html");
 +                      }
 +                      if (!webInterface.getCore().isLocalSone(album.getSone())) {
 +                              throw new RedirectException("noPermission.html");
 +                      }
 +                      String title = request.getHttpRequest().getPartAsStringFailsafe("title", 100).trim();
 +                      if (title.length() == 0) {
 +                              templateContext.set("titleMissing", true);
 +                              return;
 +                      }
 +                      String description = request.getHttpRequest().getPartAsStringFailsafe("description", 1000).trim();
 +                      album.setTitle(title).setDescription(description);
++                      webInterface.getCore().touchConfiguration();
 +                      throw new RedirectException("imageBrowser.html?album=" + album.getId());
 +              }
 +      }
 +
 +}
index eab3bb9,0000000..afb8787
mode 100644,000000..100644
--- /dev/null
@@@ -1,75 -1,0 +1,76 @@@
- import net.pterodactylus.sone.web.page.Page.Request.Method;
 +/*
 + * FreenetSone - WebInterface.java - Copyright © 2010 David Roden
 + *
 + * This program is free software: you can redistribute it and/or modify
 + * it under the terms of the GNU General Public License as published by
 + * the Free Software Foundation, either version 3 of the License, or
 + * (at your option) any later version.
 + *
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + *
 + * You should have received a copy of the GNU General Public License
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 + */
 +
 +package net.pterodactylus.sone.web;
 +
 +import net.pterodactylus.sone.data.Image;
-       protected void processTemplate(Request request, TemplateContext templateContext) throws RedirectException {
++import net.pterodactylus.sone.web.page.FreenetRequest;
 +import net.pterodactylus.util.template.Template;
 +import net.pterodactylus.util.template.TemplateContext;
++import net.pterodactylus.util.web.Method;
 +
 +/**
 + * Page that lets the user edit title and description of an {@link Image}.
 + *
 + * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
 + */
 +public class EditImagePage extends SoneTemplatePage {
 +
 +      /**
 +       * Creates a new “edit image” page.
 +       *
 +       * @param template
 +       *            The template to render
 +       * @param webInterface
 +       *            The Sone web interface
 +       */
 +      public EditImagePage(Template template, WebInterface webInterface) {
 +              super("editImage.html", template, "Page.EditImage.Title", webInterface, true);
 +      }
 +
 +      //
 +      // SONETEMPLATEPAGE METHODS
 +      //
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
-                       webInterface.getCore().saveSone(image.getSone());
++      protected void processTemplate(FreenetRequest request, TemplateContext templateContext) throws RedirectException {
 +              super.processTemplate(request, templateContext);
 +              if (request.getMethod() == Method.POST) {
 +                      String imageId = request.getHttpRequest().getPartAsStringFailsafe("image", 36);
 +                      Image image = webInterface.getCore().getImage(imageId, false);
 +                      if (image == null) {
 +                              throw new RedirectException("invalid.html");
 +                      }
 +                      if (!webInterface.getCore().isLocalSone(image.getSone())) {
 +                              throw new RedirectException("noPermission.html");
 +                      }
 +                      String title = request.getHttpRequest().getPartAsStringFailsafe("title", 100).trim();
 +                      String description = request.getHttpRequest().getPartAsStringFailsafe("description", 1024).trim();
 +                      if (title.length() == 0) {
 +                              templateContext.set("titleMissing", true);
 +                      }
 +                      image.setTitle(title);
 +                      image.setDescription(description);
++                      webInterface.getCore().touchConfiguration();
 +                      throw new RedirectException("imageBrowser.html?image=" + image.getId());
 +              }
 +      }
 +
 +}
index fdd72a8,0000000..29556c9
mode 100644,000000..100644
--- /dev/null
@@@ -1,64 -1,0 +1,77 @@@
- import net.pterodactylus.sone.web.page.Page;
 +/*
 + * Sone - GetImagePage.java - Copyright © 2011 David Roden
 + *
 + * This program is free software: you can redistribute it and/or modify
 + * it under the terms of the GNU General Public License as published by
 + * the Free Software Foundation, either version 3 of the License, or
 + * (at your option) any later version.
 + *
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + *
 + * You should have received a copy of the GNU General Public License
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 + */
 +
 +package net.pterodactylus.sone.web;
 +
++import java.io.IOException;
++
 +import net.pterodactylus.sone.data.TemporaryImage;
- public class GetImagePage implements Page {
++import net.pterodactylus.sone.web.page.FreenetRequest;
++import net.pterodactylus.util.web.Page;
++import net.pterodactylus.util.web.Response;
 +
 +/**
 + * Page that delivers a {@link TemporaryImage} to the browser.
 + *
 + * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
 + */
-       public Response handleRequest(Request request) {
++public class GetImagePage implements Page<FreenetRequest> {
 +
 +      /** The Sone web interface. */
 +      private final WebInterface webInterface;
 +
 +      /**
 +       * Creates a new “get image” page.
 +       *
 +       * @param webInterface
 +       *            The Sone web interface
 +       */
 +      public GetImagePage(WebInterface webInterface) {
 +              this.webInterface = webInterface;
 +      }
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
 +      public String getPath() {
 +              return "getImage.html";
 +      }
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
-                       return new Response(404, "Not found.", "text/plain; charset=utf-8", "");
++      public boolean isPrefixPage() {
++              return false;
++      }
++
++      /**
++       * {@inheritDoc}
++       */
++      @Override
++      public Response handleRequest(FreenetRequest request, Response response) throws IOException {
 +              String imageId = request.getHttpRequest().getParam("image");
 +              TemporaryImage temporaryImage = webInterface.getCore().getTemporaryImage(imageId);
 +              if (temporaryImage == null) {
-               return new Response(200, "OK", temporaryImage.getMimeType(), temporaryImage.getImageData()).setHeader("Content-Disposition", "attachment; filename=" + temporaryImage.getId() + "." + temporaryImage.getMimeType().substring(temporaryImage.getMimeType().lastIndexOf("/") + 1));
++                      return response.setStatusCode(404).setStatusText("Not found.").setContentType("text/html; charset=utf-8");
 +              }
++              String contentType= temporaryImage.getMimeType();
++              return response.setStatusCode(200).setStatusText("OK").setContentType(contentType).addHeader("Content-Disposition", "attachment; filename=" + temporaryImage.getId() + "." + contentType.substring(contentType.lastIndexOf('/') + 1)).write(temporaryImage.getImageData());
 +      }
 +
 +}
index 778b429,0000000..ed31283
mode 100644,000000..100644
--- /dev/null
@@@ -1,78 -1,0 +1,79 @@@
-       protected void processTemplate(Request request, TemplateContext templateContext) throws RedirectException {
 +/*
 + * Sone - ImageBrowserPage.java - Copyright © 2011 David Roden
 + *
 + * This program is free software: you can redistribute it and/or modify
 + * it under the terms of the GNU General Public License as published by
 + * the Free Software Foundation, either version 3 of the License, or
 + * (at your option) any later version.
 + *
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + *
 + * You should have received a copy of the GNU General Public License
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 + */
 +
 +package net.pterodactylus.sone.web;
 +
 +import net.pterodactylus.sone.data.Album;
 +import net.pterodactylus.sone.data.Image;
 +import net.pterodactylus.sone.data.Sone;
++import net.pterodactylus.sone.web.page.FreenetRequest;
 +import net.pterodactylus.util.template.Template;
 +import net.pterodactylus.util.template.TemplateContext;
 +
 +/**
 + * The image browser page is the entry page for the image management.
 + *
 + * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
 + */
 +public class ImageBrowserPage extends SoneTemplatePage {
 +
 +      /**
 +       * Creates a new image browser page.
 +       *
 +       * @param template
 +       *            The template to render
 +       * @param webInterface
 +       *            The Sone web interface
 +       */
 +      public ImageBrowserPage(Template template, WebInterface webInterface) {
 +              super("imageBrowser.html", template, "Page.ImageBrowser.Title", webInterface, true);
 +      }
 +
 +      //
 +      // SONETEMPLATEPAGE METHODS
 +      //
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
++      protected void processTemplate(FreenetRequest request, TemplateContext templateContext) throws RedirectException {
 +              super.processTemplate(request, templateContext);
 +              String albumId = request.getHttpRequest().getParam("album", null);
 +              if (albumId != null) {
 +                      Album album = webInterface.getCore().getAlbum(albumId, false);
 +                      templateContext.set("albumRequested", true);
 +                      templateContext.set("album", album);
 +                      return;
 +              }
 +              String imageId = request.getHttpRequest().getParam("image", null);
 +              if (imageId != null) {
 +                      Image image = webInterface.getCore().getImage(imageId, false);
 +                      templateContext.set("imageRequested", true);
 +                      templateContext.set("image", image);
 +                      return;
 +              }
 +              Sone sone = getCurrentSone(request.getToadletContext(), false);
 +              String soneId = request.getHttpRequest().getParam("sone", null);
 +              if (soneId != null) {
 +                      sone = webInterface.getCore().getSone(soneId, false);
 +              }
 +              templateContext.set("soneRequested", true);
 +              templateContext.set("sone", sone);
 +      }
 +
 +}
index 4d98179,0000000..e0aa1e1
mode 100644,000000..100644
--- /dev/null
@@@ -1,160 -1,0 +1,161 @@@
- import net.pterodactylus.sone.web.page.Page.Request.Method;
 +/*
 + * Sone - UploadImagePage.java - Copyright © 2011 David Roden
 + *
 + * This program is free software: you can redistribute it and/or modify
 + * it under the terms of the GNU General Public License as published by
 + * the Free Software Foundation, either version 3 of the License, or
 + * (at your option) any later version.
 + *
 + * This program is distributed in the hope that it will be useful,
 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 + * GNU General Public License for more details.
 + *
 + * You should have received a copy of the GNU General Public License
 + * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 + */
 +
 +package net.pterodactylus.sone.web;
 +
 +import java.awt.Image;
 +import java.io.ByteArrayInputStream;
 +import java.io.ByteArrayOutputStream;
 +import java.io.IOException;
 +import java.io.InputStream;
 +import java.util.Iterator;
 +import java.util.logging.Level;
 +import java.util.logging.Logger;
 +
 +import javax.imageio.ImageIO;
 +import javax.imageio.ImageReader;
 +import javax.imageio.stream.ImageInputStream;
 +
 +import net.pterodactylus.sone.data.Album;
 +import net.pterodactylus.sone.data.Sone;
 +import net.pterodactylus.sone.data.TemporaryImage;
-       protected void processTemplate(Request request, TemplateContext templateContext) throws RedirectException {
++import net.pterodactylus.sone.web.page.FreenetRequest;
 +import net.pterodactylus.util.io.Closer;
 +import net.pterodactylus.util.io.StreamCopier;
 +import net.pterodactylus.util.logging.Logging;
 +import net.pterodactylus.util.template.Template;
 +import net.pterodactylus.util.template.TemplateContext;
++import net.pterodactylus.util.web.Method;
 +import freenet.support.api.Bucket;
 +import freenet.support.api.HTTPUploadedFile;
 +
 +/**
 + * Page implementation that lets the user upload an image.
 + *
 + * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
 + */
 +public class UploadImagePage extends SoneTemplatePage {
 +
 +      /** The logger. */
 +      private static final Logger logger = Logging.getLogger(UploadImagePage.class);
 +
 +      /**
 +       * Creates a new “upload image” page.
 +       *
 +       * @param template
 +       *            The template to render
 +       * @param webInterface
 +       *            The Sone web interface
 +       */
 +      public UploadImagePage(Template template, WebInterface webInterface) {
 +              super("uploadImage.html", template, "Page.UploadImage.Title", webInterface, true);
 +      }
 +
 +      //
 +      // SONETEMPLATEPAGE METHODS
 +      //
 +
 +      /**
 +       * {@inheritDoc}
 +       */
 +      @Override
++      protected void processTemplate(FreenetRequest request, TemplateContext templateContext) throws RedirectException {
 +              super.processTemplate(request, templateContext);
 +              if (request.getMethod() == Method.POST) {
 +                      Sone currentSone = getCurrentSone(request.getToadletContext());
 +                      String parentId = request.getHttpRequest().getPartAsStringFailsafe("parent", 36);
 +                      Album parent = webInterface.getCore().getAlbum(parentId, false);
 +                      if (parent == null) {
 +                              /* TODO - signal error */
 +                              return;
 +                      }
 +                      if (!currentSone.equals(parent.getSone())) {
 +                              /* TODO - signal error. */
 +                              return;
 +                      }
 +                      String name = request.getHttpRequest().getPartAsStringFailsafe("title", 200);
 +                      String description = request.getHttpRequest().getPartAsStringFailsafe("description", 4000);
 +                      HTTPUploadedFile uploadedFile = request.getHttpRequest().getUploadedFile("image");
 +                      Bucket fileBucket = uploadedFile.getData();
 +                      InputStream imageInputStream = null;
 +                      ByteArrayOutputStream imageDataOutputStream = null;
 +                      net.pterodactylus.sone.data.Image image = null;
 +                      try {
 +                              imageInputStream = fileBucket.getInputStream();
 +                              /* TODO - check length */
 +                              imageDataOutputStream = new ByteArrayOutputStream((int) fileBucket.size());
 +                              StreamCopier.copy(imageInputStream, imageDataOutputStream);
 +                      } catch (IOException ioe1) {
 +                              logger.log(Level.WARNING, "Could not read uploaded image!", ioe1);
 +                              return;
 +                      } finally {
 +                              fileBucket.free();
 +                              Closer.close(imageInputStream);
 +                              Closer.close(imageDataOutputStream);
 +                      }
 +                      byte[] imageData = imageDataOutputStream.toByteArray();
 +                      ByteArrayInputStream imageDataInputStream = null;
 +                      Image uploadedImage = null;
 +                      try {
 +                              imageDataInputStream = new ByteArrayInputStream(imageData);
 +                              uploadedImage = ImageIO.read(imageDataInputStream);
 +                              if (uploadedImage == null) {
 +                                      templateContext.set("messages", webInterface.getL10n().getString("Page.UploadImage.Error.InvalidImage"));
 +                                      return;
 +                              }
 +                              String mimeType = getMimeType(imageData);
 +                              TemporaryImage temporaryImage = webInterface.getCore().createTemporaryImage(mimeType, imageData);
 +                              image = webInterface.getCore().createImage(currentSone, parent, temporaryImage);
 +                              image.setTitle(name).setDescription(description).setWidth(uploadedImage.getWidth(null)).setHeight(uploadedImage.getHeight(null));
 +                      } catch (IOException ioe1) {
 +                              logger.log(Level.WARNING, "Could not read uploaded image!", ioe1);
 +                              return;
 +                      } finally {
 +                              Closer.close(imageDataInputStream);
 +                              Closer.flush(uploadedImage);
 +                      }
 +                      throw new RedirectException("imageBrowser.html?album=" + parent.getId());
 +              }
 +      }
 +
 +      //
 +      // PRIVATE METHODS
 +      //
 +
 +      /**
 +       * Tries to detect the MIME type of the encoded image.
 +       *
 +       * @param imageData
 +       *            The encoded image
 +       * @return The MIME type of the image, or “application/octet-stream” if the
 +       *         image type could not be detected
 +       */
 +      private String getMimeType(byte[] imageData) {
 +              ByteArrayInputStream imageDataInputStream = new ByteArrayInputStream(imageData);
 +              try {
 +                      ImageInputStream imageInputStream = ImageIO.createImageInputStream(imageDataInputStream);
 +                      Iterator<ImageReader> imageReaders = ImageIO.getImageReaders(imageInputStream);
 +                      if (imageReaders.hasNext()) {
 +                              return imageReaders.next().getOriginatingProvider().getMIMETypes()[0];
 +                      }
 +              } catch (IOException ioe1) {
 +                      logger.log(Level.FINE, "Could not detect MIME type for image.", ioe1);
 +              }
 +              return "application/octet-stream";
 +      }
 +
 +}
@@@ -51,9 -49,7 +52,8 @@@ import net.pterodactylus.sone.template.
  import net.pterodactylus.sone.template.CssClassNameFilter;
  import net.pterodactylus.sone.template.HttpRequestAccessor;
  import net.pterodactylus.sone.template.IdentityAccessor;
 +import net.pterodactylus.sone.template.ImageLinkFilter;
  import net.pterodactylus.sone.template.JavascriptFilter;
- import net.pterodactylus.sone.template.NotificationManagerAccessor;
  import net.pterodactylus.sone.template.ParserFilter;
  import net.pterodactylus.sone.template.PostAccessor;
  import net.pterodactylus.sone.template.ReplyAccessor;
@@@ -121,10 -123,13 +127,13 @@@ import net.pterodactylus.util.template.
  import net.pterodactylus.util.template.XmlFilter;
  import net.pterodactylus.util.thread.Ticker;
  import net.pterodactylus.util.version.Version;
+ import net.pterodactylus.util.web.RedirectPage;
+ import net.pterodactylus.util.web.StaticPage;
+ import net.pterodactylus.util.web.TemplatePage;
  import freenet.clients.http.SessionManager;
--import freenet.clients.http.SessionManager.Session;
  import freenet.clients.http.ToadletContainer;
  import freenet.clients.http.ToadletContext;
++import freenet.clients.http.SessionManager.Session;
  import freenet.l10n.BaseL10n;
  import freenet.support.api.HTTPRequest;
  
@@@ -204,9 -210,7 +223,8 @@@ public class WebInterface implements Co
                templateContextFactory.addAccessor(Sone.class, new SoneAccessor(getCore()));
                templateContextFactory.addAccessor(Post.class, new PostAccessor(getCore()));
                templateContextFactory.addAccessor(Reply.class, new ReplyAccessor(getCore()));
 +              templateContextFactory.addAccessor(Album.class, new AlbumAccessor());
                templateContextFactory.addAccessor(Identity.class, new IdentityAccessor(getCore()));
-               templateContextFactory.addAccessor(NotificationManager.class, new NotificationManagerAccessor());
                templateContextFactory.addAccessor(Trust.class, new TrustAccessor());
                templateContextFactory.addAccessor(HTTPRequest.class, new HttpRequestAccessor());
                templateContextFactory.addFilter("date", new DateFilter());
                templateContextFactory.addFilter("unknown", new UnknownDateFilter(getL10n(), "View.Sone.Text.UnknownDate"));
                templateContextFactory.addFilter("format", new FormatFilter());
                templateContextFactory.addFilter("sort", new CollectionSortFilter());
 +              templateContextFactory.addFilter("image-link", new ImageLinkFilter(templateContextFactory));
                templateContextFactory.addFilter("replyGroup", new ReplyGroupFilter());
+               templateContextFactory.addFilter("in", new ContainsFilter());
+               templateContextFactory.addFilter("unique", new UniqueElementFilter());
                templateContextFactory.addProvider(Provider.TEMPLATE_CONTEXT_PROVIDER);
                templateContextFactory.addProvider(new ClassPathTemplateProvider());
+               templateContextFactory.addTemplateObject("webInterface", this);
                templateContextFactory.addTemplateObject("formPassword", formPassword);
  
                /* create notifications. */
                Template deletePostTemplate = TemplateParser.parse(createReader("/templates/deletePost.html"));
                Template deleteReplyTemplate = TemplateParser.parse(createReader("/templates/deleteReply.html"));
                Template deleteSoneTemplate = TemplateParser.parse(createReader("/templates/deleteSone.html"));
 +              Template imageBrowserTemplate = TemplateParser.parse(createReader("/templates/imageBrowser.html"));
 +              Template createAlbumTemplate = TemplateParser.parse(createReader("/templates/createAlbum.html"));
 +              Template deleteAlbumTemplate = TemplateParser.parse(createReader("/templates/deleteAlbum.html"));
 +              Template deleteImageTemplate = TemplateParser.parse(createReader("/templates/deleteImage.html"));
                Template noPermissionTemplate = TemplateParser.parse(createReader("/templates/noPermission.html"));
                Template optionsTemplate = TemplateParser.parse(createReader("/templates/options.html"));
+               Template rescueTemplate = TemplateParser.parse(createReader("/templates/rescue.html"));
                Template aboutTemplate = TemplateParser.parse(createReader("/templates/about.html"));
                Template invalidTemplate = TemplateParser.parse(createReader("/templates/invalid.html"));
                Template postTemplate = TemplateParser.parse(createReader("/templates/include/viewPost.html"));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new SoneTemplatePage("noPermission.html", noPermissionTemplate, "Page.NoPermission.Title", this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new DismissNotificationPage(emptyTemplate, this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new SoneTemplatePage("invalid.html", invalidTemplate, "Page.Invalid.Title", this)));
-               pageToadlets.add(pageToadletFactory.createPageToadlet(new StaticPage("css/", "/static/css/", "text/css")));
-               pageToadlets.add(pageToadletFactory.createPageToadlet(new StaticPage("javascript/", "/static/javascript/", "text/javascript")));
-               pageToadlets.add(pageToadletFactory.createPageToadlet(new StaticPage("images/", "/static/images/", "image/png")));
-               pageToadlets.add(pageToadletFactory.createPageToadlet(new TemplatePage("OpenSearch.xml", "application/opensearchdescription+xml", templateContextFactory, openSearchTemplate)));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new StaticPage<FreenetRequest>("css/", "/static/css/", "text/css")));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new StaticPage<FreenetRequest>("javascript/", "/static/javascript/", "text/javascript")));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new StaticPage<FreenetRequest>("images/", "/static/images/", "image/png")));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new TemplatePage<FreenetRequest>("OpenSearch.xml", "application/opensearchdescription+xml", templateContextFactory, openSearchTemplate)));
 +              pageToadlets.add(pageToadletFactory.createPageToadlet(new GetImagePage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new GetTranslationPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new GetStatusAjaxPage(this)));
+               pageToadlets.add(pageToadletFactory.createPageToadlet(new GetNotificationAjaxPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new DismissNotificationAjaxPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new CreatePostAjaxPage(this)));
                pageToadlets.add(pageToadletFactory.createPageToadlet(new CreateReplyAjaxPage(this)));
@@@ -136,8 -147,8 +149,9 @@@ Page.ViewSone.PostList.Title=Posts by {
  Page.ViewSone.PostList.Text.NoPostYet=This Sone has not yet posted anything.
  Page.ViewSone.Profile.Title=Profile
  Page.ViewSone.Profile.Label.Name=Name
- Page.ViewSone.Replies.Title=Replies to Posts
 +Page.ViewSone.Profile.Label.Albums=Albums
+ Page.ViewSone.Profile.Name.WoTLink=web of trust profile
+ Page.ViewSone.Replies.Title=Posts {sone} has replied to
  
  Page.ViewPost.Title=View Post - Sone
  Page.ViewPost.Page.Title=View Post by {sone}
@@@ -321,16 -300,8 +353,17 @@@ WebInterface.DefaultText.BirthMonth=Mon
  WebInterface.DefaultText.BirthYear=Year
  WebInterface.DefaultText.FieldName=Field name
  WebInterface.DefaultText.Option.InsertionDelay=Time to wait after a Sone is modified before insert (in seconds)
 +WebInterface.DefaultText.Search=What are you looking for?
 +WebInterface.DefaultText.CreateAlbum.Name=Album title
 +WebInterface.DefaultText.CreateAlbum.Description=Album description
 +WebInterface.DefaultText.EditAlbum.Title=Album title
 +WebInterface.DefaultText.EditAlbum.Description=Album description
 +WebInterface.DefaultText.UploadImage.Title=Image title
 +WebInterface.DefaultText.UploadImage.Description=Image description
 +WebInterface.DefaultText.EditImage.Title=Image title
 +WebInterface.DefaultText.EditImage.Description=Image description
  WebInterface.DefaultText.Option.PostsPerPage=Number of posts to show on a page
+ WebInterface.DefaultText.Option.CharactersPerPost=Number of characters per post after which to cut the post off
  WebInterface.DefaultText.Option.PositiveTrust=The positive trust to assign
  WebInterface.DefaultText.Option.NegativeTrust=The negative trust to assign
  WebInterface.DefaultText.Option.TrustComment=The comment to set in the web of trust
@@@ -360,6 -332,6 +393,9 @@@ Notification.SoneRescued.Text=The follo
  Notification.SoneRescued.Text.RememberToUnlock=Please remember to control the posts and replies you have given and don’t forget to unlock your Sones!
  Notification.LockedSones.Text=The following Sones have been locked for more than 5 minutes. Please check if you really want to keep these Sones locked:
  Notification.NewVersion.Text=Version {version} of the Sone plugin was found. Download it from USK@nwa8lHa271k2QvJ8aa0Ov7IHAV-DFOCFgmDt3X6BpCI,DuQSUZiI~agF8c-6tjsFFGuZ8eICrzWCILB60nT8KKo,AQACAAE/sone/{edition}​!
 +Notification.InsertingImages.Text=The following images are being inserted:
 +Notification.InsertedImages.Text=The following images have been inserted:
 +Notification.ImageInsertFailed.Text=The following images could not be inserted:
+ Notification.Mention.ShortText=You have been mentioned.
+ Notification.Mention.Text=You have been mentioned in the following posts:
+ Notification.SoneInsert.Duration={0,number} {0,choice,0#seconds|1#second|1<seconds}
@@@ -93,10 -97,20 +97,24 @@@ textarea 
        border: none;
  }
  
 +#sone .parsed {
 +      white-space: pre-wrap;
 +}
 +
+ #sone #main.offline {
+       opacity: 0.5;
+ }
+ #sone #offline-marker {
+       display: none;
+       position: fixed;
+       top: 2em;
+       right: 2em;
+       width: 128px;
+       height: 128px;
+       background-image: url("../images/sone-offline.png");
+ }
  #sone #notification-area {
        margin-top: 1em;
  }
  
                        <div class="profile-field">
                                <div class="name"><%= Page.ViewSone.Profile.Label.Name|l10n|html></div>
-                               <div class="value"><a href="/WebOfTrust/ShowIdentity?id=<% sone.id|html>"><% sone.niceName|html></a></div>
+                               <div class="value"><% sone.niceName|html> (<a href="/WebOfTrust/ShowIdentity?id=<% sone.id|html>"><%= Page.ViewSone.Profile.Name.WoTLink|l10n|html></a>)</div>
                        </div>
  
 +                      <%foreach sone.albums album>
 +                              <%first>
 +                                      <div class="profile-field">
 +                                              <div class="name"><%= Page.ViewSone.Profile.Label.Albums|l10n|html></div>
 +                                              <div class="value">
 +                              <%/first>
 +                                      <a href="imageBrowser.html?album=<%album.id|html>"><%album.title|html></a><%notlast>, <%/notlast>
 +                              <%last>
 +                                              </div>
 +                                      </div>
 +                              <%/last>
 +                      <%/foreach>
 +
                        <%foreach sone.profile.fields field>
                                <div class="profile-field">
                                        <div class="name"><% field.name|html></div>