Skip to content

Commit e2b8c5e

Browse files
authored
feat: :spakles: Implement Content Sanitization and Filtering Service (#115)
1 parent 32c51b8 commit e2b8c5e

File tree

16 files changed

+1208
-4
lines changed

16 files changed

+1208
-4
lines changed

Backend/SorobanSecurityPortalApi.Tests/Services/ContentFilterServiceTests.cs

Lines changed: 591 additions & 0 deletions
Large diffs are not rendered by default.

Backend/SorobanSecurityPortalApi/Common/Data/Db.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ public class Db : DbContext
2020
public DbSet<FileModel> File { get; set; }
2121
public DbSet<CompanyModel> Company { get; set; }
2222
public DbSet<BookmarkModel> Bookmark { get; set; }
23+
public DbSet<ModerationLogModel> ModerationLog { get; set; }
2324
public DbSet<UserProfileModel> UserProfiles { get; set; }
2425

2526

Backend/SorobanSecurityPortalApi/Common/ExtendedConfig.cs

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ public interface IExtendedConfig
2828
double TrigramContentWeight { get; }
2929
double VectorContentWeight { get; }
3030
double MinRelevanceForSearch { get; }
31+
bool ProfanityFilterEnabled { get; }
32+
List<string> ProfanityWords { get; }
33+
List<string> TrustedDomains { get; }
3134
void Reset();
3235
}
3336

@@ -182,6 +185,49 @@ private T GetValue<T>(string key, T defaultValue)
182185
[Tooltip("The Min Relevance for Search is used to specify the minimum relevance score for search results. This is used to filter out low-relevance results from search queries.")]
183186
public double MinRelevanceForSearch => GetValue<double>("MinRelevanceForSearch", 6);
184187

188+
[Category(CategoryAttribute.ConfigCategoryEnum.ContentFilter)]
189+
[DataType(DataTypeAttribute.ConfigDataTypeEnum.Boolean)]
190+
[Description("Enable Profanity Filter")]
191+
[Tooltip("Enables the profanity filter for user-generated content. When enabled, content containing profane words will be flagged for moderation.")]
192+
public bool ProfanityFilterEnabled => GetValue<bool>("ProfanityFilterEnabled", false);
193+
194+
[Category(CategoryAttribute.ConfigCategoryEnum.ContentFilter)]
195+
[DataType(DataTypeAttribute.ConfigDataTypeEnum.Multiline)]
196+
[Description("Custom Profanity Words (one per line)")]
197+
[Tooltip("Additional words to filter beyond the default dictionary. Enter one word per line. Words are matched case-insensitively. The system will notify you if a word already exists in the default dictionary.")]
198+
public List<string> ProfanityWords
199+
{
200+
get
201+
{
202+
var words = GetValue<string>("ProfanityWords", "");
203+
return string.IsNullOrWhiteSpace(words)
204+
? new List<string>()
205+
: words.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
206+
.Select(w => w.Trim())
207+
.Where(w => !string.IsNullOrWhiteSpace(w))
208+
.ToList();
209+
}
210+
}
211+
212+
[Category(CategoryAttribute.ConfigCategoryEnum.ContentFilter)]
213+
[DataType(DataTypeAttribute.ConfigDataTypeEnum.Link)]
214+
[Description("View Default Profanity Dictionary")]
215+
[Tooltip("Click to view the built-in profanity words that are always active. These words cannot be modified, but you can add custom words above.")]
216+
public string DefaultProfanityWordsLink => "/api/settings/default-profanity-words";
217+
218+
[Category(CategoryAttribute.ConfigCategoryEnum.ContentFilter)]
219+
[Description("Trusted Domains")]
220+
[Tooltip("Comma-separated list of trusted domains for URLs in content (e.g., github.com,stellar.org). If empty, all HTTPS URLs are allowed but flagged for moderation.")]
221+
public List<string> TrustedDomains
222+
{
223+
get
224+
{
225+
var domains = GetValue<string>("TrustedDomains", "");
226+
return string.IsNullOrWhiteSpace(domains)
227+
? new List<string>()
228+
: domains.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(d => d.Trim()).ToList();
229+
}
230+
}
185231

186232
}
187233

@@ -218,7 +264,8 @@ public enum ConfigDataTypeEnum
218264
Color,
219265
Hidden,
220266
Link,
221-
Dropdown
267+
Dropdown,
268+
Multiline
222269
}
223270
}
224271

@@ -261,5 +308,7 @@ public enum ConfigCategoryEnum
261308
Authentication,
262309
[System.ComponentModel.Description("Search")]
263310
Search,
311+
[System.ComponentModel.Description("Content Filter")]
312+
ContentFilter,
264313
}
265314
}

