Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;

#nullable disable

namespace OVDB_database.Migrations
{
/// <inheritdoc />
public partial class AddTrawellingWebhookSecret : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "TrawellingWebhookSecret",
table: "Users",
type: "longtext",
nullable: true);
}

/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "TrawellingWebhookSecret",
table: "Users");
}
}
}
3 changes: 3 additions & 0 deletions OVDB_database/Migrations/OVDBDatabaseContextModelSnapshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<string>("TrawellingUsername")
.HasColumnType("longtext");

b.Property<string>("TrawellingWebhookSecret")
.HasColumnType("longtext");

b.HasKey("Id");

b.HasIndex("Email")
Expand Down
6 changes: 6 additions & 0 deletions OVDB_database/Models/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ public class User
/// </summary>
public string TraewellingTagMappings { get; set; }

/// <summary>
/// Secret used to verify incoming Träwelling webhook requests (HMAC-SHA256 signing key).
/// Also used as a per-user token in the webhook URL path.
/// </summary>
public string TrawellingWebhookSecret { get; set; }

/// <summary>
/// Collection of active refresh tokens for this user (supports multiple sessions)
/// </summary>
Expand Down
127 changes: 125 additions & 2 deletions OV_DB/Controllers/TraewellingController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using OV_DB.Models;
using OV_DB.Services;
using OVDB_database.Database;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;

namespace OV_DB.Controllers
Expand Down Expand Up @@ -472,5 +476,124 @@ public async Task<IActionResult> BackfillScheduledTimes()
}
return null;
}
}
}

/// <summary>
/// Get webhook configuration info: URL and secret to register in Träwelling.
/// Generates a new webhook secret if none exists for the user.
/// </summary>
[HttpGet("webhook-info")]
public async Task<IActionResult> GetWebhookInfo()
{
try
{
var userId = GetCurrentUserId();
if (!userId.HasValue)
return Unauthorized();

var user = await _dbContext.Users.FindAsync(userId.Value);
if (user == null)
return NotFound("User not found");

var secret = await _trawellingService.GetOrGenerateWebhookSecretAsync(userId.Value);
if (secret == null)
return StatusCode(500, "Error generating webhook secret");

var webhookUrl = $"{Request.Scheme}://{Request.Host}/api/Traewelling/webhook/{user.Guid}";

return Ok(new TrawellingWebhookInfo
{
WebhookUrl = webhookUrl,
Secret = secret,
Events = new[] { "checkin_create", "checkin_update", "checkin_delete" }
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting webhook info");
return StatusCode(500, "Error retrieving webhook info");
}
}

/// <summary>
/// Receive a webhook event from Träwelling (anonymous, secured via HMAC-SHA256 signature).
/// The URL path includes the user GUID to identify which OVDB user this webhook belongs to.
/// </summary>
[HttpPost("webhook/{userGuid}")]
[AllowAnonymous]
public async Task<IActionResult> ReceiveWebhook(Guid userGuid)
{
try
{
// Find user by GUID
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Guid == userGuid);
if (user == null)
return NotFound();

if (string.IsNullOrEmpty(user.TrawellingWebhookSecret))
return Unauthorized("Webhook not configured for this user");

// Read raw request body for HMAC verification
Request.EnableBuffering();
string body;
using (var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true))
{
body = await reader.ReadToEndAsync();
}
Request.Body.Position = 0;

// Verify HMAC-SHA256 signature from 'Signature' header
if (!Request.Headers.TryGetValue("Signature", out var signatureHeader))
{
_logger.LogWarning("Webhook received for user {UserGuid} without Signature header", userGuid);
return Unauthorized("Missing signature");
}

var expectedSignature = ComputeHmacSha256(body, user.TrawellingWebhookSecret);
if (!string.Equals(expectedSignature, signatureHeader.ToString(), StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("Invalid webhook signature for user {UserGuid}", userGuid);
return Unauthorized("Invalid signature");
}

// Parse and process the webhook payload
var payload = JsonConvert.DeserializeObject<TrawellingWebhookPayload>(body);
if (payload == null)
return BadRequest("Invalid payload");

_logger.LogInformation("Received Träwelling webhook event {Event} for user {UserId}", payload.Event, user.Id);

switch (payload.Event)
{
case "checkin_create":
await _trawellingService.ProcessWebhookCheckinCreateAsync(user, payload.Status);
break;
case "checkin_update":
await _trawellingService.ProcessWebhookCheckinUpdateAsync(user, payload.Status);
break;
case "checkin_delete":
if (payload.Status != null)
await _trawellingService.ProcessWebhookCheckinDeleteAsync(user, payload.Status.Id);
break;
default:
_logger.LogInformation("Unhandled Träwelling webhook event type: {Event}", payload.Event);
break;
}

return Ok();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing Träwelling webhook for user {UserGuid}", userGuid);
return StatusCode(500, "Error processing webhook");
}
}

private static string ComputeHmacSha256(string body, string secret)
{
var keyBytes = Encoding.UTF8.GetBytes(secret);
var bodyBytes = Encoding.UTF8.GetBytes(body);
using var hmac = new HMACSHA256(keyBytes);
var hashBytes = hmac.ComputeHash(bodyBytes);
return Convert.ToHexString(hashBytes).ToLower();
} }
}
17 changes: 17 additions & 0 deletions OV_DB/Models/TrawellingDTOs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,23 @@ public class TrawellingIgnoreResponse
public string Message { get; set; }
}

// Webhook support
public class TrawellingWebhookPayload
{
[JsonProperty("event")]
public string Event { get; set; }

[JsonProperty("status")]
public TrawellingStatus Status { get; set; }
}

public class TrawellingWebhookInfo
{
public string WebhookUrl { get; set; }
public string Secret { get; set; }
public string[] Events { get; set; }
}

public class TrawellingStats
{
public int TotalTrips { get; set; }
Expand Down
Loading