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}