WopiFileResource.java
/*
* Copyright (C) 2000 - 2021 Silverpeas
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* As a special exception to the terms and conditions of version 3.0 of
* the GPL, you may redistribute this Program in connection with Free/Libre
* Open Source Software ("FLOSS") applications as described in Silverpeas's
* FLOSS exception. You should have received a copy of the text describing
* the FLOSS exception, and it is also available here:
* "https://www.silverpeas.org/legal/floss_exception.html"
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.silverpeas.wbe.wopi;
import org.silverpeas.core.admin.user.model.User;
import org.silverpeas.core.annotation.WebService;
import org.silverpeas.core.util.JSONCodec;
import org.silverpeas.core.wbe.WbeFile;
import org.silverpeas.core.wbe.WbeUser;
import org.silverpeas.core.web.rs.annotation.Authenticated;
import org.silverpeas.core.webapi.wbe.AbstractWbeFileResource;
import org.silverpeas.core.webapi.wbe.WbeFileEditionContext;
import org.silverpeas.core.webapi.wbe.WbeFileWrapper;
import org.silverpeas.core.webapi.wbe.WbeResponseError;
import org.silverpeas.kernel.util.StringUtil;
import javax.inject.Inject;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.StreamingOutput;
import java.io.IOException;
import java.time.OffsetDateTime;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static java.text.MessageFormat.format;
import static java.time.OffsetDateTime.parse;
import static java.util.Optional.*;
import static javax.ws.rs.core.MediaType.APPLICATION_OCTET_STREAM;
import static javax.ws.rs.core.Response.Status.CONFLICT;
import static org.silverpeas.core.date.TemporalFormatter.toIso8601;
import static org.silverpeas.core.util.URLUtil.getFullApplicationURL;
import static org.silverpeas.core.util.URLUtil.getServerURL;
import static org.silverpeas.core.util.file.FileServerUtils.getImageURL;
import static org.silverpeas.core.wbe.WbeLogger.logger;
import static org.silverpeas.core.wbe.WbeSettings.getWbeUserIdPrefix;
import static org.silverpeas.kernel.util.StringUtil.*;
import static org.silverpeas.wbe.wopi.util.WopiSettings.*;
/**
* @author silveryocha
*/
@WebService
@Path("wbe/wopi/files/{fileId}")
@Authenticated
public class WopiFileResource extends AbstractWbeFileResource {
private static final String WOPI_OVERRIDE_HEADER = "X-WOPI-Override";
private static final String WOPI_USER_IDS_HEADER = "X-WOPI-ViewUserIds";
static final String WOPI_LOCK_HEADER = "X-WOPI-Lock";
private static final String LAST_MODIFIED_TIME_FIELD = "LastModifiedTime";
@Inject
private org.silverpeas.wbe.wopi.WopiLockResponseManager lockManager;
/**
* @see
* <a href="https://wopi.readthedocs.io/projects/wopirest/en/latest/endpoints.html#files-endpoint"> WOPI spec,
* files endpoint</a>
*/
@GET
public Response sendFileData() {
return process(() -> {
final WbeFileEditionContext context = getEditionContext();
final WbeFile file = context.getFile();
final WbeUser user = context.getUser();
final String json = JSONCodec.encodeObject(o -> {
final User spUser = user.asSilverpeas();
final boolean canBeModifiedBy = file.canBeModifiedBy(spUser);
final boolean webViewOnly = false;
final HttpServletRequest request = getSilverpeasContext().getRequest();
return o
// File
.put("OwnerId", getWbeUserIdPrefix() + file.owner())
.put("BaseFileName", file.name())
.put("Size", file.size())
.put("UserId", user.getId())
.put("Version", file.version())
.put(LAST_MODIFIED_TIME_FIELD, formatLastModifiedTime(file))
// Host capabilities
.put("SupportsContainers", false)
.put("SupportsDeleteFile", false)
.put("SupportsEcosystem", false)
.put("SupportsExtendedLockLength", false)
.put("SupportsFolders", false)
.put("SupportsGetLock", lockManager.isEnabled())
.put("SupportsLocks", lockManager.isEnabled())
.put("SupportsRename", false)
.put("SupportsUpdate", false)
.put("SupportsUserInfo", false)
// UI
.put("PostMessageOrigin", getServerURL(request))
.put("ClosePostMessage", true)
// User
.put("UserFriendlyName", spUser.getDisplayedName())
.putJSONObject("UserExtraInfo", e ->
e.put("avatar", getFullApplicationURL(request) + getImageURL(spUser.getAvatar(), "30x")))
// User Permissions
.put("ReadOnly", !canBeModifiedBy)
.put("DisablePrint", webViewOnly)
.put("DisableExport", webViewOnly)
.put("RestrictedWebViewOnly", webViewOnly)
.put("UserCanAttend", false)
.put("UserCanNotWriteRelative", true)
.put("UserCanPresent", false)
.put("UserCanRename", false)
.put("UserCanWrite", canBeModifiedBy);
}
);
return Response.ok().type(MediaType.APPLICATION_JSON).entity(json).build();
});
}
/**
* @see
* <a href="https://wopi.readthedocs.io/projects/wopirest/en/latest/endpoints.html#files-endpoint"> WOPI spec,
* files endpoint</a>
*/
@POST
public Response receiveFileData() {
return process(() -> {
final HttpServletRequest request = getSilverpeasContext().getRequest();
final String action = defaultStringIfNotDefined(request.getHeader(WOPI_OVERRIDE_HEADER));
final WbeFile file = getEditionContext().getFile();
final Optional<Response> response;
if ("SP_CURRENT_USERS".equals(action)) {
final Set<String> userIds = Stream
.of(ofNullable(request.getHeader(WOPI_USER_IDS_HEADER)).orElse("").split("[, ;]"))
.filter(StringUtil::isDefined)
.map(String::trim)
.collect(Collectors.toSet());
getHostManager().notifyEditionWith(file, userIds);
response = of(Response.ok().build());
} else if (action.contains("LOCK")) {
response = lockManager.manage(request, action, file);
} else {
response = empty();
}
return response.orElseGet(() -> Response.status(Response.Status.NOT_IMPLEMENTED).build());
});
}
/**
* @see
* <a href="https://wopi.readthedocs.io/projects/wopirest/en/latest/endpoints.html#file-contents-endpoint"> WOPI spec,
* file contents endpoint</a>
*/
@GET
@Path("contents")
public Response sendFileContentData() {
return process(() -> {
final WbeFileEditionContext context = getEditionContext();
final StreamingOutput streamingOutput = o -> context.getFile().loadInto(o);
return Response.ok(streamingOutput, APPLICATION_OCTET_STREAM).build();
});
}
/**
* @see
* <a href="https://wopi.readthedocs.io/projects/wopirest/en/latest/endpoints.html#file-contents-endpoint"> WOPI spec,
* file contents endpoint</a>
*/
@POST
@Path("contents")
public Response receiveFileContentData() {
return process(() -> {
final HttpServletRequest request = getSilverpeasContext().getRequest();
final WbeFileEditionContext context = getEditionContext();
final WbeFile file = context.getFile();
of(isLockCapabilityEnabled())
.filter(b -> b)
.map(b -> request.getHeader(WOPI_LOCK_HEADER))
.filter(l -> (isDefined(l) && !file.lock().exists()) || !file.lock().id().equals(l))
.ifPresent(l -> {
logger().debug(() -> format("WRITE CONFLICT because of not corresponding LOCK {0} on file {1}", l, file));
throw new WbeResponseError(Response.status(CONFLICT)
.header(WOPI_LOCK_HEADER, file.lock().id())
.build());
});
getTimestampVerificationElements().ifPresent(e -> {
final String timestampHeaderValue = request.getHeader(e.getFirst());
if (isDefined(timestampHeaderValue)) {
final OffsetDateTime timestampToVerify = parse(timestampHeaderValue);
logger().debug(() -> format("timestamp {0} verified on file {1}", timestampToVerify, file));
if (!timestampToVerify.isEqual(file.lastModificationDate())) {
logger().debug(() -> format("WRITE CONFLICT because of not corresponding timestamp {0} on file {1}", timestampToVerify, file));
throw new WbeResponseError(Response.status(CONFLICT)
.type(MediaType.APPLICATION_JSON).entity(e.getSecond())
.build());
}
} else {
logger().debug(() -> format("no timestamp verification on file {0}", file));
}
});
try {
file.updateFrom(getSilverpeasContext().getRequest().getInputStream());
} catch (IOException e) {
throw new WebApplicationException(e, Response.Status.NOT_FOUND);
}
getExitFieldNameDetection()
.filter(f -> getBooleanValue(request.getHeader(f)))
.ifPresent(f -> getHostManager().revokeFile(file));
final String json = JSONCodec.encodeObject(
o -> o.put(LAST_MODIFIED_TIME_FIELD, formatLastModifiedTime(file)));
return Response.ok().type(MediaType.APPLICATION_JSON).entity(json).build();
});
}
/**
* Formats for 'LastModifiedTime' field which MUST contains the ISO8601 round-trip time format
* indicating the new/updated file's modified time in storage after successful save.
* <p>
* WARNING six digits of nano part MUST exists event if there is no nano data.
* </p>
* @param file a {@link WbeFile} instance (which is indeed into the WOPI context a
* {@link WbeFileWrapper} instance).
* @return an ISO8601 formatted string.
*/
private String formatLastModifiedTime(final WbeFile file) {
return toIso8601(file.lastModificationDate(), true).replace("Z", ".000000Z");
}
@Override
protected WbeFileWrapper wrapWbeFile(final WbeFile file) {
return new org.silverpeas.wbe.wopi.WopiFileWrapper(file);
}
}