feat(export): sanitize CSV filename + Open-folder button
The transactions CSV export now sanitizes the typed filename (basename only, illegal chars → '_', no leading dots/trailing dots-or-spaces, length-capped, always .csv) so it can't be pointed outside the config dir, and the success screen gets an "Open folder" button. (All-vs-filter / date-range export scope is left for a follow-up — it needs tx-tab filter-snapshot plumbing.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,8 @@
|
||||
|
||||
#include <string>
|
||||
#include <ctime>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
@@ -28,6 +30,31 @@ static char s_filename[256] = "";
|
||||
static std::string s_saved_path; // full path on success (shown as a copy field); empty otherwise
|
||||
static std::string s_error; // error message on failure; empty otherwise
|
||||
|
||||
// Make a user-typed filename safe to write into the config dir: basename only, illegal chars → '_',
|
||||
// no leading dots / trailing dots-or-spaces, length-capped, always ending in .csv.
|
||||
static std::string sanitizeExportFilename(std::string name) {
|
||||
const size_t slash = name.find_last_of("/\\");
|
||||
if (slash != std::string::npos) name = name.substr(slash + 1);
|
||||
auto notspace = [](unsigned char c) { return !std::isspace(c); };
|
||||
name.erase(name.begin(), std::find_if(name.begin(), name.end(), notspace));
|
||||
name.erase(std::find_if(name.rbegin(), name.rend(), notspace).base(), name.end());
|
||||
for (char& c : name) {
|
||||
const unsigned char u = static_cast<unsigned char>(c);
|
||||
if (u < 0x20 || c == '<' || c == '>' || c == ':' || c == '"' || c == '|' || c == '?' || c == '*')
|
||||
c = '_';
|
||||
}
|
||||
const size_t firstGood = name.find_first_not_of('.');
|
||||
if (firstGood == std::string::npos) name.clear(); else name.erase(0, firstGood);
|
||||
while (!name.empty() && (name.back() == '.' || name.back() == ' ')) name.pop_back();
|
||||
if (name.size() > 120) name.resize(120);
|
||||
std::string lower = name;
|
||||
std::transform(lower.begin(), lower.end(), lower.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
if (lower.size() < 4 || lower.compare(lower.size() - 4, 4, ".csv") != 0) name += ".csv";
|
||||
if (name.empty() || name == ".csv") name = "dragonx_transactions.csv";
|
||||
return name;
|
||||
}
|
||||
|
||||
void ExportTransactionsDialog::show()
|
||||
{
|
||||
s_open = true;
|
||||
@@ -94,6 +121,8 @@ void ExportTransactionsDialog::render(App* app)
|
||||
ImGui::SameLine(0, 4.0f * Layout::dpiScale());
|
||||
ImGui::TextColored(m::SuccessVec4(), "%s", TR("export_tx_success"));
|
||||
widgets::AddressCopyField("##exportedpath", s_saved_path);
|
||||
if (m::TactileButton(TR("export_tx_open_folder"), ImVec2(0, 0)))
|
||||
util::Platform::openFolder(util::Platform::getConfigDir());
|
||||
} else if (!s_error.empty()) {
|
||||
ImGui::Spacing();
|
||||
ImGui::PushTextWrapPos(0.0f);
|
||||
@@ -113,7 +142,11 @@ void ExportTransactionsDialog::render(App* app)
|
||||
Notifications::instance().warning(TR("export_tx_none"));
|
||||
} else {
|
||||
std::string configDir = util::Platform::getConfigDir();
|
||||
std::string filepath = configDir + "/" + s_filename;
|
||||
// Sanitize in-buffer so the user sees exactly what was written (and we can't be pointed outside
|
||||
// the config dir by a pasted path).
|
||||
std::string safeName = sanitizeExportFilename(s_filename);
|
||||
snprintf(s_filename, sizeof(s_filename), "%s", safeName.c_str());
|
||||
std::string filepath = configDir + "/" + safeName;
|
||||
// Build in memory, then write atomically (temp + fsync + rename): a disk-full /
|
||||
// removable-media failure is reported instead of leaving a truncated CSV behind a success toast.
|
||||
const std::string csv = data::formatTransactionsCsv(state.transactions);
|
||||
|
||||
Reference in New Issue
Block a user