OSDir


[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[users@httpd] Reverse Proxy for Web Application (or adding it as extension to Apache web server)


I have a web application I created that has a backend written in C++ with its own built-in web server.  I want to deploy it behind Apache's reverse proxy (or as an extension to the Apache web server (would that be different?), so I want to know how to do it correctly considering my situation.  


I read the Reverse Proxy Guide article on the Apache website, but there are still confusions that I have.  For instance, do I really need to add a balancer set when I really only have one web server I need to do this for?  And I need an email address for the server that I know I'll be able to get emails on, but I don't have a host name aside from the one I'm setting up a virtual host for (and that host name doesn't exist outside of being a virtual host).  I'm confident only in using the same email address as the one for this Outlook account.  I also need to know where to add the ProxyPass directive if I do have to set up a reverse proxy.  And do I also need the ProxyPassReverse directive along with that?  


In my C++ source file, I have two environment variables for the API keys that it needs to work correctly; since it's a Google Maps application with a currency conversion form on it, it needs a Google Maps API Key and an Access Key for the currency API.  On lines 130 and 133, I use std::getenv() to get the values in the environment variables.  So also need to know how to make that work in Apache.  I read about the environment variable directive in the httpd.conf file, but I also read that it's not the same thing as the OS-specific environment variables that I'm using.  The files are attached to this message, along with httpd.conf, httpd-vhosts.conf and proxy-html.conf.  I'm linking to the Gist with the _javascript_ code here, since attaching it doesn't seem to work well (there's a red circle with a slash appearing on the file): https://gist.github.com/DragonOsman/c6e8fb15343544e662f474c5a526d1c2 .


Just so you guys know, I'm using this on my own laptop PC.  I don't like how I'll have to keep it on at all times when the application is online, but have no other way to do this as of right now.  

#map {
    height: 100%;
}

html, body {
    height: 100%;
    margin: 0;
    padding: 0;
}

form {
    text-align: center;
}

#search-input {
    white-space: nowrap;
}

#search-input {
    background-color: #fff;
    font-family: Roboto;
    font-size: 15px;
    font-weight: 300;
    margin-left: 12px;
    padding: 0 11px 0 13px;
    text-overflow: ellipsis;
    width: 400px;
}

#search-input:focus {
    border-color: #4d90fe;
}
Title: DragonOsman Currency Converter

// Osman Zakir 
// 3 / 16 / 2018 
// Google Maps GUI + Currency Converter Web Application 
// This application uses a Google Map as its UI.  The user's geolocation is taken if the browser has permission, and an info window 
// is opened at that location which displays a HTML form.  The form has an input element to type in the amount of money in the base 
// currency to convert, two dropdown menus populated with a list of currencies requested from the currency API, and a button to submit 
// the form. By default, the base currency is USD and the resulting currency is the currency used at the place that the info window is 
// opened on.

// Google's Geocoder and Reverse Geocoding Service returns status "ZERO_RESULTS" for Western Sahara, Wake Island, and Kosovo. Both dropdown
// menus switch to AED in that situation. The status means that there are no results to show even though reverse geocodng did work.

// This C++ application is the web server for the application. It acts as both a servera and a client, as it also has to query the currency API,
// currencylayer.com, on its currency conversion endpoint and get the conversion result to return to the front-end code.  It also holds two
// environment variables, one to hold the Google Maps API Key and the other to hold the currencylayer.com API access key.  
#include <boost/beast/core.hpp>
#include <boost/beast/http.hpp>
#include <boost/beast/version.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/asio/connect.hpp>
#include <cstdlib>
#include <map>
#include <cctype>
#include <iostream>
#include <memory>
#include <string>
#include <thread>
#include <nlohmann/json.hpp>
#include <jinja2cpp/template.h>
#include <jinja2cpp/value.h>
#include <jinja2cpp/template_env.h.h>

using tcp = boost::asio::ip::tcp;       // from <boost/asio/ip/tcp.hpp>
using nlohmann::json;					// from <nlohmann/json.hpp>
namespace http = boost::beast::http;    // from <boost/beast/http.hpp>

										//------------------------------------------------------------------------------

										// Function to return a reasonable mime type based on the extension of a file.
boost::beast::string_view mime_type(boost::beast::string_view path);

