Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
114 changes: 114 additions & 0 deletions dotnet/EcencyApi.Tests/AiTranscribeContentTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
using System.Text;
using EcencyApi.Handlers;
using Xunit;

namespace EcencyApi.Tests;

/// <summary>
/// The transcription route is the only private-api endpoint that forwards a file, so it
/// is the only one building a multipart body by hand. Two things about that body have
/// consequences beyond a failed request:
///
/// `us` decides whose Points upstream burns. It has to be the caller resolved from the
/// signed code; taking it from the request would let anyone spend anyone else's balance.
///
/// The multipart boundary is generated, so a Content-Type set anywhere else silently
/// makes the body unparseable upstream and surfaces only as a confusing 400.
/// </summary>
public class AiTranscribeContentTests
{
private static MultipartFormDataContent Build(
string username = "good-karma",
string durationMs = "30000",
string? idem = "abcd1234efgh",
string? fileName = "clip.m4a",
string? contentType = "audio/mp4")
{
var audio = new MemoryStream(Encoding.UTF8.GetBytes("fake audio bytes"));
return PrivateApi.BuildTranscribeContent(
username, durationMs, idem, audio, fileName, contentType);
}

private static async Task<string> Render(MultipartFormDataContent content)
{
return await content.ReadAsStringAsync();
}

[Fact]
public async Task UsIsTheAuthenticatedCallerNotAClientValue()
{
using var content = Build(username: "good-karma");
var body = await Render(content);

Assert.Contains("name=us", body.Replace("\"", ""));
Assert.Contains("good-karma", body);
}

[Fact]
public async Task CarriesDurationAndIdempotencyKey()
{
using var content = Build(durationMs: "45000", idem: "abcd1234efgh");
var body = await Render(content);

Assert.Contains("45000", body);
Assert.Contains("abcd1234efgh", body);
}

[Theory]
[InlineData(null)]
[InlineData("")]
public async Task OmitsAnEmptyIdempotencyKeyRatherThanSendingBlank(string? idem)
{
// Upstream validates the key against [A-Za-z0-9_-]{8,64}; a blank one is a 400,
// whereas an absent one is simply an un-deduplicated request.
using var content = Build(idem: idem);
var body = await Render(content);

Assert.DoesNotContain("idempotency_key", body);
}

[Fact]
public async Task SendsTheAudioPartWithItsFilename()
{
using var content = Build(fileName: "clip.m4a");
var body = await Render(content);

Assert.Contains("name=audio", body.Replace("\"", ""));
Assert.Contains("clip.m4a", body);
Assert.Contains("fake audio bytes", body);
}

[Fact]
public async Task FallsBackToAFilenameWhenTheClientSendsNone()
{
using var content = Build(fileName: null);
var body = await Render(content);

Assert.Contains("name=audio", body.Replace("\"", ""));
}

[Fact]
public void ContentTypeCarriesAGeneratedBoundary()
{
using var content = Build();

var mediaType = content.Headers.ContentType;
Assert.NotNull(mediaType);
Assert.Equal("multipart/form-data", mediaType!.MediaType);

var boundary = mediaType.Parameters
.FirstOrDefault(p => p.Name.Equals("boundary", StringComparison.OrdinalIgnoreCase));
Assert.NotNull(boundary);
Assert.False(string.IsNullOrWhiteSpace(boundary!.Value));
}

[Fact]
public async Task TolerantOfAMissingAudioContentType()
{
// expo-audio and MediaRecorder do not always label the part.
using var content = Build(contentType: null);
var body = await Render(content);

Assert.Contains("fake audio bytes", body);
}
}
110 changes: 110 additions & 0 deletions dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs
Original file line number Diff line number Diff line change
Expand Up @@ -613,4 +613,114 @@ public static async Task AiAssist(HttpContext ctx)
// AI assist generation can take a long time; keep it long.
await Upstream.Pipe(ApiClient.ApiRequest("ai-assist", HttpMethod.Post, null, data, null, 120000), ctx);
}

public static async Task AiTranscribePrice(HttpContext ctx)
{
var body = await ctx.ReadBody();
var username = await ValidateCode(body);
if (username == null)
{
await ctx.SendText(401, "Unauthorized");
return;
}
await Upstream.Pipe(
ApiClient.ApiRequest($"ai-transcribe-price?us={username}", HttpMethod.Get), ctx);
}

