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/&lt;mount-path&gt;/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}