// This class represents a cache for storing results from the
// currency exchange API used by currencylayer.com
class cache_storage
{
public:
	cache_storage(const std::chrono::seconds &duration)
		: m_cache{}, m_duration{ duration }
	{
	}

	// This function queries the currency API after making sure
	// that the stored result(s) is/are old enough
	// It also makes a new query to the API if needed
	const json &query(const std::map<std::string, std::string> &query_data, const char *accesskey);

private:
	std::map<const std::map<std::string, std::string>, std::pair<std::chrono::time_point<std::chrono::steady_clock>, json>> m_cache;
	std::chrono::seconds m_duration;
};

// Parse POST body
std::map<std::string, std::string> parse(const std::string &data);

// Perform currency conversion
double convert(const std::string &from_currency, std::string &to_currency, const double money_amount, const char *accesskey);

// Append an HTTP rel-path to a local filesystem path.
// The returned path is normalized for the platform.
std::string path_cat(boost::beast::string_view base, boost::beast::string_view path);

// This function produces an HTTP response for the given
// request. The type of the response object depends on the
// contents of the request, so the interface requires the
// caller to pass a generic lambda for receiving the response.
template<class Body, class Allocator, class Send>
void handle_request(boost::beast::string_view doc_root, http::request<Body, http::basic_fields<Allocator>> &&req,
	Send &&send, const char *accesskey, const char *apikey);

//------------------------------------------------------------------------------

// Report a failure
void fail(boost::system::error_code ec, const char *what);

// This is the C++11 equivalent of a generic lambda.
// The function object is used to send an HTTP message.
template<class Stream>
struct send_lambda
{
	Stream &stream_;
	bool &close_;
	boost::system::error_code &ec_;

	explicit
		send_lambda(Stream &stream, bool &close, boost::system::error_code &ec)
		: stream_{ stream }, close_{ close }, ec_{ ec }
	{
	}

	template<bool isRequest, class Body, class Fields>
	void operator()(http::message<isRequest, Body, Fields> &&msg) const;
};

// Handles an HTTP server connection
void do_session(tcp::socket socket, const std::string &doc_root, const char *accesskey, const char *apikey);

//------------------------------------------------------------------------------

int main(int argc, char* argv[])
{
	try
	{
		// Check command line arguments.
		if (argc != 4)
		{
			std::cerr <<
				"Usage: currency_converter <address> <port> <doc_root>\n" <<
				"Example:\n" <<
				"    currency_converter 0.0.0.0 8080 .\n";
			return EXIT_FAILURE;
		}
		const auto address = boost::asio::ip::make_address(argv[1]);
		const auto port = static_cast<unsigned short>(std::atoi(argv[2]));
		const std::string doc_root = argv[3];

		// The io_context is required for all I/O
		boost::asio::io_context ioc{ 1 };

		// Google API Key
		char *apikey = std::getenv("apikey");

		// Access key for currencylayer.com's currency exchange API
		char *accesskey = std::getenv("accesskey");

		// The acceptor receives incoming connections
		tcp::acceptor acceptor{ ioc, { address, port } };
		std::cout << "Starting server at " << address << ':' << port << "...\n";
		for (;;)
		{
			// This will receive the new connection
			tcp::socket socket{ ioc };

			// Block until we get a connection
			acceptor.accept(socket);

			// Launch the session, transferring ownership of the socket
			std::thread([=, socket = std::move(socket)]() mutable {
				do_session(std::move(socket), doc_root, accesskey, apikey);
			}).detach();
		}
	}
	catch (const std::runtime_error &e)
	{
		std::cerr << "Line 154: Error: " << e.what() << '\n';
		return EXIT_FAILURE;
	}
	catch (const std::exception &e)
	{
		std::cerr << "Line 159: Error: " << e.what() << '\n';
		return EXIT_FAILURE + 1;
	}
}

