Skip to content

Commit 8321095

Browse files
author
Ghaith Prosoft
committed
Refactor storage managers and centralize logic
Updated `IFileEntry` and `FileEntry` to include `StoragePath`. Removed `ImageProcessingOptions` from `LocalOptions.cs`. Refactored `LocalFileStorageManager` to extend `FileStorageManagerBase`. Updated `S3Options` to use `S3ImageProcessingOptions`. Refactored `S3FileStorageManager` similarly to local manager. Removed `ImageProcessingOptions` from `SftpOptions.cs`. Refactored `SftpFileStorageManager` to extend `FileStorageManagerBase`. Added `ImageProcessingOptions.cs` for shared image-processing settings. Introduced `FileStorageManagerBase.cs` for centralized logic.
1 parent 5d07839 commit 8321095

11 files changed

Lines changed: 394 additions & 702 deletions

File tree

src/KitStack.Abstractions/Interfaces/IFileEntry.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,19 @@ public interface IFileEntry
2121
string FileName { get; set; }
2222

2323
/// <summary>
24-
/// Logical or provider-specific location/key for the stored file (for example "app/images/2026/photo.jpg" or an S3 key).
24+
/// Provider-relative location/key for the stored file, without the base directory
25+
/// (for example "category/Entity/Images/abc.jpg" or an S3 key).
2526
/// This value is intended to be persisted by callers and used to retrieve the file from the provider.
2627
/// </summary>
2728
string FileLocation { get; set; }
2829

30+
/// <summary>
31+
/// Full storage path including the configured base directory
32+
/// (for example "Files/category/Entity/Images/abc.jpg").
33+
/// Useful for serving files directly or constructing absolute URLs.
34+
/// </summary>
35+
string? StoragePath { get; set; }
36+
2937
/// <summary>
3038
/// Optional logical category or module name used to group files (for example "Users" or "Products").
3139
/// Providers may use this value to organize storage layout.

src/KitStack.Abstractions/Models/FileEntry.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ public class FileEntry : IFileEntry
1414

1515
public string FileLocation { get; set; } = string.Empty;
1616

17+
public string? StoragePath { get; set; }
18+
1719
public string? Category { get; set; } = string.Empty;
1820

