HttpUtil.java

/*
 * Copyright (c) 2007-2026 MetaSolutions AB
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.entrystore.rest.springboot.util;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.entrystore.rest.springboot.filter.CacheControlFilter;
import org.entrystore.rest.springboot.model.exception.EntityTooLargeException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.DigestUtils;

import java.io.IOException;
import java.io.InputStream;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Date;
import java.util.List;
import java.util.regex.Pattern;

@Slf4j
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class HttpUtil {

	/**
	 * Determines the media type based on the provided format parameter or content type header.
	 *
	 * @param format The format parameter from the request, bound via {@code MediaTypeConverter}.
	 * @param contentType The raw content-type header string from the request.
	 * @return The format parameter's string form if present (parameters preserved), otherwise the
	 *         normalized content type (type/subtype only), or null if neither can be determined.
	 */
	public static String determineMediaType(MediaType format, String contentType) {
		if (format != null) {
			return format.toString();
		}
		// content-type header often includes other data like character encoding, e.g.: 'application/json; charset=UTF-8'
		return normalizeMediaType(contentType);
	}

	/**
	 * Normalizes a content-type header string by parsing it and returning the type and subtype
	 * in lowercase, e.g., "application/json". If the contentType is null or cannot be parsed,
	 * null is returned.
	 *
	 * @param contentType The raw content-type header string as received in the request.
	 * @return The normalized media type string, or null if parsing fails or input is null.
	 */
	public static String normalizeMediaType(String contentType) {
		if (contentType != null) {
			try {
				MediaType mt = MediaType.parseMediaType(contentType);
				return mt.getType() + "/" + mt.getSubtype();
			} catch (IllegalArgumentException e) {
				log.debug("Could not parse content-type header value of '{}'. Error: {}", contentType, e.getMessage());
			}
		}
		return null;
	}

	/**
	 * Creates a strong ETag string.
	 * Example: "1686594567200"
	 */
	public static String createStrongETag(String tag) {
		return "\"" + tag + "\"";
	}

	/**
	 * Sets Last-Modified and ETag headers on the provided {@link HttpHeaders} instance.
	 * If the modification date is null, a debug message is logged and the headers are not updated.
	 *
	 * <p>Debug rather than warn: an entry whose graph carries no {@code dcterms:modified} is a data
	 * property rather than a server fault, and the unauthenticated read callers (the relation, lookup
	 * and metadata GETs) sit on endpoints anyone can request in a loop — at warn, one such entry lets
	 * an anonymous client drive unbounded log volume. The write callers (the context and group POSTs,
	 * the entry and metadata PUTs) share this helper and are knowingly silenced along with them, even
	 * though for those a null date after a successful write is a server-side invariant violation
	 * rather than a data property.
	 *
	 * @param headers      the headers to update
	 * @param modifiedDate the modification date used for generating the headers
	 */
	public static void setLastModifiedAndETag(HttpHeaders headers, Date modifiedDate) {
		if (modifiedDate == null) {
			// debug, not warn — rationale in the Javadoc above.
			log.debug("Last-Modified and ETag omitted because the modification date is null");
		} else {
			headers.setLastModified(modifiedDate.getTime());
			headers.setETag(createStrongETag(Long.toString(modifiedDate.getTime())));
		}
	}

	/**
	 * Updates the response headers with the last modification date and a strong ETag
	 * based on the provided modification date.
	 * A null modification date is logged at debug and the headers are not updated, exactly as in
	 * {@link #setLastModifiedAndETag(HttpHeaders, Date)}, which this method delegates to and whose
	 * Javadoc carries the rationale for the log level.
	 *
	 * @param responseBuilder the response builder used to set the headers
	 * @param modifiedDate    the modification date used for generating the headers
	 */
	public static ResponseEntity.HeadersBuilder<?> updateResponseWithModificationDateAndETag(
			ResponseEntity.HeadersBuilder<?> responseBuilder,
			Date modifiedDate) {

		responseBuilder.headers(headers -> setLastModifiedAndETag(headers, modifiedDate));
		return responseBuilder;
	}

	/**
	 * Sets the headers of an entry or resource representation that let a GET or HEAD be revalidated with 304:
	 * Last-Modified and ETag from the entry's modification date, and {@code Vary} on the credentials, since the
	 * representation depends on the user, plus on Accept where it selects the representation. A response that
	 * {@code CacheControlFilter} did not mark as authenticated gets {@link CacheControlFilter#CACHE_CONTROL_ANONYMOUS},
	 * so guests revalidate too. Spring adds these {@code Vary} values to those already on the response, such as the
	 * CORS ones.
	 */
	public static void setRevalidationHeaders(HttpHeaders headers, HttpServletResponse response, Date modifiedDate,
											  boolean variesWithAccept) {
		setLastModifiedAndETag(headers, modifiedDate);
		headers.setVary(variesWithAccept
				? List.of(HttpHeaders.ACCEPT, HttpHeaders.COOKIE, HttpHeaders.AUTHORIZATION)
				: List.of(HttpHeaders.COOKIE, HttpHeaders.AUTHORIZATION));
		if (response.getHeader(HttpHeaders.CACHE_CONTROL) == null) {
			headers.setCacheControl(CacheControlFilter.CACHE_CONTROL_ANONYMOUS);
		}
	}

	/**
	 * Like {@link #setRevalidationHeaders}, for an entry representation reduced to what a caller who may read neither
	 * the entry's metadata nor its resource may see. Its ETag is the date ETag with {@code -r} appended, so a caller
	 * who gains or loses read access while the entry's modification date stays is not answered with 304.
	 */
	public static void setReducedRevalidationHeaders(HttpHeaders headers, HttpServletResponse response,
													 Date modifiedDate, boolean variesWithAccept) {
		setRevalidationHeaders(headers, response, modifiedDate, variesWithAccept);
		if (modifiedDate != null) {
			headers.setETag(createStrongETag(modifiedDate.getTime() + "-r"));
		}
	}

	/**
	 * Like {@link #setRevalidationHeaders}, but with an ETag computed from {@code content}, for a representation that
	 * embeds other entries or the caller's rights and so can change while the entry's modification date does not.
	 * Last-Modified stays the entry's date: a client revalidating with If-Modified-Since alone can still get a 304
	 * for changed content, as in 5.x, while browsers send If-None-Match, which takes precedence.
	 */
	public static void setContentRevalidationHeaders(HttpHeaders headers, HttpServletResponse response,
													 Date modifiedDate, byte[] content, boolean variesWithAccept) {
		setRevalidationHeaders(headers, response, modifiedDate, variesWithAccept);
		headers.setETag(createStrongETag(DigestUtils.md5DigestAsHex(content)));
	}

	/**
	 * Whether a {@code Range} request may be answered from the current representation (RFC 9110, section 13.1.5):
	 * without {@code If-Range}, or when it names the current strong ETag or exactly the second of Last-Modified.
	 * Otherwise the full representation must be sent.
	 *
	 * @param ifRange      the {@code If-Range} header, or null
	 * @param modifiedDate the date the response's validators are built from, see {@link #setLastModifiedAndETag}
	 */
	public static boolean ifRangeMatches(String ifRange, Date modifiedDate) {
		if (ifRange == null) {
			return true;
		}
		if (modifiedDate == null) {
			return false;
		}
		String value = ifRange.trim();
		if (value.startsWith("\"") || value.startsWith("W/")) {
			return value.equals(createStrongETag(Long.toString(modifiedDate.getTime())));
		}
		try {
			long date = ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant().toEpochMilli();
			return date == modifiedDate.getTime() / 1000 * 1000;
		} catch (DateTimeParseException e) {
			return false;
		}
	}

	public static boolean isLargerThan(HttpServletRequest request, long maxSize) {
		if (request == null) {
			return false;
		}
		long repSize = request.getContentLength();
		if (repSize == -1L) {
			log.warn("Size of representation is unknown");
			return true;
		}

		return repSize > maxSize;
	}

	public static void checkRequestSize(HttpServletRequest request, int maxRequestSize) {
		if (HttpUtil.isLargerThan(request, maxRequestSize)) {
			throw new EntityTooLargeException("The size of the representation is larger than " + maxRequestSize + "bytes or unknown, request blocked.");
		}
	}

	/** How much of a rejected request body {@link #discardRejectedBody} reads at most. */
	public static final long MAX_DRAIN_BYTES = 4L * 1024 * 1024;

	/**
	 * Reads and discards up to {@link #MAX_DRAIN_BYTES} of a rejected request body, so that a client that reads the
	 * response only once it has sent a moderately oversized body, like {@code HttpURLConnection} or a piped Node
	 * stream, gets the error response instead of a connection reset. Skipped after {@code Expect: 100-continue},
	 * whose client has not sent the body and would be asked for it.
	 */
	public static void discardRejectedBody(HttpServletRequest request) {
		if ("100-continue".equalsIgnoreCase(request.getHeader(HttpHeaders.EXPECT))) {
			return;
		}
		try {
			InputStream in = request.getInputStream();
			byte[] buf = new byte[8192];
			long drained = 0;
			int read;
			while (drained < MAX_DRAIN_BYTES && (read = in.read(buf)) != -1) {
				drained += read;
			}
		} catch (IOException e) {
			log.debug("Client went away while its rejected request body was discarded: {}", e.getMessage());
		}
	}

	/** Whether the request declares a {@code multipart/form-data} body. */
	public static boolean isMultipart(HttpServletRequest request) {
		String contentType = request.getContentType();
		return contentType != null && contentType.regionMatches(true, 0, MediaType.MULTIPART_FORM_DATA_VALUE, 0,
				MediaType.MULTIPART_FORM_DATA_VALUE.length());
	}

	/**
	 * The first value of query parameter {@code name}, an empty string for a bare {@code ?name}, or null.
	 * Unlike {@code getParameter} it never reads the body, which for a multipart request would make Jetty
	 * parse the whole upload to temporary files before any access check. A pair with malformed
	 * percent-encoding is skipped, so it neither fails the request nor hides the other pairs.
	 */
	public static String getQueryParameter(HttpServletRequest request, String name) {
		String query = request.getQueryString();
		if (query == null) {
			return null;
		}
		for (String pair : query.split("&")) {
			int eq = pair.indexOf('=');
			try {
				String key = URLDecoder.decode(eq < 0 ? pair : pair.substring(0, eq), StandardCharsets.UTF_8);
				if (key.equals(name)) {
					return eq < 0 ? "" : URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8);
				}
			} catch (IllegalArgumentException e) {
				log.debug("Skipping query parameter with malformed encoding: {}", sanitizeForLog(pair));
			}
		}
		return null;
	}

	private static final int LOG_VALUE_MAX_LENGTH = 128;
	private static final Pattern CONTROL_CHARS = Pattern.compile("\\p{Cntrl}");

	/**
	 * Returns a representation of {@code value} that is safe to embed in a log line:
	 * control characters (including CR/LF) are replaced with {@code ?} so attacker-supplied
	 * input cannot forge synthetic log entries, and the result is truncated so a multi-MB
	 * username cannot blow up the log appender. Truncation happens before the regex pass so
	 * the matcher only ever scans at most {@link #LOG_VALUE_MAX_LENGTH} characters — an
	 * attacker padding a login parameter with megabytes of garbage cannot force a full-input
	 * scan + intermediate StringBuilder allocation on the credential-stuffing hot path.
	 * Returns the literal string {@code "null"} when the input is null so the call site does
	 * not have to guard.
	 */
	public static String sanitizeForLog(String value) {
		if (value == null) {
			return "null";
		}
		boolean truncated = value.length() > LOG_VALUE_MAX_LENGTH;
		String head = truncated ? value.substring(0, LOG_VALUE_MAX_LENGTH) : value;
		String stripped = CONTROL_CHARS.matcher(head).replaceAll("?");
		return truncated ? stripped + "…" : stripped;
	}

	/**
	 * Clears the {@link SecurityContextHolder} and invalidates the current HTTP session if one exists.
	 * Used on SSO reject paths: the authentication filter has already persisted the token to the
	 * SecurityContext before the success handler runs, so this undoes that persistence before the
	 * rejection is redirected back to the user.
	 */
	public static void clearAuthenticatedSession(HttpServletRequest request) {
		SecurityContextHolder.clearContext();
		HttpSession session = request.getSession(false);
		if (session != null) {
			try {
				session.invalidate();
			} catch (IllegalStateException alreadyInvalidated) {
				// Concurrent request (or the container) already invalidated this session — benign.
				log.debug("Session already invalidated");
			}
		}
	}
}