Skip to content

Commit 9d3fe04

Browse files
committed
feat: stabilize local dev paths with ContentRoot-based resolution
- Add MotelyPaths static resolver using ASP.NET Core ContentRootPath - Set ContentRoot to repo root in MotelyApiHost for consistent paths - Fix hardcoded Windows path in Endpoints.GetFilters() - Fix Filters/JamlFilters directory mismatch in FilterService - Fix WordLists/SeedSources directory mismatch in Endpoints - Replace all relative path usages with MotelyPaths properties - Add path traversal protection in FilterService.GetFilterJaml() - Support config overrides via Motely:Paths section - Add LOCAL_DEV.md documentation for path configuration Resolves cross-platform path issues on macOS/Linux/Windows
1 parent e2b10a2 commit 9d3fe04

File tree

6 files changed

+137
-25
lines changed

6 files changed

+137
-25
lines changed

Motely.API/Endpoints.cs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@ public static IResult GetFilters()
1414
{
1515
try
1616
{
17-
var fullPath = @"X:\BalatroSeedOracle\external\Motely\JamlFilters";
18-
var filters = FilterService.LoadFiltersFromDisk(fullPath, cfg => false);
17+
var filters = FilterService.LoadFiltersFromDisk(MotelyPaths.JamlFiltersDir, cfg => false);
1918
return Results.Ok(filters);
2019
}
2120
catch (Exception ex)
@@ -31,9 +30,10 @@ public static IResult GetSeedSources()
3130
new { key = "all", label = "All Seeds", kind = "builtin" }
3231
};
3332

