Skip to content

Commit 65ea915

Browse files
committed
fix: auto-detect MIME type for file uploads (fixes image upload error)
- Add MimeTypes helper mapping 18 extensions to Content-Type - UploadFileDataAsync/ResumableAsync now use detected MIME from fileName - Fixes 'Error reading file from request in direct mode' on iu.oneme.ru CDN - Document format restrictions: HTML/unsupported extensions return 'File extension is forbidden' - Add unit tests for MIME detection (image, unknown, null) - Bump version 0.6.2-alpha -> 0.6.3-alpha - Update CHANGELOG
1 parent 29ca61e commit 65ea915

6 files changed

Lines changed: 167 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## [Unreleased]
9+
## [0.6.3-alpha] - 2026-06-08
10+
11+
### Fixed
12+
13+
- **Image/Video/Audio upload MIME type**: `UploadFileDataAsync` and `UploadFileResumableAsync` no longer hardcode `Content-Type: application/octet-stream`. The MIME type is now auto-detected from the file name extension (e.g. `image/jpeg` for `.jpg`) and falls back to `application/octet-stream` for unknown extensions. This fixes the `"Error reading file from request in direct mode"` error that occurred when uploading images to the `iu.oneme.ru` CDN, which requires a correct MIME type for validation.
14+
15+
### Added
16+
17+
- **MIME type helper**: `Max.Bot.Networking.MimeTypes` maps 18 common file extensions to `Content-Type` values for images, video, and audio.
18+
19+
### Changed
20+
21+
- **Format restrictions documented**: `IFilesApi.UploadFileAsync` XML docs now note that unsupported extensions (e.g. `.html`) return `"File extension is forbidden"` from the Max API, and list supported formats per upload type.
22+
923

1024
## [0.6.2-alpha] - 2026-05-14
1125

