Add core models, DTOs, and services for EchoHub chat application

- Created User, Channel, Message, and ServerInfo models with necessary properties.
- Added DTOs for user profiles, server information, and message handling.
- Implemented JWT authentication service for user login and token generation.
- Developed Entity Framework Core DbContext for database interactions.
- Introduced SignalR ChatHub for real-time messaging and presence tracking.
- Implemented file storage and image to ASCII conversion services.
- Set up CORS and middleware for API endpoints.
- Created launch settings and development configuration for server and web projects.
This commit is contained in:
HueByte
2026-02-18 13:52:19 +01:00
parent 20a341947a
commit cfa8b90d04
47 changed files with 4596 additions and 0 deletions
@@ -0,0 +1,40 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using EchoHub.Core.Models;
using Microsoft.IdentityModel.Tokens;
namespace EchoHub.Server.Auth;
public class JwtTokenService(IConfiguration configuration)
{
private readonly string _secret = configuration["Jwt:Secret"]
?? throw new InvalidOperationException("Jwt:Secret is not configured.");
private readonly string _issuer = configuration["Jwt:Issuer"]
?? throw new InvalidOperationException("Jwt:Issuer is not configured.");
private readonly string _audience = configuration["Jwt:Audience"]
?? throw new InvalidOperationException("Jwt:Audience is not configured.");
public string GenerateToken(User user)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
Claim[] claims =
[
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new("username", user.Username),
new("display_name", user.DisplayName ?? user.Username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
];
var token = new JwtSecurityToken(
issuer: _issuer,
audience: _audience,
claims: claims,
expires: DateTime.UtcNow.AddDays(7),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
@@ -0,0 +1,76 @@
using EchoHub.Core.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace EchoHub.Server.Data;
public class EchoHubDbContext(DbContextOptions<EchoHubDbContext> options) : DbContext(options)
{
public DbSet<User> Users => Set<User>();
public DbSet<Channel> Channels => Set<Channel>();
public DbSet<Message> Messages => Set<Message>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (!optionsBuilder.IsConfigured)
{
optionsBuilder.UseSqlite("Data Source=echohub.db");
}
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>(entity =>
{
entity.HasKey(u => u.Id);
entity.HasIndex(u => u.Username).IsUnique();
entity.Property(u => u.Username).IsRequired().HasMaxLength(50);
entity.Property(u => u.PasswordHash).IsRequired();
entity.Property(u => u.DisplayName).HasMaxLength(100);
entity.Property(u => u.Bio).HasMaxLength(500);
entity.Property(u => u.NicknameColor).HasMaxLength(7);
entity.Property(u => u.AvatarAscii).HasMaxLength(10000);
entity.Property(u => u.StatusMessage).HasMaxLength(100);
});
modelBuilder.Entity<Channel>(entity =>
{
entity.HasKey(c => c.Id);
entity.HasIndex(c => c.Name);
entity.Property(c => c.Name).IsRequired().HasMaxLength(100);
entity.Property(c => c.Topic).HasMaxLength(500);
entity.HasMany(c => c.Messages)
.WithOne(m => m.Channel)
.HasForeignKey(m => m.ChannelId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<Message>(entity =>
{
entity.HasKey(m => m.Id);
entity.HasIndex(m => m.SentAt);
entity.Property(m => m.Content).IsRequired().HasMaxLength(2000);
entity.Property(m => m.SenderUsername).IsRequired().HasMaxLength(50);
entity.Property(m => m.AttachmentUrl).HasMaxLength(500);
entity.Property(m => m.AttachmentFileName).HasMaxLength(255);
});
// SQLite does not support DateTimeOffset in ORDER BY clauses.
// Convert all DateTimeOffset properties to Unix milliseconds (long) for storage.
var dateTimeOffsetConverter = new ValueConverter<DateTimeOffset, long>(
v => v.ToUnixTimeMilliseconds(),
v => DateTimeOffset.FromUnixTimeMilliseconds(v));
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
foreach (var property in entityType.GetProperties())
{
if (property.ClrType == typeof(DateTimeOffset) || property.ClrType == typeof(DateTimeOffset?))
{
property.SetValueConverter(dateTimeOffsetConverter);
}
}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.3" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
+263
View File
@@ -0,0 +1,263 @@
using System.Security.Claims;
using EchoHub.Core.Constants;
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Data;
using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Hubs;
[Authorize]
public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTracker presenceTracker) : Hub<IEchoHubClient>
{
private Guid CurrentUserId =>
Guid.Parse(Context.User?.FindFirstValue(ClaimTypes.NameIdentifier)
?? throw new HubException("User ID claim not found."));
private string CurrentUsername =>
Context.User?.FindFirstValue("username")
?? throw new HubException("Username claim not found.");
public override async Task OnConnectedAsync()
{
presenceTracker.UserConnected(Context.ConnectionId, CurrentUserId, CurrentUsername);
var user = await db.Users.FindAsync(CurrentUserId);
if (user is not null)
{
user.LastSeenAt = DateTimeOffset.UtcNow;
user.Status = UserStatus.Online;
await db.SaveChangesAsync();
}
await base.OnConnectedAsync();
logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", CurrentUsername, Context.ConnectionId);
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
// Capture channels before disconnecting, since UserDisconnected clears them
// when the last connection for a user is removed.
var preDisconnectUsername = Context.User?.FindFirstValue("username");
var channelsBeforeDisconnect = preDisconnectUsername is not null
? presenceTracker.GetChannelsForUser(preDisconnectUsername)
: [];
var username = presenceTracker.UserDisconnected(Context.ConnectionId);
if (username is not null && !presenceTracker.IsOnline(username))
{
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
if (user is not null)
{
user.LastSeenAt = DateTimeOffset.UtcNow;
user.Status = UserStatus.Invisible;
await db.SaveChangesAsync();
var presence = new UserPresenceDto(
username,
user.DisplayName,
user.NicknameColor,
UserStatus.Invisible,
user.StatusMessage);
foreach (var channel in channelsBeforeDisconnect)
{
await Clients.Group(channel).UserStatusChanged(presence);
}
}
}
await base.OnDisconnectedAsync(exception);
logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", Context.ConnectionId);
}
public async Task<List<MessageDto>> JoinChannel(string channelName)
{
channelName = channelName.ToLowerInvariant().Trim();
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null)
{
channel = new Channel
{
Id = Guid.NewGuid(),
Name = channelName,
CreatedByUserId = CurrentUserId,
};
db.Channels.Add(channel);
await db.SaveChangesAsync();
logger.LogInformation("Channel '{Channel}' created by {User}", channelName, CurrentUsername);
}
presenceTracker.JoinChannel(CurrentUsername, channelName);
await Groups.AddToGroupAsync(Context.ConnectionId, channelName);
await Clients.OthersInGroup(channelName).UserJoined(channelName, CurrentUsername);
logger.LogInformation("{User} joined channel '{Channel}'", CurrentUsername, channelName);
var history = await GetChannelHistory(channelName, HubConstants.DefaultHistoryCount);
return history;
}
public async Task LeaveChannel(string channelName)
{
channelName = channelName.ToLowerInvariant().Trim();
presenceTracker.LeaveChannel(CurrentUsername, channelName);
await Groups.RemoveFromGroupAsync(Context.ConnectionId, channelName);
await Clients.OthersInGroup(channelName).UserLeft(channelName, CurrentUsername);
logger.LogInformation("{User} left channel '{Channel}'", CurrentUsername, channelName);
}
public async Task SendMessage(string channelName, string content)
{
channelName = channelName.ToLowerInvariant().Trim();
if (string.IsNullOrWhiteSpace(content))
{
await Clients.Caller.Error("Message content cannot be empty.");
return;
}
if (content.Length > HubConstants.MaxMessageLength)
{
await Clients.Caller.Error($"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters.");
return;
}
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null)
{
await Clients.Caller.Error($"Channel '{channelName}' does not exist.");
return;
}
var sender = await db.Users.FindAsync(CurrentUserId);
var message = new Message
{
Id = Guid.NewGuid(),
Content = content,
Type = MessageType.Text,
SentAt = DateTimeOffset.UtcNow,
ChannelId = channel.Id,
SenderUserId = CurrentUserId,
SenderUsername = CurrentUsername,
};
db.Messages.Add(message);
await db.SaveChangesAsync();
var messageDto = new MessageDto(
message.Id,
message.Content,
message.SenderUsername,
sender?.NicknameColor,
channelName,
MessageType.Text,
null,
null,
message.SentAt);
await Clients.Group(channelName).ReceiveMessage(messageDto);
logger.LogDebug("{User} sent message in '{Channel}'", CurrentUsername, channelName);
}
public async Task<List<MessageDto>> GetChannelHistory(string channelName, int count = HubConstants.DefaultHistoryCount)
{
channelName = channelName.ToLowerInvariant().Trim();
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null)
{
return [];
}
var messages = await db.Messages
.Where(m => m.ChannelId == channel.Id)
.OrderByDescending(m => m.SentAt)
.Take(count)
.Join(db.Users,
m => m.SenderUserId,
u => u.Id,
(m, u) => new MessageDto(
m.Id,
m.Content,
m.SenderUsername,
u.NicknameColor,
channelName,
m.Type,
m.AttachmentUrl,
m.AttachmentFileName,
m.SentAt))
.ToListAsync();
messages.Reverse();
return messages;
}
public async Task UpdateStatus(UserStatus status, string? statusMessage)
{
var user = await db.Users.FindAsync(CurrentUserId);
if (user is null)
{
await Clients.Caller.Error("User not found.");
return;
}
user.Status = status;
user.StatusMessage = statusMessage;
user.LastSeenAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
var presence = new UserPresenceDto(
user.Username,
user.DisplayName,
user.NicknameColor,
status,
statusMessage);
var channels = presenceTracker.GetChannelsForUser(CurrentUsername);
foreach (var channel in channels)
{
await Clients.Group(channel).UserStatusChanged(presence);
}
}
public async Task<List<UserPresenceDto>> GetOnlineUsers(string channelName)
{
channelName = channelName.ToLowerInvariant().Trim();
var onlineUsernames = presenceTracker.GetOnlineUsersInChannel(channelName);
var users = await db.Users
.Where(u => onlineUsernames.Contains(u.Username))
.Select(u => new UserPresenceDto(
u.Username,
u.DisplayName,
u.NicknameColor,
u.Status,
u.StatusMessage))
.ToListAsync();
return users;
}
}
+399
View File
@@ -0,0 +1,399 @@
using System.Security.Claims;
using System.Text;
using EchoHub.Core.Constants;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Auth;
using EchoHub.Server.Data;
using EchoHub.Server.Hubs;
using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
// ── SQLite + EF Core ──────────────────────────────────────────────────────────
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
?? "Data Source=echohub.db";
builder.Services.AddDbContext<EchoHubDbContext>(options =>
options.UseSqlite(connectionString));
// ── JWT Authentication ────────────────────────────────────────────────────────
var jwtSecret = builder.Configuration["Jwt:Secret"]
?? throw new InvalidOperationException("Jwt:Secret must be configured.");
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "EchoHub.Server";
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "EchoHub.Client";
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtIssuer,
ValidAudience = jwtAudience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
};
// Allow SignalR clients to send the JWT via query string
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments(HubConstants.ChatHubPath))
{
context.Token = accessToken;
}
return Task.CompletedTask;
},
};
});
builder.Services.AddAuthorization();
// ── SignalR ───────────────────────────────────────────────────────────────────
builder.Services.AddSignalR();
// ── Services ──────────────────────────────────────────────────────────────────
builder.Services.AddSingleton<JwtTokenService>();
builder.Services.AddSingleton<PresenceTracker>();
builder.Services.AddSingleton<ImageToAsciiService>();
builder.Services.AddSingleton<FileStorageService>();
// ── CORS (allow all for development) ──────────────────────────────────────────
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials()
.SetIsOriginAllowed(_ => true);
});
});
var app = builder.Build();
// ── Database initialization ───────────────────────────────────────────────────
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
await db.Database.EnsureCreatedAsync();
if (!await db.Channels.AnyAsync(c => c.Name == HubConstants.DefaultChannel))
{
db.Channels.Add(new Channel
{
Id = Guid.NewGuid(),
Name = HubConstants.DefaultChannel,
Topic = "General discussion",
CreatedByUserId = Guid.Empty,
});
await db.SaveChangesAsync();
}
}
// ── Middleware ─────────────────────────────────────────────────────────────────
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
// ── Auth Endpoints ────────────────────────────────────────────────────────────
var auth = app.MapGroup("/api/auth");
auth.MapPost("/register", async (RegisterRequest request, EchoHubDbContext db, JwtTokenService jwt) =>
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
{
return Results.BadRequest(new { Error = "Username and password are required." });
}
if (request.Username.Length < 3 || request.Username.Length > 50)
{
return Results.BadRequest(new { Error = "Username must be between 3 and 50 characters." });
}
if (request.Password.Length < 6)
{
return Results.BadRequest(new { Error = "Password must be at least 6 characters." });
}
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
if (await db.Users.AnyAsync(u => u.Username == normalizedUsername))
{
return Results.Conflict(new { Error = "Username is already taken." });
}
var user = new User
{
Id = Guid.NewGuid(),
Username = normalizedUsername,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
DisplayName = request.DisplayName?.Trim(),
};
db.Users.Add(user);
await db.SaveChangesAsync();
var token = jwt.GenerateToken(user);
return Results.Ok(new LoginResponse(token, user.Username, user.DisplayName, user.NicknameColor));
});
auth.MapPost("/login", async (LoginRequest request, EchoHubDbContext db, JwtTokenService jwt) =>
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
{
return Results.BadRequest(new { Error = "Username and password are required." });
}
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
{
return Results.Unauthorized();
}
user.LastSeenAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
var token = jwt.GenerateToken(user);
return Results.Ok(new LoginResponse(token, user.Username, user.DisplayName, user.NicknameColor));
});
// ── Channel Endpoints ─────────────────────────────────────────────────────────
app.MapGet("/api/channels", async (EchoHubDbContext db) =>
{
var channels = await db.Channels
.Select(c => new ChannelDto(
c.Id,
c.Name,
c.Topic,
c.Messages.Count,
c.CreatedAt))
.ToListAsync();
return Results.Ok(channels);
})
.RequireAuthorization();
// ── Server Info Endpoint ──────────────────────────────────────────────────────
app.MapGet("/api/server/info", async (EchoHubDbContext db, IConfiguration config) =>
{
var userCount = await db.Users.CountAsync();
var channelCount = await db.Channels.CountAsync();
var status = new ServerStatusDto(
config["Server:Name"] ?? "EchoHub Server",
config["Server:Description"],
userCount,
channelCount);
return Results.Ok(status);
});
// ── User Profile Endpoints ────────────────────────────────────────────────────
app.MapGet("/api/users/{username}/profile", async (string username, EchoHubDbContext db) =>
{
var normalizedUsername = username.ToLowerInvariant().Trim();
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
if (user is null)
{
return Results.NotFound(new { Error = "User not found." });
}
var profile = new UserProfileDto(
user.Id,
user.Username,
user.DisplayName,
user.Bio,
user.NicknameColor,
user.AvatarAscii,
user.Status,
user.StatusMessage,
user.CreatedAt,
user.LastSeenAt);
return Results.Ok(profile);
});
app.MapPut("/api/users/profile", async (UpdateProfileRequest request, EchoHubDbContext db, HttpContext ctx) =>
{
var userIdClaim = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null)
return Results.Unauthorized();
var userId = Guid.Parse(userIdClaim);
var user = await db.Users.FindAsync(userId);
if (user is null)
return Results.NotFound(new { Error = "User not found." });
if (request.DisplayName is not null)
user.DisplayName = request.DisplayName.Trim();
if (request.Bio is not null)
user.Bio = request.Bio.Trim();
if (request.NicknameColor is not null)
user.NicknameColor = request.NicknameColor.Trim();
await db.SaveChangesAsync();
var profile = new UserProfileDto(
user.Id,
user.Username,
user.DisplayName,
user.Bio,
user.NicknameColor,
user.AvatarAscii,
user.Status,
user.StatusMessage,
user.CreatedAt,
user.LastSeenAt);
return Results.Ok(profile);
})
.RequireAuthorization();
app.MapPost("/api/users/avatar", async (HttpRequest req, EchoHubDbContext db, ImageToAsciiService asciiService, HttpContext ctx) =>
{
var userIdClaim = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null)
return Results.Unauthorized();
var userId = Guid.Parse(userIdClaim);
var user = await db.Users.FindAsync(userId);
if (user is null)
return Results.NotFound(new { Error = "User not found." });
if (!req.HasFormContentType || req.Form.Files.Count == 0)
return Results.BadRequest(new { Error = "No file uploaded." });
var file = req.Form.Files[0];
if (file.Length > HubConstants.MaxAvatarSizeBytes)
return Results.BadRequest(new { Error = $"File size exceeds maximum of {HubConstants.MaxAvatarSizeBytes / (1024 * 1024)} MB." });
using var stream = file.OpenReadStream();
var asciiArt = asciiService.ConvertToAscii(stream);
user.AvatarAscii = asciiArt;
await db.SaveChangesAsync();
return Results.Ok(new { AvatarAscii = asciiArt });
})
.RequireAuthorization();
// ── File Upload Endpoints ────────────────────────────────────────────────────
app.MapPost("/api/channels/{channel}/upload", async (string channel, HttpRequest req, EchoHubDbContext db, FileStorageService fileStorage, ImageToAsciiService asciiService, HttpContext ctx) =>
{
var userIdClaim = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier);
var usernameClaim = ctx.User.FindFirstValue("username");
if (userIdClaim is null || usernameClaim is null)
return Results.Unauthorized();
var userId = Guid.Parse(userIdClaim);
var channelName = channel.ToLowerInvariant().Trim();
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (dbChannel is null)
return Results.NotFound(new { Error = $"Channel '{channelName}' does not exist." });
if (!req.HasFormContentType || req.Form.Files.Count == 0)
return Results.BadRequest(new { Error = "No file uploaded." });
var file = req.Form.Files[0];
if (file.Length > HubConstants.MaxFileSizeBytes)
return Results.BadRequest(new { Error = $"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB." });
using var stream = file.OpenReadStream();
var (fileId, filePath) = await fileStorage.SaveFileAsync(stream, file.FileName);
var imageExtensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp" };
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
var isImage = imageExtensions.Contains(extension);
var messageType = isImage ? MessageType.Image : MessageType.File;
var content = isImage ? asciiService.ConvertToAscii(File.OpenRead(filePath)) : file.FileName;
var attachmentUrl = $"/api/files/{fileId}";
var sender = await db.Users.FindAsync(userId);
var message = new Message
{
Id = Guid.NewGuid(),
Content = content,
Type = messageType,
AttachmentUrl = attachmentUrl,
AttachmentFileName = file.FileName,
SentAt = DateTimeOffset.UtcNow,
ChannelId = dbChannel.Id,
SenderUserId = userId,
SenderUsername = usernameClaim,
};
db.Messages.Add(message);
await db.SaveChangesAsync();
var messageDto = new MessageDto(
message.Id,
message.Content,
message.SenderUsername,
sender?.NicknameColor,
channelName,
messageType,
attachmentUrl,
file.FileName,
message.SentAt);
return Results.Ok(messageDto);
})
.RequireAuthorization();
app.MapGet("/api/files/{fileId}", (string fileId, FileStorageService fileStorage) =>
{
var filePath = fileStorage.GetFilePath(fileId);
if (filePath is null)
return Results.NotFound(new { Error = "File not found." });
var contentType = Path.GetExtension(filePath).ToLowerInvariant() switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
".pdf" => "application/pdf",
".txt" => "text/plain",
_ => "application/octet-stream"
};
var fileName = Path.GetFileName(filePath);
return Results.File(filePath, contentType, fileName);
});
// ── SignalR Hub ───────────────────────────────────────────────────────────────
app.MapHub<ChatHub>(HubConstants.ChatHubPath);
app.Run();
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7171;http://localhost:5189",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,46 @@
namespace EchoHub.Server.Services;
public class FileStorageService
{
private readonly string _storagePath;
public FileStorageService(IConfiguration configuration)
{
_storagePath = configuration["Storage:Path"] ?? "./uploads";
if (!Directory.Exists(_storagePath))
{
Directory.CreateDirectory(_storagePath);
}
}
public async Task<(string fileId, string filePath)> SaveFileAsync(Stream stream, string fileName)
{
var fileId = Guid.NewGuid().ToString();
var extension = Path.GetExtension(fileName);
var storedFileName = $"{fileId}{extension}";
var filePath = Path.Combine(_storagePath, storedFileName);
using var fileStream = File.Create(filePath);
await stream.CopyToAsync(fileStream);
return (fileId, filePath);
}
public string? GetFilePath(string fileId)
{
var files = Directory.GetFiles(_storagePath, $"{fileId}.*");
return files.Length > 0 ? files[0] : null;
}
public void DeleteFile(string fileId)
{
var filePath = GetFilePath(fileId);
if (filePath is not null && File.Exists(filePath))
{
File.Delete(filePath);
}
}
}
@@ -0,0 +1,41 @@
using System.Text;
using EchoHub.Core.Constants;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
namespace EchoHub.Server.Services;
public class ImageToAsciiService
{
private static readonly char[] AsciiChars = " .:-=+*#%@".ToCharArray();
public string ConvertToAscii(Stream imageStream, int width = HubConstants.AsciiArtWidth, int height = HubConstants.AsciiArtHeight)
{
using var image = Image.Load<Rgba32>(imageStream);
image.Mutate(x => x.Resize(width, height));
var sb = new StringBuilder();
for (int y = 0; y < image.Height; y++)
{
for (int x = 0; x < image.Width; x++)
{
var pixel = image[x, y];
var brightness = 0.299 * pixel.R + 0.587 * pixel.G + 0.114 * pixel.B;
// Map brightness (0-255) to ASCII char index (inverted: dark pixels get dense chars)
var index = (int)((brightness / 255.0) * (AsciiChars.Length - 1));
sb.Append(AsciiChars[index]);
}
if (y < image.Height - 1)
{
sb.AppendLine();
}
}
return sb.ToString();
}
}
@@ -0,0 +1,113 @@
using System.Collections.Concurrent;
namespace EchoHub.Server.Services;
public class PresenceTracker
{
private readonly ConcurrentDictionary<string, (Guid userId, string username)> _connections = new();
private readonly ConcurrentDictionary<string, HashSet<string>> _userConnections = new();
private readonly ConcurrentDictionary<string, HashSet<string>> _userChannels = new();
private readonly object _lock = new();
public void UserConnected(string connectionId, Guid userId, string username)
{
_connections[connectionId] = (userId, username);
lock (_lock)
{
if (!_userConnections.TryGetValue(username, out var connections))
{
connections = new HashSet<string>();
_userConnections[username] = connections;
}
connections.Add(connectionId);
}
}
public string? UserDisconnected(string connectionId)
{
if (!_connections.TryRemove(connectionId, out var userInfo))
return null;
var username = userInfo.username;
lock (_lock)
{
if (_userConnections.TryGetValue(username, out var connections))
{
connections.Remove(connectionId);
if (connections.Count == 0)
{
_userConnections.TryRemove(username, out _);
_userChannels.TryRemove(username, out _);
}
}
}
return username;
}
public void JoinChannel(string username, string channelName)
{
lock (_lock)
{
if (!_userChannels.TryGetValue(username, out var channels))
{
channels = new HashSet<string>();
_userChannels[username] = channels;
}
channels.Add(channelName);
}
}
public void LeaveChannel(string username, string channelName)
{
lock (_lock)
{
if (_userChannels.TryGetValue(username, out var channels))
{
channels.Remove(channelName);
}
}
}
public List<string> GetOnlineUsersInChannel(string channelName)
{
var users = new List<string>();
lock (_lock)
{
foreach (var (username, channels) in _userChannels)
{
if (channels.Contains(channelName))
{
users.Add(username);
}
}
}
return users;
}
public List<string> GetChannelsForUser(string username)
{
lock (_lock)
{
if (_userChannels.TryGetValue(username, out var channels))
{
return channels.ToList();
}
}
return [];
}
public bool IsOnline(string username)
{
return _userConnections.TryGetValue(username, out var connections) && connections.Count > 0;
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}