/* This code is part of Freenet. It is distributed under the GNU General
* Public License, version 2 (or at your option any later version). See
* http://www.gnu.org/ for further details of the GPL. */
package freenet.clients.http;
import java.io.DataInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.StringTokenizer;
import java.util.Map.Entry;
import javax.naming.SizeLimitExceededException;
import freenet.support.Fields;
import freenet.support.LogThresholdCallback;
import freenet.support.Logger;
import freenet.support.MultiValueTable;
import freenet.support.SimpleReadOnlyArrayBucket;
import freenet.support.URLEncoder;
import freenet.support.Logger.LogLevel;
import freenet.support.api.Bucket;
import freenet.support.api.BucketFactory;
import freenet.support.api.HTTPRequest;
import freenet.support.api.HTTPUploadedFile;
import freenet.support.api.RandomAccessBucket;
import freenet.support.io.BucketTools;
import freenet.support.io.Closer;
import freenet.support.io.LineReadingInputStream;
/**
* Used for passing all HTTP request information to the FredPlugin that handles
* the request. It parses the query string and has several methods for accessing
* the request parameter values.
*
* @author nacktschneck
*/
public class HTTPRequestImpl implements HTTPRequest {
/**
* This map is used to store all parameter values.
*
* Don't access this map directly, use {@link #getParameterValueList(String)} and
* {@link #isParameterSet(String)} instead
*/
private final Map<String, List<String>> parameterNameValuesMap = new HashMap<String, List<String>>();
/**
* the original URI as given to the constructor
*/
private URI uri;
/**
* The headers sent by the client
*/
private MultiValueTable<String, String> headers;
/**
* The data sent in the connection
*/
private Bucket data;
/**
* A hashmap of buckets that we use to store all the parts for a multipart/form-data request
*/
private HashMap<String, RandomAccessBucket> parts;
private boolean freedParts;
/** A map for uploaded files. */
private Map<String, HTTPUploadedFileImpl> uploadedFiles = new HashMap<String, HTTPUploadedFileImpl>();
private final BucketFactory bucketfactory;
private final String method;
private static volatile boolean logMINOR;
static {
Logger.registerLogThresholdCallback(new LogThresholdCallback(){
@Override
public void shouldUpdate(){
logMINOR = Logger.shouldLog(LogLevel.MINOR, this);
}
});
}
/**
* Create a new HTTPRequest for the given URI and parse its request
* parameters.
*
* @param uri
* the URI being requested
*/
public HTTPRequestImpl(URI uri, String method) {
this.uri = uri;
this.parseRequestParameters(uri.getRawQuery(), true, false);
this.data = null;
this.parts = null;
this.bucketfactory = null;
this.method = method;
}
/**
* Creates a new HTTPRequest for the given path and url-encoded query string
*
* @param path i.e. /test/test.html
* @param encodedQueryString a=some+text&b=abc%40def.de
* @throws URISyntaxException if the URI is invalid
*/
public HTTPRequestImpl(String path, String encodedQueryString, String method) throws URISyntaxException {
this.data = null;
this.parts = null;
this.bucketfactory = null;
if ((encodedQueryString!=null) && (encodedQueryString.length()>0)) {
this.uri = new URI(path+ '?' +encodedQueryString);
} else {
this.uri = new URI(path);
}
this.method = method;
this.parseRequestParameters(uri.getRawQuery(), true, false);
}
/**
* Creates a new HTTPRequest for the given URI and data.
*
* @param uri The URI being requested
* @param h Client headers
* @param d The data
* @param ctx The toadlet context (for headers and bucket factory)
* @throws URISyntaxException if the URI is invalid
*/
public HTTPRequestImpl(URI uri, Bucket d, ToadletContext ctx, String method) {
this.uri = uri;
this.headers = ctx.getHeaders();
this.parseRequestParameters(uri.getRawQuery(), true, false);
this.data = d;
this.parts = new HashMap<String, RandomAccessBucket>();
this.bucketfactory = ctx.getBucketFactory();
this.method = method;
if(data != null) {
try {
this.parseMultiPartData();
} catch (IOException ioe) {
Logger.error(this, "Temporary files error ? Could not parse: "+ioe, ioe);
}
}
}
/* (non-Javadoc)
* @see freenet.clients.http.HTTPRequest#getPath()
*/
@Override
public String getPath() {
return this.uri.getPath();
}
/* (non-Javadoc)
* @see freenet.clients.http.HTTPRequest#hasParameters()
*/
@Override
public boolean hasParameters() {
return ! this.parameterNameValuesMap.isEmpty();
}
/**
* Returns the names of all parameters.
*
* @return The names of all parameters
*/
@Override
public Collection<String> getParameterNames() {
return parameterNameValuesMap.keySet();
}
/**
* Parse the query string and populate {@link #parameterNameValuesMap} with
* the lists of values for each parameter. If this method is not called at
* all, all other methods would be useless. Because they rely on the
* parameter map to be filled.
*
* @param queryString
* the query string in its raw form (not yet url-decoded)
* @param doUrlDecoding TODO
*/
private void parseRequestParameters(String queryString, boolean doUrlDecoding, boolean asParts) {
if(logMINOR) Logger.minor(this, "queryString is "+queryString+", doUrlDecoding="+doUrlDecoding);
Map<String, List<String>> parameters = parseUriParameters(queryString, doUrlDecoding);
if (asParts) {
for (Entry<String, List<String>> parameterValues : parameters.entrySet()) {
List<String> values = parameterValues.getValue();
String value = values.get(values.size() - 1);
byte[] buf;
try {
buf = value.getBytes("UTF-8");
} catch (UnsupportedEncodingException e) {
throw new Error("Impossible: JVM doesn't support UTF-8: " + e, e);
} // FIXME some other encoding?
RandomAccessBucket b = new SimpleReadOnlyArrayBucket(buf);
parts.put(parameterValues.getKey(), b);
if(logMINOR)
Logger.minor(this, "Added as part: name="+parameterValues.getKey()+" value="+value);
}
} else {
parameterNameValuesMap.clear();
parameterNameValuesMap.putAll(parameters);
}
}
/**
* Get the first value of the parameter with the given name.
*
* @param name
* the name of the parameter to get
* @return the first value or <code>null</code> if the parameter was not
* set
*/
private String getParameterValue(String name) {
if (!this.isParameterSet(name)) {
return null;
}
List<String> allValues = this.getParameterValueList(name);
return allValues.get(0);
}
/**
* Get the list of all values for the parameter with the given name. When
* this method is called for a given parameter for the first time, a new
* empty list of values is created and stored in
* {@link #parameterNameValuesMap}. This list is returned and should be
* used to add parameter values. If you only want to check if a parameter is
* set at all, you must use {@link #isParameterSet(String)}.
*
* @param name
* the name of the parameter to get
* @return the list of all values for this parameter that were parsed so
* far.
*/
private List<String> getParameterValueList(String name) {
List<String> values = this.parameterNameValuesMap.get(name);
if (values == null) {
values = new LinkedList<String>();
this.parameterNameValuesMap.put(name, values);
}
return values;
}
/**
* Parses the parameters from the given query string, optionally decoding
* the parameters using UTF-8.
*
* @param queryString
* The query string to decode
* @param doUrlDecoding
* {@code true} to decode the parameter names and values
* @return The decoded parameters
*/
public static Map<String, List<String>> parseUriParameters(String queryString, boolean doUrlDecoding) {
if(logMINOR) Logger.minor(HTTPRequestImpl.class, "queryString is "+queryString+", doUrlDecoding="+doUrlDecoding);
/* create result map. */
Map<String, List<String>> parameters = new HashMap<String, List<String>>();
// nothing to do if there was no query string in the URI
if ((queryString == null) || (queryString.length() == 0)) {
return parameters;
}
// iterate over all tokens in the query string (separated by &)
StringTokenizer tokenizer = new StringTokenizer(queryString, "&");
while (tokenizer.hasMoreTokens()) {
String nameValueToken = tokenizer.nextToken();
if(logMINOR) Logger.minor(HTTPRequestImpl.class, "Token: "+nameValueToken);
// a token can be either a name, or a name value pair...
String name = null;
String value = "";
int indexOfEqualsChar = nameValueToken.indexOf('=');
if (indexOfEqualsChar < 0) {
// ...it's only a name, so the value stays emptys
name = nameValueToken;
if(logMINOR) Logger.minor(HTTPRequestImpl.class, "Name: "+name);
} else if (indexOfEqualsChar == nameValueToken.length() - 1) {
// ...it's a name with an empty value, so remove the '='
// character
name = nameValueToken.substring(0, indexOfEqualsChar);
if(logMINOR) Logger.minor(HTTPRequestImpl.class, "Name: "+name);
} else {
// ...it's a name value pair, split into name and value
name = nameValueToken.substring(0, indexOfEqualsChar);
value = nameValueToken.substring(indexOfEqualsChar + 1);
if(logMINOR) Logger.minor(HTTPRequestImpl.class, "Name: "+name+" Value: "+value);
}
// url-decode the name and value
if (doUrlDecoding) {
try {
name = URLDecoder.decode(name, "UTF-8");
value = URLDecoder.decode(value, "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new Error("Impossible: JVM doesn't support UTF-8: " + e, e);
}
if(logMINOR) {
Logger.minor(HTTPRequestImpl.class, "Decoded name: "+name);
Logger.minor(HTTPRequestImpl.class, "Decoded value: "+value);
}
}
List<String> values = parameters.get(name);
if (values == null) {
values = new ArrayList<String>();
parameters.put(name, values);
}
values.add(value);
}
return parameters;
}
/**
* Creates a query string from the given parameters.
*
* @param parameterValues
* The parameters to create a query string from
* @param doUrlEncoding
* {@code true} if encoding for HTTP headers, {@code false} to
* only encode unsafe characters
* @return The query string
*/
public static String createQueryString(Map<String, List<String>> parameterValues, boolean doUrlEncoding) {
StringBuilder queryString = new StringBuilder();
for (Entry<String, List<String>> parameter : parameterValues.entrySet()) {
for (String value : parameter.getValue()) {
if (queryString.length() > 0) {
queryString.append('&');
}
queryString.append(URLEncoder.encode(parameter.getKey(), doUrlEncoding));
queryString.append('=');
queryString.append(URLEncoder.encode(value, doUrlEncoding));
}
}
return queryString.toString();
}
/* (non-Javadoc)
* @see freenet.clients.http.HTTPRequest#isParameterSet(java.lang.String)
*/
@Override
public boolean isParameterSet(String name) {
return this.parameterNameValuesMap.containsKey(name);
}
/* (non-Javadoc)
* @see freenet.clients.http.HTTPRequest#getParam(java.lang.String)
*/
@Override
public String getParam(String name) {
return this.getParam(name, "");
}
/* (non-Javadoc)
* @see freenet.clients.http.HTTPRequest#getParam(java.lang.String, java.lang.String)
*/
@Override
public String getParam(String name, String defaultValue) {
String value = this.getParameterValue(name);
if (value == null || value.isEmpty()) {
return defaultValue;
}
return value;
}
/* (non-Javadoc)
* @see freenet.clients.http.HTTPRequest#getIntParam(java.lang.String)
*/
@Override
public int getIntParam(String name) {
return this.getIntParam(name, 0);
}
/* (non-Javadoc)
* @see freenet.clients.http.HTTPRequest#getIntParam(java.lang.String, int)
*/
@Override
public int getIntParam(String name, int defaultValue) {
if (!this.isParameterSet(name)) {
return defaultValue;
}
String value = this.getParameterValue(name);
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
return defaultValue;
}
}
/* (non-Javadoc)
* @see freenet.clients.http.HTTPRequest#getIntPart(java.lang.String, int)
*/
@Override
public int getIntPart(String name, int defaultValue) {
if (!this.isPartSet(name)) {
return defaultValue;
}
String value = this.getPartAsString(name, 32);
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
return defaultValue;
}
}
// TODO: add similar methods for long, boolean etc.
/* (non-Javadoc)
* @see freenet.clients.http.HTTPRequest#getMultipleParam(java.lang.String)
*/
@Override
public String[] getMultipleParam(String name) {
List<String> valueList = this.getParameterValueList(name);
String[] values = new String[valueList.size()];
valueList.toArray(values);
return values;
}
/* (non-Javadoc)
* @see freenet.clients.http.HTTPRequest#getMultipleIntParam(java.lang.String)
*/
@Override
public int[] getMultipleIntParam(String name) {
List<String> valueList = this.getParameterValueList(name);
// try parsing all values and put the valid Integers in a new list
List<Integer> intValueList = new ArrayList<Integer>();
for (int i = 0; i < valueList.size(); i++) {
try {
intValueList.add(Integer.valueOf(valueList.get(i)));
} catch (Exception e) {
// ignore invalid parameter values
}
}
// convert the valid Integers to an array of ints
int[] values = new int[intValueList.size()];
for (int i = 0; i < intValueList.size(); i++) {
values[i] = intValueList.get(i);
}
return values;
}
// TODO: add similar methods for multiple long, boolean etc.
/**
* Parse submitted data from a bucket.
* Note that if this is application/x-www-form-urlencoded, it will come out as
* params, whereas if it is multipart/form-data it will be separated into buckets.
*/
private void parseMultiPartData() throws IOException {
InputStream is = null;
LineReadingInputStream lis = null;
OutputStream bucketos = null;
try {
if(data == null)
return;
String ctype = this.headers.get("content-type");
if(ctype == null)
return;
if(logMINOR)
Logger.minor(this, "Uploaded content-type: " + ctype);
String[] ctypeparts = ctype.split(";");
if(ctypeparts[0].equalsIgnoreCase("application/x-www-form-urlencoded")) {
// Completely different encoding, but easy to handle
if(data.size() > 1024 * 1024)
throw new IOException("Too big");
byte[] buf = BucketTools.toByteArray(data);
String s = new String(buf, "us-ascii");
parseRequestParameters(s, true, true);
}
if(!ctypeparts[0].trim().equalsIgnoreCase("multipart/form-data") || (ctypeparts.length < 2))
return;
String boundary = null;
for(String ctypepart: ctypeparts) {
String[] subparts = ctypepart.split("=");
if((subparts.length == 2) && subparts[0].trim().equalsIgnoreCase("boundary"))
boundary = subparts[1];
}
if((boundary == null) || (boundary.length() == 0))
return;
if(boundary.charAt(0) == '"')
boundary = boundary.substring(1);
if(boundary.charAt(boundary.length() - 1) == '"')
boundary = boundary.substring(0, boundary.length() - 1);
boundary = "--" + boundary;
if(logMINOR)
Logger.minor(this, "Boundary is: " + boundary);
is = this.data.getInputStream();
lis = new LineReadingInputStream(is);
String line;
line = lis.readLine(100, 100, false); // really it's US-ASCII, but ISO-8859-1 is close enough.
while((is.available() > 0) && !line.equals(boundary)) {
line = lis.readLine(100, 100, false);
}
boundary = "\r\n" + boundary;
RandomAccessBucket filedata = null;
String name = null;
String filename = null;
String contentType = null;
while(is.available() > 0) {
name = null;
filename = null;
contentType = null;
// chomp headers
while((line = lis.readLine(200, 200, true)) /* should be UTF-8 as we told the browser to send UTF-8 */ != null) {
if(line.length() == 0)
break;
String[] lineparts = line.split(":");
if(lineparts == null || lineparts.length == 0)
continue;
String hdrname = lineparts[0].trim();
if(hdrname.equalsIgnoreCase("Content-Disposition")) {
if(lineparts.length < 2)
continue;
String[] valueparts = lineparts[1].split(";");
for(int i = 0; i < valueparts.length; i++) {
String[] subparts = valueparts[i].split("=");
if(subparts.length != 2)
continue;
String fieldname = subparts[0].trim();
String value = subparts[1].trim();
if(value.startsWith("\"") && value.endsWith("\""))
value = value.substring(1, value.length() - 1);
if(fieldname.equalsIgnoreCase("name"))
name = value;
else if(fieldname.equalsIgnoreCase("filename"))
filename = value;
}
}
else if(hdrname.equalsIgnoreCase("Content-Type")) {
contentType = lineparts[1].trim();
if(logMINOR)
Logger.minor(this, "Parsed type: " + contentType);
}
else {
// Do nothing, irrelevant header
}
}
if(name == null)
continue;
// we should be at the data now. Start reading it in, checking for the
// boundary string
// we can only give an upper bound for the size of the bucket
filedata = this.bucketfactory.makeBucket(is.available());
bucketos = filedata.getOutputStream();
// buffer characters that match the boundary so far
// FIXME use whatever charset was used
byte[] bbound = boundary.getBytes("UTF-8"); // ISO-8859-1? boundary should be in US-ASCII
int offset = 0;
while((is.available() > 0) && (offset < bbound.length)) {
byte b = (byte) is.read();
if(b == bbound[offset])
offset++;
else if((b != bbound[offset]) && (offset > 0)) {
// offset bytes matched, but no more
// write the bytes that matched, then the non-matching byte
bucketos.write(bbound, 0, offset);
offset = 0;
if(b == bbound[0])
offset = 1;
else
bucketos.write(b);
}
else
bucketos.write(b);
}
bucketos.close();
bucketos = null;
parts.put(name, filedata);
if(logMINOR)
Logger.minor(this, "Name = " + name + " length = " + filedata.size() + " filename = " + filename);
if(filename != null)
uploadedFiles.put(name, new HTTPUploadedFileImpl(filename, contentType, filedata));
}
}
finally {
Closer.close(bucketos);
Closer.close(lis);
Closer.close(is);
Closer.close(is);
}
}
/* (non-Javadoc)
* @see freenet.clients.http.HTTPRequest#getUploadedFile(java.lang.String)
*/
@Override
public HTTPUploadedFile getUploadedFile(String name) {
return uploadedFiles.get(name);
}
/* (non-Javadoc)
* @see freenet.clients.http.HTTPRequest#getPart(java.lang.String)
*/
@Override
public RandomAccessBucket getPart(String name) {
if(freedParts) throw new IllegalStateException("Already freed");
return this.parts.get(name);
}
/* (non-Javadoc)
* @see freenet.clients.http.HTTPRequest#isPartSet(java.lang.String)
*/
@Override
public boolean isPartSet(String name) {
if(freedParts) throw new IllegalStateException("Already freed");
if(parts == null)
return false;
return this.parts.containsKey(name);
}
@Override
@Deprecated
public String getPartAsString(String name, int maxlength) {
try {
return new String(getPartAsBytes(name, maxlength), "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new Error("Impossible: JVM doesn't support UTF-8: " + e, e);
}
}
@Override
public String getPartAsStringThrowing(String name, int maxLength) throws NoSuchElementException, SizeLimitExceededException {
if(freedParts) throw new IllegalStateException("Already freed");
Bucket part = this.parts.get(name);
if(part == null)
throw new NoSuchElementException(name);
if(part.size() > maxLength)
throw new SizeLimitExceededException();
return getPartAsLimitedString(part, maxLength);
}
@Override
public String getPartAsStringFailsafe(String name, int maxLength) {
if(freedParts) throw new IllegalStateException("Already freed");
Bucket part = this.parts.get(name);
return part == null ? "" : getPartAsLimitedString(part, maxLength);
}
private String getPartAsLimitedString(Bucket part, int maxLength) {
try {
return new String(getPartAsLimitedBytes(part, maxLength), "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new Error("Impossible: JVM doesn't support UTF-8: " + e, e);
}
}
/* (non-Javadoc)
* @see freenet.clients.http.HTTPRequest#getPartAsString(java.lang.String, int)
*/
@Override
@Deprecated
public byte[] getPartAsBytes(String name, int maxlength) {
if(freedParts) throw new IllegalStateException("Already freed");
Bucket part = this.parts.get(name);
if(part == null) return new byte[0];
if (part.size() > maxlength) return new byte[0];
InputStream is = null;
DataInputStream dis = null;
try {
is = part.getInputStream();
dis = new DataInputStream(is);
byte[] buf = new byte[(int)Math.min(part.size(), maxlength)];
dis.readFully(buf);
return buf;
} catch (IOException ioe) {
Logger.error(this, "Caught IOE:" + ioe.getMessage());
} finally {
Closer.close(dis);
if(dis == null) Closer.close(is); // DataInputStream.close() does this for us normally
}
return new byte[0];
}
@Override
public byte[] getPartAsBytesThrowing(String name, int maxLength) throws NoSuchElementException, SizeLimitExceededException {
if(freedParts) throw new IllegalStateException("Already freed");
Bucket part = this.parts.get(name);
if(part == null)
throw new NoSuchElementException(name);
if(part.size() > maxLength)
throw new SizeLimitExceededException();
return getPartAsLimitedBytes(part, maxLength);
}
@Override
public byte[] getPartAsBytesFailsafe(String name, int maxLength) {
if(freedParts) throw new IllegalStateException("Already freed");
Bucket part = this.parts.get(name);
return part == null ? new byte[0] : getPartAsLimitedBytes(part, maxLength);
}
private byte[] getPartAsLimitedBytes(Bucket part, int maxLength) {
InputStream is = null;
DataInputStream dis = null;
try {
is = part.getInputStream();
dis = new DataInputStream(is);
byte[] buf = new byte[(int)Math.min(part.size(), maxLength)];
dis.readFully(buf, 0, buf.length);
return buf;
} catch (IOException ioe) {
Logger.error(this, "Caught IOE:" + ioe.getMessage());
return new byte[0];
} finally {
Closer.close(dis);
if(dis == null) Closer.close(is); // DataInputStream.close() does this for us normally
}
}
/* (non-Javadoc)
* @see freenet.clients.http.HTTPRequest#freeParts()
*/
@Override
public void freeParts() {
if (this.parts == null) return;
for (Bucket b : this.parts.values()) {
b.free();
}
parts.clear();
freedParts = true;
// Do not free data. Caller is responsible for that.
}
/* (non-Javadoc)
* @see freenet.clients.http.HTTPRequest#getLongParam(java.lang.String, long)
*/
@Override
public long getLongParam(String name, long defaultValue) {
if (!this.isParameterSet(name)) {
return defaultValue;
}
String value = this.getParameterValue(name);
try {
return Fields.parseLong(value);
} catch (NumberFormatException e) {
return defaultValue;
}
}
/**
* Container for uploaded files in HTTP POST requests.
*
* @author David 'Bombe' Roden <bombe@freenetproject.org>
* @version $Id$
*/
public static class HTTPUploadedFileImpl implements HTTPUploadedFile {
/** The filename. */
private final String filename;
/** The content type. */
private final String contentType;
/** The data. */
private final Bucket data;
/**
* Creates a new file with the specified filename, content type, and
* data.
*
* @param filename
* The name of the file
* @param contentType
* The content type of the file
* @param data
* The data of the file
*/
public HTTPUploadedFileImpl(String filename, String contentType, Bucket data) {
this.filename = filename;
this.contentType = contentType;
this.data = data;
}
/* (non-Javadoc)
* @see freenet.clients.http.HTTPUploadedFile#getContentType()
*/
@Override
public String getContentType() {
return contentType;
}
/* (non-Javadoc)
* @see freenet.clients.http.HTTPUploadedFile#getData()
*/
@Override
public Bucket getData() {
return data;
}
/* (non-Javadoc)
* @see freenet.clients.http.HTTPUploadedFile#getFilename()
*/
@Override
public String getFilename() {
return filename;
}
}
@Override
public String getMethod() {
return method;
}
@Override
public Bucket getRawData() {
return data;
}
@Override
public String getHeader(String name) {
assert(name.equals(name.toLowerCase()));
return this.headers.get(name.toLowerCase());
}
@Override
public int getContentLength() {
String slen = headers.get("content-length");
if (slen == null)
return -1;
// it is already parsed, so NumberFormatException can not happens here
return Integer.parseInt(slen);
}
@Override
public String[] getParts() {
if(freedParts) throw new IllegalStateException("Already freed");
return parts.keySet().toArray(new String[parts.size()]);
}
@Override
public boolean isIncognito() {
if(isParameterSet("incognito"))
return Boolean.valueOf(getParam("incognito"));
return false;
}
@Override
public boolean isChrome() {
String ua = getHeader("user-agent");
if(ua != null) {
if(ua.contains("Chrome")) return true;
}
return false;
}
}