// Function to return a reasonable mime type based on the extension of a file.
boost::beast::string_view mime_type(boost::beast::string_view path)
{
	using boost::beast::iequals;
	const auto ext = [&path]
	{
		const auto pos = path.rfind(".");
		if (pos == boost::beast::string_view::npos)
		{
			return boost::beast::string_view{};
		}
		return path.substr(pos);
	}();
	if (iequals(ext, ".htm"))
	{
		return "text/html";
	}
	if (iequals(ext, ".html"))
	{
		return "text/html";
	}
	if (iequals(ext, ".php"))
	{
		return "text/html";
	}
	if (iequals(ext, ".css"))
	{
		return "text/css";
	}
	if (iequals(ext, ".txt"))
	{
		return "text/plain";
	}
	if (iequals(ext, ".js"))
	{
		return "application/javascript";
	}
	if (iequals(ext, ".json"))
	{
		return "application/json";
	}
	if (iequals(ext, ".xml"))
	{
		return "application/xml";
	}
	if (iequals(ext, ".swf"))
	{
		return "application/x-shockwave-flash";
	}
	if (iequals(ext, ".flv"))
	{
		return "video/x-flv";
	}
	if (iequals(ext, ".png"))
	{
		return "image/png";
	}
	if (iequals(ext, ".jpe"))
	{
		return "image/jpeg";
	}
	if (iequals(ext, ".jpeg"))
	{
		return "image/jpeg";
	}
	if (iequals(ext, ".jpg"))
	{
		return "image/jpeg";
	}
	if (iequals(ext, ".gif"))
	{
		return "image/gif";
	}
	if (iequals(ext, ".bmp"))
	{
		return "image/bmp";
	}
	if (iequals(ext, ".ico"))
	{
		return "image/vnd.microsoft.icon";
	}
	if (iequals(ext, ".tiff"))
	{
		return "image/tiff";
	}
	if (iequals(ext, ".tif"))
	{
		return "image/tiff";
	}
	if (iequals(ext, ".svg"))
	{
		return "image/svg+xml";
	}
	if (iequals(ext, ".svgz"))
	{
		return "image/svg+xml";
	}
	return "application/text";
}

// Append an HTTP rel-path to a local filesystem path.
// The returned path is normalized for the platform.
std::string path_cat(boost::beast::string_view base, boost::beast::string_view path)
{
	if (base.empty())
	{
		return path.to_string();
	}
	std::string result = base.to_string();
#if BOOST_MSVC
	constexpr char path_separator = '\\';
	if (result.back() == path_separator)
	{
		result.resize(result.size() - 1);
	}
	result.append(path.data(), path.size());
	for (auto &c : result)
	{
		if (c == '/')
		{
			c = path_separator;
		}
	}
#else
	constexpr char path_separator = '/';
	if (result.back() == path_separator)
	{
		result.resize(result.size() - 1);
	}
	result.append(path.data(), path.size());
#endif
	return result;
}

// Parse POST body
// Function uses state machine to parse POST body. Newlines
// and spaces are ignored. If it sees a quote, it'll read the next 
// stuff until it encounters a space into the value string.  The values
// are added to the parsed_values vector and that vector is returned back
std::map<std::string, std::string> parse(const std::string &data)
{
	enum class States
	{
		Start,
		Name,
		Ignore,
		Value
	};

	std::map<std::string, std::string> parsed_values;
	std::string name;
	std::string value;

	States state = States::Start;
	for (char c : data)
	{
		switch (state)
		{
		case States::Start:
			if (c == '"')
			{
				state = States::Name;
			}
			break;
		case States::Name:
			if (c != '"')
			{
				name += c;
			}
			else
			{
				state = States::Ignore;
			}
			break;
		case States::Ignore:
			if (!isspace(c))
			{
				state = States::Value;
				value += c;
			}
			break;
		case States::Value:
			if (c != '\n')
			{
				value += c;
			}
			else
			{
				parsed_values.insert(std::make_pair(name, value));
				name = "";
				value = "";
				state = States::Start;
			}
			break;
		}
	}
	return parsed_values;
}