1921
public long Size { get; set; }
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
namespace KitStack.Abstractions.Options;
2+
3+
/// <summary>
4+
/// Common image-processing options shared across all storage providers.
5+
/// Providers may extend this class to add provider-specific settings (e.g. per-target routing).
6+
/// </summary>
7+
public class ImageProcessingOptions
8+
{
9+
/// <summary>Generate a thumbnail copy for uploaded images.</summary>
10+
public bool CreateThumbnail { get; set; } = true;
11+
12+
/// <summary>Thumbnail maximum width (px).</summary>
13+
public int ThumbnailMaxWidth { get; set; } = 200;
14+
15+
/// <summary>Thumbnail maximum height (px).</summary>
16+
public int ThumbnailMaxHeight { get; set; } = 200;
17+
18+
/// <summary>Generate a compressed/normalised copy for uploaded images.</summary>
19+
public bool CreateCompressed { get; set; } = true;
20+
21+
/// <summary>Maximum width for the compressed image (px).</summary>
22+
public int CompressedMaxWidth { get; set; } = 1200;
23+
24+
/// <summary>Maximum height for the compressed image (px).</summary>
25+
public int CompressedMaxHeight { get; set; } = 1200;
26+
27+
/// <summary>JPEG quality (0–100) applied to all generated variants.</summary>
28+
public int JpegQuality { get; set; } = 85;
29+
30+
/// <summary>Additional named sizes to generate.</summary>
31+
public IList<ImageSizeOption> AdditionalSizes { get; set; } = [];
32+
}
33+
34+
/// <summary>Defines a single named image-size variant.</summary>
35+
public class ImageSizeOption
36+
{
37+
public string SizeName { get; set; } = string.Empty;
38+
public int MaxWidth { get; set; }
39+
public int MaxHeight { get; set; }
40+
public int JpegQuality { get; set; } = 80;
41+
}
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
using KitStack.Abstractions.Extensions;
2+
using KitStack.Abstractions.Interfaces;
3+
using KitStack.Abstractions.Models;
4+
using KitStack.Abstractions.Options;
5+
using KitStack.Abstractions.Utilities;
6+
using Microsoft.AspNetCore.Http;
7+
8+
namespace KitStack.Abstractions.Services;
9+
10+
/// <summary>
11+
/// Abstract base class for file storage managers.
12+
/// Centralises the shared variant-processing pipeline so each provider only needs to implement
13+
/// <see cref="CreateAsync{T}(IFormFile,string?,CancellationToken)"/> and <see cref="StoreVariantAsync"/>.
14+
/// </summary>
15+
public abstract class FileStorageManagerBase : IFileStorageManager
16+
{
17+
/// <summary>Provider identifier written into every <see cref="FileEntry.StorageProvider"/>.</summary>
18+
protected abstract string StorageProvider { get; }
19+
20+
/// <summary>
21+
/// Returns the active image-processing configuration, or <c>null</c> to skip variant generation.
22+
/// </summary>
23+
protected abstract ImageProcessingOptions? GetImageProcessingOptions();
24+
25+
// ── IFileStorageManager ─────────────────────────────────────────────────
26+
27+
/// <inheritdoc/>
28+
public abstract Task<IFileEntry> CreateAsync<T>(
29+
IFormFile file, string? category, CancellationToken cancellationToken = default)
30+
where T : class;
31+
32+
/// <inheritdoc/>
33+
public async Task<IFileEntry> CreateAsync<T>(
34+
T entity, IFormFile file, string? category, CancellationToken cancellationToken = default)
35+
where T : class, IFileAttachable
36+
{
37+
var primary = await CreateAsync<T>(file, category, cancellationToken).ConfigureAwait(false);
38+
entity.AddFileAttachment(primary);
39+
primary.LinkToEntity(entity, category ?? typeof(T).Name);
40+
return primary;
41+
}
42+
43+
/// <inheritdoc/>
44+
public async Task<(IFileEntry Primary, List<IFileEntry> Variants)> CreateWithVariantsAsync<T>(
45+
IFormFile file, string? category, CancellationToken cancellationToken = default)
46+
where T : class
47+
{
48+
var primary = await CreateAsync<T>(file, category, cancellationToken).ConfigureAwait(false);
49+
var variants = new List<IFileEntry>();
50+
51+
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
52+
var options = GetImageProcessingOptions();
53+
54+
if (!ImageProcessingHelper.IsImageExtension(extension) || options is null)
55+
return (primary, variants);
56+
57+
var bytes = await ReadAllBytesAsync(file, cancellationToken).ConfigureAwait(false);
58+
variants = await ProcessVariantsAsync(primary, bytes, category, options, cancellationToken)
59+
.ConfigureAwait(false);
60+
61+
return (primary, variants);
62+
}
63+
64+
// ── Shared variant pipeline ─────────────────────────────────────────────
65+
66+
private async Task<List<IFileEntry>> ProcessVariantsAsync(
67+
IFileEntry primary,
68+
byte[] imageBytes,
69+
string? category,
70+
ImageProcessingOptions options,
71+
CancellationToken ct)
72+
{
73+
var variants = new List<IFileEntry>();
74+
var relativeFolder = Path.GetDirectoryName(primary.FileLocation)?.Replace('\\', '/') ?? string.Empty;
75+
76+
if (options.CreateCompressed)
77+
{
78+
var (location, size) = await ResizeAndStoreAsync(
79+
imageBytes, relativeFolder, "compressed", $"{primary.Id:N}.jpg",
80+
options.CompressedMaxWidth, options.CompressedMaxHeight, options.JpegQuality,
81+
"compressed", ct).ConfigureAwait(false);
82+
83+
var entry = BuildVariantEntry(location, size, "compressed", category, primary);
84+
entry.CopyRelationsFrom(primary);
85+
variants.Add(entry);
86+
(primary.Metadata ??= new Dictionary<string, string>())["CompressedPath"] = location;
87+
}
88+
89+
if (options.CreateThumbnail)
90+
{
91+
var (location, size) = await ResizeAndStoreAsync(
92+
imageBytes, relativeFolder, "thumbnails", $"{primary.Id:N}.jpg",
93+
options.ThumbnailMaxWidth, options.ThumbnailMaxHeight, options.JpegQuality,
94+
"thumbnail", ct).ConfigureAwait(false);
95+
96+
var entry = BuildVariantEntry(location, size, "thumbnail", category, primary);
97+
entry.CopyRelationsFrom(primary);
98+
variants.Add(entry);
99+
(primary.Metadata ??= new Dictionary<string, string>())["ThumbnailPath"] = location;
100+
}
101+
102+
if (options.AdditionalSizes?.Count > 0)
103+
{
104+
var variantPaths = new List<string>();
105+
foreach (var size in options.AdditionalSizes)
106+
{
107+
if (string.IsNullOrWhiteSpace(size.SizeName)) continue;
108+
109+
var (location, fileSize) = await ResizeAndStoreAsync(
110+
imageBytes, relativeFolder, size.SizeName, $"{Guid.NewGuid():N}.jpg",
111+
size.MaxWidth, size.MaxHeight, size.JpegQuality,
112+
size.SizeName, ct).ConfigureAwait(false);
113+
114+
var entry = BuildVariantEntry(location, fileSize, size.SizeName, category, primary);
115+
entry.CopyRelationsFrom(primary);
116+
variants.Add(entry);
117+
variantPaths.Add(location);
118+
}
119+
120+
if (variantPaths.Count > 0)
121+
(primary.Metadata ??= new Dictionary<string, string>())["Variants"] = string.Join(';', variantPaths);
122+
}
123+
124+
return variants;
125+
}
126+
127+
private async Task<(string location, long size)> ResizeAndStoreAsync(
128+
byte[] imageBytes,
129+
string relativeFolder,
130+
string variantFolder,
131+
string fileName,
132+
int maxWidth, int maxHeight, int jpegQuality,
133+
string variantType,
134+
CancellationToken ct)
135+
{
136+
await using var outStream = new MemoryStream();
137+
using (var src = new MemoryStream(imageBytes))
138+
await ImageProcessingHelper.CreateResizedJpegToStreamAsync(
139+
src, outStream, maxWidth, maxHeight, jpegQuality, ct).ConfigureAwait(false);
140+
141+
var size = outStream.Length;
142+
outStream.Seek(0, SeekOrigin.Begin);
143+
144+
var location = await StoreVariantAsync(
145+
outStream, relativeFolder, variantFolder, fileName, variantType, ct).ConfigureAwait(false);
146+
147+
return (location, size);
148+
}
149+
150+
/// <summary>
151+
/// Stores the processed JPEG bytes and returns the provider-relative location string
152+
/// (relative path for Local/SFTP, S3 key for S3, etc.).
153+
/// </summary>
154+
/// <param name="data">Processed JPEG stream, position reset to 0 before this call.</param>
155+
/// <param name="relativeFolder">Provider-relative folder of the primary file, e.g. <c>category/Entity/Images</c>.</param>
156+
/// <param name="variantFolder">Sub-folder for this variant, e.g. <c>compressed</c>, <c>thumbnails</c>, or a custom size name.</param>
157+
/// <param name="fileName">Generated file name, e.g. <c>abcd1234ef.jpg</c>.</param>
158+
/// <param name="variantType">Logical variant type for target routing: <c>compressed</c>, <c>thumbnail</c>, or a custom size name.</param>
159+
protected abstract Task<string> StoreVariantAsync(
160+
MemoryStream data,
161+
string relativeFolder,
162+
string variantFolder,
163+
string fileName,
164+
string variantType,
165+
CancellationToken ct);
166+
167+
/// <summary>
168+
/// Builds a <see cref="FileEntry"/> for a generated variant.
169+
/// Override to set additional provider-specific properties (e.g. <c>StoragePath</c> for Local).
170+
/// </summary>
171+
protected virtual FileEntry BuildVariantEntry(
172+
string location, long size, string variantType, string? category, IFileEntry primary)
173+
{
174+
return new FileEntry
175+
{
176+
Id = Guid.NewGuid(),
177+
FileName = Path.GetFileName(location),
178+
FileLocation = location,
179+
Size = size,
180+
ContentType = "image/jpeg",
181+
FileExtension = primary.FileExtension,
182+
UploadedTime = DateTime.UtcNow,
183+
VariantType = variantType,
184+
StorageProvider = StorageProvider,
185+
OriginalFileName = Path.GetFileName(location),
186+
LastAccessedTime = DateTimeOffset.UtcNow,
187+
Category = category,
188+
Encrypted = false,
189+
Metadata = new Dictionary<string, string>(),
190+
};
191+
}
192+
193+
// ── Utilities ───────────────────────────────────────────────────────────
194+
195+
/// <summary>Reads all bytes from <paramref name="file"/> into a new byte array.</summary>
196+
protected static async Task<byte[]> ReadAllBytesAsync(IFormFile file, CancellationToken ct)
197+
{
198+
await using var stream = file.OpenReadStream();
199+
using var ms = new MemoryStream((int)file.Length);
200+
await stream.CopyToAsync(ms, ct).ConfigureAwait(false);
201+
return ms.ToArray();
202+
}
203+
}
Lines changed: 3 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
namespace KitStack.Storage.Local.Options;
1+
using KitStack.Abstractions.Options;
2+
3+
namespace KitStack.Storage.Local.Options;
24