src/Max.Bot/Api/FilesApi.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ HttpContent CreateContent()
5151
{
5252
var content = new MultipartFormDataContent();
5353
var streamContent = new StreamContent(new NonDisposingStreamWrapper(fileStream));
54-
streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
54+
streamContent.Headers.ContentType = new MediaTypeHeaderValue(MimeTypes.FromFileName(fileName) ?? "application/octet-stream");
5555
content.Add(streamContent, "data", fileName ?? "file");
5656
return content;
5757
}
@@ -86,7 +86,7 @@ public async Task<FileUploadResult> UploadFileResumableAsync(string uploadUrl, S
8686
HttpContent CreateChunkContent()
8787
{
8888
var content = new ByteArrayContent(chunkData);
89-
content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
89+
content.Headers.ContentType = new MediaTypeHeaderValue(MimeTypes.FromFileName(fileName) ?? "application/octet-stream");
9090
// Always send Content-Range. If length unknown, use '*'
9191
content.Headers.ContentRange = fileLength > 0
9292
? new ContentRangeHeaderValue(currentOffset, currentOffset + currentBytesRead - 1, fileLength)

src/Max.Bot/Api/IFilesApi.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ public interface IFilesApi
1414
/// <summary>
1515
/// Uploads a file and returns an upload URL and optional token.
1616
/// </summary>
17+
/// <remarks>
18+
/// <para>Max API restricts supported formats:</para>
19+
/// <list type="bullet">
20+
/// <item><description>For <c>type=file</c>, unsupported extensions (e.g. <c>.html</c>) return 'File extension is forbidden'.</description></item>
21+
/// <item><description>For <c>type=image</c>, supported formats: JPG, JPEG, PNG, GIF, TIFF, BMP, HEIC.</description></item>
22+
/// <item><description>For <c>type=video</c>, supported formats: MP4, MOV, MKV, WEBM.</description></item>
23+
/// <item><description>For <c>type=audio</c>, supported formats: MP3, WAV, M4A and others.</description></item>
24+
/// </list>
25+
/// </remarks>
1726
/// <param name="uploadType">The type of file to upload.</param>
1827
/// <param name="cancellationToken">A cancellation token to cancel the operation.</param>
1928
/// <returns>A task that represents the asynchronous operation. The task result contains the upload response with URL and optional token.</returns>
@@ -27,8 +36,8 @@ public interface IFilesApi
2736
/// </summary>
2837
/// <param name="uploadUrl">The upload URL obtained from UploadFileAsync.</param>
2938
/// <param name="fileStream">The stream containing the file data to upload.</param>
30-
/// <param name="fileName">The name of the file. Optional.</param>
3139
/// <param name="cancellationToken">A cancellation token to cancel the operation.</param>
40+
/// <param name="fileName">The name of the file. Used to determine the MIME type. If the extension is unknown, falls back to "application/octet-stream". Optional.</param>
3241
/// <returns>A task that represents the asynchronous operation. The task result contains the upload result with a token.</returns>
3342
/// <exception cref="ArgumentException">Thrown when uploadUrl is null or empty, or fileStream is not readable.</exception>
3443
/// <exception cref="ArgumentNullException">Thrown when fileStream is null.</exception>
@@ -51,4 +60,3 @@ public interface IFilesApi
5160
/// <exception cref="Max.Bot.Exceptions.MaxNetworkException">Thrown when a network error occurs.</exception>
5261
Task<FileUploadResult> UploadFileResumableAsync(string uploadUrl, Stream fileStream, long chunkSize = 1024 * 1024, string? fileName = null, CancellationToken cancellationToken = default);
5362
}
54-

src/Max.Bot/Max.Bot.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<Nullable>enable</Nullable>
66

77
<PackageId>MaxMessenger.Bot</PackageId>
8-
<Version>0.6.2-alpha</Version>
8+
<Version>0.6.3-alpha</Version>
99
<Authors>MaxBotNet Contributors</Authors>
1010
<Description>C# library for Max Messenger Bot API (.NET 10, .NET 9, .NET 8)</Description>
1111
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using System.Collections.Generic;
2+
3+
namespace Max.Bot.Networking;
4+
5+
/// <summary>
6+
/// Maps file extensions to MIME types for upload Content-Type detection.
7+
/// </summary>
8+
internal static class MimeTypes
9+
{
10+
private static readonly Dictionary<string, string> Map = new(StringComparer.OrdinalIgnoreCase)
11+
{
12+
{ ".jpg", "image/jpeg" },
13+
{ ".jpeg", "image/jpeg" },
14+
{ ".png", "image/png" },
15+
{ ".gif", "image/gif" },
16+
{ ".webp", "image/webp" },
17+
{ ".bmp", "image/bmp" },
18+
{ ".tiff", "image/tiff" },
19+
{ ".tif", "image/tiff" },
20+
{ ".heic", "image/heic" },
21+
{ ".mp4", "video/mp4" },
22+
{ ".mov", "video/quicktime" },
23+
{ ".mkv", "video/x-matroska" },
24+
{ ".webm", "video/webm" },
25+
{ ".mp3", "audio/mpeg" },
26+
{ ".wav", "audio/wav" },
27+
{ ".m4a", "audio/mp4" },
28+
{ ".ogg", "audio/ogg" },
29+
{ ".opus", "audio/opus" },
30+
};
31+
32+
internal static string? FromFileName(string? fileName)
33+
{
34+
if (string.IsNullOrWhiteSpace(fileName))
35+
return null;
36+
var ext = fileName.LastIndexOf('.') is >= 0 and var idx
37+
? fileName[idx..]
38+
: fileName;
39+
return Map.TryGetValue(ext, out var mime) ? mime : null;
40+
}
41+
}

tests/Max.Bot.Tests/Unit/Api/FilesApiTests.cs

Lines changed: 99 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -255,17 +255,105 @@ await act.Should().ThrowAsync<ArgumentException>()
255255
public async Task UploadFileDataAsync_ShouldReturnFileUploadResult_WhenRequestSucceeds()
256256
{
257257
// Arrange
258-
var responseJson = "{\"token\":\"test-token\",\"file_id\":12345}";
259-
var mockResponse = new HttpResponseMessage(HttpStatusCode.OK)
260-
{
261-
Content = new StringContent(responseJson)
262-
};
258+
var uploadUrl = "https://example.com/upload";
259+
var expectedToken = "test-token-upload";
260+
var responseJson = JsonSerializer.Serialize(new { token = expectedToken, file_id = 12345 });
261+
262+
Func<HttpContent?>? capturedFactory = null;
263+
_mockHttpClient
264+
.Setup(x => x.SendAsyncRaw(
265+
uploadUrl,
266+
It.IsAny<Func<HttpContent?>>(),
267+
It.IsAny<CancellationToken>(),
268+
It.IsAny<HttpMethod?>()))
269+
.Callback<string, Func<HttpContent?>?, CancellationToken, HttpMethod?>((_, factory, _, _) =>
270+
{
271+
capturedFactory = factory;
272+
})
273+
.ReturnsAsync(responseJson);
274+
275+
var filesApi = new FilesApi(_mockHttpClient.Object, _options);
276+
using var stream = new MemoryStream([1, 2, 3]);
277+
278+
// Act
279+
var result = await filesApi.UploadFileDataAsync(uploadUrl, stream, "photo.jpg");
280+
281+
// Assert
282+
result.Should().NotBeNull();
283+
result.Token.Should().Be(expectedToken);
263284

264-
// We need to use a real HttpClient or mock the SendAsync
265-
// Since FilesApi creates its own HttpClient, we might need to adjust it for testing
266-
// or rely on the IMaxHttpClient if it was used.
267-
// Wait, FilesApi.cs: _httpClient = new HttpClient { ... };
268-
// This makes it hard to test without reflection or a wrapper.
285+
capturedFactory.Should().NotBeNull();
286+
var content = capturedFactory!();
287+
content.Should().NotBeNull();
288+
var body = await content!.ReadAsStringAsync();
289+
body.Should().Contain("Content-Type: image/jpeg");
269290
}
270-
}
271291

