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
+
+

A powerful .NET 8 URL shortener with JWT auth analytics and caching designed for scalability and security.
+
+
Code Quality
[](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
+
+
+
+
+
+
🛡️ 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)