Add callback interface to post build() method.
[Sone.git] / src / main / java / net / pterodactylus / sone / core / SoneDownloader.java
1 /*
2  * Sone - SoneDownloader.java - Copyright © 2010–2013 David Roden
3  *
4  * This program is free software: you can redistribute it and/or modify
5  * it under the terms of the GNU General Public License as published by
6  * the Free Software Foundation, either version 3 of the License, or
7  * (at your option) any later version.
8  *
9  * This program is distributed in the hope that it will be useful,
10  * but WITHOUT ANY WARRANTY; without even the implied warranty of
11  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12  * GNU General Public License for more details.
13  *
14  * You should have received a copy of the GNU General Public License
15  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
16  */
17
18 package net.pterodactylus.sone.core;
19
20 import static com.google.common.base.Optional.of;
21
22 import java.io.InputStream;
23 import java.net.MalformedURLException;
24 import java.util.HashSet;
25 import java.util.Map;
26 import java.util.Set;
27 import java.util.logging.Level;
28 import java.util.logging.Logger;
29
30 import net.pterodactylus.sone.core.FreenetInterface.Fetched;
31 import net.pterodactylus.sone.data.Album;
32 import net.pterodactylus.sone.data.Client;
33 import net.pterodactylus.sone.data.Image;
34 import net.pterodactylus.sone.data.Post;
35 import net.pterodactylus.sone.data.PostReply;
36 import net.pterodactylus.sone.data.Profile;
37 import net.pterodactylus.sone.data.Sone;
38 import net.pterodactylus.sone.data.Sone.SoneStatus;
39 import net.pterodactylus.sone.data.impl.DefaultSone;
40 import net.pterodactylus.sone.database.PostBuilder;
41 import net.pterodactylus.sone.database.PostBuilder.PostCreated;
42 import net.pterodactylus.sone.database.PostReplyBuilder;
43 import net.pterodactylus.util.io.Closer;
44 import net.pterodactylus.util.logging.Logging;
45 import net.pterodactylus.util.number.Numbers;
46 import net.pterodactylus.util.service.AbstractService;
47 import net.pterodactylus.util.xml.SimpleXML;
48 import net.pterodactylus.util.xml.XML;
49
50 import freenet.client.FetchResult;
51 import freenet.keys.FreenetURI;
52 import freenet.support.api.Bucket;
53
54 import com.google.common.base.Optional;
55 import com.google.common.collect.Maps;
56 import org.w3c.dom.Document;
57
58 /**
59  * The Sone downloader is responsible for download Sones as they are updated.
60  *
61  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
62  */
63 public class SoneDownloader extends AbstractService {
64
65         /** The logger. */
66         private static final Logger logger = Logging.getLogger(SoneDownloader.class);
67
68         /** The maximum protocol version. */
69         private static final int MAX_PROTOCOL_VERSION = 0;
70
71         /** The core. */
72         private final Core core;
73
74         /** The Freenet interface. */
75         private final FreenetInterface freenetInterface;
76
77         /** The sones to update. */
78         private final Set<Sone> sones = new HashSet<Sone>();
79
80         /**
81          * Creates a new Sone downloader.
82          *
83          * @param core
84          *            The core
85          * @param freenetInterface
86          *            The Freenet interface
87          */
88         public SoneDownloader(Core core, FreenetInterface freenetInterface) {
89                 super("Sone Downloader", false);
90                 this.core = core;
91                 this.freenetInterface = freenetInterface;
92         }
93
94         //
95         // ACTIONS
96         //
97
98         /**
99          * Adds the given Sone to the set of Sones that will be watched for updates.
100          *
101          * @param sone
102          *            The Sone to add
103          */
104         public void addSone(Sone sone) {
105                 if (!sones.add(sone)) {
106                         freenetInterface.unregisterUsk(sone);
107                 }
108                 freenetInterface.registerUsk(sone, this);
109         }
110
111         /**
112          * Removes the given Sone from the downloader.
113          *
114          * @param sone
115          *            The Sone to stop watching
116          */
117         public void removeSone(Sone sone) {
118                 if (sones.remove(sone)) {
119                         freenetInterface.unregisterUsk(sone);
120                 }
121         }
122
123         /**
124          * Fetches the updated Sone. This method is a callback method for
125          * {@link FreenetInterface#registerUsk(Sone, SoneDownloader)}.
126          *
127          * @param sone
128          *            The Sone to fetch
129          */
130         public void fetchSone(Sone sone) {
131                 fetchSone(sone, sone.getRequestUri().sskForUSK());
132         }
133
134         /**
135          * Fetches the updated Sone. This method can be used to fetch a Sone from a
136          * specific URI.
137          *
138          * @param sone
139          *            The Sone to fetch
140          * @param soneUri
141          *            The URI to fetch the Sone from
142          */
143         public void fetchSone(Sone sone, FreenetURI soneUri) {
144                 fetchSone(sone, soneUri, false);
145         }
146
147         /**
148          * Fetches the Sone from the given URI.
149          *
150          * @param sone
151          *            The Sone to fetch
152          * @param soneUri
153          *            The URI of the Sone to fetch
154          * @param fetchOnly
155          *            {@code true} to only fetch and parse the Sone, {@code false}
156          *            to {@link Core#updateSone(Sone) update} it in the core
157          * @return The downloaded Sone, or {@code null} if the Sone could not be
158          *         downloaded
159          */
160         public Sone fetchSone(Sone sone, FreenetURI soneUri, boolean fetchOnly) {
161                 logger.log(Level.FINE, String.format("Starting fetch for Sone “%s” from %s…", sone, soneUri));
162                 FreenetURI requestUri = soneUri.setMetaString(new String[] { "sone.xml" });
163                 sone.setStatus(SoneStatus.downloading);
164                 try {
165                         Fetched fetchResults = freenetInterface.fetchUri(requestUri);
166                         if (fetchResults == null) {
167                                 /* TODO - mark Sone as bad. */
168                                 return null;
169                         }
170                         logger.log(Level.FINEST, String.format("Got %d bytes back.", fetchResults.getFetchResult().size()));
171                         Sone parsedSone = parseSone(sone, fetchResults.getFetchResult(), fetchResults.getFreenetUri());
172                         if (parsedSone != null) {
173                                 if (!fetchOnly) {
174                                         parsedSone.setStatus((parsedSone.getTime() == 0) ? SoneStatus.unknown : SoneStatus.idle);
175                                         core.updateSone(parsedSone);
176                                         addSone(parsedSone);
177                                 }
178                         }
179                         return parsedSone;
180                 } finally {
181                         sone.setStatus((sone.getTime() == 0) ? SoneStatus.unknown : SoneStatus.idle);
182                 }
183         }
184
185         /**
186          * Parses a Sone from a fetch result.
187          *
188          * @param originalSone
189          *            The sone to parse, or {@code null} if the Sone is yet unknown
190          * @param fetchResult
191          *            The fetch result
192          * @param requestUri
193          *            The requested URI
194          * @return The parsed Sone, or {@code null} if the Sone could not be parsed
195          */
196         public Sone parseSone(Sone originalSone, FetchResult fetchResult, FreenetURI requestUri) {
197                 logger.log(Level.FINEST, String.format("Parsing FetchResult (%d bytes, %s) for %s…", fetchResult.size(), fetchResult.getMimeType(), originalSone));
198                 Bucket soneBucket = fetchResult.asBucket();
199                 InputStream soneInputStream = null;
200                 try {
201                         soneInputStream = soneBucket.getInputStream();
202                         Sone parsedSone = parseSone(originalSone, soneInputStream);
203                         if (parsedSone != null) {
204                                 parsedSone.setLatestEdition(requestUri.getEdition());
205                                 if (requestUri.getKeyType().equals("USK")) {
206                                         parsedSone.setRequestUri(requestUri.setMetaString(new String[0]));
207                                 } else {
208                                         parsedSone.setRequestUri(requestUri.setKeyType("USK").setDocName("Sone").setMetaString(new String[0]));
209                                 }
210                         }
211                         return parsedSone;
212                 } catch (Exception e1) {
213                         logger.log(Level.WARNING, String.format("Could not parse Sone from %s!", requestUri), e1);
214                 } finally {
215                         Closer.close(soneInputStream);
216                         soneBucket.free();
217                 }
218                 return null;
219         }
220
221         /**
222          * Parses a Sone from the given input stream and creates a new Sone from the
223          * parsed data.
224          *
225          * @param originalSone
226          *            The Sone to update
227          * @param soneInputStream
228          *            The input stream to parse the Sone from
229          * @return The parsed Sone
230          * @throws SoneException
231          *             if a parse error occurs, or the protocol is invalid
232          */
233         public Sone parseSone(Sone originalSone, InputStream soneInputStream) throws SoneException {
234                 /* TODO - impose a size limit? */
235
236                 Document document;
237                 /* XML parsing is not thread-safe. */
238                 synchronized (this) {
239                         document = XML.transformToDocument(soneInputStream);
240                 }
241                 if (document == null) {
242                         /* TODO - mark Sone as bad. */
243                         logger.log(Level.WARNING, String.format("Could not parse XML for Sone %s!", originalSone));
244                         return null;
245                 }
246
247                 Sone sone = new DefaultSone(core.getDatabase(), originalSone.getId(), originalSone.isLocal()).setIdentity(originalSone.getIdentity());
248
249                 SimpleXML soneXml;
250                 try {
251                         soneXml = SimpleXML.fromDocument(document);
252                 } catch (NullPointerException npe1) {
253                         /* for some reason, invalid XML can cause NPEs. */
254                         logger.log(Level.WARNING, String.format("XML for Sone %s can not be parsed!", sone), npe1);
255                         return null;
256                 }
257
258                 Integer protocolVersion = null;
259                 String soneProtocolVersion = soneXml.getValue("protocol-version", null);
260                 if (soneProtocolVersion != null) {
261                         protocolVersion = Numbers.safeParseInteger(soneProtocolVersion);
262                 }
263                 if (protocolVersion == null) {
264                         logger.log(Level.INFO, "No protocol version found, assuming 0.");
265                         protocolVersion = 0;
266                 }
267
268                 if (protocolVersion < 0) {
269                         logger.log(Level.WARNING, String.format("Invalid protocol version: %d! Not parsing Sone.", protocolVersion));
270                         return null;
271                 }
272
273                 /* check for valid versions. */
274                 if (protocolVersion > MAX_PROTOCOL_VERSION) {
275                         logger.log(Level.WARNING, String.format("Unknown protocol version: %d! Not parsing Sone.", protocolVersion));
276                         return null;
277                 }
278
279                 String soneTime = soneXml.getValue("time", null);
280                 if (soneTime == null) {
281                         /* TODO - mark Sone as bad. */
282                         logger.log(Level.WARNING, String.format("Downloaded time for Sone %s was null!", sone));
283                         return null;
284                 }
285                 try {
286                         sone.setTime(Long.parseLong(soneTime));
287                 } catch (NumberFormatException nfe1) {
288                         /* TODO - mark Sone as bad. */
289                         logger.log(Level.WARNING, String.format("Downloaded Sone %s with invalid time: %s", sone, soneTime));
290                         return null;
291                 }
292
293                 SimpleXML clientXml = soneXml.getNode("client");
294                 if (clientXml != null) {
295                         String clientName = clientXml.getValue("name", null);
296                         String clientVersion = clientXml.getValue("version", null);
297                         if ((clientName == null) || (clientVersion == null)) {
298                                 logger.log(Level.WARNING, String.format("Download Sone %s with client XML but missing name or version!", sone));
299                                 return null;
300                         }
301                         sone.setClient(new Client(clientName, clientVersion));
302                 }
303
304                 String soneRequestUri = soneXml.getValue("request-uri", null);
305                 if (soneRequestUri != null) {
306                         try {
307                                 sone.setRequestUri(new FreenetURI(soneRequestUri));
308                         } catch (MalformedURLException mue1) {
309                                 /* TODO - mark Sone as bad. */
310                                 logger.log(Level.WARNING, String.format("Downloaded Sone %s has invalid request URI: %s", sone, soneRequestUri), mue1);
311                                 return null;
312                         }
313                 }
314
315                 if (originalSone.getInsertUri() != null) {
316                         sone.setInsertUri(originalSone.getInsertUri());
317                 }
318
319                 SimpleXML profileXml = soneXml.getNode("profile");
320                 if (profileXml == null) {
321                         /* TODO - mark Sone as bad. */
322                         logger.log(Level.WARNING, String.format("Downloaded Sone %s has no profile!", sone));
323                         return null;
324                 }
325
326                 /* parse profile. */
327                 String profileFirstName = profileXml.getValue("first-name", null);
328                 String profileMiddleName = profileXml.getValue("middle-name", null);
329                 String profileLastName = profileXml.getValue("last-name", null);
330                 Integer profileBirthDay = Numbers.safeParseInteger(profileXml.getValue("birth-day", null));
331                 Integer profileBirthMonth = Numbers.safeParseInteger(profileXml.getValue("birth-month", null));
332                 Integer profileBirthYear = Numbers.safeParseInteger(profileXml.getValue("birth-year", null));
333                 Profile profile = new Profile(sone).setFirstName(profileFirstName).setMiddleName(profileMiddleName).setLastName(profileLastName);
334                 profile.setBirthDay(profileBirthDay).setBirthMonth(profileBirthMonth).setBirthYear(profileBirthYear);
335                 /* avatar is processed after images are loaded. */
336                 String avatarId = profileXml.getValue("avatar", null);
337
338                 /* parse profile fields. */
339                 SimpleXML profileFieldsXml = profileXml.getNode("fields");
340                 if (profileFieldsXml != null) {
341                         for (SimpleXML fieldXml : profileFieldsXml.getNodes("field")) {
342                                 String fieldName = fieldXml.getValue("field-name", null);
343                                 String fieldValue = fieldXml.getValue("field-value", "");
344                                 if (fieldName == null) {
345                                         logger.log(Level.WARNING, String.format("Downloaded profile field for Sone %s with missing data! Name: %s, Value: %s", sone, fieldName, fieldValue));
346                                         return null;
347                                 }
348                                 try {
349                                         profile.addField(fieldName).setValue(fieldValue);
350                                 } catch (IllegalArgumentException iae1) {
351                                         logger.log(Level.WARNING, String.format("Duplicate field: %s", fieldName), iae1);
352                                         return null;
353                                 }
354                         }
355                 }
356
357                 /* parse posts. */
358                 SimpleXML postsXml = soneXml.getNode("posts");
359                 Set<Post> posts = new HashSet<Post>();
360                 if (postsXml == null) {
361                         /* TODO - mark Sone as bad. */
362                         logger.log(Level.WARNING, String.format("Downloaded Sone %s has no posts!", sone));
363                 } else {
364                         for (SimpleXML postXml : postsXml.getNodes("post")) {
365                                 String postId = postXml.getValue("id", null);
366                                 String postRecipientId = postXml.getValue("recipient", null);
367                                 String postTime = postXml.getValue("time", null);
368                                 String postText = postXml.getValue("text", null);
369                                 if ((postId == null) || (postTime == null) || (postText == null)) {
370                                         /* TODO - mark Sone as bad. */
371                                         logger.log(Level.WARNING, String.format("Downloaded post for Sone %s with missing data! ID: %s, Time: %s, Text: %s", sone, postId, postTime, postText));
372                                         return null;
373                                 }
374                                 try {
375                                         PostBuilder postBuilder = sone.newPostBuilder();
376                                         /* TODO - parse time correctly. */
377                                         postBuilder.withId(postId).withTime(Long.parseLong(postTime)).withText(postText);
378                                         if ((postRecipientId != null) && (postRecipientId.length() == 43)) {
379                                                 postBuilder.to(of(postRecipientId));
380                                         }
381                                         posts.add(postBuilder.build(Optional.<PostCreated>absent()));
382                                 } catch (NumberFormatException nfe1) {
383                                         /* TODO - mark Sone as bad. */
384                                         logger.log(Level.WARNING, String.format("Downloaded post for Sone %s with invalid time: %s", sone, postTime));
385                                         return null;
386                                 }
387                         }
388                 }
389
390                 /* parse replies. */
391                 SimpleXML repliesXml = soneXml.getNode("replies");
392                 Set<PostReply> replies = new HashSet<PostReply>();
393                 if (repliesXml == null) {
394                         /* TODO - mark Sone as bad. */
395                         logger.log(Level.WARNING, String.format("Downloaded Sone %s has no replies!", sone));
396                 } else {
397                         for (SimpleXML replyXml : repliesXml.getNodes("reply")) {
398                                 String replyId = replyXml.getValue("id", null);
399                                 String replyPostId = replyXml.getValue("post-id", null);
400                                 String replyTime = replyXml.getValue("time", null);
401                                 String replyText = replyXml.getValue("text", null);
402                                 if ((replyId == null) || (replyPostId == null) || (replyTime == null) || (replyText == null)) {
403                                         /* TODO - mark Sone as bad. */
404                                         logger.log(Level.WARNING, String.format("Downloaded reply for Sone %s with missing data! ID: %s, Post: %s, Time: %s, Text: %s", sone, replyId, replyPostId, replyTime, replyText));
405                                         return null;
406                                 }
407                                 try {
408                                         PostReplyBuilder postReplyBuilder = core.postReplyBuilder();
409                                         /* TODO - parse time correctly. */
410                                         postReplyBuilder.withId(replyId).from(sone.getId()).to(replyPostId).withTime(Long.parseLong(replyTime)).withText(replyText);
411                                         replies.add(postReplyBuilder.build());
412                                 } catch (NumberFormatException nfe1) {
413                                         /* TODO - mark Sone as bad. */
414                                         logger.log(Level.WARNING, String.format("Downloaded reply for Sone %s with invalid time: %s", sone, replyTime));
415                                         return null;
416                                 }
417                         }
418                 }
419
420                 /* parse liked post IDs. */
421                 SimpleXML likePostIdsXml = soneXml.getNode("post-likes");
422                 Set<String> likedPostIds = new HashSet<String>();
423                 if (likePostIdsXml == null) {
424                         /* TODO - mark Sone as bad. */
425                         logger.log(Level.WARNING, String.format("Downloaded Sone %s has no post likes!", sone));
426                 } else {
427                         for (SimpleXML likedPostIdXml : likePostIdsXml.getNodes("post-like")) {
428                                 String postId = likedPostIdXml.getValue();
429                                 likedPostIds.add(postId);
430                         }
431                 }
432
433                 /* parse liked reply IDs. */
434                 SimpleXML likeReplyIdsXml = soneXml.getNode("reply-likes");
435                 Set<String> likedReplyIds = new HashSet<String>();
436                 if (likeReplyIdsXml == null) {
437                         /* TODO - mark Sone as bad. */
438                         logger.log(Level.WARNING, String.format("Downloaded Sone %s has no reply likes!", sone));
439                 } else {
440                         for (SimpleXML likedReplyIdXml : likeReplyIdsXml.getNodes("reply-like")) {
441                                 String replyId = likedReplyIdXml.getValue();
442                                 likedReplyIds.add(replyId);
443                         }
444                 }
445
446                 /* parse albums. */
447                 SimpleXML albumsXml = soneXml.getNode("albums");
448                 Map<String, Album> albums = Maps.newHashMap();
449                 if (albumsXml != null) {
450                         for (SimpleXML albumXml : albumsXml.getNodes("album")) {
451                                 String id = albumXml.getValue("id", null);
452                                 String parentId = albumXml.getValue("parent", null);
453                                 String title = albumXml.getValue("title", null);
454                                 String description = albumXml.getValue("description", "");
455                                 String albumImageId = albumXml.getValue("album-image", null);
456                                 if ((id == null) || (title == null) || (description == null)) {
457                                         logger.log(Level.WARNING, String.format("Downloaded Sone %s contains invalid album!", sone));
458                                         return null;
459                                 }
460                                 Album parent = sone.getRootAlbum();
461                                 if (parentId != null) {
462                                         parent = albums.get(parentId);
463                                         if (parent == null) {
464                                                 logger.log(Level.WARNING, String.format("Downloaded Sone %s has album with invalid parent!", sone));
465                                                 return null;
466                                         }
467                                 }
468                                 Album album = parent.newAlbumBuilder().withId(id).build().modify().setTitle(title).setDescription(description).update();
469                                 albums.put(album.getId(), album);
470                                 SimpleXML imagesXml = albumXml.getNode("images");
471                                 if (imagesXml != null) {
472                                         for (SimpleXML imageXml : imagesXml.getNodes("image")) {
473                                                 String imageId = imageXml.getValue("id", null);
474                                                 String imageCreationTimeString = imageXml.getValue("creation-time", null);
475                                                 String imageKey = imageXml.getValue("key", null);
476                                                 String imageTitle = imageXml.getValue("title", null);
477                                                 String imageDescription = imageXml.getValue("description", "");
478                                                 String imageWidthString = imageXml.getValue("width", null);
479                                                 String imageHeightString = imageXml.getValue("height", null);
480                                                 if ((imageId == null) || (imageCreationTimeString == null) || (imageKey == null) || (imageTitle == null) || (imageWidthString == null) || (imageHeightString == null)) {
481                                                         logger.log(Level.WARNING, String.format("Downloaded Sone %s contains invalid images!", sone));
482                                                         return null;
483                                                 }
484                                                 long creationTime = Numbers.safeParseLong(imageCreationTimeString, 0L);
485                                                 int imageWidth = Numbers.safeParseInteger(imageWidthString, 0);
486                                                 int imageHeight = Numbers.safeParseInteger(imageHeightString, 0);
487                                                 if ((imageWidth < 1) || (imageHeight < 1)) {
488                                                         logger.log(Level.WARNING, String.format("Downloaded Sone %s contains image %s with invalid dimensions (%s, %s)!", sone, imageId, imageWidthString, imageHeightString));
489                                                         return null;
490                                                 }
491                                                 Image image = album.newImageBuilder().withId(imageId).at(imageKey).created(creationTime).sized(imageWidth, imageHeight).build();
492                                                 image = image.modify().setTitle(imageTitle).setDescription(imageDescription).update();
493                                         }
494                                 }
495                                 album.modify().setAlbumImage(albumImageId).update();
496                         }
497                 }
498
499                 /* process avatar. */
500                 if (avatarId != null) {
501                         profile.setAvatar(core.getImage(avatarId).orNull());
502                 }
503
504                 /* okay, apparently everything was parsed correctly. Now import. */
505                 /* atomic setter operation on the Sone. */
506                 synchronized (sone) {
507                         sone.setProfile(profile);
508                         sone.setPosts(posts);
509                         sone.setReplies(replies);
510                         sone.setLikePostIds(likedPostIds);
511                         sone.setLikeReplyIds(likedReplyIds);
512                 }
513
514                 return sone;
515         }
516
517         //
518         // SERVICE METHODS
519         //
520
521         /**
522          * {@inheritDoc}
523          */
524         @Override
525         protected void serviceStop() {
526                 for (Sone sone : sones) {
527                         freenetInterface.unregisterUsk(sone);
528                 }
529         }
530
531 }