mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user