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