// This function produces an HTTP response for the given
// request. The type of the response object depends on the
// contents of the request, so the interface requires the
// caller to pass a generic lambda for receiving the response.
template<class Body, class Allocator, class Send>
void handle_request(boost::beast::string_view doc_root, http::request<Body, http::basic_fields<Allocator>> &&req,
	Send &&send, const char *accesskey, const char *apikey)
{
	// Returns a bad request response
	const auto bad_request = [&req](boost::beast::string_view why)
	{
		http::response<http::string_body> res{ http::status::bad_request, req.version() };
		res.set(http::field::server, BOOST_BEAST_VERSION_STRING);
		res.set(http::field::content_type, "text/html");
		res.keep_alive(req.keep_alive());
		res.body() = why.to_string();
		res.prepare_payload();
		return res;
	};

	// Returns a not found response
	const auto not_found = [&req](boost::beast::string_view target)
	{
		http::response<http::string_body> res{ http::status::not_found, req.version() };
		res.set(http::field::server, BOOST_BEAST_VERSION_STRING);
		res.set(http::field::content_type, "text/html");
		res.keep_alive(req.keep_alive());
		res.body() = "The resource '" + target.to_string() + "' was not found.";
		res.prepare_payload();
		return res;
	};

	// Returns a server error response
	const auto server_error = [&req](boost::beast::string_view what)
	{
		http::response<http::string_body> res{ http::status::internal_server_error, req.version() };
		res.set(http::field::server, BOOST_BEAST_VERSION_STRING);
		res.set(http::field::content_type, "text/html");
		res.keep_alive(req.keep_alive());
		res.body() = "An error occurred: '" + what.to_string() + "'";
		res.prepare_payload();
		return res;
	};

	// Make sure we can handle the method
	if (req.method() != http::verb::get &&
		req.method() != http::verb::head &&
		req.method() != http::verb::post)
	{
		return send(bad_request("Unknown HTTP-method"));
	}

	// Request path must be absolute and not contain "..".
	if (req.target().empty() ||
		req.target()[0] != '/' ||
		req.target().find("..") != boost::beast::string_view::npos)
	{
		return send(bad_request("Illegal request-target"));
	}

	// Build the path to the requested file
	std::string path;
	if (req.target() != "/?q=accesskey")
	{
		path = path_cat(doc_root, req.target());
		if (req.target().back() == '/')
		{
			path.append("index.html");
		}
	}

	// Attempt to open the file
	boost::beast::error_code ec;
	http::file_body::value_type body;
	if (req.target() != "/?q=accesskey" && req.target() != "/")
	{
		body.open(path.c_str(), boost::beast::file_mode::scan, ec);
	}

	// Handle the case where the file doesn't exist
	if (ec == boost::system::errc::no_such_file_or_directory)
	{
		return send(not_found(req.target()));
	}

	// Handle an unknown error
	if (ec)
	{
		return send(server_error(ec.message()));
	}

	// Respond to HEAD request
	if (req.method() == http::verb::head)
	{
		http::response<http::empty_body> res{ http::status::ok, req.version() };
		res.set(http::field::server, BOOST_BEAST_VERSION_STRING);
		res.set(http::field::content_type, mime_type(path));
		res.content_length(body.size());
		res.keep_alive(req.keep_alive());
		return send(std::move(res));
	}

	// Respond to GET request
	else if (req.method() == http::verb::get)
	{
		if (req.target() == "/?q=accesskey")
		{
			http::response<http::string_body> res{
				std::piecewise_construct,
				std::make_tuple(std::move(std::string{ accesskey })),
				std::make_tuple(http::status::ok, req.version()) };
			res.set(http::field::server, BOOST_BEAST_VERSION_STRING);
			res.set(http::field::content_type, "plain/text");
			res.content_length(res.body().size());
			res.keep_alive(req.keep_alive());
			return send(std::move(res));
		}
		else if (req.target() == "/")
		{
			jinja2::Template tpl;
			tpl.LoadFromFile(path.c_str());
			jinja2::ValuesMap params = { { "apikey", std::string(apikey) } };

			http::response<http::string_body> res{
				std::piecewise_construct,
				std::make_tuple(std::move(tpl.RenderAsString(params))),
				std::make_tuple(http::status::ok, req.version()) };
			res.set(http::field::server, BOOST_BEAST_VERSION_STRING);
			res.set(http::field::content_type, "text/html");
			res.set(http::field::content_length, tpl.RenderAsString(params).size());
			res.keep_alive(req.keep_alive());
			return send(std::move(res));
		}
		else
		{
			http::response<http::file_body> res{
			   std::piecewise_construct,
			   std::make_tuple(std::move(body)),
			   std::make_tuple(http::status::ok, req.version()) };
			res.set(http::field::server, BOOST_BEAST_VERSION_STRING);
			res.set(http::field::content_type, mime_type(path));
			res.content_length(body.size());
			res.keep_alive(req.keep_alive());
			return send(std::move(res));
		}
	}

	// Respond to POST request
	else if (req.method() == http::verb::post)
	{
		boost::beast::string_view content_type = req[http::field::content_type];
		if (content_type.find("multipart/form-data") == std::string::npos &&
			content_type.find("application/x-www-form-urlencoded") == std::string::npos)
		{
			return send(bad_request("Bad request"));
		}

		std::map<std::string, std::string> parsed_value = parse(req.body());
		double money_amount = std::stod(parsed_value["currency_amount"]);
		std::string to_currency = parsed_value["to_currency"];
		std::string from_currency = parsed_value["from_currency"];
		std::string to_abbr = to_currency.substr(0, 3);
		std::string from_abbr = from_currency.substr(0, 3);
		double conversion_result = convert(from_abbr, to_abbr, money_amount, accesskey);

		http::response<http::string_body> res{
			std::piecewise_construct,
			std::make_tuple(std::move(std::to_string(conversion_result))),
			std::make_tuple(http::status::ok, req.version()) };
		res.set(http::field::server, BOOST_BEAST_VERSION_STRING);
		res.set(http::field::content_type, "text/plain");
		res.content_length(res.body().size());
		res.keep_alive(req.keep_alive());
		return send(std::move(res));
	}
}

