Security & resource hardening: eliminate CPU/disk attack surface
Addresses production CPU spike incident. Key changes: - Guard OTel exporter behind OTEL_EXPORTER_OTLP_ENDPOINT env var; filter tracing to /api paths only — unconditional export was primary suspect - Remove /healthz endpoint entirely (unauthenticated, hit DB on every call) - Replace KnownUserMiddleware with POST /api/users/me called once on login from TokenSync — eliminates unconditional DB write on every request - Add DB indexes: (BudgetId, IsDeleted) on Incomes/Outgos, OwnerUserId on Budgets, SharedWithUserId and (IsPending, SharedWithEmail) on BudgetShares - Move UseRateLimiter() before UseStaticFiles() so all requests are throttled - Replace full-array reorder with move-by-position (id + newIndex) — bounded input, fewer DB writes, better API design - Lock ForwardedHeaders to 172.20.0.0/16 subnet; fixes KnownNetworks deprecation warning (0 warnings in build now) - Add AsNoTracking() to all read-only queries in Summary/Incomes/OutgosController - FrequencyCalculator returns 0 for unknown enum values instead of throwing - Thread.Sleep → await Task.Delay in OIDC startup loop - AllowedHosts locked to budget.stwaddle.com Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,7 @@ public class IncomesController(AppDbContext db, BudgetAuthorizationService authz
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.CanReadAsync(budgetId, userId)) return Forbid();
|
||||
var incomes = await db.Incomes
|
||||
.AsNoTracking()
|
||||
.Where(i => i.BudgetId == budgetId)
|
||||
.OrderBy(i => i.SortOrder)
|
||||
.ToListAsync();
|
||||
@@ -94,17 +95,21 @@ public class IncomesController(AppDbContext db, BudgetAuthorizationService authz
|
||||
|
||||
[HttpPut("order")]
|
||||
[EnableRateLimiting("writes")]
|
||||
public async Task<IActionResult> Reorder(Guid budgetId, [FromBody] ReorderIncomesRequest req)
|
||||
public async Task<IActionResult> Reorder(Guid budgetId, [FromBody] MoveIncomeRequest req)
|
||||
{
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
|
||||
var incomes = await db.Incomes.Where(i => i.BudgetId == budgetId).ToListAsync();
|
||||
var lookup = incomes.ToDictionary(i => i.Id);
|
||||
for (int idx = 0; idx < req.OrderedIds.Count; idx++)
|
||||
{
|
||||
if (lookup.TryGetValue(req.OrderedIds[idx], out var income))
|
||||
income.SortOrder = idx;
|
||||
}
|
||||
var incomes = await db.Incomes.Where(i => i.BudgetId == budgetId).OrderBy(i => i.SortOrder).ToListAsync();
|
||||
var item = incomes.FirstOrDefault(i => i.Id == req.Id);
|
||||
if (item is null) return NotFound();
|
||||
var oldIdx = incomes.IndexOf(item);
|
||||
var newIdx = Math.Clamp(req.NewIndex, 0, incomes.Count - 1);
|
||||
if (oldIdx == newIdx) return NoContent();
|
||||
if (newIdx < oldIdx)
|
||||
for (int i = newIdx; i < oldIdx; i++) incomes[i].SortOrder++;
|
||||
else
|
||||
for (int i = oldIdx + 1; i <= newIdx; i++) incomes[i].SortOrder--;
|
||||
item.SortOrder = newIdx;
|
||||
await db.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz)
|
||||
|
||||
private async Task<decimal> GetMonthlyIncomeAsync(Guid budgetId)
|
||||
{
|
||||
var incomes = await db.Incomes.Where(i => i.BudgetId == budgetId).ToListAsync();
|
||||
var incomes = await db.Incomes.AsNoTracking().Where(i => i.BudgetId == budgetId).ToListAsync();
|
||||
return incomes.Sum(i => FrequencyCalculator.ToMonthly(i.Amount, i.Frequency));
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz)
|
||||
{
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.CanReadAsync(budgetId, userId)) return Forbid();
|
||||
var outgos = await db.Outgos.Where(o => o.BudgetId == budgetId).OrderBy(o => o.SortOrder).ToListAsync();
|
||||
var outgos = await db.Outgos.AsNoTracking().Where(o => o.BudgetId == budgetId).OrderBy(o => o.SortOrder).ToListAsync();
|
||||
var monthlyIncome = await GetMonthlyIncomeAsync(budgetId);
|
||||
return Ok(outgos.Select(o => ToDto(o, monthlyIncome)));
|
||||
}
|
||||
@@ -112,17 +112,21 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz)
|
||||
|
||||
[HttpPut("order")]
|
||||
[EnableRateLimiting("writes")]
|
||||
public async Task<IActionResult> Reorder(Guid budgetId, [FromBody] ReorderOutgosRequest req)
|
||||
public async Task<IActionResult> Reorder(Guid budgetId, [FromBody] MoveOutgoRequest req)
|
||||
{
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
|
||||
var outgos = await db.Outgos.Where(o => o.BudgetId == budgetId).ToListAsync();
|
||||
var lookup = outgos.ToDictionary(o => o.Id);
|
||||
for (int idx = 0; idx < req.OrderedIds.Count; idx++)
|
||||
{
|
||||
if (lookup.TryGetValue(req.OrderedIds[idx], out var outgo))
|
||||
outgo.SortOrder = idx;
|
||||
}
|
||||
var outgos = await db.Outgos.AsNoTracking().Where(o => o.BudgetId == budgetId).OrderBy(o => o.SortOrder).ToListAsync();
|
||||
var item = outgos.FirstOrDefault(o => o.Id == req.Id);
|
||||
if (item is null) return NotFound();
|
||||
var oldIdx = outgos.IndexOf(item);
|
||||
var newIdx = Math.Clamp(req.NewIndex, 0, outgos.Count - 1);
|
||||
if (oldIdx == newIdx) return NoContent();
|
||||
if (newIdx < oldIdx)
|
||||
for (int i = newIdx; i < oldIdx; i++) outgos[i].SortOrder++;
|
||||
else
|
||||
for (int i = oldIdx + 1; i <= newIdx; i++) outgos[i].SortOrder--;
|
||||
item.SortOrder = newIdx;
|
||||
await db.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -29,11 +29,11 @@ public class SummaryController(AppDbContext db, BudgetAuthorizationService authz
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.CanReadAsync(budgetId, userId)) return Forbid();
|
||||
|
||||
var budget = await db.Budgets.FindAsync(budgetId);
|
||||
var budget = await db.Budgets.AsNoTracking().FirstOrDefaultAsync(b => b.Id == budgetId);
|
||||
if (budget is null) return NotFound();
|
||||
|
||||
var incomes = await db.Incomes.Where(i => i.BudgetId == budgetId).ToListAsync();
|
||||
var outgos = await db.Outgos.Where(o => o.BudgetId == budgetId).ToListAsync();
|
||||
var incomes = await db.Incomes.AsNoTracking().Where(i => i.BudgetId == budgetId).ToListAsync();
|
||||
var outgos = await db.Outgos.AsNoTracking().Where(o => o.BudgetId == budgetId).ToListAsync();
|
||||
|
||||
var monthlyIncome = incomes.Sum(i => FrequencyCalculator.ToMonthly(i.Amount, i.Frequency));
|
||||
var annualIncome = monthlyIncome * 12m;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using Budget.Core.Models;
|
||||
using Budget.Infrastructure.Data;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Budget.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/users")]
|
||||
[Authorize(Roles = "admin,user")]
|
||||
public class UsersController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpPost("me")]
|
||||
public async Task<IActionResult> RegisterMe()
|
||||
{
|
||||
var sub = User.FindFirst("sub")?.Value;
|
||||
var email = User.FindFirst("email")?.Value;
|
||||
var name = User.FindFirst("name")?.Value;
|
||||
|
||||
if (sub is null || email is null || name is null)
|
||||
return Unauthorized();
|
||||
|
||||
var known = await db.KnownUsers.FindAsync(sub);
|
||||
if (known is null)
|
||||
{
|
||||
db.KnownUsers.Add(new KnownUser { Id = sub, Email = email, Name = name, LastSeenAt = DateTimeOffset.UtcNow });
|
||||
}
|
||||
else
|
||||
{
|
||||
known.Email = email;
|
||||
known.Name = name;
|
||||
known.LastSeenAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
var pending = await db.BudgetShares
|
||||
.Where(s => s.IsPending && s.SharedWithEmail == email)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var share in pending)
|
||||
{
|
||||
share.SharedWithUserId = sub;
|
||||
share.IsPending = false;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
+22
-32
@@ -3,11 +3,9 @@ using Budget.Api.Services;
|
||||
using Budget.Infrastructure.Data;
|
||||
using Budget.Infrastructure.Services;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using OpenTelemetry;
|
||||
@@ -16,20 +14,25 @@ using OpenTelemetry.Trace;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Logging.AddOpenTelemetry(logging =>
|
||||
var otlpEndpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"];
|
||||
if (!string.IsNullOrEmpty(otlpEndpoint))
|
||||
{
|
||||
logging.IncludeFormattedMessage = true;
|
||||
logging.IncludeScopes = true;
|
||||
});
|
||||
builder.Logging.AddOpenTelemetry(logging =>
|
||||
{
|
||||
logging.IncludeFormattedMessage = true;
|
||||
logging.IncludeScopes = true;
|
||||
});
|
||||
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.ConfigureResource(resource => resource
|
||||
.AddService(serviceName: "budget", serviceVersion: "1.0.0"))
|
||||
.WithTracing(tracing => tracing
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddEntityFrameworkCoreInstrumentation())
|
||||
.UseOtlpExporter();
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.ConfigureResource(resource => resource
|
||||
.AddService(serviceName: "budget", serviceVersion: "1.0.0"))
|
||||
.WithTracing(tracing => tracing
|
||||
.AddAspNetCoreInstrumentation(opts =>
|
||||
opts.Filter = ctx => ctx.Request.Path.StartsWithSegments("/api"))
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddEntityFrameworkCoreInstrumentation())
|
||||
.UseOtlpExporter();
|
||||
}
|
||||
|
||||
var connStr = builder.Configuration.GetConnectionString("DefaultConnection")
|
||||
?? $"Host={builder.Configuration["POSTGRES_HOST"] ?? "localhost"};" +
|
||||
@@ -43,8 +46,9 @@ builder.Services.AddDbContext<AppDbContext>(opt => opt.UseNpgsql(connStr));
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||
options.KnownNetworks.Clear();
|
||||
options.KnownProxies.Clear();
|
||||
options.KnownIPNetworks.Clear();
|
||||
options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse("172.20.0.0/16"));
|
||||
});
|
||||
|
||||
var oidc = builder.Configuration.GetSection("Oidc");
|
||||
@@ -109,9 +113,6 @@ builder.Services.AddControllers()
|
||||
.AddJsonOptions(opts =>
|
||||
opts.JsonSerializerOptions.Converters.Add(
|
||||
new System.Text.Json.Serialization.JsonStringEnumConverter()));
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddDbContextCheck<AppDbContext>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Block startup until OIDC discovery succeeds (handles auth/app container race).
|
||||
@@ -136,7 +137,7 @@ while (true)
|
||||
break;
|
||||
}
|
||||
startupLogger.LogWarning(ex, "OIDC discovery failed; retrying in 5 s");
|
||||
Thread.Sleep(TimeSpan.FromSeconds(5));
|
||||
await Task.Delay(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,26 +151,15 @@ using (var scope = app.Services.CreateScope())
|
||||
app.UseForwardedHeaders();
|
||||
app.UseMiddleware<ErrorHandlingMiddleware>();
|
||||
|
||||
app.UseRateLimiter();
|
||||
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseMiddleware<KnownUserMiddleware>();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.UseRateLimiter();
|
||||
|
||||
app.MapControllers();
|
||||
app.MapHealthChecks("/healthz", new HealthCheckOptions
|
||||
{
|
||||
ResultStatusCodes =
|
||||
{
|
||||
[HealthStatus.Healthy] = StatusCodes.Status200OK,
|
||||
[HealthStatus.Degraded] = StatusCodes.Status200OK,
|
||||
[HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable,
|
||||
}
|
||||
});
|
||||
|
||||
app.MapFallbackToFile("index.html");
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
using Budget.Core.Models;
|
||||
using Budget.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Budget.Api.Services;
|
||||
|
||||
public class KnownUserMiddleware(RequestDelegate next)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context, AppDbContext db)
|
||||
{
|
||||
if (context.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var sub = context.User.FindFirst("sub")?.Value;
|
||||
var email = context.User.FindFirst("email")?.Value;
|
||||
var name = context.User.FindFirst("name")?.Value;
|
||||
|
||||
if (sub != null && email != null && name != null)
|
||||
{
|
||||
var known = await db.KnownUsers.FindAsync(sub);
|
||||
if (known is null)
|
||||
{
|
||||
db.KnownUsers.Add(new KnownUser { Id = sub, Email = email, Name = name, LastSeenAt = DateTimeOffset.UtcNow });
|
||||
}
|
||||
else
|
||||
{
|
||||
known.Email = email;
|
||||
known.Name = name;
|
||||
known.LastSeenAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
// Resolve pending shares for this user's email
|
||||
var pending = await db.BudgetShares
|
||||
.Where(s => s.IsPending && s.SharedWithEmail == email)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var share in pending)
|
||||
{
|
||||
share.SharedWithUserId = sub;
|
||||
share.IsPending = false;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
await next(context);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"AllowedHosts": "budget.stwaddle.com",
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": ""
|
||||
},
|
||||
|
||||
@@ -24,7 +24,7 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>('GET', path),
|
||||
post: <T>(path: string, body: unknown) => request<T>('POST', path, body),
|
||||
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
||||
put: <T>(path: string, body: unknown) => request<T>('PUT', path, body),
|
||||
delete: (path: string) => request<void>('DELETE', path),
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { IncomeDto, Frequency } from '../types/index';
|
||||
|
||||
interface CreateIncomeRequest { name: string; frequency: Frequency; amount: number; }
|
||||
interface UpdateIncomeRequest { name: string; frequency: Frequency; amount: number; }
|
||||
interface ReorderRequest { orderedIds: string[]; }
|
||||
interface MoveRequest { id: string; newIndex: number; }
|
||||
|
||||
export function useIncomes(budgetId: string) {
|
||||
return useQuery({
|
||||
@@ -44,7 +44,7 @@ export function useDeleteIncome(budgetId: string) {
|
||||
export function useReorderIncomes(budgetId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (req: ReorderRequest) => api.put<void>(`/api/budgets/${budgetId}/incomes/order`, req),
|
||||
mutationFn: (req: MoveRequest) => api.put<void>(`/api/budgets/${budgetId}/incomes/order`, req),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['budgets', budgetId, 'incomes'] }),
|
||||
meta: { errorMessage: 'Failed to save order' },
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ interface CreateOutgoRequest {
|
||||
frequency: Frequency; amount: number; paymentSource: string | null; notes: string | null;
|
||||
}
|
||||
interface UpdateOutgoRequest extends CreateOutgoRequest { id: string; }
|
||||
interface ReorderRequest { orderedIds: string[]; }
|
||||
interface MoveRequest { id: string; newIndex: number; }
|
||||
|
||||
export function useOutgos(budgetId: string) {
|
||||
return useQuery({
|
||||
@@ -61,7 +61,7 @@ export function useDeleteOutgo(budgetId: string) {
|
||||
export function useReorderOutgos(budgetId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (req: ReorderRequest) => api.put<void>(`/api/budgets/${budgetId}/outgos/order`, req),
|
||||
mutationFn: (req: MoveRequest) => api.put<void>(`/api/budgets/${budgetId}/outgos/order`, req),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['budgets', budgetId, 'outgos'] }),
|
||||
meta: { errorMessage: 'Failed to save order' },
|
||||
});
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAuth } from 'react-oidc-context';
|
||||
import { setTokenProvider } from '../api/client';
|
||||
import { setTokenProvider, api } from '../api/client';
|
||||
|
||||
export function TokenSync() {
|
||||
const auth = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
setTokenProvider(() => auth.user?.access_token ?? null);
|
||||
if (auth.user?.access_token) {
|
||||
api.post<void>('/api/users/me', undefined).catch(() => {});
|
||||
}
|
||||
}, [auth.user]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -238,9 +238,8 @@ export function IncomePage() {
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIdx = displayItems.findIndex(i => i.id === active.id);
|
||||
const newIdx = displayItems.findIndex(i => i.id === over.id);
|
||||
const reordered = arrayMove(displayItems, oldIdx, newIdx);
|
||||
setDisplayItems(reordered);
|
||||
reorderIncomes.mutate({ orderedIds: reordered.map(i => i.id) });
|
||||
setDisplayItems(items => arrayMove(items, oldIdx, newIdx));
|
||||
reorderIncomes.mutate({ id: active.id as string, newIndex: newIdx });
|
||||
};
|
||||
|
||||
if (isLoading) return <><BudgetNav /><LoadingSkeleton rows={5} cols={6} /></>;
|
||||
|
||||
@@ -384,9 +384,8 @@ export function OutgoPage() {
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIdx = displayItems.findIndex(o => o.id === active.id);
|
||||
const newIdx = displayItems.findIndex(o => o.id === over.id);
|
||||
const reordered = arrayMove(displayItems, oldIdx, newIdx);
|
||||
setDisplayItems(reordered);
|
||||
reorderOutgos.mutate({ orderedIds: reordered.map(o => o.id) });
|
||||
setDisplayItems(items => arrayMove(items, oldIdx, newIdx));
|
||||
reorderOutgos.mutate({ id: active.id as string, newIndex: newIdx });
|
||||
};
|
||||
|
||||
if (isLoading) return <><BudgetNav /><LoadingSkeleton rows={5} cols={9} /></>;
|
||||
|
||||
@@ -23,4 +23,4 @@ public record UpdateIncomeRequest(
|
||||
Frequency Frequency,
|
||||
[Range(0, 9_999_999_999_999_999.99)] decimal Amount);
|
||||
|
||||
public record ReorderIncomesRequest([Required] List<Guid> OrderedIds);
|
||||
public record MoveIncomeRequest([Required] Guid Id, [Range(0, int.MaxValue)] int NewIndex);
|
||||
|
||||
@@ -36,4 +36,4 @@ public record UpdateOutgoRequest(
|
||||
[MaxLength(100)] string? PaymentSource,
|
||||
[MaxLength(1000)] string? Notes);
|
||||
|
||||
public record ReorderOutgosRequest([Required] List<Guid> OrderedIds);
|
||||
public record MoveOutgoRequest([Required] Guid Id, [Range(0, int.MaxValue)] int NewIndex);
|
||||
|
||||
@@ -15,7 +15,7 @@ public static class FrequencyCalculator
|
||||
Frequency.SemiMonthly => amount * 2m,
|
||||
Frequency.Biweekly => amount * 26m / 12m,
|
||||
Frequency.Weekly => amount * 52m / 12m,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(frequency)),
|
||||
_ => 0m,
|
||||
};
|
||||
|
||||
public static decimal ToAnnually(decimal amount, Frequency frequency) =>
|
||||
|
||||
@@ -26,6 +26,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
||||
.HasColumnType("xid")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.IsConcurrencyToken();
|
||||
b.HasIndex(x => x.OwnerUserId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Income>(b =>
|
||||
@@ -34,6 +35,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
||||
b.Property(x => x.Name).IsRequired().HasMaxLength(200);
|
||||
b.Property(x => x.Amount).HasPrecision(18, 2);
|
||||
b.HasQueryFilter(x => !x.IsDeleted);
|
||||
b.HasIndex(x => new { x.BudgetId, x.IsDeleted });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Outgo>(b =>
|
||||
@@ -45,6 +47,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
||||
b.Property(x => x.Notes).HasMaxLength(1000);
|
||||
b.Property(x => x.Amount).HasPrecision(18, 2);
|
||||
b.HasQueryFilter(x => !x.IsDeleted);
|
||||
b.HasIndex(x => new { x.BudgetId, x.IsDeleted });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<KnownUser>(b =>
|
||||
@@ -61,6 +64,8 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
||||
b.Property(x => x.SharedWithUserId).HasMaxLength(200);
|
||||
b.Property(x => x.SharedWithEmail).IsRequired().HasMaxLength(200);
|
||||
b.HasIndex(x => new { x.BudgetId, x.SharedWithEmail }).IsUnique();
|
||||
b.HasIndex(x => x.SharedWithUserId);
|
||||
b.HasIndex(x => new { x.IsPending, x.SharedWithEmail });
|
||||
b.HasQueryFilter(x => !x.IsDeleted);
|
||||
});
|
||||
}
|
||||
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Budget.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Budget.Infrastructure.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260507024323_AddHardeningIndexes")]
|
||||
partial class AddHardeningIndexes
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Budget.Core.Models.Budget", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("OwnerUserId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<uint>("xmin")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("xid")
|
||||
.HasColumnName("xmin");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerUserId");
|
||||
|
||||
b.ToTable("Budgets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Budget.Core.Models.BudgetShare", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("BudgetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsPending")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("Permission")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("SharedWithEmail")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("SharedWithUserId")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SharedWithUserId");
|
||||
|
||||
b.HasIndex("BudgetId", "SharedWithEmail")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("IsPending", "SharedWithEmail");
|
||||
|
||||
b.ToTable("BudgetShares");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Budget.Core.Models.Income", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)");
|
||||
|
||||
b.Property<Guid>("BudgetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Frequency")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BudgetId", "IsDeleted");
|
||||
|
||||
b.ToTable("Incomes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Budget.Core.Models.KnownUser", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("LastSeenAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("KnownUsers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Budget.Core.Models.Outgo", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)");
|
||||
|
||||
b.Property<Guid>("BudgetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Frequency")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<string>("PaymentSource")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BudgetId", "IsDeleted");
|
||||
|
||||
b.ToTable("Outgos");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Budget.Core.Models.BudgetShare", b =>
|
||||
{
|
||||
b.HasOne("Budget.Core.Models.Budget", "Budget")
|
||||
.WithMany("Shares")
|
||||
.HasForeignKey("BudgetId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Budget");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Budget.Core.Models.Income", b =>
|
||||
{
|
||||
b.HasOne("Budget.Core.Models.Budget", "Budget")
|
||||
.WithMany("Incomes")
|
||||
.HasForeignKey("BudgetId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Budget");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Budget.Core.Models.Outgo", b =>
|
||||
{
|
||||
b.HasOne("Budget.Core.Models.Budget", "Budget")
|
||||
.WithMany("Outgos")
|
||||
.HasForeignKey("BudgetId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Budget");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Budget.Core.Models.Budget", b =>
|
||||
{
|
||||
b.Navigation("Incomes");
|
||||
|
||||
b.Navigation("Outgos");
|
||||
|
||||
b.Navigation("Shares");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Budget.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddHardeningIndexes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Outgos_BudgetId",
|
||||
table: "Outgos");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Incomes_BudgetId",
|
||||
table: "Incomes");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Outgos_BudgetId_IsDeleted",
|
||||
table: "Outgos",
|
||||
columns: new[] { "BudgetId", "IsDeleted" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Incomes_BudgetId_IsDeleted",
|
||||
table: "Incomes",
|
||||
columns: new[] { "BudgetId", "IsDeleted" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BudgetShares_IsPending_SharedWithEmail",
|
||||
table: "BudgetShares",
|
||||
columns: new[] { "IsPending", "SharedWithEmail" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BudgetShares_SharedWithUserId",
|
||||
table: "BudgetShares",
|
||||
column: "SharedWithUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Budgets_OwnerUserId",
|
||||
table: "Budgets",
|
||||
column: "OwnerUserId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Outgos_BudgetId_IsDeleted",
|
||||
table: "Outgos");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Incomes_BudgetId_IsDeleted",
|
||||
table: "Incomes");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_BudgetShares_IsPending_SharedWithEmail",
|
||||
table: "BudgetShares");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_BudgetShares_SharedWithUserId",
|
||||
table: "BudgetShares");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Budgets_OwnerUserId",
|
||||
table: "Budgets");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Outgos_BudgetId",
|
||||
table: "Outgos",
|
||||
column: "BudgetId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Incomes_BudgetId",
|
||||
table: "Incomes",
|
||||
column: "BudgetId");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,8 @@ namespace Budget.Infrastructure.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerUserId");
|
||||
|
||||
b.ToTable("Budgets");
|
||||
});
|
||||
|
||||
@@ -96,9 +98,13 @@ namespace Budget.Infrastructure.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SharedWithUserId");
|
||||
|
||||
b.HasIndex("BudgetId", "SharedWithEmail")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("IsPending", "SharedWithEmail");
|
||||
|
||||
b.ToTable("BudgetShares");
|
||||
});
|
||||
|
||||
@@ -134,7 +140,7 @@ namespace Budget.Infrastructure.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BudgetId");
|
||||
b.HasIndex("BudgetId", "IsDeleted");
|
||||
|
||||
b.ToTable("Incomes");
|
||||
});
|
||||
@@ -210,7 +216,7 @@ namespace Budget.Infrastructure.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BudgetId");
|
||||
b.HasIndex("BudgetId", "IsDeleted");
|
||||
|
||||
b.ToTable("Outgos");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user