diff --git a/src/ui/windows/export_transactions_dialog.cpp b/src/ui/windows/export_transactions_dialog.cpp index 5172037..899f2a2 100644 --- a/src/ui/windows/export_transactions_dialog.cpp +++ b/src/ui/windows/export_transactions_dialog.cpp @@ -18,6 +18,8 @@ #include #include +#include +#include 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(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(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);