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.Iterator;
021import java.util.List;
022import java.util.Map;
023import java.util.Optional;
024
025import org.apache.avalon.framework.component.Component;
026import org.apache.avalon.framework.service.ServiceException;
027import org.apache.avalon.framework.service.ServiceManager;
028import org.apache.avalon.framework.service.Serviceable;
029import org.apache.commons.lang3.StringUtils;
030
031import org.ametys.core.ui.Callable;
032import org.ametys.core.user.CurrentUserProvider;
033import org.ametys.core.user.UserIdentity;
034import org.ametys.plugins.extrausermgt.users.aad.GraphClientProvider;
035import org.ametys.plugins.extrausermgt.users.aad.GraphClientProvider.GraphClientException;
036import org.ametys.plugins.repository.AmetysObjectResolver;
037import org.ametys.plugins.repository.data.holder.ModelAwareDataHolder;
038import org.ametys.runtime.plugin.component.AbstractLogEnabled;
039import org.ametys.web.repository.page.ZoneItem;
040
041import com.microsoft.graph.http.GraphServiceException;
042import com.microsoft.graph.models.DriveItem;
043import com.microsoft.graph.models.DriveSearchParameterSet;
044import com.microsoft.graph.models.Site;
045import com.microsoft.graph.requests.DriveItemCollectionPage;
046import com.microsoft.graph.requests.DriveItemCollectionRequestBuilder;
047import com.microsoft.graph.requests.DriveRecentCollectionPage;
048import com.microsoft.graph.requests.DriveRecentCollectionRequestBuilder;
049import com.microsoft.graph.requests.DriveRequestBuilder;
050import com.microsoft.graph.requests.DriveSearchCollectionPage;
051import com.microsoft.graph.requests.DriveSearchCollectionRequestBuilder;
052import com.microsoft.graph.requests.DriveSharedWithMeCollectionPage;
053import com.microsoft.graph.requests.DriveSharedWithMeCollectionRequestBuilder;
054import com.microsoft.graph.requests.UserRequestBuilder;
055
056/**
057 * Component for OneDrive connector
058 *
059 */
060public class OneDriveConnector extends AbstractLogEnabled implements Component, Serviceable
061{
062    /** The avalon role */
063    public static final String ROLE = OneDriveConnector.class.getName();
064    
065    private static final String __DRIVE_ITEM_SELECT = "id,type,name,webUrl,webDavUrl,file,size,folder,package,parentReference,remoteItem,sharepointIds,createdBy";
066    
067    /** Graph client provider */
068    protected GraphClientProvider _graphClientProvider;
069    /** Current user provider */
070    protected CurrentUserProvider _currentUserProvider;
071    /** Ametys object resolver */
072    protected AmetysObjectResolver _resolver;
073    
074    public void service(ServiceManager manager) throws ServiceException
075    {
076        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
077        _graphClientProvider = (GraphClientProvider) manager.lookup(GraphClientProvider.ROLE);
078        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
079    }
080    
081    /**
082     * Enumeration for the type of OneDrive resources
083     *
084     */
085    public enum ResourceType
086    {
087        /** The user files */
088        USER_FILES,
089        /** The shared files */
090        SHARED_FILES,
091        /** The recent files */
092        RECENT_FILES
093    }
094    
095    /**
096     * Get the user OneDrive resources according the type of resources defined in services parameters
097     * @param zoneItemId The zone item id. Can be null or empty if itemId is not blank.
098     * @param itemId the parent folder item id
099     * @param driveId the parent folder drive id or empty for the current user drive
100     * @param contextualParameters the contextual parameters
101     * @return the OneDrive resources
102     */
103    @Callable(rights = Callable.SKIP_BUILTIN_CHECK)
104    public Map<String, Object> getFiles(String zoneItemId, String itemId, String driveId, Map<String, Object> contextualParameters)
105    {
106        UserIdentity currentUser = _currentUserProvider.getUser();
107        
108        if (StringUtils.isNotBlank(itemId))
109        {
110            return getChildren(currentUser, itemId, driveId, contextualParameters);
111        }
112        else
113        {
114            ZoneItem zoneItem = _resolver.resolveById(zoneItemId);
115            ModelAwareDataHolder serviceParameters = zoneItem.getServiceParameters();
116            
117            ResourceType type = ResourceType.valueOf(serviceParameters.getValue("type", false, ResourceType.USER_FILES.name()));
118            Long maxResults = serviceParameters.getValue("max-results", false, 10L);
119            
120            switch (type)
121            {
122                case USER_FILES:
123                    return userFiles(currentUser, zoneItem);
124                case SHARED_FILES:
125                    return sharedFiles(currentUser, maxResults.intValue());
126                case RECENT_FILES:
127                    return recentFiles(currentUser, maxResults.intValue());
128                default:
129                    throw new IllegalArgumentException("Unexpected type of OneDrive resources: " + type);
130            }
131        }
132    }
133    
134    /**
135     * Get the user OneDrive repository content
136     * @param user The user
137     * @param zoneItem the service zone item
138     * @return the user items
139     */
140    public Map<String, Object> userFiles(UserIdentity user, ZoneItem zoneItem)
141    {
142        try
143        {
144            UserRequestBuilder requestBuilder = _graphClientProvider.getUserRequestBuilder(user);
145            String userDriveId = requestBuilder.drive().buildRequest().select("id").get().id;
146            
147            DriveItemCollectionRequestBuilder driveRequestBuilder = requestBuilder.drive().root()
148                .children();
149            
150            List<Map<String, Object>> files = new ArrayList<>();
151            
152            do
153            {
154                DriveItemCollectionPage driveItemCollectionPage = driveRequestBuilder
155                        .buildRequest()
156                        .select(__DRIVE_ITEM_SELECT)
157                        .get();
158                for (DriveItem item : driveItemCollectionPage.getCurrentPage())
159                {
160                    Map<String, Object> json = _driveItem2JSON(item, userDriveId);
161                    if (json != null)
162                    {
163                        files.add(json);
164                    }
165                }
166                
167                // update the request builder to fetch the next result if needed
168                driveRequestBuilder = driveItemCollectionPage.getNextPage();
169            }
170            // loop until there is no more result to fetch or we have enough results
171            while (driveRequestBuilder != null);
172            
173            return Map.of("results", files);
174        }
175        catch (GraphClientException e)
176        {
177            getLogger().warn("Failed to get a Graph Client for user " + user, e);
178            return Map.of("error", "user-unknown");
179        }
180        catch (GraphServiceException e)
181        {
182            getLogger().warn("An error occured while contacting the graph endpoint for user " + user, e);
183            return Map.of("error", "request-failed");
184        }
185    }
186    
187    /**
188     * Get a list of recent item accessed by the current user
189     * @param user the user
190     * @param maxResults the max number of results to return
191     * @return the list of recent files
192     */
193    public Map<String, Object> recentFiles(UserIdentity user, int maxResults)
194    {
195        try
196        {
197            UserRequestBuilder requestBuilder = _graphClientProvider.getUserRequestBuilder(user);
198            String userDriveId = requestBuilder.drive().buildRequest().select("id").get().id;
199            
200            DriveRecentCollectionRequestBuilder driveRequestBuilder = requestBuilder.drive()
201                .recent();
202            
203            List<Map<String, Object>> files = new ArrayList<>();
204            do
205            {
206                DriveRecentCollectionPage driveItemCollectionPage = driveRequestBuilder
207                        .buildRequest()
208                        .select(__DRIVE_ITEM_SELECT)
209                        .top(maxResults)
210                        .get();
211                
212                List<DriveItem> currentPage = driveItemCollectionPage.getCurrentPage();
213                Iterator<DriveItem> iterator = currentPage.iterator();
214                // iterate until there is no more results or we reached the max results
215                while (iterator.hasNext() && files.size() < maxResults)
216                {
217                    Map<String, Object> json = _driveItem2JSON(iterator.next(), userDriveId);
218                    if (json != null)
219                    {
220                        files.add(json);
221                    }
222                }
223                
224                // update the request builder to fetch the next result if needed
225                driveRequestBuilder = driveItemCollectionPage.getNextPage();
226            }
227            // loop until there is no more result to fetch or we have enough results
228            while (driveRequestBuilder != null && files.size() < maxResults);
229            
230            return Map.of("results", files);
231        }
232        catch (GraphClientException e)
233        {
234            getLogger().warn("Failed to get a Graph Client for user " + user, e);
235            return Map.of("error", "user-unknown");
236        }
237        catch (GraphServiceException e)
238        {
239            getLogger().warn("An error occured while contacting the graph endpoint for user " + user, e);
240            return Map.of("error", "request-failed");
241        }
242    }
243    
244    /**
245     * Get a list of item shared with the current user
246     * @param user the user
247     * @param maxResults the max number of results to return
248     * @return the list of item as JSON
249     */
250    public Map<String, Object> sharedFiles(UserIdentity user, int maxResults)
251    {
252        try
253        {
254            UserRequestBuilder requestBuilder = _graphClientProvider.getUserRequestBuilder(user);
255            String userDriveId = requestBuilder.drive().buildRequest().select("id").get().id;
256            
257            DriveSharedWithMeCollectionRequestBuilder driveRequestBuilder = requestBuilder.drive()
258                .sharedWithMe();
259            
260            List<Map<String, Object>> files = new ArrayList<>();
261            do
262            {
263                DriveSharedWithMeCollectionPage driveItemCollectionPage = driveRequestBuilder
264                        .buildRequest()
265                        .select(__DRIVE_ITEM_SELECT)
266                        .top(maxResults)
267                        .get();
268                
269                Iterator<DriveItem> currentPageIterator = driveItemCollectionPage.getCurrentPage().iterator();
270                // iterate until there is no more results or we reached the max results
271                while (currentPageIterator.hasNext() && files.size() < maxResults)
272                {
273                    Map<String, Object> json = _driveItem2JSON(currentPageIterator.next(), userDriveId);
274                    // Filter item without drive Id to remove item that belongs to the user
275                    // For some reason, the "sharedWithMe" endpoint also return the "sharedByMe"…
276                    if (json != null && json.get("driveId") != null)
277                    {
278                        files.add(json);
279                    }
280                }
281                
282                // update the request builder to fetch the next result if needed
283                driveRequestBuilder = driveItemCollectionPage.getNextPage();
284            }
285            // loop until there is no more result to fetch or we have enough results
286            while (driveRequestBuilder != null && files.size() < maxResults);
287            
288            return Map.of("results", files);
289        }
290        catch (GraphClientException e)
291        {
292            getLogger().warn("Failed to get a Graph Client for user " + user, e);
293            return Map.of("error", "user-unknown");
294        }
295        catch (GraphServiceException e)
296        {
297            getLogger().warn("An error occured while contacting the graph endpoint for user " + user, e);
298            return Map.of("error", "request-failed");
299        }
300    }
301    
302    /**
303     * Get resources of a OneDrive folder
304     * @param user The user identity
305     * @param itemId the parent folder item id
306     * @param driveId the parent folder drive id or empty for the current user drive
307     * @param contextualParameters The contextual parameters
308     * @return the child resources
309     */
310    public Map<String, Object> getChildren(UserIdentity user, String itemId, String driveId, Map<String, Object> contextualParameters)
311    {
312        try
313        {
314            UserRequestBuilder requestBuilder = _graphClientProvider.getUserRequestBuilder(user);
315            
316            String userDriveId = requestBuilder.drive().buildRequest().select("id").get().id;
317            
318            DriveRequestBuilder driveRequestBuilder = StringUtils.isNotEmpty(driveId) ? requestBuilder.drives(driveId) : requestBuilder.drive();
319            
320            DriveItemCollectionRequestBuilder childrenRequestBuilder = driveRequestBuilder.items(itemId)
321                    .children();
322            
323            List<Map<String, Object>> files = new ArrayList<>();
324            
325            do
326            {
327                DriveItemCollectionPage driveItemCollectionPage = childrenRequestBuilder
328                        .buildRequest()
329                        .select(__DRIVE_ITEM_SELECT)
330                        .get();
331                
332                for (DriveItem item : driveItemCollectionPage.getCurrentPage())
333                {
334                    Map<String, Object> json = _driveItem2JSON(item, userDriveId);
335                    if (json != null)
336                    {
337                        files.add(json);
338                    }
339                }
340                
341                // update the request builder to fetch the next result if needed
342                childrenRequestBuilder = driveItemCollectionPage.getNextPage();
343            }
344            // loop until there is no more result to fetch
345            while (childrenRequestBuilder != null);
346            
347            return Map.of("results", files);
348        }
349        catch (GraphClientException e)
350        {
351            getLogger().warn("Failed to get a Graph Client for user " + user, e);
352            return Map.of("error", "user-unknown");
353        }
354        catch (GraphServiceException e)
355        {
356            getLogger().warn("An error occured while contacting the graph endpoint for user " + user, e);
357            return Map.of("error", "request-failed");
358        }
359    }
360    
361    /**
362     * Search resources into a OneDrive folder
363     * @param pattern the search pattern
364     * @param zoneItemId the zone item Id of the service
365     * @param contextualParameters The contextual parameters
366     * @return the child resources
367     */
368    @Callable(rights = Callable.SKIP_BUILTIN_CHECK)
369    public Map<String, Object> searchFiles(String zoneItemId, String pattern, Map<String, Object> contextualParameters)
370    {
371        UserIdentity currentUser = _currentUserProvider.getUser();
372        
373        try
374        {
375            UserRequestBuilder userRequestBuilder = _graphClientProvider.getUserRequestBuilder(currentUser);
376            
377            String userId = userRequestBuilder.buildRequest().select("id").get().id;
378            
379            ZoneItem zoneItem = _resolver.resolveById(zoneItemId);
380            ModelAwareDataHolder serviceParameters = zoneItem.getServiceParameters();
381            
382            Long maxResults = serviceParameters.getValue("max-search-results", false, 5L);
383            
384            DriveSearchCollectionRequestBuilder driveSearchRequestBuilder = userRequestBuilder.drive()
385                    .search(DriveSearchParameterSet.newBuilder().withQ(pattern).build());
386            
387            List<Map<String, Object>> results = new ArrayList<>();
388            boolean hasMore = true;
389            
390            do
391            {
392                DriveSearchCollectionPage driveSearchCollectionPage = driveSearchRequestBuilder
393                    .buildRequest()
394                    .select(__DRIVE_ITEM_SELECT)
395                    .top(maxResults.intValue())
396                    .get();
397                Iterator<DriveItem> iterator = driveSearchCollectionPage
398                        .getCurrentPage()
399                        .iterator();
400                
401                while (iterator.hasNext() && results.size() < maxResults)
402                {
403                    Map<String, Object> json = _driveItem2JSON(iterator.next(), userId);
404                    if (json != null)
405                    {
406                        results.add(json);
407                    }
408                }
409                
410                driveSearchRequestBuilder = driveSearchCollectionPage.getNextPage();
411                hasMore = iterator.hasNext() || driveSearchRequestBuilder != null;
412            }
413            while (driveSearchRequestBuilder != null && results.size() < maxResults && hasMore);
414            
415            if (hasMore)
416            {
417                // There is more results, provide a link to display the search in onedrive
418                String oneDriveUrl = userRequestBuilder.drive().buildRequest().select("webUrl").get().webUrl;
419                oneDriveUrl = oneDriveUrl + "?q=" + pattern + "*&searchScope=all";
420                return Map.of("results", results,
421                        "has-more", oneDriveUrl);
422            }
423            return Map.of("results", results);
424        }
425        catch (GraphServiceException e)
426        {
427            getLogger().warn("Failed to search drive for user " + currentUser, e);
428            return Map.of("error", "request-failed");
429        }
430        catch (GraphClientException e)
431        {
432            getLogger().warn("Failed to get a Graph Client for user " + currentUser, e);
433            return Map.of("error", "user-unknown");
434        }
435    }
436    
437    /**
438     * Get the JSON or null if the item should be ignored
439     */
440    private Map<String, Object> _driveItem2JSON(DriveItem item, String userDriveId)
441    {
442        if (StringUtils.equals(item.name, "OneNote_RecycleBin")
443                || StringUtils.equals(item.name, "OneNote_DeletedPages.one")
444                || StringUtils.endsWith(item.name, ".onetoc2"))
445        {
446            // Ignore those special folder or files from OneDrive
447            return null;
448        }
449        
450        Map<String, Object> json = new HashMap<>();
451        json.put("itemId", item.id);
452        json.put("name", item.name);
453        if (item.webUrl != null)
454        {
455            json.put("webUrl", item.webUrl);
456        }
457        else if (item.webDavUrl != null)
458        {
459            json.put("webUrl", item.webDavUrl);
460        }
461        
462        try
463        {
464            if (item.parentReference != null
465                    || item.remoteItem.parentReference != null)
466            {
467                String driveId = (item.parentReference != null) ? item.parentReference.driveId : item.remoteItem.parentReference.driveId;
468                if (!StringUtils.equals(driveId, userDriveId))
469                {
470                    json.put("driveId", driveId);
471                    _getItemLocation(item).ifPresent(location -> json.putAll(location));
472                }
473            }
474        }
475        catch (GraphClientException e)
476        {
477            getLogger().info("An error occured while retrieving the location of file " + item.name, e);
478        }
479        
480        // OneNote are represented as folder but we want to make them file
481        if (item.msgraphPackage != null && StringUtils.equals(item.msgraphPackage.type, "oneNote"))
482        {
483            json.putAll(Map.of(
484                    "type", "file",
485                    "mime-type", "application/msonenote",
486                    "size", item.size
487                    ));
488        }
489        else if (item.folder != null)
490        {
491            json.putAll(_folder2JSON(item));
492        }
493        else if (item.file != null)
494        {
495            json.putAll(_file2JSON(item));
496        }
497        return json;
498    }
499    
500    private Map<String, Object> _folder2JSON(DriveItem item)
501    {
502        
503        return Map.of(
504            "type", "folder",
505            "hasChildren", item.folder.childCount != 0
506        );
507    }
508    
509    private Map<String, Object> _file2JSON(DriveItem item)
510    {
511        return Map.of(
512            "type", "file",
513            "mime-type", item.file.mimeType,
514            "size", item.size
515        );
516    }
517    
518    private Optional<Map<String, Object>> _getItemLocation(DriveItem item) throws GraphClientException
519    {
520        // Use a sharepoint id as primary means to determine an item location
521        if (item.sharepointIds != null
522            || item.remoteItem != null && item.remoteItem.sharepointIds != null)
523        {
524            String siteId = item.sharepointIds != null ? item.sharepointIds.siteId : item.remoteItem.sharepointIds.siteId;
525            Site site = _graphClientProvider.getGraphClient().sites(siteId)
526                    .buildRequest()
527                    .select("displayName,webUrl")
528                    .get();
529            return Optional.of(_locationToI18N(site.displayName, StringUtils.contains(site.webUrl, "/personal/")));
530        }
531        
532        // Use the creator as a fallback
533        if (item.createdBy != null && item.createdBy.user != null)
534        {
535            return Optional.of(_locationToI18N(item.createdBy.user.displayName, true));
536        }
537        else if (item.remoteItem != null && item.remoteItem.createdBy != null && item.remoteItem.createdBy.user != null)
538        {
539            return Optional.of(_locationToI18N(item.remoteItem.createdBy.user.displayName, true));
540        }
541        
542        return Optional.empty();
543    }
544    
545    private Map<String, Object> _locationToI18N(String name, boolean isUser)
546    {
547        return isUser
548                ? Map.of("driveType", "user",
549                        "driveLabel", name)
550                : Map.of("driveType", "sharepoint",
551                        "driveLabel", name);
552    }
553}