001/*
002 * Copyright 2024-2025 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.servlet.javax;
018
019import com.soklet.Request;
020import com.soklet.Utilities;
021
022import javax.annotation.Nonnull;
023import javax.annotation.Nullable;
024import javax.annotation.concurrent.NotThreadSafe;
025import javax.servlet.AsyncContext;
026import javax.servlet.DispatcherType;
027import javax.servlet.RequestDispatcher;
028import javax.servlet.ServletContext;
029import javax.servlet.ServletException;
030import javax.servlet.ServletInputStream;
031import javax.servlet.ServletRequest;
032import javax.servlet.ServletResponse;
033import javax.servlet.http.Cookie;
034import javax.servlet.http.HttpServletRequest;
035import javax.servlet.http.HttpServletResponse;
036import javax.servlet.http.HttpSession;
037import javax.servlet.http.HttpUpgradeHandler;
038import javax.servlet.http.Part;
039import java.io.BufferedReader;
040import java.io.ByteArrayInputStream;
041import java.io.IOException;
042import java.io.InputStream;
043import java.io.InputStreamReader;
044import java.io.UnsupportedEncodingException;
045import java.net.InetAddress;
046import java.net.URI;
047import java.nio.charset.Charset;
048import java.nio.charset.IllegalCharsetNameException;
049import java.nio.charset.StandardCharsets;
050import java.nio.charset.UnsupportedCharsetException;
051import java.security.Principal;
052import java.time.Instant;
053import java.time.ZoneOffset;
054import java.time.format.DateTimeFormatter;
055import java.time.format.DateTimeFormatterBuilder;
056import java.time.format.SignStyle;
057import java.time.temporal.ChronoField;
058import java.util.ArrayList;
059import java.util.Collection;
060import java.util.Collections;
061import java.util.Enumeration;
062import java.util.HashMap;
063import java.util.HashSet;
064import java.util.List;
065import java.util.Locale;
066import java.util.Map;
067import java.util.Map.Entry;
068import java.util.Optional;
069import java.util.Set;
070import java.util.TreeMap;
071import java.util.UUID;
072
073import static java.lang.String.format;
074import static java.util.Locale.ROOT;
075import static java.util.Locale.US;
076import static java.util.Locale.getDefault;
077import static java.util.Objects.requireNonNull;
078
079/**
080 * Soklet integration implementation of {@link HttpServletRequest}.
081 *
082 * @author <a href="https://www.revetkn.com">Mark Allen</a>
083 */
084@NotThreadSafe
085public final class SokletHttpServletRequest implements HttpServletRequest {
086        @Nonnull
087        private static final Charset DEFAULT_CHARSET;
088        @Nonnull
089        private static final DateTimeFormatter RFC_1123_PARSER;
090        @Nonnull
091        private static final DateTimeFormatter RFC_1036_PARSER;
092        @Nonnull
093        private static final DateTimeFormatter ASCTIME_PARSER;
094
095        static {
096                DEFAULT_CHARSET = StandardCharsets.ISO_8859_1; // Per Servlet spec
097                RFC_1123_PARSER = DateTimeFormatter.RFC_1123_DATE_TIME;
098                // RFC 1036: spaces between day/month/year + 2-digit year reduced to 19xx baseline.
099                RFC_1036_PARSER = new DateTimeFormatterBuilder()
100                                .parseCaseInsensitive()
101                                .appendPattern("EEE, dd MMM ")
102                                .appendValueReduced(ChronoField.YEAR, 2, 2, 1900) // 94 -> 1994
103                                .appendPattern(" HH:mm:ss zzz")
104                                .toFormatter(US)
105                                .withZone(ZoneOffset.UTC);
106
107                // asctime: "EEE MMM  d HH:mm:ss yyyy" — allow 1 or 2 spaces before day, no zone in text → default GMT.
108                ASCTIME_PARSER = new DateTimeFormatterBuilder()
109                                .parseCaseInsensitive()
110                                .appendPattern("EEE MMM")
111                                .appendLiteral(' ')
112                                .optionalStart().appendLiteral(' ').optionalEnd() // tolerate double space before single-digit day
113                                .appendValue(ChronoField.DAY_OF_MONTH, 1, 2, SignStyle.NOT_NEGATIVE)
114                                .appendPattern(" HH:mm:ss yyyy")
115                                .toFormatter(US)
116                                .withZone(ZoneOffset.UTC);
117        }
118
119        @Nonnull
120        private final Request request;
121        @Nullable
122        private final String host;
123        @Nullable
124        private final Integer port;
125        @Nonnull
126        private final ServletContext servletContext;
127        @Nullable
128        private HttpSession httpSession;
129        @Nonnull
130        private final Map<String, Object> attributes;
131        @Nonnull
132        private final List<Cookie> cookies;
133        @Nullable
134        private Charset charset;
135        @Nullable
136        private String contentType;
137
138        @Nonnull
139        public static Builder withRequest(@Nonnull Request request) {
140                return new Builder(request);
141        }
142
143        private SokletHttpServletRequest(@Nonnull Builder builder) {
144                requireNonNull(builder);
145                requireNonNull(builder.request);
146
147                this.request = builder.request;
148                this.attributes = new HashMap<>();
149                this.cookies = parseCookies(request);
150                this.charset = parseCharacterEncoding(request).orElse(null);
151                this.contentType = parseContentType(request).orElse(null);
152                this.host = builder.host;
153                this.port = builder.port;
154                this.servletContext = builder.servletContext == null ? SokletServletContext.withDefaults() : builder.servletContext;
155                this.httpSession = builder.httpSession;
156        }
157
158        @Nonnull
159        protected Request getRequest() {
160                return this.request;
161        }
162
163        @Nonnull
164        protected Map<String, Object> getAttributes() {
165                return this.attributes;
166        }
167
168        @Nonnull
169        protected List<Cookie> parseCookies(@Nonnull Request request) {
170                requireNonNull(request);
171
172                Map<String, Set<String>> cookies = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
173                cookies.putAll(request.getCookies());
174
175                List<Cookie> convertedCookies = new ArrayList<>(cookies.size());
176
177                for (Entry<String, Set<String>> entry : cookies.entrySet()) {
178                        String name = entry.getKey();
179                        Set<String> values = entry.getValue();
180
181                        // Should never occur...
182                        if (name == null)
183                                continue;
184
185                        for (String value : values)
186                                convertedCookies.add(new Cookie(name, value));
187                }
188
189                return convertedCookies;
190        }
191
192        @Nonnull
193        protected Optional<Charset> parseCharacterEncoding(@Nonnull Request request) {
194                requireNonNull(request);
195                return Utilities.extractCharsetFromHeaders(request.getHeaders());
196        }
197
198        @Nonnull
199        protected Optional<String> parseContentType(@Nonnull Request request) {
200                requireNonNull(request);
201                return Utilities.extractContentTypeFromHeaders(request.getHeaders());
202        }
203
204        @Nonnull
205        protected Optional<HttpSession> getHttpSession() {
206                return Optional.ofNullable(this.httpSession);
207        }
208
209        protected void setHttpSession(@Nullable HttpSession httpSession) {
210                this.httpSession = httpSession;
211        }
212
213        @Nonnull
214        protected Optional<Charset> getCharset() {
215                return Optional.ofNullable(this.charset);
216        }
217
218        protected void setCharset(@Nullable Charset charset) {
219                this.charset = charset;
220        }
221
222        @Nonnull
223        protected Optional<String> getHost() {
224                return Optional.ofNullable(this.host);
225        }
226
227        @Nonnull
228        protected Optional<Integer> getPort() {
229                return Optional.ofNullable(this.port);
230        }
231
232        /**
233         * Builder used to construct instances of {@link SokletHttpServletRequest}.
234         * <p>
235         * This class is intended for use by a single thread.
236         *
237         * @author <a href="https://www.revetkn.com">Mark Allen</a>
238         */
239        @NotThreadSafe
240        public static class Builder {
241                @Nonnull
242                private Request request;
243                @Nullable
244                private Integer port;
245                @Nullable
246                private String host;
247                @Nullable
248                private ServletContext servletContext;
249                @Nullable
250                private HttpSession httpSession;
251
252                @Nonnull
253                private Builder(@Nonnull Request request) {
254                        requireNonNull(request);
255                        this.request = request;
256                }
257
258                @Nonnull
259                public Builder request(@Nonnull Request request) {
260                        requireNonNull(request);
261                        this.request = request;
262                        return this;
263                }
264
265                @Nonnull
266                public Builder host(@Nullable String host) {
267                        this.host = host;
268                        return this;
269                }
270
271                @Nonnull
272                public Builder port(@Nullable Integer port) {
273                        this.port = port;
274                        return this;
275                }
276
277                @Nonnull
278                public Builder servletContext(@Nullable ServletContext servletContext) {
279                        this.servletContext = servletContext;
280                        return this;
281                }
282
283                @Nonnull
284                public Builder httpSession(@Nullable HttpSession httpSession) {
285                        this.httpSession = httpSession;
286                        return this;
287                }
288
289                @Nonnull
290                public SokletHttpServletRequest build() {
291                        return new SokletHttpServletRequest(this);
292                }
293        }
294
295        // Implementation of HttpServletRequest methods below:
296
297        // Helpful reference at https://stackoverflow.com/a/21046620 by Victor Stafusa - BozoNaCadeia
298        //
299        // Method              URL-Decoded Result
300        // ----------------------------------------------------
301        // getContextPath()        no      /app
302        // getLocalAddr()                  127.0.0.1
303        // getLocalName()                  30thh.loc
304        // getLocalPort()                  8480
305        // getMethod()                     GET
306        // getPathInfo()           yes     /a?+b
307        // getProtocol()                   HTTP/1.1
308        // getQueryString()        no      p+1=c+d&p+2=e+f
309        // getRequestedSessionId() no      S%3F+ID
310        // getRequestURI()         no      /app/test%3F/a%3F+b;jsessionid=S+ID
311        // getRequestURL()         no      http://30thh.loc:8480/app/test%3F/a%3F+b;jsessionid=S+ID
312        // getScheme()                     http
313        // getServerName()                 30thh.loc
314        // getServerPort()                 8480
315        // getServletPath()        yes     /test?
316        // getParameterNames()     yes     [p 2, p 1]
317        // getParameter("p 1")     yes     c d
318
319        @Override
320        @Nullable
321        public String getAuthType() {
322                // This is legal according to spec
323                return null;
324        }
325
326        @Override
327        @Nonnull
328        public Cookie[] getCookies() {
329                return this.cookies.toArray(new Cookie[0]);
330        }
331
332        @Override
333        public long getDateHeader(@Nullable String name) {
334                if (name == null)
335                        return -1;
336
337                String value = getHeader(name);
338
339                if (value == null)
340                        return -1;
341
342                // Try HTTP-date formats (RFC 1123 → RFC 1036 → asctime)
343                for (DateTimeFormatter fmt : List.of(RFC_1123_PARSER, RFC_1036_PARSER, ASCTIME_PARSER)) {
344                        try {
345                                return Instant.from(fmt.parse(value)).toEpochMilli();
346                        } catch (Exception ignored) {
347                                // try next
348                        }
349                }
350
351                // Fallback: epoch millis
352                try {
353                        return Long.parseLong(value);
354                } catch (NumberFormatException e) {
355                        throw new IllegalArgumentException(
356                                        String.format("Header with name '%s' and value '%s' cannot be converted to a date", name, value),
357                                        e
358                        );
359                }
360        }
361
362        @Override
363        @Nullable
364        public String getHeader(@Nullable String name) {
365                if (name == null)
366                        return null;
367
368                return getRequest().getHeader(name).orElse(null);
369        }
370
371        @Override
372        @Nonnull
373        public Enumeration<String> getHeaders(@Nullable String name) {
374                if (name == null)
375                        return Collections.emptyEnumeration();
376
377                Set<String> values = request.getHeaders().get(name);
378                return values == null ? Collections.emptyEnumeration() : Collections.enumeration(values);
379        }
380
381        @Override
382        @Nonnull
383        public Enumeration<String> getHeaderNames() {
384                return Collections.enumeration(getRequest().getHeaders().keySet());
385        }
386
387        @Override
388        public int getIntHeader(@Nullable String name) {
389                if (name == null)
390                        return -1;
391
392                String value = getHeader(name);
393
394                if (value == null)
395                        return -1;
396
397                // Throws NumberFormatException if parsing fails, per spec
398                return Integer.valueOf(value, 10);
399        }
400
401        @Override
402        @Nonnull
403        public String getMethod() {
404                return getRequest().getHttpMethod().name();
405        }
406
407        @Override
408        @Nullable
409        public String getPathInfo() {
410                return getRequest().getPath();
411        }
412
413        @Override
414        @Nullable
415        public String getPathTranslated() {
416                return getRequest().getPath();
417        }
418
419        @Override
420        @Nonnull
421        public String getContextPath() {
422                return "";
423        }
424
425        @Override
426        @Nullable
427        public String getQueryString() {
428                try {
429                        URI uri = new URI(request.getUri());
430                        return uri.getQuery();
431                } catch (Exception ignored) {
432                        return null;
433                }
434        }
435
436        @Override
437        @Nullable
438        public String getRemoteUser() {
439                // This is legal according to spec
440                return null;
441        }
442
443        @Override
444        public boolean isUserInRole(@Nullable String role) {
445                // This is legal according to spec
446                return false;
447        }
448
449        @Override
450        @Nullable
451        public Principal getUserPrincipal() {
452                // This is legal according to spec
453                return null;
454        }
455
456        @Override
457        @Nullable
458        public String getRequestedSessionId() {
459                // This is legal according to spec
460                return null;
461        }
462
463        @Override
464        @Nonnull
465        public String getRequestURI() {
466                return getRequest().getPath();
467        }
468
469        @Override
470        @Nonnull
471        public StringBuffer getRequestURL() {
472                // Try forwarded/synthesized absolute prefix first
473                String clientUrlPrefix = Utilities.extractClientUrlPrefixFromHeaders(getRequest().getHeaders()).orElse(null);
474
475                if (clientUrlPrefix != null)
476                        return new StringBuffer(format("%s%s", clientUrlPrefix, getRequest().getPath()));
477
478                // Fall back to builder-provided host/port when available
479                String scheme = getScheme(); // Soklet returns "http" by design
480                String host = getServerName();
481                int port = getServerPort(); // may throw if not initialized by builder
482                boolean defaultPort = ("https".equalsIgnoreCase(scheme) && port == 443) || ("http".equalsIgnoreCase(scheme) && port == 80);
483                String authority = defaultPort ? host : format("%s:%d", host, port);
484                return new StringBuffer(format("%s://%s%s", scheme, authority, getRequest().getPath()));
485        }
486
487        @Override
488        @Nonnull
489        public String getServletPath() {
490                // This is legal according to spec
491                return "";
492        }
493
494        @Override
495        @Nullable
496        public HttpSession getSession(boolean create) {
497                HttpSession currentHttpSession = getHttpSession().orElse(null);
498
499                if (create && currentHttpSession == null) {
500                        currentHttpSession = SokletHttpSession.withServletContext(getServletContext());
501                        setHttpSession(currentHttpSession);
502                }
503
504                return currentHttpSession;
505        }
506
507        @Override
508        @Nonnull
509        public HttpSession getSession() {
510                HttpSession currentHttpSession = getHttpSession().orElse(null);
511
512                if (currentHttpSession == null) {
513                        currentHttpSession = SokletHttpSession.withServletContext(getServletContext());
514                        setHttpSession(currentHttpSession);
515                }
516
517                return currentHttpSession;
518        }
519
520        @Override
521        @Nonnull
522        public String changeSessionId() {
523                HttpSession currentHttpSession = getHttpSession().orElse(null);
524
525                if (currentHttpSession == null)
526                        throw new IllegalStateException("No session is present");
527
528                if (!(currentHttpSession instanceof SokletHttpSession))
529                        throw new IllegalStateException(format("Cannot change session IDs. Session must be of type %s; instead it is of type %s",
530                                        SokletHttpSession.class.getSimpleName(), currentHttpSession.getClass().getSimpleName()));
531
532                UUID newSessionId = UUID.randomUUID();
533                ((SokletHttpSession) currentHttpSession).setSessionId(newSessionId);
534                return String.valueOf(newSessionId);
535        }
536
537        @Override
538        public boolean isRequestedSessionIdValid() {
539                // This is legal according to spec
540                return false;
541        }
542
543        @Override
544        public boolean isRequestedSessionIdFromCookie() {
545                // This is legal according to spec
546                return false;
547        }
548
549        @Override
550        public boolean isRequestedSessionIdFromURL() {
551                // This is legal according to spec
552                return false;
553        }
554
555        @Override
556        @Deprecated
557        public boolean isRequestedSessionIdFromUrl() {
558                // This is legal according to spec
559                return false;
560        }
561
562        @Override
563        public boolean authenticate(@Nonnull HttpServletResponse httpServletResponse) throws IOException, ServletException {
564                requireNonNull(httpServletResponse);
565                // TODO: perhaps revisit this in the future
566                throw new ServletException("Authentication is not supported");
567        }
568
569        @Override
570        public void login(@Nullable String username,
571                                                                                @Nullable String password) throws ServletException {
572                // This is legal according to spec
573                throw new ServletException("Authentication login is not supported");
574        }
575
576        @Override
577        public void logout() throws ServletException {
578                // This is legal according to spec
579                throw new ServletException("Authentication logout is not supported");
580        }
581
582        @Override
583        @Nonnull
584        public Collection<Part> getParts() throws IOException, ServletException {
585                // Legal if the request body is larger than maxRequestSize, or any Part in the request is larger than maxFileSize,
586                // or there is no @MultipartConfig or multipart-config in deployment descriptors
587                throw new IllegalStateException("Servlet multipart configuration is not supported");
588        }
589
590        @Override
591        @Nullable
592        public Part getPart(@Nullable String name) throws IOException, ServletException {
593                // Legal if the request body is larger than maxRequestSize, or any Part in the request is larger than maxFileSize,
594                // or there is no @MultipartConfig or multipart-config in deployment descriptors
595                throw new IllegalStateException("Servlet multipart configuration is not supported");
596        }
597
598        @Override
599        @Nonnull
600        public <T extends HttpUpgradeHandler> T upgrade(@Nullable Class<T> handlerClass) throws IOException, ServletException {
601                // Legal if the given handlerClass fails to be instantiated
602                throw new ServletException("HTTP upgrade is not supported");
603        }
604
605        @Override
606        @Nullable
607        public Object getAttribute(@Nullable String name) {
608                if (name == null)
609                        return null;
610
611                return getAttributes().get(name);
612        }
613
614        @Override
615        @Nonnull
616        public Enumeration<String> getAttributeNames() {
617                return Collections.enumeration(getAttributes().keySet());
618        }
619
620        @Override
621        @Nonnull
622        public String getCharacterEncoding() {
623                Charset charset = getCharset().orElse(null);
624                return charset == null ? null : charset.name();
625        }
626
627        @Override
628        public void setCharacterEncoding(@Nullable String env) throws UnsupportedEncodingException {
629                // Note that spec says: "This method must be called prior to reading request parameters or
630                // reading input using getReader(). Otherwise, it has no effect."
631                // ...but we don't need to care about this because Soklet requests are byte arrays of finite size, not streams
632                if (env == null) {
633                        setCharset(null);
634                } else {
635                        try {
636                                setCharset(Charset.forName(env));
637                        } catch (IllegalCharsetNameException | UnsupportedCharsetException e) {
638                                throw new UnsupportedEncodingException(format("Not sure how to handle character encoding '%s'", env));
639                        }
640                }
641        }
642
643        @Override
644        public int getContentLength() {
645                byte[] body = request.getBody().orElse(null);
646                return body == null ? 0 : body.length;
647        }
648
649        @Override
650        public long getContentLengthLong() {
651                byte[] body = request.getBody().orElse(null);
652                return body == null ? 0 : body.length;
653        }
654
655        @Override
656        @Nullable
657        public String getContentType() {
658                return this.contentType;
659        }
660
661        @Override
662        @Nonnull
663        public ServletInputStream getInputStream() throws IOException {
664                byte[] body = getRequest().getBody().orElse(new byte[]{});
665                return SokletServletInputStream.withInputStream(new ByteArrayInputStream(body));
666        }
667
668        @Override
669        @Nullable
670        public String getParameter(@Nullable String name) {
671                String value = null;
672
673                // First, check query parameters.
674                if (getRequest().getQueryParameters().keySet().contains(name)) {
675                        // If there is a query parameter with the given name, return it
676                        value = getRequest().getQueryParameter(name).orElse(null);
677                } else if (getRequest().getFormParameters().keySet().contains(name)) {
678                        // Otherwise, check form parameters in request body
679                        value = getRequest().getFormParameter(name).orElse(null);
680                }
681
682                return value;
683        }
684
685        @Override
686        @Nonnull
687        public Enumeration<String> getParameterNames() {
688                Set<String> queryParameterNames = getRequest().getQueryParameters().keySet();
689                Set<String> formParameterNames = getRequest().getFormParameters().keySet();
690
691                Set<String> parameterNames = new HashSet<>(queryParameterNames.size() + formParameterNames.size());
692                parameterNames.addAll(queryParameterNames);
693                parameterNames.addAll(formParameterNames);
694
695                return Collections.enumeration(parameterNames);
696        }
697
698        @Override
699        @Nullable
700        public String[] getParameterValues(@Nullable String name) {
701                if (name == null)
702                        return null;
703
704                List<String> parameterValues = new ArrayList<>();
705
706                Set<String> queryValues = getRequest().getQueryParameters().get(name);
707
708                if (queryValues != null)
709                        parameterValues.addAll(queryValues);
710
711                Set<String> formValues = getRequest().getFormParameters().get(name);
712
713                if (formValues != null)
714                        parameterValues.addAll(formValues);
715
716                return parameterValues.isEmpty() ? null : parameterValues.toArray(new String[0]);
717        }
718
719        @Override
720        @Nonnull
721        public Map<String, String[]> getParameterMap() {
722                Map<String, Set<String>> parameterMap = new HashMap<>();
723
724                // Mutable copy of entries
725                for (Entry<String, Set<String>> entry : getRequest().getQueryParameters().entrySet())
726                        parameterMap.put(entry.getKey(), new HashSet<>(entry.getValue()));
727
728                // Add form parameters to entries
729                for (Entry<String, Set<String>> entry : getRequest().getFormParameters().entrySet()) {
730                        Set<String> existingEntries = parameterMap.get(entry.getKey());
731
732                        if (existingEntries != null)
733                                existingEntries.addAll(entry.getValue());
734                        else
735                                parameterMap.put(entry.getKey(), entry.getValue());
736                }
737
738                Map<String, String[]> finalParameterMap = new HashMap<>();
739
740                for (Entry<String, Set<String>> entry : parameterMap.entrySet())
741                        finalParameterMap.put(entry.getKey(), entry.getValue().toArray(new String[0]));
742
743                return Collections.unmodifiableMap(finalParameterMap);
744        }
745
746        @Override
747        @Nonnull
748        public String getProtocol() {
749                return "HTTP/1.1";
750        }
751
752        @Override
753        @Nonnull
754        public String getScheme() {
755                // Honor common reverse-proxy header; fall back to http
756                String proto = getRequest().getHeader("X-Forwarded-Proto").orElse(null);
757
758                if (proto != null) {
759                        proto = proto.trim().toLowerCase(ROOT);
760                        if (proto.equals("https") || proto.equals("http"))
761                                return proto;
762                }
763
764                return "http";
765        }
766
767        @Override
768        @Nonnull
769        public String getServerName() {
770                // Path only (no query parameters) preceded by remote protocol, host, and port (if available)
771                // e.g. https://www.soklet.com/test/abc
772                String clientUrlPrefix = Utilities.extractClientUrlPrefixFromHeaders(getRequest().getHeaders()).orElse(null);
773
774                if (clientUrlPrefix == null)
775                        return getLocalName();
776
777                clientUrlPrefix = clientUrlPrefix.toLowerCase(ROOT);
778
779                // Remove protocol prefix
780                if (clientUrlPrefix.startsWith("https://"))
781                        clientUrlPrefix = clientUrlPrefix.replace("https://", "");
782                else if (clientUrlPrefix.startsWith("http://"))
783                        clientUrlPrefix = clientUrlPrefix.replace("http://", "");
784
785                // Remove "/" and anything after it
786                int indexOfFirstSlash = clientUrlPrefix.indexOf("/");
787
788                if (indexOfFirstSlash != -1)
789                        clientUrlPrefix = clientUrlPrefix.substring(0, indexOfFirstSlash);
790
791                // Remove ":" and anything after it (port)
792                int indexOfColon = clientUrlPrefix.indexOf(":");
793
794                if (indexOfColon != -1)
795                        clientUrlPrefix = clientUrlPrefix.substring(0, indexOfColon);
796
797                return clientUrlPrefix;
798        }
799
800        @Override
801        public int getServerPort() {
802                // Path only (no query parameters) preceded by remote protocol, host, and port (if available)
803                // e.g. https://www.soklet.com/test/abc
804                String clientUrlPrefix = Utilities.extractClientUrlPrefixFromHeaders(getRequest().getHeaders()).orElse(null);
805
806                if (clientUrlPrefix == null)
807                        return getLocalPort();
808
809                clientUrlPrefix = clientUrlPrefix.toLowerCase(ROOT);
810
811                boolean https = false;
812
813                // Remove protocol prefix
814                if (clientUrlPrefix.startsWith("https://")) {
815                        clientUrlPrefix = clientUrlPrefix.replace("https://", "");
816                        https = true;
817                } else if (clientUrlPrefix.startsWith("http://")) {
818                        clientUrlPrefix = clientUrlPrefix.replace("http://", "");
819                }
820
821                // Remove "/" and anything after it
822                int indexOfFirstSlash = clientUrlPrefix.indexOf("/");
823
824                if (indexOfFirstSlash != -1)
825                        clientUrlPrefix = clientUrlPrefix.substring(0, indexOfFirstSlash);
826
827                String[] hostAndPortComponents = clientUrlPrefix.split(":");
828
829                // No explicit port?  Look at protocol for guidance
830                if (hostAndPortComponents.length == 1)
831                        return https ? 443 : 80;
832
833                try {
834                        return Integer.parseInt(hostAndPortComponents[1], 10);
835                } catch (Exception ignored) {
836                        return getLocalPort();
837                }
838        }
839
840        @Override
841        @Nonnull
842        public BufferedReader getReader() throws IOException {
843                Charset charset = getCharset().orElse(DEFAULT_CHARSET);
844                InputStream inputStream = new ByteArrayInputStream(getRequest().getBody().orElse(new byte[0]));
845                return new BufferedReader(new InputStreamReader(inputStream, charset));
846        }
847
848        @Override
849        @Nullable
850        public String getRemoteAddr() {
851                String xForwardedForHeader = getRequest().getHeader("X-Forwarded-For").orElse(null);
852
853                if (xForwardedForHeader == null)
854                        return null;
855
856                // Example value: 203.0.113.195,2001:db8:85a3:8d3:1319:8a2e:370:7348,198.51.100.178
857                String[] components = xForwardedForHeader.split(",");
858
859                if (components.length == 0 || components[0] == null)
860                        return null;
861
862                String value = components[0].trim();
863                return value.length() > 0 ? value : "127.0.0.1";
864        }
865
866        @Override
867        @Nullable
868        public String getRemoteHost() {
869                // This is X-Forwarded-For and is generally what we want (if present)
870                String remoteAddr = getRemoteAddr();
871
872                if (remoteAddr != null)
873                        return remoteAddr;
874
875                // Path only (no query parameters) preceded by remote protocol, host, and port (if available)
876                // e.g. https://www.soklet.com/test/abc
877                String clientUrlPrefix = Utilities.extractClientUrlPrefixFromHeaders(getRequest().getHeaders()).orElse(null);
878
879                if (clientUrlPrefix != null) {
880                        clientUrlPrefix = clientUrlPrefix.toLowerCase(ROOT);
881
882                        // Remove protocol prefix
883                        if (clientUrlPrefix.startsWith("https://"))
884                                clientUrlPrefix = clientUrlPrefix.replace("https://", "");
885                        else if (clientUrlPrefix.startsWith("http://"))
886                                clientUrlPrefix = clientUrlPrefix.replace("http://", "");
887
888                        // Remove "/" and anything after it
889                        int indexOfFirstSlash = clientUrlPrefix.indexOf("/");
890
891                        if (indexOfFirstSlash != -1)
892                                clientUrlPrefix = clientUrlPrefix.substring(0, indexOfFirstSlash);
893
894                        String[] hostAndPortComponents = clientUrlPrefix.split(":");
895
896                        String host = null;
897
898                        if (hostAndPortComponents != null && hostAndPortComponents.length > 0 && hostAndPortComponents[0] != null)
899                                host = hostAndPortComponents[0].trim();
900
901                        if (host != null && host.length() > 0)
902                                return host;
903                }
904
905                // "If the engine cannot or chooses not to resolve the hostname (to improve performance),
906                // this method returns the dotted-string form of the IP address."
907                return getRemoteAddr();
908        }
909
910        @Override
911        public void setAttribute(@Nullable String name,
912                                                                                                         @Nullable Object o) {
913                if (name == null)
914                        return;
915
916                if (o == null)
917                        removeAttribute(name);
918                else
919                        getAttributes().put(name, o);
920        }
921
922        @Override
923        public void removeAttribute(@Nullable String name) {
924                if (name == null)
925                        return;
926
927                getAttributes().remove(name);
928        }
929
930        @Override
931        @Nonnull
932        public Locale getLocale() {
933                List<Locale> locales = getRequest().getLocales();
934                return locales.size() == 0 ? getDefault() : locales.get(0);
935        }
936
937        @Override
938        @Nonnull
939        public Enumeration<Locale> getLocales() {
940                List<Locale> locales = getRequest().getLocales();
941                return Collections.enumeration(locales.size() == 0 ? List.of(getDefault()) : locales);
942        }
943
944        @Override
945        public boolean isSecure() {
946                return getScheme().equals("https");
947        }
948
949        @Override
950        @Nullable
951        public RequestDispatcher getRequestDispatcher(@Nullable String path) {
952                // "This method returns null if the servlet container cannot return a RequestDispatcher."
953                return null;
954        }
955
956        @Override
957        @Deprecated
958        @Nullable
959        public String getRealPath(String path) {
960                // "As of Version 2.1 of the Java Servlet API, use ServletContext.getRealPath(java.lang.String) instead."
961                return getServletContext().getRealPath(path);
962        }
963
964        @Override
965        public int getRemotePort() {
966                // Not reliably knowable without a socket; return 0 to indicate "unknown"
967                return 0;
968        }
969
970        @Override
971        @Nonnull
972        public String getLocalName() {
973                if (getHost().isPresent())
974                        return getHost().get();
975
976                try {
977                        String hostName = InetAddress.getLocalHost().getHostName();
978
979                        if (hostName != null) {
980                                hostName = hostName.trim();
981
982                                if (hostName.length() > 0)
983                                        return hostName;
984                        }
985                } catch (Exception e) {
986                        // Ignored
987                }
988
989                return "localhost";
990        }
991
992        @Override
993        @Nonnull
994        public String getLocalAddr() {
995                try {
996                        String hostAddress = InetAddress.getLocalHost().getHostAddress();
997
998                        if (hostAddress != null) {
999                                hostAddress = hostAddress.trim();
1000
1001                                if (hostAddress.length() > 0)
1002                                        return hostAddress;
1003                        }
1004                } catch (Exception e) {
1005                        // Ignored
1006                }
1007
1008                return "127.0.0.1";
1009        }
1010
1011        @Override
1012        public int getLocalPort() {
1013                return getPort().orElseThrow(() -> new IllegalStateException(format("%s must be initialized with a port in order to call this method",
1014                                getClass().getSimpleName())));
1015        }
1016
1017        @Override
1018        @Nonnull
1019        public ServletContext getServletContext() {
1020                return this.servletContext;
1021        }
1022
1023        @Override
1024        @Nonnull
1025        public AsyncContext startAsync() throws IllegalStateException {
1026                throw new IllegalStateException("Soklet does not support async servlet operations");
1027        }
1028
1029        @Override
1030        @Nonnull
1031        public AsyncContext startAsync(@Nonnull ServletRequest servletRequest,
1032                                                                                                                                 @Nonnull ServletResponse servletResponse) throws IllegalStateException {
1033                requireNonNull(servletResponse);
1034                requireNonNull(servletResponse);
1035
1036                throw new IllegalStateException("Soklet does not support async servlet operations");
1037        }
1038
1039        @Override
1040        public boolean isAsyncStarted() {
1041                return false;
1042        }
1043
1044        @Override
1045        public boolean isAsyncSupported() {
1046                return false;
1047        }
1048
1049        @Override
1050        @Nonnull
1051        public AsyncContext getAsyncContext() {
1052                throw new IllegalStateException("Soklet does not support async servlet operations");
1053        }
1054
1055        @Override
1056        @Nonnull
1057        public DispatcherType getDispatcherType() {
1058                // Currently Soklet does not support RequestDispatcher, so this is safe to hardcode
1059                return DispatcherType.REQUEST;
1060        }
1061}