diff --git a/Flawless-Version-Control.sln.DotSettings.user b/Flawless-Version-Control.sln.DotSettings.user index a206e54..79daafd 100644 --- a/Flawless-Version-Control.sln.DotSettings.user +++ b/Flawless-Version-Control.sln.DotSettings.user @@ -6,30 +6,42 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded diff --git a/Flawless.Client/Service/Remote_Generated.cs b/Flawless.Client/Service/Remote_Generated.cs index 983af6c..a9d5af0 100644 --- a/Flawless.Client/Service/Remote_Generated.cs +++ b/Flawless.Client/Service/Remote_Generated.cs @@ -18,6 +18,17 @@ namespace Flawless.Client.Remote [System.CodeDom.Compiler.GeneratedCode("Refitter", "1.5.3.0")] public partial interface IFlawlessServer { + /// A that completes when the request is finished. + /// Thrown when the request returns a non-success status code. + [Post("/api/admin/superuser/{username}")] + Task SuperuserPost(string username, [Query] bool? toSuper, CancellationToken cancellationToken = default); + + /// OK + /// Thrown when the request returns a non-success status code. + [Headers("Accept: text/plain, application/json, text/json")] + [Get("/api/admin/superuser/{username}")] + Task SuperuserGet(string username, CancellationToken cancellationToken = default); + /// A that completes when the request is finished. /// Thrown when the request returns a non-success status code. [Post("/api/admin/user/delete/{username}")] @@ -39,12 +50,48 @@ namespace Flawless.Client.Remote [Post("/api/admin/user/reset_password")] Task ResetPassword([Body] ResetPasswordRequest body, CancellationToken cancellationToken = default); + /// A that completes when the request is finished. + /// Thrown when the request returns a non-success status code. + [Headers("Content-Type: application/json")] + [Post("/api/admin/access_control/ip_whitelist")] + Task IpWhitelistPost([Body] IEnumerable body, CancellationToken cancellationToken = default); + + /// OK + /// Thrown when the request returns a non-success status code. + [Headers("Accept: text/plain, application/json, text/json")] + [Get("/api/admin/access_control/ip_whitelist")] + Task> IpWhitelistGet(CancellationToken cancellationToken = default); + + /// A that completes when the request is finished. + /// Thrown when the request returns a non-success status code. + [Headers("Content-Type: application/json")] + [Post("/api/admin/access_control/ip_blacklist")] + Task IpBlacklistPost([Body] IEnumerable body, CancellationToken cancellationToken = default); + + /// OK + /// Thrown when the request returns a non-success status code. + [Headers("Accept: text/plain, application/json, text/json")] + [Get("/api/admin/access_control/ip_blacklist")] + Task> IpBlacklistGet(CancellationToken cancellationToken = default); + + /// OK + /// Thrown when the request returns a non-success status code. + [Headers("Accept: text/plain, application/json, text/json")] + [Get("/api/admin/logs")] + Task> Logs([Query] System.DateTimeOffset? startTime, [Query] System.DateTimeOffset? endTime, [Query] LogLevel? level, [Query] int? page, [Query] int? pageSize, CancellationToken cancellationToken = default); + /// OK /// Thrown when the request returns a non-success status code. [Headers("Accept: text/plain, application/json, text/json")] [Get("/api/auth/status")] Task Status(CancellationToken cancellationToken = default); + /// A that completes when the request is finished. + /// Thrown when the request returns a non-success status code. + [Headers("Content-Type: application/json")] + [Post("/api/auth/first-setup")] + Task FirstSetup([Body] FirstSetupRequest body, CancellationToken cancellationToken = default); + /// A that completes when the request is finished. /// Thrown when the request returns a non-success status code. [Headers("Content-Type: application/json")] @@ -152,6 +199,28 @@ namespace Flawless.Client.Remote [Get("/api/repo/{userName}/{repositoryName}/get_users")] Task GetUsers(string userName, string repositoryName, CancellationToken cancellationToken = default); + /// A that completes when the request is finished. + /// Thrown when the request returns a non-success status code. + [Headers("Content-Type: application/json")] + [Post("/api/repo/{userName}/{repositoryName}/create_webhook")] + Task CreateWebhook(string userName, string repositoryName, [Body] WebhookCreateRequest body, CancellationToken cancellationToken = default); + + /// OK + /// Thrown when the request returns a non-success status code. + [Headers("Accept: text/plain, application/json, text/json")] + [Get("/api/repo/{userName}/{repositoryName}")] + Task> RepoGet(string userName, string repositoryName, CancellationToken cancellationToken = default); + + /// A that completes when the request is finished. + /// Thrown when the request returns a non-success status code. + [Post("/api/repo/{userName}/{repositoryName}/{webhookId}/toggle")] + Task Toggle(string userName, string repositoryName, int webhookId, [Query] bool? activate, CancellationToken cancellationToken = default); + + /// A that completes when the request is finished. + /// Thrown when the request returns a non-success status code. + [Delete("/api/repo/{userName}/{repositoryName}/{webhookId}")] + Task RepoDelete(string userName, string repositoryName, int webhookId, CancellationToken cancellationToken = default); + /// OK /// Thrown when the request returns a non-success status code. [Headers("Accept: text/plain, application/json, text/json")] @@ -214,7 +283,7 @@ namespace Flawless.Client.Remote /// A that completes when the request is finished. /// Thrown when the request returns a non-success status code. [Post("/api/update_user")] - Task UpdateUser([Query] string repositoryName, [Query] string modUser, [Query] RepositoryModel.RepositoryRole role); + Task UpdateUser([Query] string repositoryName, [Query] string modUser, [Query] RepositoryModel.RepositoryRole? role, CancellationToken cancellationToken = default); /// A that completes when the request is finished. /// Thrown when the request returns a non-success status code. @@ -372,7 +441,7 @@ namespace Flawless.Client.Remote public string Description { get; set; } [JsonPropertyName("tag")] - public string? Tag { get; set; } + public string Tag { get; set; } } @@ -388,6 +457,21 @@ namespace Flawless.Client.Remote } + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.3.0.0 (NJsonSchema v11.2.0.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class FirstSetupRequest + { + + [JsonPropertyName("adminEmail")] + public string AdminEmail { get; set; } + + [JsonPropertyName("adminUsername")] + public string AdminUsername { get; set; } + + [JsonPropertyName("adminPassword")] + public string AdminPassword { get; set; } + + } + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.3.0.0 (NJsonSchema v11.2.0.0 (Newtonsoft.Json v13.0.0.0))")] public partial class GuidPeekResponse { @@ -484,6 +568,44 @@ namespace Flawless.Client.Remote } + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.3.0.0 (NJsonSchema v11.2.0.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class LogEntryResponse + { + + [JsonPropertyName("timestamp")] + public System.DateTimeOffset Timestamp { get; set; } + + [JsonPropertyName("level")] + public string Level { get; set; } + + [JsonPropertyName("message")] + public string Message { get; set; } + + [JsonPropertyName("exception")] + public string Exception { get; set; } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.3.0.0 (NJsonSchema v11.2.0.0 (Newtonsoft.Json v13.0.0.0))")] + public enum LogLevel + { + + _0 = 0, + + _1 = 1, + + _2 = 2, + + _3 = 3, + + _4 = 4, + + _5 = 5, + + _6 = 6, + + } + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.3.0.0 (NJsonSchema v11.2.0.0 (Newtonsoft.Json v13.0.0.0))")] public partial class LoginRequest { @@ -640,6 +762,12 @@ namespace Flawless.Client.Remote [JsonPropertyName("allowPublicRegister")] public bool AllowPublicRegister { get; set; } + [JsonPropertyName("allowWebHook")] + public bool AllowWebHook { get; set; } + + [JsonPropertyName("requireInitialization")] + public bool RequireInitialization { get; set; } + } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.3.0.0 (NJsonSchema v11.2.0.0 (Newtonsoft.Json v13.0.0.0))")] @@ -660,13 +788,13 @@ namespace Flawless.Client.Remote { [JsonPropertyName("title")] - public string? Title { get; set; } + public string Title { get; set; } [JsonPropertyName("description")] - public string? Description { get; set; } + public string Description { get; set; } [JsonPropertyName("tag")] - public string? Tag { get; set; } + public string Tag { get; set; } } @@ -756,6 +884,62 @@ namespace Flawless.Client.Remote } + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.3.0.0 (NJsonSchema v11.2.0.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class Webhook + { + + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("repositoryId")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public System.Guid RepositoryId { get; set; } + + [JsonPropertyName("targetUrl")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string TargetUrl { get; set; } + + [JsonPropertyName("eventType")] + public WebhookEventType EventType { get; set; } + + [JsonPropertyName("secret")] + public string Secret { get; set; } + + [JsonPropertyName("isActive")] + public bool IsActive { get; set; } + + [JsonPropertyName("createdAt")] + public System.DateTimeOffset CreatedAt { get; set; } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.3.0.0 (NJsonSchema v11.2.0.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class WebhookCreateRequest + { + + [JsonPropertyName("targetUrl")] + public string TargetUrl { get; set; } + + [JsonPropertyName("eventType")] + public WebhookEventType EventType { get; set; } + + [JsonPropertyName("secret")] + public string Secret { get; set; } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.3.0.0 (NJsonSchema v11.2.0.0 (Newtonsoft.Json v13.0.0.0))")] + public enum WebhookEventType + { + + _0 = 0, + + _1 = 1, + + _2 = 2, + + } + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.3.0.0 (NJsonSchema v11.2.0.0 (Newtonsoft.Json v13.0.0.0))")] public partial class WorkspaceFile { diff --git a/Flawless.Client/ViewModels/HomeViewModel.cs b/Flawless.Client/ViewModels/HomeViewModel.cs index d450688..c9d7066 100644 --- a/Flawless.Client/ViewModels/HomeViewModel.cs +++ b/Flawless.Client/ViewModels/HomeViewModel.cs @@ -34,7 +34,7 @@ public partial class HomeViewModel : ViewModelBase, IRoutableViewModel HostScreen = hostScreen; Username = Api.C.Username.Value!; Api.C.ServerUrl.SubscribeOn(AvaloniaScheduler.Instance) - .Subscribe(v => ServerFriendlyName = v ?? "Unknown Server"); + .Subscribe(v => ServerFriendlyName = v ?? "Uname Server"); RefreshRepositoriesCommand.Execute(); } diff --git a/Flawless.Client/Views/SettingView.axaml b/Flawless.Client/Views/SettingView.axaml index 9224f55..17f1342 100644 --- a/Flawless.Client/Views/SettingView.axaml +++ b/Flawless.Client/Views/SettingView.axaml @@ -69,10 +69,11 @@ - + - + + diff --git a/Flawless.Communication/Request/FirstSetupRequest.cs b/Flawless.Communication/Request/FirstSetupRequest.cs new file mode 100644 index 0000000..1769490 --- /dev/null +++ b/Flawless.Communication/Request/FirstSetupRequest.cs @@ -0,0 +1,10 @@ +namespace Flawless.Communication.Request; + +public record FirstSetupRequest +{ + public string AdminEmail { get; set; } + + public string AdminUsername { get; set; } + + public string AdminPassword { get; set; } +} \ No newline at end of file diff --git a/Flawless.Communication/Request/WebHookCreateRequest.cs b/Flawless.Communication/Request/WebHookCreateRequest.cs new file mode 100644 index 0000000..04d680b --- /dev/null +++ b/Flawless.Communication/Request/WebHookCreateRequest.cs @@ -0,0 +1,5 @@ +using Flawless.Communication.Shared; + +namespace Flawless.Communication.Request; + +public record WebhookCreateRequest(string TargetUrl, WebhookEventType EventType, string Secret); \ No newline at end of file diff --git a/Flawless.Communication/Response/LogEntryResponse.cs b/Flawless.Communication/Response/LogEntryResponse.cs new file mode 100644 index 0000000..5a3bd5b --- /dev/null +++ b/Flawless.Communication/Response/LogEntryResponse.cs @@ -0,0 +1,7 @@ +namespace Flawless.Communication.Response; + +public record LogEntryResponse( + DateTime Timestamp, + string Level, + string Message, + string? Exception); \ No newline at end of file diff --git a/Flawless.Communication/Response/PaginatedResponse.cs b/Flawless.Communication/Response/PaginatedResponse.cs new file mode 100644 index 0000000..3868628 --- /dev/null +++ b/Flawless.Communication/Response/PaginatedResponse.cs @@ -0,0 +1,3 @@ +namespace Flawless.Communication.Response; + +public record PaginatedResponse(List Results, int TotalCount, int Page, int PageSize); \ No newline at end of file diff --git a/Flawless.Communication/Response/ServerStatusResponse.cs b/Flawless.Communication/Response/ServerStatusResponse.cs index 4b08e9d..0984ff7 100644 --- a/Flawless.Communication/Response/ServerStatusResponse.cs +++ b/Flawless.Communication/Response/ServerStatusResponse.cs @@ -5,4 +5,8 @@ public record ServerStatusResponse public required string? FriendlyName { get; init; } public required bool AllowPublicRegister { get; set; } + + public required bool AllowWebHook { get; set; } + + public required bool RequireInitialization { get; set; } } \ No newline at end of file diff --git a/Flawless.Communication/Shared/WebhookEventType.cs b/Flawless.Communication/Shared/WebhookEventType.cs new file mode 100644 index 0000000..f8aa1d6 --- /dev/null +++ b/Flawless.Communication/Shared/WebhookEventType.cs @@ -0,0 +1,8 @@ +namespace Flawless.Communication.Shared; + +public enum WebhookEventType +{ + Push, + IssueCreated, + IssueUpdate +} \ No newline at end of file diff --git a/Flawless.Server/Controllers/AdminController.cs b/Flawless.Server/Controllers/AdminController.cs new file mode 100644 index 0000000..8da8caa --- /dev/null +++ b/Flawless.Server/Controllers/AdminController.cs @@ -0,0 +1,154 @@ +using System.Net; +using Flawless.Communication.Request; +using Flawless.Communication.Response; +using Flawless.Server.Models; +using Flawless.Server.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Flawless.Server.Controllers; + +[ApiController, Authorize(Roles = "admin"), Route("api/admin")] +public class AdminController( + UserManager userManager, + AccessControlService accessControlService, + AppDbContext dbContext) : ControllerBase +{ + [HttpPost("superuser/{username}")] + public async Task SetSuperuserAsync(string username, bool toSuper) + { + var user = await userManager.FindByNameAsync(username); + var optUser = (await userManager.GetUserAsync(HttpContext.User))!; + + if (user == null) return BadRequest(new FailedResponse("User does not exist!")); + if (optUser == user) return BadRequest(new FailedResponse("You cannot set/unset yourself to superuser!")); + + user.Admin = toSuper; + await userManager.UpdateAsync(user); + return Ok(); + } + + + [HttpGet("superuser/{username}")] + public async Task> GetSuperuserAsync(string username) + { + var user = await userManager.FindByNameAsync(username); + if (user == null) return BadRequest(new FailedResponse("User does not exist!")); + + return user.Admin; + } + + [HttpPost("user/delete/{username}")] + public async Task DeleteUserAsync(string username) + { + var user = await userManager.FindByNameAsync(username); + + if (user == null) return BadRequest(new FailedResponse("User does not exist!")); + var result = await userManager.DeleteAsync(user); + + if (!result.Succeeded) return BadRequest(new FailedResponse(result.Errors)); + return Ok(); + } + + [HttpPost("user/enable/{username}")] + public async Task EnableUserAsync(string username) + { + var user = await userManager.FindByNameAsync(username); + + if (user == null) return BadRequest(new FailedResponse("User does not exist!")); + var result = await userManager.SetLockoutEnabledAsync(user, false); + + if (!result.Succeeded) return BadRequest(new FailedResponse(result.Errors)); + return Ok(); + } + + [HttpPost("user/disable/{username}")] + public async Task DisableUserAsync(string username) + { + var user = await userManager.FindByNameAsync(username); + + if (user == null) return BadRequest(new FailedResponse("User does not exist!")); + var result = await userManager.SetLockoutEnabledAsync(user, true); + + if (!result.Succeeded) return BadRequest(new FailedResponse(result.Errors)); + return Ok(); + } + + [HttpPost("user/reset_password")] + public async Task ResetPasswordAsync(ResetPasswordRequest r) + { + if (r.Identity == null) return BadRequest(new FailedResponse("Identity (User Id) is not set!")); + var user = await userManager.FindByIdAsync(r.Identity); + + if (user == null) return BadRequest(new FailedResponse("Identity (User Id) does not exist!")); + var resetToken = await userManager.GeneratePasswordResetTokenAsync(user); + var result = await userManager.ResetPasswordAsync(user, resetToken, r.NewPassword); + + if (!result.Succeeded) return BadRequest(new FailedResponse(result.Errors)); + return Ok(); + } + + [HttpPost("access_control/ip_whitelist")] + public async Task SetIpWhitelistAsync([FromBody] string[] ips) + { + await accessControlService.UpdatePolicyAsync(IpPolicyType.Whitelist, ips); + return Ok(); + } + + [HttpGet("access_control/ip_whitelist")] + public async Task>> GetIpWhitelistAsync() + { + return Ok(await accessControlService.GetIpListAsync(IpPolicyType.Whitelist)); + } + + [HttpPost("access_control/ip_blacklist")] + public async Task SetIpBlacklistAsync([FromBody] string[] ips) + { + await accessControlService.UpdatePolicyAsync(IpPolicyType.Blacklist, ips); + return Ok(); + } + + [HttpGet("access_control/ip_blacklist")] + public async Task>> GetIpBlacklistAsync() + { + return Ok(await accessControlService.GetIpListAsync(IpPolicyType.Blacklist)); + } + + [HttpGet("logs")] + public async Task>> GetSystemLogsAsync( + [FromQuery] DateTime? startTime = null, + [FromQuery] DateTime? endTime = null, + [FromQuery] LogLevel? level = null, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 50) + { + var query = dbContext.SystemLogs.AsQueryable(); + + // 时间过滤 + if (startTime.HasValue) + query = query.Where(l => l.Timestamp >= startTime); + if (endTime.HasValue) + query = query.Where(l => l.Timestamp <= endTime); + + // 日志级别过滤 + if (level.HasValue) + query = query.Where(l => l.LogLevel == level.Value); + + // 分页处理 + var totalCount = await query.CountAsync(); + var results = await query + .OrderByDescending(l => l.Timestamp) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(l => new LogEntryResponse( + l.Timestamp, + l.LogLevel.ToString(), + l.Message, + l.Exception)) + .ToListAsync(); + + return Ok(new PaginatedResponse(results, totalCount, page, pageSize)); + } +} diff --git a/Flawless.Server/Controllers/AdminUserController.cs b/Flawless.Server/Controllers/AdminUserController.cs deleted file mode 100644 index bd3d385..0000000 --- a/Flawless.Server/Controllers/AdminUserController.cs +++ /dev/null @@ -1,64 +0,0 @@ -using Flawless.Communication.Request; -using Flawless.Communication.Response; -using Flawless.Server.Models; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Mvc; - -namespace Flawless.Server.Controllers; - -[ApiController, Authorize, Route("api/admin")] -public class AdminUserController( - UserManager userManager) : ControllerBase -{ - [HttpPost("user/delete/{username}")] - public async Task DeleteUserAsync(string username) - { - var user = await userManager.FindByNameAsync(username); - - if (user == null) return BadRequest(new FailedResponse("User does not exist!")); - var result = await userManager.DeleteAsync(user); - - if (!result.Succeeded) return BadRequest(new FailedResponse(result.Errors)); - return Ok(); - } - - [HttpPost("user/enable/{username}")] - public async Task EnableUserAsync(string username) - { - var user = await userManager.FindByNameAsync(username); - - if (user == null) return BadRequest(new FailedResponse("User does not exist!")); - var result = await userManager.SetLockoutEnabledAsync(user, false); - - if (!result.Succeeded) return BadRequest(new FailedResponse(result.Errors)); - return Ok(); - } - - [HttpPost("user/disable/{username}")] - public async Task DisableUserAsync(string username) - { - var user = await userManager.FindByNameAsync(username); - - if (user == null) return BadRequest(new FailedResponse("User does not exist!")); - var result = await userManager.SetLockoutEnabledAsync(user, true); - - if (!result.Succeeded) return BadRequest(new FailedResponse(result.Errors)); - return Ok(); - } - - [HttpPost("user/reset_password")] - public async Task ResetPasswordAsync(ResetPasswordRequest r) - { - if (r.Identity == null) return BadRequest(new FailedResponse("Identity (User Id) is not set!")); - var user = await userManager.FindByIdAsync(r.Identity); - - if (user == null) return BadRequest(new FailedResponse("Identity (User Id) does not exist!")); - var resetToken = await userManager.GeneratePasswordResetTokenAsync(user); - var result = await userManager.ResetPasswordAsync(user, resetToken, r.NewPassword); - - if (!result.Succeeded) return BadRequest(new FailedResponse(result.Errors)); - return Ok(); - } - -} \ No newline at end of file diff --git a/Flawless.Server/Controllers/AuthenticationController.cs b/Flawless.Server/Controllers/AuthenticationController.cs index 27de4fd..007f9c7 100644 --- a/Flawless.Server/Controllers/AuthenticationController.cs +++ b/Flawless.Server/Controllers/AuthenticationController.cs @@ -17,7 +17,7 @@ public class AuthenticationController( SignInManager signInManager, TokenGenerationService tokenService, AppDbContext dbContext, - IConfiguration config, + SettingFacade setting, ILogger logger) : ControllerBase { @@ -27,15 +27,44 @@ public class AuthenticationController( { return Task.FromResult>(Ok(new ServerStatusResponse() { - AllowPublicRegister = true, - FriendlyName = "Ca2didi Server" + AllowPublicRegister = setting.AllowPublicRegistration, + AllowWebHook = setting.UseWebHook, + FriendlyName = setting.ServerName, + RequireInitialization = !dbContext.Users.Any(x => x.Admin), })); } + + [HttpPost("first-setup")] + public async Task FirstSetupAsync(FirstSetupRequest r) + { + if (dbContext.Users.Any(x => x.Admin)) + { + return BadRequest(new FailedResponse("Server has been initialized.")); + } + + var user = new AppUser + { + UserName = r.AdminUsername, + Email = r.AdminEmail, + EmailConfirmed = true, + CreatedOn = DateTime.UtcNow, + }; + + user.RenewSecurityStamp(); + var result = await userManager.CreateAsync(user, r.AdminPassword); + if (result.Succeeded) + { + logger.LogInformation("User '{0}' created (PUBLIC REGISTER)", user.UserName); + return Ok(); + } + + return BadRequest(new FailedResponse(result.Errors)); + } [HttpPost("register")] public async Task PublicRegisterAsync(RegisterRequest request) { - if (!config.GetValue("User:PublicRegister", true)) + if (!setting.AllowPublicRegistration) return BadRequest(new FailedResponse("Not opened for public register.")); @@ -45,9 +74,9 @@ public class AuthenticationController( Email = request.Email, EmailConfirmed = true, CreatedOn = DateTime.UtcNow, - SecurityStamp = Guid.NewGuid().ToString() }; + user.RenewSecurityStamp(); var result = await userManager.CreateAsync(user, request.Password); if (result.Succeeded) { diff --git a/Flawless.Server/Controllers/RepositoryInnieController.cs b/Flawless.Server/Controllers/RepositoryInnieController.cs index 96075cd..ea3d205 100644 --- a/Flawless.Server/Controllers/RepositoryInnieController.cs +++ b/Flawless.Server/Controllers/RepositoryInnieController.cs @@ -17,6 +17,7 @@ namespace Flawless.Server.Controllers; public class RepositoryInnieController( UserManager userManager, AppDbContext dbContext, + WebhookService webhookService, PathTransformer transformer) : ControllerBase { @@ -161,6 +162,52 @@ public class RepositoryInnieController( return rp; } + + [HttpPost("create_webhook")] + public async Task CreateWebhook( + string userName, + string repositoryName, + [FromBody] WebhookCreateRequest request) + { + var user = (await userManager.GetUserAsync(HttpContext.User))!; + var grantIssue = await ValidateRepositoryAsync(userName, repositoryName, user, RepositoryRole.Owner); + if (grantIssue is not Repository rp) return (IActionResult) grantIssue; + + await webhookService.AddWebhookAsync(rp.Id, request.TargetUrl, request.EventType, request.Secret); + return Created(); + } + + [HttpGet] + public async Task>> GetWebhooks(string userName, string repositoryName) + { + var user = (await userManager.GetUserAsync(HttpContext.User))!; + var grantIssue = await ValidateRepositoryAsync(userName, repositoryName, user, RepositoryRole.Developer); + if (grantIssue is not Repository rp) return (ActionResult) grantIssue; + + return Ok(await webhookService.GetWebhooksAsync(rp.Id)); + } + + [HttpPost("{webhookId}/toggle")] + public async Task ToggleWebhook(string userName, string repositoryName, int webhookId, bool activate) + { + var user = (await userManager.GetUserAsync(HttpContext.User))!; + var grantIssue = await ValidateRepositoryAsync(userName, repositoryName, user, RepositoryRole.Owner); + if (grantIssue is not Repository rp) return (ActionResult) grantIssue; + + await webhookService.ToggleWebhookAsync(rp.Id, webhookId, activate); + return Ok(); + } + + [HttpDelete("{webhookId}")] + public async Task DeleteWebhook(string userName, string repositoryName, int webhookId) + { + var user = (await userManager.GetUserAsync(HttpContext.User))!; + var grantIssue = await ValidateRepositoryAsync(userName, repositoryName, user, RepositoryRole.Owner); + if (grantIssue is not Repository rp) return (ActionResult) grantIssue; + + await webhookService.DeleteWebhookAsync(rp.Id, webhookId); + return NoContent(); + } [HttpPost("fetch_manifest")] diff --git a/Flawless.Server/Data/AppDbContext.cs b/Flawless.Server/Data/AppDbContext.cs new file mode 100644 index 0000000..e69de29 diff --git a/Flawless.Server/Flawless.Server.csproj b/Flawless.Server/Flawless.Server.csproj index 7df0a72..46a58f6 100644 --- a/Flawless.Server/Flawless.Server.csproj +++ b/Flawless.Server/Flawless.Server.csproj @@ -7,6 +7,7 @@ + @@ -37,9 +38,4 @@ - - - - - diff --git a/Flawless.Server/Middlewares/IpFilterMiddleware.cs b/Flawless.Server/Middlewares/IpFilterMiddleware.cs new file mode 100644 index 0000000..cbb2fcf --- /dev/null +++ b/Flawless.Server/Middlewares/IpFilterMiddleware.cs @@ -0,0 +1,36 @@ +using Flawless.Server.Services; + +namespace Flawless.Server.Middlewares; + +public class IpFilterMiddleware : IDisposable +{ + private readonly RequestDelegate _next; + private readonly IServiceScope _subScope; + private readonly AccessControlService _accessControl; + + public IpFilterMiddleware(RequestDelegate next, IServiceProvider provider) + { + _next = next; + _subScope = provider.CreateScope(); + _accessControl = _subScope.ServiceProvider.GetRequiredService(); + } + + public async Task InvokeAsync(HttpContext context) + { + var ip = context.Connection.RemoteIpAddress?.ToString(); + + if (string.IsNullOrEmpty(ip) || !await _accessControl.IsIpAllowedAsync(ip)) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + await context.Response.WriteAsync($"IP access is denied. Your IP: {ip}"); + return; + } + + await _next(context); + } + + public void Dispose() + { + _subScope.Dispose(); + } +} \ No newline at end of file diff --git a/Flawless.Server/Models/AppUser.cs b/Flawless.Server/Models/AppUser.cs index c7b4180..4de345c 100644 --- a/Flawless.Server/Models/AppUser.cs +++ b/Flawless.Server/Models/AppUser.cs @@ -21,6 +21,8 @@ public class AppUser : IdentityUser [Required] public bool PublicEmail { get; set; } = true; + public bool Admin { get; set; } = false; + public void RenewSecurityStamp() { diff --git a/Flawless.Server/Models/IpPolicy.cs b/Flawless.Server/Models/IpPolicy.cs new file mode 100644 index 0000000..bb7768d --- /dev/null +++ b/Flawless.Server/Models/IpPolicy.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace Flawless.Server.Models; + +public enum IpPolicyType +{ + Whitelist, + Blacklist +} + +public class IpPolicy +{ + [Key] + public int Id { get; set; } + + public required string IpAddress { get; set; } + + public required IpPolicyType PolicyType { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} \ No newline at end of file diff --git a/Flawless.Server/Models/SystemLog.cs b/Flawless.Server/Models/SystemLog.cs new file mode 100644 index 0000000..c97b8ed --- /dev/null +++ b/Flawless.Server/Models/SystemLog.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.EntityFrameworkCore; + +namespace Flawless.Server.Models; + +[Index(nameof(Timestamp))] +public class SystemLog +{ + [Key] + public int Id { get; set; } + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + public LogLevel LogLevel { get; set; } + public string Message { get; set; } = string.Empty; + public string? Exception { get; set; } + public string? Source { get; set; } +} \ No newline at end of file diff --git a/Flawless.Server/Models/Webhook.cs b/Flawless.Server/Models/Webhook.cs new file mode 100644 index 0000000..d49326d --- /dev/null +++ b/Flawless.Server/Models/Webhook.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; +using Flawless.Communication.Shared; + +namespace Flawless.Server.Models; + +public class Webhook +{ + [Key] + public int Id { get; set; } + + public required Guid RepositoryId { get; set; } + + public required string TargetUrl { get; set; } + + public required WebhookEventType EventType { get; set; } + + public required string? Secret { get; set; } + + public bool IsActive { get; set; } = true; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} \ No newline at end of file diff --git a/Flawless.Server/Program.cs b/Flawless.Server/Program.cs index 09a7b20..ff8d04d 100644 --- a/Flawless.Server/Program.cs +++ b/Flawless.Server/Program.cs @@ -1,7 +1,6 @@ +using System.Security; using System.Security.Claims; using System.Text; -using System.Text.Json.Serialization; -using Flawless.Communication.Response; using Flawless.Server.Middlewares; using Flawless.Server.Models; using Flawless.Server.Services; @@ -29,7 +28,7 @@ public static class Program var app = builder.Build(); - SetupWebApplication(app); + SetupMiddleware(app); app.Run(); } @@ -49,8 +48,15 @@ public static class Program opt.MultipartHeadersLengthLimit = int.MaxValue; }); - // Api related + // Logic services + builder.Services.AddHttpClient(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + // Api related builder.Services.AddOpenApi(); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); @@ -79,6 +85,7 @@ public static class Program } }); }); + } private static void ConfigDbContext(WebApplicationBuilder builder) @@ -88,6 +95,11 @@ public static class Program { opt.UseNpgsql(builder.Configuration.GetConnectionString("CoreDb")); }); + + builder.Logging.ClearProviders() + .AddConsole() + .AddDebug() + .Services.AddSingleton(); builder.Services.AddIdentityCore(opt => { @@ -141,18 +153,12 @@ public static class Program }); } - private static void SetupWebApplication(WebApplication app) + private static void SetupMiddleware(WebApplication app) { + app.UseMiddleware(); app.UseMiddleware(); app.UseRouting(); - // Config WebSocket support. - app.UseWebSockets(new WebSocketOptions - { - KeepAliveInterval = TimeSpan.FromSeconds(60), - KeepAliveTimeout = TimeSpan.FromSeconds(300), - }); - // Configure identity control app.UseAuthentication(); app.UseAuthorization(); @@ -178,11 +184,12 @@ public static class Program { if (context.Principal != null) { - var p = context.Principal!; var auth = context.HttpContext.GetEndpoint()?.Metadata.GetOrderedMetadata(); + var p = context.Principal!; if (auth?.Any() ?? false) { + var adminOnly = auth.Any(a => a.Policy?.ToLower() == "admin"); var id = p.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (id == null) throw new SecurityTokenExpiredException("User is not defined in the token!"); @@ -194,6 +201,8 @@ public static class Program var u = await db.FindByIdAsync(id!); if (u == null) throw new SecurityTokenExpiredException("User is not existed."); + if (adminOnly && u.Admin == false) + throw new SecurityException("This api is Admin called only!"); if (u.SecurityStamp != stamp) throw new SecurityTokenExpiredException("SecurityStamp is mismatched."); // if (u.LockoutEnabled) throw new SecurityTokenExpiredException("User has been locked."); //todo Fix lockout prob diff --git a/Flawless.Server/Services/AccessControlService.cs b/Flawless.Server/Services/AccessControlService.cs new file mode 100644 index 0000000..93c9845 --- /dev/null +++ b/Flawless.Server/Services/AccessControlService.cs @@ -0,0 +1,65 @@ +using System.Net; +using Flawless.Server.Models; +using Microsoft.EntityFrameworkCore; + +namespace Flawless.Server.Services; + +public class AccessControlService(AppDbContext dbContext) +{ + public async Task> GetIpListAsync(IpPolicyType policyType) + { + return await dbContext.IpPolicies + .Where(x => x.PolicyType == policyType) + .Select(x => x.IpAddress) + .ToListAsync(); + } + + public async Task UpdatePolicyAsync(IpPolicyType policyType, IEnumerable ips) + { + var validIps = ips.Where(IsValidIp).Distinct().ToList(); + + // 删除旧策略 + var existing = await dbContext.IpPolicies + .Where(x => x.PolicyType == policyType) + .ToListAsync(); + dbContext.IpPolicies.RemoveRange(existing); + + // 添加新策略 + var newPolicies = validIps.Select(ip => new IpPolicy + { + IpAddress = ip, + PolicyType = policyType, + CreatedAt = DateTime.UtcNow + }); + + await dbContext.IpPolicies.AddRangeAsync(newPolicies); + + await dbContext.SaveChangesAsync(); + } + + public async Task IsIpAllowedAsync(string ip) + { + if (!IsValidIp(ip)) return false; + + var policies = await dbContext.IpPolicies + .Where(x => x.IpAddress == ip) + .ToListAsync(); + + var isWhitelisted = policies.Any(x => x.PolicyType == IpPolicyType.Whitelist); + var isBlacklisted = policies.Any(x => x.PolicyType == IpPolicyType.Blacklist); + + // 如果有白名单记录则优先判断 + var hasAnyWhitelist = await dbContext.IpPolicies + .AnyAsync(x => x.PolicyType == IpPolicyType.Whitelist); + + return hasAnyWhitelist ? + isWhitelisted && !isBlacklisted : + !isBlacklisted; + } + + private static bool IsValidIp(string ip) + { + return IPAddress.TryParse(ip, out _); + } + +} \ No newline at end of file diff --git a/Flawless.Server/Services/AppDbContext.cs b/Flawless.Server/Services/AppDbContext.cs index aab781d..2d296ef 100644 --- a/Flawless.Server/Services/AppDbContext.cs +++ b/Flawless.Server/Services/AppDbContext.cs @@ -14,6 +14,12 @@ public class AppDbContext(DbContextOptions options) public DbSet Repositories { get; set; } public DbSet RepositoryIssues { get; set; } + + public DbSet IpPolicies { get; set; } + + public DbSet SystemLogs { get; set; } + + public DbSet Webhooks { get; set; } public async ValueTask<(bool existed, bool authorized)> CheckRepositoryExistedAuthorizedAsync( AppUser owner, string name, AppUser user, RepositoryRole role) diff --git a/Flawless.Server/Services/DatabaseLoggerProvider.cs b/Flawless.Server/Services/DatabaseLoggerProvider.cs new file mode 100644 index 0000000..b2eccd5 --- /dev/null +++ b/Flawless.Server/Services/DatabaseLoggerProvider.cs @@ -0,0 +1,47 @@ +using Flawless.Server.Models; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Flawless.Server.Services; + +public class DatabaseLoggerProvider(IServiceProvider serviceProvider) : ILoggerProvider +{ + public ILogger CreateLogger(string categoryName) + { + // 过滤非业务错误日志 + if (!categoryName.StartsWith("Flawless.Server")) return NullLogger.Instance; + return new DatabaseLogger(serviceProvider.CreateScope()); + } + + public void Dispose() { } +} + +public class DatabaseLogger(IServiceScope scope) : ILogger +{ + private readonly AppDbContext _dbContext = scope.ServiceProvider.GetService()!; + + public IDisposable? BeginScope(TState state) => scope; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + try + { + _dbContext.SystemLogs.Add(new SystemLog + { + LogLevel = logLevel, + Message = formatter(state, exception), + Exception = exception?.ToString(), + Timestamp = DateTime.UtcNow, + Source = eventId.Name + }); + + _dbContext.SaveChanges(); + } + catch (Exception e) + { + Console.Error.WriteLine("Cannot save logfile into database: " + e); + } + } +} \ No newline at end of file diff --git a/Flawless.Server/Services/EmailService.cs b/Flawless.Server/Services/EmailService.cs new file mode 100644 index 0000000..20f8153 --- /dev/null +++ b/Flawless.Server/Services/EmailService.cs @@ -0,0 +1,37 @@ +using MailKit.Net.Smtp; +using MimeKit; +namespace Flawless.Server.Services; + +public class EmailService(SettingFacade setting, ILogger logger) +{ + public async Task SendEmailAsync(string toEmail, string subject, string body) + { + if (!setting.UseSmtp) + { + logger.LogWarning("SMTP is deactivated, skip sending email."); + return; + } + + var message = new MimeMessage(); + message.From.Add(new MailboxAddress(setting.ServerName, setting.SmtpUsername)); + message.To.Add(new MailboxAddress("用户", toEmail)); + message.Subject = subject; + + message.Body = new TextPart("html") { Text = body }; + + using var client = new SmtpClient(); + await client.ConnectAsync( + setting.SmtpHost, + setting.SmtpPort, + setting.SmtpUseSsl); + + await client.AuthenticateAsync( + setting.SmtpUsername, + setting.SmtpPassword); + + await client.SendAsync(message); + await client.DisconnectAsync(true); + + logger.LogInformation($"Mail has sent to {toEmail}"); + } +} \ No newline at end of file diff --git a/Flawless.Server/Services/RepositoryEventSender.cs b/Flawless.Server/Services/RepositoryEventSender.cs new file mode 100644 index 0000000..d1133e7 --- /dev/null +++ b/Flawless.Server/Services/RepositoryEventSender.cs @@ -0,0 +1,6 @@ +namespace Flawless.Server.Services; + +public class RepositoryEventSender(WebhookService webhook, EmailService mail) +{ + +} \ No newline at end of file diff --git a/Flawless.Server/Services/SettingFacade.cs b/Flawless.Server/Services/SettingFacade.cs new file mode 100644 index 0000000..250b314 --- /dev/null +++ b/Flawless.Server/Services/SettingFacade.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.Configuration; + +namespace Flawless.Server.Services; + +public class SettingFacade +{ + private readonly IConfiguration _config; + + public SettingFacade(IConfiguration config) => _config = config; + + // 系统基础配置 + public string? ServerName => _config[SettingKey.ServerName] ?? null; + public bool AllowPublicRegistration => _config.GetValue(SettingKey.AllowPublicRegistration, false); + + // Webhook配置 + public bool UseWebHook => _config.GetValue(SettingKey.UseWebHook, false); + public int WebhookTimeout => _config.GetValue(SettingKey.WebhookTimeout, 5000); + public int WebhookMaxRetries => _config.GetValue(SettingKey.WebhookMaxRetries, 3); + + // SMTP配置 + public bool UseSmtp => _config.GetValue(SettingKey.UseSmtp, false); + public string SmtpHost => _config[SettingKey.SmtpHost]?.Trim() ?? "localhost"; + public int SmtpPort => _config.GetValue(SettingKey.SmtpPort, 587); + public bool SmtpUseSsl => _config.GetValue(SettingKey.SmtpUseSsl, false); + public string SmtpUsername => _config[SettingKey.SmtpUsername]?.Trim() ?? String.Empty; + public string SmtpPassword => _config[SettingKey.SmtpPassword]?.Trim()?? String.Empty; +} \ No newline at end of file diff --git a/Flawless.Server/Services/WebhookService.cs b/Flawless.Server/Services/WebhookService.cs new file mode 100644 index 0000000..18a9e3a --- /dev/null +++ b/Flawless.Server/Services/WebhookService.cs @@ -0,0 +1,114 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Flawless.Communication.Shared; +using Flawless.Server.Models; +using Microsoft.EntityFrameworkCore; + +namespace Flawless.Server.Services; + +public class WebhookService( + SettingFacade settings, + AppDbContext context, + IHttpClientFactory httpFactory, + ILogger logger) +{ + public async Task AddWebhookAsync(Guid repoId, string targetUrl, WebhookEventType eventType, string? secret) + { + // 新增参数校验 + if (string.IsNullOrWhiteSpace(targetUrl) || !Uri.TryCreate(targetUrl, UriKind.Absolute, out _)) + throw new ArgumentException("No valid target URL provided"); + + var webhook = new Webhook { + RepositoryId = repoId, + TargetUrl = targetUrl, + EventType = eventType, + Secret = secret, + IsActive = true + }; + + await context.Webhooks.AddAsync(webhook); + await context.SaveChangesAsync(); + } + + public async Task ToggleWebhookAsync(Guid repoId, int webhookId, bool activated) + { + var hook = await context.Webhooks.FindAsync(webhookId); + if (hook == null || hook.RepositoryId != repoId) return; + + if (hook.IsActive == activated) return; + + hook.IsActive = activated; + context.Webhooks.Update(hook); + await context.SaveChangesAsync(); + } + + public async Task DeleteWebhookAsync(Guid repoId, int webhookId) + { + var hook = await context.Webhooks.FindAsync(webhookId); + if (hook == null || hook.RepositoryId != repoId) return; + + context.Webhooks.Remove(hook); + await context.SaveChangesAsync(); + } + + public async Task> GetWebhooksAsync(Guid repoId) + { + return await context.Webhooks + .Where(w => w.RepositoryId == repoId) + .ToListAsync(); + } + + public async Task TriggerWebhooksAsync(Guid repoId, WebhookEventType eventType, object payload) + { + if (!settings.UseWebHook) return; + + var hooks = await context.Webhooks + .Where(w => w.RepositoryId == repoId && w.EventType == eventType && w.IsActive) + .ToListAsync(); + + using var client = httpFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(settings.WebhookTimeout); + + foreach (var hook in hooks) + { + for (var retry = 0; retry < settings.WebhookMaxRetries; retry++) + { + try + { + var content = new StringContent(JsonSerializer.Serialize(payload), + Encoding.UTF8, "application/json"); + + if (hook.Secret != null) + { + var signature = HMACSHA256.HashData( + Encoding.UTF8.GetBytes(hook.Secret), + await content.ReadAsByteArrayAsync()); + + content.Headers.Add("X-Signature", $"sha256={Convert.ToHexString(signature)}"); + } + + var response = await client.PostAsync(hook.TargetUrl, content); + if (response.IsSuccessStatusCode) break; + + logger.LogWarning($"Webhook {hook.Id} Failed:{response.StatusCode}"); + await Task.Delay(1000 * (int)Math.Pow(2, retry)); // 指数退避 + } + catch (Exception ex) + { + logger.LogError(ex, $"Webhook {hook.Id} Failed for {retry + 1} times."); + if (retry == settings.WebhookMaxRetries - 1) + await DeactivateFailedWebhook(hook); + } + } + } + } + + private async Task DeactivateFailedWebhook(Webhook hook) + { + hook.IsActive = false; + context.Webhooks.Update(hook); + await context.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/Flawless.Server/SettingKey.cs b/Flawless.Server/SettingKey.cs new file mode 100644 index 0000000..44ae62a --- /dev/null +++ b/Flawless.Server/SettingKey.cs @@ -0,0 +1,27 @@ +namespace Flawless.Server; + +public static class SettingKey +{ + public const string ServerName = nameof(ServerName); + + public const string AllowPublicRegistration = nameof(AllowPublicRegistration); + + public const string UseWebHook = nameof(UseWebHook); + + public const string WebhookTimeout = nameof(WebhookTimeout); + + public const string WebhookMaxRetries = nameof(WebhookMaxRetries); + + public const string UseSmtp = nameof(UseSmtp); + + public const string SmtpHost = nameof(SmtpHost); + + public const string SmtpPort = nameof(SmtpPort); + + public const string SmtpUseSsl = nameof(SmtpUseSsl); + + public const string SmtpUsername = nameof(SmtpUsername); + + public const string SmtpPassword = nameof(SmtpPassword); + +} \ No newline at end of file