feat: add audio playback and file download features with corresponding UI updates

This commit is contained in:
HueByte
2026-02-20 20:47:28 +01:00
parent ae06e7ae12
commit a860a8fbc3
15 changed files with 313 additions and 7 deletions
@@ -202,13 +202,16 @@ public class ChannelsController : ControllerBase
if (file.Length > HubConstants.MaxFileSizeBytes)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
// Detect if file is an image by checking magic bytes
// Detect file type: image (magic bytes), audio (extension), or generic file
using var stream = file.OpenReadStream();
var isImage = FileValidationHelper.IsValidImage(stream);
var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName);
var (fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName);
var messageType = isImage ? MessageType.Image : MessageType.File;
var messageType = isImage ? MessageType.Image
: isAudio ? MessageType.Audio
: MessageType.File;
string content;
if (isImage)
@@ -36,6 +36,13 @@ public class FilesController : ControllerBase
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
".mp3" => "audio/mpeg",
".wav" => "audio/wav",
".ogg" => "audio/ogg",
".flac" => "audio/flac",
".aac" => "audio/aac",
".m4a" => "audio/mp4",
".wma" => "audio/x-ms-wma",
".pdf" => "application/pdf",
".txt" => "text/plain",
_ => "application/octet-stream"
+1
View File
@@ -108,6 +108,7 @@ while (true)
builder.Services.AddSingleton<FileStorageService>();
builder.Services.AddSingleton<LinkEmbedService>();
builder.Services.AddHostedService<ServerDirectoryService>();
builder.Services.AddHostedService<FileCleanupService>();
// ── Encryption ─────────────────────────────────────────────────────
builder.Services.AddSingleton<IMessageEncryptionService, MessageEncryptionService>();
@@ -0,0 +1,69 @@
namespace EchoHub.Server.Services;
public sealed class FileCleanupService : BackgroundService
{
private readonly IConfiguration _configuration;
private readonly ILogger<FileCleanupService> _logger;
public FileCleanupService(IConfiguration configuration, ILogger<FileCleanupService> logger)
{
_configuration = configuration;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var intervalHours = _configuration.GetValue("Storage:CleanupIntervalHours", 1);
var retentionDays = _configuration.GetValue("Storage:RetentionDays", 30);
var storagePath = _configuration["Storage:Path"]
?? Path.Combine(AppContext.BaseDirectory, "uploads");
_logger.LogInformation(
"File cleanup service started — interval: {Hours}h, retention: {Days}d, path: {Path}",
intervalHours, retentionDays, storagePath);
while (!stoppingToken.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromHours(intervalHours), stoppingToken);
try
{
CleanupOldFiles(storagePath, retentionDays);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during file cleanup");
}
}
}
private void CleanupOldFiles(string storagePath, int retentionDays)
{
if (!Directory.Exists(storagePath))
return;
var cutoff = DateTime.UtcNow.AddDays(-retentionDays);
var files = Directory.GetFiles(storagePath);
var deleted = 0;
foreach (var file in files)
{
var createdAt = File.GetCreationTimeUtc(file);
if (createdAt < cutoff)
{
try
{
File.Delete(file);
deleted++;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete old file: {File}", file);
}
}
}
if (deleted > 0)
_logger.LogInformation("File cleanup: deleted {Count} files older than {Days} days", deleted, retentionDays);
}
}
@@ -52,6 +52,20 @@ public static class FileValidationHelper
}
}
private static readonly HashSet<string> AudioExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".mp3", ".wav", ".ogg", ".flac", ".aac", ".m4a", ".wma"
};
/// <summary>
/// Checks whether the file name has a recognized audio extension.
/// </summary>
public static bool IsAudioFile(string fileName)
{
var ext = Path.GetExtension(fileName);
return !string.IsNullOrEmpty(ext) && AudioExtensions.Contains(ext);
}
private static bool StartsWith(byte[] buffer, int length, byte[] magic)
{
if (length < magic.Length)
@@ -1,6 +1,7 @@
using System.Text.RegularExpressions;
using EchoHub.Core.Constants;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Data;
using Microsoft.EntityFrameworkCore;
@@ -12,12 +13,14 @@ public static partial class DataMigrationService
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger("EchoHub.Server.Setup.DataMigration");
await EnsureDefaultChannelsPublicAsync(db, logger);
await MigrateAnsiMessagesAsync(db, logger);
await MigrateEmbedJsonToArrayAsync(db, logger);
await EnsureConfiguredAdminsAsync(db, config, logger);
}
/// <summary>
@@ -94,6 +97,40 @@ public static partial class DataMigrationService
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
private static partial Regex AnsiColorRegex();
/// <summary>
/// Ensure usernames listed in Server:Admins config are at least Admin role.
/// Acts as a safety net in case the first registered user didn't get Owner role.
/// </summary>
private static async Task EnsureConfiguredAdminsAsync(EchoHubDbContext db, IConfiguration config, ILogger logger)
{
var adminUsernames = config.GetSection("Server:Admins").Get<string[]>();
if (adminUsernames is not { Length: > 0 })
return;
var promoted = 0;
foreach (var username in adminUsernames)
{
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
if (user is null)
{
logger.LogWarning("Configured admin '{Username}' not found in database (not registered yet).", username);
continue;
}
if (user.Role < ServerRole.Admin)
{
var oldRole = user.Role;
user.Role = ServerRole.Admin;
promoted++;
logger.LogInformation("Promoted '{Username}' from {OldRole} to Admin (configured in Server:Admins).",
username, oldRole);
}
}
if (promoted > 0)
await db.SaveChangesAsync();
}
/// <summary>
/// Migrate old single-object EmbedJson ("{...}") to array format ("[{...}]").
/// </summary>
+6 -1
View File
@@ -12,7 +12,12 @@
"Name": "My EchoHub Server",
"Description": "A self-hosted EchoHub chat server",
"PublicServer": false,
"PublicHost": ""
"PublicHost": "",
"Admins": []
},
"Storage": {
"CleanupIntervalHours": 1,
"RetentionDays": 30
},
"Encryption": {
"Key": "",