Backend: - Add API keys management (create, list, delete endpoints) - Add email verification flow (verify, resend verification) - Add account management (profile, change password, delete account) - Add billing/Stripe integration (checkout, portal, webhooks) - Add GeoIP service for analytics - Add bulk link creation and link restore endpoints - Add QR code analytics endpoint - Add project description field with migration - Add QR code name and logo support with migration - Improve QR code generator with logo overlay support - Add rate limiting middleware - Update tests for new functionality Frontend: - Refactor entire app to use Pinia for state management - Add auth store with initialization, login, register, logout - Add workspace store with CRUD for workspaces, projects, links, QR codes, domains, assets, and analytics - Add localStorage persistence for workspace selection - Update App.vue with proper store initialization - Update AppLayout.vue to use store methods instead of direct API - Refactor Projects.vue and Domains.vue to use store state/actions - Add VerifyEmail.vue for email verification flow - Add ForgotPassword.vue and ResetPassword.vue - Add Settings.vue with profile, password, API keys, danger zone - Add QRCodeDetail.vue for QR code analytics - Add Billing.vue for subscription management - Expand api/client.js with all new API methods - Add workspace change watchers for automatic data refresh Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
218 lines
8.1 KiB
C#
218 lines
8.1 KiB
C#
using System.Text;
|
|
using System.Threading.RateLimiting;
|
|
using api.Data;
|
|
using api.Features.Auth.Settings;
|
|
using api.Features.Events.Services;
|
|
using api.Features.Assets.Services;
|
|
using api.Features.Email.Services;
|
|
using api.Features.Billing.Services;
|
|
using api.Features.Billing.Settings;
|
|
using api.Features.Plans.Services;
|
|
using api.Features.QRCodes.Services;
|
|
using api.Middleware;
|
|
using FastEndpoints;
|
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using Serilog;
|
|
|
|
// Configure Serilog
|
|
Log.Logger = new LoggerConfiguration()
|
|
.MinimumLevel.Information()
|
|
.MinimumLevel.Override("Microsoft", Serilog.Events.LogEventLevel.Warning)
|
|
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", Serilog.Events.LogEventLevel.Warning)
|
|
.Enrich.FromLogContext()
|
|
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
|
|
.WriteTo.File("logs/api-.log", rollingInterval: RollingInterval.Day, retainedFileCountLimit: 7)
|
|
.CreateLogger();
|
|
|
|
try
|
|
{
|
|
Log.Information("Starting TrakQR API");
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
// Use Serilog
|
|
builder.Host.UseSerilog();
|
|
|
|
// Configure CORS
|
|
builder.Services.AddCors(options =>
|
|
{
|
|
options.AddDefaultPolicy(policy =>
|
|
{
|
|
if (builder.Environment.IsDevelopment())
|
|
{
|
|
policy.SetIsOriginAllowed(origin => new Uri(origin).IsLoopback)
|
|
.AllowAnyHeader()
|
|
.AllowAnyMethod()
|
|
.AllowCredentials();
|
|
}
|
|
else
|
|
{
|
|
// Production: configure allowed origins from config
|
|
var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>()
|
|
?? ["https://trakqr.com"];
|
|
policy.WithOrigins(allowedOrigins)
|
|
.AllowAnyHeader()
|
|
.AllowAnyMethod()
|
|
.AllowCredentials();
|
|
}
|
|
});
|
|
});
|
|
|
|
// Configure Rate Limiting (skip in Testing environment)
|
|
var isTestingEnvironment = builder.Environment.EnvironmentName == "Testing";
|
|
builder.Services.AddRateLimiter(options =>
|
|
{
|
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
|
|
|
// Use very high limits in testing environment
|
|
var authLimit = isTestingEnvironment ? 100000 : 10;
|
|
var globalLimit = isTestingEnvironment ? 100000 : 100;
|
|
var redirectLimit = isTestingEnvironment ? 100000 : 1000;
|
|
var apiLimit = isTestingEnvironment ? 100000 : 200;
|
|
|
|
// Global rate limit for all endpoints
|
|
options.AddPolicy("global", context =>
|
|
RateLimitPartition.GetFixedWindowLimiter(
|
|
partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
|
factory: _ => new FixedWindowRateLimiterOptions
|
|
{
|
|
PermitLimit = globalLimit,
|
|
Window = TimeSpan.FromMinutes(1),
|
|
QueueLimit = 0
|
|
}));
|
|
|
|
// Strict rate limit for authentication endpoints
|
|
options.AddPolicy("auth", context =>
|
|
RateLimitPartition.GetFixedWindowLimiter(
|
|
partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
|
factory: _ => new FixedWindowRateLimiterOptions
|
|
{
|
|
PermitLimit = authLimit,
|
|
Window = TimeSpan.FromMinutes(1),
|
|
QueueLimit = 0
|
|
}));
|
|
|
|
// Higher limit for redirect endpoint (public, needs to be fast)
|
|
options.AddPolicy("redirect", context =>
|
|
RateLimitPartition.GetSlidingWindowLimiter(
|
|
partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
|
factory: _ => new SlidingWindowRateLimiterOptions
|
|
{
|
|
PermitLimit = redirectLimit,
|
|
Window = TimeSpan.FromMinutes(1),
|
|
SegmentsPerWindow = 4,
|
|
QueueLimit = 0
|
|
}));
|
|
|
|
// API rate limit for authenticated endpoints
|
|
options.AddPolicy("api", context =>
|
|
RateLimitPartition.GetTokenBucketLimiter(
|
|
partitionKey: context.User?.Identity?.Name ?? context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
|
factory: _ => new TokenBucketRateLimiterOptions
|
|
{
|
|
TokenLimit = apiLimit,
|
|
ReplenishmentPeriod = TimeSpan.FromMinutes(1),
|
|
TokensPerPeriod = apiLimit,
|
|
QueueLimit = 0
|
|
}));
|
|
});
|
|
|
|
// Add services to the container
|
|
builder.Services.AddDbContext<AppDbContext>(options =>
|
|
options.UseNpgsql(builder.Configuration.GetConnectionString("PostgresConnection")));
|
|
|
|
// Register application services
|
|
builder.Services.AddSingleton<IGeoIpService, GeoIpService>();
|
|
builder.Services.AddSingleton<IEventTrackingService, EventTrackingService>();
|
|
builder.Services.AddSingleton<IQrCodeGeneratorService, QrCodeGeneratorService>();
|
|
builder.Services.AddSingleton<IAssetStorageService, LocalAssetStorageService>();
|
|
builder.Services.AddSingleton<IPlanLimitsService, PlanLimitsService>();
|
|
|
|
// Configure email service
|
|
builder.Services.Configure<EmailSettings>(builder.Configuration.GetSection("Email"));
|
|
var emailProvider = builder.Configuration.GetValue<string>("Email:Provider") ?? "console";
|
|
if (emailProvider == "smtp")
|
|
{
|
|
builder.Services.AddSingleton<IEmailService, SmtpEmailService>();
|
|
}
|
|
else
|
|
{
|
|
// Use console email service for development
|
|
builder.Services.AddSingleton<IEmailService, ConsoleEmailService>();
|
|
}
|
|
|
|
// Configure Stripe
|
|
builder.Services.Configure<StripeSettings>(builder.Configuration.GetSection("Stripe"));
|
|
builder.Services.AddSingleton<IStripeService, StripeService>();
|
|
|
|
// Configure JWT settings
|
|
builder.Services.Configure<JwtSettings>(builder.Configuration.GetSection("Jwt"));
|
|
var jwtSettings = builder.Configuration.GetSection("Jwt").Get<JwtSettings>()!;
|
|
|
|
// Configure authentication
|
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|
.AddJwtBearer(options =>
|
|
{
|
|
options.TokenValidationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuer = true,
|
|
ValidateAudience = true,
|
|
ValidateLifetime = true,
|
|
ValidateIssuerSigningKey = true,
|
|
ValidIssuer = jwtSettings.Issuer,
|
|
ValidAudience = jwtSettings.Audience,
|
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.Secret))
|
|
};
|
|
});
|
|
|
|
builder.Services.AddAuthorization();
|
|
builder.Services.AddFastEndpoints();
|
|
builder.Services.AddOpenApi();
|
|
|
|
var app = builder.Build();
|
|
|
|
// Global error handling middleware (must be first)
|
|
app.UseMiddleware<GlobalExceptionMiddleware>();
|
|
|
|
// Request logging middleware
|
|
app.UseSerilogRequestLogging(options =>
|
|
{
|
|
options.MessageTemplate = "{RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms";
|
|
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
|
|
{
|
|
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
|
|
diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent.ToString());
|
|
diagnosticContext.Set("ClientIP", httpContext.Connection.RemoteIpAddress?.ToString());
|
|
};
|
|
});
|
|
|
|
app.UseCors();
|
|
app.UseRateLimiter();
|
|
|
|
// Configure the HTTP request pipeline
|
|
if (app.Environment.IsDevelopment())
|
|
{
|
|
app.MapOpenApi().CacheOutput();
|
|
app.UseSwaggerUI(options => { options.SwaggerEndpoint("/openapi/v1.json", "v1"); });
|
|
}
|
|
|
|
app.UseHttpsRedirection();
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
|
|
app.UseFastEndpoints();
|
|
|
|
app.Run();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Fatal(ex, "Application terminated unexpectedly");
|
|
}
|
|
finally
|
|
{
|
|
Log.CloseAndFlush();
|
|
}
|