Initial TafsirQuran app with Gitea CI/CD deploy to k3s.
build-and-deploy / deploy (push) Successful in 10m50s

Blazor UI + ASP.NET API + Postgres; push to main builds images and rolls out namespace tafsirquran.
This commit is contained in:
2026-10-09 21:50:37 +03:30
commit 7f06856f45
124 changed files with 4729 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
namespace TafsirQuran.Api.Auth;
public class JwtOptions
{
public const string SectionName = "Jwt";
public string Key { get; set; } = "TafsirQuran-Dev-Secret-Key-Change-In-Production-32+";
public string Issuer { get; set; } = "TafsirQuran";
public string Audience { get; set; } = "TafsirQuran";
public int ExpireMinutes { get; set; } = 60 * 24 * 30;
}
@@ -0,0 +1,41 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using TafsirQuran.Domain.Entities;
namespace TafsirQuran.Api.Auth;
public class JwtTokenService(IOptions<JwtOptions> options)
{
private readonly JwtOptions _options = options.Value;
public string CreateToken(AppUser user)
{
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new(JwtRegisteredClaimNames.Email, user.Email),
new("display_name", user.DisplayName)
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Key));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _options.Issuer,
audience: _options.Audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(_options.ExpireMinutes),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public static Guid? GetUserId(ClaimsPrincipal user)
{
var sub = user.FindFirstValue(JwtRegisteredClaimNames.Sub)
?? user.FindFirstValue(ClaimTypes.NameIdentifier);
return Guid.TryParse(sub, out var id) ? id : null;
}
}
@@ -0,0 +1,80 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using TafsirQuran.Api.Auth;
using TafsirQuran.Domain.Entities;
using TafsirQuran.Infrastructure.Persistence;
using TafsirQuran.Shared.Dtos;
namespace TafsirQuran.Api.Endpoints;
public static class AuthEndpoints
{
public static RouteGroupBuilder 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("ایمیل و رمز عبور لازم است");
var email = request.Email.Trim().ToLowerInvariant();
if (await db.Users.AnyAsync(u => u.Email == email, ct))
return Results.Conflict("این ایمیل قبلاً ثبت شده است");
var user = new AppUser
{
Email = email,
DisplayName = string.IsNullOrWhiteSpace(request.DisplayName) ? email.Split('@')[0] : request.DisplayName!.Trim(),
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password)
};
db.Users.Add(user);
await db.SaveChangesAsync(ct);
var token = jwt.CreateToken(user);
return Results.Ok(new AuthResponse(user.Id, user.Email, user.DisplayName, token));
});
group.MapPost("/login", async (AuthRequest request, AppDbContext db, JwtTokenService jwt, CancellationToken ct) =>
{
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();
var token = jwt.CreateToken(user);
return Results.Ok(new AuthResponse(user.Id, user.Email, user.DisplayName, token));
});
group.MapPost("/logout", () => Results.NoContent());
group.MapGet("/me", async (ClaimsPrincipal principal, AppDbContext db, CancellationToken ct) =>
{
var userId = JwtTokenService.GetUserId(principal);
if (userId is null)
return Results.Ok(new UserStatusDto(false, null, null, null));
var user = await db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == userId, ct);
if (user is null)
return Results.Ok(new UserStatusDto(false, null, null, null));
return Results.Ok(new UserStatusDto(true, user.Id, user.Email, user.DisplayName));
}).RequireAuthorization();
// Allow anonymous me check without 401
group.MapGet("/status", async (ClaimsPrincipal principal, AppDbContext db, CancellationToken ct) =>
{
var userId = JwtTokenService.GetUserId(principal);
if (userId is null)
return Results.Ok(new UserStatusDto(false, null, null, null));
var user = await db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == userId, ct);
if (user is null)
return Results.Ok(new UserStatusDto(false, null, null, null));
return Results.Ok(new UserStatusDto(true, user.Id, user.Email, user.DisplayName));
});
return group;
}
}
@@ -0,0 +1,63 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using TafsirQuran.Api.Auth;
using TafsirQuran.Api.Mapping;
using TafsirQuran.Domain.Entities;
using TafsirQuran.Infrastructure.Persistence;
using TafsirQuran.Shared.Dtos;
namespace TafsirQuran.Api.Endpoints;
public static class BookmarkEndpoints
{
public static RouteGroupBuilder MapBookmarkEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/bookmarks");
group.MapGet("/", async (string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
{
var userId = JwtTokenService.GetUserId(user);
var items = await db.Bookmarks.AsNoTracking()
.Include(b => b.Episode).ThenInclude(e => e.Surah)
.Where(b => b.DeviceId == deviceId || (userId.HasValue && b.UserId == userId))
.OrderByDescending(b => b.CreatedAt)
.ToListAsync(ct);
return Results.Ok(items.Select(b => new BookmarkDto(
b.Id, b.EpisodeId, b.Note, b.CreatedAt, EpisodeMapper.ToListItem(b.Episode))).ToList());
});
group.MapPost("/", async (ToggleEpisodeRequest request, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
{
var userId = JwtTokenService.GetUserId(user);
var exists = await db.Bookmarks.AnyAsync(b =>
b.EpisodeId == request.EpisodeId &&
(b.DeviceId == request.DeviceId || (userId.HasValue && b.UserId == userId)), ct);
if (exists) return Results.NoContent();
db.Bookmarks.Add(new Bookmark
{
DeviceId = request.DeviceId,
UserId = userId,
EpisodeId = request.EpisodeId,
Note = request.Note
});
await db.SaveChangesAsync(ct);
return Results.Created($"/api/bookmarks/{request.EpisodeId}", null);
});
group.MapDelete("/{episodeId:guid}", async (Guid episodeId, string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
{
var userId = JwtTokenService.GetUserId(user);
var items = await db.Bookmarks
.Where(b => b.EpisodeId == episodeId &&
(b.DeviceId == deviceId || (userId.HasValue && b.UserId == userId)))
.ToListAsync(ct);
db.Bookmarks.RemoveRange(items);
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
return group;
}
}
@@ -0,0 +1,62 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using TafsirQuran.Api.Auth;
using TafsirQuran.Api.Mapping;
using TafsirQuran.Domain.Entities;
using TafsirQuran.Infrastructure.Persistence;
using TafsirQuran.Shared.Dtos;
namespace TafsirQuran.Api.Endpoints;
public static class FavoriteEndpoints
{
public static RouteGroupBuilder MapFavoriteEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/favorites");
group.MapGet("/", async (string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
{
var userId = JwtTokenService.GetUserId(user);
var items = await db.Favorites.AsNoTracking()
.Include(f => f.Episode).ThenInclude(e => e.Surah)
.Where(f => f.DeviceId == deviceId || (userId.HasValue && f.UserId == userId))
.OrderByDescending(f => f.CreatedAt)
.ToListAsync(ct);
return Results.Ok(items.Select(f => new FavoriteDto(
f.Id, f.EpisodeId, f.CreatedAt, EpisodeMapper.ToListItem(f.Episode))).ToList());
});
group.MapPost("/", async (ToggleEpisodeRequest request, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
{
var userId = JwtTokenService.GetUserId(user);
var exists = await db.Favorites.AnyAsync(f =>
f.EpisodeId == request.EpisodeId &&
(f.DeviceId == request.DeviceId || (userId.HasValue && f.UserId == userId)), ct);
if (exists) return Results.NoContent();
db.Favorites.Add(new Favorite
{
DeviceId = request.DeviceId,
UserId = userId,
EpisodeId = request.EpisodeId
});
await db.SaveChangesAsync(ct);
return Results.Created($"/api/favorites/{request.EpisodeId}", null);
});
group.MapDelete("/{episodeId:guid}", async (Guid episodeId, string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
{
var userId = JwtTokenService.GetUserId(user);
var items = await db.Favorites
.Where(f => f.EpisodeId == episodeId &&
(f.DeviceId == deviceId || (userId.HasValue && f.UserId == userId)))
.ToListAsync(ct);
db.Favorites.RemoveRange(items);
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
return group;
}
}
@@ -0,0 +1,76 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using TafsirQuran.Api.Auth;
using TafsirQuran.Api.Mapping;
using TafsirQuran.Domain.Entities;
using TafsirQuran.Infrastructure.Persistence;
using TafsirQuran.Shared.Dtos;
namespace TafsirQuran.Api.Endpoints;
public static class ProgressEndpoints
{
public static RouteGroupBuilder MapProgressEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/progress");
group.MapGet("/", async (string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
{
var userId = JwtTokenService.GetUserId(user);
var query = db.ListeningProgresses.AsNoTracking().Include(p => p.Episode).ThenInclude(e => e.Surah).AsQueryable();
ListeningProgress? progress = null;
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);
if (progress is null) return Results.NoContent();
return Results.Ok(new ProgressDto(
progress.EpisodeId,
progress.PositionSeconds,
progress.UpdatedAt,
EpisodeMapper.ToListItem(progress.Episode)));
});
group.MapPut("/", async (UpsertProgressRequest request, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(request.DeviceId))
return Results.BadRequest("deviceId required");
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 (existing is null)
{
existing = new ListeningProgress
{
DeviceId = request.DeviceId,
UserId = userId,
EpisodeId = request.EpisodeId,
PositionSeconds = request.PositionSeconds
};
db.ListeningProgresses.Add(existing);
}
else
{
existing.EpisodeId = request.EpisodeId;
existing.PositionSeconds = request.PositionSeconds;
existing.UpdatedAt = DateTimeOffset.UtcNow;
existing.UserId ??= userId;
if (string.IsNullOrWhiteSpace(existing.DeviceId))
existing.DeviceId = request.DeviceId;
}
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
return group;
}
}
@@ -0,0 +1,80 @@
using Microsoft.EntityFrameworkCore;
using TafsirQuran.Api.Mapping;
using TafsirQuran.Infrastructure.Persistence;
using TafsirQuran.Shared.Dtos;
namespace TafsirQuran.Api.Endpoints;
public static class TafsirEndpoints
{
public static RouteGroupBuilder MapTafsirEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api");
group.MapGet("/tafsirs", async (AppDbContext db, CancellationToken ct) =>
{
var items = await db.Tafsirs
.AsNoTracking()
.Where(t => t.IsPublished)
.OrderBy(t => t.Title)
.Select(t => new TafsirDto(t.Id, t.Title, t.Author, t.Slug, t.CoverUrl))
.ToListAsync(ct);
return Results.Ok(items);
});
group.MapGet("/tafsirs/{slug}/episodes", async (string slug, AppDbContext db, CancellationToken ct) =>
{
var tafsir = await db.Tafsirs.AsNoTracking().FirstOrDefaultAsync(t => t.Slug == slug && t.IsPublished, ct);
if (tafsir is null) return Results.NotFound();
var episodes = await db.Episodes
.AsNoTracking()
.Include(e => e.Surah)
.Where(e => e.TafsirId == tafsir.Id)
.OrderBy(e => e.SortOrder)
.ToListAsync(ct);
return Results.Ok(episodes.Select(e => EpisodeMapper.ToListItem(e)).ToList());
});
group.MapGet("/episodes/{id:guid}", async (Guid id, AppDbContext db, CancellationToken ct) =>
{
var episode = await db.Episodes
.AsNoTracking()
.Include(e => e.Surah)
.Include(e => e.Tafsir)
.FirstOrDefaultAsync(e => e.Id == id, ct);
if (episode is null) return Results.NotFound();
var ayah = await db.Ayahs.AsNoTracking()
.FirstOrDefaultAsync(a => a.SurahId == episode.SurahId && a.Number == episode.AyahNumber, ct);
var detail = EpisodeMapper.ToDetail(episode, ayah) with
{
AudioUrl = $"/api/audio/{episode.Id}"
};
return Results.Ok(detail);
});
group.MapGet("/audio/{episodeId:guid}", async (Guid episodeId, AppDbContext db, IWebHostEnvironment env, CancellationToken ct) =>
{
var episode = await db.Episodes.AsNoTracking().FirstOrDefaultAsync(e => e.Id == episodeId, ct);
if (episode is null) return Results.NotFound();
var relative = episode.AudioUrl.TrimStart('/');
var path = Path.Combine(env.ContentRootPath, "wwwroot", relative);
if (!File.Exists(path))
{
// Fallback placeholder: return empty audio-friendly response path for demo
var placeholder = Path.Combine(env.ContentRootPath, "wwwroot", "audio", "silence.mp3");
if (File.Exists(placeholder))
return Results.File(placeholder, "audio/mpeg", enableRangeProcessing: true);
return Results.NotFound();
}
return Results.File(path, "audio/mpeg", enableRangeProcessing: true);
});
return group;
}
}
@@ -0,0 +1,32 @@
using TafsirQuran.Domain.Entities;
using TafsirQuran.Shared.Dtos;
namespace TafsirQuran.Api.Mapping;
public static class EpisodeMapper
{
public static EpisodeListItemDto ToListItem(TafsirEpisode e, Ayah? ayah = null) =>
new(
e.Id,
e.Title,
e.Surah.Number,
e.Surah.NameFa,
e.AyahNumber,
e.DurationSeconds,
e.SortOrder);
public static EpisodeDetailDto ToDetail(TafsirEpisode e, Ayah? ayah) =>
new(
e.Id,
e.TafsirId,
e.Tafsir.Slug,
e.Title,
e.Surah.Number,
e.Surah.NameFa,
e.AyahNumber,
ayah?.TextAr ?? string.Empty,
ayah?.TextFa,
e.AudioUrl,
e.DurationSeconds,
e.Summary);
}
+80
View File
@@ -0,0 +1,80 @@
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using TafsirQuran.Api.Auth;
using TafsirQuran.Api.Endpoints;
using TafsirQuran.Infrastructure;
using TafsirQuran.Infrastructure.Persistence;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection(JwtOptions.SectionName));
builder.Services.AddSingleton<JwtTokenService>();
var jwt = builder.Configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>() ?? new JwtOptions();
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwt.Issuer,
ValidAudience = jwt.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.Key))
};
});
builder.Services.AddAuthorization();
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
policy.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin());
});
builder.Services.AddOpenApi();
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
for (var attempt = 1; ; attempt++)
{
try
{
await DbSeeder.SeedAsync(db);
break;
}
catch (Exception ex) when (attempt < 30)
{
app.Logger.LogWarning(ex, "DB not ready (attempt {Attempt}); retry in 2s", attempt);
await Task.Delay(TimeSpan.FromSeconds(2));
}
}
}
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseCors();
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.MapTafsirEndpoints();
app.MapProgressEndpoints();
app.MapFavoriteEndpoints();
app.MapBookmarkEndpoints();
app.MapAuthEndpoints();
app.MapGet("/", () => Results.Ok(new { name = "TafsirQuran API", status = "ok" }));
app.Run();
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5080",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.10" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\TafsirQuran.Infrastructure\TafsirQuran.Infrastructure.csproj" />
<ProjectReference Include="..\TafsirQuran.Shared\TafsirQuran.Shared.csproj" />
</ItemGroup>
</Project>
+6
View File
@@ -0,0 +1,6 @@
@TafsirQuran.Api_HostAddress = http://localhost:5025
GET {{TafsirQuran.Api_HostAddress}}/weatherforecast/
Accept: application/json
###
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Information"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"ConnectionStrings": {
"Default": "Host=localhost;Port=5434;Database=tafsirquran;Username=tafsir;Password=tafsir_dev"
},
"Jwt": {
"Key": "TafsirQuran-Dev-Secret-Key-Change-In-Production-32+",
"Issuer": "TafsirQuran",
"Audience": "TafsirQuran",
"ExpireMinutes": 43200
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.