// Report a failure
void fail(boost::system::error_code ec, const char *what)
{
	std::cerr << what << ": " << ec.message() << "\n";
}

template<class Stream>
template<bool isRequest, class Body, class Fields>
void send_lambda<Stream>::operator()(http::message<isRequest, Body, Fields> &&msg) const
{
	// Determine if we should close the connection after
	close_ = msg.need_eof();

	// We need the serializer here because the serializer requires
	// a non-const file_body, and the message oriented version of
	// http::write only works with const messages.
	http::serializer<isRequest, Body, Fields> sr{ msg };
	http::write(stream_, sr, ec_);
}

// Handles an HTTP server connection
void do_session(tcp::socket socket, const std::string &doc_root, const char *accesskey, const char *apikey)
{
	bool close = false;
	boost::system::error_code ec;

	// This buffer is required to persist across reads 
	boost::beast::flat_buffer buffer;

	// This lambda is used to send messages 
	send_lambda<tcp::socket> lambda{ socket, close, ec };

	for (;;)
	{
		// Read a request 
		http::request<http::string_body> req;
		http::read(socket, buffer, req, ec);
		if (ec == http::error::end_of_stream)
		{
			break;
		}
		if (ec)
		{
			std::cerr << "Lines 583 and 584:\n";
			return fail(ec, "read");
		}

		// Send the response 
		handle_request(doc_root, std::move(req), lambda, accesskey, apikey);
		if (ec)
		{
			std::cerr << "Lines 591 and 592:\n";
			return fail(ec, "write");
		}
		if (close)
		{
			// This means we should close the connection, usually because 
			// the response indicated the "Connection: close" semantic. 
			break;
		}
	}

	// Send a TCP shutdown 
	socket.shutdown(tcp::socket::shutdown_send, ec);

	// At this point the connection is closed gracefully
}