35
/// <summary>
46
/// Options for the local filesystem provider (bound from Storage:Local)
@@ -21,57 +23,3 @@ public class LocalOptions
2123
/// </summary>
2224
public ImageProcessingOptions ImageProcessing { get; set; } = new ImageProcessingOptions();
2325
}
24-
25-
/// <summary>
26-
/// Image processing options used by the local provider to generate derivatives.
27-
/// </summary>
28-
public class ImageProcessingOptions
29-
{
30-
/// <summary>
31-
/// Create a thumbnail copy for uploaded images.
32-
/// </summary>
33-
public bool CreateThumbnail { get; set; } = true;
34-
35-
/// <summary>
36-
/// Thumbnail maximum width (px).
37-
/// </summary>
38-
public int ThumbnailMaxWidth { get; set; } = 200;
39-
40-
/// <summary>
41-
/// Thumbnail maximum height (px).
42-
/// </summary>
43-
public int ThumbnailMaxHeight { get; set; } = 200;
44-
45-
/// <summary>
46-
/// Create a compressed/normalized copy for uploaded images.
47-
/// </summary>
48-
public bool CreateCompressed { get; set; } = true;
49-
50-
/// <summary>
51-
/// Maximum width for compressed image. If the image is larger, it will be downscaled to fit.
52-
/// </summary>
53-
public int CompressedMaxWidth { get; set; } = 1200;
54-
55-
/// <summary>
56-
/// Maximum height for compressed image.
57-
/// </summary>
58-
public int CompressedMaxHeight { get; set; } = 1200;
59-
60-
/// <summary>
61-
/// JPEG quality for compressed/thumbnail variants (0-100).
62-
/// </summary>
63-
public int JpegQuality { get; set; } = 85;
64-
65-
/// <summary>
66-
/// Additional custom sizes to create. Each entry will be stored under a folder named after SizeName.
67-
/// </summary>
68-
public IList<ImageSizeOption> AdditionalSizes { get; set; } = [];
69-
}
70-
71-
public class ImageSizeOption
72-
{
73-
public string SizeName { get; set; } = string.Empty;
74-
public int MaxWidth { get; set; }
75-
public int MaxHeight { get; set; }
76-
public int JpegQuality { get; set; } = 80;
77-
}

0 commit comments

Comments
 (0)