Backend/SorobanSecurityPortalApi/Controllers/SettingsController.cs

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,14 @@ namespace SorobanSecurityPortalApi.Controllers
1313
public class SettingsController : ControllerBase
1414
{
1515
private readonly ISettingsService _settingsService;
16-
public SettingsController(ISettingsService settingsService)
16+
private readonly IContentFilterService _contentFilterService;
17+
18+
public SettingsController(
19+
ISettingsService settingsService,
20+
IContentFilterService contentFilterService)
1721
{
1822
_settingsService = settingsService;
23+
_contentFilterService = contentFilterService;
1924
}
2025

2126
[HttpGet]
@@ -43,5 +48,44 @@ public IActionResult Reboot()
4348
_settingsService.Reboot();
4449
return Ok();
4550
}
51+
52+
[HttpGet("default-profanity-words")]
53+
[RoleAuthorize(Role.Admin)]
54+
[CombinedAuthorize]
55+
public IActionResult GetDefaultProfanityWords()
56+
{
57+
var words = _contentFilterService.GetDefaultProfanityWords();
58+
return Ok(new { words = words.OrderBy(w => w).ToList(), count = words.Count });
59+
}
60+
61+
[HttpPost("validate-profanity-words")]
62+
[RoleAuthorize(Role.Admin)]
63+
[CombinedAuthorize]
64+
public IActionResult ValidateProfanityWords([FromBody] ValidateProfanityWordsRequest request)
65+
{
66+
var defaultWords = _contentFilterService.GetDefaultProfanityWords();
67+
var customWords = request.Words
68+
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
69+
.Select(w => w.Trim().ToLowerInvariant())
70+
.Where(w => !string.IsNullOrWhiteSpace(w))
71+
.ToList();
72+
73+
var duplicatesInDefault = customWords
74+
.Where(w => defaultWords.Contains(w))
75+
.ToList();
76+
77+
var duplicatesInCustom = customWords
78+
.GroupBy(w => w)
79+
.Where(g => g.Count() > 1)
80+
.Select(g => g.Key)
81+
.ToList();
82+
83+
return Ok(new
84+
{
85+
duplicatesInDefault = duplicatesInDefault,
86+
duplicatesInCustom = duplicatesInCustom,
87+
hasDuplicates = duplicatesInDefault.Any() || duplicatesInCustom.Any()
88+
});
89+
}
4690
}
4791
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
using Microsoft.EntityFrameworkCore;
2+
using SorobanSecurityPortalApi.Common.Data;
3+
using SorobanSecurityPortalApi.Models.DbModels;
4+
5+
namespace SorobanSecurityPortalApi.Data.Processors
6+
{
7+
public class ModerationLogProcessor : IModerationLogProcessor
8+
{
9+
private readonly IDbContextFactory<Db> _dbFactory;
10+
11+
public ModerationLogProcessor(IDbContextFactory<Db> dbFactory)
12+
{
13+
_dbFactory = dbFactory;
14+
}
15+
16+
public async Task Add(ModerationLogModel moderationLog)
17+
{
18+
await using var db = await _dbFactory.CreateDbContextAsync();
19+
db.ModerationLog.Add(moderationLog);
20+
await db.SaveChangesAsync();
21+
}
22+
23+
public async Task<List<ModerationLogModel>> GetByUserId(int userId, int limit = 100)
24+
{
25+
await using var db = await _dbFactory.CreateDbContextAsync();
26+
return await db.ModerationLog
27+
.AsNoTracking()
28+
.Where(m => m.UserId == userId)
29+
.OrderByDescending(m => m.CreatedAt)
30+
.Take(limit)
31+
.ToListAsync();
32+
}
33+
34+
public async Task<ModerationLogModel?> GetLastByUserId(int userId)
35+
{
36+
await using var db = await _dbFactory.CreateDbContextAsync();
37+
return await db.ModerationLog
38+
.AsNoTracking()
39+
.Where(m => m.UserId == userId)
40+
.OrderByDescending(m => m.CreatedAt)
41+
.FirstOrDefaultAsync();
42+
}
43+
44+
public async Task<bool> HasDuplicateContent(int userId, string content, TimeSpan timeWindow)
45+
{
46+
await using var db = await _dbFactory.CreateDbContextAsync();
47+
var threshold = DateTime.UtcNow.Subtract(timeWindow);
48+
return await db.ModerationLog
49+
.AsNoTracking()
50+
.Where(m => m.UserId == userId && m.OriginalContent == content && m.CreatedAt >= threshold)
51+
.AnyAsync();
52+
}
53+
}
54+
55+
public interface IModerationLogProcessor
56+
{
57+
Task Add(ModerationLogModel moderationLog);
58+
Task<List<ModerationLogModel>> GetByUserId(int userId, int limit = 100);
59+
Task<ModerationLogModel?> GetLastByUserId(int userId);
60+
Task<bool> HasDuplicateContent(int userId, string content, TimeSpan timeWindow);
61+
}
62+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
damn
2+
hell
3+
crap
4+
shit
5+
fuck
6+
ass
7+
bitch
8+
bastard
9+
dick
10+
piss
11+
cock
12+
slut
13+
whore
14+
fag
15+
nigger
16+
nigga
17+
retard
18+
moron
19+
idiot
20+
stupid
21+
dumb
22+
jerk
23+
asshole
24+
motherfucker
25+
bullshit
26+
goddamn
27+
penis
28+
vagina
29+
porn
30+
sex
31+
nude
32+
xxx
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using Microsoft.EntityFrameworkCore.Migrations;
4+
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
5+
using SorobanSecurityPortalApi.Models.DbModels;
6+
7+
#nullable disable
8+
9+
namespace SorobanSecurityPortalApi.Migrations
10+
{
11+
/// <inheritdoc />
12+
public partial class AddModerationLog : Migration
13+
{
14+
/// <inheritdoc />
15+
protected override void Up(MigrationBuilder migrationBuilder)
16+
{
17+
migrationBuilder.CreateTable(
18+
name: "moderation_log",
19+
columns: table => new
20+
{
21+
id = table.Column<int>(type: "integer", nullable: false)
22+
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
23+
user_id = table.Column<int>(type: "integer", nullable: false),
24+
original_content = table.Column<string>(type: "text", nullable: false),
25+
sanitized_content = table.Column<string>(type: "text", nullable: false),
26+
filter_reason = table.Column<string>(type: "text", nullable: false),
27+
is_blocked = table.Column<bool>(type: "boolean", nullable: false),
28+
requires_moderation = table.Column<bool>(type: "boolean", nullable: false),
29+
warnings = table.Column<string>(type: "text", nullable: false),
30+
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
31+
},
32+
constraints: table =>
33+
{
34+
table.PrimaryKey("pk_moderation_log", x => x.id);
35+
});
36+
37+
migrationBuilder.CreateIndex(
38+
name: "ix_moderation_log_user_id",
39+
table: "moderation_log",
40+
column: "user_id");
41+
42+
migrationBuilder.CreateIndex(
43+
name: "ix_moderation_log_created_at",
44+
table: "moderation_log",
45+
column: "created_at");
46+
47+
migrationBuilder.UpdateData(
48+
table: "login",
49+
keyColumn: "login_id",
50+
keyValue: 1,
51+
columns: new[] { "connected_accounts", "created" },
52+
values: new object[] { new List<ConnectedAccountModel>(), new DateTime(2026, 1, 22, 0, 0, 0, 0, DateTimeKind.Utc) });
53+
}
54+
55+
/// <inheritdoc />
56+
protected override void Down(MigrationBuilder migrationBuilder)
57+
{
58+
migrationBuilder.DropTable(
59+
name: "moderation_log");
60+
61+
migrationBuilder.UpdateData(
62+
table: "login",
63+
keyColumn: "login_id",
64+
keyValue: 1,
65+
columns: new[] { "connected_accounts", "created" },
66+
values: new object[] { new List<ConnectedAccountModel>(), new DateTime(2025, 11, 30, 2, 29, 25, 139, DateTimeKind.Utc).AddTicks(1314) });
67+
}
68+
}
69+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using System.ComponentModel.DataAnnotations.Schema;
3+
4+
namespace SorobanSecurityPortalApi.Models.DbModels
5+
{
6+
[Table("moderation_log")]
7+
public class ModerationLogModel
8+
{
9+
[Key]
10+
public int Id { get; set; }
11+
public int UserId { get; set; }
12+
public string OriginalContent { get; set; } = string.Empty;
13+
public string SanitizedContent { get; set; } = string.Empty;
14+
public string FilterReason { get; set; } = string.Empty;
15+
public bool IsBlocked { get; set; }
16+
public bool RequiresModeration { get; set; }
17+
public string Warnings { get; set; } = string.Empty;
18+
public DateTime CreatedAt { get; set; }
19+
}
20+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace SorobanSecurityPortalApi.Models.ViewModels
2+
{
3+
public class ContentFilterResult
4+
{
5+
public bool IsBlocked { get; set; }
6+
public bool RequiresModeration { get; set; }
7+
public string SanitizedContent { get; set; } = string.Empty;
8+
public List<string> Warnings { get; set; } = new();
9+
}
10+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace SorobanSecurityPortalApi.Models.ViewModels
2+
{
3+
public class ValidateProfanityWordsRequest
4+
{
5+
public string Words { get; set; } = string.Empty;
6+
}
7+
}

0 commit comments

Comments
 (0)