TobiraEndpoint.java
/*
* Licensed to The Apereo Foundation under one or more contributor license
* agreements. See the NOTICE file distributed with this work for additional
* information regarding copyright ownership.
*
*
* The Apereo Foundation licenses this file to you under the Educational
* Community License, Version 2.0 (the "License"); you may not use this file
* except in compliance with the License. You may obtain a copy of the License
* at:
*
* http://opensource.org/licenses/ecl2.txt
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*
*/
package org.opencastproject.tobira.impl;
import static javax.ws.rs.core.MediaType.APPLICATION_JSON;
import static javax.ws.rs.core.Response.Status.BAD_REQUEST;
import static org.opencastproject.util.doc.rest.RestParameter.Type;
import org.opencastproject.adopter.registration.AdopterRegistrationExtra;
import org.opencastproject.db.DBSession;
import org.opencastproject.db.DBSessionFactory;
import org.opencastproject.playlists.PlaylistService;
import org.opencastproject.search.api.SearchService;
import org.opencastproject.security.api.AuthorizationService;
import org.opencastproject.security.api.Role;
import org.opencastproject.security.api.SecurityService;
import org.opencastproject.security.api.User;
import org.opencastproject.security.api.UserDirectoryService;
import org.opencastproject.series.api.SeriesService;
import org.opencastproject.userdirectory.UserIdRoleProvider;
import org.opencastproject.util.Jsons;
import org.opencastproject.util.doc.rest.RestParameter;
import org.opencastproject.util.doc.rest.RestQuery;
import org.opencastproject.util.doc.rest.RestResponse;
import org.opencastproject.util.doc.rest.RestService;
import org.opencastproject.workspace.api.Workspace;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import org.apache.commons.io.IOUtils;
import org.osgi.framework.BundleContext;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsResource;
import org.osgi.service.metatype.annotations.Designate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.InputStream;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import javax.persistence.EntityManagerFactory;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.Response;
/**
* Tobira API Endpoint
*/
@Path("/tobira")
@RestService(
name = "TobiraApiEndpoint",
title = "Tobira API Endpoint",
abstractText = "Opencast Tobira API endpoint.",
notes = {
"This provides API endpoint used by Tobira to harvest media metadata. "
+ "This API is specifically designed for Tobira and there are no "
+ "stability guarantees for this API beyond what Tobira needs. "
+ "Thus, you should not use this API for any other purposes!"
}
)
@Component(
property = {
"service.description=Tobira-related APIs",
"opencast.service.type=org.opencastproject.tobira",
"opencast.service.path=/tobira",
"opencast.service.jobproducer=false"
},
immediate = true,
service = TobiraEndpoint.class
)
@JaxrsResource
@Designate(ocd = TobiraConfig.class)
public class TobiraEndpoint {
private static final Logger logger = LoggerFactory.getLogger(TobiraEndpoint.class);
// Versioning the Tobira API:
//
// Since both Tobira and this API are changing over time, we need some mechanism for ensuring they
// are compatible. We don't want to enforce a 1:1 thing, where a particular Tobira needs one
// exact API as that makes the update process harder (especially once this module is included in
// the community version). So instead we have some semver-like versioning here. Increase the
// minor version for backwards-compatible changes and the major version for breaking changes.
//
// Note that we cannot use the Opencast version as some institutions might want to patch their
// Opencast to include a newer Tobira module.
//
// So what's a breaking change and what not? For starters, the harvesting still needs to work with
// all Tobira versions that it worked with previously. Since Tobira ignores unknown fields,
// adding new JSON fields is a non-breaking change. You should also consider whether Tobira needs
// to resynchronize, i.e. to get new data.
private static final int VERSION_MAJOR = 1;
private static final int VERSION_MINOR = 6;
private static final String VERSION = VERSION_MAJOR + "." + VERSION_MINOR;
private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
private static final Gson gson = new Gson();
private SearchService searchService;
private SeriesService seriesService;
private AuthorizationService authorizationService;
private SecurityService securityService;
private PlaylistService playlistService;
private Workspace workspace;
private UserDirectoryService userDirectoryService;
private UserIdRoleProvider userIdRoleProvider;
private String callbackToken;
private Predicate<String> allowedRolesPattern;
private String headerName;
/** The factory used to generate the entity manager */
protected EntityManagerFactory emf = null;
protected DBSessionFactory dbSessionFactory;
protected DBSession db;
private JsonObject cachedStats = new JsonObject();
@Activate
public void activate(TobiraConfig tobiraConfig, BundleContext bundleContext) {
logger.info("Activated Tobira API");
callbackToken = tobiraConfig.callbackToken();
headerName = tobiraConfig.headerName();
allowedRolesPattern = Pattern.compile(tobiraConfig.allowedRolesPattern()).asMatchPredicate();
this.db = dbSessionFactory.createSession(emf);
}
/** OSGi DI */
@Reference(target = "(osgi.unit.name=org.opencastproject.adopter)")
void setEntityManagerFactory(EntityManagerFactory emf) {
this.emf = emf;
}
@Reference
public void setDBSessionFactory(DBSessionFactory dbSessionFactory) {
this.dbSessionFactory = dbSessionFactory;
}
@Reference
public void setSearchService(SearchService service) {
this.searchService = service;
}
@Reference
public void setSeriesService(SeriesService service) {
this.seriesService = service;
}
@Reference
public void setAuthorizationService(AuthorizationService service) {
this.authorizationService = service;
}
@Reference
public void setSecurityService(SecurityService service) {
this.securityService = service;
}
@Reference
public void setPlaylistService(PlaylistService service) {
this.playlistService = service;
}
@Reference
public void setWorkspace(Workspace workspace) {
this.workspace = workspace;
}
@Reference
public void setUserDirectoryService(UserDirectoryService service) {
this.userDirectoryService = service;
}
@GET
@Path("/version")
@Produces(APPLICATION_JSON)
@RestQuery(
name = "version",
description = "The Tobira Module API version",
restParameters = {},
responses = {
@RestResponse(description = "Version information", responseCode = HttpServletResponse.SC_OK)
},
returnDescription = "JSON object with string field 'version'"
)
public Response version() {
var body = Jsons.obj(Jsons.p("version", VERSION));
return Response.ok(body.toJson()).build();
}
@GET
@Path("/harvest")
@Produces(APPLICATION_JSON)
@RestQuery(
name = "harvest",
description = "Harvesting API to get incremental updates about series and events.",
restParameters = {
@RestParameter(
name = "preferredAmount",
isRequired = true,
description = "A preferred number of items the request should return. This is "
+ "merely a rough guideline and the API might return more or fewer items than "
+ "this parameter. You cannot rely on an exact number of returned items! "
+ "In practice this API usually returns between 0 and twice this parameter "
+ "number of items.",
type = Type.INTEGER
),
@RestParameter(
name = "since",
isRequired = true,
description = "Only return items that changed after or at this timestamp. "
+ "Specified in milliseconds since 1970-01-01T00:00:00Z.",
type = Type.INTEGER
),
},
responses = {
@RestResponse(description = "Event and Series Data", responseCode = HttpServletResponse.SC_OK)
},
returnDescription = "Event and Series Data changed after the given timestamp"
)
public Response harvest(
@QueryParam("preferredAmount") Integer preferredAmount,
@QueryParam("since") Long since
) {
// Parameter error handling
if (since == null) {
return badRequest("Required parameter 'since' not specified");
}
if (preferredAmount == null) {
return badRequest("Required parameter 'preferredAmount' not specified");
}
if (since < 0) {
return badRequest("Parameter 'since' < 0, but it has to be positive or 0");
}
if (preferredAmount <= 0) {
return badRequest("Parameter 'preferredAmount' <= 0, but it has to be positive");
}
logger.debug("Request to '/harvest' with preferredAmount={} since={}", preferredAmount, since);
try {
var json = Harvest.harvest(
preferredAmount,
new Date(since),
searchService, seriesService, authorizationService, securityService, playlistService, workspace);
// TODO: encoding
return Response.ok(json.toJson()).build();
} catch (Exception e) {
logger.error("Unexpected exception in tobira/harvest", e);
return Response.serverError().build();
}
}
@GET
@Path("/callback/{token}")
@Produces(APPLICATION_JSON)
@RestQuery(
name = "callback",
description = "Auth callback API to get user information. This is used by Tobira to get user information.",
pathParameters = {
@RestParameter(
name = "token",
isRequired = true,
description = "The token to authorize the request.",
type = Type.STRING
)
},
responses = {
@RestResponse(description = "User Outcome Data", responseCode = HttpServletResponse.SC_OK)
},
returnDescription = "Returns user information"
)
public Response callback(@PathParam("token") String token, @Context HttpHeaders headers) {
if (callbackToken == null || !callbackToken.equals(token)) {
return badRequest("Invalid token or callback disabled");
}
List<String> username = headers.getRequestHeader(headerName);
if (username == null) {
return badRequest("No username header provided");
}
User user = null;
if (!username.isEmpty() && username.get(0) != null) {
user = userDirectoryService.loadUser(username.get(0));
}
Jsons.Obj outcome;
if (user == null) {
outcome = Jsons.obj(
Jsons.p("outcome", "no-user")
);
} else {
outcome = Jsons.obj(
Jsons.p("outcome", "user"),
Jsons.p("username", user.getUsername()),
Jsons.p("displayName", user.getName()),
Jsons.p("email", user.getEmail()),
Jsons.p("userRole",UserIdRoleProvider.getUserIdRole(user.getUsername())),
Jsons.p("roles", Jsons.arr(
user.getRoles().stream()
.map(Role::getName)
.filter(allowedRolesPattern)
.map(Jsons::v)
.collect(Collectors.toList())))
);
}
return Response.ok(outcome.toJson()).build();
}
private static Response badRequest(String msg) {
logger.warn("Bad request to tobira/harvest: {}", msg);
return Response.status(BAD_REQUEST).entity(msg).build();
}
/* Since CXF doesn't seem to like accepting multiple types on a single endpoint, this is what Tobira currently sends:
curl http://localhost/tobira/stats \
-H "Authorization: Basic YWRtaW46b3BlbmNhc3Q=" \
-H "Content-Type: application/json" \
-d '{
"num_realms": 3641,
"num_blocks": 5544,
"version": {
"identifier": "v3.4",
"build_time_utc": "Fri, 27 Jun 2025 09:13:53 +0000",
"git_commit_hash": "d421b168cc6a004dd008f4b8bc0de4070cd99c2c",
"git_was_dirty": true,
"target": "aarch64-apple-darwin"
},
"config": {
"download_button_shown": true,
"auth_source": "tobira-session",
"login_credentials_handler": "login-callback",
"session_endpoint_handler": "none",
"login_link_overridden": false,
"logout_link_overridden": false,
"uses_pre_auth": true
}
}' \
-v
*/
@POST
@Path("/stats")
@Consumes(APPLICATION_JSON)
@RestQuery(
name = "stats",
description = "Accepts a json blob of statistical data about Tobira. To test this properly see the code.",
responses = {
@RestResponse(description = "Stats parsed", responseCode = HttpServletResponse.SC_ACCEPTED)
},
returnDescription = "No data returned, just a 204 on success"
)
public Response acceptStats(@Context HttpServletRequest request) {
try (InputStream is = request.getInputStream()) {
String body = IOUtils.toString(is, request.getCharacterEncoding());
cachedStats = gson.fromJson(body, JsonElement.class).getAsJsonObject();
} catch (IOException e) {
logger.error("Error reading request body:", e);
return badRequest("Error reading response body");
} catch (IllegalStateException e) {
return Response.notAcceptable(null).build();
}
cachedStats.addProperty("updated", sdf.format(Calendar.getInstance().getTime()));
db.execTx(em -> {
AdopterRegistrationExtra existing = em.find(AdopterRegistrationExtra.class, "tobira");
if (null != existing) {
existing.setData(gson.toJson(cachedStats));
em.merge(existing);
} else {
existing = new AdopterRegistrationExtra("tobira", gson.toJson(cachedStats));
em.persist(existing);
}
});
return Response.noContent().build();
}
@GET
@Path("/stats")
@Produces(APPLICATION_JSON)
@RestQuery(name = "stats",
description = "Returns the stats, if any, pushed from Tobira",
returnDescription = "The stats, or an empty object",
responses = {
@RestResponse(description = "The stats, or an empty object", responseCode = HttpServletResponse.SC_OK)
})
public Response getCachedStats() {
return Response.ok(gson.toJson(cachedStats)).build();
}
}