From 86f19ba044454f22376df579f5cdebe4f109fa96 Mon Sep 17 00:00:00 2001 From: SysAdmin Date: Wed, 26 Nov 2025 12:33:46 +0000 Subject: [PATCH] feat: Add AlexHost deployment pipeline and bot control functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Gitea Actions workflow for manual AlexHost deployment - Add docker-compose.alexhost.yml for production deployment - Add deploy-alexhost.sh script with server-side build support - Add Bot Control feature (Start/Stop/Restart) for remote bot management - Add discovery control endpoint in TeleBot - Update TeleBot with StartPollingAsync/StopPolling/RestartPollingAsync - Fix platform architecture issues by building on target server - Update docker-compose configurations for all environments Deployment tested successfully: - TeleShop: healthy at https://teleshop.silentmary.mywire.org - TeleBot: healthy with discovery integration - SilverPay: connectivity verified 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .gitea/workflows/deploy-alexhost.yml | 193 ++++++++++++++ Dockerfile | 2 +- .../Areas/Admin/Controllers/BotsController.cs | 126 +++++++++ .../Admin/Controllers/ProductsController.cs | 12 +- .../Areas/Admin/Views/Bots/Details.cshtml | 36 ++- .../Admin/Views/Bots/DiscoverRemote.cshtml | 12 +- LittleShop/DTOs/BotDiscoveryDto.cs | 19 ++ LittleShop/Services/BotDiscoveryService.cs | 120 ++++++++- LittleShop/Services/BotService.cs | 3 +- LittleShop/Services/IBotDiscoveryService.cs | 7 + LittleShop/appsettings.Production.json | 2 +- .../Controllers/DiscoveryController.cs | 103 ++++++++ TeleBot/TeleBot/DTOs/DiscoveryDtos.cs | 30 +++ TeleBot/TeleBot/TelegramBotService.cs | 119 +++++++++ TeleBot/TeleBot/appsettings.json | 2 +- TeleBot/docker-compose.hostinger.yml | 40 +-- deploy-alexhost.sh | 249 ++++++++++++++++++ docker-compose.alexhost.yml | 124 +++++++++ docker-compose.hostinger.yml | 22 +- docker-compose.yml | 2 +- 20 files changed, 1186 insertions(+), 37 deletions(-) create mode 100644 .gitea/workflows/deploy-alexhost.yml create mode 100644 deploy-alexhost.sh create mode 100644 docker-compose.alexhost.yml diff --git a/.gitea/workflows/deploy-alexhost.yml b/.gitea/workflows/deploy-alexhost.yml new file mode 100644 index 0000000..f3bed1c --- /dev/null +++ b/.gitea/workflows/deploy-alexhost.yml @@ -0,0 +1,193 @@ +# Gitea Actions Workflow for AlexHost Deployment +# This workflow provides manual deployment to the AlexHost production server +# Server: 193.233.245.41 (teleshop.silentmary.mywire.org) + +name: Deploy to AlexHost + +on: + workflow_dispatch: + inputs: + deploy_teleshop: + description: 'Deploy TeleShop (LittleShop)' + required: true + default: 'true' + type: boolean + deploy_telebot: + description: 'Deploy TeleBot' + required: true + default: 'true' + type: boolean + force_rebuild: + description: 'Force rebuild without cache' + required: false + default: 'false' + type: boolean + +env: + ALEXHOST_IP: 193.233.245.41 + ALEXHOST_USER: sysadmin + REGISTRY: localhost:5000 + TELESHOP_IMAGE: littleshop + TELEBOT_IMAGE: telebot + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build TeleShop Image + if: ${{ inputs.deploy_teleshop == 'true' }} + run: | + echo "Building TeleShop image..." + CACHE_FLAG="" + if [ "${{ inputs.force_rebuild }}" = "true" ]; then + CACHE_FLAG="--no-cache" + fi + docker build $CACHE_FLAG -t ${{ env.TELESHOP_IMAGE }}:${{ github.sha }} -t ${{ env.TELESHOP_IMAGE }}:latest -f Dockerfile . + docker save ${{ env.TELESHOP_IMAGE }}:latest | gzip > teleshop-image.tar.gz + echo "TeleShop image built successfully" + + - name: Build TeleBot Image + if: ${{ inputs.deploy_telebot == 'true' }} + run: | + echo "Building TeleBot image..." + CACHE_FLAG="" + if [ "${{ inputs.force_rebuild }}" = "true" ]; then + CACHE_FLAG="--no-cache" + fi + docker build $CACHE_FLAG -t ${{ env.TELEBOT_IMAGE }}:${{ github.sha }} -t ${{ env.TELEBOT_IMAGE }}:latest -f Dockerfile.telebot . + docker save ${{ env.TELEBOT_IMAGE }}:latest | gzip > telebot-image.tar.gz + echo "TeleBot image built successfully" + + - name: Configure SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.ALEXHOST_SSH_KEY }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -H ${{ env.ALEXHOST_IP }} >> ~/.ssh/known_hosts 2>/dev/null || true + + - name: Copy TeleShop Image to AlexHost + if: ${{ inputs.deploy_teleshop == 'true' }} + run: | + echo "Transferring TeleShop image to AlexHost..." + scp -o StrictHostKeyChecking=no teleshop-image.tar.gz ${{ env.ALEXHOST_USER }}@${{ env.ALEXHOST_IP }}:/tmp/ + echo "TeleShop image transferred" + + - name: Copy TeleBot Image to AlexHost + if: ${{ inputs.deploy_telebot == 'true' }} + run: | + echo "Transferring TeleBot image to AlexHost..." + scp -o StrictHostKeyChecking=no telebot-image.tar.gz ${{ env.ALEXHOST_USER }}@${{ env.ALEXHOST_IP }}:/tmp/ + echo "TeleBot image transferred" + + - name: Copy Docker Compose to AlexHost + run: | + echo "Copying deployment files..." + scp -o StrictHostKeyChecking=no docker-compose.alexhost.yml ${{ env.ALEXHOST_USER }}@${{ env.ALEXHOST_IP }}:/tmp/ + + - name: Deploy TeleShop on AlexHost + if: ${{ inputs.deploy_teleshop == 'true' }} + run: | + ssh -o StrictHostKeyChecking=no ${{ env.ALEXHOST_USER }}@${{ env.ALEXHOST_IP }} << 'DEPLOY_EOF' + set -e + echo "=== Deploying TeleShop ===" + + # Load image + echo "Loading TeleShop image..." + gunzip -c /tmp/teleshop-image.tar.gz | sudo docker load + + # Tag and push to local registry + echo "Pushing to local registry..." + sudo docker tag littleshop:latest localhost:5000/littleshop:latest + sudo docker push localhost:5000/littleshop:latest + + # Stop and remove existing container + echo "Stopping existing container..." + sudo docker stop teleshop 2>/dev/null || true + sudo docker rm teleshop 2>/dev/null || true + + # Start new container using compose + echo "Starting new container..." + cd /home/sysadmin/teleshop-source 2>/dev/null || mkdir -p /home/sysadmin/teleshop-source + cp /tmp/docker-compose.alexhost.yml /home/sysadmin/teleshop-source/docker-compose.yml + cd /home/sysadmin/teleshop-source + sudo docker compose up -d teleshop + + # Wait for health check + echo "Waiting for health check..." + sleep 30 + if sudo docker ps | grep -q "teleshop.*healthy"; then + echo "TeleShop deployed successfully!" + else + echo "Warning: Container may still be starting..." + sudo docker ps | grep teleshop + fi + + # Cleanup + rm /tmp/teleshop-image.tar.gz + echo "=== TeleShop deployment complete ===" + DEPLOY_EOF + + - name: Deploy TeleBot on AlexHost + if: ${{ inputs.deploy_telebot == 'true' }} + run: | + ssh -o StrictHostKeyChecking=no ${{ env.ALEXHOST_USER }}@${{ env.ALEXHOST_IP }} << 'DEPLOY_EOF' + set -e + echo "=== Deploying TeleBot ===" + + # Load image + echo "Loading TeleBot image..." + gunzip -c /tmp/telebot-image.tar.gz | sudo docker load + + # Tag and push to local registry + echo "Pushing to local registry..." + sudo docker tag telebot:latest localhost:5000/telebot:latest + sudo docker push localhost:5000/telebot:latest + + # Stop and remove existing container + echo "Stopping existing container..." + sudo docker stop telebot 2>/dev/null || true + sudo docker rm telebot 2>/dev/null || true + + # Start new container using compose + echo "Starting new container..." + cd /home/sysadmin/teleshop-source + sudo docker compose up -d telebot + + # Wait for startup + echo "Waiting for startup..." + sleep 20 + sudo docker ps | grep telebot + + # Cleanup + rm /tmp/telebot-image.tar.gz + echo "=== TeleBot deployment complete ===" + DEPLOY_EOF + + - name: Verify Deployment + run: | + ssh -o StrictHostKeyChecking=no ${{ env.ALEXHOST_USER }}@${{ env.ALEXHOST_IP }} << 'VERIFY_EOF' + echo "=== Deployment Verification ===" + echo "" + echo "Running Containers:" + sudo docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" + echo "" + echo "Testing TeleShop health..." + curl -sf http://localhost:5100/health && echo "TeleShop: OK" || echo "TeleShop: FAIL" + echo "" + echo "Testing TeleBot health..." + curl -sf http://localhost:5010/health 2>/dev/null && echo "TeleBot: OK" || echo "TeleBot: API endpoint not exposed (normal for bot-only mode)" + echo "" + echo "=== Verification complete ===" + VERIFY_EOF + + - name: Cleanup Local Artifacts + if: always() + run: | + rm -f teleshop-image.tar.gz telebot-image.tar.gz + echo "Cleanup complete" diff --git a/Dockerfile b/Dockerfile index f62057e..2367c87 100644 --- a/Dockerfile +++ b/Dockerfile @@ -74,7 +74,7 @@ ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=0 \ ASPNETCORE_FORWARDEDHEADERS_ENABLED=true \ ASPNETCORE_URLS=http://+:8080 \ ASPNETCORE_ENVIRONMENT=Production \ - ConnectionStrings__DefaultConnection="Data Source=/app/data/teleshop-prod.db;Cache=Shared" \ + ConnectionStrings__DefaultConnection="Data Source=/app/data/littleshop-production.db;Cache=Shared" \ SilverPay__BaseUrl="http://31.97.57.205:8001" \ SilverPay__ApiKey="your-api-key-here" \ TMPDIR=/tmp diff --git a/LittleShop/Areas/Admin/Controllers/BotsController.cs b/LittleShop/Areas/Admin/Controllers/BotsController.cs index 8ffc0ff..777ec86 100644 --- a/LittleShop/Areas/Admin/Controllers/BotsController.cs +++ b/LittleShop/Areas/Admin/Controllers/BotsController.cs @@ -793,5 +793,131 @@ public class BotsController : Controller await _botService.UpdateRemoteInfoAsync(botId, ipAddress, port, instanceId, status); } + // POST: Admin/Bots/StartBot/5 + [HttpPost] + [ValidateAntiForgeryToken] + public async Task StartBot(Guid id) + { + _logger.LogInformation("Start bot requested for {BotId}", id); + + var bot = await _botService.GetBotByIdAsync(id); + if (bot == null) + { + TempData["Error"] = "Bot not found"; + return RedirectToAction(nameof(Index)); + } + + if (!bot.IsRemote) + { + TempData["Error"] = "Bot control is only available for remote bots"; + return RedirectToAction(nameof(Details), new { id }); + } + + try + { + var result = await _discoveryService.ControlBotAsync(id, "start"); + + if (result.Success) + { + TempData["Success"] = result.Message; + } + else + { + TempData["Error"] = result.Message; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to start bot {BotId}", id); + TempData["Error"] = $"Failed to start bot: {ex.Message}"; + } + + return RedirectToAction(nameof(Details), new { id }); + } + + // POST: Admin/Bots/StopBot/5 + [HttpPost] + [ValidateAntiForgeryToken] + public async Task StopBot(Guid id) + { + _logger.LogInformation("Stop bot requested for {BotId}", id); + + var bot = await _botService.GetBotByIdAsync(id); + if (bot == null) + { + TempData["Error"] = "Bot not found"; + return RedirectToAction(nameof(Index)); + } + + if (!bot.IsRemote) + { + TempData["Error"] = "Bot control is only available for remote bots"; + return RedirectToAction(nameof(Details), new { id }); + } + + try + { + var result = await _discoveryService.ControlBotAsync(id, "stop"); + + if (result.Success) + { + TempData["Success"] = result.Message; + } + else + { + TempData["Error"] = result.Message; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to stop bot {BotId}", id); + TempData["Error"] = $"Failed to stop bot: {ex.Message}"; + } + + return RedirectToAction(nameof(Details), new { id }); + } + + // POST: Admin/Bots/RestartBot/5 + [HttpPost] + [ValidateAntiForgeryToken] + public async Task RestartBot(Guid id) + { + _logger.LogInformation("Restart bot requested for {BotId}", id); + + var bot = await _botService.GetBotByIdAsync(id); + if (bot == null) + { + TempData["Error"] = "Bot not found"; + return RedirectToAction(nameof(Index)); + } + + if (!bot.IsRemote) + { + TempData["Error"] = "Bot control is only available for remote bots"; + return RedirectToAction(nameof(Details), new { id }); + } + + try + { + var result = await _discoveryService.ControlBotAsync(id, "restart"); + + if (result.Success) + { + TempData["Success"] = result.Message; + } + else + { + TempData["Error"] = result.Message; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to restart bot {BotId}", id); + TempData["Error"] = $"Failed to restart bot: {ex.Message}"; + } + + return RedirectToAction(nameof(Details), new { id }); + } + #endregion } \ No newline at end of file diff --git a/LittleShop/Areas/Admin/Controllers/ProductsController.cs b/LittleShop/Areas/Admin/Controllers/ProductsController.cs index 3e8b05a..4fa6d5d 100644 --- a/LittleShop/Areas/Admin/Controllers/ProductsController.cs +++ b/LittleShop/Areas/Admin/Controllers/ProductsController.cs @@ -58,9 +58,14 @@ public class ProductsController : Controller if (!ModelState.IsValid) { - + var categories = await _categoryService.GetAllCategoriesAsync(); ViewData["Categories"] = categories.Where(c => c.IsActive); + + // FIX: Re-populate VariantCollections for view rendering when validation fails + var variantCollections = await _variantCollectionService.GetAllVariantCollectionsAsync(); + ViewData["VariantCollections"] = variantCollections.Where(vc => vc.IsActive); + return View(model); } @@ -134,6 +139,11 @@ public class ProductsController : Controller { var categories = await _categoryService.GetAllCategoriesAsync(); ViewData["Categories"] = categories.Where(c => c.IsActive); + + // FIX: Re-populate VariantCollections for view rendering when validation fails + var variantCollections = await _variantCollectionService.GetAllVariantCollectionsAsync(); + ViewData["VariantCollections"] = variantCollections.Where(vc => vc.IsActive); + ViewData["ProductId"] = id; return View(model); } diff --git a/LittleShop/Areas/Admin/Views/Bots/Details.cshtml b/LittleShop/Areas/Admin/Views/Bots/Details.cshtml index 68a8677..0fca262 100644 --- a/LittleShop/Areas/Admin/Views/Bots/Details.cshtml +++ b/LittleShop/Areas/Admin/Views/Bots/Details.cshtml @@ -180,7 +180,7 @@
-
+
@Html.AntiForgeryToken()
+ + @if (Model.DiscoveryStatus == "Configured") + { +
+
Bot Control
+
+ + @Html.AntiForgeryToken() + + + +
+ @Html.AntiForgeryToken() + +
+ +
+ @Html.AntiForgeryToken() + +
+
+ + Controls the Telegram polling connection on the remote bot instance. + + }
} @@ -331,7 +362,8 @@
- diff --git a/LittleShop/Areas/Admin/Views/Bots/DiscoverRemote.cshtml b/LittleShop/Areas/Admin/Views/Bots/DiscoverRemote.cshtml index 7a28599..b420f48 100644 --- a/LittleShop/Areas/Admin/Views/Bots/DiscoverRemote.cshtml +++ b/LittleShop/Areas/Admin/Views/Bots/DiscoverRemote.cshtml @@ -37,15 +37,17 @@ The TeleBot must be running and configured with the same discovery secret.

