From 63bcdf003d8c9733c7cf1fd72c78bb64fedf5725 Mon Sep 17 00:00:00 2001 From: Andre Beging Date: Thu, 11 Dec 2025 16:05:31 +0100 Subject: [PATCH] Initial commit --- .gitignore | 47 +++++++++++++++++ .vscode/launch.json | 15 ++++++ .vscode/tasks.json | 24 +++++++++ AuthHelper.cs | 125 ++++++++++++++++++++++++++++++++++++++++++++ CustomTasks.cs | 114 ++++++++++++++++++++++++++++++++++++++++ Endpoints.cs | 17 ++++++ Extensions.cs | 41 +++++++++++++++ FsTool.csproj | 10 ++++ FsTool.sln | 24 +++++++++ Program.cs | 30 +++++++++++ RegionTasks.cs | 52 ++++++++++++++++++ StoreTasks.cs | 102 ++++++++++++++++++++++++++++++++++++ UserTasks.cs | 24 +++++++++ 13 files changed, 625 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json create mode 100644 AuthHelper.cs create mode 100644 CustomTasks.cs create mode 100644 Endpoints.cs create mode 100644 Extensions.cs create mode 100644 FsTool.csproj create mode 100644 FsTool.sln create mode 100644 Program.cs create mode 100644 RegionTasks.cs create mode 100644 StoreTasks.cs create mode 100644 UserTasks.cs 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