commit 63bcdf003d8c9733c7cf1fd72c78bb64fedf5725 Author: Andre Beging Date: Thu Dec 11 16:05:31 2025 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..240595a --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Build outputs +bin/ +obj/ +**/bin/ +**/obj/ +out/ + +# Visual Studio +.vs/ +*.user +*.rsuser +*.suo +*.userosscache +*.sln.docstates +*.userprefs +*.csproj.user +*.vbproj.user +[Test]Results*/ +*.vsp +*.vspx +*.appx + +# NuGet +*.nupkg +*.snupkg +.nuget/packages/ + +# Logs and diagnostics +*.log +*.trx +*.coverage +*.coveragexml + +# Rider +.idea/ +*.sln.iml + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# OS artifacts +.DS_Store +Thumbs.db diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..b2b07ab --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": ".NET Debug", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/bin/Debug/net10.0/FsTool.dll", + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "stopAtEntry": false + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..31c32bd --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,24 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "shell", + "args": [ + "build", + // Ask dotnet build to generate full paths for file names. + "/property:GenerateFullPaths=true", + // Do not generate summary otherwise it leads to duplicate errors in Problems panel + "/consoleloggerparameters:NoSummary" + ], + "group": "build", + "presentation": { + "reveal": "silent" + }, + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/AuthHelper.cs b/AuthHelper.cs new file mode 100644 index 0000000..99e1448 --- /dev/null +++ b/AuthHelper.cs @@ -0,0 +1,125 @@ +using System.Dynamic; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; + +namespace FsTool; + +public static class AuthHelper +{ + private static async Task LoadCsrfTokenAsync() + { + if (!File.Exists("csrf_token.json")) return null; + + var json = await File.ReadAllTextAsync("csrf_token.json"); + var tokenInfo = JsonSerializer.Deserialize(json); + + if (tokenInfo == null) return null; + + var token = tokenInfo["token"]?.GetValue(); + var expiresAtString = tokenInfo["expiresAt"]?.GetValue(); + + if (string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(expiresAtString)) return null; + + if (DateTime.TryParse(expiresAtString, out var expiresAt)) + { + if (DateTime.UtcNow < expiresAt.ToUniversalTime()) + return token; + } + + return null; + } + + private static async Task StoreCsrfTokenAsync(string? csrfToken, DateTime expiration) + { + if (string.IsNullOrWhiteSpace(csrfToken)) return; + + var tokenInfo = new + { + token = csrfToken, + expiresAt = expiration + }; + + var json = JsonSerializer.Serialize(tokenInfo, new JsonSerializerOptions + { + WriteIndented = true + }); + + await File.WriteAllTextAsync("csrf_token.json", json); + } + + public static async Task EnsureAuthenticationAsync(HttpClient httpClient) + { + var csrfToken = await LoadCsrfTokenAsync(); + csrfToken = null; + + if (string.IsNullOrWhiteSpace(csrfToken)) + csrfToken = await CallLoginEndpointAsync(httpClient); + + if (!string.IsNullOrWhiteSpace(csrfToken)) + { + csrfToken = csrfToken.ReplaceLineEndings(string.Empty); + + httpClient.DefaultRequestHeaders.Remove("X-CSRF-Token"); + httpClient.DefaultRequestHeaders.Add("X-CSRF-Token", csrfToken); + } + } + + private static async Task CallLoginEndpointAsync(HttpClient httpClient) + { + string? csrfToken = null; + + // prompt for 2FA code + Console.Write("Enter 2FA code: "); + var authCode = Console.ReadLine()?.Trim(); + + // validate input + if (string.IsNullOrWhiteSpace(authCode)) + { + Console.WriteLine("A valid 2FA code is required."); + return null; + } + + var payload = new + { + email = "foodsharing@beging.de", + password = "z+hc@Ox9Zu4~MXzkB:Z@O.-S1AvsT&mc!oyQA?NK1jckl1Dzi2^-)+.H.AJKBLoi", + code = authCode, + remember_me = true + }; + var response = await httpClient.PostAsJsonAsync(Endpoints.Login, payload); + + // handle unsuccessful response + if (!response.IsSuccessStatusCode) + { + var responseBody = await response.Content.ReadAsStringAsync(); + await Console.Error.WriteLineAsync($"Login failed ({(int)response.StatusCode} {response.ReasonPhrase}): {responseBody}"); + return null; + } + + // Read headers as dictionary + var headers = response.Headers.ToDictionary(h => h.Key, h => string.Join(", ", h.Value)); + if(headers.TryGetValue("Set-Cookie", out var setCookieHeader)) + { + // Split cookies by comma and semicolon + var cookies = setCookieHeader.Split(new[] { ';' }); + var csrfTokenEntry = cookies.FirstOrDefault(c => c.Trim().StartsWith("FS_CSRF_TOKEN=")); + csrfToken = csrfTokenEntry?.Split('=')[1]; + + if (string.IsNullOrWhiteSpace(csrfToken)) return null; + + var expiryEntry = cookies.FirstOrDefault(c => c.Trim().StartsWith("expires=")); + var expireString = expiryEntry?.Split('=')[1]; + if (DateTime.TryParse(expireString, out var expiration)) + await StoreCsrfTokenAsync(csrfToken, expiration); + } + + return csrfToken; + } +} \ No newline at end of file diff --git a/CustomTasks.cs b/CustomTasks.cs new file mode 100644 index 0000000..1ba5152 --- /dev/null +++ b/CustomTasks.cs @@ -0,0 +1,114 @@ +using System.Text; + +namespace FsTool +{ + public class CustomTasks + { + #region Public Method GetUnconfirmedPickupsLindenbergAsync + + public static async Task ConfirmUnconfirmedPickupsLindenbergAsync(HttpClient httpClient) + { + await AuthHelper.EnsureAuthenticationAsync(httpClient); + + var toConfirm = new List<(string Name, string Date, int User)>(); + + // Collect unconfirmed slots + var pickups = await StoreTasks.GetPickupsAsync(httpClient, 56749); + foreach (var pickup in pickups) + foreach (var slot in pickup.OccupiedSlots) + if (!slot.IsConfirmed) + { + var pickupDate = pickup.Date; + var userId = slot.Profile.Id; + var userName = slot.Profile.Name; + + toConfirm.Add((userName, pickupDate, userId)); + } + + if (toConfirm.Count != 0) + { + toConfirm.ForEach(x => Console.WriteLine($"Slot found: {x.Name} on {x.Date}")); + } + else + { + Console.Write("No unconfirmed slots found."); + return; + } + + // Confirm question + Console.Write("Confirm all unconfirmed slots? (y/n) (Enter for default): "); + var input = Console.ReadLine(); + if (string.IsNullOrEmpty(input) || input.Equals("y", StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine("Confirming unconfirmed slots..."); + + // Confirm the unconfirmed slots + foreach (var confirmEntry in toConfirm) + await StoreTasks.PatchPickupAsync(httpClient, 52170, confirmEntry.Date, confirmEntry.User); + + Console.WriteLine("done."); + } + } + + #endregion + + private record AldiMember(RegionTasks.Store Store, StoreTasks.Member Member); + + public static async Task CheckAldiMembershipsAsync(HttpClient httpClient) + { + await AuthHelper.EnsureAuthenticationAsync(httpClient); + + // Implementation for checking Aldi memberships would go here. + var stores = await RegionTasks.GetStoresInRegionAsync(httpClient, 139); + var activeAldis = stores.Where(x => x.CooperationStatus == RegionTasks.CooperationStatus.Cooperating && x.Name.Contains("ALDI", StringComparison.CurrentCultureIgnoreCase)).ToList(); + + Console.WriteLine("Found " + activeAldis.Count + " active ALDI stores in region Siegen."); + + var aldiMembers = new List(); + + // Collect members from each active ALDI store + foreach (var activeAldi in activeAldis) + { + Thread.Sleep(200); + Console.WriteLine("Checking members for store: " + activeAldi.Name); + + var members = await StoreTasks.GetStoreMembersAsync(httpClient, activeAldi.Id); + var activeMembers = members.Where(x => x is { Verified: StoreTasks.VerifiedStatus.Verified, Team_Active: StoreTasks.TeamActiveStatus.Active }).ToList(); + + activeMembers.ForEach(x => aldiMembers.Add(new(activeAldi, x))); + } + + // Group memberships + var grouped = aldiMembers.GroupBy(x => x.Member.Id); + + // Find groups with more than two memberships + var multipleMemberships = grouped.Where(g => g.Count() > 2).OrderByDescending(g => g.Count()).ToList(); + + + Console.WriteLine($"Users with more than two ALDI store memberships: {multipleMemberships.Count}"); + Console.WriteLine($"Saving data to file."); + + var sb = new StringBuilder(); + sb.AppendLine("ALDI Store Memberships Report"); + sb.AppendLine("Generated: " + DateTime.Now.ToString("dd.MM.yyyy HH:mm")); + sb.AppendLine("============================"); + sb.AppendLine(); + + foreach (var group in multipleMemberships) + { + sb.AppendLine($"{group.First().Member.Name} ({group.Key}) - {group.Count()} ALDIs"); + foreach (var membership in group) + { + sb.AppendLine($" - {membership.Store.Name}"); + } + sb.AppendLine(); + } + + // Write to file with timestamp + var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + var filename = $"AldiMemberships_{timestamp}.txt"; + await File.WriteAllTextAsync(filename, sb.ToString()); + } + + } +} \ No newline at end of file diff --git a/Endpoints.cs b/Endpoints.cs new file mode 100644 index 0000000..f2a5c59 --- /dev/null +++ b/Endpoints.cs @@ -0,0 +1,17 @@ +namespace FsTool +{ + public static class Endpoints + { + public const string Login = "https://beta.foodsharing.de/api/user/login"; + + public const string UserCurrent = "https://beta.foodsharing.de/api/user/current"; + + public const string StorePickups = "https://beta.foodsharing.de/api/stores/{0}/pickups"; + + public const string StoreMembers = "https://beta.foodsharing.de/api/stores/{0}/member"; + + public const string StorePickupsSlot = "https://beta.foodsharing.de/api/stores/{0}/pickups/{1}/{2}"; + + public const string RegionStores = "https://beta.foodsharing.de/api/region/{0}/stores"; + } +} \ No newline at end of file diff --git a/Extensions.cs b/Extensions.cs new file mode 100644 index 0000000..79e583c --- /dev/null +++ b/Extensions.cs @@ -0,0 +1,41 @@ +using System.Text; +using System.Text.Json.Nodes; + +namespace FsTool +{ + public static class Extensions + { + #region Public Method FsPostAsync + + /// + /// Sends a POST request to the specified URI with the provided JSON content. + /// + /// The instance of used to send the request. + /// The URI to which the request is sent. + /// The JSON object to include in the request body. + /// A task that represents the asynchronous operation. The task result contains the HTTP response. + public static async Task FsPostAsync(this HttpClient httpClient, string requestUri, JsonObject jsonObject) + { + var content = new StringContent(jsonObject.ToString(), Encoding.UTF8, "application/json"); + content.Headers.ContentType = new("application/json"); + return await httpClient.PostAsync(requestUri, content); + } + + #endregion + + #region Public Method ToList + + /// + /// Converts the specified JSON node into a list of non-null elements. + /// + /// The to be converted. If null, an empty list is returned. + /// A list of instances containing non-null elements. + public static List ToList(this JsonNode? node) + { + var array = node?.AsArray() ?? []; + return array.Where(x => x != null).Select(x => x!).ToList(); + } + + #endregion + } +} \ No newline at end of file diff --git a/FsTool.csproj b/FsTool.csproj new file mode 100644 index 0000000..ed9781c --- /dev/null +++ b/FsTool.csproj @@ -0,0 +1,10 @@ + + + + Exe + net10.0 + enable + enable + + + diff --git a/FsTool.sln b/FsTool.sln new file mode 100644 index 0000000..d6a2b58 --- /dev/null +++ b/FsTool.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FsTool", "FsTool.csproj", "{D8D12A22-90E0-56DB-D8E0-629AC330F42B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D8D12A22-90E0-56DB-D8E0-629AC330F42B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D8D12A22-90E0-56DB-D8E0-629AC330F42B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D8D12A22-90E0-56DB-D8E0-629AC330F42B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D8D12A22-90E0-56DB-D8E0-629AC330F42B}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {4685653D-1D5C-4785-BEF1-83E4C5377643} + EndGlobalSection +EndGlobal diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..ec03013 --- /dev/null +++ b/Program.cs @@ -0,0 +1,30 @@ +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using FsTool; + +// See https://aka.ms/new-console-template for more information + +using var httpClient = new HttpClient(); + +// Show menu for the user to choose from the two tasks +Console.WriteLine("Choose a task to execute:"); +Console.WriteLine("1. Check Aldi Memberships"); +Console.WriteLine("2. Confirm all Unconfirmed Pickups for Lindenberg"); +Console.Write("Enter the number of the task to execute (or any other key to exit): "); +var choice = Console.ReadLine(); + +switch (choice) +{ + case "1": + await CustomTasks.CheckAldiMembershipsAsync(httpClient); + break; + case "2": + await CustomTasks.ConfirmUnconfirmedPickupsLindenbergAsync(httpClient); + break; + default: + Console.WriteLine("Exiting..."); + break; +} \ No newline at end of file diff --git a/RegionTasks.cs b/RegionTasks.cs new file mode 100644 index 0000000..e8c05da --- /dev/null +++ b/RegionTasks.cs @@ -0,0 +1,52 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace FsTool +{ + public class RegionTasks + { + public record Store(int Id, string Name, CooperationStatus CooperationStatus); + + public enum CooperationStatus + { + NoStatus = 0, + NoContact = 1, + Negotiating = 2, + DoNotWant = 4, + Cooperating = 5, + DonatingToTafel = 6, + NoExisting = 7, + } + + public static async Task> GetStoresInRegionAsync(HttpClient httpClient, int regionId) + { + await AuthHelper.EnsureAuthenticationAsync(httpClient); + + var uri = string.Format(Endpoints.RegionStores, regionId); + var response = await httpClient.GetAsync(uri); + var responseBody = await response.Content.ReadAsStringAsync(); + + // handle unsuccessful response + if (!response.IsSuccessStatusCode) + { + await Console.Error.WriteLineAsync($"Region stores retrieval failed ({(int)response.StatusCode} {response.ReasonPhrase}): {responseBody}"); + return []; + } + + Console.WriteLine($"Stores in region {regionId}:"); + Console.WriteLine(responseBody); + + var root = JsonNode.Parse(responseBody); + if (root == null) return []; + + // Deserialize JsonNode to List + var opts = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = null // <── WICHTIG + }; + + return root["stores"].Deserialize>(opts) ?? []; + } + } +} \ No newline at end of file diff --git a/StoreTasks.cs b/StoreTasks.cs new file mode 100644 index 0000000..6899078 --- /dev/null +++ b/StoreTasks.cs @@ -0,0 +1,102 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace FsTool +{ + public static class StoreTasks + { + public record Pickup(string Date, List OccupiedSlots); + public record Slot(bool IsConfirmed, Profile Profile); + public record Profile(int Id, string Name); + public record Member(int Id, string Name, TeamActiveStatus Team_Active, VerifiedStatus Verified); + + public enum TeamActiveStatus + { + Jumper = 2, + Active = 1, + } + + public enum VerifiedStatus + { + Unverified = 0, + Verified = 1, + } + + public static async Task> GetStoreMembersAsync(HttpClient httpClient, int storeId) + { + await AuthHelper.EnsureAuthenticationAsync(httpClient); + + var uri = string.Format(Endpoints.StoreMembers, storeId); + var response = await httpClient.GetAsync(uri); + var responseBody = await response.Content.ReadAsStringAsync(); + + // handle unsuccessful response + if (!response.IsSuccessStatusCode) + { + await Console.Error.WriteLineAsync($"Store members retrieval failed ({(int)response.StatusCode} {response.ReasonPhrase}): {responseBody}"); + return []; + } + + var root = JsonNode.Parse(responseBody); + if (root == null) return []; + + // Deserialize JsonNode to List + var opts = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = null // <── WICHTIG + }; + + return root.Deserialize>(opts) ?? []; + + } + + public static async Task> GetPickupsAsync(HttpClient httpClient, int storeId) + { + await AuthHelper.EnsureAuthenticationAsync(httpClient); + + var uri = string.Format(Endpoints.StorePickups, storeId); + var response = await httpClient.GetAsync(uri); + var responseBody = await response.Content.ReadAsStringAsync(); + + // handle unsuccessful response + if (!response.IsSuccessStatusCode) + { + await Console.Error.WriteLineAsync($"Pickup retrieval failed ({(int)response.StatusCode} {response.ReasonPhrase}): {responseBody}"); + } + + var root = JsonNode.Parse(responseBody); + if (root == null) return []; + + // Deserialize JsonNode to List + var opts = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = null // <── WICHTIG + }; + + return root["pickups"].Deserialize>(opts) ?? []; + } + + public static async Task PatchPickupAsync(HttpClient httpClient, int storeId, string pickupDate, int fsId) + { + await AuthHelper.EnsureAuthenticationAsync(httpClient); + + var uri = string.Format(Endpoints.StorePickupsSlot, storeId, pickupDate, fsId); + var payload = new JsonObject + { + ["isConfirmed"] = true + }; + var response = await httpClient.PatchAsync(uri, JsonContent.Create(payload)); + var responseBody = await response.Content.ReadAsStringAsync(); + + // handle unsuccessful response + if (!response.IsSuccessStatusCode) + await Console.Error.WriteLineAsync($"Pickup patch failed ({(int)response.StatusCode} {response.ReasonPhrase}): {responseBody}"); + else + Console.WriteLine($"Pickup patch succeeded {fsId} on {pickupDate}"); + } + } +} \ No newline at end of file diff --git a/UserTasks.cs b/UserTasks.cs new file mode 100644 index 0000000..ad64693 --- /dev/null +++ b/UserTasks.cs @@ -0,0 +1,24 @@ +namespace FsTool +{ + public static class UserTasks + { + public static async Task GetCurrentUserAsync(HttpClient httpClient) + { + await AuthHelper.EnsureAuthenticationAsync(httpClient); + + var response = await httpClient.GetAsync(Endpoints.UserCurrent); + + // handle unsuccessful response + if (!response.IsSuccessStatusCode) + { + var responseBody = await response.Content.ReadAsStringAsync(); + await Console.Error.WriteLineAsync($"Get current user failed ({(int)response.StatusCode} {response.ReasonPhrase}): {responseBody}"); + return; + } + + var content = await response.Content.ReadAsStringAsync(); + Console.WriteLine("Current User Info:"); + Console.WriteLine(content); + } + } +} \ No newline at end of file