diff --git a/Assets/Images/Logo.png b/Assets/Images/Logo.png new file mode 100644 index 0000000..ae1482b Binary files /dev/null and b/Assets/Images/Logo.png differ diff --git a/Assets/Images/Swagger UI 0.png b/Assets/Images/Swagger UI 0.png new file mode 100644 index 0000000..66d5ac4 Binary files /dev/null and b/Assets/Images/Swagger UI 0.png differ diff --git a/Assets/Images/Swagger UI 1.png b/Assets/Images/Swagger UI 1.png new file mode 100644 index 0000000..e408bca Binary files /dev/null and b/Assets/Images/Swagger UI 1.png differ diff --git a/Assets/Images/Swagger UI 2.png b/Assets/Images/Swagger UI 2.png new file mode 100644 index 0000000..7e88bf6 Binary files /dev/null and b/Assets/Images/Swagger UI 2.png differ diff --git a/README.md b/README.md index 76547a7..76c382a 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,156 @@ -

Shortify.NET (In-Progress)

+

Shortify.NET

+ +

logo

project-image

A powerful .NET 8 URL shortener with JWT auth analytics and caching designed for scalability and security.

+
+

Code Quality

[![Qodana](https://github.com/ScriptSage001/Shortify.NET/actions/workflows/qodana_code_quality.yml/badge.svg)](https://github.com/ScriptSage001/Shortify.NET/actions/workflows/qodana_code_quality.yml) -

🚀 Swagger UI

+
+ +

✨ Why Use Shortify.NET?

+ +- **Scalable and Secure:** Built with .NET 8, ensuring high performance and strong security measures. +- **JWT Authentication:** Secure user management and role-based access control. +- **Caching:** Accelerated redirection using Redis for optimal performance. +- **Ease of Use:** A simple API interface, designed to integrate seamlessly into any application. +- **Analytics:** Gain insights into URL usage patterns. (Coming Soon) + +
+ +

🧩 Features

-[https://shortify-net.onrender.com/swagger/index.html](https://shortify-net.onrender.com/swagger/index.html) +- Generate short URLs quickly and efficiently. +- Secure endpoints with JWT authentication. +- Track detailed analytics for shortened URLs. +- Support for custom aliases. +- Scalable design for high availability. +- Dockerized for easy deployment. -

🛠️ Installation Steps:

+
-

1. docker pull

+

🚀 Tech Stack

+ +- **Backend:** .NET 8 +- **Database:** PostgreSQL (Supabase-hosted) +- **Caching:** Redis +- **Containerization:** Docker +- **API Documentation:** Swagger +- **CI/CD:** GitHub Actions +- **Hosting Platform:** Render +- **Static Code Analysis:** Qodana + +
+ +

🛠️ Installation and Setup:

+

Using Docker

+ +

1. Pull the Docker Image:

``` docker pull thescriptsage/shortifynetapi ``` +
+ +

2. Set Up Required Environment Variables:

+Ensure the following environment variables are set before running the container: + +- **DB_CONNECTION:** Connection string for the PostgreSQL database (e.g., Host=localhost;Port=5432;Database=Shortify;Username=yourUsername;Password=yourPassword). +- **REDIS_CONNECTION:** Connection string for Redis (e.g., localhost:6379). +- **APP_SECRET:** A secret key for signing JWT tokens. +- **CLIENT_SECRET:** Client-specific secret for enhanced security. +- **SENDER_EMAIL:** Email address for sending OTPs or notifications. +- **SENDER_EMAIL_PASSWORD:** Password for the sender email. +- **ALLOWED_HOST:** A comma-separated list of allowed host URLs. + +
+ +

3. Run the Docker Container:

+ +``` +docker run -d -p 5000:80 \ + -e DB_CONNECTION="Host=localhost;Port=5432;Database=Shortify;Username=yourUsername;Password=yourPassword" \ + -e REDIS_CONNECTION="localhost:6379" \ + -e APP_SECRET="yourAppSecret" \ + -e CLIENT_SECRET="yourClientSecret" \ + -e SENDER_EMAIL="yourEmail@gmail.com" \ + -e SENDER_EMAIL_PASSWORD="yourEmailPassword" \ + -e ALLOWED_HOST="http://localhost,http://example.com" \ + thescriptsage/shortifynetapi +``` +
-

2. docker run

+

3. Access the Swagger UI:

+Visit http://localhost:5000/swagger/index.html to explore the API. + +

+

Local Development

+ +

1. Clone the Repository:

``` -docker run -d -p 5000:80 thescriptsage/shortifynetapi +git clone https://github.com/ScriptSage001/Shortify.NET.git +cd Shortify.NET ``` +
+ +

2. Configure Environment Variables:

+Use an environment variable manager or .env file to configure the following values: + +- **DB_CONNECTION:** Connection string for the PostgreSQL database (e.g., Host=localhost;Port=5432;Database=Shortify;Username=yourUsername;Password=yourPassword). +- **REDIS_CONNECTION:** Connection string for Redis (e.g., localhost:6379). +- **APP_SECRET:** A secret key for signing JWT tokens. +- **CLIENT_SECRET:** Client-specific secret for enhanced security. +- **SENDER_EMAIL:** Email address for sending OTPs or notifications. +- **SENDER_EMAIL_PASSWORD:** Password for the sender email. +- **ALLOWED_HOST:** A comma-separated list of allowed host URLs. +
+

Example .env file:

+ +``` +DB_CONNECTION=Host=localhost;Port=5432;Database=Shortify;Username=yourUsername;Password=yourPassword +REDIS_CONNECTION=localhost:6379 +APP_SECRET=yourAppSecret +CLIENT_SECRET=yourClientSecret +SENDER_EMAIL=yourEmail@gmail.com +SENDER_EMAIL_PASSWORD=yourEmailPassword +ALLOWED_HOST=http://localhost,http://example.com +``` +
+ +

3. Install Dependencies:

+Ensure you have the .NET 8 SDK installed. Then, restore the NuGet packages: + +``` +dotnet restore +``` +
+ +

4. Run the Application:

+ +``` +dotnet run +``` +
+ +

5. Access the Swagger UI:

+

Swagger UI will be available at http://localhost:5000/swagger/index.html or the port specified in the console logs.

+ +
+ +

📚 API Documentation

+The API is fully documented using Swagger. Access the live documentation here: + +- Swagger UI +- Swagger JSON + +

🍰 Contribution Guidelines:

@@ -38,21 +164,41 @@ docker run -d -p 5000:80 thescriptsage/shortifynetapi ``` git checkout -b feature/AmazingFeature ``` +

3. Commit your Changes

``` -git commit -m 'Add some AmazingFeature' +git commit -m 'Add some AmazingFeature' ``` +

4. Push to the Branch

``` git push origin feature/AmazingFeature ``` +

5. Open a Pull Request

+
+ +

📸 Screenshots

+ +

Swagger UI

+

swagger-ui-01

+

swagger-ui-02

+

swagger-ui-03

+ +
+

🛡️ License:

+

This project is licensed under the Apache License. See the LICENSE file for details.

+ +
+ +

🌟 Acknowledgments

-This project is licensed under the Apache License +- Inspiration from modern URL shorteners like Bitly. +- Thanks to the .NET community for continuous support and tools. diff --git a/Shortify.NET.API/BaseApiController.cs b/Shortify.NET.API/BaseApiController.cs index 092f04c..fbad3b1 100644 --- a/Shortify.NET.API/BaseApiController.cs +++ b/Shortify.NET.API/BaseApiController.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; using Shortify.NET.Common.FunctionalTypes; using Shortify.NET.Common.Messaging.Abstractions; @@ -113,5 +114,20 @@ protected string GetUser() return userIdClaims is null ? string.Empty : userIdClaims.Value; } + + /// + /// Checks if the user is an Admin User + /// + /// + protected bool IsUserAdmin() + { + var isUserAdmin = User + .Claims + .Any(c => + c.Type.Equals(ClaimTypes.Role, StringComparison.OrdinalIgnoreCase) && + c.Value == "Admin"); + + return isUserAdmin; + } } } diff --git a/Shortify.NET.API/Controllers/V1/ShortController.cs b/Shortify.NET.API/Controllers/V1/ShortController.cs index 0f00ea7..5ea62ab 100644 --- a/Shortify.NET.API/Controllers/V1/ShortController.cs +++ b/Shortify.NET.API/Controllers/V1/ShortController.cs @@ -4,6 +4,7 @@ using Shortify.NET.API.Contracts; using Shortify.NET.API.Mappers; using Shortify.NET.Application.Url.Commands.DeleteUrl; +using Shortify.NET.Application.Url.Queries.CanCreateShortUrl; using Shortify.NET.Application.Url.Queries.GetAllShortenedUrls; using Shortify.NET.Application.Url.Queries.GetOriginalUrl; using Shortify.NET.Application.Url.Queries.ShortenedUrl; @@ -67,13 +68,31 @@ public async Task ShortenUrl( return HandleUnauthorizedRequest(); } + var canCreate = true; + + if(!IsUserAdmin()) + { + canCreate = (await _apiService + .RequestAsync(new CanCreateShortLinkQuery(userId), cancellationToken)) + .Value; + } + + if (!canCreate) + { + return HandleFailure( + Result.Failure( + Error.BadRequest( + "Error.LimitReached", + "Monthly limit of 10 short links reached."))); + } + var command = _mapper.ShortenUrlRequestToCommand(request, userId, HttpContext.Request); var response = await _apiService.SendAsync(command, cancellationToken); return response.IsFailure ? - HandleFailure(response) : - Created(nameof(ShortenUrl), response.Value.Value); + HandleFailure(response) : + Created(nameof(ShortenUrl), response.Value.Value); } /// diff --git a/Shortify.NET.API/DependencyInjection.cs b/Shortify.NET.API/DependencyInjection.cs index 3b5d118..5211009 100644 --- a/Shortify.NET.API/DependencyInjection.cs +++ b/Shortify.NET.API/DependencyInjection.cs @@ -1,10 +1,13 @@ -using System.Reflection; +using System.Net; +using System.Reflection; using System.Text; +using System.Threading.RateLimiting; using Asp.Versioning; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; +using Shortify.NET.API.Helpers; using Shortify.NET.API.SwaggerConfig; using Swashbuckle.AspNetCore.SwaggerGen; @@ -20,6 +23,7 @@ public static IServiceCollection AddApi(this IServiceCollection services, IConfi services.AddEndpointsApiExplorer(); services.AddSwagger(); services.AddCorsPolicy(configuration); + services.AddRateLimiting(configuration); return services; } @@ -124,5 +128,46 @@ private static void AddCorsPolicy(this IServiceCollection services, IConfigurati }); }); } + + private static void AddRateLimiting(this IServiceCollection services, IConfiguration configuration) + { + services.AddRateLimiter(options => + { + options.OnRejected = (context, cancellationToken) => + { + context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; + context.HttpContext.Response.WriteAsync("Too many requests. Please try again later.", cancellationToken); + return new ValueTask(); + }; + + options.GlobalLimiter = PartitionedRateLimiter.Create(context => + { + var remoteIpAddress = context.Connection.RemoteIpAddress; + + if (IPAddress.IsLoopback(remoteIpAddress!)) + return RateLimitPartition.GetNoLimiter(IPAddress.Loopback.ToString()); + + var rateLimiterOptions = configuration + .GetSection("RateLimiterOptions") + .Get(); + + if (rateLimiterOptions is not null) + { + return RateLimitPartition.GetSlidingWindowLimiter( + remoteIpAddress?.ToString()!, + _ => + new SlidingWindowRateLimiterOptions + { + PermitLimit = rateLimiterOptions.PermitLimit, + Window = TimeSpan.FromSeconds(rateLimiterOptions.WindowInSeconds), + SegmentsPerWindow = rateLimiterOptions.SegmentsPerWindow, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = rateLimiterOptions.QueueLimit + }); + } + return RateLimitPartition.GetNoLimiter(IPAddress.Loopback.ToString()); + }); + }); + } } } diff --git a/Shortify.NET.API/Helpers/RateLimiterOptions.cs b/Shortify.NET.API/Helpers/RateLimiterOptions.cs new file mode 100644 index 0000000..beb6e8e --- /dev/null +++ b/Shortify.NET.API/Helpers/RateLimiterOptions.cs @@ -0,0 +1,29 @@ +namespace Shortify.NET.API.Helpers +{ + /// + /// Defines the options to configure the rate limiter + /// + public class RateLimiterOptions + { + /// + /// Gets or Sets the number of request permitted per window + /// + public int PermitLimit { get; init; } + + /// + /// Gets or Sets the timespan of one window in seconds + /// + public int WindowInSeconds { get; init; } + + /// + /// Gets or Sets the number of segments the window is divided into + /// + public int SegmentsPerWindow { get; init; } + + /// + /// Gets or Sets the number of requests permitted in the queue. + /// Pass 0 for no queue. + /// + public int QueueLimit { get; init; } + } +} \ No newline at end of file diff --git a/Shortify.NET.API/Program.cs b/Shortify.NET.API/Program.cs index 5e40775..7bf5a72 100644 --- a/Shortify.NET.API/Program.cs +++ b/Shortify.NET.API/Program.cs @@ -59,6 +59,8 @@ app.UseHttpsRedirection(); + app.UseRateLimiter(); + app.UseAuthentication(); app.UseAuthorization(); diff --git a/Shortify.NET.API/Shortify.NET.API.csproj b/Shortify.NET.API/Shortify.NET.API.csproj index 6e4babc..49c3c30 100644 --- a/Shortify.NET.API/Shortify.NET.API.csproj +++ b/Shortify.NET.API/Shortify.NET.API.csproj @@ -16,6 +16,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Shortify.NET.API/appsettings.Development.json b/Shortify.NET.API/appsettings.Development.json index 8d03384..f56640a 100644 --- a/Shortify.NET.API/appsettings.Development.json +++ b/Shortify.NET.API/appsettings.Development.json @@ -37,5 +37,11 @@ "Port": 587, "OtpLifeSpanInMinutes": 10 }, - "AllowedClients": "http://localhost:4200" + "AllowedClients": "http://localhost:4200", + "RateLimiterOptions": { + "PermitLimit": 20, + "WindowInSeconds": 60, + "SegmentsPerWindow": 3, + "QueueLimit": 0 + } } \ No newline at end of file diff --git a/Shortify.NET.API/appsettings.json b/Shortify.NET.API/appsettings.json index 7a14572..c0f8fe0 100644 --- a/Shortify.NET.API/appsettings.json +++ b/Shortify.NET.API/appsettings.json @@ -37,5 +37,11 @@ "Port": 587, "OtpLifeSpanInMinutes": 10 }, - "AllowedClients": "${ALLOWED_HOST}" + "AllowedClients": "${ALLOWED_HOST}", + "RateLimiterOptions": { + "PermitLimit": 20, + "WindowInSeconds": 60, + "SegmentsPerWindow": 3, + "QueueLimit": 0 + } } \ No newline at end of file diff --git a/Shortify.NET.Application/Abstractions/Repositories/IShortenedUrlRepository.cs b/Shortify.NET.Application/Abstractions/Repositories/IShortenedUrlRepository.cs index df56ee6..fa063d8 100644 --- a/Shortify.NET.Application/Abstractions/Repositories/IShortenedUrlRepository.cs +++ b/Shortify.NET.Application/Abstractions/Repositories/IShortenedUrlRepository.cs @@ -13,7 +13,11 @@ public interface IShortenedUrlRepository Task GetByCodeAsync(string code, CancellationToken cancellationToken = default); - Task?> GetAllByUserIdAsync(Guid userId, CancellationToken cancellationToken = default); + Task?> GetAllByUserIdAsync( + Guid userId, + DateTime? fromDate, + DateTime? toDate, + CancellationToken cancellationToken = default); Task?> GetByIdWithFilterAndSort( Guid id, diff --git a/Shortify.NET.Application/Url/Queries/CanCreateShortUrl/CanCreateShortLinkQuery.cs b/Shortify.NET.Application/Url/Queries/CanCreateShortUrl/CanCreateShortLinkQuery.cs new file mode 100644 index 0000000..860c017 --- /dev/null +++ b/Shortify.NET.Application/Url/Queries/CanCreateShortUrl/CanCreateShortLinkQuery.cs @@ -0,0 +1,6 @@ +using Shortify.NET.Common.Messaging.Abstractions; + +namespace Shortify.NET.Application.Url.Queries.CanCreateShortUrl +{ + public record CanCreateShortLinkQuery(string UserId) : IQuery; +} diff --git a/Shortify.NET.Application/Url/Queries/CanCreateShortUrl/CanCreateShortLinkQueryHandler.cs b/Shortify.NET.Application/Url/Queries/CanCreateShortUrl/CanCreateShortLinkQueryHandler.cs new file mode 100644 index 0000000..cb60bd1 --- /dev/null +++ b/Shortify.NET.Application/Url/Queries/CanCreateShortUrl/CanCreateShortLinkQueryHandler.cs @@ -0,0 +1,37 @@ +using Shortify.NET.Application.Abstractions.Repositories; +using Shortify.NET.Common.FunctionalTypes; +using Shortify.NET.Common.Messaging.Abstractions; + +namespace Shortify.NET.Application.Url.Queries.CanCreateShortUrl +{ + internal class CanCreateShortLinkQueryHandler( + IShortenedUrlRepository shortenedUrlRepository + ) : IQueryHandler + { + private readonly IShortenedUrlRepository _shortenedUrlRepository = shortenedUrlRepository; + + public async Task> Handle(CanCreateShortLinkQuery query, CancellationToken cancellationToken) + { + var userId = Guid.Parse(query.UserId); + + var currentMonthYear = DateTime.SpecifyKind( + new DateTime( + DateTime.UtcNow.Year, + DateTime.UtcNow.Month, + 1), + DateTimeKind.Utc); + var nextMonthYear = currentMonthYear.AddMonths(1); + + var shortenedUrlCount = (await _shortenedUrlRepository + .GetAllByUserIdAsync( + userId, + currentMonthYear, + nextMonthYear, + cancellationToken))? + .Count; + + return shortenedUrlCount is null or < 10; + } + } +} + diff --git a/Shortify.NET.Persistence/Repository/ShortenedUrlRepository.cs b/Shortify.NET.Persistence/Repository/ShortenedUrlRepository.cs index d9e845e..f0ba6a2 100644 --- a/Shortify.NET.Persistence/Repository/ShortenedUrlRepository.cs +++ b/Shortify.NET.Persistence/Repository/ShortenedUrlRepository.cs @@ -66,13 +66,25 @@ public void Update(ShortenedUrl shortenedUrl) true, cancellationToken); - public async Task?> GetAllByUserIdAsync(Guid userId, CancellationToken cancellationToken = default) + public async Task?> GetAllByUserIdAsync( + Guid userId, + DateTime? fromDate, + DateTime? toDate, + CancellationToken cancellationToken = default) { - return await _appDbContext - .Set() - .AsNoTracking() - .Where(x => x.UserId == userId) - .ToListAsync(cancellationToken); + var query = _appDbContext + .Set() + .AsNoTracking() + .Where(x => + x.UserId == userId && + x.RowStatus); + + if (fromDate is not null && toDate is not null) + { + query = query.Where(url => url.CreatedOnUtc >= fromDate && url.CreatedOnUtc < toDate); + } + + return await query.ToListAsync(cancellationToken); } public async Task IsCodeUniqueAsync(string code, CancellationToken cancellationToken = default)