001/* 002 * Copyright 2022-2026 Revetware LLC. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package com.soklet; 018 019import org.jspecify.annotations.NonNull; 020import org.jspecify.annotations.Nullable; 021 022import javax.annotation.concurrent.NotThreadSafe; 023import javax.annotation.concurrent.ThreadSafe; 024import java.io.IOException; 025import java.io.InputStream; 026import java.io.UncheckedIOException; 027import java.nio.charset.StandardCharsets; 028import java.nio.file.FileSystems; 029import java.nio.file.Files; 030import java.nio.file.InvalidPathException; 031import java.nio.file.LinkOption; 032import java.nio.file.Path; 033import java.nio.file.attribute.BasicFileAttributes; 034import java.security.MessageDigest; 035import java.security.NoSuchAlgorithmException; 036import java.time.Instant; 037import java.util.Collections; 038import java.util.HexFormat; 039import java.util.LinkedHashMap; 040import java.util.LinkedHashSet; 041import java.util.List; 042import java.util.Map; 043import java.util.Optional; 044import java.util.Set; 045 046import static java.lang.String.format; 047import static java.util.Objects.requireNonNull; 048 049/** 050 * Safe helper for serving files from a configured root directory. 051 * 052 * @author <a href="https://www.revetkn.com">Mark Allen</a> 053 */ 054@ThreadSafe 055public final class StaticFiles { 056 @NonNull 057 private static final Integer MAX_RELATIVE_PATH_UTF_8_LENGTH; 058 @NonNull 059 private static final Integer MAX_RELATIVE_PATH_SEGMENTS; 060 @NonNull 061 private static final Integer CONTENT_HASH_BUFFER_SIZE; 062 @NonNull 063 private static final HexFormat HEX_FORMAT; 064 065 static { 066 MAX_RELATIVE_PATH_UTF_8_LENGTH = 4096; 067 MAX_RELATIVE_PATH_SEGMENTS = 256; 068 CONTENT_HASH_BUFFER_SIZE = 8192; 069 HEX_FORMAT = HexFormat.of(); 070 } 071 072 @NonNull 073 private final Path root; 074 @NonNull 075 private final Path noFollowRoot; 076 @NonNull 077 private final Path followRoot; 078 @NonNull 079 private final List<@NonNull String> indexFileNames; 080 @NonNull 081 private final MimeTypeResolver mimeTypeResolver; 082 @NonNull 083 private final EntityTagResolver entityTagResolver; 084 @NonNull 085 private final AccessResolver accessResolver; 086 @NonNull 087 private final LastModifiedResolver lastModifiedResolver; 088 @NonNull 089 private final CacheControlResolver cacheControlResolver; 090 @NonNull 091 private final HeadersResolver headersResolver; 092 @NonNull 093 private final RangeRequestsResolver rangeRequestsResolver; 094 @NonNull 095 private final Boolean followSymlinks; 096 097 /** 098 * Begins configuration for serving files under {@code root}. 099 * <p> 100 * Relative roots are resolved against the JVM's current working directory when the builder is 101 * built. Production deployments should generally pass an absolute path. 102 * <p> 103 * File matching follows the underlying filesystem's case-sensitivity and Unicode normalization 104 * rules. Production deployments that need portable behavior across operating systems should 105 * standardize on ASCII-safe asset names. 106 * 107 * @param root the static-file root directory 108 * @return a builder for static-file responses 109 */ 110 @NonNull 111 public static Builder withRoot(@NonNull Path root) { 112 requireNonNull(root); 113 return new Builder(root); 114 } 115 116 private StaticFiles(@NonNull Builder builder) { 117 requireNonNull(builder); 118 119 this.root = builder.root.toAbsolutePath().normalize(); 120 this.followSymlinks = builder.followSymlinks == null ? false : builder.followSymlinks; 121 this.indexFileNames = List.copyOf(builder.indexFileNames == null ? List.of() : builder.indexFileNames); 122 this.mimeTypeResolver = defaultingMimeTypeResolver(builder.mimeTypeResolver); 123 this.entityTagResolver = builder.entityTagResolver == null ? EntityTagResolver.defaultInstance() : builder.entityTagResolver; 124 this.accessResolver = builder.accessResolver == null ? AccessResolver.allowAllInstance() : builder.accessResolver; 125 this.lastModifiedResolver = builder.lastModifiedResolver == null ? LastModifiedResolver.fromAttributes() : builder.lastModifiedResolver; 126 this.cacheControlResolver = builder.cacheControlResolver == null ? CacheControlResolver.disabledInstance() : builder.cacheControlResolver; 127 this.headersResolver = builder.headersResolver == null ? HeadersResolver.disabledInstance() : builder.headersResolver; 128 this.rangeRequestsResolver = builder.rangeRequestsResolver == null ? RangeRequestsResolver.enabledInstance() : builder.rangeRequestsResolver; 129 130 for (String indexFileName : this.indexFileNames) 131 validateIndexFileName(indexFileName); 132 133 try { 134 this.noFollowRoot = this.root.toRealPath(LinkOption.NOFOLLOW_LINKS); 135 this.followRoot = this.root.toRealPath(); 136 } catch (IOException e) { 137 throw new UncheckedIOException(format("Unable to resolve static file root '%s'.", this.root), e); 138 } 139 140 if (getFollowSymlinks()) { 141 if (!Files.isDirectory(this.followRoot)) 142 throw new IllegalArgumentException(format("Static file root '%s' is not a directory.", this.root)); 143 } else if (!Files.isDirectory(this.noFollowRoot, LinkOption.NOFOLLOW_LINKS)) { 144 throw new IllegalArgumentException(format("Static file root '%s' is not a directory.", this.root)); 145 } 146 } 147 148 /** 149 * Produces a marshaled response for {@code relativePath}, if the request targets a safe, readable 150 * file under the configured root. 151 * <p> 152 * Returns {@link Optional#empty()} for HTTP methods other than {@link HttpMethod#GET} and 153 * {@link HttpMethod#HEAD}, unsafe path input, directories without a configured index file, 154 * unreadable files, and missing files. Once a path resolves, non-{@code 200} file responses such as 155 * {@code 206}, {@code 304}, {@code 412}, and {@code 416} are returned inside the optional. 156 * <p> 157 * Resolvers receive the resolved file path after index-file resolution, not the original 158 * {@code relativePath}. 159 * 160 * @param relativePath the root-relative file path 161 * @param request the incoming request 162 * @return the marshaled response, if a static file was resolved 163 */ 164 @NonNull 165 public Optional<MarshaledResponse> marshaledResponseFor(@NonNull String relativePath, 166 @NonNull Request request) { 167 requireNonNull(relativePath); 168 requireNonNull(request); 169 170 if (request.getHttpMethod() != HttpMethod.GET && request.getHttpMethod() != HttpMethod.HEAD) 171 return Optional.empty(); 172 173 ResolvedFile resolvedFile = resolvedFileFor(relativePath).orElse(null); 174 175 if (resolvedFile == null) 176 return Optional.empty(); 177 178 Path file = resolvedFile.path(); 179 BasicFileAttributes attributes = resolvedFile.attributes(); 180 Access access = requireNonNull(getAccessResolver().accessFor(file, attributes), "accessResolver returned null; return Access.ALLOW, Access.DENY, or Access.HIDE."); 181 182 if (access == Access.HIDE) 183 return Optional.empty(); 184 185 if (access == Access.DENY) 186 // Access denial is not a file representation, so validators and file headers do not apply. 187 return Optional.of(MarshaledResponse.fromStatusCode(StatusCode.HTTP_403.getStatusCode())); 188 189 EntityTag entityTag = requireNonNull(getEntityTagResolver().entityTagFor(file, attributes), "entityTagResolver returned null; use Optional.empty() to omit the header.").orElse(null); 190 Instant lastModified = requireNonNull(getLastModifiedResolver().lastModifiedFor(file, attributes), "lastModifiedResolver returned null; use Optional.empty() to omit the header.").orElse(null); 191 String cacheControl = requireNonNull(getCacheControlResolver().cacheControlFor(file, attributes), "cacheControlResolver returned null; use Optional.empty() to omit the header.").orElse(null); 192 Map<String, Set<String>> headers = requireNonNull(getHeadersResolver().headersFor(file, attributes), "headersResolver returned null; return an empty map to omit extra headers."); 193 Boolean rangeRequests = requireNonNull(getRangeRequestsResolver().rangeRequestsFor(file, attributes), "rangeRequestsResolver returned null; return false to disable range requests."); 194 String contentType = requireNonNull(getMimeTypeResolver().contentTypeFor(file), "mimeTypeResolver returned null; use Optional.empty() to omit Content-Type.").orElse(null); 195 196 MarshaledResponse response = MarshaledResponse.withFile(file, request, attributes) 197 .contentType(contentType) 198 .entityTag(entityTag) 199 .lastModified(lastModified) 200 .cacheControl(cacheControl) 201 .headers(headers) 202 .rangeRequests(rangeRequests) 203 .build(); 204 205 return Optional.of(response); 206 } 207 208 @NonNull 209 private Path getResolutionRoot() { 210 return getFollowSymlinks() ? this.followRoot : this.noFollowRoot; 211 } 212 213 @NonNull 214 private Boolean getFollowSymlinks() { 215 return this.followSymlinks; 216 } 217 218 @NonNull 219 private List<@NonNull String> getIndexFileNames() { 220 return this.indexFileNames; 221 } 222 223 @NonNull 224 private MimeTypeResolver getMimeTypeResolver() { 225 return this.mimeTypeResolver; 226 } 227 228 @NonNull 229 private EntityTagResolver getEntityTagResolver() { 230 return this.entityTagResolver; 231 } 232 233 @NonNull 234 private AccessResolver getAccessResolver() { 235 return this.accessResolver; 236 } 237 238 @NonNull 239 private LastModifiedResolver getLastModifiedResolver() { 240 return this.lastModifiedResolver; 241 } 242 243 @NonNull 244 private CacheControlResolver getCacheControlResolver() { 245 return this.cacheControlResolver; 246 } 247 248 @NonNull 249 private HeadersResolver getHeadersResolver() { 250 return this.headersResolver; 251 } 252 253 @NonNull 254 private RangeRequestsResolver getRangeRequestsResolver() { 255 return this.rangeRequestsResolver; 256 } 257 258 @NonNull 259 private Optional<ResolvedFile> resolvedFileFor(@NonNull String relativePath) { 260 requireNonNull(relativePath); 261 262 if (relativePath.getBytes(StandardCharsets.UTF_8).length > MAX_RELATIVE_PATH_UTF_8_LENGTH) 263 return Optional.empty(); 264 265 if (relativePath.split("/", -1).length > MAX_RELATIVE_PATH_SEGMENTS) 266 return Optional.empty(); 267 268 if (containsControlCharacter(relativePath)) 269 return Optional.empty(); 270 271 if (relativePath.isEmpty() && getIndexFileNames().isEmpty()) 272 return Optional.empty(); 273 274 if (looksLikeWindowsDrivePath(relativePath) || looksLikeUncPath(relativePath) || relativePath.indexOf('\\') >= 0) 275 return Optional.empty(); 276 277 Path relative; 278 279 try { 280 relative = FileSystems.getDefault().getPath(relativePath); 281 } catch (InvalidPathException e) { 282 return Optional.empty(); 283 } 284 285 if (relative.isAbsolute()) 286 return Optional.empty(); 287 288 for (Path segment : relative) { 289 if ("..".equals(segment.toString())) 290 return Optional.empty(); 291 } 292 293 Path candidate = getResolutionRoot().resolve(relative).normalize(); 294 295 if (!candidate.startsWith(getResolutionRoot())) 296 return Optional.empty(); 297 298 return resolvedFileForCandidate(candidate); 299 } 300 301 @NonNull 302 private Optional<ResolvedFile> resolvedFileForCandidate(@NonNull Path candidate) { 303 requireNonNull(candidate); 304 305 if (getFollowSymlinks()) 306 return followedResolvedFileForCandidate(candidate); 307 308 return noFollowResolvedFileForCandidate(candidate); 309 } 310 311 @NonNull 312 private Optional<ResolvedFile> noFollowResolvedFileForCandidate(@NonNull Path candidate) { 313 requireNonNull(candidate); 314 315 if (hasSymlinkComponent(candidate)) 316 return Optional.empty(); 317 318 if (Files.isDirectory(candidate, LinkOption.NOFOLLOW_LINKS)) { 319 for (String indexFileName : getIndexFileNames()) { 320 Path indexCandidate = candidate.resolve(indexFileName).normalize(); 321 322 if (!indexCandidate.startsWith(getResolutionRoot()) || hasSymlinkComponent(indexCandidate)) 323 continue; 324 325 Optional<ResolvedFile> resolvedIndexFile = noFollowRegularFile(indexCandidate); 326 327 if (resolvedIndexFile.isPresent()) 328 return resolvedIndexFile; 329 } 330 331 return Optional.empty(); 332 } 333 334 return noFollowRegularFile(candidate); 335 } 336 337 @NonNull 338 private Optional<ResolvedFile> noFollowRegularFile(@NonNull Path candidate) { 339 requireNonNull(candidate); 340 341 if (!Files.isRegularFile(candidate, LinkOption.NOFOLLOW_LINKS) || !Files.isReadable(candidate)) 342 return Optional.empty(); 343 344 try { 345 return Optional.of(new ResolvedFile(candidate, Files.readAttributes(candidate, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS))); 346 } catch (IOException e) { 347 return Optional.empty(); 348 } 349 } 350 351 @NonNull 352 private Optional<ResolvedFile> followedResolvedFileForCandidate(@NonNull Path candidate) { 353 requireNonNull(candidate); 354 355 Path realCandidate; 356 357 try { 358 realCandidate = candidate.toRealPath(); 359 } catch (IOException e) { 360 return Optional.empty(); 361 } 362 363 if (!realCandidate.startsWith(this.followRoot)) 364 return Optional.empty(); 365 366 if (Files.isDirectory(realCandidate)) { 367 for (String indexFileName : getIndexFileNames()) { 368 Path indexCandidate = realCandidate.resolve(indexFileName).normalize(); 369 370 if (!indexCandidate.startsWith(this.followRoot)) 371 continue; 372 373 Optional<ResolvedFile> resolvedIndexFile = followedRegularFile(indexCandidate); 374 375 if (resolvedIndexFile.isPresent()) 376 return resolvedIndexFile; 377 } 378 379 return Optional.empty(); 380 } 381 382 return followedRegularFile(realCandidate); 383 } 384 385 @NonNull 386 private Optional<ResolvedFile> followedRegularFile(@NonNull Path candidate) { 387 requireNonNull(candidate); 388 389 if (!Files.isRegularFile(candidate) || !Files.isReadable(candidate)) 390 return Optional.empty(); 391 392 try { 393 Path realCandidate = candidate.toRealPath(); 394 395 if (!realCandidate.startsWith(this.followRoot)) 396 return Optional.empty(); 397 398 return Optional.of(new ResolvedFile(realCandidate, Files.readAttributes(realCandidate, BasicFileAttributes.class))); 399 } catch (IOException e) { 400 return Optional.empty(); 401 } 402 } 403 404 @NonNull 405 private Boolean hasSymlinkComponent(@NonNull Path candidate) { 406 requireNonNull(candidate); 407 408 Path relative = getResolutionRoot().relativize(candidate); 409 Path current = getResolutionRoot(); 410 411 for (Path segment : relative) { 412 current = current.resolve(segment); 413 414 if (Files.isSymbolicLink(current)) 415 return true; 416 } 417 418 return false; 419 } 420 421 private static void validateIndexFileName(@NonNull String indexFileName) { 422 requireNonNull(indexFileName); 423 424 if (indexFileName.isEmpty() 425 || containsControlCharacter(indexFileName) 426 || indexFileName.indexOf('/') >= 0 427 || indexFileName.indexOf('\\') >= 0 428 || ".".equals(indexFileName) 429 || "..".equals(indexFileName) 430 || looksLikeWindowsDrivePath(indexFileName) 431 || looksLikeUncPath(indexFileName)) 432 throw new IllegalArgumentException(format("Invalid index file name '%s'.", indexFileName)); 433 } 434 435 @NonNull 436 private static Boolean containsControlCharacter(@NonNull String value) { 437 requireNonNull(value); 438 439 for (int i = 0; i < value.length(); i++) { 440 if (Character.isISOControl(value.charAt(i))) 441 return true; 442 } 443 444 return false; 445 } 446 447 @NonNull 448 private static Boolean looksLikeWindowsDrivePath(@NonNull String value) { 449 requireNonNull(value); 450 return value.length() >= 2 && Character.isLetter(value.charAt(0)) && value.charAt(1) == ':'; 451 } 452 453 @NonNull 454 private static Boolean looksLikeUncPath(@NonNull String value) { 455 requireNonNull(value); 456 return value.startsWith("//") || value.startsWith("\\\\"); 457 } 458 459 @NonNull 460 private static MimeTypeResolver defaultingMimeTypeResolver(@Nullable MimeTypeResolver userResolver) { 461 return userResolver == null ? MimeTypeResolver.defaultInstance() : userResolver; 462 } 463 464 @NonNull 465 private static Map<@NonNull String, @NonNull Set<@NonNull String>> copyHeaders( 466 @NonNull Map<@NonNull String, @NonNull Set<@NonNull String>> headers) { 467 requireNonNull(headers); 468 469 Map<String, Set<String>> copiedHeaders = new LinkedHashMap<>(); 470 471 for (Map.Entry<String, Set<String>> entry : headers.entrySet()) { 472 String headerName = requireNonNull(entry.getKey()); 473 Set<String> copiedHeaderValues = new LinkedHashSet<>(requireNonNull(entry.getValue())); 474 copiedHeaderValues.forEach(value -> requireNonNull(value, format("Header '%s' includes a null value.", headerName))); 475 copiedHeaders.put(headerName, Collections.unmodifiableSet(copiedHeaderValues)); 476 } 477 478 return Collections.unmodifiableMap(copiedHeaders); 479 } 480 481 private record ResolvedFile(@NonNull Path path, 482 @NonNull BasicFileAttributes attributes) {} 483 484 /** 485 * Builder for {@link StaticFiles} instances. 486 * 487 * @author <a href="https://www.revetkn.com">Mark Allen</a> 488 */ 489 @NotThreadSafe 490 public static final class Builder { 491 @NonNull 492 private final Path root; 493 @Nullable 494 private List<@NonNull String> indexFileNames; 495 @Nullable 496 private MimeTypeResolver mimeTypeResolver; 497 @Nullable 498 private EntityTagResolver entityTagResolver; 499 @Nullable 500 private AccessResolver accessResolver; 501 @Nullable 502 private LastModifiedResolver lastModifiedResolver; 503 @Nullable 504 private CacheControlResolver cacheControlResolver; 505 @Nullable 506 private HeadersResolver headersResolver; 507 @Nullable 508 private RangeRequestsResolver rangeRequestsResolver; 509 @Nullable 510 private Boolean followSymlinks; 511 512 private Builder(@NonNull Path root) { 513 requireNonNull(root); 514 this.root = root; 515 } 516 517 /** 518 * Sets the index file names to try when a request path resolves to a directory. 519 * <p> 520 * Names are tried in list order. Each name must be a single file name, not a path; blank 521 * names, {@code .}, {@code ..}, path separators, control characters, Windows drive paths, and 522 * UNC-style paths are rejected when the builder is built. 523 * 524 * @param indexFileNames the index file names, or {@code null} for no index files 525 * @return this builder 526 */ 527 @NonNull 528 public Builder indexFileNames(@Nullable List<@NonNull String> indexFileNames) { 529 this.indexFileNames = indexFileNames; 530 return this; 531 } 532 533 /** 534 * Sets the resolver used to produce {@code Content-Type} values. 535 * <p> 536 * The configured resolver fully replaces the default resolver. Soklet's default resolver uses a 537 * curated deterministic set of common web-asset extensions, returns {@link Optional#empty()} for 538 * unknown extensions, and does not call {@link Files#probeContentType(Path)}. Applications that 539 * need OS-level MIME database behavior can configure a resolver that calls 540 * {@code Files.probeContentType(...)} directly. 541 * 542 * @param mimeTypeResolver the resolver to use, or {@code null} to restore the default resolver 543 * @return this builder 544 */ 545 @NonNull 546 public Builder mimeTypeResolver(@Nullable MimeTypeResolver mimeTypeResolver) { 547 this.mimeTypeResolver = mimeTypeResolver; 548 return this; 549 } 550 551 /** 552 * Sets the resolver used to produce {@code ETag} values. 553 * <p> 554 * Passing {@code null} restores the default weak metadata-based resolver. 555 * 556 * @param entityTagResolver the resolver to use, or {@code null} to restore the default resolver 557 * @return this builder 558 */ 559 @NonNull 560 public Builder entityTagResolver(@Nullable EntityTagResolver entityTagResolver) { 561 this.entityTagResolver = entityTagResolver; 562 return this; 563 } 564 565 /** 566 * Sets the resolver used to decide whether resolved files are served, denied, or hidden. 567 * <p> 568 * The access resolver runs after path safety, index-file resolution, symlink policy, 569 * readability, regular-file checks, and attribute reads, but before MIME, validator, 570 * cache-control, extra-header, and range-request resolvers. It receives the resolved file path, 571 * not the original request path. 572 * 573 * @param accessResolver the resolver to use, or {@code null} to restore the allow-all default 574 * @return this builder 575 */ 576 @NonNull 577 public Builder accessResolver(@Nullable AccessResolver accessResolver) { 578 this.accessResolver = accessResolver; 579 return this; 580 } 581 582 /** 583 * Sets the resolver used to produce {@code Last-Modified} values. 584 * <p> 585 * Passing {@code null} restores the default resolver, which uses the file attributes read for 586 * the response. 587 * 588 * @param lastModifiedResolver the resolver to use, or {@code null} to restore the default resolver 589 * @return this builder 590 */ 591 @NonNull 592 public Builder lastModifiedResolver(@Nullable LastModifiedResolver lastModifiedResolver) { 593 this.lastModifiedResolver = lastModifiedResolver; 594 return this; 595 } 596 597 /** 598 * Sets the resolver used to produce {@code Cache-Control} values. 599 * <p> 600 * Passing {@code null} restores the disabled resolver, which omits {@code Cache-Control}. 601 * 602 * @param cacheControlResolver the resolver to use, or {@code null} to omit {@code Cache-Control} 603 * @return this builder 604 */ 605 @NonNull 606 public Builder cacheControlResolver(@Nullable CacheControlResolver cacheControlResolver) { 607 this.cacheControlResolver = cacheControlResolver; 608 return this; 609 } 610 611 /** 612 * Sets the resolver used to produce extra response headers. 613 * <p> 614 * Passing {@code null} restores the disabled resolver, which emits no extra headers. 615 * 616 * @param headersResolver the resolver to use, or {@code null} to omit extra headers 617 * @return this builder 618 */ 619 @NonNull 620 public Builder headersResolver(@Nullable HeadersResolver headersResolver) { 621 this.headersResolver = headersResolver; 622 return this; 623 } 624 625 /** 626 * Sets the resolver used to decide whether byte range requests are honored. 627 * <p> 628 * Passing {@code null} restores the default resolver, which enables range requests. 629 * 630 * @param rangeRequestsResolver the resolver to use, or {@code null} to enable range requests 631 * @return this builder 632 */ 633 @NonNull 634 public Builder rangeRequestsResolver(@Nullable RangeRequestsResolver rangeRequestsResolver) { 635 this.rangeRequestsResolver = rangeRequestsResolver; 636 return this; 637 } 638 639 /** 640 * Sets whether symbolic links are followed while resolving static-file paths. 641 * <p> 642 * Passing {@code null} restores the default of {@code false}. When disabled, any symbolic-link 643 * component under the static root causes the candidate path to be hidden. 644 * 645 * @param followSymlinks {@code true} to follow symlinks, {@code false} to reject them, or 646 * {@code null} for the default 647 * @return this builder 648 */ 649 @NonNull 650 public Builder followSymlinks(@Nullable Boolean followSymlinks) { 651 this.followSymlinks = followSymlinks; 652 return this; 653 } 654 655 /** 656 * Builds the static-file helper. 657 * <p> 658 * The configured root is resolved to a real path and validated as a directory when this method 659 * is called. 660 * 661 * @return the configured static-file helper 662 */ 663 @NonNull 664 public StaticFiles build() { 665 return new StaticFiles(this); 666 } 667 } 668 669 /** 670 * Resolves {@code ETag} values for static-file responses. 671 * 672 * @author <a href="https://www.revetkn.com">Mark Allen</a> 673 */ 674 @FunctionalInterface 675 public interface EntityTagResolver { 676 /** 677 * Returns the default weak metadata-based ETag resolver. 678 * <p> 679 * The default resolver produces a weak ETag derived from the file's last-modified epoch second 680 * and size. This is deterministic across processes serving the same filesystem. Configure a 681 * custom resolver, such as a content-hash resolver, when serving from filesystems that do not 682 * preserve modification times, when same-second overwrites are common, or when stronger 683 * collision resistance is required. 684 * 685 * @return the default entity-tag resolver 686 */ 687 @NonNull 688 static EntityTagResolver defaultInstance() { 689 return DefaultEntityTagResolver.defaultInstance(); 690 } 691 692 /** 693 * Returns a resolver that omits {@code ETag}. 694 * 695 * @return the disabled entity-tag resolver 696 */ 697 @NonNull 698 static EntityTagResolver disabledInstance() { 699 return DisabledEntityTagResolver.defaultInstance(); 700 } 701 702 /** 703 * Returns a strong content-hash ETag resolver. 704 * <p> 705 * This resolver streams the served file through SHA-256 on the request-handling thread and 706 * emits strong ETags with values of the form {@code sha256-<lowercase-hex>}. It does not 707 * cache digests and it fully reads the file for {@link HttpMethod#HEAD} requests as well as 708 * {@link HttpMethod#GET} requests. Applications serving large files or HEAD-heavy traffic should 709 * prefer a manifest-backed resolver. 710 * 711 * @return a strong content-hash entity-tag resolver 712 */ 713 @NonNull 714 static EntityTagResolver fromContentHash() { 715 return ContentHashEntityTagResolver.defaultInstance(); 716 } 717 718 /** 719 * Resolves the ETag for the file being served. 720 * <p> 721 * Implementations must be thread-safe; {@link StaticFiles} invokes resolvers concurrently from 722 * request-handling threads. Resolvers run on the request-handling thread, so expensive work such 723 * as content hashing or network calls should be cached or precomputed. 724 * 725 * @param path the resolved file path being served 726 * @param attributes the file attributes read for this response 727 * @return the ETag to emit, or {@link Optional#empty()} to omit it 728 */ 729 @NonNull 730 Optional<EntityTag> entityTagFor(@NonNull Path path, 731 @NonNull BasicFileAttributes attributes); 732 } 733 734 /** 735 * Access outcome for a resolved static file. 736 * 737 * @author <a href="https://www.revetkn.com">Mark Allen</a> 738 */ 739 public enum Access { 740 /** 741 * Continue normal static-file response generation. 742 */ 743 ALLOW, 744 /** 745 * Return a bodyless {@code 403 Forbidden} response. 746 */ 747 DENY, 748 /** 749 * Return {@link Optional#empty()} from {@link StaticFiles#marshaledResponseFor(String, Request)}. 750 */ 751 HIDE 752 } 753 754 /** 755 * Resolves whether a resolved static file should be served, denied, or hidden. 756 * 757 * @author <a href="https://www.revetkn.com">Mark Allen</a> 758 */ 759 @FunctionalInterface 760 public interface AccessResolver { 761 /** 762 * Returns a resolver that allows all resolved static files. 763 * 764 * @return the allow-all access resolver 765 */ 766 @NonNull 767 static AccessResolver allowAllInstance() { 768 return AllowAllAccessResolver.defaultInstance(); 769 } 770 771 /** 772 * Resolves static-file access for the file being served. 773 * <p> 774 * Implementations must be thread-safe; {@link StaticFiles} invokes resolvers concurrently from 775 * request-handling threads. This resolver is intentionally path- and attribute-based. For 776 * request-aware access decisions, gate {@link StaticFiles#marshaledResponseFor(String, Request)} 777 * from the resource method or another higher application layer. 778 * 779 * @param path the resolved file path being served 780 * @param attributes the file attributes read for this response 781 * @return the access outcome 782 */ 783 @NonNull 784 Access accessFor(@NonNull Path path, 785 @NonNull BasicFileAttributes attributes); 786 } 787 788 /** 789 * Resolves {@code Last-Modified} values for static-file responses. 790 * 791 * @author <a href="https://www.revetkn.com">Mark Allen</a> 792 */ 793 @FunctionalInterface 794 public interface LastModifiedResolver { 795 /** 796 * Returns a resolver that emits the file's last-modified timestamp from its attributes. 797 * 798 * @return the attribute-based last-modified resolver 799 */ 800 @NonNull 801 static LastModifiedResolver fromAttributes() { 802 return DefaultLastModifiedResolver.defaultInstance(); 803 } 804 805 /** 806 * Returns a resolver that omits {@code Last-Modified}. 807 * 808 * @return the disabled last-modified resolver 809 */ 810 @NonNull 811 static LastModifiedResolver disabledInstance() { 812 return DisabledLastModifiedResolver.defaultInstance(); 813 } 814 815 /** 816 * Resolves the {@code Last-Modified} value for the file being served. 817 * <p> 818 * Implementations must be thread-safe; {@link StaticFiles} invokes resolvers concurrently from 819 * request-handling threads. 820 * 821 * @param path the resolved file path being served 822 * @param attributes the file attributes read for this response 823 * @return the last-modified instant to emit, or {@link Optional#empty()} to omit it 824 */ 825 @NonNull 826 Optional<Instant> lastModifiedFor(@NonNull Path path, 827 @NonNull BasicFileAttributes attributes); 828 } 829 830 /** 831 * Resolves {@code Cache-Control} values for static-file responses. 832 * 833 * @author <a href="https://www.revetkn.com">Mark Allen</a> 834 */ 835 @FunctionalInterface 836 public interface CacheControlResolver { 837 /** 838 * Returns a resolver that always emits the supplied {@code Cache-Control} value. 839 * 840 * @param cacheControl the cache-control value to emit 841 * @return the constant cache-control resolver 842 */ 843 @NonNull 844 static CacheControlResolver fromValue(@NonNull String cacheControl) { 845 requireNonNull(cacheControl); 846 String normalizedCacheControl = Utilities.trimAggressivelyToNull(cacheControl); 847 848 if (normalizedCacheControl == null) 849 throw new IllegalArgumentException("Cache-Control value must not be blank."); 850 851 return (path, attributes) -> Optional.of(normalizedCacheControl); 852 } 853 854 /** 855 * Returns a resolver that omits {@code Cache-Control}. 856 * 857 * @return the disabled cache-control resolver 858 */ 859 @NonNull 860 static CacheControlResolver disabledInstance() { 861 return DisabledCacheControlResolver.defaultInstance(); 862 } 863 864 /** 865 * Resolves the {@code Cache-Control} value for the file being served. 866 * <p> 867 * Implementations must be thread-safe; {@link StaticFiles} invokes resolvers concurrently from 868 * request-handling threads. 869 * 870 * @param path the resolved file path being served 871 * @param attributes the file attributes read for this response 872 * @return the cache-control value to emit, or {@link Optional#empty()} to omit it 873 */ 874 @NonNull 875 Optional<String> cacheControlFor(@NonNull Path path, 876 @NonNull BasicFileAttributes attributes); 877 } 878 879 /** 880 * Resolves extra response headers for static-file responses. 881 * 882 * @author <a href="https://www.revetkn.com">Mark Allen</a> 883 */ 884 @FunctionalInterface 885 public interface HeadersResolver { 886 /** 887 * Returns a resolver that always emits the supplied headers. 888 * <p> 889 * The header map and nested value sets are defensively copied. 890 * 891 * @param headers the headers to emit 892 * @return the constant headers resolver 893 */ 894 @NonNull 895 static HeadersResolver fromHeaders(@NonNull Map<@NonNull String, @NonNull Set<@NonNull String>> headers) { 896 requireNonNull(headers); 897 Map<String, Set<String>> copiedHeaders = copyHeaders(headers); 898 return (path, attributes) -> copiedHeaders; 899 } 900 901 /** 902 * Returns a resolver that emits no extra headers. 903 * 904 * @return the disabled headers resolver 905 */ 906 @NonNull 907 static HeadersResolver disabledInstance() { 908 return DisabledHeadersResolver.defaultInstance(); 909 } 910 911 /** 912 * Resolves extra response headers for the file being served. 913 * <p> 914 * Implementations must be thread-safe; {@link StaticFiles} invokes resolvers concurrently from 915 * request-handling threads. 916 * 917 * @param path the resolved file path being served 918 * @param attributes the file attributes read for this response 919 * @return extra headers to emit 920 */ 921 @NonNull 922 Map<@NonNull String, @NonNull Set<@NonNull String>> headersFor(@NonNull Path path, 923 @NonNull BasicFileAttributes attributes); 924 } 925 926 /** 927 * Resolves whether byte range requests are honored for static-file responses. 928 * 929 * @author <a href="https://www.revetkn.com">Mark Allen</a> 930 */ 931 @FunctionalInterface 932 public interface RangeRequestsResolver { 933 /** 934 * Returns a resolver that enables byte range requests. 935 * 936 * @return the range-request-enabled resolver 937 */ 938 @NonNull 939 static RangeRequestsResolver enabledInstance() { 940 return EnabledRangeRequestsResolver.defaultInstance(); 941 } 942 943 /** 944 * Returns a resolver that disables byte range requests. 945 * 946 * @return the range-request-disabled resolver 947 */ 948 @NonNull 949 static RangeRequestsResolver disabledInstance() { 950 return DisabledRangeRequestsResolver.defaultInstance(); 951 } 952 953 /** 954 * Resolves whether byte range requests are enabled for the file being served. 955 * <p> 956 * Implementations must be thread-safe; {@link StaticFiles} invokes resolvers concurrently from 957 * request-handling threads. 958 * 959 * @param path the resolved file path being served 960 * @param attributes the file attributes read for this response 961 * @return {@code true} if range requests should be honored, otherwise {@code false} 962 */ 963 @NonNull 964 Boolean rangeRequestsFor(@NonNull Path path, 965 @NonNull BasicFileAttributes attributes); 966 } 967 968 @ThreadSafe 969 private static final class DisabledEntityTagResolver implements EntityTagResolver { 970 @NonNull 971 private static final DisabledEntityTagResolver INSTANCE; 972 973 static { 974 INSTANCE = new DisabledEntityTagResolver(); 975 } 976 977 @NonNull 978 static DisabledEntityTagResolver defaultInstance() { 979 return INSTANCE; 980 } 981 982 @Override 983 @NonNull 984 public Optional<EntityTag> entityTagFor(@NonNull Path path, 985 @NonNull BasicFileAttributes attributes) { 986 requireNonNull(path); 987 requireNonNull(attributes); 988 return Optional.empty(); 989 } 990 } 991 992 @ThreadSafe 993 private static final class ContentHashEntityTagResolver implements EntityTagResolver { 994 @NonNull 995 private static final ContentHashEntityTagResolver INSTANCE; 996 997 static { 998 INSTANCE = new ContentHashEntityTagResolver(); 999 } 1000 1001 @NonNull 1002 static ContentHashEntityTagResolver defaultInstance() { 1003 return INSTANCE; 1004 } 1005 1006 @Override 1007 @NonNull 1008 public Optional<EntityTag> entityTagFor(@NonNull Path path, 1009 @NonNull BasicFileAttributes attributes) { 1010 requireNonNull(path); 1011 requireNonNull(attributes); 1012 1013 MessageDigest messageDigest; 1014 1015 try { 1016 messageDigest = MessageDigest.getInstance("SHA-256"); 1017 } catch (NoSuchAlgorithmException e) { 1018 throw new IllegalStateException("SHA-256 message digest is not available.", e); 1019 } 1020 1021 byte[] buffer = new byte[CONTENT_HASH_BUFFER_SIZE]; 1022 1023 try (InputStream inputStream = Files.newInputStream(path)) { 1024 for (int bytesRead = inputStream.read(buffer); bytesRead >= 0; bytesRead = inputStream.read(buffer)) 1025 if (bytesRead > 0) 1026 messageDigest.update(buffer, 0, bytesRead); 1027 } catch (IOException e) { 1028 throw new UncheckedIOException(format("Unable to hash static file '%s'.", path), e); 1029 } 1030 1031 return Optional.of(EntityTag.fromStrongValue(format("sha256-%s", HEX_FORMAT.formatHex(messageDigest.digest())))); 1032 } 1033 } 1034 1035 @ThreadSafe 1036 private static final class AllowAllAccessResolver implements AccessResolver { 1037 @NonNull 1038 private static final AllowAllAccessResolver INSTANCE; 1039 1040 static { 1041 INSTANCE = new AllowAllAccessResolver(); 1042 } 1043 1044 @NonNull 1045 static AllowAllAccessResolver defaultInstance() { 1046 return INSTANCE; 1047 } 1048 1049 @Override 1050 @NonNull 1051 public Access accessFor(@NonNull Path path, 1052 @NonNull BasicFileAttributes attributes) { 1053 requireNonNull(path); 1054 requireNonNull(attributes); 1055 return Access.ALLOW; 1056 } 1057 } 1058 1059 @ThreadSafe 1060 private static final class DefaultLastModifiedResolver implements LastModifiedResolver { 1061 @NonNull 1062 private static final DefaultLastModifiedResolver INSTANCE; 1063 1064 static { 1065 INSTANCE = new DefaultLastModifiedResolver(); 1066 } 1067 1068 @NonNull 1069 static DefaultLastModifiedResolver defaultInstance() { 1070 return INSTANCE; 1071 } 1072 1073 @Override 1074 @NonNull 1075 public Optional<Instant> lastModifiedFor(@NonNull Path path, 1076 @NonNull BasicFileAttributes attributes) { 1077 requireNonNull(path); 1078 requireNonNull(attributes); 1079 return Optional.of(Instant.ofEpochSecond(attributes.lastModifiedTime().toInstant().getEpochSecond())); 1080 } 1081 } 1082 1083 @ThreadSafe 1084 private static final class DisabledLastModifiedResolver implements LastModifiedResolver { 1085 @NonNull 1086 private static final DisabledLastModifiedResolver INSTANCE; 1087 1088 static { 1089 INSTANCE = new DisabledLastModifiedResolver(); 1090 } 1091 1092 @NonNull 1093 static DisabledLastModifiedResolver defaultInstance() { 1094 return INSTANCE; 1095 } 1096 1097 @Override 1098 @NonNull 1099 public Optional<Instant> lastModifiedFor(@NonNull Path path, 1100 @NonNull BasicFileAttributes attributes) { 1101 requireNonNull(path); 1102 requireNonNull(attributes); 1103 return Optional.empty(); 1104 } 1105 } 1106 1107 @ThreadSafe 1108 private static final class DefaultEntityTagResolver implements EntityTagResolver { 1109 @NonNull 1110 private static final DefaultEntityTagResolver INSTANCE; 1111 1112 static { 1113 INSTANCE = new DefaultEntityTagResolver(); 1114 } 1115 1116 @NonNull 1117 static DefaultEntityTagResolver defaultInstance() { 1118 return INSTANCE; 1119 } 1120 1121 @Override 1122 @NonNull 1123 public Optional<EntityTag> entityTagFor(@NonNull Path path, 1124 @NonNull BasicFileAttributes attributes) { 1125 requireNonNull(path); 1126 requireNonNull(attributes); 1127 Long epochSecond = attributes.lastModifiedTime().toInstant().getEpochSecond(); 1128 Long size = attributes.size(); 1129 return Optional.of(EntityTag.fromWeakValue(format("mtime-%d-size-%d", epochSecond, size))); 1130 } 1131 } 1132 1133 @ThreadSafe 1134 private static final class DisabledCacheControlResolver implements CacheControlResolver { 1135 @NonNull 1136 private static final DisabledCacheControlResolver INSTANCE; 1137 1138 static { 1139 INSTANCE = new DisabledCacheControlResolver(); 1140 } 1141 1142 @NonNull 1143 static DisabledCacheControlResolver defaultInstance() { 1144 return INSTANCE; 1145 } 1146 1147 @Override 1148 @NonNull 1149 public Optional<String> cacheControlFor(@NonNull Path path, 1150 @NonNull BasicFileAttributes attributes) { 1151 requireNonNull(path); 1152 requireNonNull(attributes); 1153 return Optional.empty(); 1154 } 1155 } 1156 1157 @ThreadSafe 1158 private static final class DisabledHeadersResolver implements HeadersResolver { 1159 @NonNull 1160 private static final DisabledHeadersResolver INSTANCE; 1161 1162 static { 1163 INSTANCE = new DisabledHeadersResolver(); 1164 } 1165 1166 @NonNull 1167 static DisabledHeadersResolver defaultInstance() { 1168 return INSTANCE; 1169 } 1170 1171 @Override 1172 @NonNull 1173 public Map<@NonNull String, @NonNull Set<@NonNull String>> headersFor(@NonNull Path path, 1174 @NonNull BasicFileAttributes attributes) { 1175 requireNonNull(path); 1176 requireNonNull(attributes); 1177 return Map.of(); 1178 } 1179 } 1180 1181 @ThreadSafe 1182 private static final class EnabledRangeRequestsResolver implements RangeRequestsResolver { 1183 @NonNull 1184 private static final EnabledRangeRequestsResolver INSTANCE; 1185 1186 static { 1187 INSTANCE = new EnabledRangeRequestsResolver(); 1188 } 1189 1190 @NonNull 1191 static EnabledRangeRequestsResolver defaultInstance() { 1192 return INSTANCE; 1193 } 1194 1195 @Override 1196 @NonNull 1197 public Boolean rangeRequestsFor(@NonNull Path path, 1198 @NonNull BasicFileAttributes attributes) { 1199 requireNonNull(path); 1200 requireNonNull(attributes); 1201 return true; 1202 } 1203 } 1204 1205 @ThreadSafe 1206 private static final class DisabledRangeRequestsResolver implements RangeRequestsResolver { 1207 @NonNull 1208 private static final DisabledRangeRequestsResolver INSTANCE; 1209 1210 static { 1211 INSTANCE = new DisabledRangeRequestsResolver(); 1212 } 1213 1214 @NonNull 1215 static DisabledRangeRequestsResolver defaultInstance() { 1216 return INSTANCE; 1217 } 1218 1219 @Override 1220 @NonNull 1221 public Boolean rangeRequestsFor(@NonNull Path path, 1222 @NonNull BasicFileAttributes attributes) { 1223 requireNonNull(path); 1224 requireNonNull(attributes); 1225 return false; 1226 } 1227 } 1228 1229}