diff --git a/common/JailUtil.cpp b/common/JailUtil.cpp index c2e42138dd73..f0ce50bfa8b5 100644 --- a/common/JailUtil.cpp +++ b/common/JailUtil.cpp @@ -426,7 +426,9 @@ void setupChildRoot(bool bindMount, const std::string& childRoot, const std::str { // Start with a clean slate. cleanupJails(childRoot); - createJailPath(childRoot + CHILDROOT_TMP_INCOMING_PATH); + + createJailPath(childRoot + CHILDROOT_TMP_INCOMING_PATH + "/fonts"); + createJailPath(childRoot + CHILDROOT_TMP_INCOMING_PATH + "/templates"); disableBindMounting(); // Clear to avoid surprises. diff --git a/coolwsd.xml.in b/coolwsd.xml.in index d354da1a6f67..98c4d92ddef9 100644 --- a/coolwsd.xml.in +++ b/coolwsd.xml.in @@ -323,6 +323,10 @@ + + + + false diff --git a/kit/Kit.cpp b/kit/Kit.cpp index fa551799dda5..311d016ef945 100644 --- a/kit/Kit.cpp +++ b/kit/Kit.cpp @@ -3254,6 +3254,10 @@ void lokit_main( const std::string tmpSubDir = Poco::Path(tempRoot, "cool-" + jailId).toString(); const std::string jailTmpDir = Poco::Path(jailPath, "tmp").toString(); + const std::string tmpIncoming = Poco::Path(childRoot, JailUtil::CHILDROOT_TMP_INCOMING_PATH).toString(); + const std::string sharedTemplate = Poco::Path(tmpIncoming, "templates").toString(); + const std::string loJailDestImpressTemplatePath = Poco::Path(loJailDestPath, "share/template/common/presnt").toString(); + const std::string sysTemplateSubDir = Poco::Path(tempRoot, "systemplate-" + jailId).toString(); const std::string jailEtcDir = Poco::Path(jailPath, "etc").toString(); @@ -3313,6 +3317,34 @@ void lokit_main( return false; } + // copy default tempates from 'common' dir to shared templates dir + // TODO: maybe I shouldn't copy if whole point to mounting is that we don't require copying. + auto defaultTemplates = FileUtil::getDirEntries(loJailDestImpressTemplatePath); + for (auto& name : defaultTemplates) + { + std::string sourcePath = loJailDestImpressTemplatePath; + sourcePath.append("/"); + sourcePath.append(name); + std::string destPath = sharedTemplate; + destPath.append("/"); + destPath.append(name); + if (!FileUtil::copy(sourcePath, destPath, false, false)) + { + LOG_WRN("Failed to copy default impress template from [" + << sourcePath << "] to [" << sharedTemplate << ']'); + } + } + + // mount the shared templates over the lo shared templates' 'common' dir + if (!JailUtil::bind(sharedTemplate, loJailDestImpressTemplatePath) || + !JailUtil::remountReadonly(sharedTemplate, loJailDestImpressTemplatePath)) + { + // TODO: actually do this link on failure + LOG_WRN("Failed to mount [" << sharedTemplate << "] -> [" << sharedTemplate + << "], will link contents"); + return false; + } + // tmpdir inside the jail for added sercurity. Poco::File(tmpSubDir).createDirectories(); LOG_INF("Mounting random temp dir " << tmpSubDir << " -> " << jailTmpDir); diff --git a/wsd/COOLWSD.cpp b/wsd/COOLWSD.cpp index 93dae733e0b5..6d37d2cf3c51 100644 --- a/wsd/COOLWSD.cpp +++ b/wsd/COOLWSD.cpp @@ -676,6 +676,7 @@ std::string COOLWSD::ServerName; std::string COOLWSD::FileServerRoot; std::string COOLWSD::ServiceRoot; std::string COOLWSD::TmpFontDir; +std::string COOLWSD::TmpTemplateDir; std::string COOLWSD::LOKitVersion; std::string COOLWSD::ConfigFile = COOLWSD_CONFIGDIR "/coolwsd.xml"; std::string COOLWSD::ConfigDir = COOLWSD_CONFIGDIR "/conf.d"; @@ -1346,6 +1347,8 @@ void COOLWSD::innerInitialize(Poco::Util::Application& self) { "extra_export_formats.impress_png", "false" }, { "extra_export_formats.impress_svg", "false" }, { "extra_export_formats.impress_tiff", "false" }, + { "remote_template_config.url", ""}, + { "remote_font_config.url", ""}, }; // Set default values, in case they are missing from the config file. @@ -3619,7 +3622,8 @@ int COOLWSD::innerMain() assert(Server && "The COOLWSDServer instance does not exist."); Server->findClientPort(); - TmpFontDir = ChildRoot + JailUtil::CHILDROOT_TMP_INCOMING_PATH; + TmpFontDir = ChildRoot + JailUtil::CHILDROOT_TMP_INCOMING_PATH + "/fonts"; + TmpTemplateDir = ChildRoot + JailUtil::CHILDROOT_TMP_INCOMING_PATH + "/templates"; // Start the internal prisoner server and spawn forkit, // which in turn forks first child. @@ -3679,17 +3683,40 @@ int COOLWSD::innerMain() LOG_ERR("Log level is set very high to '" << LogLevel << "' this will have a " "significant performance impact. Do not use this in production."); - // Start the remote font downloading polling thread. - std::unique_ptr remoteFontConfigThread; + std::string uriConfigKey; + const std::string& fontConfigKey = "remote_font_config.url"; + const std::string& assetConfigKey = "remote_asset_config.url"; + bool remoteFontDefined = !ConfigUtil::getConfigValue(fontConfigKey, "").empty(); + bool remoteAssetDefined = !ConfigUtil::getConfigValue(assetConfigKey, "").empty(); + // Both defined: warn and use assetConfigKey + if (remoteFontDefined && remoteAssetDefined) + { + LOG_WRN("Both remote_font_config.url and remote_asset_config.url are defined, " + "remote_asset_config.url is overriden on remote_font_config.url"); + uriConfigKey = assetConfigKey; + } + // only font defined: use fontConfigKey + else if (remoteFontDefined && !remoteAssetDefined) + { + uriConfigKey = fontConfigKey; + } + // only asset defined: use assetConfigKey + else if (!remoteFontDefined && remoteAssetDefined) + { + uriConfigKey = assetConfigKey; + } + + // Start the remote asset downloading polling thread. + std::unique_ptr remoteAssetConfigThread; try { - // Fetch font settings from server if configured - remoteFontConfigThread = std::make_unique(config()); - remoteFontConfigThread->start(); + // Fetch font and/or templates settings from server if configured + remoteAssetConfigThread = std::make_unique(config(), uriConfigKey); + remoteAssetConfigThread->start(); } catch (const Poco::Exception&) { - LOG_DBG("No remote_font_config"); + LOG_DBG("No remote_asset_config"); } #endif diff --git a/wsd/COOLWSD.hpp b/wsd/COOLWSD.hpp index bd2a6d38553e..6b2e68ab0faa 100644 --- a/wsd/COOLWSD.hpp +++ b/wsd/COOLWSD.hpp @@ -82,6 +82,7 @@ class COOLWSD final : public Poco::Util::ServerApplication, static std::string FileServerRoot; static std::string ServiceRoot; ///< There are installations that need prefixing every page with some path. static std::string TmpFontDir; + static std::string TmpTemplateDir; static std::string LOKitVersion; static bool EnableTraceEventLogging; static bool EnableAccessibility; diff --git a/wsd/RemoteConfig.cpp b/wsd/RemoteConfig.cpp index 35e24b87748c..4606db4189f6 100644 --- a/wsd/RemoteConfig.cpp +++ b/wsd/RemoteConfig.cpp @@ -16,6 +16,8 @@ #include #include "RemoteConfig.hpp" +#include "FileUtil.hpp" +#include "Util.hpp" #include #include @@ -106,7 +108,8 @@ void RemoteJSONPoll::pollingThread() { std::string kind; JsonUtil::findJSONValue(remoteJson, "kind", kind); - if (kind == _expectedKind) + const std::pair expectedKinds = Util::split(_expectedKind, '|'); + if (kind == expectedKinds.first || kind == expectedKinds.second) { handleJSON(remoteJson); } @@ -556,105 +559,160 @@ void RemoteConfigPoll::handleOptions(const Poco::JSON::Object::Ptr& remoteJson) } } -void RemoteFontConfigPoll::handleJSON(const Poco::JSON::Object::Ptr& remoteJson) +bool RemoteAssetConfigPoll::getNewAssets(const Poco::JSON::Object::Ptr& remoteJson, + const std::string& assetJsonKey, + std::map& assets) { - // First mark all fonts we have downloaded previously as "inactive" to be able to check if - // some font gets deleted from the list in the JSON file. - for (auto& it : fonts) + // First mark all assets we have downloaded previously as "inactive" to be able to check if + // some asset gets deleted from the list in the JSON file. + for (auto& it : assets) it.second.active = false; - // Just pick up new fonts. - auto fontsPtr = remoteJson->getArray("fonts"); - if (!fontsPtr) + bool reDownloadConfig = false; + auto assetsPtr = remoteJson->getArray(assetJsonKey); + if (!assetsPtr) { - LOG_WRN("The 'fonts' property does not exist or is not an array"); - return; + LOG_WRN("The [" + assetJsonKey + "] property does not exist or is not an array"); + return reDownloadConfig; } - for (std::size_t i = 0; i < fontsPtr->size(); i++) + for (std::size_t i = 0; i < assetsPtr->size(); i++) { - if (!fontsPtr->isObject(i)) - LOG_WRN("Element " << i << " in fonts array is not an object"); + if (!assetsPtr->isObject(i)) + LOG_WRN("Element " << i << " in " << assetJsonKey << " array is not an object"); else { - const auto fontPtr = fontsPtr->getObject(i); - const auto uriPtr = fontPtr->get("uri"); + const auto assetPtr = assetsPtr->getObject(i); + const auto uriPtr = assetPtr->get("uri"); if (uriPtr.isEmpty() || !uriPtr.isString()) - LOG_WRN("Element in fonts array does not have an 'uri' property or it is not a " - "string"); + LOG_WRN("Element in " << assetJsonKey + << " array does not have an 'uri' property or it is not a " + "string"); else { const std::string uri = uriPtr.toString(); - const auto stampPtr = fontPtr->get("stamp"); + const auto stampPtr = assetPtr->get("stamp"); if (!stampPtr.isEmpty() && !stampPtr.isString()) - LOG_WRN("Element in fonts array with uri '" - << uri << "' has a stamp property that is not a string, ignored"); - else if (fonts.count(uri) == 0) + LOG_WRN("Element in " + << assetJsonKey << "array with uri '" << uri + << "' has a stamp property that is not a string, ignored"); + else if (assets.count(uri) == 0) { - // First case: This font has not been downloaded. + // First case: This asset has not been downloaded. if (!stampPtr.isEmpty()) { - if (downloadPlain(uri)) + if (downloadPlain(uri, assets, assetJsonKey)) { - fonts[uri].stamp = stampPtr.toString(); - fonts[uri].active = true; + assets[uri].stamp = stampPtr.toString(); + assets[uri].active = true; } } else { - if (downloadWithETag(uri, "")) + if (downloadWithETag(uri, "", assets, assetJsonKey)) { - fonts[uri].active = true; + assets[uri].active = true; } } } - else if (!stampPtr.isEmpty() && stampPtr.toString() != fonts[uri].stamp) + else if (!stampPtr.isEmpty() && stampPtr.toString() != assets[uri].stamp) { - // Second case: Font has been downloaded already, has a "stamp" property, + // Second case: asset has been downloaded already, has a "stamp" property, // and that has been changed in the JSON since it was downloaded. - restartForKitAndReDownloadConfigFile(); + reDownloadConfig = true; break; } else if (!stampPtr.isEmpty()) { - // Third case: Font has been downloaded already, has a "stamp" property, and + // Third case: asset has been downloaded already, has a "stamp" property, and // that has *not* changed in the JSON since it was downloaded. - fonts[uri].active = true; + assets[uri].active = true; } else { - // Last case: Font has been downloaded but does not have a "stamp" property. + // Last case: Asset has been downloaded but does not have a "stamp" property. // Use ETag. - if (!eTagUnchanged(uri, fonts[uri].eTag)) + if (!eTagUnchanged(uri, assets[uri].eTag)) { - restartForKitAndReDownloadConfigFile(); + reDownloadConfig = true; break; } - fonts[uri].active = true; + assets[uri].active = true; } } } } - // Any font that has been deleted from the JSON needs to be removed on this side, too. - for (const auto& it : fonts) + // Any asset that has been deleted from the JSON needs to be removed on this side, too. + for (const auto& it : assets) { if (!it.second.active) { - LOG_DBG("Font no longer mentioned in the remote font config: " << it.first); - restartForKitAndReDownloadConfigFile(); + LOG_DBG("Asset no longer mentioned in the remote font config: " << it.first); + reDownloadConfig = true; break; } } + return reDownloadConfig; } -void RemoteFontConfigPoll::handleUnchangedJSON() +std::string RemoteAssetConfigPoll::removeTemplate(const std::string& uri) { - // Iterate over the fonts that were mentioned in the JSON file when it was last downloaded. - for (auto& it : fonts) + const Poco::URI assetUri{ uri }; + const std::string& path = assetUri.getPath(); + const std::string filename = path.substr(path.find_last_of('/') + 1); + std::string assetFile; + assetFile.append(COOLWSD::TmpTemplateDir); + assetFile.append("/"); + assetFile.append(filename); + FileUtil::removeFile(assetFile); + return assetFile; +} + +void RemoteAssetConfigPoll::reDownloadConfigFile(std::map& assets, + const std::string& assetType) +{ + LOG_DBG("Downloaded asset has been updated or a asset has been removed."); + + // remove inactive templates + if (assetType == "templates") { - // If the JSON has a "stamp" for the font, and we have already downloaded it, by + for (const auto& it : assets) + if (!it.second.active) + removeTemplate(it.second.pathName); + } + + assets.clear(); + // Clear the saved ETag of the remote font configuration file so that it will be + // re-downloaded, and all fonts mentioned in it re-downloaded and fed to ForKit. + _eTagValue.clear(); + if (assetType == "fonts") + { + LOG_DBG("ForKit must be restarted."); + COOLWSD::sendMessageToForKit("exit"); + } +} + +void RemoteAssetConfigPoll::handleJSON(const Poco::JSON::Object::Ptr& remoteJson) +{ + bool reDownloadFontConfig = getNewAssets(remoteJson, "fonts", fonts); + bool reDownloadTemplateConfig = getNewAssets(remoteJson, "templates", templates); + + if (reDownloadFontConfig) + reDownloadConfigFile(fonts, "fonts"); + if (reDownloadTemplateConfig) + reDownloadConfigFile(templates, "templates"); +} + +bool RemoteAssetConfigPoll::handleUnchangedAssets(std::map& assets) +{ + bool reDownloadConfig = false; + + // Iterate over the assets that were mentioned in the JSON file when it was last downloaded. + for (auto& it : assets) + { + // If the JSON has a "stamp" for the asset, and we have already downloaded it, by // definition we don't need to do anything when the JSON file has not changed. if (it.second.stamp != "" && it.second.pathName != "") continue; @@ -663,37 +721,51 @@ void RemoteFontConfigPoll::handleUnchangedJSON() // assert() that? if (it.second.stamp != "" && it.second.pathName == "") { - LOG_WRN("Font at " << it.first << " was not downloaded, should have been"); + LOG_WRN("Asset at " << it.first << " was not downloaded, should have been"); continue; } - // Otherwise use the ETag to check if the font file needs re-downloading. + // Otherwise use the ETag to check if the asset file needs re-downloading. if (!eTagUnchanged(it.first, it.second.eTag)) { - restartForKitAndReDownloadConfigFile(); + reDownloadConfig = true; break; } } + return reDownloadConfig; +} + +void RemoteAssetConfigPoll::handleUnchangedJSON() +{ + bool reDownloadFontConfig = handleUnchangedAssets(fonts); + bool reDownloadTemplateConfig = handleUnchangedAssets(templates); + + if (reDownloadFontConfig) + reDownloadConfigFile(fonts, "fonts"); + if (reDownloadTemplateConfig) + reDownloadConfigFile(templates, "templates"); } -bool RemoteFontConfigPoll::downloadPlain(const std::string& uri) +bool RemoteAssetConfigPoll::downloadPlain(const std::string& uri, + std::map& assets, + const std::string& assetType) { - const Poco::URI fontUri{ uri }; - std::shared_ptr httpSession(StorageConnectionManager::getHttpSession(fontUri)); - http::Request request(fontUri.getPathAndQuery()); + const Poco::URI assetUri{ uri }; + std::shared_ptr httpSession(StorageConnectionManager::getHttpSession(assetUri)); + http::Request request(assetUri.getPathAndQuery()); request.set("User-Agent", http::getAgentString()); const std::shared_ptr httpResponse = httpSession->syncRequest(request); - return finishDownload(uri, httpResponse); + return finishDownload(uri, httpResponse, assets, assetType); } -bool RemoteFontConfigPoll::eTagUnchanged(const std::string& uri, const std::string& oldETag) +bool RemoteAssetConfigPoll::eTagUnchanged(const std::string& uri, const std::string& oldETag) { - const Poco::URI fontUri{ uri }; - std::shared_ptr httpSession(StorageConnectionManager::getHttpSession(fontUri)); - http::Request request(fontUri.getPathAndQuery()); + const Poco::URI assetUri{ uri }; + std::shared_ptr httpSession(StorageConnectionManager::getHttpSession(assetUri)); + http::Request request(assetUri.getPathAndQuery()); if (!oldETag.empty()) { @@ -713,11 +785,13 @@ bool RemoteFontConfigPoll::eTagUnchanged(const std::string& uri, const std::stri return false; } -bool RemoteFontConfigPoll::downloadWithETag(const std::string& uri, const std::string& oldETag) +bool RemoteAssetConfigPoll::downloadWithETag(const std::string& uri, const std::string& oldETag, + std::map& assets, + const std::string& assetType) { - const Poco::URI fontUri{ uri }; - std::shared_ptr httpSession(StorageConnectionManager::getHttpSession(fontUri)); - http::Request request(fontUri.getPathAndQuery()); + const Poco::URI assetUri{ uri }; + std::shared_ptr httpSession(StorageConnectionManager::getHttpSession(assetUri)); + http::Request request(assetUri.getPathAndQuery()); if (!oldETag.empty()) { @@ -734,15 +808,16 @@ bool RemoteFontConfigPoll::downloadWithETag(const std::string& uri, const std::s return true; } - if (!finishDownload(uri, httpResponse)) + if (!finishDownload(uri, httpResponse, assets, assetType)) return false; - fonts[uri].eTag = httpResponse->get("ETag"); + assets[uri].eTag = httpResponse->get("ETag"); return true; } -bool RemoteFontConfigPoll::finishDownload(const std::string& uri, - const std::shared_ptr& httpResponse) +bool RemoteAssetConfigPoll::finishDownload( + const std::string& uri, const std::shared_ptr& httpResponse, + std::map& assets, const std::string& assetType) { if (httpResponse->statusLine().statusCode() != http::StatusCode::OK) { @@ -752,45 +827,38 @@ bool RemoteFontConfigPoll::finishDownload(const std::string& uri, const std::string& body = httpResponse->getBody(); - // We intentionally use a new file name also when an updated version of a font is - // downloaded. It causes trouble to rewrite the same file, in case it is in use in some Kit - // process at the moment. + std::string assetFile; + if (assetType == "fonts") + // We intentionally use a new file name also when an updated version of a font is + // downloaded. It causes trouble to rewrite the same file, in case it is in use in some Kit + // process at the moment. - // We don't remove the old file either as that also causes problems. + // We don't remove the old file either as that also causes problems. + // And in reality, it is a bit unclear how likely it even is that assets downloaded through + // this mechanism even will be updated. + assetFile = COOLWSD::TmpFontDir + '/' + Util::encodeId(Util::rng::getNext()) + ".ttf"; + else if (assetType == "templates") + assetFile = removeTemplate(uri); - // And in reality, it is a bit unclear how likely it even is that fonts downloaded through - // this mechanism even will be updated. - const std::string fontFile = - COOLWSD::TmpFontDir + '/' + Util::encodeId(Util::rng::getNext()) + ".ttf"; - - std::ofstream fontStream(fontFile); - fontStream.write(body.data(), body.size()); - if (!fontStream.good()) + std::ofstream assetStream(assetFile); + assetStream.write(body.data(), body.size()); + if (!assetStream.good()) { - LOG_ERR("Could not write " << body.size() << " bytes to [" << fontFile << ']'); + LOG_ERR("Could not write " << body.size() << " bytes to [" << assetFile << ']'); return false; } - LOG_DBG("Got " << body.size() << " bytes from [" << uri << "] and wrote to [" << fontFile + LOG_DBG("Got " << body.size() << " bytes from [" << uri << "] and wrote to [" << assetFile << ']'); - fonts[uri].pathName = fontFile; - COOLWSD::sendMessageToForKit("addfont " + fontFile); + assets[uri].pathName = assetFile; + + if (assetType == "fonts") + COOLWSD::sendMessageToForKit("addfont " + assetFile); COOLWSD::requestTerminateSpareKits(); return true; } -void RemoteFontConfigPoll::restartForKitAndReDownloadConfigFile() -{ - LOG_DBG("Downloaded font has been updated or a font has been removed. ForKit must be " - "restarted."); - fonts.clear(); - // Clear the saved ETag of the remote font configuration file so that it will be - // re-downloaded, and all fonts mentioned in it re-downloaded and fed to ForKit. - _eTagValue.clear(); - COOLWSD::sendMessageToForKit("exit"); -} - /* vim:set shiftwidth=4 softtabstop=4 expandtab: */ diff --git a/wsd/RemoteConfig.hpp b/wsd/RemoteConfig.hpp index c1a038851d98..de6f9d5d1578 100644 --- a/wsd/RemoteConfig.hpp +++ b/wsd/RemoteConfig.hpp @@ -22,6 +22,8 @@ #include #include +#include +#include #include @@ -114,12 +116,12 @@ class RemoteConfigPoll : public RemoteJSONPoll Poco::AutoPtr _persistConfig = nullptr; }; -class RemoteFontConfigPoll : public RemoteJSONPoll +class RemoteAssetConfigPoll : public RemoteJSONPoll { public: - RemoteFontConfigPoll(Poco::Util::LayeredConfiguration& config) - : RemoteJSONPoll(config, "remote_font_config.url", "remotefontconfig_poll", - "fontconfiguration") + RemoteAssetConfigPoll(Poco::Util::LayeredConfiguration& config, const std::string& uriConfigKey) + : RemoteJSONPoll(config, uriConfigKey, "remoteassetconfig_poll", + "assetconfiguration|fontconfiguration") { } @@ -128,39 +130,53 @@ class RemoteFontConfigPoll : public RemoteJSONPoll void handleUnchangedJSON() override; private: - bool downloadPlain(const std::string& uri); - bool eTagUnchanged(const std::string& uri, const std::string& oldETag); - bool downloadWithETag(const std::string& uri, const std::string& oldETag); - - bool finishDownload(const std::string& uri, - const std::shared_ptr& httpResponse); - - void restartForKitAndReDownloadConfigFile(); - - struct FontData + struct AssetData { - // Each font can have a "stamp" in the JSON that we treat just as a string. In practice it + // Each asset can have a "stamp" in the JSON that we treat just as a string. In practice it // can be some timestamp, but we don't parse it. If the stamp is changed, we re-download the - // font file. + // asset file. std::string stamp; - // If the font has no "stamp" property, we use the ETag mechanism to see if the font file + // If the asset has no "stamp" property, we use the ETag mechanism to see if the font file // needs to be re-downloaded. std::string eTag; - // Where the font has been stored + // Where the asset has been stored std::string pathName; - // Flag that tells whether the font is mentioned in the JSON file that is being handled. + // Flag that tells whether the asset is mentioned in the JSON file that is being handled. // Used only in handleJSON() when the JSON has been (re-)downloaded, not when the JSON was // unchanged in handleUnchangedJSON(). bool active; }; + bool downloadPlain(const std::string& uri, std::map& assets, + const std::string& assetType); + + bool finishDownload(const std::string& uri, + const std::shared_ptr& httpResponse, + std::map& assets, const std::string& assetType); + + bool downloadWithETag(const std::string& uri, const std::string& oldETag, + std::map& assets, const std::string& assetType); + + bool getNewAssets(const Poco::JSON::Object::Ptr& remoteJson, const std::string& assetJsonKey, + std::map& assets); + + void reDownloadConfigFile(std::map& assets, + const std::string& assetType); + + bool handleUnchangedAssets(std::map& assets); + + std::string removeTemplate(const std::string& uri); + // The key of this map is the download URI of the font. - std::map fonts; + std::map fonts; + + // The key of this map is the download URI of the template. + std::map templates; }; /* vim:set shiftwidth=4 softtabstop=4 expandtab: */