001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.microservice.examples; 018 019import static org.apache.juneau.commons.utils.CollectionUtils.*; 020import static org.apache.juneau.commons.utils.IoUtils.*; 021import static org.apache.juneau.commons.utils.Shorts.*; 022import static org.apache.juneau.commons.utils.StringUtils.*; 023 024import java.io.*; 025import java.nio.file.*; 026import java.time.*; 027import java.util.*; 028import java.util.stream.*; 029 030import org.apache.juneau.bean.*; 031import org.apache.juneau.commons.*; 032import org.apache.juneau.commons.bean.*; 033import org.apache.juneau.commons.utils.*; 034import org.apache.juneau.config.*; 035import org.apache.juneau.http.*; 036import org.apache.juneau.http.Path; 037import org.apache.juneau.http.classic.response.*; 038import org.apache.juneau.marshall.html.*; 039import org.apache.juneau.rest.server.*; 040import org.apache.juneau.rest.server.beans.*; 041import org.apache.juneau.rest.server.servlet.*; 042 043/** 044 * REST resource that allows access to a file system directory. 045 * 046 * <p> 047 * The root directory is specified in one of two ways: 048 * <ul class='spaced-list'> 049 * <li> 050 * Specifying the location via a <l>DirectoryResource.rootDir</l> property. 051 * <li> 052 * Overriding the {@link #getRootDir()} method. 053 * </ul> 054 * 055 * <p> 056 * Files are listed with <l>view</l> and <l>download</l> links, which reach the {@code VIEW} and {@code DOWNLOAD} 057 * operations through {@code ?method=}. Both are declared {@link RestOp#read() read=true}. Deleting a file needs a real 058 * {@code DELETE}, for example: 059 * <p class='bcode'> 060 * curl -X DELETE http://localhost:10000/<mount-path>/foo.txt 061 * </p> 062 * 063 * <p> 064 * Read/write access control is handled through the following properties: 065 * <ul class='spaced-list'> 066 * <li> 067 * <l>DirectoryResource.allowViews</l> - If <jk>true</jk>, allows view and download access to files. 068 * <li> 069 * <l>DirectoryResource.allowUploads</l> - If <jk>true</jk>, allows files to be created or overwritten. 070 * <li> 071 * <l>DirectoryResource.allowDeletes</l> - If <jk>true</jk>, allows files to be deleted. 072 * </ul> 073 * 074 * <p> 075 * <b>Note:</b> the {@code method} query parameter is accepted only for {@code HEAD}, {@code OPTIONS} and the two read 076 * verbs {@code VIEW} and {@code DOWNLOAD}, so a link can view or download a file but never change one. A subclass that 077 * widens {@code allowedMethodParams} still can't turn a {@code GET} into a write: the router refuses any override of a 078 * {@code GET} to a method that is not a read. Uploads and deletes still change files, so put the resource behind a 079 * CSRF boundary before mounting it anywhere a browser can reach. A boundary judges a {@code ?method=VIEW} or 080 * {@code ?method=DOWNLOAD} link as a write and refuses it, so behind one the listing's links stop working; send real 081 * {@code VIEW} or {@code DOWNLOAD} requests instead. 082 * 083 * <h5 class='section'>See Also:</h5><ul> 084 * <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/JuneauMicroservice">juneau-microservice</a> 085 * </ul> 086 * 087 * @serial exclude 088 */ 089@Rest( 090 title="File System Explorer", 091 messages="nls/DirectoryResource", 092 allowedMethodParams="HEAD,OPTIONS,VIEW,DOWNLOAD" 093) 094@HtmlDocConfig( 095 navlinks={ 096 "up: request:/..", 097 "api: servlet:/api" 098 } 099) 100@HtmlConfig(uriAnchorText="PROPERTY_NAME") 101@SuppressWarnings({ 102 "java:S115", // Constants use UPPER_snakeCase convention (e.g., DIRECTORY_RESOURCE_rootDir) 103 "resource" // Streams returned to servlet container; lifecycle managed by the container 104}) 105public class DirectoryResource extends BasicRestServlet { 106 107 /** File action link. */ 108 @Response 109 @Schema(description = "File action") 110 public static class Action extends LinkString { 111 112 /** 113 * Constructor. 114 * 115 * @param name Action name. 116 * @param uri Action URI. 117 * @param uriArgs URI format arguments. 118 */ 119 public Action(String name, String uri, Object...uriArgs) { 120 super(name, uri, uriArgs); 121 } 122 123 /** {@inheritDoc} */ 124 @Override /* Overridden from LinkString */ 125 public Action setName(String value) { 126 super.setName(value); 127 return this; 128 } 129 130 /** {@inheritDoc} */ 131 @Override /* Overridden from LinkString */ 132 public Action setUri(java.net.URI value) { 133 super.setUri(value); 134 return this; 135 } 136 137 /** {@inheritDoc} */ 138 @Override /* Overridden from LinkString */ 139 public Action setUri(String value) { 140 super.setUri(value); 141 return this; 142 } 143 144 /** {@inheritDoc} */ 145 @Override /* Overridden from LinkString */ 146 public Action setUri(String value, Object...args) { 147 super.setUri(value, args); 148 return this; 149 } 150 } 151 152 /** File or directory details for REST response. */ 153 @Response 154 @Schema(description = "File or directory details") 155 @BeanType(properties = "type,name,size,lastModified,actions,files") 156 public class FileResource { 157 private final File f; 158 private final String path; 159 private final String uri; 160 private final boolean includeChildren; 161 162 /** 163 * Constructor. 164 * 165 * @param f The file or directory. 166 * @param path The path. 167 * @param includeChildren Whether to include child files. 168 */ 169 public FileResource(File f, String path, boolean includeChildren) { 170 this.f = f; 171 this.path = path; 172 this.uri = "servlet:/" + (path == null ? "" : path); 173 this.includeChildren = includeChildren; 174 } 175 176 /** 177 * Returns available actions for this file (view, download). 178 * 179 * @return The list of actions. 180 */ 181 @Html(format = HtmlFormat.HTML_CDC) 182 public List<Action> getActions() { 183 List<Action> l = list(); 184 if (allowViews && f.canRead() && ! f.isDirectory()) { 185 l.add(new Action("view", uri + "?method=VIEW")); 186 l.add(new Action("download", uri + "?method=DOWNLOAD")); 187 } 188 return l; 189 } 190 191 /** 192 * Returns child files when this is a directory and includeChildren is <jk>true</jk>. 193 * 194 * @return Child files, or <jk>null</jk> when this is a file or includeChildren is false. 195 */ 196 @SuppressWarnings({ 197 "java:S1168" // Intentional null when file or !includeChildren. 198 }) 199 public Set<FileResource> getFiles() { 200 if (f.isFile() || ! includeChildren) 201 return null; 202 return Arrays.stream(f.listFiles()) 203 .map(fc -> new FileResource(fc, (nn(path) ? (path + '/') : "") + urlEncode(fc.getName()), false)) 204 .collect(Collectors.toCollection(() -> new TreeSet<>(new FileResourceComparator()))); 205 } 206 207 /** 208 * Returns the last modified date of the file. 209 * 210 * @return The last modified date as an {@link Instant}. 211 */ 212 public Instant getLastModified() { return Instant.ofEpochMilli(f.lastModified()); } 213 214 /** 215 * Returns the file name as a link. 216 * 217 * @return The name as a link. 218 */ 219 public LinkString getName() { return new LinkString(f.getName(), uri); } 220 221 /** 222 * Returns the file size, or number of items for directories. 223 * 224 * @return The size. 225 */ 226 public long getSize() { return f.isDirectory() ? f.listFiles().length : f.length(); } 227 228 /** 229 * Returns the type: "dir" or "file". 230 * 231 * @return The type. 232 */ 233 public String getType() { return (f.isDirectory() ? "dir" : "file"); } 234 } 235 236 @Response 237 @Schema(type = "string", format = "binary", description = "Contents of file") 238 static class FileContents extends FileInputStream { 239 240 /** 241 * Constructor. 242 * 243 * @param file The file to read. 244 * @throws FileNotFoundException If the file is not found. 245 */ 246 public FileContents(File file) throws FileNotFoundException { 247 super(file); 248 } 249 } 250 251 /** Comparator for sorting file resources (directories before files, then by name). */ 252 static class FileResourceComparator implements Comparator<FileResource>, Serializable { 253 private static final long serialVersionUID = 1L; 254 255 /** {@inheritDoc} */ 256 @Override /* Overridden from Comparator */ 257 public int compare(FileResource o1, FileResource o2) { 258 int c = o1.getType().compareTo(o2.getType()); 259 return c != 0 ? c : o1.getName().compareTo(o2.getName()); 260 } 261 } 262 263 @Response 264 @Schema(description = "Redirect to root page on success") 265 static class RedirectToRoot extends SeeOtherRoot {} 266 267 private static final long serialVersionUID = 1L; 268 private final File rootDir; // The root directory 269 270 // Properties enabled through servlet init parameters 271 final boolean allowDeletes; 272 final boolean allowUploads; 273 final boolean allowViews; 274 275 /** 276 * Constructor. 277 * 278 * @param c The microservice configuration. 279 */ 280 public DirectoryResource(Config c) { 281 rootDir = new File(c.get("DirectoryResource.rootDir.s").orElse(".")); 282 allowViews = c.get("DirectoryResource.allowViews.b").asBoolean().orElse(false); 283 allowDeletes = c.get("DirectoryResource.allowDeletes.b").asBoolean().orElse(false); 284 allowUploads = c.get("DirectoryResource.allowUploads.b").asBoolean().orElse(false); 285 } 286 /** 287 * Deletes a file on the file system. 288 * 289 * @param path The file path. 290 * @return Redirect to root on success. 291 * @throws MethodNotAllowed If deletes are not enabled. 292 */ 293 @RestDelete( 294 path="/*", 295 summary="Delete file", 296 description="Delete a file on the file system." 297 ) 298 public RedirectToRoot deleteFile(@Path("/*") String path) throws MethodNotAllowed { 299 deleteFile(getFile(path)); 300 return new RedirectToRoot(); 301 } 302 303 /** 304 * Downloads the contents of a file. 305 * 306 * @param res The response. 307 * @param path The file path. 308 * @return The file contents stream. 309 * @throws NotFound If the file is not found. 310 * @throws MethodNotAllowed If views are not enabled. 311 */ 312 @RestOp( 313 method="DOWNLOAD", 314 path="/*", 315 read=true, 316 summary="Download file", 317 description="Download the contents of a file.\nContent-Type is set to 'application/octet-stream'." 318 ) 319 public FileContents downloadFile(RestResponse res, @Path("/*") String path) throws NotFound, MethodNotAllowed { 320 if (! allowViews) 321 throw new MethodNotAllowed("DOWNLOAD not enabled"); 322 323 res.setContentType("application/octet-stream"); 324 try { 325 return new FileContents(getFile(path)); 326 } catch (@SuppressWarnings("unused") FileNotFoundException e) { 327 throw new NotFound("File not found"); 328 } 329 } 330 331 /** 332 * Returns information about the specified file or directory. 333 * 334 * @param req The request. 335 * @param path The file path. 336 * @return The file or directory details. 337 * @throws NotFound If the file is not found. 338 * @throws Exception If an error occurs. 339 */ 340 @RestGet( 341 path="/*", 342 summary="View information on file or directory", 343 description="Returns information about the specified file or directory." 344 ) 345 @HtmlDocConfig( 346 nav={"<h5>Folder: $RA{fullPath}</h5>"} 347 ) 348 @SuppressWarnings({ 349 "java:S112" // throws Exception intentional - callback/lifecycle method 350 }) 351 public FileResource getFile(RestRequest req, @Path("/*") String path) throws NotFound, Exception { 352 353 var dir = getFile(path); 354 req.setAttribute("fullPath", dir.getAbsolutePath()); 355 356 return new FileResource(dir, path, true); 357 } 358 359 /** 360 * Adds or overwrites a file on the file system. 361 * 362 * @param is The file contents. 363 * @param path The file path. 364 * @return Redirect to root on success. 365 * @throws InternalServerError If the write fails. 366 */ 367 @RestPut( 368 path="/*", 369 summary="Add or replace file", 370 description="Add or overwrite a file on the file system." 371 ) 372 public RedirectToRoot updateFile( 373 @Content @Schema(type="string",format="binary") InputStream is, 374 @Path("/*") String path 375 ) throws InternalServerError { 376 377 if (! allowUploads) 378 throw new MethodNotAllowed("PUT not enabled"); 379 380 var f = getFileForUpload(path); 381 382 try (var os = new BufferedOutputStream(new FileOutputStream(f))) { 383 pipe(is, os); 384 } catch (IOException e) { 385 throw new InternalServerError(e); 386 } 387 388 return new RedirectToRoot(); 389 } 390 391 /** 392 * Views the contents of a file. 393 * 394 * @param res The response. 395 * @param path The file path. 396 * @return The file contents stream. 397 * @throws NotFound If the file is not found. 398 * @throws MethodNotAllowed If views are not enabled. 399 */ 400 @RestOp( 401 method="VIEW", 402 path="/*", 403 read=true, 404 summary="View contents of file", 405 description="View the contents of a file.\nContent-Type is set to 'text/plain'." 406 ) 407 public FileContents viewFile(RestResponse res, @Path("/*") String path) throws NotFound, MethodNotAllowed { 408 if (! allowViews) 409 throw new MethodNotAllowed("VIEW not enabled"); 410 411 res.setContentType("text/plain"); 412 try { 413 return new FileContents(getFile(path)); 414 } catch (@SuppressWarnings("unused") FileNotFoundException e) { 415 throw new NotFound("File not found"); 416 } 417 } 418 419 private void deleteFile(File f) { 420 if (! allowDeletes) 421 throw new MethodNotAllowed("DELETE not enabled"); 422 if (f.isDirectory()) { 423 var files = f.listFiles(); 424 if (nn(files)) { 425 for (var fc : files) 426 deleteFile(fc); 427 } 428 } 429 try { 430 Files.delete(f.toPath()); 431 } catch (@SuppressWarnings("unused") IOException e) { 432 throw new Forbidden("Could not delete file %s", f.getAbsolutePath()); 433 } 434 } 435 436 /** 437 * Resolves a request path against {@link #rootDir} with a canonical-path boundary check 438 * so {@code ..} segments, absolute paths, and symlink-out cannot escape the configured root. 439 * 440 * <p> 441 * The check is intentionally applied at this single funnel because every public operation on 442 * this resource (view, download, delete, upload, info) resolves the user-supplied path through 443 * this method. Delegates to {@link FileUtils#resolveSafely(File, String)} — the canonical 444 * shared implementation; the caller-layer mapping is: boundary violation → 403 (Forbidden), 445 * non-existent target → 404 (NotFound). 446 */ 447 private File getFile(String path) throws NotFound { 448 try { 449 return FileUtils.resolveSafely(rootDir, path) 450 .orElseThrow(() -> new NotFound("File not found.")); 451 } catch (IllegalArgumentException e) { 452 throw new Forbidden(e.getMessage()); 453 } 454 } 455 456 /** 457 * Upload-path variant of {@link #getFile(String)} that allows the target file to not yet exist 458 * (so {@code PUT /new-file.txt} can create a new file) while still enforcing the boundary check 459 * by resolving the parent directory through {@link FileUtils#resolveSafely(File, String)}. 460 * 461 * <p> 462 * If the target itself exists, returns it directly (overwrite path). Otherwise, resolves the 463 * parent directory (which must exist and be inside the root), then appends the requested 464 * filename. The parent's existence requirement prevents implicit directory creation via PUT. 465 */ 466 private File getFileForUpload(String path) throws NotFound { 467 try { 468 var existing = FileUtils.resolveSafely(rootDir, path); 469 if (existing.isPresent()) 470 return existing.get(); 471 var parentPath = ""; 472 var name = path == null ? "" : path; 473 var slash = name.lastIndexOf('/'); 474 if (slash >= 0) { 475 parentPath = name.substring(0, slash); 476 name = name.substring(slash + 1); 477 } 478 if (name.isEmpty()) 479 throw new NotFound("File not found."); 480 var parent = FileUtils.resolveSafely(rootDir, parentPath) 481 .orElseThrow(() -> new NotFound("File not found.")); 482 return new File(parent, name); 483 } catch (IllegalArgumentException e) { 484 throw new Forbidden(e.getMessage()); 485 } 486 } 487 488 /** 489 * Returns the root directory. 490 * 491 * @return The root directory. 492 */ 493 protected File getRootDir() { return rootDir; } 494}