/// <summary>
/// Dictation. Unlike every other private-api route this one carries a file, so the
/// request is multipart/form-data rather than JSON and the auth code arrives as a
/// form field instead of a JSON property.
///
/// `us` is taken from the validated code and never from the client, matching
/// AiAssist: the upstream bills whoever `us` names, so accepting it from the body
/// would let a caller spend someone else's Points.
/// </summary>
public static async Task AiTranscribe(HttpContext ctx)
{
if (!ctx.Request.HasFormContentType)
{
await ctx.SendText(400, "Expected multipart/form-data");
return;
}

IFormCollection form;
try
{
form = await ctx.Request.ReadFormAsync();
}
catch (Exception e)
{
// Malformed multipart, or a body past Kestrel's limit.
Console.Error.WriteLine($"aiTranscribe(): unreadable form: {e.Message}");
await ctx.SendText(400, "Bad Request");
return;
}

// ValidateCode takes the JSON body shape, so lift the form field into it and
// reuse the one implementation rather than growing a second auth path.
var codeBody = new JsonObject { ["code"] = form["code"].ToString() };
var username = await ValidateCode(codeBody);
if (username == null)
{
await ctx.SendText(401, "Unauthorized");
return;
}

var audio = form.Files.GetFile("audio");
if (audio == null)
{
await ctx.SendText(400, "Missing audio");
return;
}

await using var audioStream = audio.OpenReadStream();
using var content = BuildTranscribeContent(
username,
form["duration_ms"].ToString(),
form["idempotency_key"].ToString(),
audioStream,
audio.FileName,
audio.ContentType);

// Transcription is a vendor round trip on top of the upload; keep it long,
// matching ai-assist and ai-image-generate.
await Upstream.Pipe(ApiClient.ApiMultipartRequest("ai-transcribe", content, 120000), ctx);
}

/// <summary>
/// Builds the upstream multipart body. Split out from the handler so the part it
/// gets wrong-once-and-badly is testable: `us` must be the caller resolved from the
/// signed code, never a value the client supplied, because upstream bills whoever
/// `us` names.
/// </summary>
public static MultipartFormDataContent BuildTranscribeContent(
string username,
string durationMs,
string? idempotencyKey,
Stream audio,
string? fileName,
string? contentType)
{
var content = new MultipartFormDataContent();
content.Add(new StringContent(username), "us");
content.Add(new StringContent(durationMs), "duration_ms");

// Omit rather than send empty: upstream treats an empty key as absent anyway,
// and sending "" would fail its [A-Za-z0-9_-]{8,64} validator with a 400.
if (!string.IsNullOrEmpty(idempotencyKey))
{
content.Add(new StringContent(idempotencyKey), "idempotency_key");
}

var fileContent = new StreamContent(audio);
if (!string.IsNullOrEmpty(contentType))
{
fileContent.Headers.ContentType =
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
System.Net.Http.Headers.MediaTypeHeaderValue.Parse(contentType);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle malformed audio Content-Type without returning 500

When an authenticated multipart upload contains a syntactically malformed per-file Content-Type, IFormFile.ContentType can carry that raw value here and MediaTypeHeaderValue.Parse throws FormatException. This occurs after the form-reading catch and before Upstream.Pipe, so the global middleware turns a client-controlled part header into a 500 and the transcription is never forwarded; use TryParse and omit an invalid media type, as is already done when the header is missing.

Useful? React with 👍 / 👎.

}
content.Add(fileContent, "audio", string.IsNullOrEmpty(fileName) ? "audio" : fileName);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return content;
}
}
2 changes: 2 additions & 0 deletions dotnet/EcencyApi/Handlers/Routes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ public static void Map(WebApplication app)
app.MapPost("/private-api/ai-generate-image", PrivateApi.AiGenerateImage);
app.MapPost("/private-api/ai-assist-price", PrivateApi.AiAssistPrice);
app.MapPost("/private-api/ai-assist", PrivateApi.AiAssist);
app.MapPost("/private-api/ai-transcribe-price", PrivateApi.AiTranscribePrice);
app.MapPost("/private-api/ai-transcribe", PrivateApi.AiTranscribe);
Comment on lines +160 to +161

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Register the new routes as parity divergences

Adding these routes causes the legacy-Node parity run to report six deterministic failures: dotnet/parity/driver.py's load_routes and build_catalog automatically generate ::min, ::pop, and ::badcode JSON probes for each new POST route, while the reference image has neither route and returns 404 instead of the candidate's 400/401. Because none of those case IDs are in KNOWN_DIVERGENCES, every parity comparison now fails before it can identify unintended regressions; add exclusions for the intentional additive routes or otherwise teach the harness how to classify them.

Useful? React with 👍 / 👎.

app.MapPost("/private-api/usr-activity", PrivateApi.Activities);
app.MapPost("/private-api/get-game", PrivateApi.GameGet);
app.MapPost("/private-api/post-game", PrivateApi.GamePost);
Expand Down
28 changes: 28 additions & 0 deletions dotnet/EcencyApi/Infrastructure/ApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,34 @@ public static Task<UpstreamResponse> ApiRequest(
return Upstream.BaseApiRequest(url, method, headers, payload, query, timeoutMs);
}

