mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 00:26:07 +02:00
feat: add audio playback and file download features with corresponding UI updates
This commit is contained in:
@@ -22,6 +22,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
private readonly MainWindow _mainWindow;
|
private readonly MainWindow _mainWindow;
|
||||||
private readonly CommandHandler _commandHandler;
|
private readonly CommandHandler _commandHandler;
|
||||||
private readonly NotificationSoundService _notificationSound;
|
private readonly NotificationSoundService _notificationSound;
|
||||||
|
private readonly AudioPlaybackService _audioPlayback = new();
|
||||||
|
|
||||||
private EchoHubConnection? _connection;
|
private EchoHubConnection? _connection;
|
||||||
private ApiClient? _apiClient;
|
private ApiClient? _apiClient;
|
||||||
@@ -80,6 +81,8 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
_mainWindow.OnSavedServersRequested += HandleSavedServersRequested;
|
_mainWindow.OnSavedServersRequested += HandleSavedServersRequested;
|
||||||
_mainWindow.OnCreateChannelRequested += HandleCreateChannelRequested;
|
_mainWindow.OnCreateChannelRequested += HandleCreateChannelRequested;
|
||||||
_mainWindow.OnDeleteChannelRequested += HandleDeleteChannelRequested;
|
_mainWindow.OnDeleteChannelRequested += HandleDeleteChannelRequested;
|
||||||
|
_mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested;
|
||||||
|
_mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Command Handler Wiring ─────────────────────────────────────────────
|
// ── Command Handler Wiring ─────────────────────────────────────────────
|
||||||
@@ -798,6 +801,40 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
}, "Failed to delete channel");
|
}, "Failed to delete channel");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void HandleAudioPlayRequested(string attachmentUrl, string fileName)
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated) return;
|
||||||
|
|
||||||
|
RunAsync(async () =>
|
||||||
|
{
|
||||||
|
InvokeUI(() => _mainWindow.AddSystemMessage(_mainWindow.CurrentChannel, $"Playing {fileName}..."));
|
||||||
|
var tempPath = await _apiClient!.DownloadFileToTempAsync(attachmentUrl, fileName);
|
||||||
|
await _audioPlayback.PlayAsync(tempPath);
|
||||||
|
}, "Failed to play audio");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleFileDownloadRequested(string attachmentUrl, string fileName)
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated) return;
|
||||||
|
|
||||||
|
RunAsync(async () =>
|
||||||
|
{
|
||||||
|
InvokeUI(() => _mainWindow.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
|
||||||
|
var tempPath = await _apiClient!.DownloadFileToTempAsync(attachmentUrl, fileName);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var psi = new System.Diagnostics.ProcessStartInfo(tempPath) { UseShellExecute = true };
|
||||||
|
System.Diagnostics.Process.Start(psi);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed to open file with default app: {Path}", tempPath);
|
||||||
|
InvokeUI(() => _mainWindow.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloaded to: {tempPath}"));
|
||||||
|
}
|
||||||
|
}, "Failed to download file");
|
||||||
|
}
|
||||||
|
|
||||||
// ── Connection Event Wiring ────────────────────────────────────────────
|
// ── Connection Event Wiring ────────────────────────────────────────────
|
||||||
|
|
||||||
private void WireConnectionEvents(EchoHubConnection connection)
|
private void WireConnectionEvents(EchoHubConnection connection)
|
||||||
|
|||||||
@@ -339,7 +339,7 @@ public class CommandHandler
|
|||||||
/nick <name> - Set display name
|
/nick <name> - Set display name
|
||||||
/color <#hex> - Set nickname color
|
/color <#hex> - Set nickname color
|
||||||
/theme <name> - Switch theme
|
/theme <name> - Switch theme
|
||||||
/send <filepath or URL> [-s|-m|-l] - Send a file or image (size: small/medium/large)
|
/send <filepath or URL> [-s|-m|-l] - Send file/image/audio (size flag for images)
|
||||||
/avatar <URL or filepath> - Set your avatar
|
/avatar <URL or filepath> - Set your avatar
|
||||||
/profile [username] - View a profile
|
/profile [username] - View a profile
|
||||||
/servers - Open saved servers
|
/servers - Open saved servers
|
||||||
|
|||||||
@@ -196,6 +196,23 @@ public sealed class ApiClient : IDisposable
|
|||||||
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<string> DownloadFileToTempAsync(string relativeUrl, string fileName)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedGetAsync(relativeUrl);
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
|
||||||
|
var tempDir = Path.Combine(Path.GetTempPath(), "EchoHub");
|
||||||
|
Directory.CreateDirectory(tempDir);
|
||||||
|
var tempPath = Path.Combine(tempDir, $"{Guid.NewGuid():N}_{fileName}");
|
||||||
|
|
||||||
|
await using var stream = await response.Content.ReadAsStreamAsync();
|
||||||
|
await using var file = File.Create(tempPath);
|
||||||
|
await stream.CopyToAsync(file);
|
||||||
|
|
||||||
|
return tempPath;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null, bool isPublic = true)
|
public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null, bool isPublic = true)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using NetCoreAudio;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
public class AudioPlaybackService
|
||||||
|
{
|
||||||
|
private readonly Player _player = new();
|
||||||
|
|
||||||
|
public bool IsPlaying => _player.Playing;
|
||||||
|
|
||||||
|
public async Task PlayAsync(string filePath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_player.Playing)
|
||||||
|
await _player.Stop();
|
||||||
|
|
||||||
|
await _player.Play(filePath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed to play audio file: {Path}", filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StopAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_player.Playing)
|
||||||
|
await _player.Stop();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed to stop audio playback");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Collections;
|
using System.Collections;
|
||||||
using System.Collections.Specialized;
|
using System.Collections.Specialized;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
using Terminal.Gui.Drawing;
|
using Terminal.Gui.Drawing;
|
||||||
using Terminal.Gui.Text;
|
using Terminal.Gui.Text;
|
||||||
using Terminal.Gui.Views;
|
using Terminal.Gui.Views;
|
||||||
@@ -22,6 +23,9 @@ public partial class ChatLine
|
|||||||
public int TextLength { get; }
|
public int TextLength { get; }
|
||||||
public Guid? MessageId { get; set; }
|
public Guid? MessageId { get; set; }
|
||||||
public bool IsMention { get; set; }
|
public bool IsMention { get; set; }
|
||||||
|
public string? AttachmentUrl { get; set; }
|
||||||
|
public string? AttachmentFileName { get; set; }
|
||||||
|
public MessageType? Type { get; set; }
|
||||||
|
|
||||||
public ChatLine(string plainText)
|
public ChatLine(string plainText)
|
||||||
{
|
{
|
||||||
@@ -217,6 +221,8 @@ public class ChatListSource : IListDataSource
|
|||||||
RaiseCollectionChanged();
|
RaiseCollectionChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ChatLine? GetLine(int index) => index >= 0 && index < _lines.Count ? _lines[index] : null;
|
||||||
|
|
||||||
public bool IsMarked(int item) => false;
|
public bool IsMarked(int item) => false;
|
||||||
public void SetMark(int item, bool value) { }
|
public void SetMark(int item, bool value) { }
|
||||||
public IList ToList() => _lines.Select(l => l.ToString()).ToList();
|
public IList ToList() => _lines.Select(l => l.ToString()).ToList();
|
||||||
@@ -465,6 +471,8 @@ public static partial class ChatColors
|
|||||||
public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.Black);
|
public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.Black);
|
||||||
public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.Black);
|
public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.Black);
|
||||||
public static readonly Attribute EmbedUrlAttr = new(new Color(100, 100, 100), Color.Black);
|
public static readonly Attribute EmbedUrlAttr = new(new Color(100, 100, 100), Color.Black);
|
||||||
|
public static readonly Attribute AudioAttr = new(new Color(180, 100, 255), Color.Black);
|
||||||
|
public static readonly Attribute FileAttr = new(new Color(100, 180, 255), Color.Black);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Split text around @mentions, giving each @word the MentionTextAttr accent color.
|
/// Split text around @mentions, giving each @word the MentionTextAttr accent color.
|
||||||
|
|||||||
@@ -113,6 +113,16 @@ public sealed class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action? OnDeleteChannelRequested;
|
public event Action? OnDeleteChannelRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user activates (Enter/click) an audio message. Parameters: attachmentUrl, fileName.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<string, string>? OnAudioPlayRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user activates (Enter/click) a file message. Parameters: attachmentUrl, fileName.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<string, string>? OnFileDownloadRequested;
|
||||||
|
|
||||||
public MainWindow(IApplication app)
|
public MainWindow(IApplication app)
|
||||||
{
|
{
|
||||||
_app = app;
|
_app = app;
|
||||||
@@ -175,6 +185,7 @@ public sealed class MainWindow : Runnable
|
|||||||
Height = Dim.Fill()
|
Height = Dim.Fill()
|
||||||
};
|
};
|
||||||
_messageList.Source = new ChatListSource();
|
_messageList.Source = new ChatListSource();
|
||||||
|
_messageList.Accepting += OnMessageListAccepting;
|
||||||
_chatFrame.Add(_messageList);
|
_chatFrame.Add(_messageList);
|
||||||
Add(_chatFrame);
|
Add(_chatFrame);
|
||||||
|
|
||||||
@@ -360,6 +371,31 @@ public sealed class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnMessageListAccepting(object? sender, CommandEventArgs e)
|
||||||
|
{
|
||||||
|
if (_messageList.Source is not ChatListSource source)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var index = _messageList.SelectedItem;
|
||||||
|
if (!index.HasValue || index.Value < 0 || index.Value >= source.Count)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var line = source.GetLine(index.Value);
|
||||||
|
if (line?.AttachmentUrl is null || line.AttachmentFileName is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (line.Type == MessageType.Audio)
|
||||||
|
{
|
||||||
|
OnAudioPlayRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
else if (line.Type == MessageType.File)
|
||||||
|
{
|
||||||
|
OnFileDownloadRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void OnInputKeyDown(object? sender, Key e)
|
private void OnInputKeyDown(object? sender, Key e)
|
||||||
{
|
{
|
||||||
if (e.KeyCode == TabKey.KeyCode)
|
if (e.KeyCode == TabKey.KeyCode)
|
||||||
@@ -900,10 +936,24 @@ public sealed class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case MessageType.Audio:
|
||||||
|
var audioName = message.AttachmentFileName ?? "unknown";
|
||||||
|
var audioLine = BuildChatLineColored(time, senderName, senderColor,
|
||||||
|
$" \u266a [Audio: {audioName}] (Enter to play)", ChatColors.AudioAttr);
|
||||||
|
audioLine.AttachmentUrl = message.AttachmentUrl;
|
||||||
|
audioLine.AttachmentFileName = audioName;
|
||||||
|
audioLine.Type = MessageType.Audio;
|
||||||
|
lines.Add(audioLine);
|
||||||
|
break;
|
||||||
|
|
||||||
case MessageType.File:
|
case MessageType.File:
|
||||||
var fileName = message.AttachmentFileName ?? "unknown";
|
var fileName = message.AttachmentFileName ?? "unknown";
|
||||||
var fileContent = !string.IsNullOrWhiteSpace(message.Content) ? $" {message.Content}" : "";
|
var fileLine = BuildChatLineColored(time, senderName, senderColor,
|
||||||
lines.Add(BuildChatLine(time, senderName, senderColor, $" [File: {fileName}]{fileContent}"));
|
$" [File: {fileName}] (Enter to download)", ChatColors.FileAttr);
|
||||||
|
fileLine.AttachmentUrl = message.AttachmentUrl;
|
||||||
|
fileLine.AttachmentFileName = fileName;
|
||||||
|
fileLine.Type = MessageType.File;
|
||||||
|
lines.Add(fileLine);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MessageType.Text:
|
case MessageType.Text:
|
||||||
@@ -962,6 +1012,20 @@ public sealed class MainWindow : Runnable
|
|||||||
return new ChatLine(segments);
|
return new ChatLine(segments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a chat line with a colored suffix (used for audio/file indicators).
|
||||||
|
/// </summary>
|
||||||
|
private static ChatLine BuildChatLineColored(string time, string senderName, Attribute? senderColor, string suffix, Attribute suffixColor)
|
||||||
|
{
|
||||||
|
var segments = new List<ChatSegment>
|
||||||
|
{
|
||||||
|
new($"[{time}] ", ChatColors.TimestampAttr),
|
||||||
|
new(senderName, senderColor),
|
||||||
|
new(suffix, suffixColor)
|
||||||
|
};
|
||||||
|
return new ChatLine(segments);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Build a chat line with @mention highlighting in the suffix text.
|
/// Build a chat line with @mention highlighting in the suffix text.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ public enum MessageType
|
|||||||
{
|
{
|
||||||
Text,
|
Text,
|
||||||
Image,
|
Image,
|
||||||
File
|
File,
|
||||||
|
Audio
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ public static partial class IrcMessageFormatter
|
|||||||
case MessageType.File:
|
case MessageType.File:
|
||||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :[File: {message.AttachmentFileName}] {message.AttachmentUrl}");
|
lines.Add($"{prefix} PRIVMSG {ircChannel} :[File: {message.AttachmentFileName}] {message.AttachmentUrl}");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case MessageType.Audio:
|
||||||
|
lines.Add($"{prefix} PRIVMSG {ircChannel} :\u266a [Audio: {message.AttachmentFileName}] {message.AttachmentUrl}");
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines;
|
return lines;
|
||||||
|
|||||||
@@ -202,13 +202,16 @@ public class ChannelsController : ControllerBase
|
|||||||
if (file.Length > HubConstants.MaxFileSizeBytes)
|
if (file.Length > HubConstants.MaxFileSizeBytes)
|
||||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
|
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();
|
using var stream = file.OpenReadStream();
|
||||||
var isImage = FileValidationHelper.IsValidImage(stream);
|
var isImage = FileValidationHelper.IsValidImage(stream);
|
||||||
|
var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName);
|
||||||
|
|
||||||
var (fileId, filePath) = await _fileStorage.SaveFileAsync(stream, 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;
|
string content;
|
||||||
|
|
||||||
if (isImage)
|
if (isImage)
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ public class FilesController : ControllerBase
|
|||||||
".png" => "image/png",
|
".png" => "image/png",
|
||||||
".gif" => "image/gif",
|
".gif" => "image/gif",
|
||||||
".webp" => "image/webp",
|
".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",
|
".pdf" => "application/pdf",
|
||||||
".txt" => "text/plain",
|
".txt" => "text/plain",
|
||||||
_ => "application/octet-stream"
|
_ => "application/octet-stream"
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ while (true)
|
|||||||
builder.Services.AddSingleton<FileStorageService>();
|
builder.Services.AddSingleton<FileStorageService>();
|
||||||
builder.Services.AddSingleton<LinkEmbedService>();
|
builder.Services.AddSingleton<LinkEmbedService>();
|
||||||
builder.Services.AddHostedService<ServerDirectoryService>();
|
builder.Services.AddHostedService<ServerDirectoryService>();
|
||||||
|
builder.Services.AddHostedService<FileCleanupService>();
|
||||||
|
|
||||||
// ── Encryption ─────────────────────────────────────────────────────
|
// ── Encryption ─────────────────────────────────────────────────────
|
||||||
builder.Services.AddSingleton<IMessageEncryptionService, MessageEncryptionService>();
|
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)
|
private static bool StartsWith(byte[] buffer, int length, byte[] magic)
|
||||||
{
|
{
|
||||||
if (length < magic.Length)
|
if (length < magic.Length)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using EchoHub.Core.Constants;
|
using EchoHub.Core.Constants;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
using EchoHub.Server.Data;
|
using EchoHub.Server.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -12,12 +13,14 @@ public static partial class DataMigrationService
|
|||||||
{
|
{
|
||||||
using var scope = services.CreateScope();
|
using var scope = services.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
|
||||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>()
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>()
|
||||||
.CreateLogger("EchoHub.Server.Setup.DataMigration");
|
.CreateLogger("EchoHub.Server.Setup.DataMigration");
|
||||||
|
|
||||||
await EnsureDefaultChannelsPublicAsync(db, logger);
|
await EnsureDefaultChannelsPublicAsync(db, logger);
|
||||||
await MigrateAnsiMessagesAsync(db, logger);
|
await MigrateAnsiMessagesAsync(db, logger);
|
||||||
await MigrateEmbedJsonToArrayAsync(db, logger);
|
await MigrateEmbedJsonToArrayAsync(db, logger);
|
||||||
|
await EnsureConfiguredAdminsAsync(db, config, logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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")]
|
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
|
||||||
private static partial Regex AnsiColorRegex();
|
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>
|
/// <summary>
|
||||||
/// Migrate old single-object EmbedJson ("{...}") to array format ("[{...}]").
|
/// Migrate old single-object EmbedJson ("{...}") to array format ("[{...}]").
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -12,7 +12,12 @@
|
|||||||
"Name": "My EchoHub Server",
|
"Name": "My EchoHub Server",
|
||||||
"Description": "A self-hosted EchoHub chat server",
|
"Description": "A self-hosted EchoHub chat server",
|
||||||
"PublicServer": false,
|
"PublicServer": false,
|
||||||
"PublicHost": ""
|
"PublicHost": "",
|
||||||
|
"Admins": []
|
||||||
|
},
|
||||||
|
"Storage": {
|
||||||
|
"CleanupIntervalHours": 1,
|
||||||
|
"RetentionDays": 30
|
||||||
},
|
},
|
||||||
"Encryption": {
|
"Encryption": {
|
||||||
"Key": "",
|
"Key": "",
|
||||||
|
|||||||
Reference in New Issue
Block a user