- + @Html.AntiForgeryToken()
- The IP address or hostname where TeleBot is running + placeholder="e.g., telebot, 193.233.245.41, or telebot.example.com" required /> + + Use telebot for same-server Docker deployments, or the public IP/hostname for remote servers +
@@ -107,7 +109,7 @@
Step 2: Register Bot
- + @Html.AntiForgeryToken() @@ -188,7 +190,7 @@ Now enter the Telegram bot token from BotFather to activate this bot.

- + @Html.AntiForgeryToken() diff --git a/LittleShop/DTOs/BotDiscoveryDto.cs b/LittleShop/DTOs/BotDiscoveryDto.cs index ad957cd..cb4662e 100644 --- a/LittleShop/DTOs/BotDiscoveryDto.cs +++ b/LittleShop/DTOs/BotDiscoveryDto.cs @@ -144,3 +144,22 @@ public static class DiscoveryStatus public const string Offline = "Offline"; public const string Error = "Error"; } + +/// +/// Result of a bot control action (start/stop/restart) +/// +public class BotControlResult +{ + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; + + /// + /// Current bot status after the action + /// + public string Status { get; set; } = string.Empty; + + /// + /// Whether Telegram polling is currently running + /// + public bool IsRunning { get; set; } +} diff --git a/LittleShop/Services/BotDiscoveryService.cs b/LittleShop/Services/BotDiscoveryService.cs index 4a08e42..ae521ca 100644 --- a/LittleShop/Services/BotDiscoveryService.cs +++ b/LittleShop/Services/BotDiscoveryService.cs @@ -369,13 +369,127 @@ public class BotDiscoveryService : IBotDiscoveryService } } + public async Task ControlBotAsync(Guid botId, string action) + { + _logger.LogInformation("Sending control action '{Action}' to bot {BotId}", action, botId); + + try + { + // Get the bot details + var bot = await _botService.GetBotByIdAsync(botId); + if (bot == null) + { + return new BotControlResult + { + Success = false, + Message = "Bot not found" + }; + } + + if (string.IsNullOrEmpty(bot.RemoteAddress) || !bot.RemotePort.HasValue) + { + return new BotControlResult + { + Success = false, + Message = "Bot does not have remote address configured. Control only works for remote bots." + }; + } + + // Get the BotKey securely + var botKey = await _botService.GetBotKeyAsync(botId); + if (string.IsNullOrEmpty(botKey)) + { + return new BotControlResult + { + Success = false, + Message = "Bot key not found" + }; + } + + var endpoint = BuildEndpoint(bot.RemoteAddress, bot.RemotePort.Value, "/api/discovery/control"); + + var payload = new { Action = action }; + + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint); + request.Headers.Add("X-Bot-Key", botKey); + request.Content = new StringContent( + JsonSerializer.Serialize(payload, JsonOptions), + Encoding.UTF8, + "application/json"); + + var response = await _httpClient.SendAsync(request); + + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(); + var controlResponse = JsonSerializer.Deserialize(content, JsonOptions); + + _logger.LogInformation("Bot control action '{Action}' completed for bot {BotId}: {Success}", + action, botId, controlResponse?.Success); + + return controlResponse ?? new BotControlResult + { + Success = true, + Message = $"Action '{action}' completed" + }; + } + else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + return new BotControlResult + { + Success = false, + Message = "Invalid bot key. The bot may need to be re-initialized." + }; + } + else + { + var errorContent = await response.Content.ReadAsStringAsync(); + _logger.LogWarning("Bot control action failed: {StatusCode} - {Content}", + response.StatusCode, errorContent); + return new BotControlResult + { + Success = false, + Message = $"Control action failed: {response.StatusCode}" + }; + } + } + catch (TaskCanceledException) + { + _logger.LogWarning("Bot control timed out for bot {BotId}", botId); + return new BotControlResult + { + Success = false, + Message = "Connection timed out. The bot may be offline." + }; + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, "Bot control connection failed for bot {BotId}", botId); + return new BotControlResult + { + Success = false, + Message = $"Connection failed: {ex.Message}" + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during bot control for bot {BotId}", botId); + return new BotControlResult + { + Success = false, + Message = $"Error: {ex.Message}" + }; + } + } + #region Private Methods private string BuildEndpoint(string ipAddress, int port, string path) { - // Use HTTP for local/private networks, HTTPS for public - var scheme = IsPrivateNetwork(ipAddress) ? "http" : "https"; - return $"{scheme}://{ipAddress}:{port}{path}"; + // Always use HTTP for discovery on custom ports + // HTTPS would require proper certificate setup which is unlikely on non-standard ports + // If HTTPS is needed, the reverse proxy should handle SSL termination + return $"http://{ipAddress}:{port}{path}"; } private bool IsPrivateNetwork(string ipAddress) diff --git a/LittleShop/Services/BotService.cs b/LittleShop/Services/BotService.cs index 82b6c5e..92c9f06 100644 --- a/LittleShop/Services/BotService.cs +++ b/LittleShop/Services/BotService.cs @@ -65,7 +65,7 @@ public class BotService : IBotService { Id = Guid.NewGuid(), Name = dto.Name, - Description = dto.Description, + Description = dto.Description ?? string.Empty, Type = dto.Type, BotKey = botKey, Status = BotStatus.Active, @@ -158,6 +158,7 @@ public class BotService : IBotService var bots = await _context.Bots .Include(b => b.Sessions) .Include(b => b.Metrics) + .Where(b => b.Status != BotStatus.Deleted) // Filter out deleted bots .OrderByDescending(b => b.CreatedAt) .ToListAsync(); diff --git a/LittleShop/Services/IBotDiscoveryService.cs b/LittleShop/Services/IBotDiscoveryService.cs index ea0dd83..d2bbdad 100644 --- a/LittleShop/Services/IBotDiscoveryService.cs +++ b/LittleShop/Services/IBotDiscoveryService.cs @@ -31,4 +31,11 @@ public interface IBotDiscoveryService /// Get the status of a remote TeleBot instance /// Task GetRemoteStatusAsync(string ipAddress, int port, string botKey); + + /// + /// Control a remote TeleBot instance (start/stop/restart) + /// + /// The bot ID in LittleShop + /// Action to perform: "start", "stop", or "restart" + Task ControlBotAsync(Guid botId, string action); } diff --git a/LittleShop/appsettings.Production.json b/LittleShop/appsettings.Production.json index c41fb6a..4819df1 100644 --- a/LittleShop/appsettings.Production.json +++ b/LittleShop/appsettings.Production.json @@ -7,7 +7,7 @@ } }, "ConnectionStrings": { - "DefaultConnection": "Data Source=teleshop-production.db" + "DefaultConnection": "Data Source=/app/data/littleshop-production.db" }, "Jwt": { "Key": "${JWT_SECRET_KEY}", diff --git a/TeleBot/TeleBot/Controllers/DiscoveryController.cs b/TeleBot/TeleBot/Controllers/DiscoveryController.cs index 578a207..d3234e5 100644 --- a/TeleBot/TeleBot/Controllers/DiscoveryController.cs +++ b/TeleBot/TeleBot/Controllers/DiscoveryController.cs @@ -219,6 +219,109 @@ public class DiscoveryController : ControllerBase }); } + /// + /// Control the bot - start, stop, or restart Telegram polling + /// + [HttpPost("control")] + public async Task Control([FromBody] BotControlRequest request) + { + // Require BotKey authentication for control actions + if (!ValidateBotKey()) + { + _logger.LogWarning("Bot control rejected: invalid or missing X-Bot-Key"); + return Unauthorized(new { error = "Invalid bot key" }); + } + + if (string.IsNullOrEmpty(request.Action)) + { + return BadRequest(new BotControlResponse + { + Success = false, + Message = "Action is required (start, stop, restart)", + Status = _botManagerService.CurrentStatus, + IsRunning = _telegramBotService.IsRunning + }); + } + + var action = request.Action.ToLower(); + _logger.LogInformation("Bot control action requested: {Action}", action); + + try + { + bool success; + string message; + + switch (action) + { + case "start": + if (_telegramBotService.IsRunning) + { + return Ok(new BotControlResponse + { + Success = false, + Message = "Bot is already running", + Status = _botManagerService.CurrentStatus, + IsRunning = true + }); + } + success = await _telegramBotService.StartPollingAsync(); + message = success ? "Bot started successfully" : "Failed to start bot"; + break; + + case "stop": + if (!_telegramBotService.IsRunning) + { + return Ok(new BotControlResponse + { + Success = false, + Message = "Bot is not running", + Status = _botManagerService.CurrentStatus, + IsRunning = false + }); + } + _telegramBotService.StopPolling(); + success = true; + message = "Bot stopped successfully"; + break; + + case "restart": + success = await _telegramBotService.RestartPollingAsync(); + message = success ? "Bot restarted successfully" : "Failed to restart bot"; + break; + + default: + return BadRequest(new BotControlResponse + { + Success = false, + Message = $"Unknown action: {action}. Valid actions: start, stop, restart", + Status = _botManagerService.CurrentStatus, + IsRunning = _telegramBotService.IsRunning + }); + } + + _logger.LogInformation("Bot control action '{Action}' completed: {Success}", action, success); + + return Ok(new BotControlResponse + { + Success = success, + Message = message, + Status = _botManagerService.CurrentStatus, + IsRunning = _telegramBotService.IsRunning + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during bot control action '{Action}'", action); + return StatusCode(500, new BotControlResponse + { + Success = false, + Message = $"Error: {ex.Message}", + Status = _botManagerService.CurrentStatus, + IsRunning = _telegramBotService.IsRunning + }); + } + } + private bool ValidateDiscoverySecret() { var providedSecret = Request.Headers["X-Discovery-Secret"].ToString(); diff --git a/TeleBot/TeleBot/DTOs/DiscoveryDtos.cs b/TeleBot/TeleBot/DTOs/DiscoveryDtos.cs index a6c4dd9..d569aab 100644 --- a/TeleBot/TeleBot/DTOs/DiscoveryDtos.cs +++ b/TeleBot/TeleBot/DTOs/DiscoveryDtos.cs @@ -135,3 +135,33 @@ public class BotStatusUpdate public DateTime LastActivityAt { get; set; } public Dictionary? Metadata { get; set; } } + +/// +/// Request to control the bot (start/stop/restart) +/// +public class BotControlRequest +{ + /// + /// Control action: "start", "stop", or "restart" + /// + public string Action { get; set; } = string.Empty; +} + +/// +/// Response after a control action +/// +public class BotControlResponse +{ + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; + + /// + /// Current bot status after the action + /// + public string Status { get; set; } = string.Empty; + + /// + /// Whether Telegram polling is currently running + /// + public bool IsRunning { get; set; } +} diff --git a/TeleBot/TeleBot/TelegramBotService.cs b/TeleBot/TeleBot/TelegramBotService.cs index 482ea36..1f09806 100644 --- a/TeleBot/TeleBot/TelegramBotService.cs +++ b/TeleBot/TeleBot/TelegramBotService.cs @@ -227,6 +227,125 @@ namespace TeleBot return null; } + /// + /// Start Telegram polling (if not already running) + /// + public async Task StartPollingAsync() + { + if (_isRunning) + { + _logger.LogWarning("Bot polling is already running"); + return false; + } + + if (string.IsNullOrEmpty(_currentBotToken)) + { + _currentBotToken = _configuration["Telegram:BotToken"]; + } + + if (string.IsNullOrEmpty(_currentBotToken) || _currentBotToken == "YOUR_BOT_TOKEN_HERE") + { + _logger.LogError("Cannot start: No bot token configured"); + return false; + } + + try + { + // Create bot client with TOR support if enabled + var torEnabled = _configuration.GetValue("Privacy:EnableTor"); + if (torEnabled) + { + var torSocksHost = _configuration.GetValue("Privacy:TorSocksHost") ?? "127.0.0.1"; + var torSocksPort = _configuration.GetValue("Privacy:TorSocksPort", 9050); + var proxyUri = $"socks5://{torSocksHost}:{torSocksPort}"; + + var handler = new SocketsHttpHandler + { + Proxy = new WebProxy(proxyUri) + { + BypassProxyOnLocal = false, + UseDefaultCredentials = false + }, + UseProxy = true, + AllowAutoRedirect = false, + PooledConnectionLifetime = TimeSpan.FromMinutes(5), + PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2) + }; + + var httpClient = new HttpClient(handler); + _botClient = new TelegramBotClient(_currentBotToken, httpClient); + } + else + { + _botClient = new TelegramBotClient(_currentBotToken); + } + + _cancellationTokenSource = new CancellationTokenSource(); + + var receiverOptions = new ReceiverOptions + { + AllowedUpdates = Array.Empty(), + ThrowPendingUpdates = true + }; + + _botClient.StartReceiving( + HandleUpdateAsync, + HandleErrorAsync, + receiverOptions, + cancellationToken: _cancellationTokenSource.Token + ); + + _isRunning = true; + + var me = await _botClient.GetMeAsync(); + _logger.LogInformation("Bot polling started: @{Username} ({Id})", me.Username, me.Id); + + // Update message delivery service + if (_messageDeliveryService is MessageDeliveryService deliveryService) + { + deliveryService.SetBotClient(_botClient); + } + + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to start bot polling"); + _isRunning = false; + return false; + } + } + + /// + /// Stop Telegram polling + /// + public void StopPolling() + { + if (!_isRunning) + { + _logger.LogWarning("Bot polling is not running"); + return; + } + + _cancellationTokenSource?.Cancel(); + _isRunning = false; + _logger.LogInformation("Bot polling stopped"); + } + + /// + /// Restart Telegram polling + /// + public async Task RestartPollingAsync() + { + _logger.LogInformation("Restarting bot polling..."); + StopPolling(); + + // Brief pause to ensure clean shutdown + await Task.Delay(500); + + return await StartPollingAsync(); + } + public async Task UpdateBotTokenAsync(string newToken) { // If bot wasn't started or token changed, start/restart diff --git a/TeleBot/TeleBot/appsettings.json b/TeleBot/TeleBot/appsettings.json index 263b9ba..7046e6e 100644 --- a/TeleBot/TeleBot/appsettings.json +++ b/TeleBot/TeleBot/appsettings.json @@ -92,7 +92,7 @@ "Kestrel": { "Endpoints": { "Http": { - "Url": "http://localhost:5010" + "Url": "http://0.0.0.0:5010" } } } diff --git a/TeleBot/docker-compose.hostinger.yml b/TeleBot/docker-compose.hostinger.yml index 258afa9..cef0e6f 100644 --- a/TeleBot/docker-compose.hostinger.yml +++ b/TeleBot/docker-compose.hostinger.yml @@ -5,39 +5,49 @@ services: build: context: ../ dockerfile: TeleBot/TeleBot/Dockerfile - image: telebot:latest + image: localhost:5000/telebot:latest container_name: telebot restart: unless-stopped + ports: + - "5010:5010" # TeleBot API/health endpoint environment: - ASPNETCORE_ENVIRONMENT=Production + - ASPNETCORE_URLS=http://+:5010 - TelegramBot__BotToken=${BOT_TOKEN} - TelegramBot__WebhookUrl=${WEBHOOK_URL} - TelegramBot__UseWebhook=false - - LittleShopApi__BaseUrl=http://littleshop:5000 + - LittleShopApi__BaseUrl=http://teleshop:8080 - LittleShopApi__ApiKey=${LITTLESHOP_API_KEY} - Logging__LogLevel__Default=Information - Logging__LogLevel__Microsoft=Warning - Logging__LogLevel__Microsoft.Hosting.Lifetime=Information volumes: - - ./logs:/app/logs - - ./data:/app/data - - ./image_cache:/app/image_cache + - /opt/telebot/logs:/app/logs + - /opt/telebot/data:/app/data + - /opt/telebot/image_cache:/app/image_cache networks: - - littleshop-network - depends_on: - - littleshop + teleshop-network: + aliases: + - telebot + silverpay-network: + aliases: + - telebot healthcheck: - test: ["CMD", "pgrep", "-f", "dotnet.*TeleBot"] + test: ["CMD", "curl", "-f", "http://localhost:5010/health"] interval: 30s timeout: 10s retries: 3 start_period: 60s - - littleshop: - external: true - name: littleshop + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" networks: - littleshop-network: + teleshop-network: + name: sysadmin_teleshop-network external: true - name: littleshop-network \ No newline at end of file + silverpay-network: + name: silverdotpay_silverdotpay-network + external: true \ No newline at end of file diff --git a/deploy-alexhost.sh b/deploy-alexhost.sh new file mode 100644 index 0000000..0b19fa8 --- /dev/null +++ b/deploy-alexhost.sh @@ -0,0 +1,249 @@ +#!/bin/bash +# AlexHost Deployment Script +# Usage: ./deploy-alexhost.sh [teleshop|telebot|all] [--no-cache] +# +# This script transfers source to AlexHost and builds Docker images natively +# on the server to ensure correct architecture (AMD64). +# +# Requirements: +# - sshpass installed (for password-based SSH) +# - tar installed +# - Access to AlexHost server + +set -e + +# Configuration - can be overridden by environment variables +ALEXHOST_IP="${ALEXHOST_IP:-193.233.245.41}" +ALEXHOST_USER="${ALEXHOST_USER:-sysadmin}" +ALEXHOST_PASS="${ALEXHOST_PASS:-}" +REGISTRY="${REGISTRY:-localhost:5000}" + +# Check for required password +if [ -z "$ALEXHOST_PASS" ]; then + echo -e "${RED}Error: ALEXHOST_PASS environment variable is required${NC}" + echo "Set it with: export ALEXHOST_PASS='your-password'" + exit 1 +fi +DEPLOY_DIR="/home/sysadmin/teleshop-source" +BUILD_DIR="/tmp/littleshop-build" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Parse arguments +DEPLOY_TARGET="${1:-all}" +NO_CACHE="" +if [[ "$2" == "--no-cache" ]] || [[ "$1" == "--no-cache" ]]; then + NO_CACHE="--no-cache" + if [[ "$1" == "--no-cache" ]]; then + DEPLOY_TARGET="all" + fi +fi + +echo -e "${BLUE}========================================${NC}" +echo -e "${BLUE} AlexHost Deployment Script${NC}" +echo -e "${BLUE} Target: ${DEPLOY_TARGET}${NC}" +echo -e "${BLUE} Server: ${ALEXHOST_IP}${NC}" +echo -e "${BLUE} Mode: Server-side build (AMD64)${NC}" +echo -e "${BLUE}========================================${NC}" +echo "" + +# Function to run SSH commands with sudo +ssh_sudo() { + sshpass -p "$ALEXHOST_PASS" ssh -o StrictHostKeyChecking=no "$ALEXHOST_USER@$ALEXHOST_IP" "echo '$ALEXHOST_PASS' | sudo -S bash -c '$1'" +} + +# Function to copy files to AlexHost +scp_file() { + sshpass -p "$ALEXHOST_PASS" scp -o StrictHostKeyChecking=no "$1" "$ALEXHOST_USER@$ALEXHOST_IP:$2" +} + +# Get script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Transfer source to server +transfer_source() { + echo -e "${YELLOW}=== Transferring source to AlexHost ===${NC}" + + # Create tarball excluding unnecessary files + echo "Creating source tarball..." + tar -czf /tmp/littleshop-source.tar.gz \ + --exclude='.git' \ + --exclude='node_modules' \ + --exclude='bin' \ + --exclude='obj' \ + --exclude='*.tar.gz' \ + -C "$SCRIPT_DIR" . + + echo "Source tarball size: $(ls -lh /tmp/littleshop-source.tar.gz | awk '{print $5}')" + + # Transfer to server + echo "Transferring to AlexHost..." + scp_file "/tmp/littleshop-source.tar.gz" "/tmp/" + scp_file "docker-compose.alexhost.yml" "/tmp/" + + # Extract on server + echo "Extracting on server..." + ssh_sudo "rm -rf $BUILD_DIR && mkdir -p $BUILD_DIR && cd $BUILD_DIR && tar -xzf /tmp/littleshop-source.tar.gz" + + # Cleanup local + rm -f /tmp/littleshop-source.tar.gz + + echo -e "${GREEN}Source transferred successfully!${NC}" +} + +# Deploy TeleShop +deploy_teleshop() { + echo -e "${YELLOW}=== Building TeleShop on AlexHost ===${NC}" + + ssh_sudo " + set -e + cd $BUILD_DIR + + echo 'Building TeleShop image...' + docker build $NO_CACHE -t littleshop:latest -f Dockerfile . 2>&1 | tail -15 + + echo 'Tagging and pushing to local registry...' + docker tag littleshop:latest localhost:5000/littleshop:latest + docker push localhost:5000/littleshop:latest + + echo 'Stopping existing container...' + docker stop teleshop 2>/dev/null || true + docker rm teleshop 2>/dev/null || true + + echo 'Copying compose file...' + mkdir -p $DEPLOY_DIR + cp /tmp/docker-compose.alexhost.yml $DEPLOY_DIR/docker-compose.yml + + echo 'Starting TeleShop...' + cd $DEPLOY_DIR + docker compose up -d teleshop + + echo 'Waiting for health check...' + sleep 30 + docker ps | grep teleshop + " + + echo -e "${GREEN}TeleShop deployment complete!${NC}" +} + +# Deploy TeleBot +deploy_telebot() { + echo -e "${YELLOW}=== Building TeleBot on AlexHost ===${NC}" + + ssh_sudo " + set -e + cd $BUILD_DIR + + echo 'Building TeleBot image...' + docker build $NO_CACHE -t telebot:latest -f Dockerfile.telebot . 2>&1 | tail -15 + + echo 'Tagging and pushing to local registry...' + docker tag telebot:latest localhost:5000/telebot:latest + docker push localhost:5000/telebot:latest + + echo 'Stopping existing container...' + docker stop telebot 2>/dev/null || true + docker rm telebot 2>/dev/null || true + + echo 'Copying compose file...' + mkdir -p $DEPLOY_DIR + cp /tmp/docker-compose.alexhost.yml $DEPLOY_DIR/docker-compose.yml 2>/dev/null || true + + echo 'Starting TeleBot...' + cd $DEPLOY_DIR + docker compose up -d telebot + + echo 'Waiting for startup...' + sleep 20 + docker ps | grep telebot + " + + echo -e "${GREEN}TeleBot deployment complete!${NC}" +} + +# Verify deployment +verify_deployment() { + echo -e "${YELLOW}=== Verifying Deployment ===${NC}" + + ssh_exec " + echo '' + echo 'Container Status:' + sudo docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' | grep -E 'NAMES|teleshop|telebot' + echo '' + + echo 'Testing TeleShop health...' + if curl -sf http://localhost:5100/health > /dev/null; then + echo 'TeleShop: OK' + else + echo 'TeleShop: FAIL or starting...' + fi + + echo '' + echo 'Testing TeleBot...' + if sudo docker ps | grep -q 'telebot.*Up'; then + echo 'TeleBot: Running' + else + echo 'TeleBot: Not running' + fi + " +} + +# Cleanup build directory +cleanup() { + echo -e "${YELLOW}=== Cleaning up ===${NC}" + ssh_sudo "rm -rf $BUILD_DIR /tmp/littleshop-source.tar.gz" + echo -e "${GREEN}Cleanup complete${NC}" +} + +# Main execution +case "$DEPLOY_TARGET" in + teleshop) + transfer_source + deploy_teleshop + cleanup + verify_deployment + ;; + telebot) + transfer_source + deploy_telebot + cleanup + verify_deployment + ;; + all) + transfer_source + deploy_teleshop + deploy_telebot + cleanup + verify_deployment + ;; + verify) + verify_deployment + ;; + *) + echo -e "${RED}Usage: $0 [teleshop|telebot|all|verify] [--no-cache]${NC}" + echo "" + echo "Examples:" + echo " $0 all # Deploy both services" + echo " $0 teleshop # Deploy only TeleShop" + echo " $0 telebot # Deploy only TeleBot" + echo " $0 all --no-cache # Deploy both without Docker cache" + echo " $0 verify # Just verify current deployment" + exit 1 + ;; +esac + +echo "" +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN} Deployment Complete!${NC}" +echo -e "${GREEN}========================================${NC}" +echo "" +echo "Access points:" +echo " TeleShop Admin: https://teleshop.silentmary.mywire.org/Admin" +echo " TeleShop API: https://teleshop.silentmary.mywire.org/api" +echo " TeleBot API: http://${ALEXHOST_IP}:5010" diff --git a/docker-compose.alexhost.yml b/docker-compose.alexhost.yml new file mode 100644 index 0000000..c14d581 --- /dev/null +++ b/docker-compose.alexhost.yml @@ -0,0 +1,124 @@ +# AlexHost Deployment Configuration +# Server: 193.233.245.41 (alexhost.silentmary.mywire.org) +# Registry: localhost:5000 + +version: '3.8' + +services: + teleshop: + image: localhost:5000/littleshop:latest + container_name: teleshop + restart: unless-stopped + ports: + - "5100:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Production + - ASPNETCORE_URLS=http://+:8080 + - ConnectionStrings__DefaultConnection=Data Source=/app/data/littleshop-production.db + + # JWT Configuration + - Jwt__Key=ThisIsAVeryLongSecretKeyThatIsDefinitelyLongerThan32BytesForSure123456789ABCDEF + - Jwt__Issuer=LittleShop-Production + - Jwt__Audience=LittleShop-Production + - Jwt__ExpiryInHours=24 + + # SilverPay Configuration + - SilverPay__BaseUrl=http://silverdotpay-api:8080 + - SilverPay__PublicUrl=https://pay.thebankofdebbie.giize.com + - SilverPay__ApiKey=7703aa7a62fa4b40a87e9cfd867f5407147515c0986116ea54fc00c0a0bc30d8 + - SilverPay__WebhookSecret=Thefa1r1esd1d1twebhooks2024 + - SilverPay__DefaultWebhookUrl=https://admin.thebankofdebbie.giize.com/api/orders/payments/webhook + - SilverPay__AllowUnsignedWebhooks=false + + # Admin Credentials + - AdminUser__Username=admin + - AdminUser__Password=Thefa1r1esd1d1t + + # WebPush Notifications + - WebPush__VapidPublicKey=BMc6fFJZ8oIQKQzcl3kMnP9tTsjrm3oI_VxLt3lAGYUMWGInzDKn7jqclEoZzjvXy1QXGFb3dIun8mVBwh-QuS4 + - WebPush__VapidPrivateKey=dYuuagbz2CzCnPDFUpO_qkGLBgnN3MEFZQnjXNkc1MY + - WebPush__Subject=mailto:admin@thebankofdebbie.giize.com + + # Bot Discovery Configuration + - BotDiscovery__SharedSecret=AlexHostDiscovery2025SecretKey + - BotDiscovery__WebhookSecret=AlexHostWebhook2025SecretKey + - BotDiscovery__LittleShopApiUrl=https://admin.thebankofdebbie.giize.com + + volumes: + - /opt/littleshop/data:/app/data + - /opt/littleshop/uploads:/app/wwwroot/uploads + - /opt/littleshop/logs:/app/logs + networks: + teleshop-network: + aliases: + - teleshop + - littleshop + silverpay-network: + aliases: + - teleshop + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + telebot: + image: localhost:5000/telebot:latest + container_name: telebot + restart: unless-stopped + ports: + - "5010:5010" + environment: + - ASPNETCORE_ENVIRONMENT=Production + - ASPNETCORE_URLS=http://+:5010 + + # LittleShop API Connection (internal network) + - LittleShop__ApiUrl=http://teleshop:8080 + - LittleShop__UseTor=false + + # Telegram Bot Token (set via environment or will be configured via discovery) + - Telegram__BotToken=${TELEGRAM_BOT_TOKEN:-} + + # Discovery Configuration (must match TeleShop) + - Discovery__Secret=AlexHostDiscovery2025SecretKey + + # Privacy Settings + - Privacy__EnableTor=false + + volumes: + - /opt/telebot/data:/app/data + - /opt/telebot/logs:/app/logs + - /opt/telebot/image_cache:/app/image_cache + networks: + teleshop-network: + aliases: + - telebot + silverpay-network: + depends_on: + teleshop: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:5010/health || pgrep -f 'dotnet.*TeleBot' > /dev/null"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + +networks: + teleshop-network: + name: sysadmin_teleshop-network + external: true + silverpay-network: + name: silverdotpay_silverdotpay-network + external: true diff --git a/docker-compose.hostinger.yml b/docker-compose.hostinger.yml index 6345c28..26bf312 100644 --- a/docker-compose.hostinger.yml +++ b/docker-compose.hostinger.yml @@ -1,12 +1,12 @@ version: '3.8' services: - littleshop: + teleshop: image: localhost:5000/littleshop:latest - container_name: littleshop-admin + container_name: teleshop restart: unless-stopped ports: - - "127.0.0.1:5100:8080" # Local only, BunkerWeb will proxy + - "5100:8080" # External access on port 5100 environment: - ASPNETCORE_ENVIRONMENT=Production - ASPNETCORE_URLS=http://+:8080 # CRITICAL: Must use URLS not HTTP_PORTS @@ -19,7 +19,7 @@ services: - Jwt__ExpiryInHours=24 # SilverPay Configuration (pay.thebankofdebbie.giize.com) - - SilverPay__BaseUrl=http://silverpay-api:8000 # Internal Docker network - correct port + - SilverPay__BaseUrl=http://silverdotpay-api:8080 # Internal Docker network via silverpay-network - SilverPay__PublicUrl=https://pay.thebankofdebbie.giize.com - SilverPay__ApiKey=7703aa7a62fa4b40a87e9cfd867f5407147515c0986116ea54fc00c0a0bc30d8 - SilverPay__WebhookSecret=Thefa1r1esd1d1twebhooks2024 @@ -44,7 +44,13 @@ services: - /opt/littleshop/uploads:/app/wwwroot/uploads - /opt/littleshop/logs:/app/logs networks: - - littleshop-network # Shared network for container communication + teleshop-network: + aliases: + - teleshop + - littleshop + silverpay-network: + aliases: + - teleshop healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health"] interval: 30s @@ -58,5 +64,9 @@ services: max-file: "3" networks: - littleshop-network: + teleshop-network: + name: sysadmin_teleshop-network + external: true + silverpay-network: + name: silverdotpay_silverdotpay-network external: true \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 07c0f82..4bc9406 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: environment: - ASPNETCORE_ENVIRONMENT=Production - ASPNETCORE_URLS=http://+:5000 - - ConnectionStrings__DefaultConnection=Data Source=/app/data/teleshop-prod.db + - ConnectionStrings__DefaultConnection=Data Source=/app/data/littleshop-production.db - Jwt__Key=LittleShop-Production-JWT-SecretKey-32Characters-2025 - Jwt__Issuer=LittleShop - Jwt__Audience=LittleShop