├── .aspire └── settings.json ├── .editorconfig ├── .gitignore ├── ImageFun.AppHost ├── .aspire │ └── settings.json ├── .gitignore ├── AppHost.cs ├── Extensions.cs ├── ImageFun.AppHost.csproj ├── OpenAIExtensions.cs ├── Properties │ └── launchSettings.json ├── appsettings.Development.json ├── appsettings.json └── azure.yaml ├── ImageFun.ServiceDefaults ├── Extensions.cs └── ImageFun.ServiceDefaults.csproj ├── ImageFun.slnx ├── ImageProcessor ├── Extensions.cs ├── ImageProcessor.csproj ├── Program.cs ├── Properties │ └── launchSettings.json ├── appsettings.Development.json └── appsettings.json ├── ImageUpload ├── Components │ ├── ImagePreview.razor │ └── PhotoGallery.razor ├── Extensions.cs ├── GlobalUsings.cs ├── ImageUpload.csproj ├── ImagesApi.cs ├── Program.cs ├── Properties │ └── launchSettings.json ├── appsettings.Development.json ├── appsettings.json └── wwwroot │ └── style.css ├── LICENSE ├── README.md └── azure.yaml /.aspire/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "appHostPath": "../ImageFun.AppHost/ImageFun.AppHost.csproj" 3 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.cs] 2 | 3 | # ASPIRECOSMOSDB001: Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. 4 | dotnet_diagnostic.ASPIRECOSMOSDB001.severity = none 5 | 6 | # ASPIRECOMPUTE001: Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. 7 | dotnet_diagnostic.ASPIRECOMPUTE001.severity = none 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from `dotnet new gitignore` 5 | 6 | # dotenv files 7 | .env 8 | 9 | # User-specific files 10 | *.rsuser 11 | *.suo 12 | *.user 13 | *.userosscache 14 | *.sln.docstates 15 | 16 | # User-specific files (MonoDevelop/Xamarin Studio) 17 | *.userprefs 18 | 19 | # Mono auto generated files 20 | mono_crash.* 21 | 22 | # Build results 23 | [Dd]ebug/ 24 | [Dd]ebugPublic/ 25 | [Rr]elease/ 26 | [Rr]eleases/ 27 | x64/ 28 | x86/ 29 | [Ww][Ii][Nn]32/ 30 | [Aa][Rr][Mm]/ 31 | [Aa][Rr][Mm]64/ 32 | bld/ 33 | [Bb]in/ 34 | [Oo]bj/ 35 | [Ll]og/ 36 | [Ll]ogs/ 37 | 38 | # Visual Studio 2015/2017 cache/options directory 39 | .vs/ 40 | # Uncomment if you have tasks that create the project's static files in wwwroot 41 | #wwwroot/ 42 | 43 | # Visual Studio 2017 auto generated files 44 | Generated\ Files/ 45 | 46 | # MSTest test Results 47 | [Tt]est[Rr]esult*/ 48 | [Bb]uild[Ll]og.* 49 | 50 | # NUnit 51 | *.VisualState.xml 52 | TestResult.xml 53 | nunit-*.xml 54 | 55 | # Build Results of an ATL Project 56 | [Dd]ebugPS/ 57 | [Rr]eleasePS/ 58 | dlldata.c 59 | 60 | # Benchmark Results 61 | BenchmarkDotNet.Artifacts/ 62 | 63 | # .NET 64 | project.lock.json 65 | project.fragment.lock.json 66 | artifacts/ 67 | 68 | # Tye 69 | .tye/ 70 | 71 | # ASP.NET Scaffolding 72 | ScaffoldingReadMe.txt 73 | 74 | # StyleCop 75 | StyleCopReport.xml 76 | 77 | # Files built by Visual Studio 78 | *_i.c 79 | *_p.c 80 | *_h.h 81 | *.ilk 82 | *.meta 83 | *.obj 84 | *.iobj 85 | *.pch 86 | *.pdb 87 | *.ipdb 88 | *.pgc 89 | *.pgd 90 | *.rsp 91 | *.sbr 92 | *.tlb 93 | *.tli 94 | *.tlh 95 | *.tmp 96 | *.tmp_proj 97 | *_wpftmp.csproj 98 | *.log 99 | *.tlog 100 | *.vspscc 101 | *.vssscc 102 | .builds 103 | *.pidb 104 | *.svclog 105 | *.scc 106 | 107 | # Chutzpah Test files 108 | _Chutzpah* 109 | 110 | # Visual C++ cache files 111 | ipch/ 112 | *.aps 113 | *.ncb 114 | *.opendb 115 | *.opensdf 116 | *.sdf 117 | *.cachefile 118 | *.VC.db 119 | *.VC.VC.opendb 120 | 121 | # Visual Studio profiler 122 | *.psess 123 | *.vsp 124 | *.vspx 125 | *.sap 126 | 127 | # Visual Studio Trace Files 128 | *.e2e 129 | 130 | # TFS 2012 Local Workspace 131 | $tf/ 132 | 133 | # Guidance Automation Toolkit 134 | *.gpState 135 | 136 | # ReSharper is a .NET coding add-in 137 | _ReSharper*/ 138 | *.[Rr]e[Ss]harper 139 | *.DotSettings.user 140 | 141 | # TeamCity is a build add-in 142 | _TeamCity* 143 | 144 | # DotCover is a Code Coverage Tool 145 | *.dotCover 146 | 147 | # AxoCover is a Code Coverage Tool 148 | .axoCover/* 149 | !.axoCover/settings.json 150 | 151 | # Coverlet is a free, cross platform Code Coverage Tool 152 | coverage*.json 153 | coverage*.xml 154 | coverage*.info 155 | 156 | # Visual Studio code coverage results 157 | *.coverage 158 | *.coveragexml 159 | 160 | # NCrunch 161 | _NCrunch_* 162 | .*crunch*.local.xml 163 | nCrunchTemp_* 164 | 165 | # MightyMoose 166 | *.mm.* 167 | AutoTest.Net/ 168 | 169 | # Web workbench (sass) 170 | .sass-cache/ 171 | 172 | # Installshield output folder 173 | [Ee]xpress/ 174 | 175 | # DocProject is a documentation generator add-in 176 | DocProject/buildhelp/ 177 | DocProject/Help/*.HxT 178 | DocProject/Help/*.HxC 179 | DocProject/Help/*.hhc 180 | DocProject/Help/*.hhk 181 | DocProject/Help/*.hhp 182 | DocProject/Help/Html2 183 | DocProject/Help/html 184 | 185 | # Click-Once directory 186 | publish/ 187 | 188 | # Publish Web Output 189 | *.[Pp]ublish.xml 190 | *.azurePubxml 191 | # Note: Comment the next line if you want to checkin your web deploy settings, 192 | # but database connection strings (with potential passwords) will be unencrypted 193 | *.pubxml 194 | *.publishproj 195 | 196 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 197 | # checkin your Azure Web App publish settings, but sensitive information contained 198 | # in these scripts will be unencrypted 199 | PublishScripts/ 200 | 201 | # NuGet Packages 202 | *.nupkg 203 | # NuGet Symbol Packages 204 | *.snupkg 205 | # The packages folder can be ignored because of Package Restore 206 | **/[Pp]ackages/* 207 | # except build/, which is used as an MSBuild target. 208 | !**/[Pp]ackages/build/ 209 | # Uncomment if necessary however generally it will be regenerated when needed 210 | #!**/[Pp]ackages/repositories.config 211 | # NuGet v3's project.json files produces more ignorable files 212 | *.nuget.props 213 | *.nuget.targets 214 | 215 | # Microsoft Azure Build Output 216 | csx/ 217 | *.build.csdef 218 | 219 | # Microsoft Azure Emulator 220 | ecf/ 221 | rcf/ 222 | 223 | # Windows Store app package directories and files 224 | AppPackages/ 225 | BundleArtifacts/ 226 | Package.StoreAssociation.xml 227 | _pkginfo.txt 228 | *.appx 229 | *.appxbundle 230 | *.appxupload 231 | 232 | # Visual Studio cache files 233 | # files ending in .cache can be ignored 234 | *.[Cc]ache 235 | # but keep track of directories ending in .cache 236 | !?*.[Cc]ache/ 237 | 238 | # Others 239 | ClientBin/ 240 | ~$* 241 | *~ 242 | *.dbmdl 243 | *.dbproj.schemaview 244 | *.jfm 245 | *.pfx 246 | *.publishsettings 247 | orleans.codegen.cs 248 | 249 | # Including strong name files can present a security risk 250 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 251 | #*.snk 252 | 253 | # Since there are multiple workflows, uncomment next line to ignore bower_components 254 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 255 | #bower_components/ 256 | 257 | # RIA/Silverlight projects 258 | Generated_Code/ 259 | 260 | # Backup & report files from converting an old project file 261 | # to a newer Visual Studio version. Backup files are not needed, 262 | # because we have git ;-) 263 | _UpgradeReport_Files/ 264 | Backup*/ 265 | UpgradeLog*.XML 266 | UpgradeLog*.htm 267 | ServiceFabricBackup/ 268 | *.rptproj.bak 269 | 270 | # SQL Server files 271 | *.mdf 272 | *.ldf 273 | *.ndf 274 | 275 | # Business Intelligence projects 276 | *.rdl.data 277 | *.bim.layout 278 | *.bim_*.settings 279 | *.rptproj.rsuser 280 | *- [Bb]ackup.rdl 281 | *- [Bb]ackup ([0-9]).rdl 282 | *- [Bb]ackup ([0-9][0-9]).rdl 283 | 284 | # Microsoft Fakes 285 | FakesAssemblies/ 286 | 287 | # GhostDoc plugin setting file 288 | *.GhostDoc.xml 289 | 290 | # Node.js Tools for Visual Studio 291 | .ntvs_analysis.dat 292 | node_modules/ 293 | 294 | # Visual Studio 6 build log 295 | *.plg 296 | 297 | # Visual Studio 6 workspace options file 298 | *.opt 299 | 300 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 301 | *.vbw 302 | 303 | # Visual Studio 6 auto-generated project file (contains which files were open etc.) 304 | *.vbp 305 | 306 | # Visual Studio 6 workspace and project file (working project files containing files to include in project) 307 | *.dsw 308 | *.dsp 309 | 310 | # Visual Studio 6 technical files 311 | *.ncb 312 | *.aps 313 | 314 | # Visual Studio LightSwitch build output 315 | **/*.HTMLClient/GeneratedArtifacts 316 | **/*.DesktopClient/GeneratedArtifacts 317 | **/*.DesktopClient/ModelManifest.xml 318 | **/*.Server/GeneratedArtifacts 319 | **/*.Server/ModelManifest.xml 320 | _Pvt_Extensions 321 | 322 | # Paket dependency manager 323 | .paket/paket.exe 324 | paket-files/ 325 | 326 | # FAKE - F# Make 327 | .fake/ 328 | 329 | # CodeRush personal settings 330 | .cr/personal 331 | 332 | # Python Tools for Visual Studio (PTVS) 333 | __pycache__/ 334 | *.pyc 335 | 336 | # Cake - Uncomment if you are using it 337 | # tools/** 338 | # !tools/packages.config 339 | 340 | # Tabs Studio 341 | *.tss 342 | 343 | # Telerik's JustMock configuration file 344 | *.jmconfig 345 | 346 | # BizTalk build output 347 | *.btp.cs 348 | *.btm.cs 349 | *.odx.cs 350 | *.xsd.cs 351 | 352 | # OpenCover UI analysis results 353 | OpenCover/ 354 | 355 | # Azure Stream Analytics local run output 356 | ASALocalRun/ 357 | 358 | # MSBuild Binary and Structured Log 359 | *.binlog 360 | 361 | # NVidia Nsight GPU debugger configuration file 362 | *.nvuser 363 | 364 | # MFractors (Xamarin productivity tool) working folder 365 | .mfractor/ 366 | 367 | # Local History for Visual Studio 368 | .localhistory/ 369 | 370 | # Visual Studio History (VSHistory) files 371 | .vshistory/ 372 | 373 | # BeatPulse healthcheck temp database 374 | healthchecksdb 375 | 376 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 377 | MigrationBackup/ 378 | 379 | # Ionide (cross platform F# VS Code tools) working folder 380 | .ionide/ 381 | 382 | # Fody - auto-generated XML schema 383 | FodyWeavers.xsd 384 | 385 | # VS Code files for those working on multiple tools 386 | .vscode/* 387 | !.vscode/settings.json 388 | !.vscode/tasks.json 389 | !.vscode/launch.json 390 | !.vscode/extensions.json 391 | *.code-workspace 392 | 393 | # Local History for Visual Studio Code 394 | .history/ 395 | 396 | # Windows Installer files from build outputs 397 | *.cab 398 | *.msi 399 | *.msix 400 | *.msm 401 | *.msp 402 | 403 | # JetBrains Rider 404 | *.sln.iml 405 | .idea/ 406 | 407 | ## 408 | ## Visual studio for Mac 409 | ## 410 | 411 | 412 | # globs 413 | Makefile.in 414 | *.userprefs 415 | *.usertasks 416 | config.make 417 | config.status 418 | aclocal.m4 419 | install-sh 420 | autom4te.cache/ 421 | *.tar.gz 422 | tarballs/ 423 | test-results/ 424 | 425 | # Mac bundle stuff 426 | *.dmg 427 | *.app 428 | 429 | # content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore 430 | # General 431 | .DS_Store 432 | .AppleDouble 433 | .LSOverride 434 | 435 | # Icon must end with two \r 436 | Icon 437 | 438 | 439 | # Thumbnails 440 | ._* 441 | 442 | # Files that might appear in the root of a volume 443 | .DocumentRevisions-V100 444 | .fseventsd 445 | .Spotlight-V100 446 | .TemporaryItems 447 | .Trashes 448 | .VolumeIcon.icns 449 | .com.apple.timemachine.donotpresent 450 | 451 | # Directories potentially created on remote AFP share 452 | .AppleDB 453 | .AppleDesktop 454 | Network Trash Folder 455 | Temporary Items 456 | .apdisk 457 | 458 | # content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore 459 | # Windows thumbnail cache files 460 | Thumbs.db 461 | ehthumbs.db 462 | ehthumbs_vista.db 463 | 464 | # Dump file 465 | *.stackdump 466 | 467 | # Folder config file 468 | [Dd]esktop.ini 469 | 470 | # Recycle Bin used on file shares 471 | $RECYCLE.BIN/ 472 | 473 | # Windows Installer files 474 | *.cab 475 | *.msi 476 | *.msix 477 | *.msm 478 | *.msp 479 | 480 | # Windows shortcuts 481 | *.lnk 482 | 483 | # Vim temporary swap files 484 | *.swp 485 | .azure 486 | -------------------------------------------------------------------------------- /ImageFun.AppHost/.aspire/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "appHostPath": "../ImageFun.AppHost.csproj" 3 | } -------------------------------------------------------------------------------- /ImageFun.AppHost/.gitignore: -------------------------------------------------------------------------------- 1 | .azure 2 | -------------------------------------------------------------------------------- /ImageFun.AppHost/AppHost.cs: -------------------------------------------------------------------------------- 1 | var builder = DistributedApplication.CreateBuilder(args); 2 | 3 | var openaikey = builder.AddParameter("oaikey", secret: true); 4 | var model = builder.AddParameter("model", "gpt-4.1", publishValueAsDefault: true); 5 | 6 | // Add a model connection 7 | var oai = builder.AddOpenAIConnection("oai", openaikey, model); 8 | 9 | var storage = builder.AddAzureStorage("storage").RunAsEmulator(); 10 | 11 | var blobs = storage.AddBlobs("blobs"); 12 | 13 | // This will make sure the container is created 14 | var container = blobs.AddBlobContainer("images", blobContainerName: "image-uploads"); 15 | 16 | var acr = builder.AddAzureContainerRegistry("acr"); 17 | 18 | var feenv = builder.AddAzureAppServiceEnvironment("fe-env") 19 | .WithAzureContainerRegistry(acr); 20 | 21 | var beenv = builder.AddAzureContainerAppEnvironment("be-env") 22 | .WithAzureContainerRegistry(acr); 23 | 24 | var imageProcessor = builder.AddProject("imageprocessor") 25 | .WithExternalHttpEndpoints() 26 | .WithReference(blobs) 27 | .WithReference(oai) 28 | .WaitFor(container) 29 | .WithComputeEnvironment(beenv); 30 | 31 | builder.AddProject("web") 32 | .WithExternalHttpEndpoints() 33 | .WithReference(blobs) 34 | .WaitFor(container) 35 | .WithReference(imageProcessor) 36 | .WaitFor(imageProcessor) 37 | .WithComputeEnvironment(feenv) 38 | .FixEndpoints(); 39 | 40 | builder.Build().Run(); 41 | -------------------------------------------------------------------------------- /ImageFun.AppHost/Extensions.cs: -------------------------------------------------------------------------------- 1 | using Aspire.Hosting.Azure; 2 | using Aspire.Hosting.Azure.AppContainers; 3 | using Azure.Provisioning.Expressions; 4 | 5 | public static class Extensions 6 | { 7 | /// 8 | /// Fixes endpoint references in the Azure App Service website configuration by resolving environment-specific domains. 9 | /// 10 | /// The project resource builder to configure endpoints for. 11 | /// 12 | /// This method handles endpoint resolution between different deployment environments: 13 | /// - If the endpoint reference is within the same environment, it preserves the original configuration 14 | /// - For cross-environment references (specifically Azure Container Apps), it resolves the correct domain 15 | /// - Environment variables containing endpoint references are processed and updated accordingly 16 | /// 17 | /// 18 | /// Thrown when encountering an environment type other than AzureContainerAppEnvironment during endpoint resolution. 19 | /// 20 | public static void FixEndpoints(this IResourceBuilder projectResource) 21 | { 22 | Dictionary env = []; 23 | 24 | projectResource.WithEnvironment(context => env = context.EnvironmentVariables); 25 | 26 | projectResource.PublishAsAzureAppServiceWebsite((infra, website) => 27 | { 28 | foreach (var setting in website.SiteConfig.AppSettings) 29 | { 30 | string? name = setting.Value?.Name.Value; 31 | 32 | if (name is null) 33 | { 34 | continue; 35 | } 36 | 37 | if (env.TryGetValue(name, out var value) && value is EndpointReference e) 38 | { 39 | var thisEnvironment = projectResource.Resource.GetDeploymentTargetAnnotation()?.ComputeEnvironment; 40 | var endpointRefEnvironment = e.Resource.GetDeploymentTargetAnnotation()?.ComputeEnvironment; 41 | 42 | if (thisEnvironment == endpointRefEnvironment) 43 | { 44 | // This is a reference to the same environment, so we can use the domain 45 | // from the environment instead of the one from the project. 46 | continue; 47 | } 48 | 49 | // We need to resolve the endpoint from the endpointRefEnvironment 50 | // We only support AzureContainerAppEnvironment 51 | if (endpointRefEnvironment is AzureContainerAppEnvironmentResource endpointRefEnv) 52 | { 53 | // Get the domain from the environment. We should expose this as a property 54 | // on the AzureContainerAppEnvironmentResource. 55 | var domainParameter = new BicepOutputReference("AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN", endpointRefEnv).AsProvisioningParameter(infra); 56 | 57 | setting!.Value!.Value = BicepFunction.Interpolate( 58 | $"{e.Scheme}://{e.Resource.Name}.{domainParameter}" 59 | ); 60 | } 61 | else 62 | { 63 | throw new NotSupportedException($"Unsupported environment type: {endpointRefEnvironment?.GetType()}"); 64 | } 65 | } 66 | } 67 | }); 68 | } 69 | } -------------------------------------------------------------------------------- /ImageFun.AppHost/ImageFun.AppHost.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Exe 7 | net9.0 8 | enable 9 | enable 10 | 13c50c8e-e0bb-49c0-8edb-a563dda5565b 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /ImageFun.AppHost/OpenAIExtensions.cs: -------------------------------------------------------------------------------- 1 | public static class OpenAIExtensions 2 | { 3 | // Add an open AI compatible endpoint 4 | public static IResourceBuilder AddOpenAIConnection( 5 | this IDistributedApplicationBuilder builder, 6 | string name, 7 | IResourceBuilder endpoint, 8 | IResourceBuilder key, 9 | IResourceBuilder model) 10 | { 11 | return builder.AddConnectionString(name, cs => 12 | { 13 | cs.Append($"Endpoint={endpoint};Key={key};Model={model}"); 14 | }); 15 | } 16 | 17 | public static IResourceBuilder AddGithubModelConnection( 18 | this IDistributedApplicationBuilder builder, 19 | string name, 20 | IResourceBuilder githubToken, 21 | IResourceBuilder model) 22 | { 23 | return builder.AddConnectionString(name, cs => 24 | { 25 | cs.Append($"Endpoint=https://models.github.ai/inference;Key={githubToken};Model={model}"); 26 | }); 27 | } 28 | 29 | public static IResourceBuilder AddOpenAIConnection( 30 | this IDistributedApplicationBuilder builder, 31 | string name, 32 | IResourceBuilder key, 33 | IResourceBuilder model) 34 | { 35 | // Assume the default open AI endpoint 36 | return builder.AddConnectionString(name, cs => 37 | { 38 | cs.Append($"Key={key};Model={model}"); 39 | }); 40 | } 41 | } -------------------------------------------------------------------------------- /ImageFun.AppHost/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "https": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": true, 8 | "applicationUrl": "https://localhost:17222;http://localhost:15272", 9 | "environmentVariables": { 10 | "ASPNETCORE_ENVIRONMENT": "Development", 11 | "DOTNET_ENVIRONMENT": "Development", 12 | "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21295", 13 | "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22233" 14 | } 15 | }, 16 | "http": { 17 | "commandName": "Project", 18 | "dotnetRunMessages": true, 19 | "launchBrowser": true, 20 | "applicationUrl": "http://localhost:15272", 21 | "environmentVariables": { 22 | "ASPNETCORE_ENVIRONMENT": "Development", 23 | "DOTNET_ENVIRONMENT": "Development", 24 | "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19254", 25 | "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20136" 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /ImageFun.AppHost/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /ImageFun.AppHost/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning", 6 | "Aspire.Hosting.Dcp": "Warning" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /ImageFun.AppHost/azure.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json 2 | 3 | name: imagefun-apphost 4 | services: 5 | app: 6 | language: dotnet 7 | project: ./ImageFun.AppHost.csproj 8 | host: containerapp 9 | -------------------------------------------------------------------------------- /ImageFun.ServiceDefaults/Extensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.AspNetCore.Diagnostics.HealthChecks; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.Diagnostics.HealthChecks; 5 | using Microsoft.Extensions.Logging; 6 | using OpenTelemetry; 7 | using OpenTelemetry.Metrics; 8 | using OpenTelemetry.Trace; 9 | 10 | namespace Microsoft.Extensions.Hosting; 11 | 12 | // Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. 13 | // This project should be referenced by each service project in your solution. 14 | // To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults 15 | public static class Extensions 16 | { 17 | private const string HealthEndpointPath = "/health"; 18 | private const string AlivenessEndpointPath = "/alive"; 19 | 20 | public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder 21 | { 22 | builder.ConfigureOpenTelemetry(); 23 | 24 | builder.AddDefaultHealthChecks(); 25 | 26 | builder.Services.AddServiceDiscovery(); 27 | 28 | builder.Services.ConfigureHttpClientDefaults(http => 29 | { 30 | // Turn on resilience by default 31 | http.AddStandardResilienceHandler(); 32 | 33 | // Turn on service discovery by default 34 | http.AddServiceDiscovery(); 35 | }); 36 | 37 | // Uncomment the following to restrict the allowed schemes for service discovery. 38 | // builder.Services.Configure(options => 39 | // { 40 | // options.AllowedSchemes = ["https"]; 41 | // }); 42 | 43 | return builder; 44 | } 45 | 46 | public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder 47 | { 48 | builder.Logging.AddOpenTelemetry(logging => 49 | { 50 | logging.IncludeFormattedMessage = true; 51 | logging.IncludeScopes = true; 52 | }); 53 | 54 | builder.Services.AddOpenTelemetry() 55 | .WithMetrics(metrics => 56 | { 57 | metrics.AddAspNetCoreInstrumentation() 58 | .AddHttpClientInstrumentation() 59 | .AddRuntimeInstrumentation(); 60 | }) 61 | .WithTracing(tracing => 62 | { 63 | tracing.AddSource(builder.Environment.ApplicationName) 64 | .AddAspNetCoreInstrumentation(tracing => 65 | // Exclude health check requests from tracing 66 | tracing.Filter = context => 67 | !context.Request.Path.StartsWithSegments(HealthEndpointPath) 68 | && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) 69 | ) 70 | // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) 71 | //.AddGrpcClientInstrumentation() 72 | .AddHttpClientInstrumentation(); 73 | }); 74 | 75 | builder.AddOpenTelemetryExporters(); 76 | 77 | return builder; 78 | } 79 | 80 | private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder 81 | { 82 | var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); 83 | 84 | if (useOtlpExporter) 85 | { 86 | builder.Services.AddOpenTelemetry().UseOtlpExporter(); 87 | } 88 | 89 | // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) 90 | //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) 91 | //{ 92 | // builder.Services.AddOpenTelemetry() 93 | // .UseAzureMonitor(); 94 | //} 95 | 96 | return builder; 97 | } 98 | 99 | public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder 100 | { 101 | builder.Services.AddHealthChecks() 102 | // Add a default liveness check to ensure app is responsive 103 | .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); 104 | 105 | return builder; 106 | } 107 | 108 | public static WebApplication MapDefaultEndpoints(this WebApplication app) 109 | { 110 | // Adding health checks endpoints to applications in non-development environments has security implications. 111 | // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. 112 | if (app.Environment.IsDevelopment()) 113 | { 114 | // All health checks must pass for app to be considered ready to accept traffic after starting 115 | app.MapHealthChecks(HealthEndpointPath); 116 | 117 | // Only health checks tagged with the "live" tag must pass for app to be considered alive 118 | app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions 119 | { 120 | Predicate = r => r.Tags.Contains("live") 121 | }); 122 | } 123 | 124 | return app; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /ImageFun.ServiceDefaults/ImageFun.ServiceDefaults.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | true 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /ImageFun.slnx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /ImageProcessor/Extensions.cs: -------------------------------------------------------------------------------- 1 | using Azure.Storage.Blobs; 2 | using Microsoft.Extensions.AI; 3 | 4 | public static class Extensions 5 | { 6 | public static void AddChatClient(this IHostApplicationBuilder builder) 7 | { 8 | builder.AddOpenAIClient("oai") 9 | .AddChatClient() 10 | .UseOpenTelemetry() 11 | .UseLogging(); 12 | 13 | // This is the default name of the trace source and meter 14 | var telemetryName = "Experimental.Microsoft.Extensions.AI"; 15 | 16 | builder.Services.AddOpenTelemetry() 17 | .WithTracing(t => t.AddSource(telemetryName)) 18 | .WithMetrics(m => m.AddMeter(telemetryName)); 19 | } 20 | 21 | public static void AddAzureBlobs(this IHostApplicationBuilder builder) 22 | { 23 | // There's a bug https://github.com/dotnet/aspire/issues/9454 24 | // builder.AddAzureBlobContainerClient("images"); 25 | 26 | builder.AddAzureBlobClient("blobs"); 27 | builder.Services.AddSingleton(sp => 28 | { 29 | var client = sp.GetRequiredService(); 30 | return client.GetBlobContainerClient("image-uploads"); 31 | }); 32 | } 33 | } -------------------------------------------------------------------------------- /ImageProcessor/ImageProcessor.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | dotnet-ImageProcessor-8de186b3-06fa-49e4-91ef-72aca11be3b9 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /ImageProcessor/Program.cs: -------------------------------------------------------------------------------- 1 | using Azure.Storage.Blobs; 2 | using Microsoft.Extensions.AI; 3 | 4 | var builder = WebApplication.CreateBuilder(args); 5 | 6 | builder.AddServiceDefaults(); 7 | 8 | builder.AddChatClient(); 9 | 10 | builder.AddAzureBlobs(); 11 | 12 | var app = builder.Build(); 13 | 14 | app.MapGet("/describe/{name}", async (string name, BlobContainerClient client, IChatClient chatClient) => 15 | { 16 | // Get the blob client for the image 17 | var blobClient = client.GetBlobClient(name); 18 | 19 | // Check if the blob exists 20 | if (!await blobClient.ExistsAsync()) 21 | { 22 | return Results.NotFound(); 23 | } 24 | 25 | var properties = await blobClient.GetPropertiesAsync(); 26 | var contentType = properties.Value.ContentType ?? "image/png"; 27 | 28 | AIContent imageContent = await DownloadBlobAsByteArray(contentType); 29 | 30 | async Task DownloadBlobAsByteArray(string contentType) 31 | { 32 | // Download the image bytes 33 | var response = await blobClient.DownloadAsync(); 34 | var ms = new MemoryStream(); 35 | await response.Value.Content.CopyToAsync(ms); 36 | 37 | return new DataContent(ms.ToArray(), contentType); 38 | } 39 | 40 | // Generate a description for the image using AI 41 | var chatResponse = await chatClient.GetResponseAsync(new ChatMessage() 42 | { 43 | Contents = 44 | [ 45 | new TextContent("Generate a fun caption for this image:"), 46 | imageContent 47 | ] 48 | }); 49 | 50 | return Results.Content(chatResponse.Text); 51 | }); 52 | 53 | app.Run(); 54 | -------------------------------------------------------------------------------- /ImageProcessor/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "http": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": false, 8 | "applicationUrl": "http://localhost:5561", 9 | "environmentVariables": { 10 | "ASPNETCORE_ENVIRONMENT": "Development" 11 | } 12 | }, 13 | "https": { 14 | "commandName": "Project", 15 | "dotnetRunMessages": true, 16 | "launchBrowser": false, 17 | "applicationUrl": "https://localhost:7364;http://localhost:5561", 18 | "environmentVariables": { 19 | "ASPNETCORE_ENVIRONMENT": "Development" 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ImageProcessor/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.Hosting.Lifetime": "Information" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /ImageProcessor/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.Hosting.Lifetime": "Information" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /ImageUpload/Components/ImagePreview.razor: -------------------------------------------------------------------------------- 1 | @using static System.Web.HttpUtility 2 | 3 | @code { 4 | [Parameter] 5 | public string Path { get; set; } = string.Empty; 6 | } 7 | 8 | 9 | 10 | @{ 11 | var safePath = HtmlEncode(Path); 12 | var backHref = "/"; 13 | var imgSrc = $"/images/{UrlPathEncode(Path)}"; 14 | } 15 | 16 | 17 | 18 | 37 | 38 | 39 |
40 |
41 |

