DocumentTemplateWebDavServlet.java
package com.taxonomy.templates;
import com.taxonomy.templates.DocumentTemplateGitRepository.TemplateConflictException;
import com.taxonomy.templates.DocumentTemplateGitRepository.TemplateDescriptor;
import com.taxonomy.templates.DocumentTemplateGitRepository.TemplateNotFoundException;
import com.taxonomy.templates.DocumentTemplateService.TemplateFile;
import com.taxonomy.templates.DocumentTemplateWebDavLockManager.LockConflictException;
import com.taxonomy.templates.DocumentTemplateWebDavLockManager.LockedWriteResult;
import com.taxonomy.templates.DocumentTemplateWebDavLockManager.TemplateLock;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.xml.stream.XMLOutputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamWriter;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.Principal;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Virtual WebDAV collection exposing only real DOTX resources.
*
* <p>The unzipped package files remain available through Git and the Taxonomy
* inspection API, but are intentionally not projected as WebDAV children.</p>
*/
public final class DocumentTemplateWebDavServlet extends HttpServlet {
private static final Logger log =
LoggerFactory.getLogger(DocumentTemplateWebDavServlet.class);
private static final int SC_MULTI_STATUS = 207;
private static final int SC_LOCKED = 423;
private static final int SC_PRECONDITION_REQUIRED = 428;
private static final String DAV_NAMESPACE = "DAV:";
private static final Pattern LOCK_TOKEN =
Pattern.compile("opaquelocktoken:[^>\\s)]+", Pattern.CASE_INSENSITIVE);
private static final DateTimeFormatter HTTP_DATE =
DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneOffset.UTC);
private static final XMLOutputFactory XML_OUTPUT_FACTORY =
XMLOutputFactory.newFactory();
private final DocumentTemplateService templates;
private final DocumentTemplateWebDavLockManager locks;
public DocumentTemplateWebDavServlet(
DocumentTemplateService templates,
DocumentTemplateWebDavLockManager locks) {
this.templates = templates;
this.locks = locks;
}
@Override
protected void service(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
addDavHeaders(response);
try {
switch (request.getMethod().toUpperCase(Locale.ROOT)) {
case "OPTIONS" -> options(response);
case "PROPFIND" -> propfind(request, response);
case "GET" -> get(request, response, true);
case "HEAD" -> get(request, response, false);
case "PUT" -> put(request, response);
case "LOCK" -> lock(request, response);
case "UNLOCK" -> unlock(request, response);
default -> {
response.setHeader("Allow",
"OPTIONS, PROPFIND, GET, HEAD, PUT, LOCK, UNLOCK");
response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
}
}
} catch (TemplateNotFoundException exception) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, exception.getMessage());
} catch (TemplateConflictException exception) {
response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED,
exception.getMessage());
} catch (LockConflictException exception) {
response.sendError(SC_LOCKED, exception.getMessage());
} catch (SecurityException exception) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, exception.getMessage());
} catch (IllegalArgumentException exception) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, exception.getMessage());
} catch (IOException exception) {
log.warn("WebDAV template operation failed: {}", exception.getMessage());
if (!response.isCommitted()) {
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
"Template operation failed");
}
}
}
private void options(HttpServletResponse response) {
response.setHeader("Allow", "OPTIONS, PROPFIND, GET, HEAD, PUT, LOCK, UNLOCK");
response.setStatus(HttpServletResponse.SC_OK);
}
private void propfind(HttpServletRequest request, HttpServletResponse response)
throws IOException {
Resource resource = resource(request);
String depth = request.getHeader("Depth");
response.setStatus(SC_MULTI_STATUS);
response.setContentType("application/xml");
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
try {
XMLStreamWriter writer = XML_OUTPUT_FACTORY.createXMLStreamWriter(
response.getOutputStream(), StandardCharsets.UTF_8.name());
writer.writeStartDocument(StandardCharsets.UTF_8.name(), "1.0");
start(writer, "multistatus");
writer.writeNamespace("D", DAV_NAMESPACE);
if (resource.collection()) {
writeCollectionResponse(writer, collectionHref(request));
if (!"0".equals(depth)) {
for (TemplateDescriptor descriptor : templates.list()) {
TemplateFile file = templates.downloadCurrent(
descriptor.templateId());
byte[] content = file.content();
writeTemplateResponse(
writer,
templateHref(request, file.manifest().fileName()),
file.manifest().displayName(),
file.commitId(),
file.manifest().updatedAt(),
content.length,
locks.find(descriptor.templateId()));
}
}
} else {
TemplateFile file = templates.downloadCurrent(resource.templateId());
byte[] content = file.content();
writeTemplateResponse(
writer,
templateHref(request, file.manifest().fileName()),
file.manifest().displayName(),
file.commitId(),
file.manifest().updatedAt(),
content.length,
locks.find(resource.templateId()));
}
writer.writeEndElement();
writer.writeEndDocument();
writer.flush();
} catch (XMLStreamException exception) {
throw new IOException("Could not produce WebDAV multistatus XML", exception);
}
}
private void get(
HttpServletRequest request,
HttpServletResponse response,
boolean includeBody) throws IOException {
Resource resource = resource(request);
if (resource.collection()) {
response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
return;
}
TemplateFile file = templates.downloadCurrent(resource.templateId());
byte[] content = file.content();
response.setContentType(OoxmlTemplatePackageCodec.DOTX_MEDIA_TYPE);
response.setHeader("ETag", file.etag());
response.setHeader("Last-Modified", HTTP_DATE.format(file.lastModified()));
response.setHeader("Content-Disposition",
"inline; filename=\"" + file.manifest().fileName() + "\"");
if (etagMatches(request.getHeader("If-None-Match"), file.commitId())) {
response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
return;
}
response.setContentLengthLong(content.length);
response.setStatus(HttpServletResponse.SC_OK);
if (includeBody) {
response.getOutputStream().write(content);
}
}
private void put(HttpServletRequest request, HttpServletResponse response)
throws IOException {
requireTemplateWriter(request);
Resource resource = resource(request);
if (resource.collection()) {
response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
return;
}
String owner = principalName(request);
TemplateFile current = currentOrNull(resource.templateId());
String suppliedToken = requestLockToken(request);
TemplateLock observedLock = locks.find(resource.templateId());
if (current != null
&& etagMatches(request.getHeader("If-None-Match"), current.commitId())) {
response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED,
"If-None-Match precondition failed for the current template");
return;
}
String ifMatch = request.getHeader("If-Match");
String expectedWithoutLock = null;
if (ifMatch != null && !ifMatch.isBlank()) {
// Evaluate every If-Match precondition even when a DAV lock is active.
// The service implements strong comparison and comma-separated tag lists.
expectedWithoutLock = templates.resolveExpectedVersion(
resource.templateId(), ifMatch);
} else if (observedLock == null && current != null) {
response.sendError(SC_PRECONDITION_REQUIRED,
"Use WebDAV LOCK or If-Match before replacing a template");
return;
}
String displayName = current == null
? resource.templateId()
: current.manifest().displayName();
String fallbackExpected = observedLock == null ? expectedWithoutLock : null;
TemplateDescriptor saved = locks.executeWrite(
resource.templateId(),
suppliedToken,
owner,
fallbackExpected,
expectedCommit -> {
TemplateDescriptor committed = templates.upload(
resource.templateId(),
displayName,
request.getInputStream(),
expectedCommit,
owner,
"Update document template through WebDAV");
return new LockedWriteResult<>(
committed, committed.headCommit());
});
response.setHeader("ETag", "\"" + saved.headCommit() + "\"");
response.setHeader("Location",
templateHref(request, saved.fileName()));
response.setStatus(current == null
? HttpServletResponse.SC_CREATED
: HttpServletResponse.SC_NO_CONTENT);
}
private void lock(HttpServletRequest request, HttpServletResponse response)
throws IOException {
requireTemplateWriter(request);
Resource resource = resource(request);
if (resource.collection()) {
response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
return;
}
String owner = principalName(request);
TemplateFile current = currentOrNull(resource.templateId());
String refreshToken = requestLockToken(request);
TemplateLock lock = locks.acquire(
resource.templateId(),
owner,
current == null ? null : current.commitId(),
parseTimeout(request.getHeader("Timeout")),
refreshToken);
response.setStatus(HttpServletResponse.SC_OK);
response.setHeader("Lock-Token", "<" + lock.token() + ">");
response.setContentType("application/xml");
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
writeLockResponse(
response,
lock,
templateHref(request, resource.templateId() + ".dotx"));
}
private void unlock(HttpServletRequest request, HttpServletResponse response) {
requireTemplateWriter(request);
Resource resource = resource(request);
if (resource.collection()) {
response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
return;
}
locks.release(
resource.templateId(),
request.getHeader("Lock-Token"),
principalName(request));
response.setStatus(HttpServletResponse.SC_NO_CONTENT);
}
private TemplateFile currentOrNull(String templateId) throws IOException {
try {
return templates.downloadCurrent(templateId);
} catch (TemplateNotFoundException exception) {
return null;
}
}
private static void requireTemplateWriter(HttpServletRequest request) {
if (request.isUserInRole("ADMIN")
|| request.isUserInRole("ROLE_ADMIN")) {
return;
}
throw new SecurityException(
"Administrator role is required to modify document templates");
}
private static String principalName(HttpServletRequest request) {
Principal principal = request.getUserPrincipal();
if (principal == null || principal.getName() == null
|| principal.getName().isBlank()) {
throw new SecurityException("Authentication is required");
}
return principal.getName();
}
private static Resource resource(HttpServletRequest request) {
String path = request.getPathInfo();
if (path == null || path.isBlank() || "/".equals(path)) {
return new Resource(true, null);
}
if (!path.startsWith("/") || path.indexOf('/', 1) >= 0
|| !path.endsWith(".dotx")) {
throw new IllegalArgumentException(
"WebDAV exposes only one level of .dotx template files");
}
String templateId = path.substring(1, path.length() - ".dotx".length());
DocumentTemplateGitRepository.validateTemplateId(templateId);
return new Resource(false, templateId);
}
private static String collectionHref(HttpServletRequest request) {
String contextPath = request.getContextPath();
return (contextPath == null ? "" : contextPath) + "/dav/templates/";
}
private static String templateHref(HttpServletRequest request, String fileName) {
return collectionHref(request) + encode(fileName);
}
private static String requestLockToken(HttpServletRequest request) {
String direct = request.getHeader("Lock-Token");
if (direct != null && !direct.isBlank()) {
return DocumentTemplateWebDavLockManager.normalizeToken(direct);
}
String condition = request.getHeader("If");
if (condition == null) {
return null;
}
Matcher matcher = LOCK_TOKEN.matcher(condition);
return matcher.find() ? matcher.group() : null;
}
private static Duration parseTimeout(String header) {
if (header == null || header.isBlank()) {
return DocumentTemplateWebDavLockManager.DEFAULT_TIMEOUT;
}
for (String candidate : header.split(",")) {
String value = candidate.strip();
if ("Infinite".equalsIgnoreCase(value)) {
return DocumentTemplateWebDavLockManager.MAX_TIMEOUT;
}
if (value.regionMatches(true, 0, "Second-", 0, 7)) {
try {
return Duration.ofSeconds(Long.parseLong(value.substring(7)));
} catch (NumberFormatException ignored) {
// Fall through to the safe default.
}
}
}
return DocumentTemplateWebDavLockManager.DEFAULT_TIMEOUT;
}
private static boolean etagMatches(String header, String commitId) {
if (header == null) {
return false;
}
for (String candidate : header.split(",")) {
String value = DocumentTemplateService.stripEtag(candidate);
if ("*".equals(value) || commitId.equals(value)) {
return true;
}
}
return false;
}
private static void addDavHeaders(HttpServletResponse response) {
response.setHeader("DAV", "1, 2");
response.setHeader("MS-Author-Via", "DAV");
response.setHeader("Cache-Control", "private, no-cache");
}
private static void writeCollectionResponse(
XMLStreamWriter writer,
String href) throws XMLStreamException {
start(writer, "response");
element(writer, "href", href);
start(writer, "propstat");
start(writer, "prop");
element(writer, "displayname", "Taxonomy document templates");
start(writer, "resourcetype");
empty(writer, "collection");
writer.writeEndElement();
writeSupportedLock(writer);
writer.writeEndElement();
element(writer, "status", "HTTP/1.1 200 OK");
writer.writeEndElement();
writer.writeEndElement();
}
private static void writeTemplateResponse(
XMLStreamWriter writer,
String href,
String displayName,
String commitId,
String updatedAt,
long contentLength,
TemplateLock lock) throws XMLStreamException {
start(writer, "response");
element(writer, "href", href);
start(writer, "propstat");
start(writer, "prop");
element(writer, "displayname", safeXmlText(displayName));
empty(writer, "resourcetype");
element(writer, "getcontenttype",
OoxmlTemplatePackageCodec.DOTX_MEDIA_TYPE);
element(writer, "getcontentlength", Long.toString(contentLength));
element(writer, "getetag", "\"" + commitId + "\"");
element(writer, "getlastmodified", httpDate(updatedAt));
writeSupportedLock(writer);
writeLockDiscovery(writer, lock, href);
writer.writeEndElement();
element(writer, "status", "HTTP/1.1 200 OK");
writer.writeEndElement();
writer.writeEndElement();
}
private static void writeSupportedLock(XMLStreamWriter writer)
throws XMLStreamException {
start(writer, "supportedlock");
start(writer, "lockentry");
start(writer, "lockscope");
empty(writer, "exclusive");
writer.writeEndElement();
start(writer, "locktype");
empty(writer, "write");
writer.writeEndElement();
writer.writeEndElement();
writer.writeEndElement();
}
private static void writeLockDiscovery(
XMLStreamWriter writer,
TemplateLock lock,
String href) throws XMLStreamException {
start(writer, "lockdiscovery");
if (lock != null) {
writeActiveLock(writer, lock, href);
}
writer.writeEndElement();
}
private static void writeActiveLock(
XMLStreamWriter writer,
TemplateLock lock,
String href) throws XMLStreamException {
start(writer, "activelock");
start(writer, "locktype");
empty(writer, "write");
writer.writeEndElement();
start(writer, "lockscope");
empty(writer, "exclusive");
writer.writeEndElement();
element(writer, "depth", "0");
start(writer, "owner");
element(writer, "href", safeXmlText(lock.owner()));
writer.writeEndElement();
element(writer, "timeout", "Second-" + Math.max(1,
Duration.between(Instant.now(), lock.expiresAt()).toSeconds()));
start(writer, "locktoken");
element(writer, "href", lock.token());
writer.writeEndElement();
start(writer, "lockroot");
element(writer, "href", href);
writer.writeEndElement();
writer.writeEndElement();
}
private static void writeLockResponse(
HttpServletResponse response,
TemplateLock lock,
String href) throws IOException {
try {
XMLStreamWriter writer = XML_OUTPUT_FACTORY.createXMLStreamWriter(
response.getOutputStream(), StandardCharsets.UTF_8.name());
writer.writeStartDocument(StandardCharsets.UTF_8.name(), "1.0");
start(writer, "prop");
writer.writeNamespace("D", DAV_NAMESPACE);
start(writer, "lockdiscovery");
writeActiveLock(writer, lock, href);
writer.writeEndElement();
writer.writeEndElement();
writer.writeEndDocument();
writer.flush();
} catch (XMLStreamException exception) {
throw new IOException("Could not produce WebDAV lock XML", exception);
}
}
private static void start(XMLStreamWriter writer, String localName)
throws XMLStreamException {
writer.writeStartElement("D", localName, DAV_NAMESPACE);
}
private static void empty(XMLStreamWriter writer, String localName)
throws XMLStreamException {
writer.writeEmptyElement("D", localName, DAV_NAMESPACE);
}
private static void element(
XMLStreamWriter writer,
String localName,
String value) throws XMLStreamException {
start(writer, localName);
writer.writeCharacters(safeXmlText(value));
writer.writeEndElement();
}
private static String safeXmlText(String value) {
if (value == null) {
return "";
}
StringBuilder safe = new StringBuilder(value.length());
for (int offset = 0; offset < value.length();) {
int codePoint = value.codePointAt(offset);
safe.appendCodePoint(DocumentTemplateService.isValidXml10CodePoint(codePoint)
? codePoint : 0xFFFD);
offset += Character.charCount(codePoint);
}
return safe.toString();
}
private static String httpDate(String instant) {
try {
return HTTP_DATE.format(Instant.parse(instant));
} catch (RuntimeException exception) {
return HTTP_DATE.format(Instant.EPOCH);
}
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8)
.replace("+", "%20");
}
private record Resource(boolean collection, String templateId) {
}
}