View Javadoc
1   /*
2    * Licensed to The Apereo Foundation under one or more contributor license
3    * agreements. See the NOTICE file distributed with this work for additional
4    * information regarding copyright ownership.
5    *
6    *
7    * The Apereo Foundation licenses this file to you under the Educational
8    * Community License, Version 2.0 (the "License"); you may not use this file
9    * except in compliance with the License. You may obtain a copy of the License
10   * at:
11   *
12   *   http://opensource.org/licenses/ecl2.txt
13   *
14   * Unless required by applicable law or agreed to in writing, software
15   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
16   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
17   * License for the specific language governing permissions and limitations under
18   * the License.
19   *
20   */
21  package org.opencastproject.playlists;
22  
23  import static org.opencastproject.security.api.SecurityConstants.GLOBAL_ADMIN_ROLE;
24  
25  import org.opencastproject.elasticsearch.api.SearchIndexException;
26  import org.opencastproject.elasticsearch.api.SearchResult;
27  import org.opencastproject.elasticsearch.index.ElasticsearchIndex;
28  import org.opencastproject.elasticsearch.index.objects.event.Event;
29  import org.opencastproject.elasticsearch.index.objects.event.EventSearchQuery;
30  import org.opencastproject.playlists.persistence.PlaylistDatabaseException;
31  import org.opencastproject.playlists.persistence.PlaylistDatabaseService;
32  import org.opencastproject.playlists.serialization.JaxbPlaylist;
33  import org.opencastproject.playlists.serialization.JaxbPlaylistEntry;
34  import org.opencastproject.security.api.AccessControlEntry;
35  import org.opencastproject.security.api.AccessControlList;
36  import org.opencastproject.security.api.AuthorizationService;
37  import org.opencastproject.security.api.Organization;
38  import org.opencastproject.security.api.Permissions;
39  import org.opencastproject.security.api.SecurityService;
40  import org.opencastproject.security.api.UnauthorizedException;
41  import org.opencastproject.security.api.User;
42  import org.opencastproject.util.NotFoundException;
43  import org.opencastproject.util.requests.SortCriterion;
44  
45  import com.fasterxml.jackson.core.JsonProcessingException;
46  import com.fasterxml.jackson.databind.DeserializationFeature;
47  import com.fasterxml.jackson.databind.ObjectMapper;
48  import com.fasterxml.jackson.databind.ObjectReader;
49  import com.fasterxml.jackson.module.jaxb.JaxbAnnotationModule;
50  
51  import org.osgi.service.component.ComponentContext;
52  import org.osgi.service.component.annotations.Activate;
53  import org.osgi.service.component.annotations.Component;
54  import org.osgi.service.component.annotations.Reference;
55  import org.slf4j.Logger;
56  import org.slf4j.LoggerFactory;
57  
58  import java.util.ArrayList;
59  import java.util.Date;
60  import java.util.List;
61  
62  @Component(
63      property = {
64          "service.description=Playlist Service",
65          "service.pid=org.opencastproject.playlists.PlaylistService"
66      },
67      immediate = true,
68      service = { PlaylistService.class }
69  )
70  public class PlaylistService {
71    /** Logging facility */
72    private static final Logger logger = LoggerFactory.getLogger(PlaylistService.class);
73  
74    /** Persistent storage */
75    protected PlaylistDatabaseService persistence;
76  
77    /** The security service */
78    protected SecurityService securityService;
79  
80    /** The authorization service */
81    protected AuthorizationService authorizationService = null;
82  
83    private ElasticsearchIndex elasticsearchIndex;
84  
85    /**
86     * Callback to set the playlist database
87     *
88     * @param persistence
89     *          the playlist database
90     */
91    @Reference(name = "playlist-persistence")
92    public void setPersistence(PlaylistDatabaseService persistence) {
93      this.persistence = persistence;
94    }
95  
96    /**
97     * OSGi callback to set the security service.
98     *
99     * @param securityService
100    *          the securityService to set
101    */
102   @Reference(name = "security-service")
103   public void setSecurityService(SecurityService securityService) {
104     this.securityService = securityService;
105   }
106 
107   /**
108    * Callback for setting the authorization service.
109    *
110    * @param authorizationService
111    *          the authorizationService to set
112    */
113   @Reference
114   public void setAuthorizationService(AuthorizationService authorizationService) {
115     this.authorizationService = authorizationService;
116   }
117 
118   @Reference
119   void setElasticsearchIndex(ElasticsearchIndex elasticsearchIndex) {
120     this.elasticsearchIndex = elasticsearchIndex;
121   }
122 
123   @Activate
124   public void activate(ComponentContext cc) throws Exception {
125     logger.info("Activating Playlist Service");
126   }
127 
128   /**
129    * Returns a playlist from the database by its id
130    * @param id playlist id
131    * @return The {@link Playlist} belonging to the id
132    * @throws NotFoundException If no playlist with the given id could be found
133    * @throws IllegalStateException If something went wrong in the database service
134    * @throws UnauthorizedException If the user does not have read access for the playlist
135    */
136   public Playlist getPlaylistById(String id) throws NotFoundException, IllegalStateException, UnauthorizedException {
137     try {
138       Playlist playlist = persistence.getPlaylist(id);
139       if (!checkPermission(playlist, Permissions.Action.READ)) {
140         throw new UnauthorizedException("User does not have read permissions");
141       }
142       return playlist;
143     } catch (PlaylistDatabaseException e) {
144       throw new IllegalStateException("Could not get playlist from database with id ");
145     }
146   }
147 
148   /**
149    * Get multiple playlists from the database
150    * @param limit The maximum amount of playlists to get with one request.
151    * @param offset The index of the first result to return.
152    * @return A list of {@link Playlist}s
153    * @throws IllegalStateException If something went wrong in the database service
154    */
155   public List<Playlist> getPlaylists(int limit, int offset) throws IllegalStateException {
156     return getPlaylists(limit, offset, new SortCriterion("", SortCriterion.Order.None));
157   }
158 
159   public List<Playlist> getPlaylists(int limit, int offset, SortCriterion sortCriterion)
160           throws IllegalStateException {
161     try {
162       List<Playlist> playlists = persistence.getPlaylists(limit, offset, sortCriterion);
163       playlists.removeIf(playlist -> !checkPermission(playlist, Permissions.Action.READ));
164       return playlists;
165     } catch (PlaylistDatabaseException e) {
166       throw new IllegalStateException("Could not get playlist from database with id ");
167     }
168   }
169 
170   public List<Playlist> getAllForAdministrativeRead(Date from, Date to, int limit)
171           throws IllegalStateException, UnauthorizedException {
172     final var user = securityService.getUser();
173     final var orgAdminRole = securityService.getOrganization().getAdminRole();
174     if (!user.hasRole(GLOBAL_ADMIN_ROLE) && !user.hasRole(orgAdminRole)) {
175       throw new UnauthorizedException("Only (org-)admins can call this method");
176     }
177 
178     try {
179       return persistence.getAllForAdministrativeRead(from, to, limit);
180     } catch (PlaylistDatabaseException e) {
181       throw new IllegalStateException("Could not get playlist from database", e);
182     }
183   }
184 
185   /**
186    * Persist a new playlist in the database or update an existing one
187    * @param playlist The {@link Playlist} to create or update with
188    * @return The updated {@link Playlist}
189    * @throws IllegalStateException If something went wrong in the database service
190    * @throws UnauthorizedException If the user does not have write access for an existing playlist
191    */
192   public Playlist update(Playlist playlist)
193           throws IllegalStateException, UnauthorizedException, IllegalArgumentException {
194     try {
195       Playlist existingPlaylist = persistence.getPlaylist(playlist.getId());
196       if (!checkPermission(existingPlaylist, Permissions.Action.WRITE)) {
197         throw new UnauthorizedException("User does not have write permissions");
198       }
199 
200       // Validate entry IDs
201       for (PlaylistEntry entry : playlist.getEntries()) {
202         if (existingPlaylist.getEntries().stream().noneMatch(e -> entry.getId() == e.getId())) {
203           if (entry.getId() != 0) {
204             throw new IllegalArgumentException("When updating a playlist, entries should either have the id of an "
205                 + "existing entry, or no id at all.");
206           }
207         }
208       }
209       for (PlaylistAccessControlEntry entry : playlist.getAccessControlEntries()) {
210         if (existingPlaylist.getAccessControlEntries().stream().noneMatch(e -> entry.getId() == e.getId())) {
211           if (entry.getId() != 0) {
212             throw new IllegalArgumentException("When updating a playlist, ACL entries should either have the id of an "
213                 + "existing entry, or no id at all.");
214           }
215         }
216       }
217     } catch (NotFoundException e) {
218       // This means we are creating a new playlist
219       for (PlaylistEntry entry : playlist.getEntries()) {
220         if (entry.getId() != 0) {
221           throw new IllegalArgumentException("Entries for new playlists should not have identifiers set");
222         }
223       }
224       for (PlaylistAccessControlEntry entry : playlist.getAccessControlEntries()) {
225         if (entry.getId() != 0) {
226           throw new IllegalArgumentException("ACL Entries for new playlists should not have identifiers set");
227         }
228       }
229     } catch (PlaylistDatabaseException e) {
230       throw new IllegalStateException("Could not get playlist from database with id ");
231     }
232 
233     if (playlist.getOrganization() == null) {
234       playlist.setOrganization(securityService.getOrganization().getId());
235     }
236     for (PlaylistEntry entry : playlist.getEntries()) {
237       entry.setPlaylist(playlist);
238     }
239     for (PlaylistAccessControlEntry entry : playlist.getAccessControlEntries()) {
240       entry.setPlaylist(playlist);
241     }
242 
243     try {
244       playlist = persistence.updatePlaylist(playlist, securityService.getOrganization().getId());
245       return playlist;
246     } catch (PlaylistDatabaseException e) {
247       throw new IllegalStateException("Could not update playlist from database with id ");
248     }
249   }
250 
251   /**
252    * Overwrite an existing playlist with a playlist in JSON format.
253    * Only fields present in the JSON will be overwritten! Conversely, if a field is not present in the JSON,
254    * the field in the existing playlist will not change.
255    * @param id Identifier of the playlist to update.
256    * @param json JSON containing data to update the playlist with.
257    * @return The updated {@link Playlist}
258    */
259   public Playlist updateWithJson(String id, String json) throws JsonProcessingException, UnauthorizedException {
260     try {
261       Playlist existingPlaylist = persistence.getPlaylist(id);
262       if (!checkPermission(existingPlaylist, Permissions.Action.WRITE)) {
263         throw new UnauthorizedException("User does not have write permissions");
264       }
265 
266       JaxbAnnotationModule module = new JaxbAnnotationModule();
267       ObjectMapper objectMapper = new ObjectMapper();
268       objectMapper.registerModule(module);
269       objectMapper.enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY);
270 
271       ObjectReader updater = objectMapper.readerForUpdating(new JaxbPlaylist(existingPlaylist));
272       JaxbPlaylist merged = updater.readValue(json);
273 
274       return update(merged.toPlaylist());
275     } catch (NotFoundException | PlaylistDatabaseException e) {
276       throw new IllegalStateException("Could not get playlist from database with id " + id);
277     }
278   }
279 
280   /**
281    * Deletes a playlist from the database
282    * @param playlistId The playlist identifier
283    * @return The removed {@link Playlist}
284    * @throws NotFoundException If no playlist with the given id could be found
285    * @throws IllegalStateException If something went wrong in the database service
286    * @throws UnauthorizedException If the user does not have write access for the playlist
287    */
288   public Playlist remove(String playlistId)
289           throws NotFoundException, IllegalStateException, UnauthorizedException {
290     try {
291       Playlist playlist = persistence.getPlaylist(playlistId);
292       if (!checkPermission(playlist, Permissions.Action.WRITE)) {
293         throw new UnauthorizedException("User does not have write permissions");
294       }
295       playlist = persistence.deletePlaylist(playlist, securityService.getOrganization().getId());
296       return playlist;
297     } catch (PlaylistDatabaseException e) {
298       throw new IllegalStateException("Could not delete playlist from database with id " + playlistId);
299     }
300   }
301 
302   /**
303    * Replaces the entries in the playlist with the given entries
304    * @param playlistId identifier of the playlist to modify
305    * @param playlistEntries the new playlist entries
306    * @return {@link Playlist} with the new entries
307    * @throws NotFoundException If no playlist with the given id could be found
308    * @throws IllegalStateException If something went wrong in the database service
309    * @throws UnauthorizedException If the user does not have write access for the playlist
310    */
311   public Playlist updateEntries(String playlistId, List<PlaylistEntry> playlistEntries)
312           throws NotFoundException, IllegalStateException, UnauthorizedException {
313     Playlist playlist;
314     try {
315       playlist = persistence.getPlaylist(playlistId);
316     } catch (PlaylistDatabaseException e) {
317       throw new IllegalStateException(e);
318     }
319     if (!checkPermission(playlist, Permissions.Action.WRITE)) {
320       throw new UnauthorizedException("User does not have write permissions");
321     }
322     playlist.setEntries(playlistEntries);
323 
324     try {
325       playlist = persistence.updatePlaylist(playlist, securityService.getOrganization().getId());
326 
327       return playlist;
328     } catch (PlaylistDatabaseException e) {
329       throw new IllegalStateException("Could not delete playlist from database with id " + playlistId);
330     }
331   }
332 
333   /**
334    * Adds a new entry at the end of a playlist and persists it
335    * @param playlistId The playlist identifier
336    * @param contentId content (e.g. mediapacakge) identifier
337    * @param type arbitrary string
338    * @return {@link Playlist} with the new entry
339    * @throws NotFoundException If no playlist with the given id could be found
340    * @throws IllegalStateException If something went wrong in the database service
341    * @throws UnauthorizedException If the user does not have write access for the playlist
342    */
343   public Playlist addEntry(String playlistId, String contentId, PlaylistEntryType type)
344           throws NotFoundException, IllegalStateException, UnauthorizedException {
345     Playlist playlist;
346     try {
347       playlist = persistence.getPlaylist(playlistId);
348     } catch (PlaylistDatabaseException e) {
349       throw new IllegalStateException(e);
350     }
351     if (!checkPermission(playlist, Permissions.Action.WRITE)) {
352       throw new UnauthorizedException("User does not have write permissions");
353     }
354     PlaylistEntry playlistEntry = new PlaylistEntry();
355     playlistEntry.setContentId(contentId);
356     playlistEntry.setType(type);
357     playlist.addEntry(playlistEntry);
358 
359     try {
360       playlist = persistence.updatePlaylist(playlist, securityService.getOrganization().getId());
361 
362       return playlist;
363     } catch (PlaylistDatabaseException e) {
364       throw new IllegalStateException("Could not delete playlist from database with id " + playlistId);
365     }
366   }
367 
368   /**
369    * Removes an entry with the given id from the playlist and persists it
370    * @param playlistId The playlist identifier
371    * @param entryId The entry identifier
372    * @return {@link Playlist} without the entry
373    * @throws NotFoundException If no playlist with the given id could be found
374    * @throws IllegalStateException If something went wrong in the database service
375    * @throws UnauthorizedException If the user does not have write access for the playlist
376    */
377   public Playlist removeEntry(String playlistId, long entryId)
378           throws NotFoundException, IllegalStateException, UnauthorizedException {
379     Playlist playlist;
380     try {
381       playlist = persistence.getPlaylist(playlistId);
382       if (!checkPermission(playlist, Permissions.Action.WRITE)) {
383         throw new UnauthorizedException("User does not have write permissions");
384       }
385     } catch (PlaylistDatabaseException e) {
386       throw new IllegalStateException(e);
387     }
388 
389     playlist.removeEntry(
390         playlist.getEntries()
391             .stream()
392             .filter(e -> e.getId() == entryId)
393             .findFirst()
394             .get()
395     );
396 
397     try {
398       playlist = persistence.updatePlaylist(playlist, securityService.getOrganization().getId());
399       return playlist;
400     } catch (PlaylistDatabaseException e) {
401       throw new IllegalStateException("Could not delete playlist from database with id " + playlistId);
402     }
403   }
404 
405   /**
406    * Enrich each entry of a playlist with information about the content. Intended to be used by endpoints when
407    * returning information about a playlist. Currently only adds publication information for entries of type EVENT.
408    * @param playlist The playlist to enrich
409    * @return The serialization class of the playlist, since the added information does not belong to the playlist
410    * itself.
411    */
412   public JaxbPlaylist enrich(Playlist playlist) {
413     var jaxbPlaylist = new JaxbPlaylist(playlist);
414     var org = securityService.getOrganization().getId();
415     var user = securityService.getUser();
416 
417     // Add additional infos about events
418     List<JaxbPlaylistEntry> jaxbPlaylistEntries = jaxbPlaylist.getEntries();
419     for (JaxbPlaylistEntry entry : jaxbPlaylistEntries) {
420       String contentId = entry.getContentId();
421 
422       if (contentId == null || contentId.isEmpty()) {
423         entry.setType(PlaylistEntryType.INACCESSIBLE);
424         logger.warn("Entry {} has no content, marking as inaccessible", entry.getId());
425         continue;
426       }
427 
428       try {
429         if (entry.getType() == PlaylistEntryType.EVENT) {
430 
431           // We only get an event from the index if we have permission to do so (and if it exists ofc)
432           SearchResult<Event> result = elasticsearchIndex.getByQuery(
433               new EventSearchQuery(org, user).withIdentifier(contentId));
434           if (result.getPageSize() != 0) {
435             Event event = result.getItems()[0].getSource();
436             entry.setPublications(event.getPublications());
437           } else {
438             entry.setType(PlaylistEntryType.INACCESSIBLE);
439           }
440         }
441       } catch (SearchIndexException e) {
442         throw new RuntimeException(e);
443       }
444     }
445     jaxbPlaylist.setEntries(jaxbPlaylistEntries);
446 
447     return jaxbPlaylist;
448   }
449 
450   /**
451    * Runs a permission check on the given playlist for the given action
452    * @param playlist {@link Playlist} to check permission for
453    * @param action Action to check permission for
454    * @return True if action is permitted on the {@link Playlist}, else false
455    */
456   private boolean checkPermission(Playlist playlist, Permissions.Action action) {
457     User currentUser = securityService.getUser();
458     Organization currentOrg = securityService.getOrganization();
459     String currentOrgAdminRole = currentOrg.getAdminRole();
460     String currentOrgId = currentOrg.getId();
461 
462     return currentUser.hasRole(GLOBAL_ADMIN_ROLE)
463         || (currentUser.hasRole(currentOrgAdminRole) && currentOrgId.equals(playlist.getOrganization()))
464         || authorizationService.hasPermission(getAccessControlList(playlist), action.toString());
465   }
466 
467   /**
468    * Parse the access information for a playlist from its database format into an {@link AccessControlList}
469    * @param playlist The {@link Playlist} to get the {@link AccessControlList} for
470    * @return The {@link AccessControlList} for the given {@link Playlist}
471    */
472   private AccessControlList getAccessControlList(Playlist playlist) {
473     List<AccessControlEntry> accessControlEntries = new ArrayList<>();
474     for (PlaylistAccessControlEntry entry : playlist.getAccessControlEntries()) {
475       accessControlEntries.add(entry.toAccessControlEntry());
476     }
477     return new AccessControlList(accessControlEntries);
478   }
479 }