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
@@ -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;
}
}