diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fe1152b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,30 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md +!**/.gitignore +!.git/HEAD +!.git/config +!.git/packed-refs +!.git/refs/heads/** \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba7a514 --- /dev/null +++ b/.gitignore @@ -0,0 +1,428 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates +*.env + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ + +[Dd]ebug/x64/ +[Dd]ebugPublic/x64/ +[Rr]elease/x64/ +[Rr]eleases/x64/ +bin/x64/ +obj/x64/ + +[Dd]ebug/x86/ +[Dd]ebugPublic/x86/ +[Rr]elease/x86/ +[Rr]eleases/x86/ +bin/x86/ +obj/x86/ + +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +[Aa][Rr][Mm]64[Ee][Cc]/ +bld/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Build results on 'Bin' directories +**/[Bb]in/* +# Uncomment if you have tasks that rely on *.refresh files to move binaries +# (https://github.com/github/gitignore/pull/3736) +#!**/[Bb]in/*.refresh + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* +*.trx + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Approval Tests result files +*.received.* + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.idb +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +**/.paket/paket.exe +paket-files/ + +# FAKE - F# Make +**/.fake/ + +# CodeRush personal settings +**/.cr/personal + +# Python Tools for Visual Studio (PTVS) +**/__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +#tools/** +#!tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog +MSBuild_Logs/ + +# AWS SAM Build and Temporary Artifacts folder +.aws-sam + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +**/.mfractor/ + +# Local History for Visual Studio +**/.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +**/.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp \ No newline at end of file diff --git a/OnlyPrompt.Backend/ApiModels/Auth/Models.cs b/OnlyPrompt.Backend/ApiModels/Auth/Models.cs new file mode 100644 index 0000000..90dc0a6 --- /dev/null +++ b/OnlyPrompt.Backend/ApiModels/Auth/Models.cs @@ -0,0 +1,4 @@ +namespace OnlyPrompt.Backend.ApiModels.Auth +{ + public record ApiUser(Guid Id, string UserName, string Email, string[] Roles); +} diff --git a/OnlyPrompt.Backend/ApiModels/Auth/Requests.cs b/OnlyPrompt.Backend/ApiModels/Auth/Requests.cs new file mode 100644 index 0000000..fd26cb5 --- /dev/null +++ b/OnlyPrompt.Backend/ApiModels/Auth/Requests.cs @@ -0,0 +1,8 @@ +using OnlyPrompt.Backend.ApiModels.Validators; +using System.ComponentModel.DataAnnotations; + +namespace OnlyPrompt.Backend.ApiModels.Auth +{ + public record ApiLoginRequest(string UserNameOrEmail, string Password); + public record ApiRegisterRequest([MaxLength(100)] string DisplayName, [MaxLength(100)][NoWhitespace] string UserName, string Email, string Password); +} diff --git a/OnlyPrompt.Backend/ApiModels/UserProfile/Models.cs b/OnlyPrompt.Backend/ApiModels/UserProfile/Models.cs new file mode 100644 index 0000000..3894287 --- /dev/null +++ b/OnlyPrompt.Backend/ApiModels/UserProfile/Models.cs @@ -0,0 +1,4 @@ +namespace OnlyPrompt.Backend.ApiModels.UserProfile +{ + public record ApiUserProfile(string DisplayName, string Slug, string? Bio, string AvatarUrl, string? Specialities, double AverageRating, int Subscribers); +} diff --git a/OnlyPrompt.Backend/ApiModels/Validators/NoWhitespaceAttribute.cs b/OnlyPrompt.Backend/ApiModels/Validators/NoWhitespaceAttribute.cs new file mode 100644 index 0000000..dd4b7eb --- /dev/null +++ b/OnlyPrompt.Backend/ApiModels/Validators/NoWhitespaceAttribute.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; +using System.Reflection.Metadata.Ecma335; + +namespace OnlyPrompt.Backend.ApiModels.Validators +{ + public class NoWhitespaceAttribute : ValidationAttribute + { + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + { + if(value is string strValue) + { + if(strValue.Any(c => char.IsWhiteSpace(c))) + return new ValidationResult($"{validationContext.DisplayName} should not contain any whitespace characters."); + + return ValidationResult.Success; + } + + return base.IsValid(value, validationContext); + } + } + +} diff --git a/OnlyPrompt.Backend/Controllers/AdminController.cs b/OnlyPrompt.Backend/Controllers/AdminController.cs new file mode 100644 index 0000000..8b657c1 --- /dev/null +++ b/OnlyPrompt.Backend/Controllers/AdminController.cs @@ -0,0 +1,11 @@ +using OnlyPrompt.Backend.Database; + +namespace OnlyPrompt.Backend.Controllers +{ + public class AdminController : BaseController + { + public AdminController(OnlyPromptContext db) : base(db) + { + } + } +} diff --git a/OnlyPrompt.Backend/Controllers/AuthController.cs b/OnlyPrompt.Backend/Controllers/AuthController.cs new file mode 100644 index 0000000..dc4454e --- /dev/null +++ b/OnlyPrompt.Backend/Controllers/AuthController.cs @@ -0,0 +1,105 @@ +using AutoMapper; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OnlyPrompt.Backend.ApiModels.Auth; +using OnlyPrompt.Backend.Database; +using OnlyPrompt.Backend.Database.Models; +using OnlyPrompt.Backend.Services.Jwt; +using OnlyPrompt.Backend.Utils; + +namespace OnlyPrompt.Backend.Controllers +{ + [ApiController] + [Route("api/v1/auth")] + public class AuthController : BaseController + { + private static readonly CookieOptions AuthCookieOptions = new CookieOptions { Secure = true, HttpOnly = true, IsEssential = true }; + private readonly IPasswordHasher _passwordHasher; + private readonly ITokenService _jwtService; + private readonly ILogger _logger; + private readonly IMapper _mapper; + + public AuthController(OnlyPromptContext db, IPasswordHasher passwordHasher, IMapper mapper, ILogger logger, ITokenService jwtService) : base(db) + { + _passwordHasher=passwordHasher; + _mapper=mapper; + _logger=logger; + _jwtService=jwtService; + } + + + [AllowAnonymous] + [HttpPost("login")] + public async Task, NotFound>> LoginAsync([FromBody] ApiLoginRequest request) + { + var user = await FindUserAsync(request.UserNameOrEmail); + if (user is null) + return TypedResults.NotFound("User not found"); + + var verificationResult = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, request.Password); + if (verificationResult == PasswordVerificationResult.Failed) + return TypedResults.NotFound("User not found"); // Don't reveal that the user exists + + if (user.IsLockoutEnabled) + return TypedResults.BadRequest("User is locked out"); // Don't reveal that the user exists + + var token = _jwtService.BuildToken(user, out var validUntil); + this.Response.Cookies.Append("jwt", token, AuthCookieOptions.Copy(c => c.Expires = validUntil)); + return TypedResults.Redirect("feed"); + } + + [AllowAnonymous] + [HttpPost("register")] + public async Task>> RegisterAsync([FromBody] ApiRegisterRequest request) + { + var existingUser = await FindUserAsync(request.UserName, request.Email); + if (existingUser is not null) + { + var errors = new Dictionary(); + + if (existingUser.UserName == request.UserName) + errors.Add(nameof(request.UserName), ["Username is already taken"]); + + if (existingUser.Email == request.Email) + errors.Add(nameof(request.Email), ["Email is already registered"]); + + return TypedResults.ValidationProblem(errors); + } + + var id = Guid.NewGuid(); + var slug = await SlugHelper.GenerateUniqueSlug(request.UserName, s => _db.UserProfiles.AnyAsync(up => up.Slug == s), 32); + var avatarUrl = $"https://api.dicebear.com/9.x/bottts/svg?seed={id}"; + var newUser = new UserModel + { + Id = id, + Profile = new UserProfileModel + { + AvatarUrl = avatarUrl, + DisplayName = request.DisplayName, + Slug = slug, + }, + Roles = [ModelConstants.UserRole], + PasswordHash = null, + UserName = request.UserName, + Email = request.Email, + IsLockoutEnabled = false, + }; + + newUser.PasswordHash = _passwordHasher.HashPassword(newUser, request.Password); + _db.Users.Add(newUser); + await _db.SaveChangesAsync(); + return TypedResults.Ok(_mapper.Map(newUser)); + } + + + [HttpPost("logout")] + public RedirectHttpResult Logout() + { + this.Response.Cookies.Delete("jwt", AuthCookieOptions); + return TypedResults.Redirect("login"); + } + } +} diff --git a/OnlyPrompt.Backend/Controllers/BaseController.cs b/OnlyPrompt.Backend/Controllers/BaseController.cs new file mode 100644 index 0000000..0badc54 --- /dev/null +++ b/OnlyPrompt.Backend/Controllers/BaseController.cs @@ -0,0 +1,35 @@ +using AutoMapper; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OnlyPrompt.Backend.Database; +using OnlyPrompt.Backend.Database.Models; +using OnlyPrompt.Backend.Utils; + +namespace OnlyPrompt.Backend.Controllers +{ + public abstract class BaseController : Controller + { + protected OnlyPromptContext _db; + protected IMapper _mapper; + + public BaseController(OnlyPromptContext db, IMapper mapper) + { + _db=db; + _mapper=mapper; + } + + public Task FindUserAsync(Guid id) => _db.Users.FirstOrDefaultAsync(x => x.Id == id); + public Task FindUserAsync(string userName, string email) => _db.Users.FirstOrDefaultAsync(x => x.Email == email || x.UserName == userName); + public Task FindUserAsync(string emailOrUsername) => _db.Users.FirstOrDefaultAsync(x => x.Email == emailOrUsername || x.UserName == emailOrUsername); + + public async Task GetUserAsync() + { + var id = User.GetUserId(); + if (id.HasValue == false) + return null; + + var user = await _db.Users.FindAsync(id.Value); + return user; + } + } +} diff --git a/OnlyPrompt.Backend/Controllers/ProfileController.cs b/OnlyPrompt.Backend/Controllers/ProfileController.cs new file mode 100644 index 0000000..014054d --- /dev/null +++ b/OnlyPrompt.Backend/Controllers/ProfileController.cs @@ -0,0 +1,39 @@ +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OnlyPrompt.Backend.ApiModels.UserProfile; +using OnlyPrompt.Backend.Database; +using OnlyPrompt.Backend.Utils; + +namespace OnlyPrompt.Backend.Controllers +{ + [ApiController] + [Route("api/v1/profiles")] + [Authorize(Roles = ModelConstants.UserRole)] + public class ProfileController : BaseController + { + public ProfileController(OnlyPromptContext db, IMapper mapper) : base(db, mapper) + { + } + + [HttpGet("{id}")] + public async Task, Ok>> GetProfileAsync(Identifier id) + { + var userId = User.GetUserId(); + var profile = await _db.UserProfiles.OfIdentifer(id) + .Where(up => up.IsPublic || up.Id == userId) + .ProjectTo(_mapper.ConfigurationProvider) + .FirstOrDefaultAsync(); + + if (profile is null) + return TypedResults.NotFound("Profile not found or is private."); + + return TypedResults.Ok(profile); + } + + } +} diff --git a/OnlyPrompt.Backend/Database/Core/EntityBase.cs b/OnlyPrompt.Backend/Database/Core/EntityBase.cs new file mode 100644 index 0000000..0962583 --- /dev/null +++ b/OnlyPrompt.Backend/Database/Core/EntityBase.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OnlyPrompt.Backend.Database.Core +{ + public class EntityBase : IEntity + { + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public Guid Id { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/OnlyPrompt.Backend/Database/Core/IEntity.cs b/OnlyPrompt.Backend/Database/Core/IEntity.cs new file mode 100644 index 0000000..762070c --- /dev/null +++ b/OnlyPrompt.Backend/Database/Core/IEntity.cs @@ -0,0 +1,7 @@ +namespace OnlyPrompt.Backend.Database.Core +{ + public interface IEntity + { + public Guid Id { get; } + } +} diff --git a/OnlyPrompt.Backend/Database/Core/IHasSlug.cs b/OnlyPrompt.Backend/Database/Core/IHasSlug.cs new file mode 100644 index 0000000..94628ae --- /dev/null +++ b/OnlyPrompt.Backend/Database/Core/IHasSlug.cs @@ -0,0 +1,7 @@ +namespace OnlyPrompt.Backend.Database.Core +{ + public interface IHasSlug + { + public string Slug { get; set; } + } +} diff --git a/OnlyPrompt.Backend/Database/ModelConstants.cs b/OnlyPrompt.Backend/Database/ModelConstants.cs new file mode 100644 index 0000000..1b4bbc5 --- /dev/null +++ b/OnlyPrompt.Backend/Database/ModelConstants.cs @@ -0,0 +1,8 @@ +namespace OnlyPrompt.Backend.Database +{ + public static class ModelConstants + { + public const int MaxSlugLength = 100; + public const string UserRole = "user"; + } +} diff --git a/OnlyPrompt.Backend/Database/Models/CategoryModel.cs b/OnlyPrompt.Backend/Database/Models/CategoryModel.cs new file mode 100644 index 0000000..3c56dfc --- /dev/null +++ b/OnlyPrompt.Backend/Database/Models/CategoryModel.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using OnlyPrompt.Backend.Database.Core; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OnlyPrompt.Backend.Database.Models +{ + [Index(nameof(Slug), IsUnique = true)] + public class CategoryModel : EntityBase, IHasSlug + { + [MaxLength(ModelConstants.MaxSlugLength)] + [Column(TypeName = "citext")] + public required string Slug { get; set; } + public required string Name { get; set; } + public string? Description { get; set; } + + public virtual IList Prompts { get; set; } = new List(); + } +} diff --git a/OnlyPrompt.Backend/Database/Models/PromptModel.cs b/OnlyPrompt.Backend/Database/Models/PromptModel.cs new file mode 100644 index 0000000..47c2758 --- /dev/null +++ b/OnlyPrompt.Backend/Database/Models/PromptModel.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore; +using OnlyPrompt.Backend.Database.Core; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OnlyPrompt.Backend.Database.Models +{ + [Index(nameof(Slug), IsUnique = true)] + public class PromptModel : EntityBase, IHasSlug + { + [Required] + [ForeignKey(nameof(Creator))] + public Guid CreatorId { get; set; } + + [DeleteBehavior(DeleteBehavior.Cascade)] + public required virtual UserModel Creator { get; set; } + + [Required] + [ForeignKey(nameof(Category))] + public Guid CategoryId { get; set; } + + [DeleteBehavior(DeleteBehavior.Cascade)] + public required virtual CategoryModel Category { get; set; } + + [MaxLength(200)] + public required string Title { get; set; } + public required string Content { get; set; } + + + [MaxLength(ModelConstants.MaxSlugLength)] + [Column(TypeName = "citext")] + public required string Slug { get; set; } + + [ForeignKey(nameof(SubscriptionTier))] + public Guid? SubscriptionTierId { get; set; } + + [DeleteBehavior(DeleteBehavior.SetNull)] + public virtual SubscriptionTierModel? SubscriptionTier { get; set; } + public virtual IList Reviews { get; set; } = new List(); + } +} diff --git a/OnlyPrompt.Backend/Database/Models/ReviewModel.cs b/OnlyPrompt.Backend/Database/Models/ReviewModel.cs new file mode 100644 index 0000000..28eee85 --- /dev/null +++ b/OnlyPrompt.Backend/Database/Models/ReviewModel.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using OnlyPrompt.Backend.Database.Core; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OnlyPrompt.Backend.Database.Models +{ + [Index(nameof(ReviewerId), nameof(PromptId), IsUnique = true)] + public class ReviewModel : EntityBase + { + [Required] + [ForeignKey(nameof(Reviewer))] + public Guid ReviewerId { get; set; } + + [DeleteBehavior(DeleteBehavior.Cascade)] + public virtual UserModel Reviewer { get; set; } + + [Required] + [ForeignKey(nameof(Prompt))] + public Guid PromptId { get; set; } + + [DeleteBehavior(DeleteBehavior.Cascade)] + public virtual PromptModel Prompt { get; set; } + + [Range(1, 5)] + public int Rating { get; set; } + + [MaxLength(200)] + public string? Comment { get; set; } = null; + } +} diff --git a/OnlyPrompt.Backend/Database/Models/SubscriptionModel.cs b/OnlyPrompt.Backend/Database/Models/SubscriptionModel.cs new file mode 100644 index 0000000..7381d52 --- /dev/null +++ b/OnlyPrompt.Backend/Database/Models/SubscriptionModel.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OnlyPrompt.Backend.Database.Models +{ + [Table("UserSubscriptions")] + [PrimaryKey(nameof(SubscriberId), nameof(SubscribedToId))] + public class SubscriptionModel + { + [Required] + [ForeignKey(nameof(SubscribedTo))] + public Guid SubscribedToId { get; set; } + + [DeleteBehavior(DeleteBehavior.Cascade)] + public required virtual UserModel SubscribedTo { get; set; } + + [Required] + [ForeignKey(nameof(Subscriber))] + public Guid SubscriberId { get; set; } + + [DeleteBehavior(DeleteBehavior.Cascade)] + public required virtual UserModel Subscriber { get; set; } + + + [ForeignKey(nameof(SubscriptionTier))] + public virtual Guid? SubscriptionTierId { get; set; } + + [DeleteBehavior(DeleteBehavior.SetNull)] + public virtual SubscriptionTierModel? SubscriptionTier { get; set; } + } +} diff --git a/OnlyPrompt.Backend/Database/Models/SubscriptionTierModel.cs b/OnlyPrompt.Backend/Database/Models/SubscriptionTierModel.cs new file mode 100644 index 0000000..9bacaf6 --- /dev/null +++ b/OnlyPrompt.Backend/Database/Models/SubscriptionTierModel.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using OnlyPrompt.Backend.Database.Core; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OnlyPrompt.Backend.Database.Models +{ + [Index(nameof(Level), nameof(UserId), IsUnique = true)] + public class SubscriptionTierModel : EntityBase + { + [Required] + public required string Name { get; set; } + + [MaxLength(1000)] + public string? Description { get; set; } + + [Required] + [ForeignKey(nameof(User))] + public Guid UserId { get; set; } + + [DeleteBehavior(DeleteBehavior.Cascade)] + public required virtual UserModel User { get; set; } + + public decimal MonthlyPrice { get; set; } + public int Level { get; set; } + + public virtual IList Prompts { get; set; } = new List(); + public virtual IList Subscriptions { get; set; } = new List(); + } +} diff --git a/OnlyPrompt.Backend/Database/Models/UserModel.cs b/OnlyPrompt.Backend/Database/Models/UserModel.cs new file mode 100644 index 0000000..e6fae25 --- /dev/null +++ b/OnlyPrompt.Backend/Database/Models/UserModel.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using OnlyPrompt.Backend.Database.Core; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OnlyPrompt.Backend.Database.Models +{ + [Index(nameof(Email), IsUnique = true)] + [Index(nameof(UserName), IsUnique = true)] + public class UserModel : EntityBase + { + [MaxLength(100)] + [Column(TypeName = "citext")] + public required string UserName { get; set; } + + [Column(TypeName = "citext")] + public required string Email { get; set; } + public required string PasswordHash { get; set; } + public required string[] Roles { get; set; } + + [Required] + public required virtual UserProfileModel Profile { get; set; } + + public virtual IList Prompts { get; set; } = new List(); + public virtual IList Subscriptions { get; set; } = new List(); + public virtual IList Subscribers { get; set; } = new List(); + + public bool IsLockoutEnabled { get; set; } = false; + } +} diff --git a/OnlyPrompt.Backend/Database/Models/UserProfileModel.cs b/OnlyPrompt.Backend/Database/Models/UserProfileModel.cs new file mode 100644 index 0000000..abe7984 --- /dev/null +++ b/OnlyPrompt.Backend/Database/Models/UserProfileModel.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore; +using OnlyPrompt.Backend.Database.Core; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OnlyPrompt.Backend.Database.Models +{ + [Index(nameof(Slug), IsUnique = true)] + public class UserProfileModel : EntityBase, IHasSlug + { + [MaxLength(100)] + public required string DisplayName { get; set; } + + [MaxLength(ModelConstants.MaxSlugLength)] + [Column(TypeName = "citext")] + public required string Slug { get; set; } + + [MaxLength(2000)] + public string? Bio { get; set; } + + public required string AvatarUrl { get; set; } + + [MaxLength(200)] + public string? Specialities { get; set; } + + public virtual UserModel User { get; set; } + public bool IsPublic { get; set; } = false; + } +} diff --git a/OnlyPrompt.Backend/Database/OnlyPromptContext.cs b/OnlyPrompt.Backend/Database/OnlyPromptContext.cs new file mode 100644 index 0000000..76a4969 --- /dev/null +++ b/OnlyPrompt.Backend/Database/OnlyPromptContext.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using OnlyPrompt.Backend.Database.Models; + +namespace OnlyPrompt.Backend.Database +{ + public class OnlyPromptContext : DbContext + { + public DbSet Users { get; set; } + public DbSet UserProfiles { get; set; } + public DbSet Categories { get; set; } + public DbSet Prompts { get; set; } + public DbSet SubscriptionTiers { get; set; } + public DbSet Subscriptions { get; set; } + public DbSet Reviews { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + if(optionsBuilder.IsConfigured == false) + { + optionsBuilder.UseNpgsql(); + optionsBuilder.UseLazyLoadingProxies(); + } + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.Entity(entity => + { + entity.HasOne(e => e.Profile) + .WithOne(p => p.User) + .HasForeignKey(p => p.Id); + }); + + modelBuilder.Entity(entity => + { + entity.HasOne(e => e.Subscriber) + .WithMany(s => s.Subscriptions) + .HasForeignKey(e => e.SubscriberId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(e => e.SubscribedTo) + .WithMany(c => c.Subscribers) + .HasForeignKey(e => e.SubscribedToId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(e => e.SubscriptionTier) + .WithMany(t => t.Subscriptions) + .HasForeignKey(e => e.SubscriptionTierId) + .OnDelete(DeleteBehavior.SetNull); + }); + } + } +} diff --git a/OnlyPrompt.Backend/Dockerfile b/OnlyPrompt.Backend/Dockerfile new file mode 100644 index 0000000..377780c --- /dev/null +++ b/OnlyPrompt.Backend/Dockerfile @@ -0,0 +1,30 @@ +# See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. + +# This stage is used when running from VS in fast mode (Default for Debug configuration) +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8080 +EXPOSE 8081 + + +# This stage is used to build the service project +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["OnlyPrompt.Backend/OnlyPrompt.Backend.csproj", "OnlyPrompt.Backend/"] +RUN dotnet restore "./OnlyPrompt.Backend/OnlyPrompt.Backend.csproj" +COPY . . +WORKDIR "/src/OnlyPrompt.Backend" +RUN dotnet build "./OnlyPrompt.Backend.csproj" -c $BUILD_CONFIGURATION -o /app/build + +# This stage is used to publish the service project to be copied to the final stage +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./OnlyPrompt.Backend.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +# This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration) +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "OnlyPrompt.Backend.dll"] \ No newline at end of file diff --git a/OnlyPrompt.Backend/Migrations/20260411191205_Initial.Designer.cs b/OnlyPrompt.Backend/Migrations/20260411191205_Initial.Designer.cs new file mode 100644 index 0000000..ae7e076 --- /dev/null +++ b/OnlyPrompt.Backend/Migrations/20260411191205_Initial.Designer.cs @@ -0,0 +1,419 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OnlyPrompt.Backend.Database; + +#nullable disable + +namespace OnlyPrompt.Backend.Migrations +{ + [DbContext(typeof(OnlyPromptContext))] + [Migration("20260411191205_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true) + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.CategoryModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext"); + + b.Property("SubscriptionTierId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("CreatorId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("SubscriptionTierId"); + + b.ToTable("Prompts"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.ReviewModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PromptId") + .HasColumnType("uuid"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("ReviewerId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PromptId"); + + b.HasIndex("ReviewerId", "PromptId") + .IsUnique(); + + b.ToTable("Reviews"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionModel", b => + { + b.Property("SubscriberId") + .HasColumnType("uuid"); + + b.Property("SubscribedToId") + .HasColumnType("uuid"); + + b.Property("SubscriptionTierId") + .HasColumnType("uuid"); + + b.HasKey("SubscriberId", "SubscribedToId"); + + b.HasIndex("SubscribedToId"); + + b.HasIndex("SubscriptionTierId"); + + b.ToTable("UserSubscriptions"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("MonthlyPrice") + .HasColumnType("numeric"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Level", "UserId") + .IsUnique(); + + b.ToTable("SubscriptionTiers"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("citext"); + + b.Property("IsLockoutEnabled") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.PrimitiveCollection("Roles") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserProfileModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("Bio") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsPublic") + .HasColumnType("boolean"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext"); + + b.Property("Specialities") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("UserProfiles"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b => + { + b.HasOne("OnlyPrompt.Backend.Database.Models.CategoryModel", "Category") + .WithMany("Prompts") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Creator") + .WithMany("Prompts") + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", "SubscriptionTier") + .WithMany("Prompts") + .HasForeignKey("SubscriptionTierId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Category"); + + b.Navigation("Creator"); + + b.Navigation("SubscriptionTier"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.ReviewModel", b => + { + b.HasOne("OnlyPrompt.Backend.Database.Models.PromptModel", "Prompt") + .WithMany("Reviews") + .HasForeignKey("PromptId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Reviewer") + .WithMany() + .HasForeignKey("ReviewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Prompt"); + + b.Navigation("Reviewer"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionModel", b => + { + b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "SubscribedTo") + .WithMany("Subscribers") + .HasForeignKey("SubscribedToId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Subscriber") + .WithMany("Subscriptions") + .HasForeignKey("SubscriberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", "SubscriptionTier") + .WithMany("Subscriptions") + .HasForeignKey("SubscriptionTierId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("SubscribedTo"); + + b.Navigation("Subscriber"); + + b.Navigation("SubscriptionTier"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b => + { + b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserProfileModel", b => + { + b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "User") + .WithOne("Profile") + .HasForeignKey("OnlyPrompt.Backend.Database.Models.UserProfileModel", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.CategoryModel", b => + { + b.Navigation("Prompts"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b => + { + b.Navigation("Reviews"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b => + { + b.Navigation("Prompts"); + + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserModel", b => + { + b.Navigation("Profile") + .IsRequired(); + + b.Navigation("Prompts"); + + b.Navigation("Subscribers"); + + b.Navigation("Subscriptions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/OnlyPrompt.Backend/Migrations/20260411191205_Initial.cs b/OnlyPrompt.Backend/Migrations/20260411191205_Initial.cs new file mode 100644 index 0000000..fe09980 --- /dev/null +++ b/OnlyPrompt.Backend/Migrations/20260411191205_Initial.cs @@ -0,0 +1,300 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OnlyPrompt.Backend.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:PostgresExtension:citext", ",,"); + + migrationBuilder.CreateTable( + name: "Categories", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Slug = table.Column(type: "citext", maxLength: 100, nullable: false), + Name = table.Column(type: "text", nullable: false), + Description = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Categories", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserName = table.Column(type: "citext", maxLength: 100, nullable: false), + Email = table.Column(type: "citext", nullable: false), + PasswordHash = table.Column(type: "text", nullable: false), + Roles = table.Column(type: "text[]", nullable: false), + IsLockoutEnabled = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "SubscriptionTiers", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "text", nullable: false), + Description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + UserId = table.Column(type: "uuid", nullable: false), + MonthlyPrice = table.Column(type: "numeric", nullable: false), + Level = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SubscriptionTiers", x => x.Id); + table.ForeignKey( + name: "FK_SubscriptionTiers_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserProfiles", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + DisplayName = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Slug = table.Column(type: "citext", maxLength: 100, nullable: false), + Bio = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + AvatarUrl = table.Column(type: "text", nullable: false), + Specialities = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + IsPublic = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserProfiles", x => x.Id); + table.ForeignKey( + name: "FK_UserProfiles_Users_Id", + column: x => x.Id, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Prompts", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CreatorId = table.Column(type: "uuid", nullable: false), + CategoryId = table.Column(type: "uuid", nullable: false), + Title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Content = table.Column(type: "text", nullable: false), + Slug = table.Column(type: "citext", maxLength: 100, nullable: false), + SubscriptionTierId = table.Column(type: "uuid", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Prompts", x => x.Id); + table.ForeignKey( + name: "FK_Prompts_Categories_CategoryId", + column: x => x.CategoryId, + principalTable: "Categories", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Prompts_SubscriptionTiers_SubscriptionTierId", + column: x => x.SubscriptionTierId, + principalTable: "SubscriptionTiers", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_Prompts_Users_CreatorId", + column: x => x.CreatorId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserSubscriptions", + columns: table => new + { + SubscribedToId = table.Column(type: "uuid", nullable: false), + SubscriberId = table.Column(type: "uuid", nullable: false), + SubscriptionTierId = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_UserSubscriptions", x => new { x.SubscriberId, x.SubscribedToId }); + table.ForeignKey( + name: "FK_UserSubscriptions_SubscriptionTiers_SubscriptionTierId", + column: x => x.SubscriptionTierId, + principalTable: "SubscriptionTiers", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_UserSubscriptions_Users_SubscribedToId", + column: x => x.SubscribedToId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserSubscriptions_Users_SubscriberId", + column: x => x.SubscriberId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Reviews", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ReviewerId = table.Column(type: "uuid", nullable: false), + PromptId = table.Column(type: "uuid", nullable: false), + Rating = table.Column(type: "integer", nullable: false), + Comment = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Reviews", x => x.Id); + table.ForeignKey( + name: "FK_Reviews_Prompts_PromptId", + column: x => x.PromptId, + principalTable: "Prompts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Reviews_Users_ReviewerId", + column: x => x.ReviewerId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Categories_Slug", + table: "Categories", + column: "Slug", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Prompts_CategoryId", + table: "Prompts", + column: "CategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_Prompts_CreatorId", + table: "Prompts", + column: "CreatorId"); + + migrationBuilder.CreateIndex( + name: "IX_Prompts_Slug", + table: "Prompts", + column: "Slug", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Prompts_SubscriptionTierId", + table: "Prompts", + column: "SubscriptionTierId"); + + migrationBuilder.CreateIndex( + name: "IX_Reviews_PromptId", + table: "Reviews", + column: "PromptId"); + + migrationBuilder.CreateIndex( + name: "IX_Reviews_ReviewerId_PromptId", + table: "Reviews", + columns: new[] { "ReviewerId", "PromptId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SubscriptionTiers_Level_UserId", + table: "SubscriptionTiers", + columns: new[] { "Level", "UserId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SubscriptionTiers_UserId", + table: "SubscriptionTiers", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_UserProfiles_Slug", + table: "UserProfiles", + column: "Slug", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_Email", + table: "Users", + column: "Email", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_UserName", + table: "Users", + column: "UserName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UserSubscriptions_SubscribedToId", + table: "UserSubscriptions", + column: "SubscribedToId"); + + migrationBuilder.CreateIndex( + name: "IX_UserSubscriptions_SubscriptionTierId", + table: "UserSubscriptions", + column: "SubscriptionTierId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Reviews"); + + migrationBuilder.DropTable( + name: "UserProfiles"); + + migrationBuilder.DropTable( + name: "UserSubscriptions"); + + migrationBuilder.DropTable( + name: "Prompts"); + + migrationBuilder.DropTable( + name: "Categories"); + + migrationBuilder.DropTable( + name: "SubscriptionTiers"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/OnlyPrompt.Backend/Migrations/OnlyPromptContextModelSnapshot.cs b/OnlyPrompt.Backend/Migrations/OnlyPromptContextModelSnapshot.cs new file mode 100644 index 0000000..7295fc4 --- /dev/null +++ b/OnlyPrompt.Backend/Migrations/OnlyPromptContextModelSnapshot.cs @@ -0,0 +1,416 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OnlyPrompt.Backend.Database; + +#nullable disable + +namespace OnlyPrompt.Backend.Migrations +{ + [DbContext(typeof(OnlyPromptContext))] + partial class OnlyPromptContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true) + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.CategoryModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext"); + + b.Property("SubscriptionTierId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("CreatorId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("SubscriptionTierId"); + + b.ToTable("Prompts"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.ReviewModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PromptId") + .HasColumnType("uuid"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("ReviewerId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PromptId"); + + b.HasIndex("ReviewerId", "PromptId") + .IsUnique(); + + b.ToTable("Reviews"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionModel", b => + { + b.Property("SubscriberId") + .HasColumnType("uuid"); + + b.Property("SubscribedToId") + .HasColumnType("uuid"); + + b.Property("SubscriptionTierId") + .HasColumnType("uuid"); + + b.HasKey("SubscriberId", "SubscribedToId"); + + b.HasIndex("SubscribedToId"); + + b.HasIndex("SubscriptionTierId"); + + b.ToTable("UserSubscriptions"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("MonthlyPrice") + .HasColumnType("numeric"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Level", "UserId") + .IsUnique(); + + b.ToTable("SubscriptionTiers"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("citext"); + + b.Property("IsLockoutEnabled") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.PrimitiveCollection("Roles") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserProfileModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("Bio") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsPublic") + .HasColumnType("boolean"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext"); + + b.Property("Specialities") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("UserProfiles"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b => + { + b.HasOne("OnlyPrompt.Backend.Database.Models.CategoryModel", "Category") + .WithMany("Prompts") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Creator") + .WithMany("Prompts") + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", "SubscriptionTier") + .WithMany("Prompts") + .HasForeignKey("SubscriptionTierId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Category"); + + b.Navigation("Creator"); + + b.Navigation("SubscriptionTier"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.ReviewModel", b => + { + b.HasOne("OnlyPrompt.Backend.Database.Models.PromptModel", "Prompt") + .WithMany("Reviews") + .HasForeignKey("PromptId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Reviewer") + .WithMany() + .HasForeignKey("ReviewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Prompt"); + + b.Navigation("Reviewer"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionModel", b => + { + b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "SubscribedTo") + .WithMany("Subscribers") + .HasForeignKey("SubscribedToId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Subscriber") + .WithMany("Subscriptions") + .HasForeignKey("SubscriberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", "SubscriptionTier") + .WithMany("Subscriptions") + .HasForeignKey("SubscriptionTierId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("SubscribedTo"); + + b.Navigation("Subscriber"); + + b.Navigation("SubscriptionTier"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b => + { + b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserProfileModel", b => + { + b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "User") + .WithOne("Profile") + .HasForeignKey("OnlyPrompt.Backend.Database.Models.UserProfileModel", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.CategoryModel", b => + { + b.Navigation("Prompts"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b => + { + b.Navigation("Reviews"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b => + { + b.Navigation("Prompts"); + + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserModel", b => + { + b.Navigation("Profile") + .IsRequired(); + + b.Navigation("Prompts"); + + b.Navigation("Subscribers"); + + b.Navigation("Subscriptions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/OnlyPrompt.Backend/OnlyPrompt.Backend.csproj b/OnlyPrompt.Backend/OnlyPrompt.Backend.csproj new file mode 100644 index 0000000..0c379d3 --- /dev/null +++ b/OnlyPrompt.Backend/OnlyPrompt.Backend.csproj @@ -0,0 +1,37 @@ + + + + net10.0 + enable + enable + aspnet-OnlyPrompt.Backend-dc36f9f9-a53c-4ee3-a0b4-3396ee55b7c8 + Linux + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/OnlyPrompt.Backend/OnlyPrompt.Backend.http b/OnlyPrompt.Backend/OnlyPrompt.Backend.http new file mode 100644 index 0000000..d526ecb --- /dev/null +++ b/OnlyPrompt.Backend/OnlyPrompt.Backend.http @@ -0,0 +1,6 @@ +@OnlyPrompt.Backend_HostAddress = http://localhost:5093 + +GET {{OnlyPrompt.Backend_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/OnlyPrompt.Backend/Program.cs b/OnlyPrompt.Backend/Program.cs new file mode 100644 index 0000000..60c9fbd --- /dev/null +++ b/OnlyPrompt.Backend/Program.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Identity.Web; +using OnlyPrompt.Backend.Database; +using OnlyPrompt.Backend.Database.Models; +using OnlyPrompt.Backend.Services.Jwt; +using OnlyPrompt.Backend.Utils; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme); +builder.Services.AddDbContext(opts => +{ + opts.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")); + opts.UseLazyLoadingProxies(); +}); + +builder.Services.AddSingleton, PasswordHasher>(); +builder.Services.AddSingleton(); +builder.Services.AddAutoMapper(AutoMapperSetup.Setup); + +builder.Services.AddAuthorization(); +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, opts => { + opts.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + if (context.Request.Cookies.ContainsKey("jwt")) + context.Token = context.Request.Cookies["jwt"]; + + return Task.CompletedTask; + } + }; + }); + +builder.Services.AddControllers(); +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/OnlyPrompt.Backend/Properties/launchSettings.json b/OnlyPrompt.Backend/Properties/launchSettings.json new file mode 100644 index 0000000..0f86129 --- /dev/null +++ b/OnlyPrompt.Backend/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "profiles": { + "https": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:7163;http://localhost:5093" + }, + "Container (Dockerfile)": { + "commandName": "Docker", + "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}", + "environmentVariables": { + "ASPNETCORE_HTTPS_PORTS": "8081", + "ASPNETCORE_HTTP_PORTS": "8080" + }, + "publishAllPorts": true, + "useSSL": true + } + }, + "$schema": "https://json.schemastore.org/launchsettings.json" +} \ No newline at end of file diff --git a/OnlyPrompt.Backend/Services/Jwt/ITokenService.cs b/OnlyPrompt.Backend/Services/Jwt/ITokenService.cs new file mode 100644 index 0000000..3c5d961 --- /dev/null +++ b/OnlyPrompt.Backend/Services/Jwt/ITokenService.cs @@ -0,0 +1,15 @@ +using OnlyPrompt.Backend.Database.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; + +namespace OnlyPrompt.Backend.Services.Jwt +{ + public interface ITokenService + { + string BuildToken(UserModel user, out DateTime validUntil); + bool ValidateToken(string token, out ClaimsPrincipal claims); + } +} diff --git a/OnlyPrompt.Backend/Services/Jwt/JwtTokenService.cs b/OnlyPrompt.Backend/Services/Jwt/JwtTokenService.cs new file mode 100644 index 0000000..22a7eb8 --- /dev/null +++ b/OnlyPrompt.Backend/Services/Jwt/JwtTokenService.cs @@ -0,0 +1,72 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; +using OnlyPrompt.Backend.Database.Models; +using OnlyPrompt.Backend.Utils; +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Security.Claims; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +namespace OnlyPrompt.Backend.Services.Jwt +{ + public class JwtTokenService : ITokenService + { + private string _key; + private string _issuer; + private string _audience; + private TimeSpan _valid; + + public JwtTokenService(IConfiguration config) + { + config = config.GetSection("Jwt"); + _key = config["Key"]; + _issuer = config["Issuer"]; + _audience = config["Audience"]; + _valid = config.GetValue("Valid"); + } + + public string BuildToken(UserModel user, out DateTime validUntil) + { + var claims = user.GetClaims().ToList(); + validUntil = DateTime.UtcNow.Add(_valid); + claims.Add(new Claim("exp", new DateTimeOffset(validUntil).ToUnixTimeSeconds().ToString())); + claims.Add(new Claim("amr", "pwd")); + var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_key)); + var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256Signature); + var tokenDescriptor = new JwtSecurityToken(_issuer, _audience, claims, expires: validUntil, signingCredentials: credentials); + return new JwtSecurityTokenHandler().WriteToken(tokenDescriptor); + } + + public bool ValidateToken(string token, out ClaimsPrincipal claims) + { + var mySecret = Encoding.UTF8.GetBytes(_key); + var mySecurityKey = new SymmetricSecurityKey(mySecret); + var tokenHandler = new JwtSecurityTokenHandler(); + try + { + claims = tokenHandler.ValidateToken(token, + new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + ValidateIssuer = true, + ValidateAudience = true, + ValidIssuer = _issuer, + ValidAudience = _audience, + IssuerSigningKey = mySecurityKey, + }, out SecurityToken validatedToken); + } + catch + { + claims = null; + return false; + } + + return true; + } + } + +} diff --git a/OnlyPrompt.Backend/Utils/AutoMapperSetup.cs b/OnlyPrompt.Backend/Utils/AutoMapperSetup.cs new file mode 100644 index 0000000..652bccd --- /dev/null +++ b/OnlyPrompt.Backend/Utils/AutoMapperSetup.cs @@ -0,0 +1,28 @@ +using AutoMapper; +using OnlyPrompt.Backend.ApiModels.Auth; +using OnlyPrompt.Backend.ApiModels.UserProfile; +using OnlyPrompt.Backend.Database.Models; + +namespace OnlyPrompt.Backend.Utils +{ + public static class AutoMapperSetup + { + public static void Setup(IMapperConfigurationExpression config) + { + config.CreateMap() + .MapCtorParamFrom(x => x.Id, x => x.Id) + .MapCtorParamFrom(x => x.UserName, x => x.UserName) + .MapCtorParamFrom(x => x.Roles, x => x.Roles) + .MapCtorParamFrom(x => x.Email, x => x.Email); + + config.CreateMap() + .MapCtorParamFrom(x => x.DisplayName, x => x.DisplayName) + .MapCtorParamFrom(x => x.Slug, x => x.Slug) + .MapCtorParamFrom(x => x.Bio, x => x.Bio) + .MapCtorParamFrom(x => x.AvatarUrl, x => x.AvatarUrl) + .MapCtorParamFrom(x => x.Specialities, x => x.Specialities) + .MapCtorParamFrom(x => x.AverageRating, x => x.User.Prompts.Average(p => p.Reviews.Average(r => r.Rating))) + .MapCtorParamFrom(x => x.Subscribers, x => x.User.Subscribers.Count()); + } + } +} diff --git a/OnlyPrompt.Backend/Utils/AutomapperExtensions.cs b/OnlyPrompt.Backend/Utils/AutomapperExtensions.cs new file mode 100644 index 0000000..b3c44cb --- /dev/null +++ b/OnlyPrompt.Backend/Utils/AutomapperExtensions.cs @@ -0,0 +1,46 @@ +using AutoMapper; +using AutoMapper.Configuration; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Text; +using System.Threading.Tasks; + +namespace OnlyPrompt.Backend.Utils +{ + public static class AutomapperExtensions + { + + public static IMappingExpression CreateUpdateMap(this IMapperConfigurationExpression cfg, MemberList memberList = MemberList.Source) + { + return cfg.CreateMap(memberList) + .IgnoreNullMembers(); + } + + public static IMappingExpression MapMemberFrom(this IMappingExpression mapping, Expression> destinationMember, Expression> sourceMember) + { + mapping.ForMember(destinationMember, x => x.MapFrom(sourceMember)); + return mapping; + } + + public static IMappingExpression IgnoreNullMembers(this IMappingExpression mapping) + { + mapping.ForAllMembers(opts => opts.Condition((src, dest, member) => src != null)); + return mapping; + } + + public static IMappingExpression MapCtorParamFrom(this IMappingExpression mapping, Expression> destinationMember, Expression> sourceMember) + { + mapping.ForCtorParam(destinationMember, x => x.MapFrom(sourceMember)); + return mapping; + } + + public static IMappingExpression ForCtorParam(this IMappingExpression mapping, Expression> paramSelector, Action> configure) + { + var ctorParamName = ((MemberExpression)paramSelector.Body).Member.Name; + mapping.ForCtorParam(ctorParamName, configure); + return mapping; + } + } +} diff --git a/OnlyPrompt.Backend/Utils/EntityExtensions.cs b/OnlyPrompt.Backend/Utils/EntityExtensions.cs new file mode 100644 index 0000000..b109c30 --- /dev/null +++ b/OnlyPrompt.Backend/Utils/EntityExtensions.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using OnlyPrompt.Backend.Database.Core; + +namespace OnlyPrompt.Backend.Utils +{ + public static class EntityExtensions + { + public static Task FindBySlugAsync(this IQueryable queryable, string slug) where T : class, IHasSlug + { + return queryable.FirstOrDefaultAsync(e => e.Slug == slug); + } + + public static Task FindByIdentifierAsync(this IQueryable queryable, Identifier identifier) where T : class, IHasSlug, IEntity + { + if (identifier.Id.HasValue) + return queryable.FirstOrDefaultAsync(e => e.Id == identifier.Id.Value); + + return queryable.FindBySlugAsync(identifier.Slug); + } + + public static IQueryable OfIdentifer(this IQueryable queryable, Identifier identifier) where T : class, IHasSlug, IEntity + { + if (identifier.Id.HasValue) + return queryable.Where(e => e.Id == identifier.Id.Value); + return queryable.Where(e => e.Slug == identifier.Slug); + } + } +} diff --git a/OnlyPrompt.Backend/Utils/Extensions.cs b/OnlyPrompt.Backend/Utils/Extensions.cs new file mode 100644 index 0000000..75bc583 --- /dev/null +++ b/OnlyPrompt.Backend/Utils/Extensions.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using OnlyPrompt.Backend.Database.Models; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Security.Claims; + +namespace OnlyPrompt.Backend.Utils +{ + public static class Extensions + { + public static string? GetIdentifier(this ClaimsPrincipal principal) => principal.FindFirstValue(ClaimTypes.NameIdentifier); + public static bool TryGetIdentifier(this ClaimsPrincipal principal, [NotNullWhen(true)]out string? identifier) + { + identifier = principal.FindFirstValue(ClaimTypes.NameIdentifier); + return identifier != null; + } + + public static Guid? GetUserId(this ClaimsPrincipal principal) + { + if (principal.TryGetIdentifier(out var identifier) && Guid.TryParse(identifier, out var userId)) + return userId; + + return null; + } + + public static IEnumerable GetClaims(this UserModel user) + { + yield return new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()); + yield return new Claim(ClaimTypes.Name, user.UserName); + yield return new Claim(ClaimTypes.Email, user.Email); + + foreach (var role in user.Roles) + yield return new Claim(ClaimTypes.Role, role); + } + + public static CookieOptions Copy(this CookieOptions options, Action? modify = null) + { + var newOptions = new CookieOptions + { + Domain = options.Domain, + Expires = options.Expires, + HttpOnly = options.HttpOnly, + IsEssential = options.IsEssential, + MaxAge = options.MaxAge, + Path = options.Path, + SameSite = options.SameSite, + Secure = options.Secure + }; + + modify?.Invoke(newOptions); + return newOptions; + } + + public static string Limit(this string @string, int maxLength) + { + if (@string.Length <= maxLength) + return @string; + return @string.Substring(0, maxLength); + } + + public const string LowerAlphabet = "abcdefghijklmnopqrstuvwxyz"; + public static string GetString(this Random @random, int lenght, string alphabet = LowerAlphabet) + { + Span chars = stackalloc char[lenght]; + for (int i = 0; i < lenght; i++) + chars[i] = alphabet[@random.Next(alphabet.Length)]; + + return new string(chars); + } + } +} diff --git a/OnlyPrompt.Backend/Utils/Identifier.cs b/OnlyPrompt.Backend/Utils/Identifier.cs new file mode 100644 index 0000000..a0b70dc --- /dev/null +++ b/OnlyPrompt.Backend/Utils/Identifier.cs @@ -0,0 +1,34 @@ +using Scalar.AspNetCore; + +namespace OnlyPrompt.Backend.Utils +{ + public struct Identifier + { + public string Slug { get; init; } + public Guid? Id { get; init; } + + public Identifier(string slug) + { + this.Slug = slug; + this.Id = null; + } + + public Identifier(Guid guid) + { + this.Id = guid; + this.Slug = string.Empty; + } + + public static implicit operator Identifier(string slug) => new Identifier(slug); + public static implicit operator Identifier(Guid guid) => new Identifier(guid); + + public static bool TryParse(string input, out Identifier identifier) + { + identifier= new Identifier(input); + if (Guid.TryParse(input, out var guid)) + identifier = new Identifier(guid); + + return true; + } + } +} diff --git a/OnlyPrompt.Backend/Utils/SlugHelper.cs b/OnlyPrompt.Backend/Utils/SlugHelper.cs new file mode 100644 index 0000000..176536e --- /dev/null +++ b/OnlyPrompt.Backend/Utils/SlugHelper.cs @@ -0,0 +1,31 @@ +using System.Text.RegularExpressions; + +namespace OnlyPrompt.Backend.Utils +{ + public static class SlugHelper + { + private static readonly Regex InvalidCharacters = new(@"[^a-z0-9\-]", RegexOptions.Compiled); + private static readonly Regex MultipleDashes = new(@"-+", RegexOptions.Compiled); + + public static string GenerateSlug(string input, int? maxLength = null) + { + if (string.IsNullOrWhiteSpace(input)) + return string.Empty; + + var slug = input.ToLowerInvariant().Replace(" ", "-").Replace("_", "-"); + slug = InvalidCharacters.Replace(slug, string.Empty); + slug = MultipleDashes.Replace(slug, "-"); + slug = slug.Trim('-'); + return slug; + } + + private const string SuffixChars = "abcdefghijklmnopqrstuvwxyz0123456789"; + public static async Task GenerateUniqueSlug(string input, Func> existsFunc, int? maxLenght) + { + var baseSlug = GenerateSlug(input, maxLenght - 9); + var slug = baseSlug; + var suffix = Random.Shared.GetString(8, SuffixChars); + return $"{slug}-{suffix}"; + } + } +} diff --git a/OnlyPrompt.Backend/appsettings.Development.json b/OnlyPrompt.Backend/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/OnlyPrompt.Backend/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/OnlyPrompt.Backend/appsettings.json b/OnlyPrompt.Backend/appsettings.json new file mode 100644 index 0000000..ee7e11f --- /dev/null +++ b/OnlyPrompt.Backend/appsettings.json @@ -0,0 +1,18 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Include Error Detail=true;User ID=onlyprompt;Password=onlyprompt;Host=postgres;Port=1803;Database=onlyprompt;Pooling=true;MinPoolSize=0;MaxPoolSize=100;Connection Lifetime=0;" + }, + "Jwt": { + "Issuer": "https://onlyprompts.com", + "Audience": "https://onlyprompts.com", + "Key": "TfZi@!CC!b5UoD81gs&%tvY4J0M$p3cI", + "Valid": "3.0:0:0" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/css/base.css b/OnlyPrompt.Frontend/css/base.css similarity index 100% rename from css/base.css rename to OnlyPrompt.Frontend/css/base.css diff --git a/css/chats.css b/OnlyPrompt.Frontend/css/chats.css similarity index 100% rename from css/chats.css rename to OnlyPrompt.Frontend/css/chats.css diff --git a/css/community.css b/OnlyPrompt.Frontend/css/community.css similarity index 100% rename from css/community.css rename to OnlyPrompt.Frontend/css/community.css diff --git a/css/create.css b/OnlyPrompt.Frontend/css/create.css similarity index 100% rename from css/create.css rename to OnlyPrompt.Frontend/css/create.css diff --git a/css/dashboard.css b/OnlyPrompt.Frontend/css/dashboard.css similarity index 100% rename from css/dashboard.css rename to OnlyPrompt.Frontend/css/dashboard.css diff --git a/css/login.css b/OnlyPrompt.Frontend/css/login.css similarity index 100% rename from css/login.css rename to OnlyPrompt.Frontend/css/login.css diff --git a/css/marketplace.css b/OnlyPrompt.Frontend/css/marketplace.css similarity index 100% rename from css/marketplace.css rename to OnlyPrompt.Frontend/css/marketplace.css diff --git a/css/post-detail.css b/OnlyPrompt.Frontend/css/post-detail.css similarity index 100% rename from css/post-detail.css rename to OnlyPrompt.Frontend/css/post-detail.css diff --git a/css/profile.css b/OnlyPrompt.Frontend/css/profile.css similarity index 100% rename from css/profile.css rename to OnlyPrompt.Frontend/css/profile.css diff --git a/css/settings.css b/OnlyPrompt.Frontend/css/settings.css similarity index 100% rename from css/settings.css rename to OnlyPrompt.Frontend/css/settings.css diff --git a/css/sidebar.css b/OnlyPrompt.Frontend/css/sidebar.css similarity index 100% rename from css/sidebar.css rename to OnlyPrompt.Frontend/css/sidebar.css diff --git a/css/signup.css b/OnlyPrompt.Frontend/css/signup.css similarity index 100% rename from css/signup.css rename to OnlyPrompt.Frontend/css/signup.css diff --git a/css/topbar.css b/OnlyPrompt.Frontend/css/topbar.css similarity index 100% rename from css/topbar.css rename to OnlyPrompt.Frontend/css/topbar.css diff --git a/css/variables.css b/OnlyPrompt.Frontend/css/variables.css similarity index 100% rename from css/variables.css rename to OnlyPrompt.Frontend/css/variables.css diff --git a/html/chats.html b/OnlyPrompt.Frontend/html/chats.html similarity index 100% rename from html/chats.html rename to OnlyPrompt.Frontend/html/chats.html diff --git a/html/community.html b/OnlyPrompt.Frontend/html/community.html similarity index 100% rename from html/community.html rename to OnlyPrompt.Frontend/html/community.html diff --git a/html/create.html b/OnlyPrompt.Frontend/html/create.html similarity index 100% rename from html/create.html rename to OnlyPrompt.Frontend/html/create.html diff --git a/html/dashboard.html b/OnlyPrompt.Frontend/html/dashboard.html similarity index 100% rename from html/dashboard.html rename to OnlyPrompt.Frontend/html/dashboard.html diff --git a/html/login.html b/OnlyPrompt.Frontend/html/login.html similarity index 100% rename from html/login.html rename to OnlyPrompt.Frontend/html/login.html diff --git a/html/marketplace.html b/OnlyPrompt.Frontend/html/marketplace.html similarity index 100% rename from html/marketplace.html rename to OnlyPrompt.Frontend/html/marketplace.html diff --git a/html/post-detail.html b/OnlyPrompt.Frontend/html/post-detail.html similarity index 100% rename from html/post-detail.html rename to OnlyPrompt.Frontend/html/post-detail.html diff --git a/html/profile.html b/OnlyPrompt.Frontend/html/profile.html similarity index 100% rename from html/profile.html rename to OnlyPrompt.Frontend/html/profile.html diff --git a/html/settings.html b/OnlyPrompt.Frontend/html/settings.html similarity index 100% rename from html/settings.html rename to OnlyPrompt.Frontend/html/settings.html diff --git a/html/sidebar.html b/OnlyPrompt.Frontend/html/sidebar.html similarity index 100% rename from html/sidebar.html rename to OnlyPrompt.Frontend/html/sidebar.html diff --git a/html/signup.html b/OnlyPrompt.Frontend/html/signup.html similarity index 100% rename from html/signup.html rename to OnlyPrompt.Frontend/html/signup.html diff --git a/html/topbar.html b/OnlyPrompt.Frontend/html/topbar.html similarity index 100% rename from html/topbar.html rename to OnlyPrompt.Frontend/html/topbar.html diff --git a/images/content/cat.png b/OnlyPrompt.Frontend/images/content/cat.png similarity index 100% rename from images/content/cat.png rename to OnlyPrompt.Frontend/images/content/cat.png diff --git a/images/content/creator1.png b/OnlyPrompt.Frontend/images/content/creator1.png similarity index 100% rename from images/content/creator1.png rename to OnlyPrompt.Frontend/images/content/creator1.png diff --git a/images/content/creator2.png b/OnlyPrompt.Frontend/images/content/creator2.png similarity index 100% rename from images/content/creator2.png rename to OnlyPrompt.Frontend/images/content/creator2.png diff --git a/images/content/creator3.png b/OnlyPrompt.Frontend/images/content/creator3.png similarity index 100% rename from images/content/creator3.png rename to OnlyPrompt.Frontend/images/content/creator3.png diff --git a/images/content/creator4.png b/OnlyPrompt.Frontend/images/content/creator4.png similarity index 100% rename from images/content/creator4.png rename to OnlyPrompt.Frontend/images/content/creator4.png diff --git a/images/content/creator5.png b/OnlyPrompt.Frontend/images/content/creator5.png similarity index 100% rename from images/content/creator5.png rename to OnlyPrompt.Frontend/images/content/creator5.png diff --git a/images/content/creator6.png b/OnlyPrompt.Frontend/images/content/creator6.png similarity index 100% rename from images/content/creator6.png rename to OnlyPrompt.Frontend/images/content/creator6.png diff --git a/images/content/feed1.png b/OnlyPrompt.Frontend/images/content/feed1.png similarity index 100% rename from images/content/feed1.png rename to OnlyPrompt.Frontend/images/content/feed1.png diff --git a/images/content/feed2.png b/OnlyPrompt.Frontend/images/content/feed2.png similarity index 100% rename from images/content/feed2.png rename to OnlyPrompt.Frontend/images/content/feed2.png diff --git a/images/content/feed3.png b/OnlyPrompt.Frontend/images/content/feed3.png similarity index 100% rename from images/content/feed3.png rename to OnlyPrompt.Frontend/images/content/feed3.png diff --git a/images/content/feed4.png b/OnlyPrompt.Frontend/images/content/feed4.png similarity index 100% rename from images/content/feed4.png rename to OnlyPrompt.Frontend/images/content/feed4.png diff --git a/images/content/market1.png b/OnlyPrompt.Frontend/images/content/market1.png similarity index 100% rename from images/content/market1.png rename to OnlyPrompt.Frontend/images/content/market1.png diff --git a/images/content/market2.png b/OnlyPrompt.Frontend/images/content/market2.png similarity index 100% rename from images/content/market2.png rename to OnlyPrompt.Frontend/images/content/market2.png diff --git a/images/content/market3.png b/OnlyPrompt.Frontend/images/content/market3.png similarity index 100% rename from images/content/market3.png rename to OnlyPrompt.Frontend/images/content/market3.png diff --git a/images/content/market4.png b/OnlyPrompt.Frontend/images/content/market4.png similarity index 100% rename from images/content/market4.png rename to OnlyPrompt.Frontend/images/content/market4.png diff --git a/images/content/market5.png b/OnlyPrompt.Frontend/images/content/market5.png similarity index 100% rename from images/content/market5.png rename to OnlyPrompt.Frontend/images/content/market5.png diff --git a/images/content/market6.png b/OnlyPrompt.Frontend/images/content/market6.png similarity index 100% rename from images/content/market6.png rename to OnlyPrompt.Frontend/images/content/market6.png diff --git a/images/content/post1.png b/OnlyPrompt.Frontend/images/content/post1.png similarity index 100% rename from images/content/post1.png rename to OnlyPrompt.Frontend/images/content/post1.png diff --git a/images/content/post2.png b/OnlyPrompt.Frontend/images/content/post2.png similarity index 100% rename from images/content/post2.png rename to OnlyPrompt.Frontend/images/content/post2.png diff --git a/images/logo_full.png b/OnlyPrompt.Frontend/images/logo_full.png similarity index 100% rename from images/logo_full.png rename to OnlyPrompt.Frontend/images/logo_full.png diff --git a/images/logo_icon.png b/OnlyPrompt.Frontend/images/logo_icon.png similarity index 100% rename from images/logo_icon.png rename to OnlyPrompt.Frontend/images/logo_icon.png diff --git a/images/logo_text.png b/OnlyPrompt.Frontend/images/logo_text.png similarity index 100% rename from images/logo_text.png rename to OnlyPrompt.Frontend/images/logo_text.png diff --git a/OnlyPrompt.slnx b/OnlyPrompt.slnx new file mode 100644 index 0000000..6080465 --- /dev/null +++ b/OnlyPrompt.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/docker-compose.dcproj b/docker-compose.dcproj new file mode 100644 index 0000000..149eba7 --- /dev/null +++ b/docker-compose.dcproj @@ -0,0 +1,17 @@ + + + + 2.1 + Linux + ee26a7fb-2dee-47a7-964c-12ef03b1114a + LaunchBrowser + {Scheme}://localhost:{ServicePort}/scalar + documentapi + + + + + + + + \ No newline at end of file diff --git a/docker-compose.debug.yml b/docker-compose.debug.yml new file mode 100644 index 0000000..54ca625 --- /dev/null +++ b/docker-compose.debug.yml @@ -0,0 +1,15 @@ +services: + database: + image: postgres:latest + restart: unless-stopped + environment: + - POSTGRES_PASSWORD=${DB_PASSWORD} + - POSTGRES_USER=${DB_USER} + - POSTGRES_DB=${DB_NAME} + volumes: + - 'database_data:/var/lib/postgresql' + ports: + - "${PORT_PREFIX}3:5432" + +volumes: + database_data: \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..844fc1b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +services: + onlyprompt.backend: + image: ${DOCKER_REGISTRY-}onlyprompt + build: + context: . + dockerfile: OnlyPrompt.Backend/Dockerfile + ports: + - "${PORT_PREFIX}1:8080" + - "${PORT_PREFIX}2:8081" + environment: + JWT__ISSUER: "https://onlyprompt.com" + CONNECTIONSTRINGS__DEFAULT: "Include Error Detail=true;User ID=${DB_USER};Password=${DB_PASSWORD};Host=postgres;Port=5432;Database=${DB_NAME};Pooling=true;MinPoolSize=0;MaxPoolSize=100;Connection Lifetime=0;" + ASPNETCORE_URLS: "http://*:8080" + ASPNETCORE_ENVIRONMENT: "Development" + + database: + image: postgres:latest + restart: unless-stopped + environment: + - POSTGRES_PASSWORD=${DB_PASSWORD} + - POSTGRES_USER=${DB_USER} + - POSTGRES_DB=${DB_NAME} + volumes: + - 'database_data:/var/lib/postgresql' + +volumes: + database_data: \ No newline at end of file