34-
if (Directory.Exists("WordLists"))
33+
var seedSourcesDir = MotelyPaths.SeedSourcesDir;
34+
if (Directory.Exists(seedSourcesDir))
3535
{
36-
foreach (var file in Directory.GetFiles("WordLists", "*.*")
36+
foreach (var file in Directory.GetFiles(seedSourcesDir, "*.*")
3737
.Where(f => f.EndsWith(".db") || f.EndsWith(".txt") || f.EndsWith(".csv"))
3838
.Select(Path.GetFileName)
3939
.Where(f => f != null)
@@ -112,7 +112,8 @@ public static async Task<IResult> SaveFilter(string id, HttpRequest req)
112112
var request = await req.ReadFromJsonAsync<FilterSaveRequest>();
113113
if (request?.FilterJaml == null) return Results.BadRequest();
114114

115-
Directory.CreateDirectory("JamlFilters");
115+
var jamlFiltersDir = MotelyPaths.JamlFiltersDir;
116+
Directory.CreateDirectory(jamlFiltersDir);
116117

117118
// Use id from route, or extract name from JAML
118119
string? name = id;
@@ -122,7 +123,7 @@ public static async Task<IResult> SaveFilter(string id, HttpRequest req)
122123
}
123124

124125
var fileName = $"{name}.jaml";
125-
var fullPath = Path.Combine("JamlFilters", fileName);
126+
var fullPath = Path.Combine(jamlFiltersDir, fileName);
126127
File.WriteAllText(fullPath, request.FilterJaml);
127128

128129
return Results.Ok(new { filePath = fileName });
@@ -131,7 +132,7 @@ public static async Task<IResult> SaveFilter(string id, HttpRequest req)
131132
public static IResult DeleteFilter(string id)
132133
{
133134
var safeName = Path.GetFileName(id);
134-
var fullPath = Path.Combine("JamlFilters", safeName);
135+
var fullPath = Path.Combine(MotelyPaths.JamlFiltersDir, safeName);
135136

136137
if (File.Exists(fullPath))
137138
{

Motely.API/MotelyApiHost.cs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ public static WebApplication CreateHost(string[] args)
4747
{
4848
var builder = WebApplication.CreateBuilder(args);
4949

50+
// Set ContentRoot to repo root for consistent path resolution
51+
var motelyRoot = FindMotelyRoot();
52+
if (!string.IsNullOrEmpty(motelyRoot))
53+
{
54+
builder.Host.UseContentRoot(motelyRoot);
55+
}
56+
5057
builder.Services.AddEndpointsApiExplorer();
5158
builder.Services.AddCors(options =>
5259
{
@@ -96,12 +103,14 @@ public static WebApplication CreateHost(string[] args)
96103

97104
var app = builder.Build();
98105

99-
// Initialize SearchManager with motely root path
100-
// Find the root directory by looking for JamlFilters folder
101-
var motelyRoot = FindMotelyRoot();
102-
if (!string.IsNullOrEmpty(motelyRoot))
106+
// Initialize MotelyPaths with ContentRoot and configuration
107+
MotelyPaths.Initialize(app.Environment, app.Configuration);
108+
109+
// Initialize SearchManager with motely root path (for SaveFilterToEcosystem compatibility)
110+
var motelyRootForSearchManager = FindMotelyRoot();
111+
if (!string.IsNullOrEmpty(motelyRootForSearchManager))
103112
{
104-
SearchManager.Instance.SetMotelyRoot(motelyRoot);
113+
SearchManager.Instance.SetMotelyRoot(motelyRootForSearchManager);
105114
}
106115

107116
// Wire up SearchBroadcaster to SearchManager

Motely.API/MotelyPaths.cs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
using Microsoft.AspNetCore.Hosting;
2+
using Microsoft.Extensions.Configuration;
3+
4+
namespace Motely.API;
5+
6+
/// <summary>
7+
/// Centralized path resolver for Motely directories.
8+
/// Uses ASP.NET Core ContentRootPath as the base, with optional config overrides.
9+
/// </summary>
10+
public static class MotelyPaths
11+
{
12+
private static string _contentRoot = Directory.GetCurrentDirectory();
13+
private static string? _jamlFiltersOverride;
14+
private static string? _seedSourcesOverride;
15+
private static string? _searchResultsOverride;
16+
17+
/// <summary>
18+
/// Gets the content root path (typically the repo root).
19+
/// </summary>
20+
public static string ContentRoot => _contentRoot;
21+
22+
/// <summary>
23+
/// Gets the directory for JAML filter files.
24+
/// Defaults to &lt;ContentRoot&gt;/JamlFilters, can be overridden via config.
25+
/// </summary>
26+
public static string JamlFiltersDir => ResolvePath(_jamlFiltersOverride, "JamlFilters");
27+
28+
/// <summary>
29+
/// Gets the directory for seed source files (txt, csv, db).
30+
/// Defaults to &lt;ContentRoot&gt;/SeedSources, can be overridden via config.
31+
/// </summary>
32+
public static string SeedSourcesDir => ResolvePath(_seedSourcesOverride, "SeedSources");
33+
34+
/// <summary>
35+
/// Gets the directory for search result databases and metadata.
36+
/// Defaults to &lt;ContentRoot&gt;/SearchResults, can be overridden via config.
37+
/// </summary>
38+
public static string SearchResultsDir => ResolvePath(_searchResultsOverride, "SearchResults");
39+
40+
/// <summary>
41+
/// Initializes MotelyPaths with the web host environment and configuration.
42+
/// Should be called once at application startup.
43+
/// </summary>
44+
public static void Initialize(IWebHostEnvironment env, IConfiguration? config = null)
45+
{
46+
_contentRoot = env.ContentRootPath;
47+
48+
if (config != null)
49+
{
50+
_jamlFiltersOverride = config["Motely:Paths:JamlFiltersDir"];
51+
_seedSourcesOverride = config["Motely:Paths:SeedSourcesDir"];
52+
_searchResultsOverride = config["Motely:Paths:SearchResultsDir"];
53+
}
54+
55+
// Ensure directories exist
56+
Directory.CreateDirectory(JamlFiltersDir);
57+
Directory.CreateDirectory(SeedSourcesDir);
58+
Directory.CreateDirectory(SearchResultsDir);
59+
}
60+
61+
/// <summary>
62+
/// Resolves a path: if override is provided and is absolute, use it;
63+
/// if override is relative, combine with ContentRoot;
64+
/// otherwise use default relative to ContentRoot.
65+
/// </summary>
66+
private static string ResolvePath(string? overridePath, string defaultSubDir)
67+
{
68+
if (!string.IsNullOrWhiteSpace(overridePath))
69+
{
70+
// If override is an absolute path, use it as-is
71+
if (Path.IsPathRooted(overridePath))
72+
{
73+
return overridePath;
74+
}
75+
// If override is relative, combine with ContentRoot
76+
return Path.Combine(_contentRoot, overridePath);
77+
}
78+
79+
// Default: combine ContentRoot with default subdirectory
80+
return Path.Combine(_contentRoot, defaultSubDir);
81+
}
82+
}

Motely.API/SearchManager.cs

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,8 @@ public class SearchManager
2222

2323
private readonly ConcurrentDictionary<string, ActiveSearch> _activeSearches = new();
2424
private readonly ConcurrentDictionary<string, string> _lastErrors = new(StringComparer.OrdinalIgnoreCase);
25-
private static readonly string _searchResultsDir = "SearchResults";
2625

27-
public string GetSearchResultsDir() => _searchResultsDir;
26+
public string GetSearchResultsDir() => MotelyPaths.SearchResultsDir;
2827

2928
private string? _motelyRoot;
3029

@@ -115,7 +114,7 @@ public class ActiveSearch
115114
await _lifecycleGate.WaitAsync();
116115
try
117116
{
118-
Directory.CreateDirectory(_searchResultsDir);
117+
Directory.CreateDirectory(MotelyPaths.SearchResultsDir);
119118

120119
if (!JamlConfigLoader.TryLoadFromJamlString(filterJaml, out var config, out var error) || config == null)
121120
throw new ArgumentException(error ?? "Invalid filter");
@@ -126,7 +125,7 @@ public class ActiveSearch
126125
var filterName = GetFilterName(filterJaml);
127126
var sanitizedName = SanitizeFilterFileStem(filterName);
128127
var searchId = $"{sanitizedName}_{deck}_{stake}";
129-
var dbPath = Path.Combine(_searchResultsDir, $"{searchId}.db");
128+
var dbPath = Path.Combine(MotelyPaths.SearchResultsDir, $"{searchId}.db");
130129

131130
_lastErrors.TryRemove(searchId, out _);
132131

@@ -175,7 +174,7 @@ public class ActiveSearch
175174
search.Database = new MotelySearchDatabase(dbPath, columnNames);
176175

177176
// Save JAML metadata file so it can be retrieved even after search stops
178-
var jamlPath = Path.Combine(_searchResultsDir, $"{searchId}.jaml");
177+
var jamlPath = Path.Combine(MotelyPaths.SearchResultsDir, $"{searchId}.jaml");
179178
File.WriteAllText(jamlPath, filterJaml);
180179

181180
// Automatically save filter to JamlFilters ecosystem
@@ -772,7 +771,7 @@ public async Task<List<SearchResult>> StopSearchAsync(string searchId)
772771
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
773772
_broadcaster?.Broadcast(JsonSerializer.Serialize(new { type = "filters_changed" }, options));
774773

775-
var dbPath = Path.Combine(_searchResultsDir, $"{searchId}.db");
774+
var dbPath = Path.Combine(MotelyPaths.SearchResultsDir, $"{searchId}.db");
776775
return GetTopResultsFromDb(dbPath, 1000);
777776
}
778777
finally
@@ -820,9 +819,9 @@ public async Task ClearAllSearchesAsync()
820819
}
821820

822821
// Clear all stored results by deleting all database files
823-
if (Directory.Exists(_searchResultsDir))
822+
if (Directory.Exists(MotelyPaths.SearchResultsDir))
824823
{
825-
var dbFiles = Directory.GetFiles(_searchResultsDir, "*.db");
824+
var dbFiles = Directory.GetFiles(MotelyPaths.SearchResultsDir, "*.db");
826825
foreach (var file in dbFiles)
827826
{
828827
try
@@ -901,7 +900,7 @@ private async Task<bool> StopSearchInternalAsync(ActiveSearch search, string rea
901900
try
902901
{
903902
var dbPath = search.Database?.DatabasePath
904-
?? Path.Combine(_searchResultsDir, $"{search.SearchId}.db");
903+
?? Path.Combine(MotelyPaths.SearchResultsDir, $"{search.SearchId}.db");
905904
await ExportTopResultsToFertilizerAsync(dbPath, limit: 1000);
906905
}
907906
catch (Exception ex)
@@ -1028,7 +1027,7 @@ private void ApplySeedSource(JsonSearchParams searchParams, string? seedSource)
10281027
else
10291028
{
10301029
// Relative path - look in SeedSources folder
1031-
var relativePath = Path.Combine("SeedSources", safeName);
1030+
var relativePath = Path.Combine(MotelyPaths.SeedSourcesDir, safeName);
10321031
if (File.Exists(relativePath))
10331032
{
10341033
csvPath = relativePath;
@@ -1065,7 +1064,7 @@ private void ApplySeedSource(JsonSearchParams searchParams, string? seedSource)
10651064
else
10661065
{
10671066
// Relative path - look in SeedSources folder
1068-
var relativePath = Path.Combine("SeedSources", safeName);
1067+
var relativePath = Path.Combine(MotelyPaths.SeedSourcesDir, safeName);
10691068
if (File.Exists(relativePath))
10701069
{
10711070
searchParams.SeedSources = relativePath;

Motely.API/Services/FilterService.cs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Text.Json;
22
using Motely;
3+
using Motely.API;
34

45
namespace Motely.API.Services;
56

@@ -9,8 +10,21 @@ public static string GetFilterJaml(string? filterId)
910
{
1011
if (string.IsNullOrEmpty(filterId))
1112
return string.Empty;
12-
13-
var filterPath = Path.Combine("Filters", $"{filterId}.jaml");
13+
14+
// Sanitize filterId to prevent path traversal attacks
15+
// Extract just the filename stem (no path separators, no extension)
16+
var safeName = Path.GetFileNameWithoutExtension(filterId);
17+
if (string.IsNullOrWhiteSpace(safeName))
18+
return string.Empty;
19+
20+
// Remove any remaining path separators or invalid characters
21+
var invalidChars = Path.GetInvalidFileNameChars();
22+
foreach (var c in invalidChars)
23+
{
24+
safeName = safeName.Replace(c, '_');
25+
}
26+
27+
var filterPath = Path.Combine(MotelyPaths.JamlFiltersDir, $"{safeName}.jaml");
1428
if (!File.Exists(filterPath))
1529
return string.Empty;
1630

Motely.API/appsettings.example.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,12 @@
2020
"Bucket": "balatro-seed-sources",
2121
"Enabled": true
2222
}
23+
},
24+
"Motely": {
25+
"Paths": {
26+
"JamlFiltersDir": null,
27+
"SeedSourcesDir": null,
28+
"SearchResultsDir": null
29+
}
2330
}
2431
}

0 commit comments

Comments
 (0)