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-&lt;lowercase-hex&gt;}. 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}