diff --git a/dotnet/EcencyApi.Tests/AiTranscribeContentTests.cs b/dotnet/EcencyApi.Tests/AiTranscribeContentTests.cs new file mode 100644 index 00000000..869f3959 --- /dev/null +++ b/dotnet/EcencyApi.Tests/AiTranscribeContentTests.cs @@ -0,0 +1,150 @@ +using System.Text; +using EcencyApi.Handlers; +using Xunit; + +namespace EcencyApi.Tests; + +/// +/// 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. +/// +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 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); + } + + [Theory] + // A bare token with no subtype, a trailing separator, and a broken parameter -- + // all things a client can put in a multipart part header. + [InlineData("audio")] + [InlineData("audio/")] + [InlineData("audio/mp4;")] + [InlineData("audio/mp4; codecs=")] + [InlineData("not a media type at all")] + [InlineData("///")] + public async Task AMalformedContentTypeIsDroppedRatherThanThrowing(string contentType) + { + // MediaTypeHeaderValue.Parse throws FormatException on these, and the throw + // would escape the handler's form-reading catch and become a 500 from the + // global middleware. The upload itself is fine, so the label is dropped and + // the request proceeds. + var ex = Record.Exception(() => + { + using var content = Build(contentType: contentType); + }); + Assert.Null(ex); + + using var built = Build(contentType: contentType); + var body = await Render(built); + Assert.Contains("fake audio bytes", body); + } + + [Fact] + public async Task AValidContentTypeIsStillForwarded() + { + // The permissive path above must not have made this a no-op. + using var content = Build(contentType: "audio/mp4"); + var body = await Render(content); + + Assert.Contains("audio/mp4", body); + } +} diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs b/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs index c1d711e4..66363e08 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs @@ -613,4 +613,120 @@ 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); + } + + /// + /// 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. + /// + 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); + } + + /// + /// 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. + /// + 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); + // TryParse, not Parse. This value is client-controlled, and Parse throws + // FormatException on malformed input -- which escapes the handler's + // form-reading catch and surfaces as a 500 from the global middleware. A + // label we cannot parse is not worth failing an otherwise valid upload over, + // so it is dropped exactly like an absent one; upstream identifies the audio + // from its contents regardless. + if (!string.IsNullOrEmpty(contentType) && + System.Net.Http.Headers.MediaTypeHeaderValue.TryParse(contentType, out var parsedType)) + { + fileContent.Headers.ContentType = parsedType; + } + content.Add(fileContent, "audio", string.IsNullOrEmpty(fileName) ? "audio" : fileName); + + return content; + } } diff --git a/dotnet/EcencyApi/Handlers/Routes.cs b/dotnet/EcencyApi/Handlers/Routes.cs index 50648a0c..e4d40a51 100644 --- a/dotnet/EcencyApi/Handlers/Routes.cs +++ b/dotnet/EcencyApi/Handlers/Routes.cs @@ -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); app.MapPost("/private-api/usr-activity", PrivateApi.Activities); app.MapPost("/private-api/get-game", PrivateApi.GameGet); app.MapPost("/private-api/post-game", PrivateApi.GamePost); diff --git a/dotnet/EcencyApi/Infrastructure/ApiClient.cs b/dotnet/EcencyApi/Infrastructure/ApiClient.cs index 95d1fefc..a2cab631 100644 --- a/dotnet/EcencyApi/Infrastructure/ApiClient.cs +++ b/dotnet/EcencyApi/Infrastructure/ApiClient.cs @@ -68,6 +68,23 @@ public static Task ApiRequest( JsonNode? payload = null, IEnumerable>? query = null, int timeoutMs = Upstream.DefaultTimeoutMs) + { + var headers = RequiredAuthHeaders(); + if (extraHeaders != null) + { + headers.AddRange(extraHeaders); + } + + return Upstream.BaseApiRequest( + $"{Config.PrivateApiAddr}/{endpoint}", method, headers, payload, query, timeoutMs); + } + + /// + /// PRIVATE_API_AUTH headers every upstream call carries. Throws the same way the + /// Node version failed when they can't be built, so a misconfigured deployment is + /// loud rather than silently unauthenticated. + /// + private static List> RequiredAuthHeaders() { var apiAuth = MakeApiAuth(); if (apiAuth == null) @@ -76,19 +93,26 @@ public static Task ApiRequest( throw new ApiAuthException(); } - var url = $"{Config.PrivateApiAddr}/{endpoint}"; - var headers = new List>(); foreach (var kv in apiAuth) { headers.Add(kv); } - if (extraHeaders != null) - { - headers.AddRange(extraHeaders); - } + return headers; + } - return Upstream.BaseApiRequest(url, method, headers, payload, query, timeoutMs); + /// + /// 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. + /// + public static Task ApiMultipartRequest( + string endpoint, + MultipartFormDataContent content, + int timeoutMs = Upstream.DefaultTimeoutMs) + { + return Upstream.BaseMultipartRequest( + $"{Config.PrivateApiAddr}/{endpoint}", content, RequiredAuthHeaders(), timeoutMs); } /// fetchPromotedEntries + getPromotedEntries (5-minute cached, shuffled). diff --git a/dotnet/EcencyApi/Infrastructure/Upstream.cs b/dotnet/EcencyApi/Infrastructure/Upstream.cs index 6c539c30..e5add5d0 100644 --- a/dotnet/EcencyApi/Infrastructure/Upstream.cs +++ b/dotnet/EcencyApi/Infrastructure/Upstream.cs @@ -131,30 +131,84 @@ public static async Task 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 }; - } + /// + /// 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. + /// + public static async Task BaseMultipartRequest( + string url, + MultipartFormDataContent content, + IEnumerable>? 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 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 }; } } diff --git a/dotnet/parity/__pycache__/driver.cpython-312.pyc b/dotnet/parity/__pycache__/driver.cpython-312.pyc index 38e24022..40b03ce4 100644 Binary files a/dotnet/parity/__pycache__/driver.cpython-312.pyc and b/dotnet/parity/__pycache__/driver.cpython-312.pyc differ diff --git a/dotnet/parity/driver.py b/dotnet/parity/driver.py index e990cc71..30948790 100644 --- a/dotnet/parity/driver.py +++ b/dotnet/parity/driver.py @@ -216,6 +216,14 @@ def norm_body(text): return text +AI_TRANSCRIBE_DIVERGENCE = ( + "Dictation route added after the port (ePoints ai-transcribe). The reference build " + "has no such route and answers 404; this one validates the request and answers " + "400/401. Deterministic and additive -- no behavior the reference ever had is " + "changing, so there is nothing meaningful to diff. The catalog generates ::min, " + "::pop and ::badcode for every POST route, which is why all three appear here." +) + # Cases where the C# port intentionally differs from Node (Node bugs the port fixes). KNOWN_DIVERGENCES = { "/auth-api/hs-token-refresh::min": @@ -233,6 +241,21 @@ def norm_body(text): "has no such route, so it answers with the unmatched-GET template page while " "this one proxies the tips payload. Deterministic and additive; the POST case " "still covers the shared upstream behavior.", + # Dictation routes, added after the port. The reference build predates them and so + # answers 404, while this build validates and answers 400/401. Purely additive: no + # behavior the reference ever had is changing, so there is nothing to compare. + "/private-api/ai-transcribe-price::min": + AI_TRANSCRIBE_DIVERGENCE, + "/private-api/ai-transcribe-price::pop": + AI_TRANSCRIBE_DIVERGENCE, + "/private-api/ai-transcribe-price::badcode": + AI_TRANSCRIBE_DIVERGENCE, + "/private-api/ai-transcribe::min": + AI_TRANSCRIBE_DIVERGENCE, + "/private-api/ai-transcribe::pop": + AI_TRANSCRIBE_DIVERGENCE, + "/private-api/ai-transcribe::badcode": + AI_TRANSCRIBE_DIVERGENCE, } # Deliberately NOT listed above: /wallet-api/portfolio-v2::pop, whose HP action list