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}