/// <summary>
/// multipart/form-data variant of ApiRequest, for endpoints carrying a file.
/// Applies the same PRIVATE_API_AUTH headers and fails the same way when they
/// can't be built.
/// </summary>
public static Task<UpstreamResponse> ApiMultipartRequest(
string endpoint,
MultipartFormDataContent content,
int timeoutMs = Upstream.DefaultTimeoutMs)
{
var apiAuth = MakeApiAuth();
if (apiAuth == null)
{
Console.Error.WriteLine("Api auth couldn't be create!");
throw new ApiAuthException();
}

var url = $"{Config.PrivateApiAddr}/{endpoint}";

var headers = new List<KeyValuePair<string, string>>();
foreach (var kv in apiAuth)
{
headers.Add(kv);
}

return Upstream.BaseMultipartRequest(url, content, headers, timeoutMs);
}

/// <summary>fetchPromotedEntries + getPromotedEntries (5-minute cached, shuffled).</summary>
public static async Task<JsonArray> GetPromotedEntries(int limit, int shortContent)
{
Expand Down
90 changes: 72 additions & 18 deletions dotnet/EcencyApi/Infrastructure/Upstream.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,30 +131,84 @@ public static async Task<UpstreamResponse> BaseApiRequest(

using (resp)
{
var bytes = await resp.Content.ReadAsByteArrayAsync();
var status = (int)resp.StatusCode;
var respHeaders = new HttpResponseHeaders2(resp);
return await ReadUpstreamResponse(resp);
}
}

// axios responseType "json": try to parse; fall back to raw text.
var text = Encoding.UTF8.GetString(bytes);
if (text.Length == 0)
{
// axios turns an empty body into an empty string
return new UpstreamResponse { Status = status, RawText = "", Headers = respHeaders };
}
/// <summary>
/// Multipart/form-data variant of BaseApiRequest, for endpoints that carry a file
/// rather than a JSON body. The JSON path can't be reused: it always sets a
/// StringContent body with an application/json content type, and multipart needs
/// the boundary that MultipartFormDataContent generates for itself.
///
/// The caller owns building the content; response handling is identical.
/// </summary>
public static async Task<UpstreamResponse> BaseMultipartRequest(
string url,
MultipartFormDataContent content,
IEnumerable<KeyValuePair<string, string>>? headers = null,
int timeoutMs = DefaultTimeoutMs)
{
using var req = new HttpRequestMessage(HttpMethod.Post, url);
req.Content = content;

try
if (headers != null)
{
foreach (var (name, value) in headers)
{
var node = JsonNode.Parse(text, documentOptions: new JsonDocumentOptions
// Never let a caller override Content-Type here: multipart carries a
// generated boundary, and replacing it makes the body unparseable
// upstream in a way that only shows up as a confusing 400.
if (name.Equals("Content-Type", StringComparison.OrdinalIgnoreCase))
{
AllowTrailingCommas = false,
});
return new UpstreamResponse { Status = status, Json = node, Headers = respHeaders };
continue;
}
req.Headers.TryAddWithoutValidation(name, value);
}
catch (JsonException)
}

using var cts = new CancellationTokenSource(timeoutMs);
HttpResponseMessage resp;
try
{
resp = await Http.SendAsync(req, HttpCompletionOption.ResponseContentRead, cts.Token);
}
catch (OperationCanceledException e) when (cts.IsCancellationRequested)
{
throw new UpstreamTimeoutException(url, e);
}

using (resp)
{
return await ReadUpstreamResponse(resp);
}
}

private static async Task<UpstreamResponse> ReadUpstreamResponse(HttpResponseMessage resp)
{
var bytes = await resp.Content.ReadAsByteArrayAsync();
var status = (int)resp.StatusCode;
var respHeaders = new HttpResponseHeaders2(resp);

// axios responseType "json": try to parse; fall back to raw text.
var text = Encoding.UTF8.GetString(bytes);
if (text.Length == 0)
{
// axios turns an empty body into an empty string
return new UpstreamResponse { Status = status, RawText = "", Headers = respHeaders };
}

try
{
var node = JsonNode.Parse(text, documentOptions: new JsonDocumentOptions
{
return new UpstreamResponse { Status = status, RawText = text, Headers = respHeaders };
}
AllowTrailingCommas = false,
});
return new UpstreamResponse { Status = status, Json = node, Headers = respHeaders };
}
catch (JsonException)
{
return new UpstreamResponse { Status = status, RawText = text, Headers = respHeaders };
}
}

Expand Down
Loading