blob: bc3d3cd9909fa685a5771072d6ecf04ab0d1d283 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.
*
* Contributors:
* Original contributors from geronimo-javamail_1.4_spec-1.7.1
* Florent Guillaume
*/
package org.apache.chemistry.opencmis.commons.impl;
import java.io.ByteArrayOutputStream;
import java.io.UnsupportedEncodingException;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import org.apache.chemistry.opencmis.commons.exceptions.CmisRuntimeException;
/**
* MIME helper class.
*/
public final class MimeHelper {
public static final String CONTENT_DISPOSITION = "Content-Disposition";
public static final String DISPOSITION_ATTACHMENT = "attachment";
public static final String DISPOSITION_INLINE = "inline";
public static final String DISPOSITION_FILENAME = "filename";
public static final String DISPOSITION_NAME = "name";
public static final String DISPOSITION_FORM_DATA_CONTENT = "form-data; " + DISPOSITION_NAME + "=\"content\"";
// RFC 2045
private static final String MIME_SPECIALS = "()<>@,;:\\\"/[]?=" + "\t ";
private static final String RFC2231_SPECIALS = "*'%" + MIME_SPECIALS;
private static final String WHITE = " \t\n\r";
private static final char[] HEX_DIGITS = "0123456789ABCDEF".toCharArray();
private static final byte[] HEX_DECODE = new byte[0x80];
static {
for (int i = 0; i < HEX_DIGITS.length; i++) {
HEX_DECODE[HEX_DIGITS[i]] = (byte) i;
HEX_DECODE[Character.toLowerCase(HEX_DIGITS[i])] = (byte) i;
}
}
private MimeHelper() {
}
/**
* Encodes a value per RFC 2231.
* <p>
* This is used to pass non-ASCII parameters to MIME parameter lists.
* <p>
* This implementation always uses UTF-8 and no language.
* <p>
* See <a href="https://tools.ietf.org/html/rfc2231">RFC 2231</a> for
* details.
*
* @param value
* the value to encode
* @param buf
* the buffer to fill
* @return {@code true} if an encoding was needed, or {@code false} if no
* encoding was actually needed
*/
protected static boolean encodeRFC2231value(String value, StringBuilder buf) {
assert value != null;
assert buf != null;
String charset = IOUtils.UTF8;
buf.append(charset);
buf.append("''"); // no language
byte[] bytes = IOUtils.toUTF8Bytes(value);
boolean encoded = false;
for (int i = 0; i < bytes.length; i++) {
int ch = bytes[i] & 0xff;
if (ch <= 32 || ch >= 127 || RFC2231_SPECIALS.indexOf(ch) != -1) {
buf.append('%');
buf.append(HEX_DIGITS[ch >> 4]);
buf.append(HEX_DIGITS[ch & 0xf]);
encoded = true;
} else {
buf.append((char) ch);
}
}
return encoded;
}
/**
* Encodes a MIME parameter per RFC 2231.
* <p>
* This implementation always uses UTF-8 and no language.
* <p>
* See <a href="https://tools.ietf.org/html/rfc2231">RFC 2231</a> for
* details.
*
* @param value
* the string to encode
* @return the encoded string
*/
protected static String encodeRFC2231(String key, String value) {
StringBuilder buf = new StringBuilder(32);
boolean encoded = encodeRFC2231value(value, buf);
if (encoded) {
return "; " + key + "*=" + buf.toString();
} else {
return "; " + key + "=" + value;
}
}
/**
* Encodes the Content-Disposition header value according to RFC 2183 and
* RFC 2231.
* <p>
* See <a href="https://tools.ietf.org/html/rfc2231">RFC 2231</a> for
* details.
*
* @param disposition
* the disposition
* @param filename
* the file name
* @return the encoded header value
*/
public static String encodeContentDisposition(String disposition, String filename) {
if (disposition == null) {
disposition = DISPOSITION_ATTACHMENT;
}
return disposition + encodeRFC2231(DISPOSITION_FILENAME, filename);
}
/**
* Decodes a filename from the Content-Disposition header value according to
* RFC 2183 and RFC 2231.
* <p>
* See <a href="https://tools.ietf.org/html/rfc2231">RFC 2231</a> for
* details.
*
* @param value
* the header value to decode
* @return the filename
*/
public static String decodeContentDispositionFilename(String value) {
Map<String, String> params = new HashMap<String, String>();
decodeContentDisposition(value, params);
return params.get(DISPOSITION_FILENAME);
}
/**
* Decodes the Content-Disposition header value according to RFC 2183 and
* RFC 2231.
* <p>
* Does not deal with continuation lines.
* <p>
* See <a href="https://tools.ietf.org/html/rfc2231">RFC 2231</a> for
* details.
*
* @param value
* the header value to decode
* @param params
* the map of parameters to fill
* @return the disposition
*
*/
public static String decodeContentDisposition(String value, Map<String, String> params) {
try {
HeaderTokenizer tokenizer = new HeaderTokenizer(value);
// get the first token, which must be an ATOM
Token token = tokenizer.next();
if (token.getType() != Token.ATOM) {
return null;
}
String disposition = token.getValue();
// value ignored in this method
// the remainder is the parameters
String remainder = tokenizer.getRemainder();
if (remainder != null) {
getParameters(remainder, params);
}
return disposition;
} catch (ParseException e) {
return null;
}
}
/**
* Gets charset from a content type header.
*
* @param value
* the header value to decode
* @return the charset or <code>null</code> if no valid boundary available
*/
public static String getCharsetFromContentType(String value) {
try {
HeaderTokenizer tokenizer = new HeaderTokenizer(value, ";", true);
// get the first token, which must be an ATOM
Token token = tokenizer.next();
if (token.getType() != Token.ATOM) {
return null;
}
// the remainder is the parameters
String remainder = tokenizer.getRemainder();
Map<String, String> params;
if (remainder != null) {
params = new HashMap<String, String>();
getParameters(remainder, params);
return params.get("charset");
}
} catch (ParseException e) {
return null;
}
return null;
}
/**
* Parses a WWW-Authenticate header value.
*
* @param value
* the header value to parse
*
* @return a map with the (lower case) challenge name as key and as the
* value a sub-map with parameters of the challenge
*/
public static Map<String, Map<String, String>> getChallengesFromAuthenticateHeader(String value) {
if (value == null || value.length() == 0) {
return null;
}
final String trimValue = value.trim();
Map<String, Map<String, String>> result = new HashMap<String, Map<String, String>>();
boolean inQuotes = false;
boolean inName = true;
String challenge = null;
String paramName = "";
StringBuilder sb = new StringBuilder(64);
for (int i = 0; i < trimValue.length(); i++) {
char c = trimValue.charAt(i);
if (c == '\\') {
if (!inQuotes) {
return null;
}
if (trimValue.length() > i && trimValue.charAt(i + 1) == '\\') {
sb.append('\\');
i++;
} else if (trimValue.length() > i && trimValue.charAt(i + 1) == '"') {
sb.append('"');
i++;
} else {
return null;
}
} else if (c == '"') {
if (inName) {
return null;
}
if (inQuotes) {
Map<String, String> authMap = result.get(challenge);
if (authMap == null) {
return null;
}
authMap.put(paramName, sb.toString());
}
sb.setLength(0);
inQuotes = !inQuotes;
} else if (c == '=') {
if (inName) {
paramName = sb.toString().trim();
int spcIdx = paramName.indexOf(' ');
if (spcIdx > -1) {
challenge = paramName.substring(0, spcIdx).toLowerCase(Locale.ENGLISH);
result.put(challenge, new HashMap<String, String>());
paramName = paramName.substring(spcIdx).trim();
}
sb.setLength(0);
inName = false;
} else if (!inQuotes) {
return null;
}
} else if (c == ',') {
if (inName) {
challenge = sb.toString().trim().toLowerCase(Locale.ENGLISH);
result.put(challenge, new HashMap<String, String>());
sb.setLength(0);
} else {
if (inQuotes) {
sb.append(c);
} else {
Map<String, String> authMap = result.get(challenge);
if (authMap == null) {
return null;
}
if (!authMap.containsKey(paramName)) {
authMap.put(paramName, sb.toString().trim());
}
sb.setLength(0);
inName = true;
}
}
} else {
sb.append(c);
}
}
if (inQuotes) {
return null;
}
if (inName) {
challenge = sb.toString().trim().toLowerCase(Locale.ENGLISH);
result.put(challenge, new HashMap<String, String>());
} else {
Map<String, String> authMap = result.get(challenge);
if (authMap == null) {
return null;
}
if (!authMap.containsKey(paramName)) {
authMap.put(paramName, sb.toString().trim());
}
}
return result;
}
/**
* Gets the boundary from a <code>multipart/formdata</code> content type
* header.
*
* @param value
* the header value to decode
* @return the boundary as a byte array or <code>null</code> if no valid
* boundary available
*/
public static byte[] getBoundaryFromMultiPart(String value) {
try {
HeaderTokenizer tokenizer = new HeaderTokenizer(value, ";", true);
// get the first token, which must be an ATOM
Token token = tokenizer.next();
if (token.getType() != Token.ATOM) {
return null;
}
// check content type
String multipartContentType = token.getValue();
if (multipartContentType == null
|| !(multipartContentType.equalsIgnoreCase("multipart/form-data") || multipartContentType
.equalsIgnoreCase("multipart/related"))) {
return null;
}
// the remainder is the parameters
String remainder = tokenizer.getRemainder();
if (remainder != null) {
Map<String, String> params = new HashMap<String, String>();
getParameters(remainder, params);
String boundaryStr = params.get("boundary");
if (boundaryStr != null && boundaryStr.length() > 0) {
try {
return boundaryStr.getBytes(IOUtils.ISO_8859_1);
} catch (UnsupportedEncodingException e) {
// shouldn't happen...
throw new CmisRuntimeException("Unsupported encoding 'ISO-8859-1'", e);
}
}
}
} catch (ParseException e) {
return null;
}
return null;
}
protected static class ParseException extends Exception {
private static final long serialVersionUID = 1L;
public ParseException() {
super();
}
public ParseException(String message) {
super(message);
}
}
/*
* From geronimo-javamail_1.4_spec-1.7.1. Token
*/
protected static class Token {
// Constant values from J2SE 1.4 API Docs (Constant values)
public static final int ATOM = -1;
public static final int COMMENT = -3;
public static final int EOF = -4;
public static final int QUOTEDSTRING = -2;
private final int type;
private final String value;
public Token(int type, String value) {
this.type = type;
this.value = value;
}
public int getType() {
return type;
}
public String getValue() {
return value;
}
}
/*
* Tweaked from geronimo-javamail_1.4_spec-1.7.1. HeaderTokenizer
*/
protected static class HeaderTokenizer {
private static final Token EOF = new Token(Token.EOF, null);
private final String header;
private final String delimiters;
private final boolean skipComments;
private int pos;
public HeaderTokenizer(String header) {
this(header, MIME_SPECIALS, true);
}
protected HeaderTokenizer(String header, String delimiters, boolean skipComments) {
this.header = header;
this.delimiters = delimiters;
this.skipComments = skipComments;
}
public String getRemainder() {
return header.substring(pos);
}
public Token next() throws ParseException {
return readToken();
}
/**
* Read an ATOM token from the parsed header.
*
* @return A token containing the value of the atom token.
*/
private Token readAtomicToken() {
// skip to next delimiter
int start = pos;
while (++pos < header.length()) {
// break on the first non-atom character.
char ch = header.charAt(pos);
if (delimiters.indexOf(header.charAt(pos)) != -1 || ch < 32 || ch >= 127) {
break;
}
}
return new Token(Token.ATOM, header.substring(start, pos));
}
/**
* Read the next token from the header.
*
* @return The next token from the header. White space is skipped, and
* comment tokens are also skipped if indicated.
*/
private Token readToken() throws ParseException {
if (pos >= header.length()) {
return EOF;
} else {
char c = header.charAt(pos);
// comment token...read and skip over this
if (c == '(') {
Token comment = readComment();
if (skipComments) {
return readToken();
} else {
return comment;
}
// quoted literal
} else if (c == '\"') {
return readQuotedString();
// white space, eat this and find a real token.
} else if (WHITE.indexOf(c) != -1) {
eatWhiteSpace();
return readToken();
// either a CTL or special. These characters have a
// self-defining token type.
} else if (c < 32 || c >= 127 || delimiters.indexOf(c) != -1) {
pos++;
return new Token(c, String.valueOf(c));
} else {
// start of an atom, parse it off.
return readAtomicToken();
}
}
}
/**
* Extract a substring from the header string and apply any
* escaping/folding rules to the string.
*
* @param start
* The starting offset in the header.
* @param end
* The header end offset + 1.
* @return The processed string value.
*/
private String getEscapedValue(int start, int end) throws ParseException {
StringBuilder value = new StringBuilder(32);
for (int i = start; i < end; i++) {
char ch = header.charAt(i);
// is this an escape character?
if (ch == '\\') {
i++;
if (i == end) {
throw new ParseException("Invalid escape character");
}
value.append(header.charAt(i));
} else if (ch == '\r') {
// line breaks are ignored, except for naked '\n'
// characters, which are consider parts of linear
// whitespace.
// see if this is a CRLF sequence, and skip the second if it
// is.
if (i < end - 1 && header.charAt(i + 1) == '\n') {
i++;
}
} else {
// just append the ch value.
value.append(ch);
}
}
return value.toString();
}
/**
* Read a comment from the header, applying nesting and escape rules to
* the content.
*
* @return A comment token with the token value.
*/
private Token readComment() throws ParseException {
int start = pos + 1;
int nesting = 1;
boolean requiresEscaping = false;
// skip to end of comment/string
while (++pos < header.length()) {
char ch = header.charAt(pos);
if (ch == ')') {
nesting--;
if (nesting == 0) {
break;
}
} else if (ch == '(') {
nesting++;
} else if (ch == '\\') {
pos++;
requiresEscaping = true;
} else if (ch == '\r') {
// we need to process line breaks also
requiresEscaping = true;
}
}
if (nesting != 0) {
throw new ParseException("Unbalanced comments");
}
String value;
if (requiresEscaping) {
value = getEscapedValue(start, pos);
} else {
value = header.substring(start, pos++);
}
return new Token(Token.COMMENT, value);
}
/**
* Parse out a quoted string from the header, applying escaping rules to
* the value.
*
* @return The QUOTEDSTRING token with the value.
* @exception ParseException
*/
private Token readQuotedString() throws ParseException {
int start = pos + 1;
boolean requiresEscaping = false;
// skip to end of comment/string
while (++pos < header.length()) {
char ch = header.charAt(pos);
if (ch == '"') {
String value;
if (requiresEscaping) {
value = getEscapedValue(start, pos++);
} else {
value = header.substring(start, pos++);
}
return new Token(Token.QUOTEDSTRING, value);
} else if (ch == '\\') {
pos++;
requiresEscaping = true;
} else if (ch == '\r') {
// we need to process line breaks also
requiresEscaping = true;
}
}
throw new ParseException("Missing '\"'");
}
/**
* Skip white space in the token string.
*/
private void eatWhiteSpace() {
// skip to end of whitespace
while (++pos < header.length() && WHITE.indexOf(header.charAt(pos)) != -1) {
// just read
}
}
}
/*
* Tweaked from geronimo-javamail_1.4_spec-1.7.1. ParameterList
*/
protected static Map<String, String> getParameters(String list, Map<String, String> params) throws ParseException {
HeaderTokenizer tokenizer = new HeaderTokenizer(list);
while (true) {
Token token = tokenizer.next();
switch (token.getType()) {
case Token.EOF:
// the EOF token terminates parsing.
return params;
case ';':
// each new parameter is separated by a semicolon, including
// the first, which separates
// the parameters from the main part of the header.
// the next token needs to be a parameter name
token = tokenizer.next();
// allow a trailing semicolon on the parameters.
if (token.getType() == Token.EOF) {
return params;
}
if (token.getType() != Token.ATOM) {
throw new ParseException("Invalid parameter name: " + token.getValue());
}
// get the parameter name as a lower case version for better
// mapping.
String name = token.getValue().toLowerCase(Locale.ENGLISH);
token = tokenizer.next();
// parameters are name=value, so we must have the "=" here.
if (token.getType() != '=') {
throw new ParseException("Missing '='");
}
// now the value, which may be an atom or a literal
token = tokenizer.next();
if (token.getType() != Token.ATOM && token.getType() != Token.QUOTEDSTRING) {
throw new ParseException("Invalid parameter value: " + token.getValue());
}
String value = token.getValue();
// we might have to do some additional decoding. A name that
// ends with "*" is marked as being encoded, so if requested, we
// decode the value.
if (name.endsWith("*")) {
name = name.substring(0, name.length() - 1);
value = decodeRFC2231value(value);
}
params.put(name, value);
break;
default:
throw new ParseException("Missing ';'");
}
}
}
protected static String decodeRFC2231value(String value) {
int q1 = value.indexOf('\'');
if (q1 == -1) {
// missing charset
return value;
}
String mimeCharset = value.substring(0, q1);
int q2 = value.indexOf('\'', q1 + 1);
if (q2 == -1) {
// missing language
return value;
}
byte[] bytes = fromHex(value.substring(q2 + 1));
try {
return new String(bytes, getJavaCharset(mimeCharset));
} catch (UnsupportedEncodingException e) {
// incorrect encoding
return value;
}
}
protected static byte[] fromHex(String data) {
ByteArrayOutputStream out = new ByteArrayOutputStream(data.length());
for (int i = 0; i < data.length();) {
char c = data.charAt(i++);
if (c == '%') {
if (i > data.length() - 2) {
break; // unterminated sequence
}
byte b1 = HEX_DECODE[data.charAt(i++) & 0x7f];
byte b2 = HEX_DECODE[data.charAt(i++) & 0x7f];
out.write((b1 << 4) | b2);
} else {
out.write((byte) c);
}
}
return out.toByteArray();
}
protected static String getJavaCharset(String mimeCharset) {
// good enough for standard values
return mimeCharset;
}
}