001/**
002 * Copyright 2014 Tampere University of Technology, Pori Department
003 * 
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 * 
008 *   http://www.apache.org/licenses/LICENSE-2.0
009 * 
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package service.tut.pori.contentstorage;
017
018import java.io.Closeable;
019import java.io.IOException;
020import java.io.InputStream;
021import java.net.URI;
022import java.net.URISyntaxException;
023import java.util.ArrayList;
024import java.util.Date;
025import java.util.List;
026
027import javax.xml.bind.annotation.XmlAccessType;
028import javax.xml.bind.annotation.XmlAccessorType;
029import javax.xml.bind.annotation.XmlAttribute;
030import javax.xml.bind.annotation.XmlElement;
031import javax.xml.bind.annotation.XmlRootElement;
032import javax.xml.parsers.DocumentBuilderFactory;
033import javax.xml.parsers.ParserConfigurationException;
034
035import org.apache.commons.lang3.ArrayUtils;
036import org.apache.commons.lang3.StringUtils;
037import org.apache.http.HttpEntity;
038import org.apache.http.StatusLine;
039import org.apache.http.client.methods.CloseableHttpResponse;
040import org.apache.http.client.methods.HttpGet;
041import org.apache.http.impl.client.CloseableHttpClient;
042import org.apache.http.impl.client.HttpClients;
043import org.apache.log4j.Logger;
044import org.w3c.dom.DOMException;
045import org.w3c.dom.Node;
046import org.w3c.dom.NodeList;
047import org.xml.sax.SAXException;
048
049import service.tut.pori.users.google.OAuth2Token;
050import service.tut.pori.users.google.GoogleCredential;
051import service.tut.pori.users.google.GoogleUserCore;
052import core.tut.pori.users.UserIdentity;
053import core.tut.pori.utils.XMLFormatter;
054
055/**
056 * This is a simple client that provides high-level operations on the Picasa Web
057 * Albums GData API. It can also be used as a command-line application to test
058 * out some of the features of the API.
059 * 
060 * Note that even though this class is in principle thread-safe, it uses a single-connection http client internally, which means
061 * that multiple method calls are not guaranteed to work at the same time.
062 * 
063 * To get user details (albums etc):
064 *
065 * https://picasaweb.google.com/data/feed/api/user/114407540644219368282 (or with e.g. /user/otula123)
066 *
067 *   - albums: in "entry" -elements: in gphoto:id the id of the album
068 * 
069 * 
070 * to get album details:
071 * 
072 * https://picasaweb.google.com/data/feed/api/user/114407540644219368282/albumid/5789788743364446385 (or with otula123 
073 *    and Private = name of the album)
074 * 
075 *    - photos: from entry, get link (rel="self", type="application/atom+xml", optionally: get id from gphoto:id
076 * 
077 * 
078 * 
079 * to get static access URL:
080 * 
081 * https://picasaweb.google.com/data/entry/api/user/114407540644219368282/albumid/5789788743364446385/photoid/
082 *    5789788743072584594
083 * 
084 * 
085 *    - in content -element (src), or in media:group/media:content (url)
086 *    
087 * 
088 * NOTE: This client is may not work on private folders, it is recommended to use public folders, and because of buggy API on Google's side, there might be issues when retrieving more than 998 photos.
089 */
090public final class PicasawebClient implements Closeable {
091  private static final String PARAMETER_FIELDS = "fields";
092  private static final String PREFIX_GPHOTO = "gphoto";
093  private static final String PREFIX_MEDIA = "media";
094  private static final String ATTRIBUTE_SRC = "src";
095  private static final String ELEMENT_CONTENT = "content";
096  private static final String ELEMENT_ENTRY = "entry";
097  private static final String ELEMENT_FEED = "feed";
098  private static final String ELEMENT_ACCESS = "access";
099  private static final String ELEMENT_ALBUM_ID = "albumid";
100  private static final String ELEMENT_ID = "id";
101  private static final String ELEMENT_GROUP = "group";
102  private static final String ELEMENT_KEYWORDS = "keywords";
103  private static final String ELEMENT_SUMMARY = "summary";
104  private static final String ELEMENT_TITLE = "title";
105  private static final String ELEMENT_UPDATED = "updated";
106  private static final String FIELDS_GET_ALBUM_IDS = "?"+PARAMETER_FIELDS+"="+ELEMENT_ENTRY+"("+PREFIX_GPHOTO+":"+ELEMENT_ID+")";
107  private static final String FIELDS_GET_PHOTO_ENTRIES = "?"+PARAMETER_FIELDS+"="+ELEMENT_ENTRY+"("+PREFIX_GPHOTO+":"+ELEMENT_ID+","+PREFIX_GPHOTO+":"+ELEMENT_ALBUM_ID+","+ELEMENT_UPDATED+","+ELEMENT_SUMMARY+","+ELEMENT_TITLE+","+PREFIX_GPHOTO+":"+ELEMENT_ACCESS+","+ELEMENT_CONTENT+","+PREFIX_MEDIA+":"+ELEMENT_GROUP+"("+PREFIX_MEDIA+":"+ELEMENT_KEYWORDS+"))";
108  private static final String FIELDS_GET_URL = "?"+PARAMETER_FIELDS+"="+ELEMENT_CONTENT;
109  private static final String GDATA_VERSION = "2";
110  private static final String HEADER_AUTHRORIZATION = "Authorization";
111  private static final String HEADER_GDATA_VERSION = "GData-Version";
112  private static final Logger LOGGER = Logger.getLogger(PicasawebClient.class);
113  private static final String NAMESPACE_ATOM = "http://www.w3.org/2005/Atom";
114  private static final String NAMESPACE_GPHOTO = "http://schemas.google.com/photos/2007";
115  private static final String NAMESPACE_MEDIA = "http://search.yahoo.com/mrss/";
116  private static final String PICASA_DATA_HOST = "https://picasaweb.google.com/data";
117  private static final String PICASA_PARAMETER_ALBUM_ID = "albumid";
118  private static final String PICASA_PARAMETER_PHOTO_ID = "photoid";
119  private static final String PICASA_URI_ENTRY = PICASA_DATA_HOST+"/entry/api/user/";
120  private static final String PICASA_URI_FEED = PICASA_DATA_HOST+"/feed/api/user/";
121  private static final String TOKEN_TYPE = "Bearer ";
122  private CloseableHttpClient _client = null;
123  private GoogleCredential _credential = null;
124  private OAuth2Token _token = null;
125
126  /**
127   * 
128   * @param credential
129   * @throws IllegalArgumentException on bad credentials
130   */
131  public PicasawebClient(GoogleCredential credential) throws IllegalArgumentException{
132    if(credential == null || StringUtils.isBlank(credential.getId())){
133      throw new IllegalArgumentException("Invalid credential.");
134    }
135    _client = HttpClients.createDefault();
136    _credential = credential;
137    _token = GoogleUserCore.getToken(credential.getUserId());
138    if(_token == null){
139      throw new IllegalArgumentException("Could not retrieve access token.");
140    }
141  }
142
143  /**
144   * 
145   * @param albumId
146   * @param picasaPhotoId
147   * @return static URL for the requested content or null if not found
148   * @throws IllegalArgumentException
149   */
150  public String generateStaticUrl(String albumId, String picasaPhotoId) throws IllegalArgumentException{
151    HttpGet get = new HttpGet(PICASA_URI_ENTRY+_credential.getId()+"/"+PICASA_PARAMETER_ALBUM_ID+"/"+albumId+"/"+PICASA_PARAMETER_PHOTO_ID+"/"+picasaPhotoId+FIELDS_GET_URL);
152    setHeaders(get);
153    try (CloseableHttpResponse response = _client.execute(get)) {
154      InputStream body = getBody(response);
155      if(body == null){
156        LOGGER.warn("Failed to resolve static URL.");
157        return null;
158      }
159      
160      NodeList contentNodes = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(body).getElementsByTagName(ELEMENT_CONTENT);
161      if(contentNodes.getLength() < 1){
162        LOGGER.debug("No content found.");
163        return null;
164      }
165      
166      Node n = contentNodes.item(0).getAttributes().getNamedItem(ATTRIBUTE_SRC);
167      if(n == null){
168        LOGGER.warn("Element "+ELEMENT_CONTENT+" did not contain attribute "+ATTRIBUTE_SRC);
169        return null;
170      }
171      
172      return n.getNodeValue();
173    } catch (IOException | SAXException | ParserConfigurationException ex) {
174      LOGGER.error(ex, ex);
175      throw new IllegalArgumentException("Could not generate URL for user: "+_credential.getId()+" with albumId: "+albumId+" and photoId: "+picasaPhotoId);
176    }
177  }
178
179  /**
180   * 
181   * @param get
182   */
183  private void setHeaders(HttpGet get) {
184    get.setHeader(HEADER_GDATA_VERSION, GDATA_VERSION);
185    String token = _token.getAccessToken();
186    if(StringUtils.isBlank(token)){
187      LOGGER.warn("No access token : limited to public access.");
188    }else{
189      get.setHeader(HEADER_AUTHRORIZATION, TOKEN_TYPE+token);
190    }
191  }
192
193  /**
194   * 
195   * @return list of photos or null if none
196   */
197  public List<PhotoEntry> getPhotos(){
198    String picasaUserFeed = PICASA_URI_FEED+_credential.getId();
199    HttpGet get = new HttpGet(picasaUserFeed+FIELDS_GET_ALBUM_IDS); //get list of albums
200    setHeaders(get);
201    NodeList albumIdNodes = null;
202    try (CloseableHttpResponse response = _client.execute(get)) {
203      InputStream body = getBody(response);
204      if(body == null){
205        LOGGER.warn("Failed to retrieve photo albums.");
206        return null;
207      }
208      
209      albumIdNodes = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(body).getElementsByTagName(PREFIX_GPHOTO+":"+ELEMENT_ID);
210    } catch (IOException | ParserConfigurationException | SAXException ex) {
211      LOGGER.error(ex, ex);
212      return null;
213    }
214
215    int nodeCount = albumIdNodes.getLength();
216    if(nodeCount < 1){
217      LOGGER.debug("No photo albums found for google user, id: "+_credential.getId());
218      return null;
219    }
220    List<PhotoEntry> entries = new ArrayList<>(nodeCount);
221    XMLFormatter formatter = new XMLFormatter();
222    try {
223      for(int i=0;i<nodeCount;++i){ //loop thru albums and get photos
224        String albumId = albumIdNodes.item(i).getTextContent();
225        get.setURI(new URI(picasaUserFeed+"/"+PICASA_PARAMETER_ALBUM_ID+"/"+albumId+FIELDS_GET_PHOTO_ENTRIES));
226        try(CloseableHttpResponse response = _client.execute(get)){
227          InputStream body = getBody(response);
228          if(body == null){
229            LOGGER.warn("Failed to retrieve album photos.");
230            return null;
231          }
232
233          Feed feed = formatter.toObject(body, Feed.class);
234          if(feed._entries == null || feed._entries.isEmpty()){
235            LOGGER.debug("No entries for album, id: "+albumId);
236            continue;
237          }
238
239          entries.addAll(feed._entries);
240        } catch (IOException ex) {
241          LOGGER.error(ex, ex);
242          return null;
243        } 
244      } // for
245    } catch (DOMException | URISyntaxException ex) {
246      LOGGER.error(ex, ex); 
247    }
248
249    return (entries.isEmpty() ? null : entries);
250  }
251
252  /**
253   * 
254   * @param response
255   * @return response entity or null on non OK response or if no entity is present
256   */
257  private InputStream getBody(CloseableHttpResponse response) {
258    StatusLine sl = response.getStatusLine();
259    int statusCode = sl.getStatusCode();
260    if(statusCode < 200 || statusCode >= 300){
261      LOGGER.warn("Server responded: "+statusCode+" "+sl.getReasonPhrase());
262      return null;
263    }
264    HttpEntity entity = response.getEntity();
265    if(entity == null){
266      LOGGER.warn("No response entity provided.");
267      return null;
268    }
269    try {
270      return entity.getContent();
271    } catch (IllegalStateException | IOException ex) {
272      LOGGER.error(ex, ex);
273      return null;
274    }
275  }
276
277  /**
278   * 
279   * @return user identity set to this client or null if none
280   */
281  public UserIdentity getUserIdentity(){
282    return (_credential == null ? null : _credential.getUserId());
283  }
284
285  /**
286   * @see service.tut.pori.users.google.GoogleCredential#getId()
287   * 
288   * @return google user id
289   */
290  public String getGoogleUserId() {
291    return (_credential == null ? null : _credential.getId());
292  }
293
294  @Override
295  public void close() throws IOException {
296    _client.close();
297  }
298
299  /**
300   * Represents a single photo entry returned by picasa.
301   */
302  @XmlRootElement(name=ELEMENT_ENTRY)
303  @XmlAccessorType(XmlAccessType.NONE)
304  public static class PhotoEntry{
305    @XmlElement(name=ELEMENT_ACCESS, namespace=NAMESPACE_GPHOTO)
306    private String _albumAccess = null;
307    @XmlElement(name=ELEMENT_ALBUM_ID, namespace=NAMESPACE_GPHOTO)
308    private String _albumId = null;
309    @XmlElement(name=ELEMENT_ID, namespace=NAMESPACE_GPHOTO)
310    private String _gphotoId = null;
311    private List<String> _keywords = null;
312    @XmlElement(name=ELEMENT_SUMMARY, namespace=NAMESPACE_ATOM)
313    private String _summary = null;
314    @XmlElement(name=ELEMENT_TITLE, namespace=NAMESPACE_ATOM)
315    private String _title = null;
316    @XmlElement(name=ELEMENT_UPDATED, namespace=NAMESPACE_ATOM)
317    private Date _updated = null;
318    private String _url = null;
319
320    /**
321     * 
322     * @param content
323     */
324    @XmlElement(name=ELEMENT_CONTENT, namespace=NAMESPACE_ATOM)
325    private void setContent(Content content){
326      if(content != null && !StringUtils.isBlank(content._src)){
327        _url = content._src;
328      }
329    }
330
331    /**
332     * 
333     * @param mediaGroup
334     */
335    @XmlElement(name=ELEMENT_GROUP, namespace=NAMESPACE_MEDIA)
336    private void setMediaGroup(MediaGroup mediaGroup){
337      if(mediaGroup != null && !StringUtils.isBlank(mediaGroup._keywords)){
338        String[] keywords = StringUtils.split(mediaGroup._keywords, core.tut.pori.http.Definitions.SEPARATOR_URI_QUERY_PARAM_VALUES);
339        if(!ArrayUtils.isEmpty(keywords)){
340          _keywords = new ArrayList<>(keywords.length);
341          for(int i=0;i<keywords.length;++i){
342            _keywords.add(keywords[i].trim());
343          }
344        } // if
345      } // if
346    }
347
348    /**
349     * 
350     */
351    private PhotoEntry(){
352      // nothing needed
353    }
354
355    /**
356     * 
357     * @return album access permission string
358     */
359    public String getAlbumAccess(){
360      return _albumAccess;
361    }
362
363    /**
364     * 
365     * @return summary
366     */
367    public String getSummary(){
368      return _summary;
369    }
370
371    /**
372     * 
373     * @return title
374     */
375    public String getTitle() {
376      return _title;
377    }
378
379    /**
380     * 
381     * @return updated timestamp
382     */
383    public Date getUpdated() {
384      return _updated;
385    }
386
387    /**
388     * 
389     * @return list of keywords
390     */
391    public List<String> getKeywords() {
392      return _keywords;
393    }
394
395    /**
396     * 
397     * @return url
398     */
399    public String getUrl() {
400      return _url;
401    }
402
403    /**
404     * 
405     * @return album id
406     */
407    public String getAlbumId() {
408      return _albumId;
409    }
410
411    /**
412     * 
413     * @return google photo id
414     */
415    public String getGphotoId() {
416      return _gphotoId;
417    }
418  } // class PhotoEntry
419
420  /**
421   * Represents a photo entry content element
422   *
423   */
424  @XmlRootElement
425  @XmlAccessorType(XmlAccessType.NONE)
426  private static class Content {
427    @XmlAttribute(name=ATTRIBUTE_SRC)
428    private String _src = null;
429  } // class PhotoEntryContent
430
431  /**
432   * Represents a media group, which contains the user given keywords for a photo content
433   *
434   */
435  @XmlRootElement
436  @XmlAccessorType(XmlAccessType.NONE)
437  private static class MediaGroup {
438    @XmlElement(name=ELEMENT_KEYWORDS, namespace=NAMESPACE_MEDIA)
439    private String _keywords = null;
440  } // class PhotoEntryContent
441
442  /**
443   * The photo feed
444   *
445   */
446  @XmlRootElement(name=ELEMENT_FEED, namespace=NAMESPACE_ATOM)
447  @XmlAccessorType(XmlAccessType.NONE)
448  private static class Feed {
449    @XmlElement(name=ELEMENT_ENTRY, namespace=NAMESPACE_ATOM)
450    private List<PhotoEntry> _entries = null;
451  } // class PhotoEntryContent
452}