2020-06-03 11:46:42 -05:00
|
|
|
/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4; fill-column: 100 -*- */
|
|
|
|
/*
|
2023-10-30 16:58:54 -05:00
|
|
|
* Copyright the Collabora Online contributors.
|
|
|
|
*
|
|
|
|
* SPDX-License-Identifier: MPL-2.0
|
2023-11-09 12:23:00 -06:00
|
|
|
*
|
|
|
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
2020-06-03 11:46:42 -05:00
|
|
|
*/
|
|
|
|
|
|
|
|
#include <config.h>
|
|
|
|
|
|
|
|
#include "FileServer.hpp"
|
2022-03-29 20:37:57 -05:00
|
|
|
#include "StringVector.hpp"
|
|
|
|
#include "Util.hpp"
|
|
|
|
|
|
|
|
#include <Poco/JSON/Object.h>
|
2020-06-03 11:46:42 -05:00
|
|
|
|
2023-08-08 05:51:07 -05:00
|
|
|
#include <cctype>
|
|
|
|
|
2023-08-06 08:55:14 -05:00
|
|
|
PreProcessedFile::PreProcessedFile(std::string filename, const std::string& data)
|
|
|
|
: _filename(std::move(filename))
|
|
|
|
, _size(data.length())
|
|
|
|
{
|
|
|
|
std::size_t pos = 0; //< The current position to search from.
|
|
|
|
std::size_t lastpos = 0; //< The last position in a literal string.
|
|
|
|
|
|
|
|
do
|
|
|
|
{
|
|
|
|
std::size_t newpos = data.find_first_of("<%", pos);
|
|
|
|
if (newpos == std::string::npos || newpos + 2 >= _size)
|
|
|
|
{
|
|
|
|
// Not enough data to parse a variable.
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
|
|
|
|
assert(newpos + 2 < _size && "Expected at least 3 characters for variable");
|
|
|
|
|
|
|
|
if (data[newpos] == '<')
|
|
|
|
{
|
|
|
|
if (newpos + 5 < _size && data.compare(newpos + 1, 4, "!--%") != 0)
|
|
|
|
{
|
|
|
|
// Just a tag; continue searching.
|
|
|
|
pos = newpos + 1;
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
std::size_t nestedpos = data.find_first_of("<>", newpos + 1);
|
|
|
|
if (nestedpos != std::string::npos && data[nestedpos] == '<')
|
|
|
|
{
|
|
|
|
// We expected to find the end of comment before a new tag.
|
|
|
|
// Resume searching.
|
|
|
|
pos = nestedpos;
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Find the matching closing comment
|
|
|
|
std::size_t endpos = data.find_first_of('>', newpos + 1);
|
|
|
|
if (endpos == std::string::npos)
|
|
|
|
{
|
|
|
|
// Broken comment.
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Extract variable name.
|
|
|
|
const std::size_t varstart = data.find_first_of('%', newpos);
|
|
|
|
if (varstart == std::string::npos || varstart > endpos)
|
|
|
|
{
|
|
|
|
// Comment without a variable.
|
|
|
|
pos = endpos + 1;
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
2023-08-08 05:51:07 -05:00
|
|
|
std::size_t varend = varstart + 1;
|
|
|
|
while (varend < endpos)
|
|
|
|
{
|
|
|
|
if (data[varend] == '%' || (!std::isalpha(data[varend]) && data[varend] != '_'))
|
|
|
|
break;
|
|
|
|
|
|
|
|
++varend;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (varend >= endpos || data[varend] != '%')
|
2023-08-06 08:55:14 -05:00
|
|
|
{
|
|
|
|
// Comment without a variable.
|
|
|
|
pos = endpos + 1;
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Insert previous literal.
|
|
|
|
if (newpos > lastpos)
|
|
|
|
{
|
2023-08-08 07:20:44 -05:00
|
|
|
_segments.emplace_back(SegmentType::Data, data.substr(lastpos, newpos - lastpos));
|
2023-08-06 08:55:14 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
lastpos = endpos + 1;
|
2023-08-08 07:20:44 -05:00
|
|
|
_segments.emplace_back(SegmentType::CommentedVariable,
|
|
|
|
data.substr(varstart + 1, varend - varstart - 1));
|
2023-08-06 08:55:14 -05:00
|
|
|
}
|
|
|
|
else
|
|
|
|
{
|
|
|
|
assert(data[newpos] == '%' && "Expected '%' at given position");
|
|
|
|
|
|
|
|
// Extract variable name.
|
2023-08-08 05:51:07 -05:00
|
|
|
std::size_t varend = newpos + 1;
|
|
|
|
while (varend < _size)
|
|
|
|
{
|
|
|
|
if (data[varend] == '%' || (!std::isalpha(data[varend]) && data[varend] != '_'))
|
|
|
|
break;
|
|
|
|
|
|
|
|
++varend;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (varend > _size || data[varend] != '%')
|
2023-08-06 08:55:14 -05:00
|
|
|
{
|
|
|
|
// Broken variable.
|
2023-08-08 05:51:07 -05:00
|
|
|
pos = varend;
|
|
|
|
continue;
|
2023-08-06 08:55:14 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
// Insert previous literal.
|
|
|
|
if (newpos > lastpos)
|
|
|
|
{
|
2023-08-08 07:20:44 -05:00
|
|
|
_segments.emplace_back(SegmentType::Data, data.substr(lastpos, newpos - lastpos));
|
2023-08-06 08:55:14 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
lastpos = varend + 1;
|
2023-08-08 07:20:44 -05:00
|
|
|
_segments.emplace_back(SegmentType::Variable,
|
|
|
|
data.substr(newpos + 1, varend - newpos - 1));
|
2023-08-06 08:55:14 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
pos = lastpos;
|
|
|
|
} while (pos < _size);
|
|
|
|
|
|
|
|
if (lastpos < _size)
|
|
|
|
{
|
2023-08-08 07:20:44 -05:00
|
|
|
_segments.emplace_back(SegmentType::Data, data.substr(lastpos));
|
2023-08-06 08:55:14 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-08-08 07:21:06 -05:00
|
|
|
std::string PreProcessedFile::substitute(const std::unordered_map<std::string, std::string>& values)
|
|
|
|
{
|
|
|
|
std::string recon;
|
|
|
|
recon.reserve(_size * 2);
|
|
|
|
for (const auto& seg : _segments)
|
|
|
|
{
|
|
|
|
switch (seg.first)
|
|
|
|
{
|
|
|
|
case SegmentType::Data:
|
|
|
|
recon.append(seg.second);
|
|
|
|
break;
|
|
|
|
case SegmentType::Variable:
|
|
|
|
case SegmentType::CommentedVariable:
|
|
|
|
{
|
|
|
|
const auto it = values.find(seg.second);
|
|
|
|
if (it == values.end())
|
|
|
|
{
|
|
|
|
// Leave original variable as-is.
|
|
|
|
if (seg.first == SegmentType::Variable)
|
|
|
|
{
|
|
|
|
recon.append("%");
|
|
|
|
recon.append(seg.second);
|
|
|
|
recon.append("%");
|
|
|
|
}
|
|
|
|
else if (seg.first == SegmentType::CommentedVariable)
|
|
|
|
{
|
|
|
|
recon.append("<!--%");
|
|
|
|
recon.append(seg.second);
|
|
|
|
recon.append("%-->");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
else
|
|
|
|
{
|
|
|
|
// Substitute with the given value.
|
|
|
|
recon.append(it->second);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return recon;
|
|
|
|
}
|
|
|
|
|
2023-10-30 17:35:17 -05:00
|
|
|
std::string FileServerRequestHandler::uiDefaultsToJSON(const std::string& uiDefaults, std::string& uiMode, std::string& uiTheme, std::string& savedUIState)
|
2020-06-03 11:46:42 -05:00
|
|
|
{
|
|
|
|
static std::string previousUIDefaults;
|
|
|
|
static std::string previousJSON("{}");
|
2020-12-07 02:02:06 -06:00
|
|
|
static std::string previousUIMode;
|
2020-06-03 11:46:42 -05:00
|
|
|
|
|
|
|
// early exit if we are serving the same thing
|
|
|
|
if (uiDefaults == previousUIDefaults)
|
2020-12-07 02:02:06 -06:00
|
|
|
{
|
|
|
|
uiMode = previousUIMode;
|
2020-06-03 11:46:42 -05:00
|
|
|
return previousJSON;
|
2020-12-07 02:02:06 -06:00
|
|
|
}
|
2020-06-03 11:46:42 -05:00
|
|
|
|
|
|
|
Poco::JSON::Object json;
|
|
|
|
Poco::JSON::Object textDefs;
|
|
|
|
Poco::JSON::Object spreadsheetDefs;
|
|
|
|
Poco::JSON::Object presentationDefs;
|
2021-10-22 11:07:37 -05:00
|
|
|
Poco::JSON::Object drawingDefs;
|
2020-06-03 11:46:42 -05:00
|
|
|
|
2020-12-07 02:02:06 -06:00
|
|
|
uiMode = "";
|
2023-05-25 16:51:17 -05:00
|
|
|
uiTheme = "light";
|
2023-10-30 17:35:17 -05:00
|
|
|
savedUIState = "true";
|
2022-03-29 20:37:57 -05:00
|
|
|
StringVector tokens(StringVector::tokenize(uiDefaults, ';'));
|
2020-06-03 11:46:42 -05:00
|
|
|
for (const auto& token : tokens)
|
|
|
|
{
|
2022-03-29 20:37:57 -05:00
|
|
|
StringVector keyValue(StringVector::tokenize(tokens.getParam(token), '='));
|
2020-06-03 11:46:42 -05:00
|
|
|
Poco::JSON::Object* currentDef = nullptr;
|
|
|
|
std::string key;
|
|
|
|
|
|
|
|
// detect the UIMode or component
|
2021-10-18 09:41:12 -05:00
|
|
|
if (keyValue.equals(0, "UIMode"))
|
2020-06-03 11:46:42 -05:00
|
|
|
{
|
2022-04-22 05:00:00 -05:00
|
|
|
if (keyValue.equals(1, "compact") || keyValue.equals(1, "classic"))
|
2020-12-07 02:02:06 -06:00
|
|
|
{
|
2022-04-22 05:00:00 -05:00
|
|
|
json.set("uiMode", "classic");
|
|
|
|
uiMode = "classic";
|
|
|
|
}
|
|
|
|
else if(keyValue.equals(1, "tabbed") || keyValue.equals(1, "notebookbar"))
|
|
|
|
{
|
|
|
|
json.set("uiMode", "notebookbar");
|
|
|
|
uiMode = "notebookbar";
|
2020-12-07 02:02:06 -06:00
|
|
|
}
|
2020-06-03 11:46:42 -05:00
|
|
|
else
|
2021-02-17 07:50:48 -06:00
|
|
|
LOG_ERR("unknown UIMode value " << keyValue[1]);
|
2020-06-03 11:46:42 -05:00
|
|
|
|
|
|
|
continue;
|
|
|
|
}
|
2023-05-25 16:51:17 -05:00
|
|
|
|
|
|
|
// detect the UITheme default, light or dark
|
|
|
|
if (keyValue.equals(0, "UITheme"))
|
|
|
|
{
|
2023-10-26 09:19:21 -05:00
|
|
|
json.set("darkTheme", keyValue.equals(1, "dark"));
|
|
|
|
uiTheme = keyValue[1];
|
|
|
|
continue;
|
2023-05-25 16:51:17 -05:00
|
|
|
}
|
2023-10-30 17:35:17 -05:00
|
|
|
if (keyValue.equals(0, "SavedUIState"))
|
|
|
|
{
|
|
|
|
if (keyValue.equals(1, "false"))
|
|
|
|
{
|
|
|
|
savedUIState = "false";
|
|
|
|
}
|
|
|
|
else
|
|
|
|
{
|
|
|
|
if (!keyValue.equals(1, "true"))
|
|
|
|
{
|
|
|
|
LOG_ERR("unknown SavedUIState value " << keyValue[1]);
|
|
|
|
}
|
|
|
|
savedUIState = "true";
|
|
|
|
}
|
|
|
|
}
|
2022-09-06 12:12:07 -05:00
|
|
|
if (keyValue.equals(0, "SaveAsMode"))
|
|
|
|
{
|
|
|
|
if (keyValue.equals(1, "group"))
|
|
|
|
{
|
|
|
|
json.set("saveAsMode", "group");
|
|
|
|
}
|
|
|
|
continue;
|
Add a ui_defaults hint for touchscreens
At the moment, we try to detect whether the browser is running with a
touchscreen, however this is very imperfect. It's possible an integrator
may have more information about whether COOL is running on a device with
a touchscreen, so this ui_defaults option allows us to specify. Touch
mode binds inputs for touchscreen devices (long press for menu, pinch to
zoom, etc.) and does not bind the normal inputs (right click for menu,
etc.), so it's crucial to get it on all touch devices and no desktop
devices, as input is severely hampered if they are the wrong way round.
The option is called TouchscreenHint. Setting it to 'true' will enable
touchscreen mode, setting it to 'false' will disable touchscreen mode.
Leaving it undefined will keep our detection active.
This option must be set at page load so we can register the right
events at creation time. Therefore, ui_defaults is perfect as a method
to override this.
This is not a long-term solution. Instead, "The right thing" is to look
specifically for touch events and specifically for mouse events, rather
than using the default hammer.js behavior which is to look for both...
that should be an eventual followup to this. However, this was a lot
faster to implement and helps with the most pressing issue: not being
able to override our detection when it goes wrong.
Change-Id: Id28a156fe352fe6565ce6b472b7aa54d0869c48e
Signed-off-by: Skyler Grey <skyler.grey@collabora.com>
2023-11-10 09:20:02 -06:00
|
|
|
}
|
|
|
|
if (keyValue.equals(0, "TouchscreenHint"))
|
|
|
|
{
|
|
|
|
json.set("touchscreenHint", keyValue.equals(1, "true"));
|
|
|
|
continue;
|
2022-09-06 12:12:07 -05:00
|
|
|
}
|
2023-11-03 11:33:43 -05:00
|
|
|
if (keyValue.equals(0, "OnscreenKeyboardHint"))
|
|
|
|
{
|
|
|
|
json.set("onscreenKeyboardHint", keyValue.equals(1, "true"));
|
|
|
|
continue;
|
|
|
|
}
|
2021-10-14 06:38:45 -05:00
|
|
|
else if (keyValue.startsWith(0, "Text"))
|
2020-06-03 11:46:42 -05:00
|
|
|
{
|
|
|
|
currentDef = &textDefs;
|
|
|
|
key = keyValue[0].substr(4);
|
|
|
|
}
|
2021-10-14 06:38:45 -05:00
|
|
|
else if (keyValue.startsWith(0, "Spreadsheet"))
|
2020-06-03 11:46:42 -05:00
|
|
|
{
|
|
|
|
currentDef = &spreadsheetDefs;
|
|
|
|
key = keyValue[0].substr(11);
|
|
|
|
}
|
2021-10-14 06:38:45 -05:00
|
|
|
else if (keyValue.startsWith(0, "Presentation"))
|
2020-06-03 11:46:42 -05:00
|
|
|
{
|
|
|
|
currentDef = &presentationDefs;
|
|
|
|
key = keyValue[0].substr(12);
|
|
|
|
}
|
2021-10-22 11:07:37 -05:00
|
|
|
else if (Util::startsWith(keyValue[0], "Drawing"))
|
|
|
|
{
|
|
|
|
currentDef = &drawingDefs;
|
|
|
|
key = keyValue[0].substr(7);
|
|
|
|
}
|
2020-06-03 11:46:42 -05:00
|
|
|
else
|
|
|
|
{
|
2021-02-17 07:50:48 -06:00
|
|
|
LOG_ERR("unknown UI default's component " << keyValue[0]);
|
2020-06-03 11:46:42 -05:00
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
assert(currentDef);
|
|
|
|
|
|
|
|
// detect the actual UI widget we want to hide or show
|
2023-11-07 14:15:06 -06:00
|
|
|
if (key == "Ruler" || key == "Sidebar" || key == "Statusbar" || key == "Toolbar")
|
2020-06-03 11:46:42 -05:00
|
|
|
{
|
|
|
|
bool value(true);
|
2021-10-18 09:41:12 -05:00
|
|
|
if (keyValue.equals(1, "false") || keyValue.equals(1, "False") || keyValue.equals(1, "0"))
|
2020-06-03 11:46:42 -05:00
|
|
|
value = false;
|
|
|
|
|
|
|
|
currentDef->set("Show" + key, value);
|
|
|
|
}
|
|
|
|
else
|
|
|
|
{
|
2021-02-17 07:50:48 -06:00
|
|
|
LOG_ERR("unknown UI default " << keyValue[0]);
|
2020-06-03 11:46:42 -05:00
|
|
|
continue;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (textDefs.size() > 0)
|
|
|
|
json.set("text", textDefs);
|
|
|
|
|
|
|
|
if (spreadsheetDefs.size() > 0)
|
|
|
|
json.set("spreadsheet", spreadsheetDefs);
|
|
|
|
|
|
|
|
if (presentationDefs.size() > 0)
|
|
|
|
json.set("presentation", presentationDefs);
|
|
|
|
|
2021-10-22 11:07:37 -05:00
|
|
|
if (drawingDefs.size() > 0)
|
|
|
|
json.set("drawing", drawingDefs);
|
|
|
|
|
2020-06-03 11:46:42 -05:00
|
|
|
std::ostringstream oss;
|
|
|
|
Poco::JSON::Stringifier::stringify(json, oss);
|
|
|
|
|
|
|
|
previousUIDefaults = uiDefaults;
|
|
|
|
previousJSON = oss.str();
|
2020-12-07 02:02:06 -06:00
|
|
|
previousUIMode = uiMode;
|
2020-06-03 11:46:42 -05:00
|
|
|
|
|
|
|
return previousJSON;
|
|
|
|
}
|
|
|
|
|
2022-10-10 10:21:27 -05:00
|
|
|
std::string FileServerRequestHandler::checkFileInfoToJSON(const std::string& checkfileInfo)
|
|
|
|
{
|
|
|
|
static std::string previousCheckFileInfo;
|
|
|
|
static std::string previousCheckFileInfoJSON("{}");
|
|
|
|
|
|
|
|
// early exit if we are serving the same thing
|
|
|
|
if (checkfileInfo == previousCheckFileInfo)
|
|
|
|
return previousCheckFileInfoJSON;
|
|
|
|
|
|
|
|
Poco::JSON::Object json;
|
|
|
|
StringVector tokens(StringVector::tokenize(checkfileInfo, ';'));
|
|
|
|
for (const auto& token : tokens)
|
|
|
|
{
|
|
|
|
StringVector keyValue(StringVector::tokenize(tokens.getParam(token), '='));
|
|
|
|
if (keyValue.equals(0, "DownloadAsPostMessage"))
|
|
|
|
{
|
|
|
|
bool value(false);
|
|
|
|
if (keyValue.equals(1, "true") || keyValue.equals(1, "True") || keyValue.equals(1, "1"))
|
|
|
|
value = true;
|
|
|
|
json.set(keyValue[0], value);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
std::ostringstream oss;
|
|
|
|
Poco::JSON::Stringifier::stringify(json, oss);
|
|
|
|
previousCheckFileInfo = checkfileInfo;
|
|
|
|
previousCheckFileInfoJSON = oss.str();
|
|
|
|
return previousCheckFileInfoJSON;
|
|
|
|
}
|
|
|
|
|
2021-06-30 09:51:50 -05:00
|
|
|
namespace
|
|
|
|
{
|
|
|
|
bool isValidCss(const std::string& token)
|
|
|
|
{
|
|
|
|
const std::string forbidden = "<>{}&|\\\"^`'$[]";
|
|
|
|
for (auto c: token)
|
|
|
|
{
|
|
|
|
if (c < 0x20 || c >= 0x7F || forbidden.find(c) != std::string::npos)
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-10-22 06:08:11 -05:00
|
|
|
std::string FileServerRequestHandler::cssVarsToStyle(const std::string& cssVars)
|
|
|
|
{
|
|
|
|
static std::string previousVars;
|
2020-12-03 20:14:30 -06:00
|
|
|
static std::string previousStyle;
|
2020-10-22 06:08:11 -05:00
|
|
|
|
|
|
|
// early exit if we are serving the same thing
|
|
|
|
if (cssVars == previousVars)
|
|
|
|
return previousStyle;
|
|
|
|
|
|
|
|
std::ostringstream styleOSS;
|
|
|
|
styleOSS << "<style>:root {";
|
2022-03-29 20:37:57 -05:00
|
|
|
StringVector tokens(StringVector::tokenize(cssVars, ';'));
|
2020-10-22 06:08:11 -05:00
|
|
|
for (const auto& token : tokens)
|
|
|
|
{
|
2022-03-29 20:37:57 -05:00
|
|
|
StringVector keyValue(StringVector::tokenize(tokens.getParam(token), '='));
|
2020-10-22 06:08:11 -05:00
|
|
|
if (keyValue.size() < 2)
|
|
|
|
{
|
2021-02-17 07:50:48 -06:00
|
|
|
LOG_ERR("Skipping the token [" << tokens.getParam(token) << "] since it does not have '='");
|
2020-10-22 06:08:11 -05:00
|
|
|
continue;
|
|
|
|
}
|
|
|
|
else if (keyValue.size() > 2)
|
|
|
|
{
|
2021-02-17 07:50:48 -06:00
|
|
|
LOG_ERR("Skipping the token [" << tokens.getParam(token) << "] since it has more than one '=' pair");
|
2020-10-22 06:08:11 -05:00
|
|
|
continue;
|
|
|
|
}
|
2021-06-30 09:51:50 -05:00
|
|
|
|
|
|
|
if (!isValidCss(tokens.getParam(token)))
|
|
|
|
{
|
|
|
|
LOG_WRN("Skipping the token [" << tokens.getParam(token) << "] since it contains forbidden characters");
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
2020-12-06 22:01:18 -06:00
|
|
|
styleOSS << keyValue[0] << ':' << keyValue[1] << ';';
|
2020-10-22 06:08:11 -05:00
|
|
|
}
|
|
|
|
styleOSS << "}</style>";
|
|
|
|
|
|
|
|
previousVars = cssVars;
|
|
|
|
previousStyle = styleOSS.str();
|
|
|
|
|
|
|
|
return previousStyle;
|
|
|
|
}
|
|
|
|
|
2021-12-07 04:04:14 -06:00
|
|
|
std::string FileServerRequestHandler::stringifyBoolFromConfig(
|
|
|
|
const Poco::Util::LayeredConfiguration& config,
|
|
|
|
std::string propertyName,
|
|
|
|
bool defaultValue)
|
|
|
|
{
|
|
|
|
std::string value = "false";
|
|
|
|
if (config.getBool(propertyName, defaultValue))
|
|
|
|
value = "true";
|
|
|
|
return value;
|
|
|
|
}
|
|
|
|
|
2020-06-03 11:46:42 -05:00
|
|
|
/* vim:set shiftwidth=4 softtabstop=4 expandtab: */
|