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 @NotThreadSafe 485 public static final class Builder { 486 @NonNull 487 private final Path root; 488 @Nullable 489 private List<@NonNull String> indexFileNames; 490 @Nullable 491 private MimeTypeResolver mimeTypeResolver; 492 @Nullable 493 private EntityTagResolver entityTagResolver; 494 @Nullable 495 private AccessResolver accessResolver; 496 @Nullable 497 private LastModifiedResolver lastModifiedResolver; 498 @Nullable 499 private CacheControlResolver cacheControlResolver; 500 @Nullable 501 private HeadersResolver headersResolver; 502 @Nullable 503 private RangeRequestsResolver rangeRequestsResolver; 504 @Nullable 505 private Boolean followSymlinks; 506 507 private Builder(@NonNull Path root) { 508 requireNonNull(root); 509 this.root = root; 510 } 511 512 @NonNull 513 public Builder indexFileNames(@Nullable List<@NonNull String> indexFileNames) { 514 this.indexFileNames = indexFileNames; 515 return this; 516 } 517 518 /** 519 * Sets the resolver used to produce {@code Content-Type} values. 520 * <p> 521 * The configured resolver fully replaces the default resolver. Soklet's default resolver uses a 522 * curated deterministic set of common web-asset extensions, returns {@link Optional#empty()} for 523 * unknown extensions, and does not call {@link Files#probeContentType(Path)}. Applications that 524 * need OS-level MIME database behavior can configure a resolver that calls 525 * {@code Files.probeContentType(...)} directly. 526 * 527 * @param mimeTypeResolver the resolver to use, or {@code null} to restore the default resolver 528 * @return this builder 529 */ 530 @NonNull 531 public Builder mimeTypeResolver(@Nullable MimeTypeResolver mimeTypeResolver) { 532 this.mimeTypeResolver = mimeTypeResolver; 533 return this; 534 } 535 536 @NonNull 537 public Builder entityTagResolver(@Nullable EntityTagResolver entityTagResolver) { 538 this.entityTagResolver = entityTagResolver; 539 return this; 540 } 541 542 /** 543 * Sets the resolver used to decide whether resolved files are served, denied, or hidden. 544 * <p> 545 * The access resolver runs after path safety, index-file resolution, symlink policy, 546 * readability, regular-file checks, and attribute reads, but before MIME, validator, 547 * cache-control, extra-header, and range-request resolvers. It receives the resolved file path, 548 * not the original request path. 549 * 550 * @param accessResolver the resolver to use, or {@code null} to restore the allow-all default 551 * @return this builder 552 */ 553 @NonNull 554 public Builder accessResolver(@Nullable AccessResolver accessResolver) { 555 this.accessResolver = accessResolver; 556 return this; 557 } 558 559 @NonNull 560 public Builder lastModifiedResolver(@Nullable LastModifiedResolver lastModifiedResolver) { 561 this.lastModifiedResolver = lastModifiedResolver; 562 return this; 563 } 564 565 @NonNull 566 public Builder cacheControlResolver(@Nullable CacheControlResolver cacheControlResolver) { 567 this.cacheControlResolver = cacheControlResolver; 568 return this; 569 } 570 571 @NonNull 572 public Builder headersResolver(@Nullable HeadersResolver headersResolver) { 573 this.headersResolver = headersResolver; 574 return this; 575 } 576 577 @NonNull 578 public Builder rangeRequestsResolver(@Nullable RangeRequestsResolver rangeRequestsResolver) { 579 this.rangeRequestsResolver = rangeRequestsResolver; 580 return this; 581 } 582 583 @NonNull 584 public Builder followSymlinks(@Nullable Boolean followSymlinks) { 585 this.followSymlinks = followSymlinks; 586 return this; 587 } 588 589 @NonNull 590 public StaticFiles build() { 591 return new StaticFiles(this); 592 } 593 } 594 595 @FunctionalInterface 596 public interface EntityTagResolver { 597 /** 598 * Returns the default weak metadata-based ETag resolver. 599 * <p> 600 * The default resolver produces a weak ETag derived from the file's last-modified epoch second 601 * and size. This is deterministic across processes serving the same filesystem. Configure a 602 * custom resolver, such as a content-hash resolver, when serving from filesystems that do not 603 * preserve modification times, when same-second overwrites are common, or when stronger 604 * collision resistance is required. 605 * 606 * @return the default entity-tag resolver 607 */ 608 @NonNull 609 static EntityTagResolver defaultInstance() { 610 return DefaultEntityTagResolver.defaultInstance(); 611 } 612 613 @NonNull 614 static EntityTagResolver disabledInstance() { 615 return DisabledEntityTagResolver.defaultInstance(); 616 } 617 618 /** 619 * Returns a strong content-hash ETag resolver. 620 * <p> 621 * This resolver streams the served file through SHA-256 on the request-handling thread and 622 * emits strong ETags with values of the form {@code sha256-<lowercase-hex>}. It does not 623 * cache digests and it fully reads the file for {@link HttpMethod#HEAD} requests as well as 624 * {@link HttpMethod#GET} requests. Applications serving large files or HEAD-heavy traffic should 625 * prefer a manifest-backed resolver. 626 * 627 * @return a strong content-hash entity-tag resolver 628 */ 629 @NonNull 630 static EntityTagResolver fromContentHash() { 631 return ContentHashEntityTagResolver.defaultInstance(); 632 } 633 634 /** 635 * Resolves the ETag for the file being served. 636 * <p> 637 * Implementations must be thread-safe; {@link StaticFiles} invokes resolvers concurrently from 638 * request-handling threads. Resolvers run on the request-handling thread, so expensive work such 639 * as content hashing or network calls should be cached or precomputed. 640 * 641 * @param path the resolved file path being served 642 * @param attributes the file attributes read for this response 643 * @return the ETag to emit, or {@link Optional#empty()} to omit it 644 */ 645 @NonNull 646 Optional<EntityTag> entityTagFor(@NonNull Path path, 647 @NonNull BasicFileAttributes attributes); 648 } 649 650 /** 651 * Access outcome for a resolved static file. 652 * 653 * @author <a href="https://www.revetkn.com">Mark Allen</a> 654 */ 655 public enum Access { 656 /** 657 * Continue normal static-file response generation. 658 */ 659 ALLOW, 660 /** 661 * Return a bodyless {@code 403 Forbidden} response. 662 */ 663 DENY, 664 /** 665 * Return {@link Optional#empty()} from {@link StaticFiles#marshaledResponseFor(String, Request)}. 666 */ 667 HIDE 668 } 669 670 @FunctionalInterface 671 public interface AccessResolver { 672 @NonNull 673 static AccessResolver allowAllInstance() { 674 return AllowAllAccessResolver.defaultInstance(); 675 } 676 677 /** 678 * Resolves static-file access for the file being served. 679 * <p> 680 * Implementations must be thread-safe; {@link StaticFiles} invokes resolvers concurrently from 681 * request-handling threads. This resolver is intentionally path- and attribute-based. For 682 * request-aware access decisions, gate {@link StaticFiles#marshaledResponseFor(String, Request)} 683 * from the resource method or another higher application layer. 684 * 685 * @param path the resolved file path being served 686 * @param attributes the file attributes read for this response 687 * @return the access outcome 688 */ 689 @NonNull 690 Access accessFor(@NonNull Path path, 691 @NonNull BasicFileAttributes attributes); 692 } 693 694 @FunctionalInterface 695 public interface LastModifiedResolver { 696 @NonNull 697 static LastModifiedResolver fromAttributes() { 698 return DefaultLastModifiedResolver.defaultInstance(); 699 } 700 701 @NonNull 702 static LastModifiedResolver disabledInstance() { 703 return DisabledLastModifiedResolver.defaultInstance(); 704 } 705 706 /** 707 * Resolves the {@code Last-Modified} value for the file being served. 708 * <p> 709 * Implementations must be thread-safe; {@link StaticFiles} invokes resolvers concurrently from 710 * request-handling threads. 711 * 712 * @param path the resolved file path being served 713 * @param attributes the file attributes read for this response 714 * @return the last-modified instant to emit, or {@link Optional#empty()} to omit it 715 */ 716 @NonNull 717 Optional<Instant> lastModifiedFor(@NonNull Path path, 718 @NonNull BasicFileAttributes attributes); 719 } 720 721 @FunctionalInterface 722 public interface CacheControlResolver { 723 @NonNull 724 static CacheControlResolver fromValue(@NonNull String cacheControl) { 725 requireNonNull(cacheControl); 726 String normalizedCacheControl = Utilities.trimAggressivelyToNull(cacheControl); 727 728 if (normalizedCacheControl == null) 729 throw new IllegalArgumentException("Cache-Control value must not be blank."); 730 731 return (path, attributes) -> Optional.of(normalizedCacheControl); 732 } 733 734 @NonNull 735 static CacheControlResolver disabledInstance() { 736 return DisabledCacheControlResolver.defaultInstance(); 737 } 738 739 /** 740 * Resolves the {@code Cache-Control} value for the file being served. 741 * <p> 742 * Implementations must be thread-safe; {@link StaticFiles} invokes resolvers concurrently from 743 * request-handling threads. 744 * 745 * @param path the resolved file path being served 746 * @param attributes the file attributes read for this response 747 * @return the cache-control value to emit, or {@link Optional#empty()} to omit it 748 */ 749 @NonNull 750 Optional<String> cacheControlFor(@NonNull Path path, 751 @NonNull BasicFileAttributes attributes); 752 } 753 754 @FunctionalInterface 755 public interface HeadersResolver { 756 @NonNull 757 static HeadersResolver fromHeaders(@NonNull Map<@NonNull String, @NonNull Set<@NonNull String>> headers) { 758 requireNonNull(headers); 759 Map<String, Set<String>> copiedHeaders = copyHeaders(headers); 760 return (path, attributes) -> copiedHeaders; 761 } 762 763 @NonNull 764 static HeadersResolver disabledInstance() { 765 return DisabledHeadersResolver.defaultInstance(); 766 } 767 768 /** 769 * Resolves extra response headers for the file being served. 770 * <p> 771 * Implementations must be thread-safe; {@link StaticFiles} invokes resolvers concurrently from 772 * request-handling threads. 773 * 774 * @param path the resolved file path being served 775 * @param attributes the file attributes read for this response 776 * @return extra headers to emit 777 */ 778 @NonNull 779 Map<@NonNull String, @NonNull Set<@NonNull String>> headersFor(@NonNull Path path, 780 @NonNull BasicFileAttributes attributes); 781 } 782 783 @FunctionalInterface 784 public interface RangeRequestsResolver { 785 @NonNull 786 static RangeRequestsResolver enabledInstance() { 787 return EnabledRangeRequestsResolver.defaultInstance(); 788 } 789 790 @NonNull 791 static RangeRequestsResolver disabledInstance() { 792 return DisabledRangeRequestsResolver.defaultInstance(); 793 } 794 795 /** 796 * Resolves whether byte range requests are enabled for the file being served. 797 * <p> 798 * Implementations must be thread-safe; {@link StaticFiles} invokes resolvers concurrently from 799 * request-handling threads. 800 * 801 * @param path the resolved file path being served 802 * @param attributes the file attributes read for this response 803 * @return {@code true} if range requests should be honored, otherwise {@code false} 804 */ 805 @NonNull 806 Boolean rangeRequestsFor(@NonNull Path path, 807 @NonNull BasicFileAttributes attributes); 808 } 809 810 @ThreadSafe 811 private static final class DisabledEntityTagResolver implements EntityTagResolver { 812 @NonNull 813 private static final DisabledEntityTagResolver INSTANCE; 814 815 static { 816 INSTANCE = new DisabledEntityTagResolver(); 817 } 818 819 @NonNull 820 static DisabledEntityTagResolver defaultInstance() { 821 return INSTANCE; 822 } 823 824 @Override 825 @NonNull 826 public Optional<EntityTag> entityTagFor(@NonNull Path path, 827 @NonNull BasicFileAttributes attributes) { 828 requireNonNull(path); 829 requireNonNull(attributes); 830 return Optional.empty(); 831 } 832 } 833 834 @ThreadSafe 835 private static final class ContentHashEntityTagResolver implements EntityTagResolver { 836 @NonNull 837 private static final ContentHashEntityTagResolver INSTANCE; 838 839 static { 840 INSTANCE = new ContentHashEntityTagResolver(); 841 } 842 843 @NonNull 844 static ContentHashEntityTagResolver defaultInstance() { 845 return INSTANCE; 846 } 847 848 @Override 849 @NonNull 850 public Optional<EntityTag> entityTagFor(@NonNull Path path, 851 @NonNull BasicFileAttributes attributes) { 852 requireNonNull(path); 853 requireNonNull(attributes); 854 855 MessageDigest messageDigest; 856 857 try { 858 messageDigest = MessageDigest.getInstance("SHA-256"); 859 } catch (NoSuchAlgorithmException e) { 860 throw new IllegalStateException("SHA-256 message digest is not available.", e); 861 } 862 863 byte[] buffer = new byte[CONTENT_HASH_BUFFER_SIZE]; 864 865 try (InputStream inputStream = Files.newInputStream(path)) { 866 for (int bytesRead = inputStream.read(buffer); bytesRead >= 0; bytesRead = inputStream.read(buffer)) 867 if (bytesRead > 0) 868 messageDigest.update(buffer, 0, bytesRead); 869 } catch (IOException e) { 870 throw new UncheckedIOException(format("Unable to hash static file '%s'.", path), e); 871 } 872 873 return Optional.of(EntityTag.fromStrongValue(format("sha256-%s", HEX_FORMAT.formatHex(messageDigest.digest())))); 874 } 875 } 876 877 @ThreadSafe 878 private static final class AllowAllAccessResolver implements AccessResolver { 879 @NonNull 880 private static final AllowAllAccessResolver INSTANCE; 881 882 static { 883 INSTANCE = new AllowAllAccessResolver(); 884 } 885 886 @NonNull 887 static AllowAllAccessResolver defaultInstance() { 888 return INSTANCE; 889 } 890 891 @Override 892 @NonNull 893 public Access accessFor(@NonNull Path path, 894 @NonNull BasicFileAttributes attributes) { 895 requireNonNull(path); 896 requireNonNull(attributes); 897 return Access.ALLOW; 898 } 899 } 900 901 @ThreadSafe 902 private static final class DefaultLastModifiedResolver implements LastModifiedResolver { 903 @NonNull 904 private static final DefaultLastModifiedResolver INSTANCE; 905 906 static { 907 INSTANCE = new DefaultLastModifiedResolver(); 908 } 909 910 @NonNull 911 static DefaultLastModifiedResolver defaultInstance() { 912 return INSTANCE; 913 } 914 915 @Override 916 @NonNull 917 public Optional<Instant> lastModifiedFor(@NonNull Path path, 918 @NonNull BasicFileAttributes attributes) { 919 requireNonNull(path); 920 requireNonNull(attributes); 921 return Optional.of(Instant.ofEpochSecond(attributes.lastModifiedTime().toInstant().getEpochSecond())); 922 } 923 } 924 925 @ThreadSafe 926 private static final class DisabledLastModifiedResolver implements LastModifiedResolver { 927 @NonNull 928 private static final DisabledLastModifiedResolver INSTANCE; 929 930 static { 931 INSTANCE = new DisabledLastModifiedResolver(); 932 } 933 934 @NonNull 935 static DisabledLastModifiedResolver defaultInstance() { 936 return INSTANCE; 937 } 938 939 @Override 940 @NonNull 941 public Optional<Instant> lastModifiedFor(@NonNull Path path, 942 @NonNull BasicFileAttributes attributes) { 943 requireNonNull(path); 944 requireNonNull(attributes); 945 return Optional.empty(); 946 } 947 } 948 949 @ThreadSafe 950 private static final class DefaultEntityTagResolver implements EntityTagResolver { 951 @NonNull 952 private static final DefaultEntityTagResolver INSTANCE; 953 954 static { 955 INSTANCE = new DefaultEntityTagResolver(); 956 } 957 958 @NonNull 959 static DefaultEntityTagResolver defaultInstance() { 960 return INSTANCE; 961 } 962 963 @Override 964 @NonNull 965 public Optional<EntityTag> entityTagFor(@NonNull Path path, 966 @NonNull BasicFileAttributes attributes) { 967 requireNonNull(path); 968 requireNonNull(attributes); 969 Long epochSecond = attributes.lastModifiedTime().toInstant().getEpochSecond(); 970 Long size = attributes.size(); 971 return Optional.of(EntityTag.fromWeakValue(format("mtime-%d-size-%d", epochSecond, size))); 972 } 973 } 974 975 @ThreadSafe 976 private static final class DisabledCacheControlResolver implements CacheControlResolver { 977 @NonNull 978 private static final DisabledCacheControlResolver INSTANCE; 979 980 static { 981 INSTANCE = new DisabledCacheControlResolver(); 982 } 983 984 @NonNull 985 static DisabledCacheControlResolver defaultInstance() { 986 return INSTANCE; 987 } 988 989 @Override 990 @NonNull 991 public Optional<String> cacheControlFor(@NonNull Path path, 992 @NonNull BasicFileAttributes attributes) { 993 requireNonNull(path); 994 requireNonNull(attributes); 995 return Optional.empty(); 996 } 997 } 998 999 @ThreadSafe 1000 private static final class DisabledHeadersResolver implements HeadersResolver { 1001 @NonNull 1002 private static final DisabledHeadersResolver INSTANCE; 1003 1004 static { 1005 INSTANCE = new DisabledHeadersResolver(); 1006 } 1007 1008 @NonNull 1009 static DisabledHeadersResolver defaultInstance() { 1010 return INSTANCE; 1011 } 1012 1013 @Override 1014 @NonNull 1015 public Map<@NonNull String, @NonNull Set<@NonNull String>> headersFor(@NonNull Path path, 1016 @NonNull BasicFileAttributes attributes) { 1017 requireNonNull(path); 1018 requireNonNull(attributes); 1019 return Map.of(); 1020 } 1021 } 1022 1023 @ThreadSafe 1024 private static final class EnabledRangeRequestsResolver implements RangeRequestsResolver { 1025 @NonNull 1026 private static final EnabledRangeRequestsResolver INSTANCE; 1027 1028 static { 1029 INSTANCE = new EnabledRangeRequestsResolver(); 1030 } 1031 1032 @NonNull 1033 static EnabledRangeRequestsResolver defaultInstance() { 1034 return INSTANCE; 1035 } 1036 1037 @Override 1038 @NonNull 1039 public Boolean rangeRequestsFor(@NonNull Path path, 1040 @NonNull BasicFileAttributes attributes) { 1041 requireNonNull(path); 1042 requireNonNull(attributes); 1043 return true; 1044 } 1045 } 1046 1047 @ThreadSafe 1048 private static final class DisabledRangeRequestsResolver implements RangeRequestsResolver { 1049 @NonNull 1050 private static final DisabledRangeRequestsResolver INSTANCE; 1051 1052 static { 1053 INSTANCE = new DisabledRangeRequestsResolver(); 1054 } 1055 1056 @NonNull 1057 static DisabledRangeRequestsResolver defaultInstance() { 1058 return INSTANCE; 1059 } 1060 1061 @Override 1062 @NonNull 1063 public Boolean rangeRequestsFor(@NonNull Path path, 1064 @NonNull BasicFileAttributes attributes) { 1065 requireNonNull(path); 1066 requireNonNull(attributes); 1067 return false; 1068 } 1069 } 1070 1071}