// Perform currency conversion
double convert(const std::string &from_currency, std::string &to_currency, const double money_amount, const char *accesskey)
{
	using namespace std::chrono_literals;
	std::vector<std::string> currencies{
		"AED","AFN","ALL","AMD","ANG","AOA","ARS","AUD","AWG","AZN","BAM","BBD","BDT","BGN","BHD","BIF","BMD","BND","BOB","BRL","BSD","BTC","BTN","BWP",
		"BYN","BYR","BZD","CAD","CDF","CHF","CLF","CLP","CNY","COP","CRC","CUC","CUP","CVE","CZK","DJF","DKK","DOP","DZD","EGP","ERN","ETB","EUR","FJD",
		"FKP","GBP","GEL","GGP","GHS","GIP","GMD","GNF","GTQ","GYD","HKD","HNL","HRK","HTG","HUF","IDR","ILS","IMP","INR","IQD","IRR","ISK","JEP","JMD",
		"JOD","JPY","KES","KGS","KHR","KMF","KPW","KRW","KWD","KYD","KZT","LAK","LBP","LKR","LRD","LSL","LTL","LVL","LYD","MAD","MDL","MGA","MKD","MMK",
		"MNT","MOP","MRO","MUR","MVR","MWK","MXN","MYR","MZN","NAD","NGN","NIO","NOK","NPR","NZD","OMR","PAB","PEN","PGK","PHP","PKR","PLN","PYG","QAR",
		"RON","RSD","RUB","RWF","SAR","SBD","SCR","SDG","SEK","SGD","SHP","SLL","SOS","SRD","STD","SVC","SYP","SZL","THB","TJS","TMT","TND","TOP","TRY",
		"TTD","TWD","TZS","UAH","UGX","USD","UYU","UZS","VEF","VND","VUV","WST","XAF","XAG","XAU","XCD","XDR","XOF","XPF","YER","ZAR","ZMK","ZMW","ZWL"
	};

	std::map<std::string, std::string> query_data{ std::make_pair("from_currency", from_currency), std::make_pair("to_currency", to_currency) };

	cache_storage cache{ 1h };
	json j_res = cache.query(query_data, accesskey);

	double result = 0, rate = 0;

	try
	{
		rate = j_res["quotes"][from_currency + to_currency].get<double>();
	}
	catch (const json::exception &e)
	{
		std::cerr << "Line 635: Error: " << e.what() << '\n';
	}

	if (std::find(currencies.begin(), currencies.end(), to_currency) != currencies.end() &&
		std::find(currencies.begin(), currencies.end(), from_currency) != currencies.end())
	{
		result = money_amount * rate;
	}

	return result;
}

// This function queries the currency API after making sure
// that the stored result(s) is/are old enough
// It also makes a new query to the API if needed
const json &cache_storage::query(const std::map<std::string, std::string> &query_data, const char *accesskey)
{
	auto found = m_cache.find(query_data);
	boost::beast::error_code ec;
	try
	{
		if (found == m_cache.end() || (std::chrono::steady_clock::now() - found->second.first) > m_duration)
		{
			std::string host{ "apilayer.net" }, api_endpoint{ "/api/live" },
				key{ accesskey }, source{ query_data.at(std::string("from_currency")) }, currency_param{ query_data.at(std::string("to_currency")) };
			std::string target;
			if (query_data.at(std::string("from_currency")) != "USD")
			{
				target = api_endpoint + "?access_key=" + accesskey + "&source=" + source + "&currencies=" + currency_param + "&format=1";
			}
			else
			{
				target = api_endpoint + "?access_key=" + accesskey + "&currencies=" + currency_param + "&format=1";
			}
			std::string port{ "80" };
			int version = 11;

			// The io_context is required for all IO
			boost::asio::io_context ioc;

			// These objects perform our IO
			tcp::resolver resolver{ ioc };
			tcp::socket socket{ ioc };

			// Look up the domain name
			const auto results = resolver.resolve(host, port);

			// Make the connection on the IP address we get from a lookup
			boost::asio::connect(socket, results.begin(), results.end());

			// Set up an HTTP GET request message
			http::request<http::string_body> req{ http::verb::get, target, version };
			req.set(http::field::host, host);
			req.set(http::field::content_type, "application/json");
			req.set(http::field::accept, "application/json");
			req.set(http::field::user_agent, BOOST_BEAST_VERSION_STRING);

			// Send the HTTP request to the remote host
			http::write(socket, req);

			// This buffer is used for reading and must be persisted
			boost::beast::flat_buffer buffer;

			// Declare a container to hold the response
			http::response<http::string_body> res;

			// Receive the HTTP response
			http::read(socket, buffer, res, ec);
			found = m_cache.insert_or_assign(found, query_data, std::make_pair(std::chrono::steady_clock::now(), json::parse(res.body())));
		}
		return found->second.second;
	}
	catch (const std::exception &e)
	{
		std::cerr << "Line 709: Error: " << e.what() << '\n';
	}
	catch (const boost::beast::error_code &ec)
	{
		std::cerr << "Line 713: Error: " << ec.message() << '\n';
	}
	return json{ nullptr };
}

Attachment: httpd.conf
Description: httpd.conf

Attachment: httpd-vhosts.conf
Description: httpd-vhosts.conf

Attachment: proxy-html.conf
Description: proxy-html.conf

---------------------------------------------------------------------
To unsubscribe, e-mail: users-unsubscribe@xxxxxxxxxxxxxxxx
For additional commands, e-mail: users-help@xxxxxxxxxxxxxxxx