package org.swisspush.reststorage; import io.vertx.core.Handler; import io.vertx.core.Vertx; import io.vertx.core.buffer.Buffer; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import io.vertx.core.logging.Logger; import io.vertx.core.streams.Pump; import io.vertx.ext.web.Router; import io.vertx.ext.web.RoutingContext; import org.swisspush.reststorage.util.LockMode; import org.swisspush.reststorage.util.ResourceNameUtil; import org.swisspush.reststorage.util.StatusCode; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; public class RestStorageHandler implements Handler<HttpServerRequest> { private static final String EXPIRE_AFTER_HEADER = "x-expire-after"; private static final String ETAG_HEADER = "Etag"; private static final String IF_NONE_MATCH_HEADER = "if-none-match"; private static final String COMPRESS_HEADER = "x-stored-compressed"; private static final String LOCK_HEADER = "x-lock"; private static final String LOCK_MODE_HEADER = "x-lock-mode"; private static final String LOCK_EXPIRE_AFTER_HEADER = "x-lock-expire-after"; private static final String OFFSET_PARAMETER = "offset"; private static final String LIMIT_PARAMETER = "limit"; private static final String STORAGE_EXPAND_PARAMETER = "storageExpand"; private static final String CONTENT_TYPE = "Content-Type"; private static final String CONTENT_LENGTH = "Content-Length"; private Logger log; private Router router; private Storage storage; private MimeTypeResolver mimeTypeResolver = new MimeTypeResolver("application/json; charset=utf-8"); private Map<String, String> editors = new LinkedHashMap<>(); private String newMarker = "?new=true"; private String prefixFixed; private String prefix; public RestStorageHandler(Vertx vertx, final Logger log, final Storage storage, final String prefix, JsonObject editorConfig, final String lockPrefix) { this.router = Router.router(vertx); this.log = log; this.storage = storage; this.prefix = prefix; prefixFixed = prefix.equals("/") ? "" : prefix; if (editorConfig != null) { for (Entry<String, Object> entry : editorConfig.getMap().entrySet()) { editors.put(entry.getKey(), entry.getValue().toString()); } } router.postWithRegex(".*_cleanup").handler(this::cleanup); router.postWithRegex(prefixFixed + ".*").handler(this::storageExpand); router.getWithRegex(prefixFixed + ".*").handler(this::getResource); router.putWithRegex(prefixFixed + ".*").handler(this::putResource); router.deleteWithRegex(prefixFixed + ".*").handler(this::deleteResource); router.getWithRegex(".*").handler(this::getResourceNotFound); router.routeWithRegex(".*").handler(this::respondMethodNotAllowed); } @Override public void handle(HttpServerRequest request) { router.accept(request); } //////////////////////////// // Begin Router handling // //////////////////////////// private void respondMethodNotAllowed(RoutingContext ctx) { respondWithNotAllowed(ctx.request()); } private void cleanup(RoutingContext ctx) { if (log.isTraceEnabled()) { log.trace("RestStorageHandler cleanup"); } storage.cleanup(documentResource -> { if (log.isTraceEnabled()) { log.trace("RestStorageHandler cleanup"); } ctx.response().headers().add(CONTENT_LENGTH, "" + documentResource.length); ctx.response().headers().add(CONTENT_TYPE, "application/json; charset=utf-8"); ctx.response().setStatusCode(StatusCode.OK.getStatusCode()); final Pump pump = Pump.pump(documentResource.readStream, ctx.response()); documentResource.readStream.endHandler(nothing -> { documentResource.closeHandler.handle(null); ctx.response().end(); }); pump.start(); }, ctx.request().params().get("cleanupResourcesAmount")); } private void getResourceNotFound(RoutingContext ctx) { if (log.isTraceEnabled()) { log.trace("RestStorageHandler resource not found: " + ctx.request().uri()); } ctx.response().setStatusCode(StatusCode.NOT_FOUND.getStatusCode()); ctx.response().setStatusMessage(StatusCode.NOT_FOUND.getStatusMessage()); ctx.response().end(StatusCode.NOT_FOUND.toString()); } private void getResource(RoutingContext ctx){ final String path = cleanPath(ctx.request().path().substring(prefixFixed.length())); final String etag = ctx.request().headers().get(IF_NONE_MATCH_HEADER); if (log.isTraceEnabled()) { log.trace("RestStorageHandler got GET Request path: " + path + " etag: " + etag); } String offsetFromUrl = ctx.request().params().get(OFFSET_PARAMETER); String limitFromUrl = ctx.request().params().get(LIMIT_PARAMETER); OffsetLimit offsetLimit = UrlParser.offsetLimit(offsetFromUrl, limitFromUrl); storage.get(path, etag, offsetLimit.offset, offsetLimit.limit, new Handler<Resource>() { public void handle(Resource resource) { if (log.isTraceEnabled()) { log.trace("RestStorageHandler resource exists: " + resource.exists); } if(resource.error){ ctx.response().setStatusCode(StatusCode.INTERNAL_SERVER_ERROR.getStatusCode()); ctx.response().setStatusMessage(StatusCode.INTERNAL_SERVER_ERROR.getStatusMessage()); String message = StatusCode.INTERNAL_SERVER_ERROR.getStatusMessage(); if(resource.errorMessage != null){ message = resource.errorMessage; } ctx.response().end(message); return; } if (!resource.modified) { ctx.response().setStatusCode(StatusCode.NOT_MODIFIED.getStatusCode()); ctx.response().setStatusMessage(StatusCode.NOT_MODIFIED.getStatusMessage()); ctx.response().headers().set(ETAG_HEADER, etag); ctx.response().headers().add(CONTENT_LENGTH, "0"); ctx.response().end(); return; } if (resource.exists) { String accept = ctx.request().headers().get("Accept"); boolean html = (accept != null && accept.contains("text/html")); if (resource instanceof CollectionResource) { if (log.isTraceEnabled()) { log.trace("RestStorageHandler resource is collection: " + ctx.request().uri()); } CollectionResource collection = (CollectionResource) resource; String collectionName = collectionName(path); if (html && !ctx.request().uri().endsWith("/")) { if (log.isTraceEnabled()) { log.trace("RestStorageHandler accept contains text/html and ends with /"); } ctx.response().setStatusCode(StatusCode.FOUND.getStatusCode()); ctx.response().setStatusMessage(StatusCode.FOUND.getStatusMessage()); ctx.response().headers().add("Location", ctx.request().uri() + "/"); ctx.response().end(); } else if (html) { if (log.isTraceEnabled()) { log.trace("RestStorageHandler accept contains text/html"); } if (!(ctx.request().query() != null && ctx.request().query().contains("follow=off")) && collection.items.size() == 1 && collection.items.get(0) instanceof CollectionResource) { if (log.isTraceEnabled()) { log.trace("RestStorageHandler query contains follow=off"); } ctx.response().setStatusCode(StatusCode.FOUND.getStatusCode()); ctx.response().setStatusMessage(StatusCode.FOUND.getStatusMessage()); ctx.response().headers().add("Location", (ctx.request().uri()) + collection.items.get(0).name); ctx.response().end(); return; } StringBuilder body = new StringBuilder(); String editor = null; if (editors.size() > 0) { editor = editors.values().iterator().next(); } body.append("<!DOCTYPE html>\n"); body.append("<html><head><meta charset='utf-8'/><title>").append(collectionName).append("</title>"); body.append("<link href='//netdna.bootstrapcdn.com/twitter-bootstrap/2.3.1/css/bootstrap-combined.min.css' rel='stylesheet'></head>"); body.append("<body><div style='font-size: 2em; height:48px; border-bottom: 1px solid lightgray; color: darkgray'><div style='padding:12px;'>").append(htmlPath(prefix + path)).append("</div>"); if (editor != null) { String editorString = editor.replace("$path", path + (path.equals("/") ? "" : "/") + "$new"); editorString = editorString.replaceFirst("\\?", newMarker); body.append("<div style='position: fixed; top: 8px; right: 20px;'>" + "<input id='name' type='text' placeholder='New Resource\u2026' onkeydown='if (event.keyCode == 13) { if(document.getElementById(\"name\").value) {window.location=\"" + editorString + "\".replace(\"$new\",document.getElementById(\"name\").value);}}'></input></div>"); } body.append("</div><ul style='padding: 12px; font-size: 1.2em;' class='unstyled'><li><a href=\"../?follow=off\">..</a></li>"); List<String> sortedNames = sortedNames(collection); ResourceNameUtil.resetReplacedColonsAndSemiColonsInList(sortedNames); for (String name : sortedNames) { body.append("<li><a href=\"" + name + "\">" + name + "</a>"); body.append("</li>"); } body.append("</ul></body></html>"); ctx.response().headers().add(CONTENT_LENGTH, "" + body.length()); ctx.response().headers().add(CONTENT_TYPE, "text/html; charset=utf-8"); ctx.response().end(body.toString()); } else { JsonArray array = new JsonArray(); List<String> sortedNames = sortedNames(collection); ResourceNameUtil.resetReplacedColonsAndSemiColonsInList(sortedNames); sortedNames.forEach(array::add); if (log.isTraceEnabled()) { log.trace("RestStorageHandler return collection: " + sortedNames); } String body = new JsonObject().put(collectionName, array).encode(); ctx.response().headers().add(CONTENT_LENGTH, "" + body.length()); ctx.response().headers().add(CONTENT_TYPE, "application/json; charset=utf-8"); ctx.response().end(body); } } if (resource instanceof DocumentResource) { if (log.isTraceEnabled()) { log.trace("RestStorageHandler resource is a DocumentResource: " + ctx.request().uri()); } if (ctx.request().uri().endsWith("/")) { if (log.isTraceEnabled()) { log.trace("RestStorageHandler DocumentResource ends with /"); } ctx.response().setStatusCode(StatusCode.FOUND.getStatusCode()); ctx.response().setStatusMessage(StatusCode.FOUND.getStatusMessage()); ctx.response().headers().add("Location", ctx.request().uri().substring(0, ctx.request().uri().length() - 1)); ctx.response().end(); } else { if (log.isTraceEnabled()) { log.trace("RestStorageHandler DocumentResource does not end with /"); } String mimeType = mimeTypeResolver.resolveMimeType(path); if (ctx.request().headers().names().contains("Accept") && ctx.request().headers().get("Accept").contains("text/html")) { String editor = editors.get(mimeType.split(";")[0]); if (editor != null) { ctx.response().setStatusCode(StatusCode.FOUND.getStatusCode()); ctx.response().setStatusMessage(StatusCode.FOUND.getStatusMessage()); String editorString = editor.replaceAll("\\$path", path); ctx.response().headers().add("Location", editorString); ctx.response().end(); return; } } final DocumentResource documentResource = (DocumentResource) resource; if (documentResource.etag != null && !documentResource.etag.isEmpty()) { ctx.response().headers().add(ETAG_HEADER, documentResource.etag); } ctx.response().headers().add(CONTENT_LENGTH, "" + documentResource.length); ctx.response().headers().add(CONTENT_TYPE, mimeType); final Pump pump = Pump.pump(documentResource.readStream, ctx.response()); documentResource.readStream.endHandler(nothing -> { documentResource.closeHandler.handle(null); ctx.response().end(); }); pump.start(); // TODO: exception handlers } } } else { if (log.isTraceEnabled()) { log.trace("RestStorageHandler Could not find resource: " + ctx.request().uri()); } ctx.response().setStatusCode(StatusCode.NOT_FOUND.getStatusCode()); ctx.response().setStatusMessage(StatusCode.NOT_FOUND.getStatusMessage()); ctx.response().end(StatusCode.NOT_FOUND.toString()); } } private List<String> sortedNames(CollectionResource collection) { List<String> collections = new ArrayList<>(); List<String> documents = new ArrayList<>(); for (Resource r : collection.items) { String name = r.name; if (r instanceof CollectionResource) { collections.add(name + "/"); } else { documents.add(name); } } collections.addAll(documents); return collections; } }); } private void putResource(RoutingContext ctx) { ctx.request().pause(); final String path = cleanPath(ctx.request().path().substring(prefixFixed.length())); long expire = -1; // default infinit if (ctx.request().headers().contains(EXPIRE_AFTER_HEADER)) { try { expire = Long.parseLong(ctx.request().headers().get(EXPIRE_AFTER_HEADER)); } catch (NumberFormatException nfe) { ctx.request().resume(); ctx.response().setStatusCode(StatusCode.BAD_REQUEST.getStatusCode()); ctx.response().setStatusMessage("Invalid " + EXPIRE_AFTER_HEADER + " header: " + ctx.request().headers().get(EXPIRE_AFTER_HEADER)); ctx.response().end(ctx.response().getStatusMessage()); log.error(EXPIRE_AFTER_HEADER + " header, invalid value: " + ctx.response().getStatusMessage()); return; } } if (log.isTraceEnabled()) { log.trace("RestStorageHandler put resource: " + ctx.request().uri() + " with expire: " + expire); } String lock = ""; long lockExpire = 300; // default 300s LockMode lockMode = LockMode.SILENT; // default if ( ctx.request().headers().contains(LOCK_HEADER) ) { lock = ctx.request().headers().get(LOCK_HEADER); if (ctx.request().headers().contains(LOCK_MODE_HEADER)) { try { lockMode = LockMode.valueOf(ctx.request().headers().get(LOCK_MODE_HEADER).toUpperCase()); } catch (IllegalArgumentException e) { ctx.request().resume(); ctx.response().setStatusCode(StatusCode.BAD_REQUEST.getStatusCode()); ctx.response().setStatusMessage("Invalid " + LOCK_MODE_HEADER + " header: " + ctx.request().headers().get(LOCK_MODE_HEADER)); ctx.response().end(ctx.response().getStatusMessage()); log.error(LOCK_MODE_HEADER + " header, invalid value: " + ctx.response().getStatusMessage()); return; } } if (ctx.request().headers().contains(LOCK_EXPIRE_AFTER_HEADER)) { try { lockExpire = Long.parseLong(ctx.request().headers().get(LOCK_EXPIRE_AFTER_HEADER)); } catch (NumberFormatException nfe) { ctx.request().resume(); ctx.response().setStatusCode(StatusCode.BAD_REQUEST.getStatusCode()); ctx.response().setStatusMessage("Invalid " + LOCK_EXPIRE_AFTER_HEADER + " header: " + ctx.request().headers().get(LOCK_EXPIRE_AFTER_HEADER)); ctx.response().end(ctx.response().getStatusMessage()); log.error(LOCK_EXPIRE_AFTER_HEADER + " header, invalid value: " + ctx.response().getStatusMessage()); return; } } } boolean merge = (ctx.request().query() != null && ctx.request().query().contains("merge=true") && mimeTypeResolver.resolveMimeType(path).contains("application/json")); final String etag = ctx.request().headers().get(IF_NONE_MATCH_HEADER); boolean storeCompressed = Boolean.parseBoolean(ctx.request().headers().get(COMPRESS_HEADER)); if(merge && storeCompressed){ ctx.request().resume(); ctx.response().setStatusCode(StatusCode.BAD_REQUEST.getStatusCode()); ctx.response().setStatusMessage("Invalid parameter/header combination: merge parameter and " + COMPRESS_HEADER + " header cannot be used concurrently"); ctx.response().end(ctx.response().getStatusMessage()); return; } storage.put(path, etag, merge, expire, lock, lockMode, lockExpire, storeCompressed, resource -> { ctx.request().resume(); if(resource.error){ ctx.response().setStatusCode(StatusCode.INTERNAL_SERVER_ERROR.getStatusCode()); ctx.response().setStatusMessage(StatusCode.INTERNAL_SERVER_ERROR.getStatusMessage()); String message = StatusCode.INTERNAL_SERVER_ERROR.getStatusMessage(); if(resource.errorMessage != null){ message = resource.errorMessage; } ctx.response().end(message); return; } if (resource.rejected) { ctx.response().setStatusCode(StatusCode.CONFLICT.getStatusCode()); ctx.response().setStatusMessage(StatusCode.CONFLICT.getStatusMessage()); ctx.response().end(); return; } if (!resource.modified) { ctx.response().setStatusCode(StatusCode.NOT_MODIFIED.getStatusCode()); ctx.response().setStatusMessage(StatusCode.NOT_MODIFIED.getStatusMessage()); ctx.response().headers().set(ETAG_HEADER, etag); ctx.response().headers().add(CONTENT_LENGTH, "0"); ctx.response().end(); return; } if (!resource.exists && resource instanceof DocumentResource) { ctx.response().setStatusCode(StatusCode.METHOD_NOT_ALLOWED.getStatusCode()); ctx.response().setStatusMessage(StatusCode.METHOD_NOT_ALLOWED.getStatusMessage()); ctx.response().headers().add("Allow", "GET, DELETE"); ctx.response().end(); } if (resource instanceof CollectionResource) { ctx.response().setStatusCode(StatusCode.METHOD_NOT_ALLOWED.getStatusCode()); ctx.response().setStatusMessage(StatusCode.METHOD_NOT_ALLOWED.getStatusMessage()); ctx.response().headers().add("Allow", "GET, DELETE"); ctx.response().end(); } if (resource instanceof DocumentResource) { final DocumentResource documentResource = (DocumentResource) resource; documentResource.errorHandler = error -> { ctx.response().setStatusCode(StatusCode.BAD_REQUEST.getStatusCode()); ctx.response().setStatusMessage(StatusCode.BAD_REQUEST.getStatusMessage()); ctx.response().end(error); }; documentResource.endHandler = event -> ctx.response().end(); final Pump pump = Pump.pump(ctx.request(), documentResource.writeStream); ctx.request().endHandler(v -> documentResource.closeHandler.handle(null)); // TODO: exception handlers pump.start(); } }); } private void deleteResource(RoutingContext ctx) { final String path = cleanPath(ctx.request().path().substring(prefixFixed.length())); if (log.isTraceEnabled()) { log.trace("RestStorageHandler delete resource: " + ctx.request().uri()); } String lock = ""; long lockExpire = 300; // default 300s LockMode lockMode = LockMode.SILENT; // default if ( ctx.request().headers().contains(LOCK_HEADER) ) { lock = ctx.request().headers().get(LOCK_HEADER); if (ctx.request().headers().contains(LOCK_MODE_HEADER)) { try { lockMode = LockMode.valueOf(ctx.request().headers().get(LOCK_MODE_HEADER).toUpperCase()); } catch (IllegalArgumentException e) { ctx.request().resume(); ctx.response().setStatusCode(StatusCode.BAD_REQUEST.getStatusCode()); ctx.response().setStatusMessage("Invalid " + LOCK_MODE_HEADER + " header: " + ctx.request().headers().get(LOCK_MODE_HEADER)); ctx.response().end(ctx.response().getStatusMessage()); log.error(LOCK_MODE_HEADER + " header, invalid value: " + ctx.response().getStatusMessage()); return; } } if (ctx.request().headers().contains(LOCK_EXPIRE_AFTER_HEADER)) { try { lockExpire = Long.parseLong(ctx.request().headers().get(LOCK_EXPIRE_AFTER_HEADER)); } catch (NumberFormatException nfe) { ctx.request().resume(); ctx.response().setStatusCode(StatusCode.BAD_REQUEST.getStatusCode()); ctx.response().setStatusMessage("Invalid " + LOCK_EXPIRE_AFTER_HEADER + " header: " + ctx.request().headers().get(LOCK_EXPIRE_AFTER_HEADER)); ctx.response().end(ctx.response().getStatusMessage()); log.error(LOCK_EXPIRE_AFTER_HEADER + " header, invalid value: " + ctx.response().getStatusMessage()); return; } } } storage.delete(path, lock, lockMode, lockExpire, resource -> { if (resource.rejected) { ctx.response().setStatusCode(StatusCode.CONFLICT.getStatusCode()); ctx.response().setStatusMessage(StatusCode.CONFLICT.getStatusMessage()); ctx.response().end(); } else if (!resource.exists) { ctx.request().response().setStatusCode(StatusCode.NOT_FOUND.getStatusCode()); ctx.request().response().setStatusMessage(StatusCode.NOT_FOUND.getStatusMessage()); ctx.request().response().end(StatusCode.NOT_FOUND.toString()); } else { ctx.request().response().end(); } }); } private void storageExpand(RoutingContext ctx){ if (!ctx.request().params().contains(STORAGE_EXPAND_PARAMETER)) { respondWithNotAllowed(ctx.request()); } else { ctx.request().bodyHandler(new Handler<Buffer>() { @Override public void handle(Buffer event) { List<String> subResourceNames = new ArrayList<>(); try { JsonObject body = new JsonObject(event.toString()); JsonArray subResourcesArray = body.getJsonArray("subResources"); if (subResourcesArray == null) { respondWithBadRequest(ctx.request(), "Bad Request: Expected array field 'subResources' with names of resources"); return; } for (int i = 0; i < subResourcesArray.size(); i++) { subResourceNames.add(subResourcesArray.getString(i)); } ResourceNameUtil.replaceColonsAndSemiColonsInList(subResourceNames); } catch(RuntimeException ex){ respondWithBadRequest(ctx.request(), "Bad Request: Unable to parse body of storageExpand POST request"); return; } final String path = cleanPath(ctx.request().path().substring(prefixFixed.length())); final String etag = ctx.request().headers().get(IF_NONE_MATCH_HEADER); storage.storageExpand(path, etag, subResourceNames, resource -> { if(resource.error){ ctx.response().setStatusCode(StatusCode.CONFLICT.getStatusCode()); ctx.response().setStatusMessage(StatusCode.CONFLICT.getStatusMessage()); String message = StatusCode.CONFLICT.getStatusMessage(); if(resource.errorMessage != null){ message = resource.errorMessage; } ctx.response().end(message); return; } if(resource.invalid){ ctx.response().setStatusCode(StatusCode.INTERNAL_SERVER_ERROR.getStatusCode()); ctx.response().setStatusMessage(StatusCode.INTERNAL_SERVER_ERROR.getStatusMessage()); String message = StatusCode.INTERNAL_SERVER_ERROR.getStatusMessage(); if(resource.invalidMessage != null){ message = resource.invalidMessage; } ctx.response().end(new JsonObject().put("error", message).encode()); return; } if (!resource.modified) { ctx.response().setStatusCode(StatusCode.NOT_MODIFIED.getStatusCode()); ctx.response().setStatusMessage(StatusCode.NOT_MODIFIED.getStatusMessage()); ctx.response().headers().set(ETAG_HEADER, etag); ctx.response().headers().add(CONTENT_LENGTH, "0"); ctx.response().end(); return; } if (resource.exists) { if (log.isTraceEnabled()) { log.trace("RestStorageHandler resource is a DocumentResource: " + ctx.request().uri()); } String mimeType = mimeTypeResolver.resolveMimeType(path); final DocumentResource documentResource = (DocumentResource) resource; if (documentResource.etag != null && !documentResource.etag.isEmpty()) { ctx.response().headers().add(ETAG_HEADER, documentResource.etag); } ctx.response().headers().add(CONTENT_LENGTH, "" + documentResource.length); ctx.response().headers().add(CONTENT_TYPE, mimeType); final Pump pump = Pump.pump(documentResource.readStream, ctx.response()); documentResource.readStream.endHandler(nothing -> { documentResource.closeHandler.handle(null); ctx.response().end(); }); pump.start(); // TODO: exception handlers } else { if (log.isTraceEnabled()) { log.trace("RestStorageHandler Could not find resource: " + ctx.request().uri()); } ctx.response().setStatusCode(StatusCode.NOT_FOUND.getStatusCode()); ctx.response().setStatusMessage(StatusCode.NOT_FOUND.getStatusMessage()); ctx.response().end(StatusCode.NOT_FOUND.toString()); } }); } }); } } //////////////////////////// // End Router handling // //////////////////////////// private String cleanPath(String value) { value = value.replaceAll("\\.\\.", "").replaceAll("\\/\\/", "/"); while (value.endsWith("/")) { value = value.substring(0, value.length() - 1); } if (value.isEmpty()) { return "/"; } return value; } public static class OffsetLimit { public OffsetLimit(int offset, int limit) { this.offset = offset; this.limit = limit; } public int offset; public int limit; } private void respondWithNotAllowed(HttpServerRequest request) { request.response().setStatusCode(StatusCode.METHOD_NOT_ALLOWED.getStatusCode()); request.response().setStatusMessage(StatusCode.METHOD_NOT_ALLOWED.getStatusMessage()); request.response().end(StatusCode.METHOD_NOT_ALLOWED.toString()); } private void respondWithBadRequest(HttpServerRequest request, String responseMessage) { request.response().setStatusCode(StatusCode.BAD_REQUEST.getStatusCode()); request.response().setStatusMessage(StatusCode.BAD_REQUEST.getStatusMessage()); request.response().end(responseMessage); } private String collectionName(String path) { if (path.equals("/") || path.equals("")) { return "root"; } else { return path.substring(path.lastIndexOf("/") + 1); } } private String htmlPath(String path) { if (path.equals("/")) { return "/"; } StringBuilder sb = new StringBuilder(""); StringBuilder p = new StringBuilder(); String[] parts = path.split("/"); for (int i = 0; i < parts.length; i++) { String part = parts[i]; p.append(part); p.append("/"); if (i < parts.length - 1) { sb.append(" <a href=\""); sb.append(p); sb.append("?follow=off\">"); sb.append(part); sb.append("</a> > "); } else { sb.append(" "); sb.append(part); } } return sb.toString(); } }