🖼️ @safePath

42 |
43 |
44 |
45 | Preview of @safePath 46 |
47 | 48 |
49 |

🤖 AI Description

50 |
Click the button below to generate a description...
51 |
52 | 53 | 54 |
55 | ← Back to Gallery 56 |
57 |
58 | 59 | 60 | -------------------------------------------------------------------------------- /ImageUpload/Components/PhotoGallery.razor: -------------------------------------------------------------------------------- 1 | @using Microsoft.AspNetCore.Components.Forms 2 | @using static System.Web.HttpUtility 3 | 4 | @code { 5 | [Parameter] 6 | public List Images { get; set; } = []; 7 | } 8 | 9 | 10 | 11 | 12 | 13 | 14 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /ImageUpload/Extensions.cs: -------------------------------------------------------------------------------- 1 | public static class Extensions 2 | { 3 | 4 | public static void AddAzureBlobs(this IHostApplicationBuilder builder) 5 | { 6 | // There's a bug https://github.com/dotnet/aspire/issues/9454 7 | // builder.AddAzureBlobContainerClient("images"); 8 | 9 | builder.AddAzureBlobClient("blobs"); 10 | builder.Services.AddSingleton(sp => 11 | { 12 | var client = sp.GetRequiredService(); 13 | return client.GetBlobContainerClient("image-uploads"); 14 | }); 15 | } 16 | } -------------------------------------------------------------------------------- /ImageUpload/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using Azure.Storage.Blobs; 2 | global using ImageUpload.Components; 3 | global using Microsoft.AspNetCore.Http.HttpResults; 4 | global using Microsoft.AspNetCore.Mvc; 5 | global using static System.Web.HttpUtility; -------------------------------------------------------------------------------- /ImageUpload/ImageUpload.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /ImageUpload/ImagesApi.cs: -------------------------------------------------------------------------------- 1 | using Azure.Storage.Blobs.Models; 2 | 3 | public static class ImagesApi 4 | { 5 | public static void MapImageApi(this WebApplication app) 6 | { 7 | app.MapGet("/", async (BlobContainerClient container) => 8 | { 9 | var blobs = container.GetBlobsAsync(); 10 | var images = new List(); 11 | 12 | await foreach (var item in blobs) 13 | { 14 | images.Add(item.Name); 15 | } 16 | 17 | return new RazorComponentResult(new { Images = images }); 18 | }); 19 | 20 | app.MapPost("/upload", async ( 21 | IFormFile file, 22 | BlobContainerClient container) => 23 | { 24 | var contentType = Path.GetExtension(file.FileName).ToLowerInvariant() switch 25 | { 26 | ".jpg" or ".jpeg" => "image/jpeg", 27 | ".png" => "image/png", 28 | ".gif" => "image/gif", 29 | _ => null 30 | }; 31 | 32 | if (contentType == null) 33 | { 34 | return Results.BadRequest("Unsupported file type."); 35 | } 36 | 37 | var blob = container.GetBlobClient(file.FileName); 38 | using var stream = file.OpenReadStream(); 39 | await blob.UploadAsync(stream, overwrite: true); 40 | await blob.SetHttpHeadersAsync(new BlobHttpHeaders 41 | { 42 | ContentType = contentType 43 | }); 44 | 45 | // Redirect to home after upload 46 | return Results.Redirect("/"); 47 | }); 48 | 49 | app.MapGet("/images/{*path}", async (string path, BlobContainerClient container) => 50 | { 51 | if (string.IsNullOrEmpty(path)) 52 | { 53 | return Results.NotFound(); 54 | } 55 | 56 | var blob = container.GetBlobClient(path); 57 | if (!await blob.ExistsAsync()) 58 | { 59 | return Results.NotFound(); 60 | } 61 | 62 | async Task GetContentTypeAsync() 63 | { 64 | var props = await blob.GetPropertiesAsync(); 65 | 66 | if (props.Value.ContentType != null) 67 | { 68 | return props.Value.ContentType; 69 | } 70 | 71 | return Path.GetExtension(path).ToLowerInvariant() switch 72 | { 73 | ".jpg" or ".jpeg" => "image/jpeg", 74 | ".png" => "image/png", 75 | ".gif" => "image/gif", 76 | _ => "application/octet-stream" 77 | }; 78 | } 79 | 80 | var contentType = await GetContentTypeAsync(); 81 | 82 | return Results.Stream(await blob.OpenReadAsync(), contentType); 83 | }); 84 | 85 | app.MapGet("/preview/{*path}", async Task (string path, BlobContainerClient container) => 86 | { 87 | var blob = container.GetBlobClient(path); 88 | if (!await blob.ExistsAsync()) 89 | { 90 | return Results.NotFound(); 91 | } 92 | 93 | // Render the ImagePreview Razor component 94 | return new RazorComponentResult(new { Path = path }); 95 | }); 96 | 97 | app.MapGet("/describe/{name}", async (string name, HttpClient client) => 98 | { 99 | // Forward the request to the image processor service 100 | var encodedName = UrlPathEncode(name); 101 | return await client.GetStringAsync($"http+https://imageprocessor/describe/{encodedName}"); 102 | }); 103 | 104 | // Endpoint to delete a blob 105 | app.MapPost("/delete", async ([FromForm] string file, BlobContainerClient container) => 106 | { 107 | if (!string.IsNullOrEmpty(file)) 108 | { 109 | var blob = container.GetBlobClient(file); 110 | await blob.DeleteIfExistsAsync(); 111 | } 112 | return Results.Redirect("/"); 113 | }); 114 | } 115 | } -------------------------------------------------------------------------------- /ImageUpload/Program.cs: -------------------------------------------------------------------------------- 1 | var builder = WebApplication.CreateBuilder(args); 2 | 3 | builder.AddAzureBlobs(); 4 | 5 | builder.Services.AddRazorComponents(); 6 | 7 | builder.Services.AddAntiforgery(); 8 | 9 | builder.AddServiceDefaults(); 10 | 11 | var app = builder.Build(); 12 | 13 | app.UseAntiforgery(); 14 | 15 | app.MapStaticAssets(); 16 | 17 | app.MapImageApi(); 18 | 19 | app.MapDefaultEndpoints(); 20 | 21 | app.Run(); 22 | -------------------------------------------------------------------------------- /ImageUpload/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "http": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": false, 8 | "applicationUrl": "http://localhost:5565", 9 | "environmentVariables": { 10 | "ASPNETCORE_ENVIRONMENT": "Development" 11 | } 12 | }, 13 | "https": { 14 | "commandName": "Project", 15 | "dotnetRunMessages": true, 16 | "launchBrowser": false, 17 | "applicationUrl": "https://localhost:7363;http://localhost:5565", 18 | "environmentVariables": { 19 | "ASPNETCORE_ENVIRONMENT": "Development" 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ImageUpload/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /ImageUpload/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /ImageUpload/wwwroot/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: system-ui, sans-serif; 3 | background: #f8f9fa; 4 | color: #222; 5 | margin: 0; 6 | padding: 2em 0 0 0; 7 | } 8 | 9 | h1 { 10 | font-size: 1.5em; 11 | margin: 1em 0 0.5em 0; 12 | } 13 | 14 | ul { 15 | list-style: none; 16 | padding: 0 2em; 17 | margin: 0 0 3em 0; 18 | max-width: 700px; 19 | } 20 | 21 | li { 22 | margin: 1.5em 0; 23 | padding: 1em 0.5em; 24 | background: #fff; 25 | border-radius: 8px; 26 | box-shadow: 0 1px 4px rgba(0,0,0,0.04); 27 | } 28 | 29 | a { 30 | color: #1565c0; 31 | text-decoration: none; 32 | margin-right: 0.5em; 33 | } 34 | 35 | a:hover { 36 | text-decoration: underline; 37 | } 38 | 39 | form { 40 | display: inline; 41 | margin-left: 1em; 42 | } 43 | 44 | button { 45 | background: #e53935; 46 | color: #fff; 47 | border: none; 48 | border-radius: 4px; 49 | padding: 0.3em 0.8em; 50 | cursor: pointer; 51 | margin-left: 0.5em; 52 | } 53 | 54 | button:hover { 55 | background: #b71c1c; 56 | } 57 | 58 | input[type='file'] { 59 | margin-right: 0.5em; 60 | } 61 | 62 | pre { 63 | background: #f4f4f4; 64 | padding: 1em; 65 | border-radius: 6px; 66 | margin-top: 1em; 67 | } 68 | 69 | form[action='/upload'] { 70 | display: block; 71 | margin-bottom: 2em; 72 | } 73 | 74 | .gallery { 75 | display: flex; 76 | flex-wrap: wrap; 77 | gap: 1.5em; 78 | justify-content: flex-start; 79 | padding: 2em; 80 | } 81 | 82 | .gallery-item { 83 | background: #fff; 84 | border-radius: 8px; 85 | box-shadow: 0 1px 4px rgba(0,0,0,0.07); 86 | padding: 1em; 87 | display: flex; 88 | flex-direction: column; 89 | align-items: center; 90 | width: 180px; 91 | } 92 | 93 | .gallery-item img { 94 | width: 160px; 95 | height: 120px; 96 | object-fit: cover; 97 | border-radius: 6px; 98 | margin-bottom: 0.7em; 99 | box-shadow: 0 1px 4px rgba(0,0,0,0.10); 100 | background: #eee; 101 | } 102 | 103 | /* ImagePreview styles */ 104 | .image-preview-body { 105 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 106 | margin: 0; 107 | padding: 2rem; 108 | background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 109 | min-height: 100vh; 110 | color: #333; 111 | } 112 | 113 | .preview-container { 114 | max-width: 800px; 115 | margin: 0 auto; 116 | background: white; 117 | border-radius: 16px; 118 | box-shadow: 0 8px 32px rgba(0,0,0,0.1); 119 | overflow: hidden; 120 | } 121 | 122 | .preview-header { 123 | background: #4a5568; 124 | color: white; 125 | padding: 1.5rem 2rem; 126 | text-align: center; 127 | } 128 | 129 | .preview-header h1 { 130 | margin: 0; 131 | font-size: 1.5rem; 132 | font-weight: 600; 133 | } 134 | 135 | .preview-content { 136 | padding: 2rem; 137 | text-align: center; 138 | } 139 | 140 | .image-container { 141 | margin: 2rem 0; 142 | position: relative; 143 | display: inline-block; 144 | border-radius: 12px; 145 | overflow: hidden; 146 | box-shadow: 0 4px 20px rgba(0,0,0,0.15); 147 | } 148 | 149 | .preview-image { 150 | max-width: 100%; 151 | max-height: 500px; 152 | display: block; 153 | border-radius: 12px; 154 | } 155 | 156 | .btn { 157 | background: linear-gradient(45deg, #667eea, #764ba2); 158 | color: white; 159 | border: none; 160 | padding: 12px 24px; 161 | border-radius: 8px; 162 | font-size: 1rem; 163 | font-weight: 500; 164 | cursor: pointer; 165 | transition: all 0.3s ease; 166 | box-shadow: 0 2px 8px rgba(0,0,0,0.15); 167 | margin: 1rem 0; 168 | } 169 | 170 | .btn:hover { 171 | transform: translateY(-2px); 172 | box-shadow: 0 4px 16px rgba(0,0,0,0.2); 173 | } 174 | 175 | .btn:active { 176 | transform: translateY(0); 177 | } 178 | 179 | .description-section { 180 | background: linear-gradient(135deg, #f8fafc 0%, #ffffff 100%); 181 | border: 1px solid #e2e8f0; 182 | border-radius: 12px; 183 | padding: 1.5rem; 184 | margin: 2rem 0; 185 | box-shadow: 0 4px 16px rgba(0,0,0,0.08); 186 | font-size: 1.1rem; 187 | line-height: 1.6; 188 | color: #4a5568; 189 | text-align: left; 190 | max-height: 300px; 191 | overflow-y: auto; 192 | } 193 | 194 | .description-section h3 { 195 | margin: 0 0 1rem 0; 196 | color: #4a5568; 197 | font-size: 1.2rem; 198 | font-weight: 600; 199 | display: flex; 200 | align-items: center; 201 | gap: 0.5rem; 202 | } 203 | 204 | .back-link { 205 | display: inline-block; 206 | color: #667eea; 207 | text-decoration: none; 208 | font-weight: 500; 209 | margin-top: 2rem; 210 | padding: 8px 16px; 211 | border: 2px solid #667eea; 212 | border-radius: 8px; 213 | transition: all 0.3s ease; 214 | } 215 | 216 | .back-link:hover { 217 | background: #667eea; 218 | color: white; 219 | transform: translateY(-1px); 220 | } 221 | 222 | .loading { 223 | color: #667eea; 224 | font-style: italic; 225 | } 226 | 227 | /* Modern Photo Gallery styles */ 228 | .photo-gallery-body { 229 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 230 | margin: 0; 231 | padding: 2rem; 232 | background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 233 | min-height: 100vh; 234 | color: #333; 235 | } 236 | 237 | .gallery-container { 238 | max-width: 1200px; 239 | margin: 0 auto; 240 | background: white; 241 | border-radius: 16px; 242 | box-shadow: 0 8px 32px rgba(0,0,0,0.1); 243 | overflow: hidden; 244 | } 245 | 246 | .gallery-header { 247 | background: #4a5568; 248 | color: white; 249 | padding: 2rem; 250 | text-align: center; 251 | } 252 | 253 | .gallery-header h1 { 254 | margin: 0 0 1rem 0; 255 | font-size: 2rem; 256 | font-weight: 600; 257 | } 258 | 259 | .upload-section { 260 | background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%); 261 | padding: 2rem; 262 | border-bottom: 1px solid #e2e8f0; 263 | } 264 | 265 | .upload-form { 266 | display: flex; 267 | align-items: center; 268 | justify-content: center; 269 | gap: 1rem; 270 | flex-wrap: wrap; 271 | margin: 0; 272 | } 273 | 274 | .upload-form label { 275 | font-weight: 500; 276 | color: #4a5568; 277 | } 278 | 279 | .file-input { 280 | padding: 8px 12px; 281 | border: 2px solid #e2e8f0; 282 | border-radius: 8px; 283 | background: white; 284 | font-size: 1rem; 285 | transition: border-color 0.3s ease; 286 | } 287 | 288 | .file-input:focus { 289 | outline: none; 290 | border-color: #667eea; 291 | box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); 292 | } 293 | 294 | .upload-btn { 295 | background: linear-gradient(45deg, #667eea, #764ba2); 296 | color: white; 297 | border: none; 298 | padding: 10px 20px; 299 | border-radius: 8px; 300 | font-size: 1rem; 301 | font-weight: 500; 302 | cursor: pointer; 303 | transition: all 0.3s ease; 304 | box-shadow: 0 2px 8px rgba(0,0,0,0.15); 305 | } 306 | 307 | .upload-btn:hover { 308 | transform: translateY(-2px); 309 | box-shadow: 0 4px 16px rgba(0,0,0,0.2); 310 | } 311 | 312 | .gallery-content { 313 | padding: 2rem; 314 | } 315 | 316 | .modern-gallery { 317 | display: grid; 318 | grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); 319 | gap: 2rem; 320 | padding: 0; 321 | } 322 | 323 | .modern-gallery-item { 324 | background: white; 325 | border-radius: 12px; 326 | box-shadow: 0 4px 20px rgba(0,0,0,0.08); 327 | overflow: hidden; 328 | transition: all 0.3s ease; 329 | position: relative; 330 | } 331 | 332 | .modern-gallery-item:hover { 333 | transform: translateY(-8px); 334 | box-shadow: 0 8px 32px rgba(0,0,0,0.15); 335 | } 336 | 337 | .image-link { 338 | display: block; 339 | position: relative; 340 | overflow: hidden; 341 | } 342 | 343 | .gallery-image { 344 | width: 100%; 345 | height: 200px; 346 | object-fit: cover; 347 | transition: transform 0.3s ease; 348 | } 349 | 350 | .modern-gallery-item:hover .gallery-image { 351 | transform: scale(1.05); 352 | } 353 | 354 | .image-overlay { 355 | position: absolute; 356 | top: 0; 357 | left: 0; 358 | right: 0; 359 | bottom: 0; 360 | background: linear-gradient(45deg, rgba(102, 126, 234, 0.8), rgba(118, 75, 162, 0.8)); 361 | opacity: 0; 362 | transition: opacity 0.3s ease; 363 | display: flex; 364 | align-items: center; 365 | justify-content: center; 366 | color: white; 367 | font-weight: 500; 368 | font-size: 1.1rem; 369 | } 370 | 371 | .modern-gallery-item:hover .image-overlay { 372 | opacity: 1; 373 | } 374 | 375 | .gallery-item-actions { 376 | padding: 1rem; 377 | display: flex; 378 | justify-content: space-between; 379 | align-items: center; 380 | background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%); 381 | } 382 | 383 | .image-name { 384 | font-size: 0.9rem; 385 | color: #4a5568; 386 | font-weight: 500; 387 | max-width: 150px; 388 | white-space: nowrap; 389 | overflow: hidden; 390 | text-overflow: ellipsis; 391 | } 392 | 393 | .delete-form { 394 | margin: 0; 395 | } 396 | 397 | .delete-btn { 398 | background: linear-gradient(45deg, #e53935, #c62828); 399 | color: white; 400 | border: none; 401 | padding: 6px 12px; 402 | border-radius: 6px; 403 | font-size: 0.85rem; 404 | font-weight: 500; 405 | cursor: pointer; 406 | transition: all 0.3s ease; 407 | box-shadow: 0 2px 4px rgba(0,0,0,0.1); 408 | } 409 | 410 | .delete-btn:hover { 411 | transform: translateY(-1px); 412 | box-shadow: 0 4px 8px rgba(0,0,0,0.2); 413 | } 414 | 415 | .empty-gallery { 416 | text-align: center; 417 | padding: 4rem 2rem; 418 | color: #718096; 419 | } 420 | 421 | .empty-gallery h3 { 422 | font-size: 1.5rem; 423 | margin-bottom: 1rem; 424 | color: #4a5568; 425 | } 426 | 427 | .empty-gallery p { 428 | font-size: 1.1rem; 429 | margin: 0; 430 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 David Fowler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ImageFun 📸✨ 2 | 3 | ImageFun is an AI-enabled photo gallery application that allows users to upload, view, and automatically generate *fun* descriptions for their images. Built with modern .NET technologies and powered by OpenAI's GPT-4o model, ImageFun combines cloud storage with artificial intelligence to create an enhanced photo management experience. 4 | 5 | ## Architecture 6 | 7 | ImageFun is built as a distributed application using .NET Aspire with two main components: 8 | 9 | ### Frontend (ImageUpload) 10 | - **Technology**: ASP.NET Core with Razor Components 11 | - **Purpose**: Serves the web UI, handles image uploads, and manages the photo gallery 12 | - **Deployment**: Azure App Service Environment 13 | 14 | ### Backend (ImageProcessor) 15 | - **Technology**: ASP.NET Minimal API 16 | - **Purpose**: Processes images and generates AI descriptions using OpenAI 17 | - **Deployment**: Azure Container Apps Environment 18 | 19 | ## External Dependencies 20 | 21 | To run ImageFun, you'll need the following external services: 22 | 23 | ### Required Services 24 | 25 | 1. **OpenAI API Key** (`oaikey`) 26 | - Used for generating AI descriptions of uploaded images 27 | - Requires a valid OpenAI API key with access to GPT-4o model 28 | - Configured as a secret parameter in the Aspire host (`Parameters:oaikey`) 29 | 30 | ## .NET Aspire Multi-Compute Environment Showcase 31 | 32 | This project showcases the new **Compute Environments** feature introduced in .NET Aspire 9.3, which allows you to deploy different parts of your application to different Azure compute services based on their specific requirements. 33 | 34 | ### Compute Environment Configuration 35 | 36 | ```csharp 37 | // Frontend - optimized for web serving 38 | var feenv = builder.AddAzureAppServiceEnvironment("fe-env") 39 | .WithAzureContainerRegistry(acr); 40 | 41 | // Backend - optimized for API processing 42 | var beenv = builder.AddAzureContainerAppEnvironment("be-env") 43 | .WithAzureContainerRegistry(acr); 44 | 45 | // Assign services to appropriate environments 46 | var imageProcessor = builder.AddProject("imageprocessor") 47 | .WithComputeEnvironment(beenv); // API service → Container Apps 48 | 49 | builder.AddProject("web") 50 | .WithComputeEnvironment(feenv); // Web frontend → App Service 51 | ``` 52 | 53 | ### Why Different Compute Environments? 54 | 55 | This application is showcasing using different compute environments for different workloads: 56 | 57 | **App Service Environment (Frontend)** 58 | - Optimized for web applications with built-in scaling 59 | - Excellent for serving static content and web UIs 60 | - Integrated deployment and monitoring capabilities 61 | - Cost-effective for web workloads 62 | 63 | **Container Apps Environment (Backend)** 64 | - Perfect for microservices and API workloads 65 | - Event-driven scaling capabilities 66 | - Better resource utilization for processing tasks 67 | - Ideal for AI/ML workloads that may have variable demand 68 | 69 | ## Getting Started 70 | 71 | ### Prerequisites 72 | 73 | - .NET 9.0 SDK 74 | - Azure CLI 75 | - Docker Desktop (for local development) 76 | - OpenAI API key 77 | 78 | ### Local Development 79 | 80 | 1. **Clone the repository** 81 | ```bash 82 | git clone 83 | cd ImageFun 84 | ``` 85 | 86 | 2. **Set up user secrets for OpenAI** 87 | ```bash 88 | cd ImageFun.AppHost 89 | dotnet user-secrets set "Parameters:oaikey" "your-openai-api-key-here" 90 | ``` 91 | 92 | 3. **Install the aspire cli** 93 | ```bash 94 | dotnet tool install -g aspire --prerelease 95 | ``` 96 | 97 | 4. **Run the application** 98 | ```bash 99 | aspire run 100 | ``` 101 | 102 | 5. **Access the application** 103 | - Open your browser to the URL shown in the Aspire dashboard 104 | - Upload images and enjoy AI-generated descriptions! 105 | 106 | ### Deployment to Azure 107 | 108 | 1. **Provision Azure resources** 109 | ```bash 110 | azd up 111 | ``` 112 | 113 | ## Technology Stack 114 | 115 | - **.NET 9.0**: Latest .NET runtime and SDK 116 | - **ASP.NET Core**: Web framework for both frontend and backend 117 | - **Razor Components**: Server-side rendering for the UI 118 | - **.NET Aspire**: Cloud-native orchestration and deployment 119 | - **Azure Blob Storage**: Image storage and retrieval 120 | - **OpenAI GPT-4o**: AI-powered image description generation 121 | - **Azure App Service**: Frontend hosting 122 | - **Azure Container Apps**: Backend API hosting 123 | 124 | ## Contributing 125 | 126 | 1. Fork the repository 127 | 2. Create a feature branch 128 | 3. Make your changes 129 | 4. Test locally using the Aspire dashboard 130 | 5. Submit a pull request 131 | 132 | ## License 133 | 134 | This project is licensed under the MIT License - see the LICENSE file for details. 135 | -------------------------------------------------------------------------------- /azure.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json 2 | 3 | name: image-fun 4 | services: 5 | app: 6 | language: dotnet 7 | project: ./ImageFun.AppHost/ImageFun.AppHost.csproj 8 | host: containerapp 9 | --------------------------------------------------------------------------------