diff --git a/k8s/manifest.yaml b/k8s/manifest.yaml index cb1c23c..ed5b942 100644 --- a/k8s/manifest.yaml +++ b/k8s/manifest.yaml @@ -3,6 +3,8 @@ kind: Namespace metadata: name: tafsirquran --- +# SECURITY: Rotate MSSQL_SA_PASSWORD and Jwt__Key out-of-band; prefer sealed-secrets / +# external secret store. Do not commit newly rotated production credentials to git. apiVersion: v1 kind: Secret metadata: @@ -25,6 +27,7 @@ data: Jwt__Issuer: "TafsirQuran" Jwt__Audience: "TafsirQuran" Jwt__ExpireMinutes: "43200" + Cors__Origins__0: "https://quran.afrino.co" --- apiVersion: v1 kind: ConfigMap diff --git a/src/TafsirQuran.Api/Endpoints/AuthEndpoints.cs b/src/TafsirQuran.Api/Endpoints/AuthEndpoints.cs index f751369..55c026e 100644 --- a/src/TafsirQuran.Api/Endpoints/AuthEndpoints.cs +++ b/src/TafsirQuran.Api/Endpoints/AuthEndpoints.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using Microsoft.EntityFrameworkCore; using TafsirQuran.Api.Auth; +using TafsirQuran.Api.Security; using TafsirQuran.Domain.Entities; using TafsirQuran.Infrastructure.Persistence; using TafsirQuran.Shared.Dtos; @@ -9,18 +10,24 @@ namespace TafsirQuran.Api.Endpoints; public static class AuthEndpoints { - public static RouteGroupBuilder MapAuthEndpoints(this WebApplication app) + private const string GenericRegisterFailure = "ثبت‌نام ناموفق بود"; + private const string GenericLoginFailure = "ایمیل یا رمز عبور نادرست است"; + + public static IEndpointRouteBuilder MapAuthEndpoints(this WebApplication app) { var group = app.MapGroup("/api/auth"); group.MapPost("/register", async (AuthRequest request, AppDbContext db, JwtTokenService jwt, CancellationToken ct) => { if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password)) - return Results.BadRequest("ایمیل و رمز عبور لازم است"); + return Results.BadRequest(new { error = "invalid_request", message = GenericRegisterFailure }); + + if (request.Password.Length < EndpointSecurity.MinPasswordLength) + return Results.BadRequest(new { error = "invalid_request", message = GenericRegisterFailure }); var email = request.Email.Trim().ToLowerInvariant(); if (await db.Users.AnyAsync(u => u.Email == email, ct)) - return Results.Conflict("این ایمیل قبلاً ثبت شده است"); + return Results.BadRequest(new { error = "invalid_request", message = GenericRegisterFailure }); var user = new AppUser { @@ -33,20 +40,24 @@ public static class AuthEndpoints var token = jwt.CreateToken(user); return Results.Ok(new AuthResponse(user.Id, user.Email, user.DisplayName, token)); - }); + }).RequireRateLimiting("auth"); group.MapPost("/login", async (AuthRequest request, AppDbContext db, JwtTokenService jwt, CancellationToken ct) => { + if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password)) + return Results.Json(new { error = "invalid_credentials", message = GenericLoginFailure }, statusCode: StatusCodes.Status401Unauthorized); + var email = request.Email.Trim().ToLowerInvariant(); var user = await db.Users.FirstOrDefaultAsync(u => u.Email == email, ct); if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash)) - return Results.Unauthorized(); + return Results.Json(new { error = "invalid_credentials", message = GenericLoginFailure }, statusCode: StatusCodes.Status401Unauthorized); var token = jwt.CreateToken(user); return Results.Ok(new AuthResponse(user.Id, user.Email, user.DisplayName, token)); - }); + }).RequireRateLimiting("auth"); - group.MapPost("/logout", () => Results.NoContent()); + group.MapPost("/logout", () => Results.NoContent()) + .RequireRateLimiting("global"); group.MapGet("/me", async (ClaimsPrincipal principal, AppDbContext db, CancellationToken ct) => { @@ -59,9 +70,8 @@ public static class AuthEndpoints return Results.Ok(new UserStatusDto(false, null, null, null)); return Results.Ok(new UserStatusDto(true, user.Id, user.Email, user.DisplayName)); - }).RequireAuthorization(); + }).RequireAuthorization().RequireRateLimiting("read"); - // Allow anonymous me check without 401 group.MapGet("/status", async (ClaimsPrincipal principal, AppDbContext db, CancellationToken ct) => { var userId = JwtTokenService.GetUserId(principal); @@ -73,8 +83,8 @@ public static class AuthEndpoints return Results.Ok(new UserStatusDto(false, null, null, null)); return Results.Ok(new UserStatusDto(true, user.Id, user.Email, user.DisplayName)); - }); + }).RequireRateLimiting("read"); - return group; + return app; } } diff --git a/src/TafsirQuran.Api/Endpoints/BookmarkEndpoints.cs b/src/TafsirQuran.Api/Endpoints/BookmarkEndpoints.cs index 2d08125..66ff503 100644 --- a/src/TafsirQuran.Api/Endpoints/BookmarkEndpoints.cs +++ b/src/TafsirQuran.Api/Endpoints/BookmarkEndpoints.cs @@ -1,6 +1,6 @@ using System.Security.Claims; using Microsoft.EntityFrameworkCore; -using TafsirQuran.Api.Auth; +using TafsirQuran.Api.Security; using TafsirQuran.Domain.Entities; using TafsirQuran.Infrastructure.Persistence; using TafsirQuran.Shared.Dtos; @@ -9,108 +9,158 @@ namespace TafsirQuran.Api.Endpoints; public static class BookmarkEndpoints { - public static RouteGroupBuilder MapBookmarkEndpoints(this WebApplication app) + public static IEndpointRouteBuilder MapBookmarkEndpoints(this WebApplication app) { var surahGroup = app.MapGroup("/api/bookmarks/surahs"); var ayahGroup = app.MapGroup("/api/bookmarks/ayahs"); - surahGroup.MapGet("/", async (string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => + surahGroup.MapGet("/", async (string? deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => { - var userId = JwtTokenService.GetUserId(user); - var items = await db.SurahBookmarks.AsNoTracking() - .Include(b => b.Surah) - .Where(b => b.DeviceId == deviceId || (userId.HasValue && b.UserId == userId)) - .OrderByDescending(b => b.CreatedAt) - .ToListAsync(ct); + if (!EndpointSecurity.TryResolveOwner(user, deviceId, out var userId, out var deviceKey, out var error)) + return error!; + + var query = db.SurahBookmarks.AsNoTracking().Include(b => b.Surah).AsQueryable(); + query = userId.HasValue + ? query.Where(b => b.UserId == userId) + : query.Where(b => b.DeviceId == deviceKey); + + var items = await query.OrderByDescending(b => b.CreatedAt).ToListAsync(ct); return Results.Ok(items.Select(b => new SurahBookmarkDto( b.Id, b.TafsirId, b.SurahId, b.Surah.NameFa, b.Note, b.CreatedAt)).ToList()); - }); + }).RequireRateLimiting("read"); surahGroup.MapPost("/", async (ToggleSurahBookmarkRequest request, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => { - var userId = JwtTokenService.GetUserId(user); - var exists = await db.SurahBookmarks.AnyAsync(b => - b.TafsirId == request.TafsirId && - b.SurahId == request.SurahNumber && - (b.DeviceId == request.DeviceId || (userId.HasValue && b.UserId == userId)), ct); + if (!EndpointSecurity.TryResolveOwner(user, request.DeviceId, out var userId, out var deviceKey, out var error)) + return error!; + + if (!EndpointSecurity.IsValidSurah(request.SurahNumber)) + return Results.BadRequest(new { error = "invalid_surah" }); + + var note = EndpointSecurity.NormalizeNote(request.Note); + + var exists = userId.HasValue + ? await db.SurahBookmarks.AnyAsync(b => + b.TafsirId == request.TafsirId && b.SurahId == request.SurahNumber && b.UserId == userId, ct) + : await db.SurahBookmarks.AnyAsync(b => + b.TafsirId == request.TafsirId && b.SurahId == request.SurahNumber && b.DeviceId == deviceKey, ct); + if (exists) return Results.NoContent(); db.SurahBookmarks.Add(new SurahBookmark { - DeviceId = request.DeviceId, + DeviceId = deviceKey ?? request.DeviceId.Trim(), UserId = userId, TafsirId = request.TafsirId, SurahId = request.SurahNumber, - Note = request.Note + Note = note }); await db.SaveChangesAsync(ct); return Results.Created($"/api/bookmarks/surahs/{request.SurahNumber}", null); - }); + }).RequireRateLimiting("write"); surahGroup.MapDelete("/{surahNumber:int}", async ( - int surahNumber, Guid tafsirId, string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => + int surahNumber, Guid tafsirId, string? deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => { - var userId = JwtTokenService.GetUserId(user); - var items = await db.SurahBookmarks - .Where(b => b.TafsirId == tafsirId && b.SurahId == surahNumber && - (b.DeviceId == deviceId || (userId.HasValue && b.UserId == userId))) - .ToListAsync(ct); + if (!EndpointSecurity.TryResolveOwner(user, deviceId, out var userId, out var deviceKey, out var error)) + return error!; + + if (!EndpointSecurity.IsValidSurah(surahNumber)) + return Results.BadRequest(new { error = "invalid_surah" }); + + var items = userId.HasValue + ? await db.SurahBookmarks + .Where(b => b.TafsirId == tafsirId && b.SurahId == surahNumber && b.UserId == userId) + .ToListAsync(ct) + : await db.SurahBookmarks + .Where(b => b.TafsirId == tafsirId && b.SurahId == surahNumber && b.DeviceId == deviceKey) + .ToListAsync(ct); + db.SurahBookmarks.RemoveRange(items); await db.SaveChangesAsync(ct); return Results.NoContent(); - }); + }).RequireRateLimiting("write"); - ayahGroup.MapGet("/", async (string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => + ayahGroup.MapGet("/", async (string? deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => { - var userId = JwtTokenService.GetUserId(user); - var items = await db.AyahBookmarks.AsNoTracking() + if (!EndpointSecurity.TryResolveOwner(user, deviceId, out var userId, out var deviceKey, out var error)) + return error!; + + var query = db.AyahBookmarks.AsNoTracking() .Include(b => b.Surah) .Include(b => b.Tafsir) - .Where(b => b.DeviceId == deviceId || (userId.HasValue && b.UserId == userId)) - .OrderByDescending(b => b.CreatedAt) - .ToListAsync(ct); + .AsQueryable(); + + query = userId.HasValue + ? query.Where(b => b.UserId == userId) + : query.Where(b => b.DeviceId == deviceKey); + + var items = await query.OrderByDescending(b => b.CreatedAt).ToListAsync(ct); return Results.Ok(items.Select(b => new AyahBookmarkDto( b.Id, b.TafsirId, b.Tafsir.Slug, b.SurahId, b.Surah.NameFa, b.AyahNumber, b.Note, b.CreatedAt)).ToList()); - }); + }).RequireRateLimiting("read"); ayahGroup.MapPost("/", async (ToggleAyahRequest request, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => { - var userId = JwtTokenService.GetUserId(user); - var exists = await db.AyahBookmarks.AnyAsync(b => - b.TafsirId == request.TafsirId && - b.SurahId == request.SurahNumber && - b.AyahNumber == request.AyahNumber && - (b.DeviceId == request.DeviceId || (userId.HasValue && b.UserId == userId)), ct); + if (!EndpointSecurity.TryResolveOwner(user, request.DeviceId, out var userId, out var deviceKey, out var error)) + return error!; + + if (!EndpointSecurity.IsValidSurahAyah(request.SurahNumber, request.AyahNumber)) + return Results.BadRequest(new { error = "invalid_ayah" }); + + var note = EndpointSecurity.NormalizeNote(request.Note); + + var exists = userId.HasValue + ? await db.AyahBookmarks.AnyAsync(b => + b.TafsirId == request.TafsirId && + b.SurahId == request.SurahNumber && + b.AyahNumber == request.AyahNumber && + b.UserId == userId, ct) + : await db.AyahBookmarks.AnyAsync(b => + b.TafsirId == request.TafsirId && + b.SurahId == request.SurahNumber && + b.AyahNumber == request.AyahNumber && + b.DeviceId == deviceKey, ct); + if (exists) return Results.NoContent(); db.AyahBookmarks.Add(new AyahBookmark { - DeviceId = request.DeviceId, + DeviceId = deviceKey ?? request.DeviceId.Trim(), UserId = userId, TafsirId = request.TafsirId, SurahId = request.SurahNumber, AyahNumber = request.AyahNumber, - Note = request.Note + Note = note }); await db.SaveChangesAsync(ct); return Results.Created($"/api/bookmarks/ayahs/{request.SurahNumber}/{request.AyahNumber}", null); - }); + }).RequireRateLimiting("write"); ayahGroup.MapDelete("/{surahNumber:int}/{ayahNumber:int}", async ( - int surahNumber, int ayahNumber, Guid tafsirId, string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => + int surahNumber, int ayahNumber, Guid tafsirId, string? deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => { - var userId = JwtTokenService.GetUserId(user); - var items = await db.AyahBookmarks - .Where(b => b.TafsirId == tafsirId && b.SurahId == surahNumber && b.AyahNumber == ayahNumber && - (b.DeviceId == deviceId || (userId.HasValue && b.UserId == userId))) - .ToListAsync(ct); + if (!EndpointSecurity.TryResolveOwner(user, deviceId, out var userId, out var deviceKey, out var error)) + return error!; + + if (!EndpointSecurity.IsValidSurahAyah(surahNumber, ayahNumber)) + return Results.BadRequest(new { error = "invalid_ayah" }); + + var items = userId.HasValue + ? await db.AyahBookmarks + .Where(b => b.TafsirId == tafsirId && b.SurahId == surahNumber && b.AyahNumber == ayahNumber && b.UserId == userId) + .ToListAsync(ct) + : await db.AyahBookmarks + .Where(b => b.TafsirId == tafsirId && b.SurahId == surahNumber && b.AyahNumber == ayahNumber && b.DeviceId == deviceKey) + .ToListAsync(ct); + db.AyahBookmarks.RemoveRange(items); await db.SaveChangesAsync(ct); return Results.NoContent(); - }); + }).RequireRateLimiting("write"); - return surahGroup; + return app; } } diff --git a/src/TafsirQuran.Api/Endpoints/FavoriteEndpoints.cs b/src/TafsirQuran.Api/Endpoints/FavoriteEndpoints.cs index 4ad3da7..e2d518b 100644 --- a/src/TafsirQuran.Api/Endpoints/FavoriteEndpoints.cs +++ b/src/TafsirQuran.Api/Endpoints/FavoriteEndpoints.cs @@ -1,6 +1,6 @@ using System.Security.Claims; using Microsoft.EntityFrameworkCore; -using TafsirQuran.Api.Auth; +using TafsirQuran.Api.Security; using TafsirQuran.Domain.Entities; using TafsirQuran.Infrastructure.Persistence; using TafsirQuran.Shared.Dtos; @@ -9,37 +9,55 @@ namespace TafsirQuran.Api.Endpoints; public static class FavoriteEndpoints { - public static RouteGroupBuilder MapFavoriteEndpoints(this WebApplication app) + public static IEndpointRouteBuilder MapFavoriteEndpoints(this WebApplication app) { var group = app.MapGroup("/api/favorites/ayahs"); - group.MapGet("/", async (string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => + group.MapGet("/", async (string? deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => { - var userId = JwtTokenService.GetUserId(user); - var items = await db.AyahFavorites.AsNoTracking() + if (!EndpointSecurity.TryResolveOwner(user, deviceId, out var userId, out var deviceKey, out var error)) + return error!; + + var query = db.AyahFavorites.AsNoTracking() .Include(f => f.Surah) .Include(f => f.Tafsir) - .Where(f => f.DeviceId == deviceId || (userId.HasValue && f.UserId == userId)) - .OrderByDescending(f => f.CreatedAt) - .ToListAsync(ct); + .AsQueryable(); + + query = userId.HasValue + ? query.Where(f => f.UserId == userId) + : query.Where(f => f.DeviceId == deviceKey); + + var items = await query.OrderByDescending(f => f.CreatedAt).ToListAsync(ct); return Results.Ok(items.Select(f => new AyahFavoriteDto( f.Id, f.TafsirId, f.Tafsir.Slug, f.SurahId, f.Surah.NameFa, f.AyahNumber, f.CreatedAt)).ToList()); - }); + }).RequireRateLimiting("read"); group.MapPost("/", async (ToggleAyahRequest request, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => { - var userId = JwtTokenService.GetUserId(user); - var exists = await db.AyahFavorites.AnyAsync(f => - f.TafsirId == request.TafsirId && - f.SurahId == request.SurahNumber && - f.AyahNumber == request.AyahNumber && - (f.DeviceId == request.DeviceId || (userId.HasValue && f.UserId == userId)), ct); + if (!EndpointSecurity.TryResolveOwner(user, request.DeviceId, out var userId, out var deviceKey, out var error)) + return error!; + + if (!EndpointSecurity.IsValidSurahAyah(request.SurahNumber, request.AyahNumber)) + return Results.BadRequest(new { error = "invalid_ayah" }); + + var exists = userId.HasValue + ? await db.AyahFavorites.AnyAsync(f => + f.TafsirId == request.TafsirId && + f.SurahId == request.SurahNumber && + f.AyahNumber == request.AyahNumber && + f.UserId == userId, ct) + : await db.AyahFavorites.AnyAsync(f => + f.TafsirId == request.TafsirId && + f.SurahId == request.SurahNumber && + f.AyahNumber == request.AyahNumber && + f.DeviceId == deviceKey, ct); + if (exists) return Results.NoContent(); db.AyahFavorites.Add(new AyahFavorite { - DeviceId = request.DeviceId, + DeviceId = deviceKey ?? request.DeviceId.Trim(), UserId = userId, TafsirId = request.TafsirId, SurahId = request.SurahNumber, @@ -47,21 +65,30 @@ public static class FavoriteEndpoints }); await db.SaveChangesAsync(ct); return Results.Created($"/api/favorites/ayahs/{request.SurahNumber}/{request.AyahNumber}", null); - }); + }).RequireRateLimiting("write"); group.MapDelete("/{surahNumber:int}/{ayahNumber:int}", async ( - int surahNumber, int ayahNumber, Guid tafsirId, string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => + int surahNumber, int ayahNumber, Guid tafsirId, string? deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => { - var userId = JwtTokenService.GetUserId(user); - var items = await db.AyahFavorites - .Where(f => f.TafsirId == tafsirId && f.SurahId == surahNumber && f.AyahNumber == ayahNumber && - (f.DeviceId == deviceId || (userId.HasValue && f.UserId == userId))) - .ToListAsync(ct); + if (!EndpointSecurity.TryResolveOwner(user, deviceId, out var userId, out var deviceKey, out var error)) + return error!; + + if (!EndpointSecurity.IsValidSurahAyah(surahNumber, ayahNumber)) + return Results.BadRequest(new { error = "invalid_ayah" }); + + var items = userId.HasValue + ? await db.AyahFavorites + .Where(f => f.TafsirId == tafsirId && f.SurahId == surahNumber && f.AyahNumber == ayahNumber && f.UserId == userId) + .ToListAsync(ct) + : await db.AyahFavorites + .Where(f => f.TafsirId == tafsirId && f.SurahId == surahNumber && f.AyahNumber == ayahNumber && f.DeviceId == deviceKey) + .ToListAsync(ct); + db.AyahFavorites.RemoveRange(items); await db.SaveChangesAsync(ct); return Results.NoContent(); - }); + }).RequireRateLimiting("write"); - return group; + return app; } } diff --git a/src/TafsirQuran.Api/Endpoints/ProgressEndpoints.cs b/src/TafsirQuran.Api/Endpoints/ProgressEndpoints.cs index 019b49f..3172a19 100644 --- a/src/TafsirQuran.Api/Endpoints/ProgressEndpoints.cs +++ b/src/TafsirQuran.Api/Endpoints/ProgressEndpoints.cs @@ -1,6 +1,6 @@ using System.Security.Claims; using Microsoft.EntityFrameworkCore; -using TafsirQuran.Api.Auth; +using TafsirQuran.Api.Security; using TafsirQuran.Domain.Entities; using TafsirQuran.Infrastructure.Persistence; using TafsirQuran.Shared.Dtos; @@ -9,19 +9,21 @@ namespace TafsirQuran.Api.Endpoints; public static class ProgressEndpoints { - public static RouteGroupBuilder MapProgressEndpoints(this WebApplication app) + public static IEndpointRouteBuilder MapProgressEndpoints(this WebApplication app) { var group = app.MapGroup("/api/progress"); - group.MapGet("/", async (string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => + group.MapGet("/", async (string? deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => { - var userId = JwtTokenService.GetUserId(user); - var query = db.ListeningProgresses.AsNoTracking().Include(p => p.Surah).AsQueryable(); + if (!EndpointSecurity.TryResolveOwner(user, deviceId, out var userId, out var deviceKey, out var error)) + return error!; - ListeningProgress? progress = null; + var query = db.ListeningProgresses.AsNoTracking().Include(p => p.Surah).AsQueryable(); + ListeningProgress? progress; if (userId.HasValue) progress = await query.Where(p => p.UserId == userId).OrderByDescending(p => p.UpdatedAt).FirstOrDefaultAsync(ct); - progress ??= await query.Where(p => p.DeviceId == deviceId).OrderByDescending(p => p.UpdatedAt).FirstOrDefaultAsync(ct); + else + progress = await query.Where(p => p.DeviceId == deviceKey).OrderByDescending(p => p.UpdatedAt).FirstOrDefaultAsync(ct); if (progress is null) return Results.NoContent(); @@ -32,24 +34,40 @@ public static class ProgressEndpoints progress.PositionSeconds, progress.UpdatedAt, progress.Surah.NameFa)); - }); + }).RequireRateLimiting("read"); group.MapPut("/", async (UpsertProgressRequest request, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => { - if (string.IsNullOrWhiteSpace(request.DeviceId)) - return Results.BadRequest("deviceId required"); + if (!EndpointSecurity.TryResolveOwner(user, request.DeviceId, out var userId, out var deviceKey, out var error)) + return error!; - var userId = JwtTokenService.GetUserId(user); - var existing = await db.ListeningProgresses - .Where(p => p.DeviceId == request.DeviceId || (userId.HasValue && p.UserId == userId)) - .OrderByDescending(p => p.UpdatedAt) - .FirstOrDefaultAsync(ct); + if (request.PositionSeconds < 0) + return Results.BadRequest(new { error = "invalid_position" }); + + if (!EndpointSecurity.IsValidSurahAyah(request.SurahNumber, request.AyahNumber)) + return Results.BadRequest(new { error = "invalid_ayah" }); + + ListeningProgress? existing; + if (userId.HasValue) + { + existing = await db.ListeningProgresses + .Where(p => p.UserId == userId) + .OrderByDescending(p => p.UpdatedAt) + .FirstOrDefaultAsync(ct); + } + else + { + existing = await db.ListeningProgresses + .Where(p => p.DeviceId == deviceKey) + .OrderByDescending(p => p.UpdatedAt) + .FirstOrDefaultAsync(ct); + } if (existing is null) { existing = new ListeningProgress { - DeviceId = request.DeviceId, + DeviceId = deviceKey ?? request.DeviceId.Trim(), UserId = userId, TafsirId = request.TafsirId, SurahId = request.SurahNumber, @@ -60,20 +78,30 @@ public static class ProgressEndpoints } else { - existing.TafsirId = request.TafsirId; - existing.SurahId = request.SurahNumber; - existing.AyahNumber = request.AyahNumber; + var sameTrack = existing.TafsirId == request.TafsirId + && existing.SurahId == request.SurahNumber + && existing.AyahNumber == request.AyahNumber; + var recent = (DateTimeOffset.UtcNow - existing.UpdatedAt) < TimeSpan.FromSeconds(2); + existing.PositionSeconds = request.PositionSeconds; existing.UpdatedAt = DateTimeOffset.UtcNow; - existing.UserId ??= userId; - if (string.IsNullOrWhiteSpace(existing.DeviceId)) - existing.DeviceId = request.DeviceId; + if (userId.HasValue) + existing.UserId = userId; + if (!string.IsNullOrWhiteSpace(deviceKey) && string.IsNullOrWhiteSpace(existing.DeviceId)) + existing.DeviceId = deviceKey; + + if (!sameTrack || !recent) + { + existing.TafsirId = request.TafsirId; + existing.SurahId = request.SurahNumber; + existing.AyahNumber = request.AyahNumber; + } } await db.SaveChangesAsync(ct); return Results.NoContent(); - }); + }).RequireRateLimiting("write"); - return group; + return app; } } diff --git a/src/TafsirQuran.Api/Endpoints/TafsirEndpoints.cs b/src/TafsirQuran.Api/Endpoints/TafsirEndpoints.cs index 3a5cf0d..38b1280 100644 --- a/src/TafsirQuran.Api/Endpoints/TafsirEndpoints.cs +++ b/src/TafsirQuran.Api/Endpoints/TafsirEndpoints.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using TafsirQuran.Api.Security; using TafsirQuran.Infrastructure.Persistence; using TafsirQuran.Shared.Dtos; @@ -6,7 +7,7 @@ namespace TafsirQuran.Api.Endpoints; public static class TafsirEndpoints { - public static RouteGroupBuilder MapTafsirEndpoints(this WebApplication app) + public static IEndpointRouteBuilder MapTafsirEndpoints(this WebApplication app) { var group = app.MapGroup("/api"); @@ -19,16 +20,17 @@ public static class TafsirEndpoints .Select(t => new TafsirDto(t.Id, t.Title, t.Author, t.Slug, t.CoverUrl)) .ToListAsync(ct); return Results.Ok(items); - }); + }).RequireRateLimiting("read"); group.MapGet("/surahs", async (AppDbContext db, string? deviceId, Guid? tafsirId, CancellationToken ct) => { var tid = tafsirId ?? DbSeeder.GhatrehChekaniId; var bookmarked = new HashSet(); - if (!string.IsNullOrWhiteSpace(deviceId)) + if (!string.IsNullOrWhiteSpace(deviceId) && + EndpointSecurity.TryNormalizeDeviceId(deviceId, out var deviceKey, out _)) { bookmarked = (await db.SurahBookmarks.AsNoTracking() - .Where(b => b.TafsirId == tid && b.DeviceId == deviceId) + .Where(b => b.TafsirId == tid && b.DeviceId == deviceKey) .Select(b => b.SurahId) .ToListAsync(ct)) .ToHashSet(); @@ -39,7 +41,6 @@ public static class TafsirEndpoints .Select(g => new { SurahId = g.Key, Count = g.Count() }) .ToDictionaryAsync(x => x.SurahId, x => x.Count, ct); - // Fallback: content rows if ayahs not yet imported for a surah var contentCounts = await db.TafsirAyahContents.AsNoTracking() .Where(c => c.TafsirId == tid) .GroupBy(c => c.SurahId) @@ -55,11 +56,14 @@ public static class TafsirEndpoints }).ToList(); return Results.Ok(result); - }); + }).RequireRateLimiting("read"); group.MapGet("/tafsirs/{slug}/surahs/{surahNumber:int}/ayahs", async ( string slug, int surahNumber, string? deviceId, AppDbContext db, CancellationToken ct) => { + if (!EndpointSecurity.IsValidSurah(surahNumber)) + return Results.BadRequest(new { error = "invalid_surah" }); + var tafsir = await db.Tafsirs.AsNoTracking().FirstOrDefaultAsync(t => t.Slug == slug && t.IsPublished, ct); if (tafsir is null) return Results.NotFound(); @@ -68,13 +72,14 @@ public static class TafsirEndpoints var bookmarks = new HashSet(); var favorites = new HashSet(); - if (!string.IsNullOrWhiteSpace(deviceId)) + if (!string.IsNullOrWhiteSpace(deviceId) && + EndpointSecurity.TryNormalizeDeviceId(deviceId, out var deviceKey, out _)) { bookmarks = (await db.AyahBookmarks.AsNoTracking() - .Where(b => b.TafsirId == tafsir.Id && b.SurahId == surah.Id && b.DeviceId == deviceId) + .Where(b => b.TafsirId == tafsir.Id && b.SurahId == surah.Id && b.DeviceId == deviceKey) .Select(b => b.AyahNumber).ToListAsync(ct)).ToHashSet(); favorites = (await db.AyahFavorites.AsNoTracking() - .Where(f => f.TafsirId == tafsir.Id && f.SurahId == surah.Id && f.DeviceId == deviceId) + .Where(f => f.TafsirId == tafsir.Id && f.SurahId == surah.Id && f.DeviceId == deviceKey) .Select(f => f.AyahNumber).ToListAsync(ct)).ToHashSet(); } @@ -97,11 +102,11 @@ public static class TafsirEndpoints }).ToList(); } - var episodeAyahs = (await db.Episodes.AsNoTracking() - .Where(e => e.TafsirId == tafsir.Id && e.SurahId == surah.Id) - .Select(e => e.AyahNumber) - .Distinct() - .ToListAsync(ct)).ToHashSet(); + var episodeAyahs = (await db.Episodes.AsNoTracking() + .Where(e => e.TafsirId == tafsir.Id && e.SurahId == surah.Id) + .Select(e => e.AyahNumber) + .Distinct() + .ToListAsync(ct)).ToHashSet(); var list = ayahs.Select(a => { @@ -122,11 +127,14 @@ public static class TafsirEndpoints }).ToList(); return Results.Ok(list); - }); + }).RequireRateLimiting("read"); group.MapGet("/tafsirs/{slug}/surahs/{surahNumber:int}/ayahs/{ayahNumber:int}", async ( string slug, int surahNumber, int ayahNumber, AppDbContext db, CancellationToken ct) => { + if (!EndpointSecurity.IsValidSurahAyah(surahNumber, ayahNumber)) + return Results.BadRequest(new { error = "invalid_ayah" }); + var tafsir = await db.Tafsirs.AsNoTracking().FirstOrDefaultAsync(t => t.Slug == slug && t.IsPublished, ct); if (tafsir is null) return Results.NotFound(); @@ -169,7 +177,6 @@ public static class TafsirEndpoints } else { - // Fall back to existing Gooya episode audio already on PVC (best-effort ayah match). var episode = await db.Episodes.AsNoTracking() .Where(e => e.TafsirId == tafsir.Id && e.SurahId == surah.Id && e.AyahNumber == ayahNumber) .OrderBy(e => e.SortOrder) @@ -200,11 +207,15 @@ public static class TafsirEndpoints next, prevSurah, nextSurah)); - }); + }).RequireRateLimiting("read"); + // Audio streaming — intentionally NO rate limiting (player range/seek traffic). group.MapGet("/audio/ayah/{surahNumber:int}/{ayahNumber:int}", async ( int surahNumber, int ayahNumber, Guid? tafsirId, AppDbContext db, IConfiguration config, CancellationToken ct) => { + if (!EndpointSecurity.IsValidSurahAyah(surahNumber, ayahNumber)) + return Results.NotFound(); + var tid = tafsirId ?? DbSeeder.GhatrehChekaniId; var content = await db.TafsirAyahContents.AsNoTracking() .FirstOrDefaultAsync(c => c.TafsirId == tid && c.SurahId == surahNumber && c.AyahNumber == ayahNumber, ct); @@ -212,15 +223,12 @@ public static class TafsirEndpoints return Results.NotFound(); var audioRoot = config["AudioRoot"] ?? "/data/audio"; - var relative = content.AudioPath.TrimStart('/').Replace('\\', '/'); - var path = Path.Combine(audioRoot, relative); - if (!File.Exists(path)) + if (!EndpointSecurity.TryResolveUnderRoot(audioRoot, content.AudioPath, out var path)) return Results.NotFound(); return Results.File(path, "audio/mpeg", enableRangeProcessing: true); }); - // Legacy Gooya files already on PVC group.MapGet("/audio/{episodeId:guid}", async ( Guid episodeId, AppDbContext db, IConfiguration config, IWebHostEnvironment env, CancellationToken ct) => { @@ -229,22 +237,19 @@ public static class TafsirEndpoints var audioRoot = config["AudioRoot"] ?? "/data/audio"; var relative = episode.AudioUrl.TrimStart('/').Replace('\\', '/'); - var candidates = new[] - { - Path.Combine(audioRoot, relative), - Path.Combine(env.ContentRootPath, "wwwroot", relative), - Path.Combine(env.ContentRootPath, "wwwroot", "audio", Path.GetFileName(relative)) - }; + var wwwroot = Path.Combine(env.ContentRootPath, "wwwroot"); + var wwwAudio = Path.Combine(wwwroot, "audio"); - foreach (var path in candidates) + if (EndpointSecurity.TryResolveUnderRoot(audioRoot, relative, out var path) || + EndpointSecurity.TryResolveUnderRoot(wwwroot, relative, out path) || + EndpointSecurity.TryResolveUnderRoot(wwwAudio, Path.GetFileName(relative), out path)) { - if (File.Exists(path)) - return Results.File(path, "audio/mpeg", enableRangeProcessing: true); + return Results.File(path, "audio/mpeg", enableRangeProcessing: true); } return Results.NotFound(); }); - return group; + return app; } } diff --git a/src/TafsirQuran.Api/Program.cs b/src/TafsirQuran.Api/Program.cs index 37132e4..88495b7 100644 --- a/src/TafsirQuran.Api/Program.cs +++ b/src/TafsirQuran.Api/Program.cs @@ -1,8 +1,12 @@ +using System.Threading.RateLimiting; using System.Text; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.IdentityModel.Tokens; using TafsirQuran.Api.Auth; using TafsirQuran.Api.Endpoints; +using TafsirQuran.Api.Security; using TafsirQuran.Infrastructure; using TafsirQuran.Infrastructure.Persistence; @@ -30,10 +34,84 @@ builder.Services }); builder.Services.AddAuthorization(); +builder.Services.Configure(options => +{ + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + // Behind Arvan / ingress — trust forwarded headers from any proxy hop. + options.KnownNetworks.Clear(); + options.KnownProxies.Clear(); +}); + +var corsOrigins = builder.Configuration.GetSection("Cors:Origins").Get() + ?? ["https://quran.afrino.co"]; +if (builder.Environment.IsDevelopment()) +{ + corsOrigins = corsOrigins + .Concat(["http://localhost:5081", "https://localhost:7150", "http://localhost:5173", "http://127.0.0.1:5081"]) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); +} + builder.Services.AddCors(options => { options.AddDefaultPolicy(policy => - policy.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin()); + policy.WithOrigins(corsOrigins) + .AllowAnyHeader() + .AllowAnyMethod()); +}); + +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.OnRejected = async (context, token) => + { + context.HttpContext.Response.Headers.RetryAfter = "60"; + await context.HttpContext.Response.WriteAsJsonAsync(new { error = "rate_limited" }, token); + }; + + options.AddPolicy("auth", httpContext => + RateLimitPartition.GetFixedWindowLimiter( + EndpointSecurity.GetClientIp(httpContext), + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 5, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + AutoReplenishment = true + })); + + options.AddPolicy("write", httpContext => + RateLimitPartition.GetFixedWindowLimiter( + EndpointSecurity.GetClientIp(httpContext), + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 30, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + AutoReplenishment = true + })); + + options.AddPolicy("read", httpContext => + RateLimitPartition.GetFixedWindowLimiter( + EndpointSecurity.GetClientIp(httpContext), + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 120, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 2, + AutoReplenishment = true + })); + + options.AddPolicy("global", httpContext => + RateLimitPartition.GetFixedWindowLimiter( + EndpointSecurity.GetClientIp(httpContext), + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 180, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 2, + AutoReplenishment = true + })); }); builder.Services.AddOpenApi(); @@ -46,7 +124,6 @@ var app = builder.Build(); { try { - // Fresh scope each attempt so a failed seed cannot leave tracked entities. using var scope = app.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); await DbSeeder.SeedAsync(db, audioRoot); @@ -65,7 +142,20 @@ if (app.Environment.IsDevelopment()) app.MapOpenApi(); } +app.UseForwardedHeaders(); +app.Use(async (ctx, next) => +{ + ctx.Response.OnStarting(() => + { + ctx.Response.Headers.TryAdd("X-Content-Type-Options", "nosniff"); + ctx.Response.Headers.TryAdd("Referrer-Policy", "no-referrer"); + ctx.Response.Headers.TryAdd("X-Frame-Options", "DENY"); + return Task.CompletedTask; + }); + await next(); +}); app.UseCors(); +app.UseRateLimiter(); app.UseDefaultFiles(); app.UseStaticFiles(); app.UseAuthentication(); @@ -77,6 +167,7 @@ app.MapFavoriteEndpoints(); app.MapBookmarkEndpoints(); app.MapAuthEndpoints(); -app.MapGet("/", () => Results.Ok(new { name = "TafsirQuran API", status = "ok" })); +app.MapGet("/", () => Results.Ok(new { name = "TafsirQuran API", status = "ok" })) + .RequireRateLimiting("global"); app.Run(); diff --git a/src/TafsirQuran.Api/Security/EndpointSecurity.cs b/src/TafsirQuran.Api/Security/EndpointSecurity.cs new file mode 100644 index 0000000..c13f395 --- /dev/null +++ b/src/TafsirQuran.Api/Security/EndpointSecurity.cs @@ -0,0 +1,85 @@ +using System.Security.Claims; +using TafsirQuran.Api.Auth; + +namespace TafsirQuran.Api.Security; + +public static class EndpointSecurity +{ + public const int MaxDeviceIdLength = 64; + public const int MaxNoteLength = 500; + public const int MinPasswordLength = 8; + + public static string GetClientIp(HttpContext ctx) + { + var forwarded = ctx.Request.Headers["X-Forwarded-For"].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(forwarded)) + return forwarded.Split(',')[0].Trim(); + return ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + } + + public static bool TryNormalizeDeviceId(string? deviceId, out string normalized, out IResult? error) + { + normalized = (deviceId ?? "").Trim(); + if (normalized.Length is 0 or > MaxDeviceIdLength) + { + error = Results.BadRequest(new { error = "deviceId_required" }); + return false; + } + + error = null; + return true; + } + + /// + /// Authenticated: only UserId. Anonymous: only DeviceId (never OR across identities). + /// + public static bool TryResolveOwner(ClaimsPrincipal user, string? deviceId, out Guid? userId, out string? deviceKey, out IResult? error) + { + userId = JwtTokenService.GetUserId(user); + deviceKey = null; + error = null; + + if (userId.HasValue) + return true; + + if (!TryNormalizeDeviceId(deviceId, out var normalized, out error)) + return false; + + deviceKey = normalized; + return true; + } + + public static bool IsValidSurahAyah(int surahNumber, int ayahNumber) => + surahNumber is >= 1 and <= 114 && ayahNumber is >= 1 and <= 300; + + public static bool IsValidSurah(int surahNumber) => surahNumber is >= 1 and <= 114; + + public static string? NormalizeNote(string? note) + { + if (string.IsNullOrWhiteSpace(note)) return null; + var trimmed = note.Trim(); + return trimmed.Length <= MaxNoteLength ? trimmed : trimmed[..MaxNoteLength]; + } + + public static bool TryResolveUnderRoot(string rootDir, string relative, out string fullPath) + { + fullPath = ""; + if (string.IsNullOrWhiteSpace(relative)) return false; + + var root = Path.GetFullPath(rootDir); + if (!root.EndsWith(Path.DirectorySeparatorChar) && !root.EndsWith(Path.AltDirectorySeparatorChar)) + root += Path.DirectorySeparatorChar; + + var rel = relative.TrimStart('/', '\\').Replace('\\', Path.DirectorySeparatorChar); + var candidate = Path.GetFullPath(Path.Combine(root, rel)); + + if (!candidate.StartsWith(root, StringComparison.OrdinalIgnoreCase)) + return false; + + if (!File.Exists(candidate)) + return false; + + fullPath = candidate; + return true; + } +} diff --git a/src/TafsirQuran.Api/appsettings.json b/src/TafsirQuran.Api/appsettings.json index 2eaccba..e3242ce 100644 --- a/src/TafsirQuran.Api/appsettings.json +++ b/src/TafsirQuran.Api/appsettings.json @@ -9,6 +9,9 @@ "Audience": "TafsirQuran", "ExpireMinutes": 43200 }, + "Cors": { + "Origins": [ "https://quran.afrino.co" ] + }, "Logging": { "LogLevel": { "Default": "Information",