001/* 002 * Copyright 2024 Anyware Services 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 org.ametys.plugins.microsoft365; 017 018import java.util.ArrayList; 019import java.util.HashMap; 020import java.util.List; 021import java.util.Map; 022import java.util.Optional; 023 024import org.apache.avalon.framework.component.Component; 025import org.apache.avalon.framework.service.ServiceException; 026import org.apache.avalon.framework.service.ServiceManager; 027import org.apache.avalon.framework.service.Serviceable; 028import org.apache.commons.lang3.StringUtils; 029 030import org.ametys.core.ui.Callable; 031import org.ametys.core.user.CurrentUserProvider; 032import org.ametys.core.user.UserIdentity; 033import org.ametys.plugins.extrausermgt.users.aad.GraphClientProvider; 034import org.ametys.plugins.extrausermgt.users.aad.GraphClientProvider.GraphClientException; 035import org.ametys.plugins.repository.AmetysObjectResolver; 036import org.ametys.plugins.repository.data.holder.ModelAwareDataHolder; 037import org.ametys.runtime.plugin.component.AbstractLogEnabled; 038import org.ametys.web.repository.page.ZoneItem; 039 040import com.microsoft.graph.core.tasks.PageIterator; 041import com.microsoft.graph.core.tasks.PageIterator.PageIteratorState; 042import com.microsoft.graph.drives.item.DriveItemRequestBuilder; 043import com.microsoft.graph.drives.item.items.item.searchwithq.SearchWithQGetResponse; 044import com.microsoft.graph.drives.item.recent.RecentGetResponse; 045import com.microsoft.graph.drives.item.sharedwithme.SharedWithMeGetResponse; 046import com.microsoft.graph.models.Drive; 047import com.microsoft.graph.models.DriveItem; 048import com.microsoft.graph.models.DriveItemCollectionResponse; 049import com.microsoft.graph.models.Site; 050import com.microsoft.graph.serviceclient.GraphServiceClient; 051import com.microsoft.graph.users.item.UserItemRequestBuilder; 052 053/** 054 * Component for OneDrive connector 055 * 056 */ 057public class OneDriveConnector extends AbstractLogEnabled implements Component, Serviceable 058{ 059 /** The avalon role */ 060 public static final String ROLE = OneDriveConnector.class.getName(); 061 062 private static final String[] __DRIVE_ITEM_SELECT = new String[]{"id", "type", "name", "webUrl", "webDavUrl", "file", "size", "folder", 063 "package", "parentReference", "remoteItem", "sharepointIds", "createdBy"}; 064 065 /** 066 * Constant to use when requesting a given number of item. 067 * This allow requesting a bit more than necessary to maximize chance 068 * of having enough results in the first request even after some being filtered out 069 */ 070 private static final float __PAGING_MARGIN = 1.1f; 071 072 /** Graph client provider */ 073 protected GraphClientProvider _graphClientProvider; 074 /** Current user provider */ 075 protected CurrentUserProvider _currentUserProvider; 076 /** Ametys object resolver */ 077 protected AmetysObjectResolver _resolver; 078 079 public void service(ServiceManager manager) throws ServiceException 080 { 081 _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE); 082 _graphClientProvider = (GraphClientProvider) manager.lookup(GraphClientProvider.ROLE); 083 _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 084 } 085 086 /** 087 * Enumeration for the type of OneDrive resources 088 * 089 */ 090 public enum ResourceType 091 { 092 /** The user files */ 093 USER_FILES, 094 /** The shared files */ 095 SHARED_FILES, 096 /** The recent files */ 097 RECENT_FILES 098 } 099 100 /** 101 * Get the user OneDrive resources according the type of resources defined in services parameters 102 * @param zoneItemId The zone item id. Can be null or empty if itemId is not blank. 103 * @param itemId the parent folder item id 104 * @param driveId the parent folder drive id or empty for the current user drive 105 * @param contextualParameters the contextual parameters 106 * @return the OneDrive resources 107 */ 108 @Callable(rights = Callable.SKIP_BUILTIN_CHECK) 109 public Map<String, Object> getFiles(String zoneItemId, String itemId, String driveId, Map<String, Object> contextualParameters) 110 { 111 UserIdentity currentUser = _currentUserProvider.getUser(); 112 113 if (StringUtils.isNotBlank(itemId)) 114 { 115 return getChildren(currentUser, itemId, driveId, contextualParameters); 116 } 117 else 118 { 119 ZoneItem zoneItem = _resolver.resolveById(zoneItemId); 120 ModelAwareDataHolder serviceParameters = zoneItem.getServiceParameters(); 121 122 ResourceType type = ResourceType.valueOf(serviceParameters.getValue("type", false, ResourceType.USER_FILES.name())); 123 Long maxResults = serviceParameters.getValue("max-results", false, 10L); 124 125 switch (type) 126 { 127 case USER_FILES: 128 return getChildren(currentUser, "", "", contextualParameters); 129 case SHARED_FILES: 130 return sharedFiles(currentUser, maxResults.intValue()); 131 case RECENT_FILES: 132 return recentFiles(currentUser, maxResults.intValue()); 133 default: 134 throw new IllegalArgumentException("Unexpected type of OneDrive resources: " + type); 135 } 136 } 137 } 138 139 /** 140 * Get a list of recent item accessed by the current user 141 * @param user the user 142 * @param maxResults the max number of results to return 143 * @return the list of recent files 144 */ 145 public Map<String, Object> recentFiles(UserIdentity user, int maxResults) 146 { 147 try 148 { 149 UserItemRequestBuilder requestBuilder = _graphClientProvider.getUserRequestBuilder(user); 150 151 String userDriveId = requestBuilder.drive().get().getId(); 152 153 GraphServiceClient graphClient = _graphClientProvider.getGraphClient(); 154 RecentGetResponse recentResponse = graphClient.drives().byDriveId(userDriveId).recent().get(requestConfiguration -> { 155 requestConfiguration.queryParameters.top = Math.min(Math.round(maxResults * __PAGING_MARGIN), 999); // take a few more to anticipate filter 156 requestConfiguration.queryParameters.select = __DRIVE_ITEM_SELECT; 157 }); 158 159 List<Map<String, Object>> files = new ArrayList<>(); 160 new PageIterator.Builder<DriveItem, RecentGetResponse>() 161 .client(graphClient) 162 .collectionPage(recentResponse) 163 .collectionPageFactory(RecentGetResponse::createFromDiscriminatorValue) 164 .processPageItemCallback(item -> { 165 Map<String, Object> json = _driveItem2JSON(item, userDriveId); 166 if (json != null) 167 { 168 files.add(json); 169 } 170 171 return files.size() < maxResults; 172 }) 173 .build() 174 .iterate(); 175 176 return Map.of("results", files); 177 } 178 catch (GraphClientException e) 179 { 180 getLogger().warn("Failed to get a Graph Client for user " + user, e); 181 return Map.of("error", "user-unknown"); 182 } 183 catch (Exception e) 184 { 185 getLogger().warn("An error occured while contacting the graph endpoint for user " + user, e); 186 return Map.of("error", "request-failed"); 187 } 188 } 189 190 /** 191 * Get a list of item shared with the current user 192 * @param user the user 193 * @param maxResults the max number of results to return 194 * @return the list of item as JSON 195 */ 196 public Map<String, Object> sharedFiles(UserIdentity user, int maxResults) 197 { 198 try 199 { 200 UserItemRequestBuilder requestBuilder = _graphClientProvider.getUserRequestBuilder(user); 201 202 String userDriveId = requestBuilder.drive().get().getId(); 203 204 GraphServiceClient graphClient = _graphClientProvider.getGraphClient(); 205 SharedWithMeGetResponse sharedWithMeResponse = graphClient.drives().byDriveId(userDriveId).sharedWithMe().get(requestConfiguration -> { 206 requestConfiguration.queryParameters.top = Math.min(Math.round(maxResults * __PAGING_MARGIN), 999); // take a few more to anticipate filter 207 requestConfiguration.queryParameters.select = __DRIVE_ITEM_SELECT; 208 }); 209 210 List<Map<String, Object>> files = new ArrayList<>(); 211 new PageIterator.Builder<DriveItem, SharedWithMeGetResponse>() 212 .client(graphClient) 213 .collectionPage(sharedWithMeResponse) 214 .collectionPageFactory(SharedWithMeGetResponse::createFromDiscriminatorValue) 215 .processPageItemCallback(item -> { 216 Map<String, Object> json = _driveItem2JSON(item, userDriveId); 217 // Filter item without drive Id to remove item that belongs to the user 218 // For some reason, the "sharedWithMe" endpoint also return the "sharedByMe"… 219 if (json != null && json.get("driveId") != null) 220 { 221 files.add(json); 222 } 223 224 return files.size() < maxResults; 225 }) 226 .build() 227 .iterate(); 228 229 return Map.of("results", files); 230 } 231 catch (GraphClientException e) 232 { 233 getLogger().warn("Failed to get a Graph Client for user " + user, e); 234 return Map.of("error", "user-unknown"); 235 } 236 catch (Exception e) 237 { 238 getLogger().warn("An error occured while contacting the graph endpoint for user " + user, e); 239 return Map.of("error", "request-failed"); 240 } 241 } 242 243 /** 244 * Get resources of a OneDrive folder 245 * @param user The user identity 246 * @param itemId the parent folder item id or empty for the root of the drive 247 * @param driveId the parent folder drive id or empty for the current user drive 248 * @param contextualParameters The contextual parameters 249 * @return the child resources 250 */ 251 public Map<String, Object> getChildren(UserIdentity user, String itemId, String driveId, Map<String, Object> contextualParameters) 252 { 253 try 254 { 255 UserItemRequestBuilder requestBuilder = _graphClientProvider.getUserRequestBuilder(user); 256 257 String userDriveId = requestBuilder.drive().get().getId(); 258 259 GraphServiceClient graphClient = _graphClientProvider.getGraphClient(); 260 DriveItemRequestBuilder driveRequestBuilder = graphClient.drives().byDriveId(StringUtils.isNotEmpty(driveId) ? driveId : userDriveId); 261 262 // if the item is not provided, get the root 263 String itemToRequest = StringUtils.isNotEmpty(itemId) ? itemId : driveRequestBuilder.root().get().getId(); 264 265 DriveItemCollectionResponse driveItemResponse = driveRequestBuilder.items().byDriveItemId(itemToRequest).children().get(requestConfiguration -> { 266 requestConfiguration.queryParameters.select = __DRIVE_ITEM_SELECT; 267 }); 268 269 List<Map<String, Object>> files = new ArrayList<>(); 270 new PageIterator.Builder<DriveItem, DriveItemCollectionResponse>() 271 .client(graphClient) 272 .collectionPage(driveItemResponse) 273 .collectionPageFactory(DriveItemCollectionResponse::createFromDiscriminatorValue) 274 .processPageItemCallback(item -> { 275 Map<String, Object> json = _driveItem2JSON(item, userDriveId); 276 if (json != null) 277 { 278 files.add(json); 279 } 280 281 return true; 282 }) 283 .build() 284 .iterate(); 285 286 return Map.of("results", files); 287 } 288 catch (GraphClientException e) 289 { 290 getLogger().warn("Failed to get a Graph Client for user " + user, e); 291 return Map.of("error", "user-unknown"); 292 } 293 catch (Exception e) 294 { 295 getLogger().warn("An error occured while contacting the graph endpoint for user " + user, e); 296 return Map.of("error", "request-failed"); 297 } 298 } 299 300 /** 301 * Search resources into a OneDrive folder 302 * @param pattern the search pattern 303 * @param zoneItemId the zone item Id of the service 304 * @param contextualParameters The contextual parameters 305 * @return the child resources 306 */ 307 @Callable(rights = Callable.SKIP_BUILTIN_CHECK) 308 public Map<String, Object> searchFiles(String zoneItemId, String pattern, Map<String, Object> contextualParameters) 309 { 310 UserIdentity currentUser = _currentUserProvider.getUser(); 311 312 try 313 { 314 UserItemRequestBuilder userRequestBuilder = _graphClientProvider.getUserRequestBuilder(currentUser); 315 316 Drive drive = userRequestBuilder.drive().get(); 317 String userDriveId = drive.getId(); 318 319 ZoneItem zoneItem = _resolver.resolveById(zoneItemId); 320 ModelAwareDataHolder serviceParameters = zoneItem.getServiceParameters(); 321 322 Long maxResults = serviceParameters.getValue("max-search-results", false, 5L); 323 324 GraphServiceClient graphClient = _graphClientProvider.getGraphClient(); 325 DriveItemRequestBuilder driveRequestBuilder = graphClient.drives().byDriveId(userDriveId); 326 SearchWithQGetResponse searchResponse = driveRequestBuilder.items().byDriveItemId(driveRequestBuilder.root().get().getId()).searchWithQ(pattern).get(requestConfiguration -> { 327 requestConfiguration.queryParameters.top = Math.min(Math.round(maxResults * __PAGING_MARGIN), 999); // take a few more to anticipate filter 328 requestConfiguration.queryParameters.select = __DRIVE_ITEM_SELECT; 329 }); 330 331 List<Map<String, Object>> files = new ArrayList<>(); 332 PageIterator<DriveItem, SearchWithQGetResponse> pageIterator = new PageIterator.Builder<DriveItem, SearchWithQGetResponse>() 333 .client(graphClient) 334 .collectionPage(searchResponse) 335 .collectionPageFactory(SearchWithQGetResponse::createFromDiscriminatorValue) 336 .processPageItemCallback(item -> { 337 Map<String, Object> json = _driveItem2JSON(item, userDriveId); 338 if (json != null) 339 { 340 files.add(json); 341 } 342 343 return files.size() < maxResults; 344 }) 345 .build(); 346 pageIterator.iterate(); 347 348 if (pageIterator.getPageIteratorState() != PageIteratorState.COMPLETE) 349 { 350 // There is more results, provide a link to display the search in onedrive 351 String oneDriveUrl = drive.getWebUrl(); 352 oneDriveUrl = oneDriveUrl + "?q=" + pattern + "*&searchScope=all"; 353 return Map.of("results", files, 354 "has-more", oneDriveUrl); 355 } 356 357 return Map.of("results", files); 358 } 359 catch (GraphClientException e) 360 { 361 getLogger().warn("Failed to get a Graph Client for user " + currentUser, e); 362 return Map.of("error", "user-unknown"); 363 } 364 catch (Exception e) 365 { 366 getLogger().warn("Failed to search drive for user " + currentUser, e); 367 return Map.of("error", "request-failed"); 368 } 369 } 370 371 /** 372 * Get the JSON or null if the item should be ignored 373 */ 374 private Map<String, Object> _driveItem2JSON(DriveItem item, String userDriveId) 375 { 376 String itemName = item.getName(); 377 if (StringUtils.equals(itemName, "OneNote_RecycleBin") 378 || StringUtils.equals(itemName, "OneNote_DeletedPages.one") 379 || StringUtils.endsWith(itemName, ".onetoc2")) 380 { 381 // Ignore those special folder or files from OneDrive 382 return null; 383 } 384 385 Map<String, Object> json = new HashMap<>(); 386 json.put("itemId", item.getId()); 387 json.put("name", itemName); 388 if (item.getWebUrl() != null) 389 { 390 json.put("webUrl", item.getWebUrl()); 391 } 392 else if (item.getWebDavUrl() != null) 393 { 394 json.put("webUrl", item.getWebDavUrl()); 395 } 396 397 try 398 { 399 if (item.getParentReference() != null 400 || item.getRemoteItem().getParentReference() != null) 401 { 402 String driveId = (item.getParentReference() != null) ? item.getParentReference().getDriveId() : item.getRemoteItem().getParentReference().getDriveId(); 403 if (!StringUtils.equals(driveId, userDriveId)) 404 { 405 json.put("driveId", driveId); 406 _getItemLocation(item).ifPresent(location -> json.putAll(location)); 407 } 408 } 409 } 410 catch (GraphClientException e) 411 { 412 getLogger().info("An error occured while retrieving the location of file " + itemName, e); 413 } 414 415 // OneNote are represented as folder but we want to make them file 416 if (item.getPackage() != null && StringUtils.equals(item.getPackage().getType(), "oneNote")) 417 { 418 json.putAll(Map.of( 419 "type", "file", 420 "mime-type", "application/msonenote", 421 "size", item.getSize() 422 )); 423 } 424 else if (item.getFolder() != null) 425 { 426 json.putAll(_folder2JSON(item)); 427 } 428 else if (item.getFile() != null) 429 { 430 json.putAll(_file2JSON(item)); 431 } 432 return json; 433 } 434 435 private Map<String, Object> _folder2JSON(DriveItem item) 436 { 437 438 return Map.of( 439 "type", "folder", 440 "hasChildren", item.getFolder().getChildCount() != 0 441 ); 442 } 443 444 private Map<String, Object> _file2JSON(DriveItem item) 445 { 446 return Map.of( 447 "type", "file", 448 "mime-type", item.getFile().getMimeType(), 449 "size", item.getSize() 450 ); 451 } 452 453 private Optional<Map<String, Object>> _getItemLocation(DriveItem item) throws GraphClientException 454 { 455 // Use a sharepoint id as primary means to determine an item location 456 if (item.getSharepointIds() != null 457 || item.getRemoteItem() != null && item.getRemoteItem().getSharepointIds() != null) 458 { 459 String siteId = item.getSharepointIds() != null ? item.getSharepointIds().getSiteId() : item.getRemoteItem().getSharepointIds().getSiteId(); 460 Site site = _graphClientProvider.getGraphClient().sites().bySiteId(siteId).get(requestConfiguration -> { 461 requestConfiguration.queryParameters.select = new String[]{"displayName", "webUrl"}; 462 }); 463 464 return Optional.of(_locationToI18N(site.getDisplayName(), StringUtils.contains(site.getWebUrl(), "/personal/"))); 465 } 466 467 // Use the creator as a fallback 468 if (item.getCreatedBy() != null && item.getCreatedBy().getUser() != null) 469 { 470 return Optional.of(_locationToI18N(item.getCreatedBy().getUser().getDisplayName(), true)); 471 } 472 else if (item.getRemoteItem() != null && item.getRemoteItem().getCreatedBy() != null && item.getRemoteItem().getCreatedBy().getUser() != null) 473 { 474 return Optional.of(_locationToI18N(item.getRemoteItem().getCreatedBy().getUser().getDisplayName(), true)); 475 } 476 477 return Optional.empty(); 478 } 479 480 private Map<String, Object> _locationToI18N(String name, boolean isUser) 481 { 482 return isUser 483 ? Map.of("driveType", "user", 484 "driveLabel", name) 485 : Map.of("driveType", "sharepoint", 486 "driveLabel", name); 487 } 488}