292+
[Fact]
293+
public async Task UploadFileDataAsync_ShouldUseOctetStream_WhenExtensionUnknown()
294+
{
295+
// Arrange
296+
var uploadUrl = "https://example.com/upload";
297+
var responseJson = JsonSerializer.Serialize(new { token = "test-token" });
298+
299+
Func<HttpContent?>? capturedFactory = null;
300+
_mockHttpClient
301+
.Setup(x => x.SendAsyncRaw(
302+
uploadUrl,
303+
It.IsAny<Func<HttpContent?>>(),
304+
It.IsAny<CancellationToken>(),
305+
It.IsAny<HttpMethod?>()))
306+
.Callback<string, Func<HttpContent?>?, CancellationToken, HttpMethod?>((_, factory, _, _) =>
307+
{
308+
capturedFactory = factory;
309+
})
310+
.ReturnsAsync(responseJson);
311+
312+
var filesApi = new FilesApi(_mockHttpClient.Object, _options);
313+
using var stream = new MemoryStream([1, 2, 3]);
314+
315+
// Act
316+
await filesApi.UploadFileDataAsync(uploadUrl, stream, "data.bin");
317+
318+
// Assert
319+
capturedFactory.Should().NotBeNull();
320+
var content = capturedFactory!();
321+
content.Should().NotBeNull();
322+
var body = await content!.ReadAsStringAsync();
323+
body.Should().Contain("Content-Type: application/octet-stream");
324+
}
325+
326+
[Fact]
327+
public async Task UploadFileDataAsync_ShouldUseOctetStream_WhenFileNameNull()
328+
{
329+
// Arrange
330+
var uploadUrl = "https://example.com/upload";
331+
var responseJson = JsonSerializer.Serialize(new { token = "test-token" });
332+
333+
Func<HttpContent?>? capturedFactory = null;
334+
_mockHttpClient
335+
.Setup(x => x.SendAsyncRaw(
336+
uploadUrl,
337+
It.IsAny<Func<HttpContent?>>(),
338+
It.IsAny<CancellationToken>(),
339+
It.IsAny<HttpMethod?>()))
340+
.Callback<string, Func<HttpContent?>?, CancellationToken, HttpMethod?>((_, factory, _, _) =>
341+
{
342+
capturedFactory = factory;
343+
})
344+
.ReturnsAsync(responseJson);
345+
346+
var filesApi = new FilesApi(_mockHttpClient.Object, _options);
347+
using var stream = new MemoryStream([1, 2, 3]);
348+
349+
// Act
350+
await filesApi.UploadFileDataAsync(uploadUrl, stream, null);
351+
352+
// Assert
353+
capturedFactory.Should().NotBeNull();
354+
var content = capturedFactory!();
355+
content.Should().NotBeNull();
356+
var body = await content!.ReadAsStringAsync();
357+
body.Should().Contain("Content-Type: application/octet-stream");
358+
}
359+
}

0 commit comments

Comments
 (0)