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