diff --git a/OpenJibo/docs/feature-backlog.md b/OpenJibo/docs/feature-backlog.md index b93b5f7..3fcb49d 100644 --- a/OpenJibo/docs/feature-backlog.md +++ b/OpenJibo/docs/feature-backlog.md @@ -466,7 +466,7 @@ Current release theme: ### 11. Binary-Safe Media Storage -- Status: `ready` +- Status: `in progress` - Tags: `storage`, `protocol` - Why next: - the first gallery bridge stores metadata and text-body placeholders, but final gallery support needs originals and thumbnails @@ -474,6 +474,9 @@ Current release theme: - whether stock gallery expects originals, thumbnails, or both - what upload metadata must survive for gallery refresh - how to map this cleanly to Blob Storage +- Implementation notes: + - media content now flows through a storage seam with file and Azure Blob adapters + - the protocol still serves the legacy text-body contract, but the original payload is now persisted separately and can be swapped to binary-native storage later ### Next Up (`2026-05-06`): Dialog Parsing Expansion And Ambiguity Guardrails diff --git a/OpenJibo/docs/release-1.0.19-plan.md b/OpenJibo/docs/release-1.0.19-plan.md index d636e98..fccc96f 100644 --- a/OpenJibo/docs/release-1.0.19-plan.md +++ b/OpenJibo/docs/release-1.0.19-plan.md @@ -343,6 +343,7 @@ First completed slice in this personal-report parity track: 8. STT noise-screening and short-utterance reliability pass 9. Provider-backed news expansion and deeper weather parity 10. Capture indexing and retention boundary for group testing, including a lightweight manifest beside raw capture files +11. Binary-safe media storage seam with file and Azure Blob adapters, ready for original/thumbnails follow-up For slice 1, use the new import ladder above to keep the work grounded in what OpenJibo can already render today versus what needs new scaffolding. For slices 2-5, use Pegasus phrase lists, MIM IDs, and behavior patterns as the source anchor before broadening into OpenJibo-native improvements. diff --git a/OpenJibo/src/Jibo.Cloud/README.md b/OpenJibo/src/Jibo.Cloud/README.md index ccf34f6..2bea4dd 100644 --- a/OpenJibo/src/Jibo.Cloud/README.md +++ b/OpenJibo/src/Jibo.Cloud/README.md @@ -57,11 +57,14 @@ It shows the expected keys for: - `OpenJibo:State:ConnectionString` - `OpenJibo:PersonalMemory:Backend` - `OpenJibo:PersonalMemory:ConnectionString` +- `OpenJibo:Media:Backend` +- `OpenJibo:Media:ConnectionString` The connection string can also come from: - `OPENJIBO_STATE_STORAGE_CONNECTION_STRING` - `OPENJIBO_PERSONAL_MEMORY_STORAGE_CONNECTION_STRING` +- `OPENJIBO_MEDIA_STORAGE_CONNECTION_STRING` For a real storage account, swap `UseDevelopmentStorage=true` with your Azure Storage connection string. @@ -80,6 +83,8 @@ $env:OpenJibo__State__Backend = "AzureBlob" $env:OpenJibo__State__ConnectionString = "UseDevelopmentStorage=true" $env:OpenJibo__PersonalMemory__Backend = "AzureBlob" $env:OpenJibo__PersonalMemory__ConnectionString = "UseDevelopmentStorage=true" +$env:OpenJibo__Media__Backend = "AzureBlob" +$env:OpenJibo__Media__ConnectionString = "UseDevelopmentStorage=true" dotnet run --project dotnet/src/Jibo.Cloud.Api/Jibo.Cloud.Api.csproj ``` diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/Properties/launchSettings.json b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/Properties/launchSettings.json index dbd50b4..5d1b81a 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/Properties/launchSettings.json +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/Properties/launchSettings.json @@ -16,7 +16,9 @@ "OpenJibo__State__Backend": "AzureBlob", "OpenJibo__State__ConnectionString": "UseDevelopmentStorage=true", "OpenJibo__PersonalMemory__Backend": "AzureBlob", - "OpenJibo__PersonalMemory__ConnectionString": "UseDevelopmentStorage=true" + "OpenJibo__PersonalMemory__ConnectionString": "UseDevelopmentStorage=true", + "OpenJibo__Media__Backend": "AzureBlob", + "OpenJibo__Media__ConnectionString": "UseDevelopmentStorage=true" }, "applicationUrl": "https://localhost:24604;http://localhost:24605" } diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/appsettings.AzureBlob.sample.json b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/appsettings.AzureBlob.sample.json index 3134933..5cfe3de 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/appsettings.AzureBlob.sample.json +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/appsettings.AzureBlob.sample.json @@ -9,6 +9,11 @@ "Backend": "AzureBlob", "ConnectionString": "UseDevelopmentStorage=true", "PersistencePath": "App_Data/personal-memory.json" + }, + "Media": { + "Backend": "AzureBlob", + "ConnectionString": "UseDevelopmentStorage=true", + "ContainerName": "openjibo-media" } } } diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IMediaContentStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IMediaContentStore.cs new file mode 100644 index 0000000..52a2787 --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IMediaContentStore.cs @@ -0,0 +1,17 @@ +namespace Jibo.Cloud.Application.Abstractions; + +public interface IMediaContentStore +{ + Task StoreAsync(string path, string contentType, byte[] content, IReadOnlyDictionary? meta, + CancellationToken cancellationToken = default); + + Task LoadAsync(string path, CancellationToken cancellationToken = default); +} + +public sealed record MediaContentSnapshot +{ + public string ContentType { get; init; } = "application/octet-stream"; + public byte[] Content { get; init; } = []; + public IReadOnlyDictionary Meta { get; init; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboCloudProtocolService.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboCloudProtocolService.cs index ec771fd..20ce3dd 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboCloudProtocolService.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboCloudProtocolService.cs @@ -4,8 +4,10 @@ using Jibo.Cloud.Domain.Models; namespace Jibo.Cloud.Application.Services; -public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) +public sealed class JiboCloudProtocolService { + private readonly ICloudStateStore _stateStore; + private readonly IMediaContentStore _mediaContentStore; private static readonly string[] AcceptedHosts = [ "api.jibo.com", @@ -14,6 +16,12 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) "localhost" ]; + public JiboCloudProtocolService(ICloudStateStore stateStore, IMediaContentStore? mediaContentStore = null) + { + _stateStore = stateStore; + _mediaContentStore = mediaContentStore ?? new NullMediaContentStore(); + } + public Task DispatchAsync(ProtocolEnvelope envelope, CancellationToken cancellationToken = default) { @@ -89,13 +97,13 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) private ProtocolDispatchResult HandleAccount(string operation, ProtocolEnvelope envelope) { - var account = stateStore.GetAccount(); + var account = _stateStore.GetAccount(); var body = envelope.TryParseBody(); if (operation.Equals("CreateHubToken", StringComparison.OrdinalIgnoreCase)) return ProtocolDispatchResult.Ok(new { - token = stateStore.IssueHubToken(), + token = _stateStore.IssueHubToken(), expires = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds() }); @@ -184,7 +192,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) accessKeyId = account.AccessKeyId, secretAccessKey = account.SecretAccessKey, email = account.Email, - friendlyId = stateStore.GetRobot().RobotId, + friendlyId = _stateStore.GetRobot().RobotId, payload = ReadObject(body, "payload") }); @@ -247,11 +255,11 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) ?? ReadString(body, "robotId") ?? "unknown-device"; - stateStore.GetOrCreateDevice(deviceId, envelope.FirmwareVersion, envelope.ApplicationVersion); + _stateStore.GetOrCreateDevice(deviceId, envelope.FirmwareVersion, envelope.ApplicationVersion); return ProtocolDispatchResult.Ok(new { - token = stateStore.IssueRobotToken(deviceId) + token = _stateStore.IssueRobotToken(deviceId) }); } @@ -259,7 +267,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) { if (operation is not ("List" or "ListLoops")) return ProtocolDispatchResult.Ok(Array.Empty()); - return ProtocolDispatchResult.Ok(stateStore.GetLoops().Select(loop => new + return ProtocolDispatchResult.Ok(_stateStore.GetLoops().Select(loop => new { id = loop.LoopId, name = loop.Name, @@ -315,23 +323,23 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) var body = envelope.TryParseBody(); if (operation.Equals("List", StringComparison.OrdinalIgnoreCase)) - return ProtocolDispatchResult.Ok(stateStore.ListMedia( + return ProtocolDispatchResult.Ok(_stateStore.ListMedia( ReadStringArray(body, "loopIds"), ReadLong(body, "after"), ReadLong(body, "before")).Select(MapMedia).ToArray()); if (operation.Equals("Get", StringComparison.OrdinalIgnoreCase)) - return ProtocolDispatchResult.Ok(stateStore.GetMedia(ReadStringArray(body, "paths")).Select(MapMedia) + return ProtocolDispatchResult.Ok(_stateStore.GetMedia(ReadStringArray(body, "paths")).Select(MapMedia) .ToArray()); if (operation.Equals("Remove", StringComparison.OrdinalIgnoreCase)) - return ProtocolDispatchResult.Ok(stateStore.RemoveMedia(ReadStringArray(body, "paths")).Select(MapMedia) + return ProtocolDispatchResult.Ok(_stateStore.RemoveMedia(ReadStringArray(body, "paths")).Select(MapMedia) .ToArray()); if (!operation.Equals("Create", StringComparison.OrdinalIgnoreCase)) return ProtocolDispatchResult.Ok(Array.Empty()); - var loopId = ReadHeader(envelope, "x-loop-id") ?? ReadString(body, "loopId") ?? stateStore.GetLoops()[0].LoopId; + var loopId = ReadHeader(envelope, "x-loop-id") ?? ReadString(body, "loopId") ?? _stateStore.GetLoops()[0].LoopId; var path = ReadHeader(envelope, "x-path") ?? ReadString(body, "path") ?? $"/media/{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; var type = ReadHeader(envelope, "x-type") ?? ReadString(body, "type") ?? "unknown"; @@ -342,27 +350,31 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) meta["contentType"] = contentType; if (!string.IsNullOrWhiteSpace(envelope.BodyText)) meta["bodyText"] = envelope.BodyText; + _mediaContentStore.StoreAsync(path, contentType, + string.IsNullOrWhiteSpace(envelope.BodyText) ? [] : System.Text.Encoding.UTF8.GetBytes(envelope.BodyText), + meta as IReadOnlyDictionary, CancellationToken.None).GetAwaiter().GetResult(); + return ProtocolDispatchResult.Ok( - MapMedia(stateStore.CreateMedia(loopId, path, type, reference, isEncrypted, meta))); + MapMedia(_stateStore.CreateMedia(loopId, path, type, reference, isEncrypted, meta))); } private ProtocolDispatchResult HandlePerson(string operation) { return ProtocolDispatchResult.Ok(operation.Equals("ListHolidays", StringComparison.OrdinalIgnoreCase) - ? stateStore.GetHolidays() + ? _stateStore.GetHolidays() : []); } private ProtocolDispatchResult HandleBackup(string operation, ProtocolEnvelope envelope) { if (operation.Equals("List", StringComparison.OrdinalIgnoreCase)) - return ProtocolDispatchResult.Ok(stateStore.GetBackups()); + return ProtocolDispatchResult.Ok(_stateStore.GetBackups()); if (operation.Equals("Create", StringComparison.OrdinalIgnoreCase)) { var body = envelope.TryParseBody(); var requestedName = ReadString(body, "name") ?? ReadString(body, "backupName"); - return ProtocolDispatchResult.Ok(stateStore.CreateBackup(requestedName ?? $"backup-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}")); + return ProtocolDispatchResult.Ok(_stateStore.CreateBackup(requestedName ?? $"backup-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}")); } return ProtocolDispatchResult.Ok(Array.Empty()); @@ -371,18 +383,18 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) private ProtocolDispatchResult HandleKey(string operation, ProtocolEnvelope envelope) { var body = envelope.TryParseBody(); - var loopId = ReadString(body, "loopId") ?? ReadString(body, "id") ?? stateStore.GetLoops()[0].LoopId; + var loopId = ReadString(body, "loopId") ?? ReadString(body, "id") ?? _stateStore.GetLoops()[0].LoopId; if (operation.Equals("ShouldCreate", StringComparison.OrdinalIgnoreCase)) return ProtocolDispatchResult.Ok(new { - shouldCreate = stateStore.ShouldCreateSymmetricKey(loopId) + shouldCreate = _stateStore.ShouldCreateSymmetricKey(loopId) }); string? symmetricKey; if (operation.Equals("CreateSymmetricKey", StringComparison.OrdinalIgnoreCase)) { - symmetricKey = stateStore.GetOrCreateSymmetricKey(loopId); + symmetricKey = _stateStore.GetOrCreateSymmetricKey(loopId); return ProtocolDispatchResult.Ok(new { loopId, @@ -394,7 +406,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) if (operation is "CreateRequest" or "RequestSymmetricKey") { - var record = stateStore.CreateKeyRequest(loopId, ReadString(body, "publicKey") ?? string.Empty); + var record = _stateStore.CreateKeyRequest(loopId, ReadString(body, "publicKey") ?? string.Empty); return ProtocolDispatchResult.Ok(new { id = record.RequestId, @@ -403,14 +415,14 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) } if (operation.Equals("GetRequest", StringComparison.OrdinalIgnoreCase)) - return ProtocolDispatchResult.Ok(stateStore.GetKeyRequest(loopId, ReadString(body, "id"), + return ProtocolDispatchResult.Ok(_stateStore.GetKeyRequest(loopId, ReadString(body, "id"), ReadString(body, "publicKey"))); if (operation.Equals("ListIncomingRequests", StringComparison.OrdinalIgnoreCase)) - return ProtocolDispatchResult.Ok(stateStore.GetIncomingKeyRequests()); + return ProtocolDispatchResult.Ok(_stateStore.GetIncomingKeyRequests()); if (operation.Equals("ListBinaryRequests", StringComparison.OrdinalIgnoreCase)) - return ProtocolDispatchResult.Ok(stateStore.GetBinaryRequests()); + return ProtocolDispatchResult.Ok(_stateStore.GetBinaryRequests()); if (operation is "Share" or "ShareSymmetricKey" or "ShareBinary") return ProtocolDispatchResult.Ok(new { ok = true }); @@ -418,7 +430,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) if (!operation.Equals("LoadSymmetricKey", StringComparison.OrdinalIgnoreCase)) return ProtocolDispatchResult.Ok(new { ok = true, operation }); - symmetricKey = stateStore.GetOrCreateSymmetricKey(loopId); + symmetricKey = _stateStore.GetOrCreateSymmetricKey(loopId); return ProtocolDispatchResult.Ok(new { loopId, @@ -429,7 +441,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) private ProtocolDispatchResult HandleRobot(string operation, ProtocolEnvelope envelope) { - var robot = stateStore.GetRobot(); + var robot = _stateStore.GetRobot(); if (operation.Equals("UpdateRobot", StringComparison.OrdinalIgnoreCase)) { @@ -443,7 +455,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) HostMappings = robot.HostMappings }; - stateStore.UpdateRobot(updated); + _stateStore.UpdateRobot(updated); return ProtocolDispatchResult.Ok(new { result = "ok" @@ -456,7 +468,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) result = "ok" }); - var profile = stateStore.GetRobotProfile(); + var profile = _stateStore.GetRobotProfile(); return ProtocolDispatchResult.Ok(new { id = ReadString(envelope.TryParseBody(), "id") ?? profile.RobotId, @@ -476,15 +488,15 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) return operation switch { - "ListUpdates" => ProtocolDispatchResult.Ok(stateStore.ListUpdates(subsystem, filter).Select(MapUpdate) + "ListUpdates" => ProtocolDispatchResult.Ok(_stateStore.ListUpdates(subsystem, filter).Select(MapUpdate) .ToArray()), - "ListUpdatesFrom" => ProtocolDispatchResult.Ok(stateStore.ListUpdates(subsystem, filter) + "ListUpdatesFrom" => ProtocolDispatchResult.Ok(_stateStore.ListUpdates(subsystem, filter) .Where(update => fromVersion is null || update.FromVersion.Equals(fromVersion, StringComparison.OrdinalIgnoreCase)) .Select(MapUpdate) .ToArray()), "GetUpdateFrom" => HandleGetUpdateFrom(subsystem, fromVersion, filter), - "CreateUpdate" => ProtocolDispatchResult.Ok(MapUpdate(stateStore.CreateUpdate( + "CreateUpdate" => ProtocolDispatchResult.Ok(MapUpdate(_stateStore.CreateUpdate( fromVersion, ReadString(body, "toVersion"), ReadString(body, "changes"), @@ -493,7 +505,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) subsystem, filter, ReadObject(body, "dependencies")))), - "RemoveUpdate" => ProtocolDispatchResult.Ok(MapUpdate(stateStore.RemoveUpdate(ReadString(body, "id")))), + "RemoveUpdate" => ProtocolDispatchResult.Ok(MapUpdate(_stateStore.RemoveUpdate(ReadString(body, "id")))), _ => ProtocolDispatchResult.Ok(Array.Empty()) }; } @@ -502,17 +514,35 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) { var path = Uri.UnescapeDataString(envelope.Path["/media/".Length..]); var candidatePaths = new[] { path, $"/{path}" }; - var media = stateStore.GetMedia(candidatePaths).FirstOrDefault(); + var media = _stateStore.GetMedia(candidatePaths).FirstOrDefault(); if (media is null || media.IsDeleted) return ProtocolDispatchResult.Raw(404, string.Empty); - var contentType = TryReadMetaString(media.Meta, "contentType") ?? "application/octet-stream"; - var bodyText = TryReadMetaString(media.Meta, "bodyText") ?? string.Empty; + var storedContent = _mediaContentStore.LoadAsync(media.Path, CancellationToken.None).GetAwaiter().GetResult(); + var contentType = storedContent?.ContentType ?? TryReadMetaString(media.Meta, "contentType") ?? + "application/octet-stream"; + var bodyText = storedContent is not null + ? System.Text.Encoding.UTF8.GetString(storedContent.Content) + : TryReadMetaString(media.Meta, "bodyText") ?? string.Empty; return ProtocolDispatchResult.Raw(200, bodyText, contentType); } + private sealed class NullMediaContentStore : IMediaContentStore + { + public Task StoreAsync(string path, string contentType, byte[] content, + IReadOnlyDictionary? meta, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task LoadAsync(string path, CancellationToken cancellationToken = default) + { + return Task.FromResult(null); + } + } + private ProtocolDispatchResult HandleGetUpdateFrom(string? subsystem, string? fromVersion, string? filter) { - var update = stateStore.GetUpdateFrom(subsystem, fromVersion, filter); + var update = _stateStore.GetUpdateFrom(subsystem, fromVersion, filter); return update is null ? ProtocolDispatchResult.Ok(new { }) : ProtocolDispatchResult.Ok(MapUpdate(update)); diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs index bfa07e1..19217d0 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using Jibo.Cloud.Application.Abstractions; using Jibo.Cloud.Application.Services; using Jibo.Cloud.Infrastructure.Audio; using Jibo.Cloud.Infrastructure.Content; +using Jibo.Cloud.Infrastructure.Media; using Jibo.Cloud.Infrastructure.News; using Jibo.Cloud.Infrastructure.Persistence; using Jibo.Cloud.Infrastructure.Telemetry; @@ -59,7 +60,15 @@ public static class ServiceCollectionExtensions "OPENJIBO_PERSONAL_MEMORY_STORAGE_CONNECTION_STRING") ?? Environment.GetEnvironmentVariable( "OPENJIBO_PERSONAL_MEMORY_SQL_CONNECTION_STRING"); + var mediaOptions = new MediaContentStoreOptions(); + if (configuration is not null) + configuration.GetSection("OpenJibo:Media").Bind(mediaOptions); + + if (string.IsNullOrWhiteSpace(mediaOptions.ConnectionString)) + mediaOptions.ConnectionString = Environment.GetEnvironmentVariable("OPENJIBO_MEDIA_STORAGE_CONNECTION_STRING"); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(provider => { var snapshotFactory = provider.GetRequiredService(); @@ -84,6 +93,12 @@ public static class ServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(provider => + { + var factory = provider.GetRequiredService(); + return factory.Create(mediaOptions.DirectoryPath, mediaOptions.Backend, mediaOptions.ContainerName, + mediaOptions.ConnectionString); + }); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -99,4 +114,4 @@ public static class ServiceCollectionExtensions ? backendKind : PersistenceBackendKind.File; } -} \ No newline at end of file +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Media/AzureBlobMediaContentStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Media/AzureBlobMediaContentStore.cs new file mode 100644 index 0000000..6423959 --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Media/AzureBlobMediaContentStore.cs @@ -0,0 +1,82 @@ +using System.Text.Json; +using Azure.Storage.Blobs; +using Jibo.Cloud.Application.Abstractions; + +namespace Jibo.Cloud.Infrastructure.Media; + +internal sealed class AzureBlobMediaContentStore : IMediaContentStore +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true, + PropertyNameCaseInsensitive = true + }; + + private readonly BlobContainerClient _containerClient; + + public AzureBlobMediaContentStore(string? connectionString, string containerName = "openjibo-media") + { + if (string.IsNullOrWhiteSpace(connectionString)) + throw new InvalidOperationException("Azure Blob media persistence requires a storage connection string."); + + _containerClient = new BlobContainerClient(connectionString, + string.IsNullOrWhiteSpace(containerName) ? "openjibo-media" : containerName); + } + + public async Task StoreAsync(string path, string contentType, byte[] content, + IReadOnlyDictionary? meta, CancellationToken cancellationToken = default) + { + var relative = MediaPathHelper.GetRelativeStoragePath(path); + var contentBlob = _containerClient.GetBlobClient($"{relative}.bin"); + var metaBlob = _containerClient.GetBlobClient($"{relative}.json"); + await _containerClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken); + await contentBlob.UploadAsync(new MemoryStream(content), overwrite: true, cancellationToken); + var payload = JsonSerializer.Serialize(new + { + path, + contentType, + meta + }, JsonOptions); + await metaBlob.UploadAsync(BinaryData.FromString(payload), overwrite: true, cancellationToken); + } + + public async Task LoadAsync(string path, CancellationToken cancellationToken = default) + { + var relative = MediaPathHelper.GetRelativeStoragePath(path); + var contentBlob = _containerClient.GetBlobClient($"{relative}.bin"); + if (!await contentBlob.ExistsAsync(cancellationToken)) return null; + + var content = await contentBlob.DownloadContentAsync(cancellationToken); + var contentType = "application/octet-stream"; + IDictionary meta = new Dictionary(StringComparer.OrdinalIgnoreCase); + var metaBlob = _containerClient.GetBlobClient($"{relative}.json"); + + if (await metaBlob.ExistsAsync(cancellationToken)) + { + try + { + var json = (await metaBlob.DownloadContentAsync(cancellationToken)).Value.Content.ToString(); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + if (root.TryGetProperty("contentType", out var type) && type.ValueKind == JsonValueKind.String) + contentType = type.GetString() ?? contentType; + + if (root.TryGetProperty("meta", out var metaElement) && metaElement.ValueKind == JsonValueKind.Object) + meta = JsonSerializer.Deserialize>(metaElement.GetRawText(), + JsonOptions) ?? + new Dictionary(StringComparer.OrdinalIgnoreCase); + } + catch + { + // Keep the raw binary available even if metadata parsing fails. + } + } + + return new MediaContentSnapshot + { + ContentType = contentType, + Content = content.Value.Content.ToArray(), + Meta = meta as IReadOnlyDictionary ?? new Dictionary(meta) + }; + } +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Media/FileMediaContentStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Media/FileMediaContentStore.cs new file mode 100644 index 0000000..f682b92 --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Media/FileMediaContentStore.cs @@ -0,0 +1,84 @@ +using System.Text.Json; +using Jibo.Cloud.Application.Abstractions; + +namespace Jibo.Cloud.Infrastructure.Media; + +internal sealed class FileMediaContentStore : IMediaContentStore +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + public FileMediaContentStore(string? directoryPath) + { + DirectoryPath = string.IsNullOrWhiteSpace(directoryPath) ? null : directoryPath; + } + + private string? DirectoryPath { get; } + + public async Task StoreAsync(string path, string contentType, byte[] content, + IReadOnlyDictionary? meta, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(DirectoryPath) || string.IsNullOrWhiteSpace(path)) return; + + var root = Path.GetFullPath(DirectoryPath); + var relative = MediaPathHelper.GetRelativeStoragePath(path); + var contentPath = Path.Combine(root, $"{relative}.bin"); + var metaPath = Path.Combine(root, $"{relative}.json"); + + Directory.CreateDirectory(Path.GetDirectoryName(contentPath)!); + await File.WriteAllBytesAsync(contentPath, content, cancellationToken); + var payload = new + { + path, + contentType, + meta + }; + await File.WriteAllTextAsync(metaPath, JsonSerializer.Serialize(payload, JsonOptions), cancellationToken); + } + + public async Task LoadAsync(string path, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(DirectoryPath) || string.IsNullOrWhiteSpace(path)) return null; + + var root = Path.GetFullPath(DirectoryPath); + var relative = MediaPathHelper.GetRelativeStoragePath(path); + var contentPath = Path.Combine(root, $"{relative}.bin"); + var metaPath = Path.Combine(root, $"{relative}.json"); + if (!File.Exists(contentPath)) return null; + + var content = await File.ReadAllBytesAsync(contentPath, cancellationToken); + var contentType = "application/octet-stream"; + IDictionary meta = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (File.Exists(metaPath)) + { + try + { + using var document = JsonDocument.Parse(await File.ReadAllTextAsync(metaPath, cancellationToken)); + var rootElement = document.RootElement; + if (rootElement.TryGetProperty("contentType", out var type) && + type.ValueKind == JsonValueKind.String) + contentType = type.GetString() ?? contentType; + + if (rootElement.TryGetProperty("meta", out var metaElement) && + metaElement.ValueKind == JsonValueKind.Object) + meta = JsonSerializer.Deserialize>(metaElement.GetRawText(), + JsonOptions) ?? + new Dictionary(StringComparer.OrdinalIgnoreCase); + } + catch + { + // Keep binary content available even if the manifest is malformed. + } + } + + return new MediaContentSnapshot + { + ContentType = contentType, + Content = content, + Meta = meta as IReadOnlyDictionary ?? new Dictionary(meta) + }; + } +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Media/IMediaContentStoreFactory.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Media/IMediaContentStoreFactory.cs new file mode 100644 index 0000000..760f515 --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Media/IMediaContentStoreFactory.cs @@ -0,0 +1,9 @@ +using Jibo.Cloud.Application.Abstractions; + +namespace Jibo.Cloud.Infrastructure.Media; + +internal interface IMediaContentStoreFactory +{ + IMediaContentStore Create(string? directoryPath, MediaContentStoreKind backendKind, string containerName, + string? connectionString); +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Media/MediaContentStoreFactory.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Media/MediaContentStoreFactory.cs new file mode 100644 index 0000000..f941148 --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Media/MediaContentStoreFactory.cs @@ -0,0 +1,16 @@ +using Jibo.Cloud.Application.Abstractions; + +namespace Jibo.Cloud.Infrastructure.Media; + +internal sealed class MediaContentStoreFactory : IMediaContentStoreFactory +{ + public IMediaContentStore Create(string? directoryPath, MediaContentStoreKind backendKind, string containerName, + string? connectionString) + { + return backendKind switch + { + MediaContentStoreKind.AzureBlob => new AzureBlobMediaContentStore(connectionString, containerName), + _ => new FileMediaContentStore(directoryPath) + }; + } +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Media/MediaContentStoreKind.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Media/MediaContentStoreKind.cs new file mode 100644 index 0000000..e2b4328 --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Media/MediaContentStoreKind.cs @@ -0,0 +1,7 @@ +namespace Jibo.Cloud.Infrastructure.Media; + +public enum MediaContentStoreKind +{ + File, + AzureBlob +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Media/MediaContentStoreOptions.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Media/MediaContentStoreOptions.cs new file mode 100644 index 0000000..bd894aa --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Media/MediaContentStoreOptions.cs @@ -0,0 +1,9 @@ +namespace Jibo.Cloud.Infrastructure.Media; + +public sealed class MediaContentStoreOptions +{ + public MediaContentStoreKind Backend { get; set; } = MediaContentStoreKind.File; + public string? DirectoryPath { get; set; } + public string? ConnectionString { get; set; } + public string ContainerName { get; set; } = "openjibo-media"; +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Media/MediaPathHelper.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Media/MediaPathHelper.cs new file mode 100644 index 0000000..959fed5 --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Media/MediaPathHelper.cs @@ -0,0 +1,23 @@ +namespace Jibo.Cloud.Infrastructure.Media; + +internal static class MediaPathHelper +{ + public static string GetRelativeStoragePath(string path) + { + var trimmed = path.Trim().TrimStart('/', '\\'); + var segments = trimmed.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(SanitizeSegment) + .Where(segment => !string.IsNullOrWhiteSpace(segment)) + .ToArray(); + + return segments.Length == 0 ? "media-item" : Path.Combine(segments); + } + + private static string SanitizeSegment(string value) + { + var chars = value + .Select(character => char.IsLetterOrDigit(character) || character is '-' or '_' or '.' ? character : '_') + .ToArray(); + return string.Join(string.Empty, chars); + } +} diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Properties/launchSettings.json b/OpenJibo/tests/Jibo.Cloud.Tests/Properties/launchSettings.json index ea3a924..8de5545 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/Properties/launchSettings.json +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Properties/launchSettings.json @@ -6,7 +6,9 @@ "OpenJibo__State__Backend": "AzureBlob", "OpenJibo__State__ConnectionString": "UseDevelopmentStorage=true", "OpenJibo__PersonalMemory__Backend": "AzureBlob", - "OpenJibo__PersonalMemory__ConnectionString": "UseDevelopmentStorage=true" + "OpenJibo__PersonalMemory__ConnectionString": "UseDevelopmentStorage=true", + "OpenJibo__Media__Backend": "AzureBlob", + "OpenJibo__Media__ConnectionString": "UseDevelopmentStorage=true" } } } diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/JiboCloudProtocolServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/JiboCloudProtocolServiceTests.cs index bf13706..88836e4 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/JiboCloudProtocolServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/JiboCloudProtocolServiceTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Jibo.Cloud.Application.Services; using Jibo.Cloud.Domain.Models; +using Jibo.Cloud.Infrastructure.Media; using Jibo.Cloud.Infrastructure.Persistence; namespace Jibo.Cloud.Tests.Protocol; @@ -255,6 +256,47 @@ public sealed class JiboCloudProtocolServiceTests Assert.Equal("binary-photo-placeholder", mediaGet.BodyText); } + [Fact] + public async Task MediaCreate_PersistsBinaryContentThroughFileMediaStore() + { + var directoryPath = Path.Combine(Path.GetTempPath(), "OpenJibo.Media.Tests", Guid.NewGuid().ToString("N")); + var service = new JiboCloudProtocolService(new InMemoryCloudStateStore(), + new FileMediaContentStore(directoryPath)); + + var result = await service.DispatchAsync(new ProtocolEnvelope + { + HostName = "api.jibo.com", + Method = "POST", + ServicePrefix = "Media_20160725", + Operation = "Create", + Headers = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Content-Type"] = "image/jpeg", + ["x-path"] = "photo-blob-2", + ["x-type"] = "image" + }, + BodyText = "binary-photo-placeholder" + }); + + using var createdPayload = JsonDocument.Parse(result.BodyText); + Assert.Equal("https://api.jibo.com/media/photo-blob-2", + createdPayload.RootElement.GetProperty("url").GetString()); + + var storedFile = Path.Combine(directoryPath, "photo-blob-2.bin"); + Assert.True(File.Exists(storedFile)); + + var mediaGet = await service.DispatchAsync(new ProtocolEnvelope + { + HostName = "api.jibo.com", + Method = "GET", + Path = "/media/photo-blob-2" + }); + + Assert.Equal(200, mediaGet.StatusCode); + Assert.Equal("image/jpeg", mediaGet.ContentType); + Assert.Equal("binary-photo-placeholder", mediaGet.BodyText); + } + [Fact] public async Task KeyCreateSymmetricKey_ReturnsKeyPayload() {