From 645df7d9b7c16e28e84ae2caa7a671a0239b904f Mon Sep 17 00:00:00 2001 From: Mike Kaganski Date: Sat, 14 Mar 2026 20:16:41 +0500 Subject: [PATCH] tdf#101630: Make local HTTP server cross-platform, using boost/beast Allows proper Google Drive authentication e.g. on Linux. Change-Id: I16efa58ba90b98e71b13a0081dcdcc9d660db0b6 Reviewed-on: https://gerrit.libreoffice.org/c/core/+/201714 Tested-by: Jenkins CollaboraOffice Reviewed-by: Miklos Vajna Signed-off-by: Xisco Fauli Reviewed-on: https://gerrit.libreoffice.org/c/core/+/201820 --- config_host/config_oauth2.h.in | 4 - external/boost/include/boost/asio/ip/tcp.hpp | 32 +++ external/boost/include/boost/beast/core.hpp | 32 +++ external/boost/include/boost/beast/http.hpp | 32 +++ .../source/dialogs/ServerDetailsControls.cxx | 5 +- uui/Library_uui.mk | 2 +- uui/source/iahndl-authentication.cxx | 2 - ...ahndl-oauth2-win.cxx => iahndl-oauth2.cxx} | 204 ++++++++---------- uui/source/iahndl-oauth2.hxx | 6 - 9 files changed, 191 insertions(+), 128 deletions(-) create mode 100644 external/boost/include/boost/asio/ip/tcp.hpp create mode 100644 external/boost/include/boost/beast/core.hpp create mode 100644 external/boost/include/boost/beast/http.hpp rename uui/source/{iahndl-oauth2-win.cxx => iahndl-oauth2.cxx} (55%) diff --git a/config_host/config_oauth2.h.in b/config_host/config_oauth2.h.in index 8b5bd3db0045..30d94c0f81ab 100644 --- a/config_host/config_oauth2.h.in +++ b/config_host/config_oauth2.h.in @@ -45,10 +45,6 @@ #define ONEDRIVE_REDIRECT_URI "http://localhost/LibreOffice" #define ONEDRIVE_SCOPE "Files.ReadWrite offline_access" -#if defined _WIN32 -#define OAUTH2REQUEST_SUPPORTED -#endif - #endif /* vim:set shiftwidth=4 softtabstop=4 expandtab: */ diff --git a/external/boost/include/boost/asio/ip/tcp.hpp b/external/boost/include/boost/asio/ip/tcp.hpp new file mode 100644 index 000000000000..edf581c9311f --- /dev/null +++ b/external/boost/include/boost/asio/ip/tcp.hpp @@ -0,0 +1,32 @@ +/* generated by bin/gen-boost-headers, do not edit! */ +#pragma once +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wpragmas" /* first! for GCC */ +#pragma GCC diagnostic ignored "-Wunknown-warning-option" // second! for Clang 5 +#pragma GCC diagnostic ignored "-Wdelete-non-virtual-dtor" +#pragma GCC diagnostic ignored "-Wdeprecated-builtins" +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +#pragma GCC diagnostic ignored "-Wdeprecated-copy" +#pragma GCC diagnostic ignored "-Wdeprecated-copy-dtor" +#pragma GCC diagnostic ignored "-Wextra" +#pragma GCC diagnostic ignored "-Wignored-qualifiers" +#pragma GCC diagnostic ignored "-Wimplicit-fallthrough" +#pragma GCC diagnostic ignored "-Winvalid-constexpr" +#pragma GCC diagnostic ignored "-Wmaybe-uninitialized" +#pragma GCC diagnostic ignored "-Wmicrosoft-unqualified-friend" +#pragma GCC diagnostic ignored "-Wnonnull" +#pragma GCC diagnostic ignored "-Wparentheses" +#pragma GCC diagnostic ignored "-Wplacement-new" +#pragma GCC diagnostic ignored "-Wreturn-type" +#pragma GCC diagnostic ignored "-Wshadow" +#pragma GCC diagnostic ignored "-Wstrict-aliasing" +#pragma GCC diagnostic ignored "-Wtautological-constant-out-of-range-compare" +#pragma GCC diagnostic ignored "-Wtype-limits" +#pragma GCC diagnostic ignored "-Wundef" +#pragma GCC diagnostic ignored "-Wunused-but-set-variable" +#pragma GCC diagnostic ignored "-Wunused-local-typedefs" +#pragma GCC diagnostic ignored "-Wunused-macros" +#pragma GCC diagnostic ignored "-Wunused-parameter" +#pragma GCC diagnostic ignored "-Wunused-variable" +#include_next +#pragma GCC diagnostic pop diff --git a/external/boost/include/boost/beast/core.hpp b/external/boost/include/boost/beast/core.hpp new file mode 100644 index 000000000000..655d194c1c42 --- /dev/null +++ b/external/boost/include/boost/beast/core.hpp @@ -0,0 +1,32 @@ +/* generated by bin/gen-boost-headers, do not edit! */ +#pragma once +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wpragmas" /* first! for GCC */ +#pragma GCC diagnostic ignored "-Wunknown-warning-option" // second! for Clang 5 +#pragma GCC diagnostic ignored "-Wdelete-non-virtual-dtor" +#pragma GCC diagnostic ignored "-Wdeprecated-builtins" +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +#pragma GCC diagnostic ignored "-Wdeprecated-copy" +#pragma GCC diagnostic ignored "-Wdeprecated-copy-dtor" +#pragma GCC diagnostic ignored "-Wextra" +#pragma GCC diagnostic ignored "-Wignored-qualifiers" +#pragma GCC diagnostic ignored "-Wimplicit-fallthrough" +#pragma GCC diagnostic ignored "-Winvalid-constexpr" +#pragma GCC diagnostic ignored "-Wmaybe-uninitialized" +#pragma GCC diagnostic ignored "-Wmicrosoft-unqualified-friend" +#pragma GCC diagnostic ignored "-Wnonnull" +#pragma GCC diagnostic ignored "-Wparentheses" +#pragma GCC diagnostic ignored "-Wplacement-new" +#pragma GCC diagnostic ignored "-Wreturn-type" +#pragma GCC diagnostic ignored "-Wshadow" +#pragma GCC diagnostic ignored "-Wstrict-aliasing" +#pragma GCC diagnostic ignored "-Wtautological-constant-out-of-range-compare" +#pragma GCC diagnostic ignored "-Wtype-limits" +#pragma GCC diagnostic ignored "-Wundef" +#pragma GCC diagnostic ignored "-Wunused-but-set-variable" +#pragma GCC diagnostic ignored "-Wunused-local-typedefs" +#pragma GCC diagnostic ignored "-Wunused-macros" +#pragma GCC diagnostic ignored "-Wunused-parameter" +#pragma GCC diagnostic ignored "-Wunused-variable" +#include_next +#pragma GCC diagnostic pop diff --git a/external/boost/include/boost/beast/http.hpp b/external/boost/include/boost/beast/http.hpp new file mode 100644 index 000000000000..904cb91ae4ac --- /dev/null +++ b/external/boost/include/boost/beast/http.hpp @@ -0,0 +1,32 @@ +/* generated by bin/gen-boost-headers, do not edit! */ +#pragma once +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wpragmas" /* first! for GCC */ +#pragma GCC diagnostic ignored "-Wunknown-warning-option" // second! for Clang 5 +#pragma GCC diagnostic ignored "-Wdelete-non-virtual-dtor" +#pragma GCC diagnostic ignored "-Wdeprecated-builtins" +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +#pragma GCC diagnostic ignored "-Wdeprecated-copy" +#pragma GCC diagnostic ignored "-Wdeprecated-copy-dtor" +#pragma GCC diagnostic ignored "-Wextra" +#pragma GCC diagnostic ignored "-Wignored-qualifiers" +#pragma GCC diagnostic ignored "-Wimplicit-fallthrough" +#pragma GCC diagnostic ignored "-Winvalid-constexpr" +#pragma GCC diagnostic ignored "-Wmaybe-uninitialized" +#pragma GCC diagnostic ignored "-Wmicrosoft-unqualified-friend" +#pragma GCC diagnostic ignored "-Wnonnull" +#pragma GCC diagnostic ignored "-Wparentheses" +#pragma GCC diagnostic ignored "-Wplacement-new" +#pragma GCC diagnostic ignored "-Wreturn-type" +#pragma GCC diagnostic ignored "-Wshadow" +#pragma GCC diagnostic ignored "-Wstrict-aliasing" +#pragma GCC diagnostic ignored "-Wtautological-constant-out-of-range-compare" +#pragma GCC diagnostic ignored "-Wtype-limits" +#pragma GCC diagnostic ignored "-Wundef" +#pragma GCC diagnostic ignored "-Wunused-but-set-variable" +#pragma GCC diagnostic ignored "-Wunused-local-typedefs" +#pragma GCC diagnostic ignored "-Wunused-macros" +#pragma GCC diagnostic ignored "-Wunused-parameter" +#pragma GCC diagnostic ignored "-Wunused-variable" +#include_next +#pragma GCC diagnostic pop diff --git a/svtools/source/dialogs/ServerDetailsControls.cxx b/svtools/source/dialogs/ServerDetailsControls.cxx index 2d6879d93b59..9b751ea59e93 100644 --- a/svtools/source/dialogs/ServerDetailsControls.cxx +++ b/svtools/source/dialogs/ServerDetailsControls.cxx @@ -307,13 +307,12 @@ void CmisDetailsContainer::set_visible( bool bShow ) m_pDialog->m_xRepositoryBox->hide(); m_pDialog->m_xEDRoot->hide(); m_pDialog->m_xFTRoot->hide(); -#if defined OAUTH2REQUEST_SUPPORTED + if (m_sBinding == GDRIVE_BASE_URL || m_sBinding == ONEDRIVE_BASE_URL) { m_pDialog->m_xFTShare->hide(); m_pDialog->m_xEDShare->hide(); } -#endif } else { @@ -332,10 +331,8 @@ void CmisDetailsContainer::set_visible( bool bShow ) bool CmisDetailsContainer::enablePassword() { -#if defined OAUTH2REQUEST_SUPPORTED if (m_sBinding == GDRIVE_BASE_URL || m_sBinding == ONEDRIVE_BASE_URL) return false; -#endif return DetailsContainer::enablePassword(); } diff --git a/uui/Library_uui.mk b/uui/Library_uui.mk index 0e82e131ba21..1bc54ca6edd1 100644 --- a/uui/Library_uui.mk +++ b/uui/Library_uui.mk @@ -52,7 +52,7 @@ $(eval $(call gb_Library_add_exception_objects,uui,\ uui/source/iahndl-filter \ uui/source/iahndl-ioexceptions \ uui/source/iahndl-locking \ - $(if $(filter MSC,$(COM)),uui/source/iahndl-oauth2-win) \ + uui/source/iahndl-oauth2 \ uui/source/iahndl-ssl \ uui/source/interactionhandler \ uui/source/lockfailed \ diff --git a/uui/source/iahndl-authentication.cxx b/uui/source/iahndl-authentication.cxx index c9a5bfcd4cca..2e512503b94c 100644 --- a/uui/source/iahndl-authentication.cxx +++ b/uui/source/iahndl-authentication.cxx @@ -770,7 +770,6 @@ UUIInteractionHelper::handleAuthFallbackRequest( const OUString & instructions, uno::Sequence< uno::Reference< task::XInteractionContinuation > > const & rContinuations ) { OUString code; -#if defined OAUTH2REQUEST_SUPPORTED if (url.indexOf(OAUTH2_REDIRECT_URI_PREFIX) >= 0) { OAuth2Request request(url); @@ -778,7 +777,6 @@ UUIInteractionHelper::handleAuthFallbackRequest( const OUString & instructions, code = request.getRetCode(); } else -#endif { uno::Reference xParent = getParentXWindow(); AuthFallbackDlg dlg(Application::GetFrameWeld(xParent), instructions, url); diff --git a/uui/source/iahndl-oauth2-win.cxx b/uui/source/iahndl-oauth2.cxx similarity index 55% rename from uui/source/iahndl-oauth2-win.cxx rename to uui/source/iahndl-oauth2.cxx index 79e803ba0470..28dc1a0337e1 100644 --- a/uui/source/iahndl-oauth2-win.cxx +++ b/uui/source/iahndl-oauth2.cxx @@ -13,22 +13,27 @@ #include "iahndl-oauth2.hxx" -// Has to be before vcl/svapp.hxx +#ifdef _WIN32 +// On Windows, Boost.Asio pulls in windows.h #include -#include -#include +#endif -#pragma comment(lib, "httpapi.lib") +#include +#include +#include + +#ifdef _WIN32 +#include +#endif #include +#include #include -#include -#include +#include #include #include #include #include -#include #include #include #include @@ -40,6 +45,11 @@ #include #include +namespace beast = boost::beast; +namespace http = beast::http; +namespace net = boost::asio; +using tcp = net::ip::tcp; + static OUString getQueryComponent(std::u16string_view query, std::u16string_view name) { auto i = query.find(name); @@ -62,11 +72,11 @@ struct OAuth2Request::Impl std::unique_ptr cancelBox; OUString authUrl; OUString redirectUrl; + OString redirectPath; // path portion of redirectUrl, for matching incoming requests OUString retCode; - int initStage = 0; - HTTP_URL_GROUP_ID groupId{}; - HTTP_BINDING_INFO bindingInfo{ .Flags = { .Present = 1 }, .RequestQueueHandle = nullptr }; + net::io_context ioc{ 1 }; + tcp::acceptor acceptor{ ioc }; Impl(const OUString& auth_url) : cancelBox(Application::CreateMessageDialog( @@ -79,11 +89,10 @@ struct OAuth2Request::Impl DECL_LINK(CloseDialog, void*, void); bool initHTTP(); - void deinitHTTP(); static void SAL_CALL listenHTTP(void* pThis); bool openBrowser() const; - void sendResponse(HTTP_REQUEST_ID id, std::string_view body = {}) const; - void sendCloseResponse(HTTP_REQUEST_ID id, std::u16string_view error) const; + static void sendResponse(tcp::socket& socket, std::string_view body = {}); + void sendCloseResponse(tcp::socket& socket, std::u16string_view error) const; }; OAuth2Request::OAuth2Request(const OUString& auth_url) @@ -108,7 +117,7 @@ void OAuth2Request::run() if (impl->openBrowser()) impl->cancelBox->run(); - // Send DELETE request + // Send DELETE request to stop the server thread CURL* curl = curl_easy_init(); curl_easy_setopt(curl, CURLOPT_URL, OUStringToOString(impl->redirectUrl, RTL_TEXTENCODING_UTF8).getStr()); @@ -119,7 +128,6 @@ void OAuth2Request::run() osl_joinWithThread(thread); } } - impl->deinitHTTP(); // Cleans up everything possibly initialized, even on initHTTP failure } const OUString& OAuth2Request::getRetCode() const { return impl->retCode; } @@ -130,78 +138,48 @@ bool OAuth2Request::Impl::initHTTP() if (!redirectUrl.startsWith(OAUTH2_REDIRECT_URI_PREFIX)) return false; - ULONG result = HttpInitialize(HTTPAPI_VERSION_2, HTTP_INITIALIZE_SERVER, 0); - if (result != NO_ERROR) - { - SAL_WARN("ucb.ucp.cmis", "HttpInitialize failed with code " << result); + // Parse port from redirect URL: "http://localhost:PORT/..." + static constexpr std::u16string_view sPrefix(u"http://localhost:"); + auto nPortEnd = redirectUrl.indexOf('/', sPrefix.size()); + if (nPortEnd < 0) + return false; + sal_uInt16 nPort = o3tl::toUInt32(redirectUrl.subView(sPrefix.size(), nPortEnd)); + if (nPort == 0) return false; - } - initStage = 1; - HTTP_SERVER_SESSION_ID serverSessionId; - result = HttpCreateServerSession(HTTPAPI_VERSION_2, &serverSessionId, 0); - if (result != NO_ERROR) + // Store path for request matching + redirectPath = OUStringToOString(redirectUrl.subView(nPortEnd), RTL_TEXTENCODING_UTF8); + + beast::error_code ec; + auto endpoint = tcp::endpoint(net::ip::make_address("127.0.0.1"), nPort); + + acceptor.open(endpoint.protocol(), ec); + if (ec) { - SAL_WARN("ucb.ucp.cmis", "HttpCreateServerSession failed with code " << result); + SAL_WARN("ucb.ucp.cmis", "Failed to open acceptor: " << ec.message()); return false; } - result = HttpCreateUrlGroup(serverSessionId, &groupId, 0); - if (result != NO_ERROR) + // SO_REUSEADDR: allow rebind if the port is still in TIME_WAIT from a previous run + acceptor.set_option(net::socket_base::reuse_address(true), ec); + + acceptor.bind(endpoint, ec); + if (ec) { - SAL_WARN("ucb.ucp.cmis", "HttpCreateUrlGroup failed with code " << result); + SAL_WARN("ucb.ucp.cmis", "Failed to bind to port " << nPort << ": " << ec.message()); return false; } - result = HttpCreateRequestQueue(HTTPAPI_VERSION_2, nullptr, nullptr, 0, - &bindingInfo.RequestQueueHandle); - if (result != NO_ERROR) + acceptor.listen(net::socket_base::max_listen_connections, ec); + if (ec) { - SAL_WARN("ucb.ucp.cmis", "HttpCreateRequestQueue failed with code " << result); + SAL_WARN("ucb.ucp.cmis", "Failed to listen: " << ec.message()); return false; } - initStage = 2; - result = HttpSetUrlGroupProperty(groupId, HttpServerBindingProperty, &bindingInfo, - sizeof(bindingInfo)); - if (result != NO_ERROR) - { - SAL_WARN("ucb.ucp.cmis", "HttpSetUrlGroupProperty failed with code " << result); - return false; - } - initStage = 3; - - result = HttpAddUrlToUrlGroup(groupId, o3tl::toW(redirectUrl.getStr()), 0, 0); - if (result != NO_ERROR) - { - SAL_WARN("ucb.ucp.cmis", "HttpAddUrlToUrlGroup failed with code " << result); - return false; - } - initStage = 4; return true; } -void OAuth2Request::Impl::deinitHTTP() -{ - switch (initStage) - { - case 4: - HttpRemoveUrlFromUrlGroup(groupId, o3tl::toW(redirectUrl.getStr()), 0); - [[fallthrough]]; - case 3: - HttpSetUrlGroupProperty(groupId, HttpServerBindingProperty, - &o3tl::temporary(HTTP_BINDING_INFO{ - .Flags = { .Present = 0 }, .RequestQueueHandle = nullptr }), - sizeof(HTTP_BINDING_INFO)); - [[fallthrough]]; - case 2: - HttpShutdownRequestQueue(bindingInfo.RequestQueueHandle); - [[fallthrough]]; - case 1: - HttpTerminate(HTTP_INITIALIZE_SERVER, nullptr); - } -} - // The server must handle the redirect data -> send HTML with a prompt to close the current page; // and DELETE requests, which mean stop the operation. @@ -213,42 +191,59 @@ void SAL_CALL OAuth2Request::Impl::listenHTTP(void* pThis) for (;;) { - char buf[sizeof(HTTP_REQUEST) + 0x10000]{}; // 64K for the data - PHTTP_REQUEST request = reinterpret_cast(buf); - ULONG result = HttpReceiveHttpRequest(impl->bindingInfo.RequestQueueHandle, HTTP_NULL_ID, - HTTP_RECEIVE_REQUEST_FLAG_COPY_BODY, request, - std::size(buf), &o3tl::temporary(ULONG()), nullptr); - if (result != NO_ERROR) + beast::error_code ec; + tcp::socket socket(impl->ioc); + impl->acceptor.accept(socket, ec); + if (ec) { - SAL_WARN("ucb.ucp.cmis", "HttpReceiveHttpRequest failed with code " << result); + SAL_WARN("ucb.ucp.cmis", "accept failed: " << ec.message()); return; } - if (request->Verb == HttpVerbDELETE) + beast::flat_buffer buffer; + http::request req; + http::read(socket, buffer, req, ec); + if (ec) + { + SAL_WARN("ucb.ucp.cmis", "HTTP read failed: " << ec.message()); + continue; + } + + if (req.method() == http::verb::delete_) { SAL_INFO("ucb.ucp.cmis", "DELETE request received"); - impl->sendResponse(request->RequestId); // send 200 "OK" + sendResponse(socket); // send 200 "OK" // No need to send anything to the dialog return; } - if (request->Verb = HttpVerbGET) + if (req.method() == http::verb::get) { - std::u16string_view query(o3tl::toU(request->CookedUrl.pQueryString)); - if (auto code = getQueryComponent(query, u"code"); !code.isEmpty()) + auto target = req.target(); + if (!target.starts_with(boost::core::string_view(impl->redirectPath))) { - impl->retCode = code; - impl->sendCloseResponse(request->RequestId, {}); + sendResponse(socket); continue; } - else if (auto error = getQueryComponent(query, u"error"); !error.isEmpty()) + auto qpos = target.find('?', impl->redirectPath.getLength()); + if (qpos != boost::core::string_view::npos) { - impl->sendCloseResponse(request->RequestId, error); - continue; + OUString query = OStringToOUString(target.substr(qpos), RTL_TEXTENCODING_UTF8); + if (auto code = getQueryComponent(query, u"code"); !code.isEmpty()) + { + impl->retCode = code; + impl->sendCloseResponse(socket, {}); + continue; + } + else if (auto error = getQueryComponent(query, u"error"); !error.isEmpty()) + { + impl->sendCloseResponse(socket, error); + continue; + } } } - impl->sendResponse(request->RequestId); // just send 200 OK + sendResponse(socket); // just send 200 OK } } @@ -276,33 +271,20 @@ bool OAuth2Request::Impl::openBrowser() const } } -void OAuth2Request::Impl::sendResponse(HTTP_REQUEST_ID id, std::string_view body) const +void OAuth2Request::Impl::sendResponse(tcp::socket& socket, std::string_view body) { - HTTP_RESPONSE response{}; - response.StatusCode = 200; - - static constexpr std::string_view ok("OK"); - response.pReason = ok.data(); - response.ReasonLength = ok.size(); - - static constexpr std::string_view text_html("text/html"); - response.Headers.KnownHeaders[HttpHeaderContentType].pRawValue = text_html.data(); - response.Headers.KnownHeaders[HttpHeaderContentType].RawValueLength = text_html.size(); - + http::response res(http::status::ok, 11); + res.set(http::field::content_type, "text/html"); + res.keep_alive(false); if (!body.empty()) - { - HTTP_DATA_CHUNK chunk{ .DataChunkType = HttpDataChunkFromMemory, - .FromMemory = { .pBuffer = const_cast(body.data()), - .BufferLength = static_cast(body.size()) } }; - response.EntityChunkCount = 1; - response.pEntityChunks = &chunk; - } + res.body() = std::string(body); + res.prepare_payload(); - HttpSendHttpResponse(bindingInfo.RequestQueueHandle, id, 0, &response, nullptr, nullptr, - nullptr, 0, nullptr, nullptr); + beast::error_code ec; + http::write(socket, res, ec); } -void OAuth2Request::Impl::sendCloseResponse(HTTP_REQUEST_ID id, std::u16string_view error) const +void OAuth2Request::Impl::sendCloseResponse(tcp::socket& socket, std::u16string_view error) const { static constexpr OUString html_tmpl(u"" "" @@ -320,7 +302,7 @@ void OAuth2Request::Impl::sendCloseResponse(HTTP_REQUEST_ID id, std::u16string_v OUString u16 = html_tmpl // replace the placeholders .replaceFirst("$RESULT", result) .replaceFirst("$PRODUCTNAME", utl::ConfigManager::getProductName()); - sendResponse(id, OUStringToOString(u16, RTL_TEXTENCODING_UTF8)); + sendResponse(socket, OUStringToOString(u16, RTL_TEXTENCODING_UTF8)); // Executed in the VCL thread, inside the dialog's run() Application::PostUserEvent(LINK(const_cast(this), Impl, CloseDialog)); diff --git a/uui/source/iahndl-oauth2.hxx b/uui/source/iahndl-oauth2.hxx index 140dce870a65..f994b575c85c 100644 --- a/uui/source/iahndl-oauth2.hxx +++ b/uui/source/iahndl-oauth2.hxx @@ -11,10 +11,6 @@ #include -#include - -#if defined OAUTH2REQUEST_SUPPORTED - #include #include @@ -51,6 +47,4 @@ private: std::unique_ptr impl; }; -#endif // OAUTH2REQUEST_SUPPORTED - /* vim:set shiftwidth=4 softtabstop=4 expandtab cinoptions=b1,g0,N-s cinkeys+=0=break: */