├── self-hosting ├── run.sh ├── librum-server.service ├── librum-server.conf ├── librum-server.7 └── self-host-installation.md ├── src ├── Infrastructure │ ├── .config │ │ └── dotnet-tools.json │ ├── Persistence │ │ ├── EntityConfigurations │ │ │ ├── TagConfiguration.cs │ │ │ ├── HighlightConfiguration.cs │ │ │ ├── FolderConfiguration.cs │ │ │ ├── BookConfiguration.cs │ │ │ └── UserConfiguration.cs │ │ ├── Repository │ │ │ ├── TagRepository.cs │ │ │ ├── ProductRepository.cs │ │ │ ├── FolderRepository.cs │ │ │ ├── UserRepository.cs │ │ │ └── BookRepository.cs │ │ ├── Migrations │ │ │ ├── 20231018060059_AddedProjectGutenbergId.cs │ │ │ ├── 20231013134330_AddedAiExplanationCount.cs │ │ │ ├── 20231017161702_AddBookmarks.cs │ │ │ ├── 20230911141116_FixHighlightReference.cs │ │ │ └── 20230911113759_AddedHighlights.cs │ │ └── DataContext.cs │ └── Infrastructure.csproj ├── Application │ ├── Common │ │ ├── DTOs │ │ │ ├── StatisticsOutDto.cs │ │ │ ├── Tags │ │ │ │ ├── TagOutDto.cs │ │ │ │ ├── TagForUpdateDto.cs │ │ │ │ └── TagInDto.cs │ │ │ ├── Bookmarks │ │ │ │ ├── BookmarkOutDto.cs │ │ │ │ └── BookmarkInDto.cs │ │ │ ├── Highlights │ │ │ │ ├── RectFOutDto.cs │ │ │ │ ├── HighlightOutDto.cs │ │ │ │ ├── HighlightInDto.cs │ │ │ │ └── RectFInDto.cs │ │ │ ├── Folders │ │ │ │ ├── FolderInDto.cs │ │ │ │ └── FolderOutDto.cs │ │ │ ├── Users │ │ │ │ ├── LoginDto.cs │ │ │ │ ├── UserOutDto.cs │ │ │ │ ├── UserForUpdateDto.cs │ │ │ │ └── RegisterDto.cs │ │ │ ├── Product │ │ │ │ ├── ProductOutDto.cs │ │ │ │ ├── ProductForUpdateDto.cs │ │ │ │ └── ProductInDto.cs │ │ │ ├── ApiExceptionDto.cs │ │ │ ├── CommonErrorDto.cs │ │ │ └── Books │ │ │ │ ├── BookOutDto.cs │ │ │ │ ├── BookForUpdateDto.cs │ │ │ │ └── BookInDto.cs │ │ ├── Enums │ │ │ └── BookSortOptions.cs │ │ ├── Exceptions │ │ │ ├── InternalServerException.cs │ │ │ └── CommonErrorException.cs │ │ ├── Mappings │ │ │ ├── BookmarkAutoMapperProfile.cs │ │ │ ├── HighlightAutoMapperProfile.cs │ │ │ ├── TagAutoMapperProfile.cs │ │ │ ├── RectFAutoMapperProfile.cs │ │ │ ├── ProductAutoMapperProfile.cs │ │ │ ├── UserAutoMapperProfile.cs │ │ │ └── BookAutoMapperProfile.cs │ │ ├── Attributes │ │ │ └── DisableFormValueModelBindingAttribute.cs │ │ ├── Extensions │ │ │ └── SSEHttpContextExtensions.cs │ │ ├── DataAnnotations │ │ │ └── EmptyOrMinLengthAttribute.cs │ │ └── Middleware │ │ │ ├── CustomIpRateLimitMiddleware.cs │ │ │ └── ExceptionHandlingMiddleware.cs │ ├── Interfaces │ │ ├── Repositories │ │ │ ├── ITagRepository.cs │ │ │ ├── IFolderRepository.cs │ │ │ ├── IProductRepository.cs │ │ │ ├── IBookRepository.cs │ │ │ └── IUserRepository.cs │ │ ├── Services │ │ │ ├── IFolderService.cs │ │ │ ├── IAiService.cs │ │ │ ├── ITagService.cs │ │ │ ├── IAuthenticationService.cs │ │ │ ├── IProductService.cs │ │ │ ├── IBookService.cs │ │ │ └── IUserService.cs │ │ ├── Utility │ │ │ └── IEmailSender.cs │ │ └── Managers │ │ │ ├── IUserBlobStorageManager.cs │ │ │ ├── IBookBlobStorageManager.cs │ │ │ └── IAuthenticationManager.cs │ ├── Application.csproj │ ├── BackgroundServices │ │ ├── DeleteUnconfirmedUsers.cs │ │ ├── ResetTranslationsCount.cs │ │ ├── ResetAiExplanationCount.cs │ │ └── DeleteBooksOfDowngradedAccounts.cs │ ├── Services │ │ ├── TagService.cs │ │ ├── FolderService.cs │ │ ├── AuthenticationService.cs │ │ └── ProductService.cs │ ├── Managers │ │ ├── UserBlobStorageManager.cs │ │ ├── UserLocalStorageManager.cs │ │ ├── AuthenticationManager.cs │ │ ├── BookBlobStorageManager.cs │ │ └── BookLocalStorageManager.cs │ └── Utility │ │ └── EmailSender.cs ├── Presentation │ ├── appsettings.Development.json │ ├── Controllers │ │ ├── ProductController.cs │ │ ├── FolderController.cs │ │ ├── TagController.cs │ │ ├── AppInfoController.cs │ │ ├── AiController.cs │ │ ├── AuthenticationController.cs │ │ └── WebHookController.cs │ ├── appsettings.json │ ├── Properties │ │ └── launchSettings.json │ ├── wwwroot │ │ ├── EmailConfirmationSucceeded.html │ │ ├── EmailConfirmationFailed.html │ │ └── error.svg │ ├── Presentation.csproj │ └── Program.cs └── Domain │ ├── Entities │ ├── ProductFeature.cs │ ├── Tag.cs │ ├── Bookmark.cs │ ├── RectF.cs │ ├── Highlight.cs │ ├── Product.cs │ ├── Folder.cs │ ├── User.cs │ └── Book.cs │ └── Domain.csproj ├── .github ├── FUNDING.yml └── workflows │ ├── test.yml │ ├── deploy.yaml │ └── docker.yml ├── Librum-Server.sln.DotSettings ├── tests └── Application.UnitTests │ ├── TestHelper.cs │ ├── Application.UnitTests.csproj │ ├── Services │ └── UserServiceTests.cs │ └── AuthenticationManagerTests.cs ├── appsettings.json ├── Dockerfile ├── README.md ├── docker-compose.yml └── Librum-Server.sln /self-hosting/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd /var/lib/librum-server/srv 4 | dotnet Presentation.dll 5 | -------------------------------------------------------------------------------- /src/Infrastructure/.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": {} 5 | } -------------------------------------------------------------------------------- /src/Application/Common/DTOs/StatisticsOutDto.cs: -------------------------------------------------------------------------------- 1 | namespace Application.Common.DTOs; 2 | 3 | public class StatisticsOutDto 4 | { 5 | 6 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [Librum-Reader] 4 | custom: ["https://librumreader.com/contribute/donate"] 5 | -------------------------------------------------------------------------------- /src/Presentation/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Information" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /src/Application/Common/DTOs/Tags/TagOutDto.cs: -------------------------------------------------------------------------------- 1 | namespace Application.Common.DTOs.Tags; 2 | 3 | public class TagOutDto 4 | { 5 | public string Guid { get; set; } 6 | public string Name { get; set; } 7 | } -------------------------------------------------------------------------------- /src/Application/Common/Enums/BookSortOptions.cs: -------------------------------------------------------------------------------- 1 | namespace Application.Common.Enums; 2 | 3 | public enum BookSortOptions 4 | { 5 | Nothing, 6 | RecentlyRead, 7 | RecentlyAdded, 8 | Percentage, 9 | TitleLexicAsc, 10 | TitleLexicDesc, 11 | } -------------------------------------------------------------------------------- /src/Application/Common/Exceptions/InternalServerException.cs: -------------------------------------------------------------------------------- 1 | namespace Application.Common.Exceptions; 2 | 3 | public class InternalServerException : Exception 4 | { 5 | public InternalServerException(string message) 6 | : base(message) 7 | { 8 | } 9 | } -------------------------------------------------------------------------------- /src/Application/Interfaces/Repositories/ITagRepository.cs: -------------------------------------------------------------------------------- 1 | using Domain.Entities; 2 | 3 | namespace Application.Interfaces.Repositories; 4 | 5 | public interface ITagRepository 6 | { 7 | public Task SaveChangesAsync(); 8 | void Delete(Tag tag); 9 | void Add(Tag tag); 10 | } -------------------------------------------------------------------------------- /src/Application/Interfaces/Services/IFolderService.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.DTOs.Folders; 2 | 3 | namespace Application.Interfaces.Services; 4 | 5 | public interface IFolderService 6 | { 7 | public Task UpdateFoldersAsync(string email, FolderInDto folderInDto); 8 | public Task GetFoldersAsync(string email); 9 | } -------------------------------------------------------------------------------- /src/Application/Common/DTOs/Bookmarks/BookmarkOutDto.cs: -------------------------------------------------------------------------------- 1 | namespace Application.Common.DTOs.Bookmarks; 2 | 3 | public class BookmarkOutDto 4 | { 5 | public string Guid { get; set; } 6 | 7 | public string Name { get; set; } 8 | 9 | public int PageNumber { get; set; } 10 | 11 | public float YOffset { get; set; } 12 | } -------------------------------------------------------------------------------- /src/Application/Interfaces/Services/IAiService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | 3 | namespace Application.Interfaces.Services; 4 | 5 | public interface IAiService 6 | { 7 | Task ExplainAsync(string email, HttpContext context, string text, string mode); 8 | Task TranslateAsync(string email, string text, string sourceLang, string targetLang); 9 | } -------------------------------------------------------------------------------- /src/Application/Interfaces/Utility/IEmailSender.cs: -------------------------------------------------------------------------------- 1 | using Domain.Entities; 2 | 3 | namespace Application.Interfaces.Utility; 4 | 5 | public interface IEmailSender 6 | { 7 | public Task SendEmailConfirmationEmail(User user, string token); 8 | public Task SendPasswordResetEmail(User user, string token); 9 | public Task SendDowngradeWarningEmail(User user); 10 | } -------------------------------------------------------------------------------- /src/Application/Common/DTOs/Highlights/RectFOutDto.cs: -------------------------------------------------------------------------------- 1 | namespace Application.Common.DTOs.Highlights; 2 | 3 | public class RectFOutDto 4 | { 5 | public string Guid { get; set; } 6 | 7 | public float X { get; set; } 8 | 9 | public float Y { get; set; } 10 | 11 | public float Width { get; set; } 12 | 13 | public float Height { get; set; } 14 | } -------------------------------------------------------------------------------- /src/Application/Common/DTOs/Highlights/HighlightOutDto.cs: -------------------------------------------------------------------------------- 1 | namespace Application.Common.DTOs.Highlights; 2 | 3 | public class HighlightOutDto 4 | { 5 | public string Guid { get; set; } 6 | 7 | public string Color { get; set; } 8 | 9 | public int PageNumber { get; set; } 10 | 11 | public ICollection Rects { get; set; } = new List(); 12 | } -------------------------------------------------------------------------------- /src/Application/Interfaces/Repositories/IFolderRepository.cs: -------------------------------------------------------------------------------- 1 | using Domain.Entities; 2 | 3 | namespace Application.Interfaces.Repositories; 4 | 5 | public interface IFolderRepository 6 | { 7 | public Task SaveChangesAsync(); 8 | public Task GetFolderAsync(Guid folderId); 9 | public Task CreateFolderAsync(Folder folder); 10 | public void RemoveFolder(Folder folder); 11 | } -------------------------------------------------------------------------------- /src/Application/Interfaces/Services/ITagService.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.DTOs.Tags; 2 | 3 | namespace Application.Interfaces.Services; 4 | 5 | public interface ITagService 6 | { 7 | public Task DeleteTagAsync(string email, Guid guid); 8 | public Task UpdateTagAsync(string email, TagForUpdateDto tagDto); 9 | public Task> GetTagsAsync(string email); 10 | } -------------------------------------------------------------------------------- /Librum-Server.sln.DotSettings: -------------------------------------------------------------------------------- 1 | 2 | True -------------------------------------------------------------------------------- /src/Application/Interfaces/Managers/IUserBlobStorageManager.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.WebUtilities; 2 | 3 | namespace Application.Interfaces.Managers; 4 | 5 | public interface IUserBlobStorageManager 6 | { 7 | public Task DownloadProfilePicture(string guid); 8 | public Task ChangeProfilePicture(string guid, MultipartReader reader); 9 | public Task DeleteProfilePicture(string guid); 10 | } -------------------------------------------------------------------------------- /src/Application/Common/DTOs/Tags/TagForUpdateDto.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace Application.Common.DTOs.Tags; 4 | 5 | public class TagForUpdateDto 6 | { 7 | [Required] 8 | public Guid Guid { get; set; } 9 | 10 | [MinLength(2, ErrorMessage = "The tag name is too short")] 11 | [MaxLength(30, ErrorMessage = "The tag name is too long")] 12 | public string Name { get; set; } 13 | } -------------------------------------------------------------------------------- /src/Infrastructure/Persistence/EntityConfigurations/TagConfiguration.cs: -------------------------------------------------------------------------------- 1 | using Domain.Entities; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 4 | 5 | namespace Infrastructure.Persistence.EntityConfigurations; 6 | 7 | public class TagConfiguration : IEntityTypeConfiguration 8 | { 9 | public void Configure(EntityTypeBuilder builder) 10 | { 11 | 12 | } 13 | } -------------------------------------------------------------------------------- /src/Application/Common/DTOs/Tags/TagInDto.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace Application.Common.DTOs.Tags; 4 | 5 | public class TagInDto 6 | { 7 | [Required] 8 | public Guid Guid { get; set; } 9 | 10 | [Required] 11 | [MinLength(2, ErrorMessage = "The tag name is too short")] 12 | [MaxLength(5000, ErrorMessage = "The tag name is too long")] 13 | public string Name { get; set; } 14 | } -------------------------------------------------------------------------------- /src/Application/Common/Exceptions/CommonErrorException.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.DTOs; 2 | 3 | namespace Application.Common.Exceptions; 4 | 5 | public class CommonErrorException : Exception 6 | { 7 | public CommonErrorDto Error { get; } 8 | 9 | public CommonErrorException(int status, string message, int code) 10 | : base(message) 11 | { 12 | Error = new CommonErrorDto(status, message, code); 13 | } 14 | } -------------------------------------------------------------------------------- /src/Application/Interfaces/Repositories/IProductRepository.cs: -------------------------------------------------------------------------------- 1 | using Domain.Entities; 2 | 3 | namespace Application.Interfaces.Repositories; 4 | 5 | public interface IProductRepository 6 | { 7 | public Task SaveChangesAsync(); 8 | public void CreateProduct(Product product); 9 | public IQueryable GetAll(); 10 | public Task GetByIdAsync(string id); 11 | public void DeleteProduct(Product product); 12 | } -------------------------------------------------------------------------------- /src/Domain/Entities/ProductFeature.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.ComponentModel.DataAnnotations.Schema; 3 | 4 | namespace Domain.Entities; 5 | 6 | public class ProductFeature 7 | { 8 | [Key] 9 | public Guid ProductFeatureId { get; set; } 10 | 11 | [Required] 12 | public string Name { get; set; } 13 | 14 | public string ProductId { get; set; } 15 | public Product Product { get; set; } 16 | } -------------------------------------------------------------------------------- /src/Application/Common/DTOs/Highlights/HighlightInDto.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | namespace Application.Common.DTOs.Highlights; 3 | 4 | public class HighlightInDto 5 | { 6 | [Required] 7 | public Guid Guid { get; set; } 8 | 9 | [Required] 10 | public string Color { get; set; } 11 | 12 | [Required] 13 | public int PageNumber { get; set; } 14 | 15 | [Required] 16 | public ICollection Rects { get; set; } = new List(); 17 | } -------------------------------------------------------------------------------- /src/Application/Common/DTOs/Highlights/RectFInDto.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | namespace Application.Common.DTOs.Highlights; 3 | 4 | public class RectFInDto 5 | { 6 | [Required] 7 | public Guid Guid { get; set; } 8 | 9 | [Required] 10 | public float X { get; set; } 11 | 12 | [Required] 13 | public float Y { get; set; } 14 | 15 | [Required] 16 | public float Width { get; set; } 17 | 18 | [Required] 19 | public float Height { get; set; } 20 | } -------------------------------------------------------------------------------- /src/Application/Interfaces/Services/IAuthenticationService.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.DTOs.Users; 2 | 3 | namespace Application.Interfaces.Services; 4 | 5 | public interface IAuthenticationService 6 | { 7 | public Task LoginUserAsync(LoginDto loginDto); 8 | public Task RegisterUserAsync(RegisterDto registerDto); 9 | public Task ConfirmEmail(string email, string token); 10 | public Task CheckIfEmailIsConfirmed(string email); 11 | public Task VerifyReCaptcha(string userToken); 12 | } -------------------------------------------------------------------------------- /src/Application/Interfaces/Services/IProductService.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.DTOs.Product; 2 | 3 | namespace Application.Interfaces.Services; 4 | 5 | public interface IProductService 6 | { 7 | public Task> GetAllProductsAsync(); 8 | public Task CreateProductAsync(ProductInDto productInDto); 9 | public Task UpdateProductAsync(ProductForUpdateDto productInDto); 10 | public Task DeleteProductAsync(string id); 11 | public Task AddPriceToProductAsync(string id, string priceId, int price); 12 | } -------------------------------------------------------------------------------- /src/Application/Interfaces/Repositories/IBookRepository.cs: -------------------------------------------------------------------------------- 1 | using Domain.Entities; 2 | 3 | namespace Application.Interfaces.Repositories; 4 | 5 | public interface IBookRepository 6 | { 7 | public Task SaveChangesAsync(); 8 | public Task LoadRelationShipsAsync(Book book); 9 | public IQueryable GetAllAsync(string userId, bool loadRelationships = false); 10 | public Task ExistsAsync(string userId, Guid bookGuid); 11 | void DeleteBook(Book book); 12 | public Task GetUsedBookStorage(string userId); 13 | } -------------------------------------------------------------------------------- /src/Infrastructure/Persistence/EntityConfigurations/HighlightConfiguration.cs: -------------------------------------------------------------------------------- 1 | using Domain.Entities; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 4 | 5 | namespace Infrastructure.Persistence.EntityConfigurations; 6 | 7 | public class HighlightConfiguration : IEntityTypeConfiguration 8 | { 9 | public void Configure(EntityTypeBuilder builder) 10 | { 11 | 12 | builder.Property(x => x.HighlightId) 13 | .ValueGeneratedNever(); 14 | } 15 | } -------------------------------------------------------------------------------- /src/Application/Interfaces/Managers/IBookBlobStorageManager.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.WebUtilities; 2 | 3 | namespace Application.Interfaces.Managers; 4 | 5 | public interface IBookBlobStorageManager 6 | { 7 | public Task DownloadBookBlob(Guid guid); 8 | public Task UploadBookBlob(Guid guid, MultipartReader reader); 9 | public Task DeleteBookBlob(Guid guid); 10 | public Task ChangeBookCover(Guid guid, MultipartReader reader); 11 | public Task DownloadBookCover(Guid guid); 12 | public Task DeleteBookCover(Guid guid); 13 | } -------------------------------------------------------------------------------- /src/Application/Common/DTOs/Bookmarks/BookmarkInDto.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using Application.Common.DTOs.Highlights; 3 | 4 | namespace Application.Common.DTOs.Bookmarks; 5 | 6 | public class BookmarkInDto 7 | { 8 | [Required] 9 | public Guid Guid { get; set; } 10 | 11 | [Required] 12 | [MaxLength(5000, ErrorMessage = "The name is too long")] 13 | public string Name { get; set; } 14 | 15 | [Required] 16 | public int PageNumber { get; set; } 17 | 18 | [Required] 19 | public float YOffset { get; set; } 20 | } -------------------------------------------------------------------------------- /src/Application/Common/DTOs/Folders/FolderInDto.cs: -------------------------------------------------------------------------------- 1 | namespace Application.Common.DTOs.Folders; 2 | 3 | public class FolderInDto 4 | { 5 | public string Guid { get; set; } 6 | 7 | public string Name { get; set; } 8 | 9 | public string Color { get; set; } 10 | 11 | public string Icon { get; set; } 12 | 13 | public string Description { get; set; } 14 | 15 | public string LastModified { get; set; } 16 | 17 | public int IndexInParent { get; set; } 18 | 19 | public ICollection Children { get; set; } = new List(); 20 | } -------------------------------------------------------------------------------- /src/Application/Common/DTOs/Users/LoginDto.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace Application.Common.DTOs.Users; 4 | 5 | public class LoginDto 6 | { 7 | [Required] 8 | [EmailAddress(ErrorMessage = "8 Invalid email address format")] 9 | [MaxLength(60, ErrorMessage = "10 The email is too long")] 10 | public string Email { get; set; } 11 | 12 | [Required] 13 | [MinLength(4, ErrorMessage = "11 The password is too short")] 14 | [MaxLength(60, ErrorMessage = "12 The password is too long")] 15 | public string Password { get; set; } 16 | } -------------------------------------------------------------------------------- /src/Application/Common/Mappings/BookmarkAutoMapperProfile.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.DTOs.Bookmarks; 2 | using AutoMapper; 3 | using Domain.Entities; 4 | 5 | namespace Application.Common.Mappings; 6 | 7 | public class BookmarkAutoMapperProfile : Profile 8 | { 9 | public BookmarkAutoMapperProfile() 10 | { 11 | CreateMap() 12 | .ForMember(dest => dest.BookmarkId, temp => temp.MapFrom(src => src.Guid)); 13 | CreateMap() 14 | .ForMember(dest => dest.Guid, temp => temp.MapFrom(src => src.BookmarkId.ToString())); 15 | } 16 | } -------------------------------------------------------------------------------- /src/Application/Common/DTOs/Product/ProductOutDto.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.ObjectModel; 2 | 3 | namespace Application.Common.DTOs.Product; 4 | 5 | public class ProductOutDto 6 | { 7 | public string Id { get; set; } 8 | 9 | public bool Active { get; set; } 10 | 11 | public string Name { get; set; } 12 | 13 | public string Description { get; set; } 14 | 15 | public int Price { get; set; } 16 | 17 | public string PriceId { get; set; } 18 | 19 | public bool LiveMode { get; set; } 20 | 21 | public ICollection Features { get; set; } = new Collection(); 22 | } -------------------------------------------------------------------------------- /src/Application/Common/Mappings/HighlightAutoMapperProfile.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.DTOs.Highlights; 2 | using AutoMapper; 3 | using Domain.Entities; 4 | 5 | namespace Application.Common.Mappings; 6 | 7 | public class HighlightAutoMapperProfile : Profile 8 | { 9 | public HighlightAutoMapperProfile() 10 | { 11 | CreateMap() 12 | .ForMember(dest => dest.HighlightId, temp => temp.MapFrom(src => src.Guid)); 13 | CreateMap() 14 | .ForMember(dest => dest.Guid, temp => temp.MapFrom(src => src.HighlightId.ToString())); 15 | } 16 | } -------------------------------------------------------------------------------- /src/Application/Interfaces/Repositories/IUserRepository.cs: -------------------------------------------------------------------------------- 1 | using Domain.Entities; 2 | 3 | namespace Application.Interfaces.Repositories; 4 | 5 | public interface IUserRepository 6 | { 7 | public Task GetAsync(string email, bool trackChanges); 8 | public Task GetByCustomerIdAsync(string customerId, bool trackChanges); 9 | public void Delete(User user); 10 | public Task DeleteUnconfirmedUsers(); 11 | public Task> GetUsersWhoDowngradedMoreThanAWeekAgo(); 12 | public Task ResetAiExplanationCount(); 13 | public Task ResetTranslationsCount(); 14 | public Task SaveChangesAsync(); 15 | } -------------------------------------------------------------------------------- /self-hosting/librum-server.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Server for the Librum application 3 | 4 | [Service] 5 | WorkingDirectory=/var/lib/librum-server/srv 6 | ExecStart=/var/lib/librum-server/srv/run.sh 7 | User=librum-server 8 | Restart=always 9 | # Restart service after 10 seconds if the dotnet service crashes: 10 | RestartSec=10 11 | KillSignal=SIGINT 12 | SyslogIdentifier=librum-server 13 | Environment=ASPNETCORE_ENVIRONMENT=Production 14 | Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false 15 | Environment=LIBRUM_SELFHOSTED=true 16 | EnvironmentFile=/etc/librum-server/librum-server.conf 17 | 18 | [Install] 19 | WantedBy=multi-user.target 20 | -------------------------------------------------------------------------------- /src/Infrastructure/Persistence/EntityConfigurations/FolderConfiguration.cs: -------------------------------------------------------------------------------- 1 | using Domain.Entities; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 4 | 5 | namespace Infrastructure.Persistence.EntityConfigurations; 6 | 7 | public class FolderConfiguration : IEntityTypeConfiguration 8 | { 9 | public void Configure(EntityTypeBuilder builder) 10 | { 11 | builder 12 | .HasOne(f => f.ParentFolder) 13 | .WithMany(f => f.Children) 14 | .HasForeignKey(f => f.ParentFolderId) 15 | .OnDelete(DeleteBehavior.NoAction); 16 | } 17 | } -------------------------------------------------------------------------------- /src/Presentation/Controllers/ProductController.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.DTOs.Product; 2 | using Application.Interfaces.Services; 3 | using Microsoft.AspNetCore.Mvc; 4 | 5 | namespace Presentation.Controllers; 6 | 7 | [ApiController] 8 | [Route("products")] 9 | public class ProductController(IProductService productService) : ControllerBase 10 | { 11 | private IProductService ProductService { get; } = productService; 12 | 13 | [HttpGet] 14 | public async Task>> GetTags() 15 | { 16 | var result = await ProductService.GetAllProductsAsync(); 17 | return Ok(result); 18 | } 19 | } -------------------------------------------------------------------------------- /src/Application/Common/DTOs/Folders/FolderOutDto.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.ObjectModel; 2 | 3 | namespace Application.Common.DTOs.Folders; 4 | 5 | public class FolderOutDto 6 | { 7 | public string Guid { get; set; } 8 | 9 | public string Name { get; set; } 10 | 11 | public string Color { get; set; } 12 | 13 | public string Icon { get; set; } 14 | 15 | public string Description { get; set; } 16 | 17 | public string LastModified { get; set; } 18 | 19 | public int IndexInParent { get; set; } 20 | 21 | public ICollection Children { get; set; } = new Collection(); 22 | } -------------------------------------------------------------------------------- /src/Application/Common/DTOs/Product/ProductForUpdateDto.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.ObjectModel; 2 | 3 | namespace Application.Common.DTOs.Product; 4 | 5 | public class ProductForUpdateDto 6 | { 7 | public string Id { get; set; } 8 | 9 | public string Name { get; set; } 10 | 11 | public string Description { get; set; } 12 | 13 | public int Price { get; set; } 14 | 15 | public long BookStorageLimit { get; set; } 16 | 17 | public int AiRequestLimit { get; set; } 18 | 19 | public int TranslationsLimit { get; set; } 20 | 21 | public ICollection Features { get; set; } = new Collection(); 22 | } -------------------------------------------------------------------------------- /src/Domain/Entities/Tag.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.ComponentModel.DataAnnotations.Schema; 3 | 4 | namespace Domain.Entities; 5 | 6 | public class Tag 7 | { 8 | [DatabaseGenerated(DatabaseGeneratedOption.None)] 9 | [Key] 10 | public Guid TagId { get; set; } 11 | 12 | [MinLength(1, ErrorMessage = "The tag name is too short")] 13 | [MaxLength(5000, ErrorMessage = "The tag name is too long")] 14 | public string Name { get; set; } 15 | 16 | [Required] 17 | public DateTime CreationDate { get; set; } 18 | 19 | 20 | public string UserId { get; set; } 21 | public User User { get; set; } 22 | } -------------------------------------------------------------------------------- /src/Presentation/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Warning", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*", 9 | "AzureKeyVaultUri": "https://librum-keyvault.vault.azure.net/", 10 | "IpRateLimiting": { 11 | "EnableEndpointRateLimiting": true, 12 | "StackBlockedRequests": false, 13 | "RealIpHeader": "X-Real-IP", 14 | "ClientIdHeader": "X-ClientId", 15 | "HttpStatusCode": 429, 16 | "GeneralRules": [ 17 | { 18 | "Endpoint": "post:/api/register", 19 | "Period": "15m", 20 | "Limit": 6 21 | } 22 | ] 23 | } 24 | } -------------------------------------------------------------------------------- /src/Application/Common/Mappings/TagAutoMapperProfile.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.DTOs.Tags; 2 | using AutoMapper; 3 | using Domain.Entities; 4 | 5 | namespace Application.Common.Mappings; 6 | 7 | public class TagAutoMapperProfile : Profile 8 | { 9 | public TagAutoMapperProfile() 10 | { 11 | CreateMap() 12 | .ForMember(dest => dest.CreationDate, temp => temp.MapFrom(src => DateTime.UtcNow)) 13 | .ForMember(dest => dest.TagId, temp => temp.MapFrom(src => src.Guid)); 14 | 15 | CreateMap() 16 | .ForMember(dest => dest.Guid, temp => temp.MapFrom(src => src.TagId.ToString())); 17 | } 18 | } -------------------------------------------------------------------------------- /src/Application/Common/Mappings/RectFAutoMapperProfile.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.DTOs.Highlights; 2 | using AutoMapper; 3 | using Domain.Entities; 4 | 5 | namespace Application.Common.Mappings; 6 | 7 | public class RectFAutoMapperProfile : Profile 8 | { 9 | public RectFAutoMapperProfile() 10 | { 11 | CreateMap() 12 | .ForMember(dest => dest.RectFId, temp => temp.MapFrom(src => src.Guid)); 13 | CreateMap().ForMember(dest => dest.Guid, 14 | temp => temp.MapFrom( 15 | src => src.RectFId.ToString())); 16 | } 17 | } -------------------------------------------------------------------------------- /src/Domain/Entities/Bookmark.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.ComponentModel.DataAnnotations.Schema; 3 | 4 | namespace Domain.Entities; 5 | 6 | public class Bookmark 7 | { 8 | [DatabaseGenerated(DatabaseGeneratedOption.None)] 9 | [Key] 10 | public Guid BookmarkId { get; set; } 11 | 12 | [Required] 13 | [MaxLength(5000, ErrorMessage = "The name is too long")] 14 | public string Name { get; set; } 15 | 16 | [Required] 17 | public int PageNumber { get; set; } 18 | 19 | [Required] 20 | public float YOffset { get; set; } 21 | 22 | public Guid BookId { get; set; } 23 | public Book Book { get; set; } 24 | } -------------------------------------------------------------------------------- /src/Domain/Entities/RectF.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.ComponentModel.DataAnnotations.Schema; 3 | 4 | namespace Domain.Entities; 5 | 6 | public class RectF 7 | { 8 | [DatabaseGenerated(DatabaseGeneratedOption.None)] 9 | [Key] 10 | public Guid RectFId { get; set; } 11 | 12 | [Required] 13 | public float X { get; set; } 14 | 15 | [Required] 16 | public float Y { get; set; } 17 | 18 | [Required] 19 | public float Width { get; set; } 20 | 21 | [Required] 22 | public float Height { get; set; } 23 | 24 | public Guid HighlightId { get; set; } 25 | public Highlight Highlight { get; set; } 26 | } -------------------------------------------------------------------------------- /src/Domain/Domain.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | disable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/Application/Interfaces/Managers/IAuthenticationManager.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.DTOs.Users; 2 | using Domain.Entities; 3 | 4 | namespace Application.Interfaces.Managers; 5 | 6 | public interface IAuthenticationManager 7 | { 8 | public Task CreateUserAsync(User user, string password); 9 | public Task UserExistsAsync(string email, string password); 10 | public Task EmailAlreadyExistsAsync(string email); 11 | public Task CreateTokenAsync(LoginDto loginDto); 12 | public Task GetEmailConfirmationLinkAsync(User user); 13 | public Task ConfirmEmailAsync(string email, string token); 14 | public Task IsEmailConfirmed(string email); 15 | } -------------------------------------------------------------------------------- /tests/Application.UnitTests/TestHelper.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | using Moq; 3 | 4 | namespace Application.UnitTests; 5 | 6 | public static class TestHelpers 7 | { 8 | public static Mock> MockUserManager() 9 | where TUser : class 10 | { 11 | var store = new Mock>(); 12 | var mgr = new Mock>(store.Object, null, null, null, 13 | null, null, null, null, null); 14 | mgr.Object.UserValidators.Add(new UserValidator()); 15 | mgr.Object.PasswordValidators.Add(new PasswordValidator()); 16 | return mgr; 17 | } 18 | } -------------------------------------------------------------------------------- /src/Infrastructure/Persistence/EntityConfigurations/BookConfiguration.cs: -------------------------------------------------------------------------------- 1 | using Domain.Entities; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 4 | 5 | 6 | namespace Infrastructure.Persistence.EntityConfigurations; 7 | 8 | 9 | public class BookConfiguration : IEntityTypeConfiguration 10 | { 11 | public void Configure(EntityTypeBuilder builder) 12 | { 13 | builder.Property(b => b.ProjectGutenbergId) 14 | .HasDefaultValue(0); 15 | builder.Property(b => b.ColorTheme) 16 | .HasDefaultValue("Normal"); 17 | builder.Property(b => b.FileHash) 18 | .HasDefaultValue(""); 19 | } 20 | } -------------------------------------------------------------------------------- /src/Domain/Entities/Highlight.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.ComponentModel.DataAnnotations.Schema; 3 | 4 | namespace Domain.Entities; 5 | 6 | public class Highlight 7 | { 8 | [DatabaseGenerated(DatabaseGeneratedOption.None)] 9 | [Key] 10 | public Guid HighlightId { get; set; } 11 | 12 | [Required] 13 | [MaxLength(500, ErrorMessage = "The color is too long")] 14 | public string Color { get; set; } 15 | 16 | [Required] 17 | public int PageNumber { get; set; } 18 | 19 | [Required] 20 | public ICollection Rects { get; set; } = new List(); 21 | 22 | public Guid BookId { get; set; } 23 | public Book Book { get; set; } 24 | } -------------------------------------------------------------------------------- /src/Infrastructure/Persistence/Repository/TagRepository.cs: -------------------------------------------------------------------------------- 1 | using Application.Interfaces.Repositories; 2 | using Domain.Entities; 3 | 4 | namespace Infrastructure.Persistence.Repository; 5 | 6 | public class TagRepository : ITagRepository 7 | { 8 | private readonly DataContext _context; 9 | 10 | 11 | public TagRepository(DataContext context) 12 | { 13 | _context = context; 14 | } 15 | 16 | 17 | public async Task SaveChangesAsync() 18 | { 19 | return await _context.SaveChangesAsync(); 20 | } 21 | 22 | public void Add(Tag tag) 23 | { 24 | _context.Add(tag); 25 | } 26 | 27 | public void Delete(Tag tag) 28 | { 29 | _context.Tags.Remove(tag); 30 | } 31 | } -------------------------------------------------------------------------------- /src/Application/Common/DTOs/ApiExceptionDto.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | 3 | namespace Application.Common.DTOs; 4 | 5 | /// 6 | /// API specific failures that require a stack trace for debugging 7 | /// 8 | public class ApiExceptionDto 9 | { 10 | public int StatusCode { get; set; } 11 | 12 | public string Message { get; set; } 13 | 14 | public string StackTrace { get; set; } 15 | 16 | 17 | public ApiExceptionDto(int statusCode, string message, string stackTrace = null) 18 | { 19 | StatusCode = statusCode; 20 | Message = message; 21 | StackTrace = stackTrace; 22 | } 23 | 24 | public override string ToString() => 25 | JsonConvert.SerializeObject(this, new JsonSerializerSettings()); 26 | } -------------------------------------------------------------------------------- /src/Application/Common/DTOs/CommonErrorDto.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | 3 | namespace Application.Common.DTOs; 4 | 5 | /// 6 | /// Common errors such as "Wrong Password 7 | /// 8 | public class CommonErrorDto 9 | { 10 | [JsonProperty("status")] 11 | public int Status { get; set; } 12 | [JsonProperty("message")] 13 | public string Message { get; set; } 14 | [JsonProperty("code")] 15 | public int Code { get; set; } 16 | 17 | public CommonErrorDto(int status, string message, int code) 18 | { 19 | Status = status; 20 | Message = message; 21 | Code = code; 22 | } 23 | 24 | public override string ToString() => 25 | JsonConvert.SerializeObject(this, new JsonSerializerSettings()); 26 | } -------------------------------------------------------------------------------- /appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Kestrel": { 3 | "EndPoints": { 4 | "Http": { 5 | "Url": "http://0.0.0.0:5000" 6 | } 7 | } 8 | }, 9 | "Logging": { 10 | "LogLevel": { 11 | "Default": "Warning", 12 | "Microsoft.AspNetCore": "Warning" 13 | } 14 | }, 15 | "AllowedHosts": "*", 16 | "AzureKeyVaultUri": "https://librum-keyvault.vault.azure.net/", 17 | "IpRateLimiting": { 18 | "EnableEndpointRateLimiting": true, 19 | "StackBlockedRequests": false, 20 | "RealIpHeader": "X-Real-IP", 21 | "ClientIdHeader": "X-ClientId", 22 | "HttpStatusCode": 429, 23 | "GeneralRules": [ 24 | { 25 | "Endpoint": "post:/api/register", 26 | "Period": "15m", 27 | "Limit": 6 28 | } 29 | ] 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Application/Common/Attributes/DisableFormValueModelBindingAttribute.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.Filters; 2 | using Microsoft.AspNetCore.Mvc.ModelBinding; 3 | 4 | namespace Application.Common.Attributes; 5 | 6 | [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] 7 | public class DisableFormValueModelBindingAttribute : Attribute, IResourceFilter 8 | { 9 | public void OnResourceExecuting(ResourceExecutingContext context) 10 | { 11 | var factories = context.ValueProviderFactories; 12 | factories.RemoveType(); 13 | factories.RemoveType(); 14 | factories.RemoveType(); 15 | } 16 | 17 | public void OnResourceExecuted(ResourceExecutedContext context) 18 | { 19 | } 20 | } -------------------------------------------------------------------------------- /src/Application/Common/Extensions/SSEHttpContextExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | 3 | namespace Application.Common.Extensions; 4 | 5 | public static class SSEHttpContextExtensions 6 | { 7 | public static async Task SSEInitAsync(this HttpContext ctx) 8 | { 9 | ctx.Response.Headers.Add("Cache-Control", "no-cache"); 10 | ctx.Response.Headers.Add("Content-Type", "text/event-stream"); 11 | await ctx.Response.Body.FlushAsync(); 12 | } 13 | 14 | public static async Task SSESendDataAsync(this HttpContext ctx, string data) 15 | { 16 | foreach(var line in data.Split('\n')) 17 | await ctx.Response.WriteAsync("data: " + line + "\n"); 18 | 19 | await ctx.Response.WriteAsync("\n"); 20 | await ctx.Response.Body.FlushAsync(); 21 | } 22 | } -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # Runs all tests 2 | name: Test 3 | 4 | on: 5 | workflow_dispatch: 6 | push: 7 | branches: 8 | - dev/develop 9 | - main 10 | pull_request: 11 | branches: 12 | - dev/develop 13 | - main 14 | 15 | 16 | 17 | 18 | jobs: 19 | test: 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | 25 | - name: Setup .NET 26 | uses: actions/setup-dotnet@v3 27 | with: 28 | dotnet-version: '8.0.x' 29 | 30 | - name: Restore 31 | run: dotnet restore ./Librum-Server.sln 32 | 33 | - name: Build 34 | run: dotnet build src/Presentation --configuration Release --no-restore 35 | 36 | - name: Test 37 | run: dotnet test --configuration Release 38 | -------------------------------------------------------------------------------- /src/Application/Common/DTOs/Product/ProductInDto.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.ObjectModel; 2 | using System.ComponentModel.DataAnnotations; 3 | 4 | namespace Application.Common.DTOs.Product; 5 | 6 | public class ProductInDto 7 | { 8 | [Required] 9 | public string Id { get; set; } 10 | 11 | [Required] 12 | public string Name { get; set; } 13 | 14 | [Required] 15 | public string Description { get; set; } 16 | 17 | [Required] 18 | public long BookStorageLimit { get; set; } 19 | 20 | [Required] 21 | public int AiRequestLimit { get; set; } 22 | 23 | [Required] 24 | public int TranslationsLimit { get; set; } 25 | 26 | [Required] 27 | public bool LiveMode { get; set; } 28 | 29 | [Required] 30 | public ICollection Features { get; set; } = new Collection(); 31 | } -------------------------------------------------------------------------------- /src/Infrastructure/Persistence/Migrations/20231018060059_AddedProjectGutenbergId.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace Infrastructure.Persistence.Migrations 6 | { 7 | public partial class AddedProjectGutenbergId : Migration 8 | { 9 | protected override void Up(MigrationBuilder migrationBuilder) 10 | { 11 | migrationBuilder.AddColumn( 12 | name: "ProjectGutenbergId", 13 | table: "Books", 14 | type: "int", 15 | nullable: false, 16 | defaultValue: 0); 17 | } 18 | 19 | protected override void Down(MigrationBuilder migrationBuilder) 20 | { 21 | migrationBuilder.DropColumn( 22 | name: "ProjectGutenbergId", 23 | table: "Books"); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Application/Common/Mappings/ProductAutoMapperProfile.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.DTOs.Highlights; 2 | using Application.Common.DTOs.Product; 3 | using AutoMapper; 4 | using Domain.Entities; 5 | 6 | namespace Application.Common.Mappings; 7 | 8 | public class ProductAutoMapperProfile : Profile 9 | { 10 | public ProductAutoMapperProfile() 11 | { 12 | CreateMap() 13 | .ForMember(dest => dest.ProductId, temp => temp.MapFrom(src => src.Id)) 14 | .ForMember(dest => dest.Features, temp => temp.Ignore()) 15 | .ForMember(dest => dest.Price, temp => temp.Ignore()); 16 | CreateMap() 17 | .ForMember(dest => dest.Id, temp => temp.MapFrom(src => src.ProductId.ToString())) 18 | .ForMember(dest => dest.Features, temp => temp.MapFrom(src => src.Features.Select(feature => feature.Name))); 19 | } 20 | } -------------------------------------------------------------------------------- /src/Infrastructure/Persistence/Migrations/20231013134330_AddedAiExplanationCount.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace Infrastructure.Persistence.Migrations 6 | { 7 | public partial class AddedAiExplanationCount : Migration 8 | { 9 | protected override void Up(MigrationBuilder migrationBuilder) 10 | { 11 | migrationBuilder.AddColumn( 12 | name: "AiExplanationRequestsMadeToday", 13 | table: "AspNetUsers", 14 | type: "int", 15 | nullable: false, 16 | defaultValue: 0); 17 | } 18 | 19 | protected override void Down(MigrationBuilder migrationBuilder) 20 | { 21 | migrationBuilder.DropColumn( 22 | name: "AiExplanationRequestsMadeToday", 23 | table: "AspNetUsers"); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Application/Common/Mappings/UserAutoMapperProfile.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.DTOs.Users; 2 | using Application.Interfaces.Repositories; 3 | using AutoMapper; 4 | using Domain.Entities; 5 | 6 | namespace Application.Common.Mappings; 7 | 8 | public class UserAutoMapperProfile : Profile 9 | { 10 | public UserAutoMapperProfile() 11 | { 12 | CreateMap() 13 | .ForMember(dest => dest.Role, temp => temp.Ignore()); 14 | 15 | CreateMap() 16 | .ForMember(dest => dest.UserName, temp => temp.MapFrom(src => src.Email)) 17 | .ForMember(dest => dest.AccountCreation, 18 | temp => temp.MapFrom(src => DateTime.UtcNow)) 19 | .ForMember(dest => dest.PasswordHash, temp => temp.Ignore()) 20 | .ForMember(dest => dest.ProductId, temp => temp.Ignore()); 21 | 22 | CreateMap() 23 | .ReverseMap(); 24 | } 25 | } -------------------------------------------------------------------------------- /self-hosting/librum-server.conf: -------------------------------------------------------------------------------- 1 | # Valid issuer for JWT Key - string 2 | JWTValidIssuer="exampleIssuer" 3 | 4 | # Secret key for JWT token generation (at least 20 symbols) 5 | JWTKey="exampleOfALongSecretToken" 6 | 7 | # An admin email for seeding the database with an admin account on the first run 8 | AdminEmail="admin@example.com" 9 | 10 | # A password for the admin account (5 symbols minimum) 11 | AdminPassword="strongPassword123" 12 | 13 | # The connection string for Mysql (or MariaDB) 14 | DBConnectionString="Server=127.0.0.1;port=3306;Database=my_database_name;Uid=mysql_user;Pwd=mysql_password;" 15 | 16 | # A clean url without ports, it will be used to build the "reset password link". 17 | # As an example, a server running on 127.0.0.1:5000 can be exposed to the web as https://myserver.com, so the CleanUrl would be https://myserver.com 18 | CleanUrl="https://127.0.0.1" 19 | 20 | # Your OpenAI api token - If left empty, all Ai services will simply be disabled 21 | OpenAIToken="" 22 | -------------------------------------------------------------------------------- /src/Application/Interfaces/Services/IBookService.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.DTOs.Books; 2 | using Microsoft.AspNetCore.Mvc; 3 | using Microsoft.AspNetCore.WebUtilities; 4 | 5 | namespace Application.Interfaces.Services; 6 | 7 | public interface IBookService 8 | { 9 | Task CreateBookAsync(string email, BookInDto bookInDto); 10 | Task> GetBooksAsync(string email); 11 | Task DeleteBooksAsync(string email, IEnumerable guids); 12 | Task UpdateBookAsync(string email, BookForUpdateDto bookUpdateDto); 13 | Task AddBookBinaryData(string email, Guid guid, MultipartReader reader); 14 | Task GetBookBinaryData(string email, Guid guid); 15 | Task ChangeBookCover(string email, Guid guid, MultipartReader reader); 16 | Task GetBookCover(string email, Guid guid); 17 | Task DeleteBookCover(string email, Guid guid); 18 | Task GetFormatForBook(string email, Guid guid); 19 | Task GetExtensionForBook(string email, Guid guid); 20 | } -------------------------------------------------------------------------------- /src/Application/Common/DTOs/Users/UserOutDto.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.DTOs.Tags; 2 | 3 | namespace Application.Common.DTOs.Users; 4 | 5 | public class UserOutDto 6 | { 7 | // Legacy - to be removed 8 | public string FirstName { get; set; } 9 | 10 | // Legacy - to be removed 11 | public string LastName { get; set; } 12 | 13 | public string Name { get; set; } 14 | 15 | public string Email { get; set; } 16 | 17 | public string Role { get; set; } 18 | 19 | public string ProductId { get; set; } 20 | 21 | public string CustomerId { get; set; } 22 | 23 | public DateTime AccountCreation { get; set; } 24 | 25 | public long UsedBookStorage { get; set; } 26 | 27 | public long BookStorageLimit { get; set; } 28 | 29 | public DateTime ProfilePictureLastUpdated { get; set; } 30 | 31 | public bool HasProfilePicture { get; set; } 32 | 33 | public ICollection Tags { get; set; } = new List(); 34 | } 35 | -------------------------------------------------------------------------------- /src/Presentation/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:59123", 8 | "sslPort": 44308 9 | } 10 | }, 11 | "profiles": { 12 | "Presentation": { 13 | "commandName": "Project", 14 | "dotnetRunMessages": true, 15 | "launchBrowser": false, 16 | "launchUrl": "swagger", 17 | "applicationUrl": "https://localhost:7084;http://localhost:7412", 18 | "environmentVariables": { 19 | "ASPNETCORE_ENVIRONMENT": "Development" 20 | } 21 | }, 22 | "IIS Express": { 23 | "commandName": "IISExpress", 24 | "launchBrowser": false, 25 | "launchUrl": "swagger", 26 | "environmentVariables": { 27 | "ASPNETCORE_ENVIRONMENT": "Development" 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Domain/Entities/Product.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.ObjectModel; 2 | using System.ComponentModel.DataAnnotations; 3 | using System.ComponentModel.DataAnnotations.Schema; 4 | 5 | namespace Domain.Entities; 6 | 7 | public class Product 8 | { 9 | [DatabaseGenerated(DatabaseGeneratedOption.None)] 10 | [Key] 11 | public string ProductId { get; set; } 12 | 13 | [Required] 14 | public string Name { get; set; } 15 | 16 | [Required] 17 | public string Description { get; set; } 18 | 19 | public int Price { get; set; } 20 | 21 | public string PriceId { get; set; } 22 | 23 | [Required] 24 | public long BookStorageLimit { get; set; } 25 | 26 | [Required] 27 | public int AiRequestLimit { get; set; } 28 | 29 | [Required] 30 | public int TranslationsLimit { get; set; } 31 | 32 | [Required] 33 | public bool LiveMode { get; set; } = true; 34 | 35 | public ICollection Features { get; set; } = new List(); 36 | } -------------------------------------------------------------------------------- /src/Application/Common/Mappings/BookAutoMapperProfile.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.DTOs.Books; 2 | using AutoMapper; 3 | using Domain.Entities; 4 | 5 | namespace Application.Common.Mappings; 6 | 7 | public class BookAutoMapperProfile : Profile 8 | { 9 | public BookAutoMapperProfile() 10 | { 11 | CreateMap() 12 | .ForMember(dest => dest.Tags, temp => temp.Ignore()) 13 | .ForMember(dest => dest.Highlights, temp => temp.Ignore()) 14 | .ForMember(dest => dest.Bookmarks, temp => temp.Ignore()); 15 | 16 | CreateMap() 17 | .ForMember(dest => dest.Format, 18 | temp => temp.MapFrom(src => src.Format.ToString())) 19 | .ForMember(dest => dest.Guid, 20 | temp => temp.MapFrom(src => src.BookId.ToString())); 21 | 22 | CreateMap() 23 | .ForMember(dest => dest.Tags, temp => temp.Ignore()); 24 | 25 | CreateMap(); 26 | } 27 | } -------------------------------------------------------------------------------- /src/Infrastructure/Persistence/Repository/ProductRepository.cs: -------------------------------------------------------------------------------- 1 | using Application.Interfaces.Repositories; 2 | using Domain.Entities; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | namespace Infrastructure.Persistence.Repository; 6 | 7 | public class ProductRepository(DataContext context) : IProductRepository 8 | { 9 | public DataContext Context { get; } = context; 10 | 11 | public async Task SaveChangesAsync() 12 | { 13 | return await Context.SaveChangesAsync(); 14 | } 15 | 16 | public void CreateProduct(Product product) 17 | { 18 | Context.Add(product); 19 | } 20 | 21 | public IQueryable GetAll() 22 | { 23 | return Context.Products.Include(p => p.Features); 24 | } 25 | 26 | public async Task GetByIdAsync(string id) 27 | { 28 | return await Context.Products.Include(p => p.Features).SingleOrDefaultAsync(p => p.ProductId == id); 29 | } 30 | 31 | public void DeleteProduct(Product product) 32 | { 33 | Context.Remove(product); 34 | } 35 | } -------------------------------------------------------------------------------- /src/Infrastructure/Infrastructure.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | disable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | runtime; build; native; contentfiles; analyzers; buildtransitive 15 | all 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yaml: -------------------------------------------------------------------------------- 1 | # Deploys updated binaries to the production server 2 | name: Deploy 3 | 4 | on: 5 | workflow_dispatch: 6 | push: 7 | branches: 8 | - main 9 | 10 | env: 11 | AZURE_WEBAPP_NAME: librum-server 12 | AZURE_WEBAPP_PACKAGE_PATH: "${{ github.workspace }}/publish" 13 | 14 | 15 | jobs: 16 | deploy: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | 22 | - name: Setup .NET 23 | uses: actions/setup-dotnet@v3 24 | with: 25 | dotnet-version: '8.0.x' 26 | 27 | - name: Restore 28 | run: dotnet restore ./Librum-Server.sln 29 | 30 | - name: Build 31 | run: dotnet build src/Presentation --configuration Release --no-restore 32 | 33 | - name: Publish 34 | run: dotnet publish src/Presentation --configuration Release --no-build --property PublishDir=${{ env.AZURE_WEBAPP_PACKAGE_PATH }} 35 | 36 | - name: Deploy 37 | uses: azure/webapps-deploy@v2 38 | with: 39 | app-name: ${{ env.AZURE_WEBAPP_NAME }} 40 | publish-profile: ${{ secrets.AZURE_PUBLISH_PROFILE }} 41 | package: ${{ env.AZURE_WEBAPP_PACKAGE_PATH }} 42 | -------------------------------------------------------------------------------- /src/Infrastructure/Persistence/DataContext.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using Domain.Entities; 3 | using Microsoft.AspNetCore.Identity.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore; 5 | 6 | namespace Infrastructure.Persistence; 7 | 8 | public class DataContext : IdentityDbContext 9 | { 10 | public DbSet Books { get; set; } 11 | public DbSet Highlights { get; set; } 12 | public DbSet Bookmarks { get; set; } 13 | public DbSet Tags { get; set; } 14 | public DbSet Products { get; set; } 15 | public DbSet Folders { get; set; } 16 | 17 | public DataContext(DbContextOptions options) : 18 | base(options) 19 | { 20 | } 21 | 22 | protected override void OnModelCreating(ModelBuilder builder) 23 | { 24 | builder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); 25 | 26 | builder 27 | .Entity() 28 | .HasMany(p => p.Features) 29 | .WithOne(pf => pf.Product) 30 | .HasForeignKey(pf => pf.ProductId) 31 | .IsRequired(); 32 | 33 | base.OnModelCreating(builder); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Application/Common/DataAnnotations/EmptyOrMinLengthAttribute.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using Microsoft.IdentityModel.Tokens; 3 | 4 | namespace Application.Common.DataAnnotations; 5 | 6 | /// Specifies the minimum length of string data, which is still allowed to be 7 | /// empty, in an attribute. 8 | [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | 9 | AttributeTargets.Parameter, AllowMultiple = false)] 10 | public class EmptyOrMinLengthAttribute : ValidationAttribute 11 | { 12 | public int Length { get; private set; } 13 | 14 | public EmptyOrMinLengthAttribute(int length) : base("String is smaller than the " + 15 | "minimum length.") 16 | { 17 | Length = length; 18 | } 19 | 20 | public override bool IsValid(object value) 21 | { 22 | if (Length < 0) 23 | throw new InvalidOperationException("Minimum length can not be less than 0."); 24 | 25 | var str = value as string; 26 | if (str.IsNullOrEmpty()) // Automatically pass if value is null or empty 27 | { 28 | return true; 29 | } 30 | 31 | return str!.Length >= Length; 32 | } 33 | } -------------------------------------------------------------------------------- /src/Infrastructure/Persistence/EntityConfigurations/UserConfiguration.cs: -------------------------------------------------------------------------------- 1 | using Domain.Entities; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 4 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 5 | 6 | namespace Infrastructure.Persistence.EntityConfigurations; 7 | 8 | public class UserConfiguration : IEntityTypeConfiguration 9 | { 10 | public void Configure(EntityTypeBuilder builder) 11 | { 12 | var utcConverter = new ValueConverter( 13 | toDb => toDb, 14 | fromDb => DateTime.SpecifyKind(fromDb, DateTimeKind.Utc) 15 | ); 16 | 17 | builder.Property(x => x.AccountCreation) 18 | .HasConversion(utcConverter); 19 | 20 | builder 21 | .HasMany(x => x.Books) 22 | .WithOne(x => x.User) 23 | .HasForeignKey(x => x.UserId) 24 | .IsRequired() 25 | .OnDelete(DeleteBehavior.Cascade); 26 | 27 | builder 28 | .HasMany(x => x.Tags) 29 | .WithOne(x => x.User) 30 | .HasForeignKey(x => x.UserId) 31 | .IsRequired() 32 | .OnDelete(DeleteBehavior.Cascade); 33 | } 34 | } -------------------------------------------------------------------------------- /src/Domain/Entities/Folder.cs: -------------------------------------------------------------------------------- 1 | #nullable enable 2 | using System.ComponentModel.DataAnnotations; 3 | using System.ComponentModel.DataAnnotations.Schema; 4 | 5 | namespace Domain.Entities; 6 | 7 | public class Folder 8 | { 9 | [DatabaseGenerated(DatabaseGeneratedOption.None)] 10 | [Key] 11 | public Guid FolderId { get; set; } 12 | 13 | [Required] 14 | [MaxLength(5000, ErrorMessage = "The name is too long")] 15 | public string Name { get; set; } 16 | 17 | [Required] 18 | [MaxLength(500, ErrorMessage = "The color is too long")] 19 | public string Color { get; set; } 20 | 21 | [Required] 22 | [MaxLength(500, ErrorMessage = "The icon name is too long")] 23 | public string Icon { get; set; } 24 | 25 | [MaxLength(10000, ErrorMessage = "The description is too long")] 26 | public string Description { get; set; } 27 | 28 | [Required] 29 | [MaxLength(500, ErrorMessage = "The last modified is too long")] 30 | public string LastModified { get; set; } 31 | 32 | public int IndexInParent { get; set; } 33 | 34 | public Guid? ParentFolderId { get; set; } 35 | public Folder? ParentFolder { get; set; } 36 | public List? Children { get; set; } = new List(); 37 | } -------------------------------------------------------------------------------- /src/Application/Common/DTOs/Users/UserForUpdateDto.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using Microsoft.IdentityModel.Tokens; 3 | 4 | namespace Application.Common.DTOs.Users; 5 | 6 | public class UserForUpdateDto 7 | { 8 | // Legacy - to be removed 9 | [MinLength(2, ErrorMessage = "The firstname is too short")] 10 | [MaxLength(40, ErrorMessage = "The firstname is too long")] 11 | public string FirstName { get; set; } 12 | 13 | // Legacy - to be removed 14 | [MinLength(2, ErrorMessage = "The lastname is too short")] 15 | [MaxLength(50, ErrorMessage = "The lastname is too long")] 16 | public string LastName { get; set; } 17 | 18 | [MinLength(2, ErrorMessage = "23 The name is too short")] 19 | [MaxLength(150, ErrorMessage = "24 The name is too long")] 20 | public string Name { get; set; } 21 | 22 | public DateTime ProfilePictureLastUpdated { get; set; } 23 | 24 | public bool HasProfilePicture { get; set; } 25 | 26 | public bool DataIsValid() 27 | { 28 | if (Name.IsNullOrEmpty()) 29 | { 30 | return LastName.Length is >= 2 and <= 50 && 31 | FirstName.Length is >= 2 and <= 40; 32 | } 33 | return Name.Length is >= 2 and <= 150; 34 | } 35 | } -------------------------------------------------------------------------------- /src/Application/Common/DTOs/Users/RegisterDto.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace Application.Common.DTOs.Users; 4 | 5 | public class RegisterDto 6 | { 7 | // Legacy - to be removed 8 | [MinLength(2, ErrorMessage = "13 The firstname is too short")] 9 | [MaxLength(40, ErrorMessage = "14 The firstname is too long")] 10 | public string FirstName { get; set; } 11 | 12 | // Legacy - to be removed 13 | [MinLength(2, ErrorMessage = "15 The lastname is too short")] 14 | [MaxLength(50, ErrorMessage = "16 The lastname is too long")] 15 | public string LastName { get; set; } 16 | 17 | [MinLength(2, ErrorMessage = "23 The name is too short")] 18 | [MaxLength(150, ErrorMessage = "24 The name is too long")] 19 | public string Name { get; set; } 20 | 21 | [Required] 22 | [EmailAddress(ErrorMessage = "8 Invalid email address format.")] 23 | [MaxLength(60, ErrorMessage = "10 The email is too long")] 24 | public string Email { get; set; } 25 | 26 | [Required] 27 | [MinLength(4, ErrorMessage = "11 The password is too short")] 28 | [MaxLength(60, ErrorMessage = "12 The password is too long")] 29 | public string Password { get; set; } 30 | 31 | public IEnumerable Roles { get; set; } 32 | } -------------------------------------------------------------------------------- /src/Application/Application.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | disable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/Application/BackgroundServices/DeleteUnconfirmedUsers.cs: -------------------------------------------------------------------------------- 1 | using Application.Interfaces.Repositories; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Microsoft.Extensions.Hosting; 4 | using Microsoft.Extensions.Logging; 5 | 6 | namespace Application.BackgroundServices; 7 | 8 | public class DeleteUnconfirmedUsers : BackgroundService 9 | { 10 | private readonly IServiceProvider _serviceProvider; 11 | 12 | public DeleteUnconfirmedUsers(IServiceProvider serviceProvider) 13 | { 14 | _serviceProvider = serviceProvider; 15 | } 16 | 17 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 18 | { 19 | while (!stoppingToken.IsCancellationRequested) 20 | { 21 | using (var scope = _serviceProvider.CreateScope()) 22 | { 23 | var logger = scope.ServiceProvider 24 | .GetService>(); 25 | logger.LogWarning("Deleting unconfirmed users"); 26 | 27 | var userRepository = scope.ServiceProvider.GetService(); 28 | await userRepository.DeleteUnconfirmedUsers(); 29 | } 30 | await Task.Delay(TimeSpan.FromMinutes(15), stoppingToken); 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /src/Application/Interfaces/Services/IUserService.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.DTOs.Tags; 2 | using Application.Common.DTOs.Users; 3 | using Microsoft.AspNetCore.JsonPatch; 4 | using Microsoft.AspNetCore.Mvc; 5 | using Microsoft.AspNetCore.WebUtilities; 6 | 7 | namespace Application.Interfaces.Services; 8 | 9 | public interface IUserService 10 | { 11 | public Task GetUserAsync(string email); 12 | public Task DeleteUserAsync(string email); 13 | public Task PatchUserAsync(string email, 14 | JsonPatchDocument patchDoc, 15 | ControllerBase controllerBase); 16 | public Task ChangeProfilePicture(string email, MultipartReader reader); 17 | public Task GetProfilePicture(string email); 18 | public Task DeleteProfilePicture(string email); 19 | public Task ChangePasswordAsync(string email, string newPassword); 20 | public Task ChangePasswordWithTokenAsync(string email, string token, 21 | string newPassword); 22 | public Task ForgotPassword(string email); 23 | public Task AddCustomerIdToUser(string email, string customerId); 24 | public Task AddTierToUser(string customerId, string productId); 25 | public Task ResetUserToFreeTier(string customerId); 26 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build 2 | WORKDIR /app 3 | 4 | COPY . . 5 | RUN dotnet restore && \ 6 | cd src/Presentation && \ 7 | dotnet publish -c Release -o build --no-restore --verbosity m 8 | 9 | FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/aspnet:8.0 10 | WORKDIR /var/lib/librum-server/ 11 | 12 | RUN groupadd -r -f librum-server 13 | RUN useradd -r -g librum-server -d /var/lib/librum-server --shell /usr/sbin/nologin librum-server 14 | 15 | COPY --from=build /app/src/Presentation/build /var/lib/librum-server/srv 16 | COPY --from=build /app/appsettings.json /var/lib/librum-server/srv/ 17 | RUN chmod -R 660 /var/lib/librum-server/ && \ 18 | chmod 770 /var/lib/librum-server && \ 19 | chmod 770 /var/lib/librum-server/srv 20 | COPY --from=build /app/self-hosting/run.sh . 21 | 22 | RUN install run.sh -m770 /var/lib/librum-server/srv && \ 23 | mkdir librum_storage && \ 24 | rm -f ./run.sh && \ 25 | chown -R librum-server: /var/lib/librum-server/ 26 | 27 | ENV CleanUrl=http://0.0.0.0 28 | ENV ASPNETCORE_ENVIRONMENT=Production 29 | ENV DOTNET_PRINT_TELEMETRY_MESSAGE=false 30 | ENV LIBRUM_SELFHOSTED=true 31 | 32 | EXPOSE 5000/tcp 33 | EXPOSE 5001/tcp 34 | 35 | VOLUME /var/lib/librum-server/librum_storage 36 | 37 | WORKDIR /var/lib/librum-server/srv 38 | USER librum-server 39 | ENTRYPOINT /var/lib/librum-server/srv/run.sh 40 | -------------------------------------------------------------------------------- /tests/Application.UnitTests/Application.UnitTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | runtime; build; native; contentfiles; analyzers; buildtransitive 19 | all 20 | 21 | 22 | runtime; build; native; contentfiles; analyzers; buildtransitive 23 | all 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Librum-Server 2 | The Librum-Server includes the API, database, and other fundamental infrastructure required for the "backend" of all Librum client applications and the website. 3 | 4 | The server is written in C# using ASP.NET Core. The codebase can be developed, built, run, and deployed cross-platform on Windows, macOS, and Linux. 5 | 6 |
7 | 8 | # Self-hosting 9 | Librum-Server can easily be self-hosted. This way all your data and books remain on your own devices and are not synchronized to the official cloud. 10 | 11 | ## 🐋 With Docker 12 | Librum-Server can be run with Docker. We provide a [docker-compose.yml](docker-compose.yml) file as well as our own images. We are using GitHub's `ghrc.io` Container Registry. 13 | 14 | ```bash 15 | wget https://github.com/Librum-Reader/Librum-Server/raw/main/docker-compose.yml 16 | 17 | docker compose up -d 18 | ``` 19 | 20 | ## 📃 Manual installation 21 | If you don't like Docker, you can also selfhost Librum-Server by running it as a service on your linux server. Instructions can be found [here](self-hosting/self-host-installation.md). 22 | 23 |
24 | 25 | # Contributing 26 | Feel free to reach out to us via email (contact@librumreader.com) or discord (m_david#0631) if you are interested in contributing.
27 |
28 | We are following a pull request workflow where every contribution is sent as a pull request and merged into the dev/develop branch for testing. 29 | Please make sure to keep to the conventions used throughout the application and ensure that all tests pass, before submitting any pull request. 30 | -------------------------------------------------------------------------------- /src/Infrastructure/Persistence/Repository/FolderRepository.cs: -------------------------------------------------------------------------------- 1 | using Application.Interfaces.Repositories; 2 | using Domain.Entities; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | namespace Infrastructure.Persistence.Repository; 6 | 7 | public class FolderRepository(DataContext context) : IFolderRepository 8 | { 9 | public DataContext Context { get; } = context; 10 | 11 | public async Task SaveChangesAsync() 12 | { 13 | await Context.SaveChangesAsync(); 14 | } 15 | 16 | public async Task GetFolderAsync(Guid folderId) 17 | { 18 | return await Context.Folders 19 | .Where(f => f.FolderId == folderId) 20 | .Include(f => f.Children) 21 | .ThenInclude(f => f.Children) 22 | .ThenInclude(f => f.Children) 23 | .ThenInclude(f => f.Children) 24 | .ThenInclude(f => f.Children) 25 | .FirstOrDefaultAsync(); 26 | } 27 | 28 | public async Task CreateFolderAsync(Folder folder) 29 | { 30 | await Context.Folders.AddAsync(folder); 31 | } 32 | 33 | public void RemoveFolder(Folder folder) 34 | { 35 | var allFolders = new List(); 36 | GetAllChildren(folder, allFolders); 37 | allFolders.Add(folder); 38 | 39 | Context.Folders.RemoveRange(allFolders); 40 | } 41 | 42 | private void GetAllChildren(Folder folder, List allChildren) 43 | { 44 | allChildren.Add(folder); 45 | foreach (var child in folder.Children) 46 | { 47 | GetAllChildren(child, allChildren); 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /src/Application/BackgroundServices/ResetTranslationsCount.cs: -------------------------------------------------------------------------------- 1 | using Application.Interfaces.Repositories; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Microsoft.Extensions.Hosting; 4 | using Microsoft.Extensions.Logging; 5 | 6 | namespace Application.BackgroundServices; 7 | 8 | public class ResetTranslationsCount : BackgroundService 9 | { 10 | private readonly IServiceProvider _serviceProvider; 11 | 12 | public ResetTranslationsCount(IServiceProvider serviceProvider) 13 | { 14 | _serviceProvider = serviceProvider; 15 | } 16 | 17 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 18 | { 19 | do 20 | { 21 | // Execute once at 00:00 UTC 22 | int hourSpan = 24 - DateTime.UtcNow.Hour; 23 | int numberOfHours = hourSpan; 24 | if (hourSpan == 24) 25 | { 26 | using (var scope = _serviceProvider.CreateScope()) 27 | { 28 | var logger = scope.ServiceProvider 29 | .GetService>(); 30 | logger.LogWarning("Resetting Translations Counts"); 31 | 32 | var userRepository = 33 | scope.ServiceProvider.GetService(); 34 | await userRepository.ResetTranslationsCount(); 35 | 36 | numberOfHours = 24; 37 | } 38 | } 39 | 40 | await Task.Delay(TimeSpan.FromHours(numberOfHours), stoppingToken); 41 | } 42 | while (!stoppingToken.IsCancellationRequested); 43 | } 44 | } -------------------------------------------------------------------------------- /src/Application/BackgroundServices/ResetAiExplanationCount.cs: -------------------------------------------------------------------------------- 1 | using Application.Interfaces.Repositories; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Microsoft.Extensions.Hosting; 4 | using Microsoft.Extensions.Logging; 5 | 6 | namespace Application.BackgroundServices; 7 | 8 | public class ResetAiExplanationCount : BackgroundService 9 | { 10 | private readonly IServiceProvider _serviceProvider; 11 | 12 | public ResetAiExplanationCount(IServiceProvider serviceProvider) 13 | { 14 | _serviceProvider = serviceProvider; 15 | } 16 | 17 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 18 | { 19 | do 20 | { 21 | // Execute once at 00:00 UTC 22 | int hourSpan = 24 - DateTime.UtcNow.Hour; 23 | int numberOfHours = hourSpan; 24 | if (hourSpan == 24) 25 | { 26 | using (var scope = _serviceProvider.CreateScope()) 27 | { 28 | var logger = scope.ServiceProvider 29 | .GetService>(); 30 | logger.LogWarning("Resetting Ai Explanation Requests"); 31 | 32 | var userRepository = 33 | scope.ServiceProvider.GetService(); 34 | await userRepository.ResetAiExplanationCount(); 35 | 36 | numberOfHours = 24; 37 | } 38 | } 39 | 40 | await Task.Delay(TimeSpan.FromHours(numberOfHours), stoppingToken); 41 | } 42 | while (!stoppingToken.IsCancellationRequested); 43 | } 44 | } -------------------------------------------------------------------------------- /src/Presentation/wwwroot/EmailConfirmationSucceeded.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Account Confirmation 6 | 49 | 50 | 51 |
52 |

Account Confirmed!

53 |

Your account has been confirmed successfully!
Go back to the application and enjoy Librum.

54 |
55 | Illustration 56 |
57 |
58 | 59 | 60 | -------------------------------------------------------------------------------- /src/Application/Common/Middleware/CustomIpRateLimitMiddleware.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.DTOs; 2 | using AspNetCoreRateLimit; 3 | using Microsoft.AspNetCore.Http; 4 | using Microsoft.Extensions.Logging; 5 | using Microsoft.Extensions.Options; 6 | using Newtonsoft.Json; 7 | 8 | namespace Application.Common.Middleware; 9 | 10 | public class CustomIpRateLimitMiddleware: IpRateLimitMiddleware 11 | { 12 | public CustomIpRateLimitMiddleware(RequestDelegate next, 13 | IProcessingStrategy strategy, 14 | IOptions options, 15 | IIpPolicyStore policyStore, 16 | IRateLimitConfiguration config, 17 | ILogger logger) 18 | : base(next, strategy, options, policyStore, config, logger) 19 | { 20 | } 21 | 22 | public override Task ReturnQuotaExceededResponse(HttpContext httpContext, 23 | RateLimitRule rule, 24 | string retryAfter) 25 | { 26 | var message = $"Too many requests. Limit is: {rule.Limit} per {rule.Period}. " + 27 | $"Retry after {retryAfter}s"; 28 | var response = new CommonErrorDto(429, message, 19); 29 | 30 | httpContext.Response.Headers["Retry-After"] = retryAfter; 31 | httpContext.Response.StatusCode = 429; 32 | httpContext.Response.ContentType = "application/json"; 33 | 34 | return httpContext.Response.WriteAsync(JsonConvert.SerializeObject(response)); 35 | } 36 | } -------------------------------------------------------------------------------- /src/Application/Common/Middleware/ExceptionHandlingMiddleware.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using Application.Common.DTOs; 3 | using Microsoft.AspNetCore.Http; 4 | using Microsoft.Extensions.Hosting; 5 | using Microsoft.Extensions.Logging; 6 | 7 | namespace Application.Common.Middleware; 8 | 9 | public class ExceptionHandlingMiddleware 10 | { 11 | private readonly RequestDelegate _next; 12 | private readonly IHostEnvironment _env; 13 | private readonly ILogger _logger; 14 | 15 | 16 | public ExceptionHandlingMiddleware(RequestDelegate next, 17 | IHostEnvironment env, 18 | ILogger logger) 19 | { 20 | _next = next; 21 | _env = env; 22 | _logger = logger; 23 | } 24 | 25 | 26 | public async Task InvokeAsync(HttpContext context) 27 | { 28 | try 29 | { 30 | await _next.Invoke(context); 31 | } 32 | catch (Exception ex) 33 | { 34 | _logger.LogError(ex, ex.Message); 35 | 36 | context.Response.ContentType = "application/json"; 37 | context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; 38 | 39 | var response = _env.IsDevelopment() 40 | ? new ApiExceptionDto(context.Response.StatusCode, 41 | ex.Message, ex.StackTrace) 42 | : new ApiExceptionDto(context.Response.StatusCode, 43 | "An error occured"); 44 | 45 | await context.Response.WriteAsync(response.ToString()); 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /src/Application/Common/DTOs/Books/BookOutDto.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.DTOs.Bookmarks; 2 | using Application.Common.DTOs.Highlights; 3 | using Application.Common.DTOs.Tags; 4 | 5 | namespace Application.Common.DTOs.Books; 6 | 7 | public class BookOutDto 8 | { 9 | public string Guid { get; set; } 10 | 11 | public string Title { get; set; } 12 | 13 | public int PageCount { get; set; } 14 | 15 | public int CurrentPage { get; set; } 16 | 17 | public string Format { get; set; } 18 | 19 | public string Extension { get; set; } 20 | 21 | public string Language { get; set; } 22 | 23 | public string DocumentSize { get; set; } 24 | 25 | public string PagesSize { get; set; } 26 | 27 | public string Creator { get; set; } 28 | 29 | public string Authors { get; set; } 30 | 31 | public string CreationDate { get; set; } 32 | 33 | public string AddedToLibrary { get; set; } 34 | 35 | public string LastOpened { get; set; } 36 | 37 | public string LastModified { get; set; } 38 | 39 | public string CoverLastModified { get; set; } 40 | 41 | public bool HasCover { get; set; } 42 | 43 | public int ProjectGutenbergId { get; set; } 44 | 45 | public string ColorTheme { get; set; } 46 | 47 | public string FileHash { get; set; } 48 | 49 | public string ParentFolderId { get; set; } 50 | 51 | public ICollection Tags { get; set; } = new List(); 52 | 53 | public ICollection Highlights { get; set; } = new List(); 54 | 55 | public ICollection Bookmarks { get; set; } = new List(); 56 | } -------------------------------------------------------------------------------- /src/Presentation/Controllers/FolderController.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.DTOs.Folders; 2 | using Application.Common.Exceptions; 3 | using Application.Interfaces.Services; 4 | using Microsoft.AspNetCore.Authorization; 5 | using Microsoft.AspNetCore.Http.HttpResults; 6 | using Microsoft.AspNetCore.Mvc; 7 | 8 | namespace Presentation.Controllers; 9 | 10 | [Authorize] 11 | [ApiController] 12 | [Route("[controller]")] 13 | public class FolderController(IFolderService folderService, ILogger logger) : ControllerBase 14 | { 15 | private IFolderService FolderService { get; } = folderService; 16 | private ILogger Logger { get; } = logger; 17 | 18 | [HttpGet] 19 | public async Task GetFolders() 20 | { 21 | try 22 | { 23 | var email = HttpContext.User.Identity!.Name; 24 | var folders = await FolderService.GetFoldersAsync(email); 25 | return Ok(folders); 26 | } 27 | catch (CommonErrorException e) 28 | { 29 | Logger.LogWarning("{ErrorMessage}", e.Message); 30 | return StatusCode(e.Error.Status, e.Error); 31 | } 32 | } 33 | 34 | [HttpPost("update")] 35 | public async Task UpdateFolders([FromBody] FolderInDto folderInDto) 36 | { 37 | try 38 | { 39 | var email = HttpContext.User.Identity!.Name; 40 | await FolderService.UpdateFoldersAsync(email, folderInDto); 41 | return Ok(); 42 | } 43 | catch (CommonErrorException e) 44 | { 45 | Logger.LogWarning("{ErrorMessage}", e.Message); 46 | return StatusCode(e.Error.Status, e.Error); 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /src/Presentation/wwwroot/EmailConfirmationFailed.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Account Confirmation 6 | 50 | 51 | 52 |
53 |

There was a problem

54 |

We weren't able to confirm your account.
Please register again, or contact us for support.

55 |
56 | Illustration 57 |
58 |
59 | 60 | 61 | -------------------------------------------------------------------------------- /src/Presentation/Controllers/TagController.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.DTOs.Tags; 2 | using Application.Common.Exceptions; 3 | using Application.Interfaces.Services; 4 | using Microsoft.AspNetCore.Authorization; 5 | using Microsoft.AspNetCore.Mvc; 6 | 7 | namespace Presentation.Controllers; 8 | 9 | [Authorize] 10 | [ApiController] 11 | [Route("[controller]")] 12 | public class TagController : ControllerBase 13 | { 14 | private readonly ITagService _tagService; 15 | private readonly ILogger _logger; 16 | 17 | 18 | public TagController(ILogger logger, ITagService tagService) 19 | { 20 | _tagService = tagService; 21 | _logger = logger; 22 | } 23 | 24 | 25 | [HttpDelete("{guid:guid}")] 26 | public async Task DeleteTag(Guid guid) 27 | { 28 | await _tagService.DeleteTagAsync(HttpContext.User.Identity!.Name, guid); 29 | return NoContent(); 30 | } 31 | 32 | [HttpPut] 33 | public async Task UpdateTag(TagForUpdateDto tagUpdateDto) 34 | { 35 | try 36 | { 37 | await _tagService.UpdateTagAsync(HttpContext.User.Identity!.Name, 38 | tagUpdateDto); 39 | return StatusCode(201); 40 | } 41 | catch (CommonErrorException e) 42 | { 43 | _logger.LogWarning("{ErrorMessage}", e.Message); 44 | return StatusCode(e.Error.Status, e.Error); 45 | } 46 | } 47 | 48 | [HttpGet] 49 | public async Task>> GetTags() 50 | { 51 | var userName = HttpContext.User.Identity!.Name; 52 | var result = await _tagService.GetTagsAsync(userName); 53 | return Ok(result); 54 | } 55 | } -------------------------------------------------------------------------------- /src/Domain/Entities/User.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using Microsoft.AspNetCore.Identity; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | namespace Domain.Entities; 6 | 7 | [Index(nameof(Email), IsUnique = true)] 8 | public class User : IdentityUser 9 | { 10 | // Legacy - to be removed 11 | [MinLength(2, ErrorMessage = "The firstname is too short")] 12 | [MaxLength(40, ErrorMessage = "The firstname is too long")] 13 | public string FirstName { get; set; } 14 | 15 | // Legacy - to be removed 16 | [MinLength(2, ErrorMessage = "The lastname is too short")] 17 | [MaxLength(50, ErrorMessage = "The lastname is too long")] 18 | public string LastName { get; set; } 19 | 20 | [MinLength(2, ErrorMessage = "The name is too short")] 21 | [MaxLength(150, ErrorMessage = "The name is too long")] 22 | public string Name { get; set; } 23 | 24 | [Required] 25 | [MinLength(6, ErrorMessage = "The email is too short")] 26 | [MaxLength(50, ErrorMessage = "The email is too long")] 27 | public override string Email { get; set; } 28 | 29 | public string ProductId { get; set; } 30 | 31 | public string CustomerId { get; set; } 32 | 33 | [Required] 34 | public DateTime AccountCreation { get; set; } 35 | 36 | [Required] 37 | public DateTime ProfilePictureLastUpdated { get; set; } 38 | 39 | public DateTime AccountLastDowngraded { get; set; } = DateTime.MaxValue; 40 | 41 | public bool HasProfilePicture { get; set; } 42 | 43 | [Required] 44 | public int AiExplanationRequestsMadeToday { get; set; } = 0; 45 | 46 | [Required] 47 | public int TranslationRequestsMadeToday { get; set; } = 0; 48 | 49 | public Guid RootFolderId { get; set; } 50 | 51 | public ICollection Books { get; set; } 52 | public ICollection Tags { get; set; } 53 | } -------------------------------------------------------------------------------- /src/Presentation/Presentation.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | disable 7 | 382b1483-c7c4-4b71-9d11-dc0676e84216 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | all 25 | runtime; build; native; contentfiles; analyzers; buildtransitive 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/Infrastructure/Persistence/Migrations/20231017161702_AddBookmarks.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | #nullable disable 5 | 6 | namespace Infrastructure.Persistence.Migrations 7 | { 8 | public partial class AddBookmarks : Migration 9 | { 10 | protected override void Up(MigrationBuilder migrationBuilder) 11 | { 12 | migrationBuilder.CreateTable( 13 | name: "Bookmarks", 14 | columns: table => new 15 | { 16 | BookmarkId = table.Column(type: "uniqueidentifier", nullable: false), 17 | Name = table.Column(type: "nvarchar(max)", nullable: false), 18 | PageNumber = table.Column(type: "int", nullable: false), 19 | YOffset = table.Column(type: "real", nullable: false), 20 | BookId = table.Column(type: "uniqueidentifier", nullable: false) 21 | }, 22 | constraints: table => 23 | { 24 | table.PrimaryKey("PK_Bookmarks", x => x.BookmarkId); 25 | table.ForeignKey( 26 | name: "FK_Bookmarks_Books_BookId", 27 | column: x => x.BookId, 28 | principalTable: "Books", 29 | principalColumn: "BookId", 30 | onDelete: ReferentialAction.Cascade); 31 | }); 32 | 33 | migrationBuilder.CreateIndex( 34 | name: "IX_Bookmarks_BookId", 35 | table: "Bookmarks", 36 | column: "BookId"); 37 | } 38 | 39 | protected override void Down(MigrationBuilder migrationBuilder) 40 | { 41 | migrationBuilder.DropTable( 42 | name: "Bookmarks"); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Application/Services/TagService.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.DTOs.Tags; 2 | using Application.Common.Exceptions; 3 | using Application.Interfaces.Repositories; 4 | using Application.Interfaces.Services; 5 | using AutoMapper; 6 | 7 | 8 | namespace Application.Services; 9 | 10 | public class TagService : ITagService 11 | { 12 | private readonly IUserRepository _userRepository; 13 | private readonly ITagRepository _tagRepository; 14 | private readonly IMapper _mapper; 15 | 16 | 17 | public TagService(IMapper mapper, 18 | ITagRepository tagRepository, 19 | IUserRepository userRepository) 20 | { 21 | _mapper = mapper; 22 | _userRepository = userRepository; 23 | _tagRepository = tagRepository; 24 | } 25 | 26 | public async Task DeleteTagAsync(string email, Guid guid) 27 | { 28 | var user = await _userRepository.GetAsync(email, trackChanges: true); 29 | var tag = user.Tags.SingleOrDefault(tag => tag.TagId == guid); 30 | if (tag == default) 31 | return; 32 | 33 | tag.UserId = user.Id; 34 | _tagRepository.Delete(tag); 35 | 36 | await _tagRepository.SaveChangesAsync(); 37 | } 38 | 39 | public async Task UpdateTagAsync(string email, TagForUpdateDto tagDto) 40 | { 41 | var user = await _userRepository.GetAsync(email, trackChanges: true); 42 | 43 | var tag = user.Tags.SingleOrDefault(tag => tag.TagId == tagDto.Guid); 44 | if (tag == default) 45 | { 46 | const string message = "No tag with this name exists"; 47 | throw new CommonErrorException(404, message, 7); 48 | } 49 | 50 | tag.Name = tagDto.Name; 51 | await _tagRepository.SaveChangesAsync(); 52 | } 53 | 54 | public async Task> GetTagsAsync(string email) 55 | { 56 | var user = await _userRepository.GetAsync(email, trackChanges: true); 57 | 58 | return user.Tags.Select(tag => _mapper.Map(tag)); 59 | } 60 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: "3.8" 3 | services: 4 | librum: 5 | image: ghcr.io/librum-reader/librum-server:latest 6 | hostname: librum 7 | container_name: librum 8 | ports: 9 | - 5000:5000 10 | networks: 11 | - librum 12 | volumes: 13 | - librum:/var/lib/librum-server/librum_storage 14 | environment: 15 | - JWTValidIssuer=exampleIssuer # Optional. You can leave it as-is 16 | - JWTKey=exampleOfALongSecretToken # Optional. You can leave it as-is 17 | - SMTPEndpoint=smtp.example.com # Example for Gmail: smtp.gmail.com:587 18 | - SMTPUsername=mailuser123 19 | - SMTPPassword=smtpUserPassword123 20 | - SMTPMailFrom=mailuser123@example.com 21 | - DBConnectionString=Server=mariadb;port=3306;Database=librum;Uid=librum;Pwd=mariadb; 22 | - AdminEmail=admin@example.com # Admin login username 23 | - AdminPassword=strongPassword123 # Admin login password 24 | #- OpenAIToken= # Optional. Generate here: https://platform.openai.com/api-keys 25 | restart: unless-stopped 26 | depends_on: 27 | librum_db: 28 | condition: service_healthy # Ensures the DB is up before the server. 29 | 30 | librum_db: 31 | image: mariadb:latest 32 | hostname: mariadb 33 | container_name: librum_db 34 | networks: 35 | - librum 36 | volumes: 37 | - librum_db:/var/lib/mysql 38 | environment: 39 | - MARIADB_USER=librum 40 | - MARIADB_PASSWORD=mariadb 41 | - MARIADB_DATABASE=librum 42 | - MARIADB_ROOT_PASSWORD=mariadb 43 | restart: unless-stopped 44 | healthcheck: # Ensures the DB is up before the server. 45 | test: ["CMD", "mariadb-admin", "ping", "-u", "librum", "-p'mariadb'", "-h", "localhost"] 46 | interval: 20s 47 | timeout: 40s 48 | retries: 3 49 | start_period: 30s 50 | 51 | networks: 52 | librum: 53 | name: "librum" 54 | 55 | volumes: 56 | librum: 57 | name: "librum" 58 | librum_db: 59 | name: "librum_db" 60 | -------------------------------------------------------------------------------- /src/Presentation/Controllers/AppInfoController.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http.Headers; 2 | using Microsoft.AspNetCore.Authorization; 3 | using Microsoft.AspNetCore.Mvc; 4 | using Newtonsoft.Json; 5 | 6 | namespace Presentation.Controllers; 7 | 8 | [Authorize] 9 | [ApiController] 10 | [Route("[controller]")] 11 | public class AppInfoController : ControllerBase 12 | { 13 | private readonly ILogger _logger; 14 | private readonly IHttpClientFactory _httpClientFactory; 15 | private readonly IConfiguration _configuration; 16 | 17 | 18 | public AppInfoController(ILogger logger, 19 | IHttpClientFactory httpClientFactory, 20 | IConfiguration configuration) 21 | { 22 | _logger = logger; 23 | _httpClientFactory = httpClientFactory; 24 | _configuration = configuration; 25 | } 26 | 27 | [AllowAnonymous] 28 | [HttpGet("health")] 29 | public ActionResult Health() 30 | { 31 | return Ok(); 32 | } 33 | 34 | [AllowAnonymous] 35 | [HttpGet("latest-version")] 36 | public async Task GetLatestVersion() 37 | { 38 | var url = "https://api.github.com/repos/Librum-Reader/Librum/releases"; 39 | 40 | var httpClient = _httpClientFactory.CreateClient(); 41 | httpClient.DefaultRequestHeaders.TryAddWithoutValidation( 42 | "User-Agent", 43 | "Librum/1.0.0"); 44 | httpClient.DefaultRequestHeaders.Authorization = 45 | new AuthenticationHeaderValue("Bearer", _configuration["GitAccessToken"]); 46 | 47 | var response = await httpClient.GetAsync(url); 48 | if (response.IsSuccessStatusCode) 49 | { 50 | using var responseStream = await response.Content.ReadAsStreamAsync(); 51 | string responseBody = await response.Content.ReadAsStringAsync();; 52 | 53 | var jsonList = JsonConvert.DeserializeObject>(responseBody); 54 | string name = jsonList.First().name; 55 | 56 | name = name[2..]; // Remove "v." from version 57 | return name; 58 | } 59 | 60 | _logger.LogWarning("Getting latest application version failed"); 61 | return "0"; 62 | } 63 | } -------------------------------------------------------------------------------- /self-hosting/librum-server.7: -------------------------------------------------------------------------------- 1 | .TH librum-server 2 | 3 | .SH NAME 4 | .B librum-server 5 | - the server for the Librum application 6 | 7 | 8 | .SH DESCRIPTION 9 | .B librum-server 10 | is a server running as a service 11 | 12 | .SH INSTALLATION 13 | .TP 14 | After installing the librum-server package 15 | .RS 16 | .B 1. 17 | Install and configure the MariaDb or MySql service 18 | .RS 19 | .LP 20 | .B a) 21 | Edit /etc/mysql/mariadb.conf.d/50-server.cnf to set bind-address=127.0.0.1 and comment out the skip-networking option 22 | .LP 23 | .B b) 24 | Restart MySql server - systemctl restart mysqld 25 | .LP 26 | .B c) 27 | Run mysql and create a user for the mysql database. For example: 28 | ALTER USER 'root'@'localhost' IDENTIFIED BY 'strongPassword123'; 29 | .RE 30 | 31 | .LP 32 | .B 2. 33 | Edit the configuration file at /etc/librum-server/librum-server.conf 34 | You must provide: 35 | .RS 36 | .LP 37 | .B JWTValidIssuer 38 | - Any string for key provider for example "myhomeKeyProvider" 39 | .LP 40 | .B JWTKey 41 | - The secret key for JWT token generation (at least 20 symbols) 42 | .LP 43 | .B AdminEmail 44 | - An admin email for seeding the database with an admin account on the first run 45 | .LP 46 | .B AdminPassword 47 | - A password for the admin account (5 symbols minimum) 48 | .LP 49 | .B DBConnectionString 50 | - The connection string for Mysql (or MariaDB) 51 | for example "Server=127.0.0.1;port=3306;Database=my_database_name;Uid=mysql_user;Pwd=mysql_password;" 52 | .LP 53 | .B CleanUrl 54 | - A clean url without ports, it will be used to build the "reset password link". 55 | As an example, a server running on 127.0.0.1:5000 can be exposed to the web as https://myserver.com, so the CleanUrl would be https://myserver.com 56 | .RE 57 | .LP 58 | .B 3. 59 | Refresh the systemd services by running: systemctl daemon-reload 60 | .LP 61 | .B 4. 62 | Run the server: systemctl start librum-server 63 | .LP 64 | .B 5. 65 | Check status with: systemctl status librum-server 66 | .RE 67 | .LP 68 | .B 6. 69 | Configure your librum-reader app to launch using your server. 70 | In ~/.config/librum-server/librum-server.conf set selfHosted to true and set serverHost to the servers url (e.g. https://127.0.0.1:5001) 71 | 72 | .SH UNINSTALL 73 | .TP 74 | Delte the package and in ~/.config/librum-server/librum-server.conf, change selfHosted to false and serverHost to api.librumreader.com to switch back to the official servers. 75 | 76 | .SH DIAGNOSTICS 77 | .PP 78 | The activity of server is logged to /var/lib/librum-server/srv/Data/Logs and journalctl. 79 | 80 | 81 | -------------------------------------------------------------------------------- /src/Application/Common/DTOs/Books/BookForUpdateDto.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using Application.Common.DataAnnotations; 3 | using Application.Common.DTOs.Bookmarks; 4 | using Application.Common.DTOs.Highlights; 5 | using Application.Common.DTOs.Tags; 6 | 7 | namespace Application.Common.DTOs.Books; 8 | 9 | public class BookForUpdateDto 10 | { 11 | [Required] 12 | public Guid Guid { get; set; } 13 | 14 | [MinLength(2, ErrorMessage = "The title is too short")] 15 | [MaxLength(2000, ErrorMessage = "The title is too long")] 16 | public string Title { get; set; } 17 | 18 | [Range(0, int.MaxValue)] 19 | public int CurrentPage { get; set; } 20 | 21 | [EmptyOrMinLength(2, ErrorMessage = "The language is too short")] 22 | [MaxLength(100, ErrorMessage = "The language is too long")] 23 | public string Language { get; set; } 24 | 25 | [MaxLength(2000, ErrorMessage = "The creator is too long")] 26 | public string Creator { get; set; } 27 | 28 | [MaxLength(2000, ErrorMessage = "The authors are too long")] 29 | public string Authors { get; set; } 30 | 31 | [MaxLength(100, ErrorMessage = "The creation date is too long")] 32 | public string CreationDate { get; set; } 33 | 34 | [EmptyOrMinLength(4, ErrorMessage = "The last opened is too short")] 35 | [MaxLength(100, ErrorMessage = "The last opened is too long")] 36 | public string LastOpened { get; set; } 37 | 38 | [MinLength(4, ErrorMessage = "The last modified is too short")] 39 | [MaxLength(100, ErrorMessage = "The last modified is too long")] 40 | public string LastModified { get; set; } 41 | 42 | [MinLength(4, ErrorMessage = "The cover last modified is too short")] 43 | [MaxLength(100, ErrorMessage = "The cover last modified is too long")] 44 | public string CoverLastModified { get; set; } 45 | 46 | public bool HasCover { get; set; } 47 | 48 | [Required] 49 | public int ProjectGutenbergId { get; set; } 50 | 51 | [Required] 52 | [MaxLength(100, ErrorMessage = "ColorTheme is too long")] 53 | public string ColorTheme { get; set; } 54 | 55 | [MaxLength(100, ErrorMessage = "ParentFolderId is too long")] 56 | public string ParentFolderId { get; set; } 57 | 58 | public ICollection Tags { get; set; } = new List(); 59 | 60 | public ICollection Highlights { get; set; } = new List(); 61 | 62 | public ICollection Bookmarks { get; set; } = new List(); 63 | } -------------------------------------------------------------------------------- /src/Presentation/Controllers/AiController.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.DTOs; 2 | using Application.Common.Exceptions; 3 | using Application.Interfaces.Services; 4 | using Microsoft.AspNetCore.Authorization; 5 | using Microsoft.AspNetCore.Mvc; 6 | 7 | namespace Presentation.Controllers; 8 | 9 | [Authorize] 10 | [ApiController] 11 | [Route("[controller]")] 12 | public class AiController : ControllerBase 13 | { 14 | private readonly ILogger _logger; 15 | private readonly IAiService _aiService; 16 | 17 | public AiController(ILogger logger, IAiService aiService) 18 | { 19 | _logger = logger; 20 | _aiService = aiService; 21 | } 22 | 23 | public struct ExplainRequest 24 | { 25 | public string Text { get; set; } 26 | public string Mode { get; set; } 27 | } 28 | 29 | public struct TranslateRequest 30 | { 31 | public string Text { get; set; } 32 | public string SourceLang { get; set; } 33 | public string TargetLang { get; set; } 34 | } 35 | 36 | [HttpPost("complete")] 37 | public async Task Explain(ExplainRequest request) 38 | { 39 | if (request.Text.Length > 5000) 40 | { 41 | const string message = "The text is too long"; 42 | _logger.LogWarning(message); 43 | return StatusCode(400, new CommonErrorDto(400, message, 26)); 44 | } 45 | 46 | try 47 | { 48 | await _aiService.ExplainAsync(HttpContext.User.Identity!.Name, HttpContext, 49 | request.Text, request.Mode); 50 | return Ok(); 51 | } 52 | catch (CommonErrorException e) 53 | { 54 | return StatusCode(e.Error.Status, e.Error); 55 | } 56 | } 57 | 58 | [HttpPost("translate")] 59 | public async Task> Translate(TranslateRequest request) 60 | { 61 | if (request.Text.Length > 3000) 62 | { 63 | const string message = "The text is too long"; 64 | _logger.LogWarning(message); 65 | return StatusCode(400, new CommonErrorDto(400, message, 27)); 66 | } 67 | 68 | try 69 | { 70 | var result = await _aiService.TranslateAsync(HttpContext.User.Identity!.Name, request.Text, 71 | request.SourceLang, request.TargetLang); 72 | return Ok(result); 73 | } 74 | catch (CommonErrorException e) 75 | { 76 | return StatusCode(e.Error.Status, e.Error); 77 | } 78 | } 79 | } -------------------------------------------------------------------------------- /src/Infrastructure/Persistence/Repository/UserRepository.cs: -------------------------------------------------------------------------------- 1 | using Application.Interfaces.Repositories; 2 | using Domain.Entities; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | namespace Infrastructure.Persistence.Repository; 6 | 7 | public class UserRepository : IUserRepository 8 | { 9 | private readonly DataContext _context; 10 | 11 | 12 | public UserRepository(DataContext context) 13 | { 14 | _context = context; 15 | } 16 | 17 | 18 | public async Task GetAsync(string email, bool trackChanges) 19 | { 20 | return trackChanges 21 | ? await _context.Users.Include(user => user.Books) 22 | .Include(user => user.Tags) 23 | .SingleOrDefaultAsync(user => user.Email == email) 24 | : await _context.Users 25 | .Include(user => user.Tags) 26 | .AsNoTracking() 27 | .SingleOrDefaultAsync(user => user.Email == email); 28 | } 29 | 30 | public Task GetByCustomerIdAsync(string customerId, bool trackChanges) 31 | { 32 | return trackChanges 33 | ? _context.Users.Include(user => user.Books) 34 | .SingleOrDefaultAsync(user => user.CustomerId == customerId) 35 | : _context.Users 36 | .AsNoTracking() 37 | .SingleOrDefaultAsync(user => user.CustomerId == customerId); 38 | } 39 | 40 | public void Delete(User user) 41 | { 42 | _context.Users.Remove(user); 43 | } 44 | 45 | public async Task DeleteUnconfirmedUsers() 46 | { 47 | // Users with unconfirmed emails created more than 30 minutes ago. 48 | var usersToRemove = _context.Users.Where(u => !u.EmailConfirmed && 49 | u.AccountCreation < DateTime.Now.AddMinutes(-30)); 50 | _context.Users.RemoveRange(usersToRemove); 51 | await _context.SaveChangesAsync(); 52 | } 53 | 54 | public async Task> GetUsersWhoDowngradedMoreThanAWeekAgo() 55 | { 56 | return await _context.Users.Where(u => u.AccountLastDowngraded <= DateTime.UtcNow.AddDays(-7)).ToListAsync(); 57 | } 58 | 59 | public async Task ResetAiExplanationCount() 60 | { 61 | await _context.Users.ForEachAsync(u => u.AiExplanationRequestsMadeToday = 0); 62 | await _context.SaveChangesAsync(); 63 | } 64 | 65 | public async Task ResetTranslationsCount() 66 | { 67 | await _context.Users.ForEachAsync(u => u.TranslationRequestsMadeToday = 0); 68 | await _context.SaveChangesAsync(); 69 | } 70 | 71 | public async Task SaveChangesAsync() 72 | { 73 | return await _context.SaveChangesAsync(); 74 | } 75 | } -------------------------------------------------------------------------------- /src/Infrastructure/Persistence/Migrations/20230911141116_FixHighlightReference.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | #nullable disable 5 | 6 | namespace Infrastructure.Persistence.Migrations 7 | { 8 | public partial class FixHighlightReference : Migration 9 | { 10 | protected override void Up(MigrationBuilder migrationBuilder) 11 | { 12 | migrationBuilder.DropForeignKey( 13 | name: "FK_Highlights_Books_BookId1", 14 | table: "Highlights"); 15 | 16 | migrationBuilder.DropIndex( 17 | name: "IX_Highlights_BookId1", 18 | table: "Highlights"); 19 | 20 | migrationBuilder.DropColumn( 21 | name: "BookId1", 22 | table: "Highlights"); 23 | 24 | migrationBuilder.AlterColumn( 25 | name: "BookId", 26 | table: "Highlights", 27 | type: "uniqueidentifier", 28 | nullable: false, 29 | defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), 30 | oldClrType: typeof(string), 31 | oldType: "nvarchar(max)", 32 | oldNullable: true); 33 | 34 | migrationBuilder.CreateIndex( 35 | name: "IX_Highlights_BookId", 36 | table: "Highlights", 37 | column: "BookId"); 38 | 39 | migrationBuilder.AddForeignKey( 40 | name: "FK_Highlights_Books_BookId", 41 | table: "Highlights", 42 | column: "BookId", 43 | principalTable: "Books", 44 | principalColumn: "BookId", 45 | onDelete: ReferentialAction.Cascade); 46 | } 47 | 48 | protected override void Down(MigrationBuilder migrationBuilder) 49 | { 50 | migrationBuilder.DropForeignKey( 51 | name: "FK_Highlights_Books_BookId", 52 | table: "Highlights"); 53 | 54 | migrationBuilder.DropIndex( 55 | name: "IX_Highlights_BookId", 56 | table: "Highlights"); 57 | 58 | migrationBuilder.AlterColumn( 59 | name: "BookId", 60 | table: "Highlights", 61 | type: "nvarchar(max)", 62 | nullable: true, 63 | oldClrType: typeof(Guid), 64 | oldType: "uniqueidentifier"); 65 | 66 | migrationBuilder.AddColumn( 67 | name: "BookId1", 68 | table: "Highlights", 69 | type: "uniqueidentifier", 70 | nullable: true); 71 | 72 | migrationBuilder.CreateIndex( 73 | name: "IX_Highlights_BookId1", 74 | table: "Highlights", 75 | column: "BookId1"); 76 | 77 | migrationBuilder.AddForeignKey( 78 | name: "FK_Highlights_Books_BookId1", 79 | table: "Highlights", 80 | column: "BookId1", 81 | principalTable: "Books", 82 | principalColumn: "BookId"); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Application/Managers/UserBlobStorageManager.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.Exceptions; 2 | using Application.Interfaces.Managers; 3 | using Azure.Storage.Blobs; 4 | using Microsoft.AspNetCore.WebUtilities; 5 | using Microsoft.Net.Http.Headers; 6 | 7 | namespace Application.Managers; 8 | 9 | public class UserBlobStorageManager : IUserBlobStorageManager 10 | { 11 | private readonly BlobServiceClient _blobServiceClient; 12 | private readonly string _profilePicturePrefix = "profilePicture_"; 13 | 14 | public UserBlobStorageManager(BlobServiceClient blobServiceClient) 15 | { 16 | _blobServiceClient = blobServiceClient; 17 | } 18 | 19 | 20 | public Task DownloadProfilePicture(string guid) 21 | { 22 | var containerClient = 23 | _blobServiceClient.GetBlobContainerClient("users"); 24 | var blobClient = containerClient.GetBlobClient(_profilePicturePrefix + guid); 25 | return blobClient.Exists() ? blobClient.OpenReadAsync() : Task.FromResult(new MemoryStream()); 26 | } 27 | 28 | public async Task ChangeProfilePicture(string guid, MultipartReader reader) 29 | { 30 | var containerClient = 31 | _blobServiceClient.GetBlobContainerClient("users"); 32 | var blobClient = containerClient.GetBlobClient(_profilePicturePrefix + guid); 33 | 34 | await using var dest = await blobClient.OpenWriteAsync(true); 35 | 36 | var section = await reader.ReadNextSectionAsync(); 37 | while (section != null) 38 | { 39 | var hasContentDispositionHeader = 40 | ContentDispositionHeaderValue.TryParse( 41 | section.ContentDisposition, 42 | out var contentDisposition); 43 | 44 | if (!hasContentDispositionHeader) 45 | continue; 46 | 47 | if (!HasFileContentDisposition(contentDisposition)) 48 | { 49 | var message = "Missing content disposition header"; 50 | throw new CommonErrorException(400, message, 0); 51 | } 52 | 53 | await section.Body.CopyToAsync(dest); 54 | section = await reader.ReadNextSectionAsync(); 55 | } 56 | } 57 | 58 | private static bool HasFileContentDisposition( 59 | ContentDispositionHeaderValue contentDisposition) 60 | { 61 | // Content-Disposition: form-data; name="myfile1"; filename="Misc 002.jpg" 62 | return contentDisposition != null && 63 | contentDisposition.DispositionType.Equals("form-data") && 64 | (!string.IsNullOrEmpty(contentDisposition.FileName.Value) || 65 | !string.IsNullOrEmpty(contentDisposition.FileNameStar.Value)); 66 | } 67 | 68 | public async Task DeleteProfilePicture(string guid) 69 | { 70 | var containerClient = 71 | _blobServiceClient.GetBlobContainerClient("users"); 72 | var blobClient = containerClient.GetBlobClient(_profilePicturePrefix + guid); 73 | if(!blobClient.Exists()) 74 | throw new CommonErrorException(400, "No profile picture exists", 0); 75 | 76 | await blobClient.DeleteAsync(); 77 | } 78 | } -------------------------------------------------------------------------------- /src/Domain/Entities/Book.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.ComponentModel.DataAnnotations.Schema; 3 | 4 | namespace Domain.Entities; 5 | 6 | public class Book 7 | { 8 | [DatabaseGenerated(DatabaseGeneratedOption.None)] 9 | [Key] 10 | public Guid BookId { get; set; } 11 | 12 | [Required] 13 | [MinLength(2, ErrorMessage = "The book title is too short")] 14 | [MaxLength(2000, ErrorMessage = "The book title is too long")] 15 | public string Title { get; set; } 16 | 17 | [Required] 18 | [Range(0, int.MaxValue, ErrorMessage = "The amount of pages is not in bounds")] 19 | public int PageCount { get; set; } 20 | 21 | [Required] 22 | [Range(0, int.MaxValue, ErrorMessage = "The current page is not in bounds")] 23 | public int CurrentPage { get; set; } 24 | 25 | [Required] 26 | [MinLength(1, ErrorMessage = "The format is too short")] 27 | [MaxLength(100, ErrorMessage = "The format is too long")] 28 | public string Format { get; set; } 29 | 30 | [MaxLength(500, ErrorMessage = "The extension is too long")] 31 | public string Extension { get; set; } 32 | 33 | [MinLength(2, ErrorMessage = "The language is too short")] 34 | [MaxLength(100, ErrorMessage = "The language is too long")] 35 | public string Language { get; set; } 36 | 37 | [Required] 38 | [MinLength(2, ErrorMessage = "The document size is too short")] 39 | [MaxLength(60, ErrorMessage = "The document size is too long")] 40 | public string DocumentSize { get; set; } 41 | 42 | [Required] 43 | [MinLength(2, ErrorMessage = "The pages size is too short")] 44 | [MaxLength(600, ErrorMessage = "The pages size is too long")] 45 | public string PagesSize { get; set; } 46 | 47 | [MaxLength(2000, ErrorMessage = "The creator is too long")] 48 | public string Creator { get; set; } 49 | 50 | [MaxLength(2000, ErrorMessage = "The authors are too long")] 51 | public string Authors { get; set; } 52 | 53 | [MaxLength(140, ErrorMessage = "The creation date is too long")] 54 | public string CreationDate { get; set; } 55 | 56 | [Required] 57 | public string AddedToLibrary { get; set; } 58 | 59 | public string LastOpened { get; set; } 60 | 61 | [Required] 62 | public string LastModified { get; set; } 63 | 64 | [Required] 65 | public string CoverLastModified { get; set; } 66 | 67 | [Required] 68 | public long CoverSize { get; set; } = 0; 69 | 70 | [Required] 71 | public bool HasCover { get; set; } 72 | 73 | [Required] 74 | public int ProjectGutenbergId { get; set; } 75 | 76 | [Required] 77 | public string ColorTheme { get; set; } 78 | 79 | [Required] 80 | public string FileHash { get; set; } 81 | 82 | [MaxLength(200, ErrorMessage = "ParentFolderId is too long")] 83 | public string ParentFolderId { get; set; } 84 | 85 | public ICollection Tags { get; set; } = new List(); 86 | 87 | public ICollection Highlights { get; set; } = new List(); 88 | 89 | public ICollection Bookmarks { get; set; } = new List(); 90 | 91 | public string UserId { get; set; } 92 | public User User { get; set; } 93 | } 94 | -------------------------------------------------------------------------------- /src/Infrastructure/Persistence/Migrations/20230911113759_AddedHighlights.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | #nullable disable 5 | 6 | namespace Infrastructure.Persistence.Migrations 7 | { 8 | public partial class AddedHighlights : Migration 9 | { 10 | protected override void Up(MigrationBuilder migrationBuilder) 11 | { 12 | migrationBuilder.CreateTable( 13 | name: "Highlights", 14 | columns: table => new 15 | { 16 | HighlightId = table.Column(type: "uniqueidentifier", nullable: false), 17 | Color = table.Column(type: "nvarchar(max)", nullable: false), 18 | PageNumber = table.Column(type: "int", nullable: false), 19 | BookId = table.Column(type: "nvarchar(max)", nullable: true), 20 | BookId1 = table.Column(type: "uniqueidentifier", nullable: true) 21 | }, 22 | constraints: table => 23 | { 24 | table.PrimaryKey("PK_Highlights", x => x.HighlightId); 25 | table.ForeignKey( 26 | name: "FK_Highlights_Books_BookId1", 27 | column: x => x.BookId1, 28 | principalTable: "Books", 29 | principalColumn: "BookId"); 30 | }); 31 | 32 | migrationBuilder.CreateTable( 33 | name: "RectF", 34 | columns: table => new 35 | { 36 | RectFId = table.Column(type: "uniqueidentifier", nullable: false), 37 | X = table.Column(type: "real", nullable: false), 38 | Y = table.Column(type: "real", nullable: false), 39 | Width = table.Column(type: "real", nullable: false), 40 | Height = table.Column(type: "real", nullable: false), 41 | HighlightId = table.Column(type: "uniqueidentifier", nullable: false) 42 | }, 43 | constraints: table => 44 | { 45 | table.PrimaryKey("PK_RectF", x => x.RectFId); 46 | table.ForeignKey( 47 | name: "FK_RectF_Highlights_HighlightId", 48 | column: x => x.HighlightId, 49 | principalTable: "Highlights", 50 | principalColumn: "HighlightId", 51 | onDelete: ReferentialAction.Cascade); 52 | }); 53 | 54 | migrationBuilder.CreateIndex( 55 | name: "IX_Highlights_BookId1", 56 | table: "Highlights", 57 | column: "BookId1"); 58 | 59 | migrationBuilder.CreateIndex( 60 | name: "IX_RectF_HighlightId", 61 | table: "RectF", 62 | column: "HighlightId"); 63 | } 64 | 65 | protected override void Down(MigrationBuilder migrationBuilder) 66 | { 67 | migrationBuilder.DropTable( 68 | name: "RectF"); 69 | 70 | migrationBuilder.DropTable( 71 | name: "Highlights"); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Infrastructure/Persistence/Repository/BookRepository.cs: -------------------------------------------------------------------------------- 1 | using Application.Interfaces.Repositories; 2 | using Domain.Entities; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | namespace Infrastructure.Persistence.Repository; 6 | 7 | public class BookRepository : IBookRepository 8 | { 9 | private readonly DataContext _context; 10 | 11 | 12 | public BookRepository(DataContext context) 13 | { 14 | _context = context; 15 | } 16 | 17 | 18 | public async Task SaveChangesAsync() 19 | { 20 | return await _context.SaveChangesAsync(); 21 | } 22 | 23 | public async Task LoadRelationShipsAsync(Book book) 24 | { 25 | await _context.Entry(book).Collection(p => p.Tags).LoadAsync(); 26 | await _context.Entry(book).Collection(p => p.Highlights).LoadAsync(); 27 | await _context.Entry(book).Collection(p => p.Bookmarks).LoadAsync(); 28 | 29 | // Load the RectFs from the loaded highlights as well 30 | foreach (var highlight in book.Highlights) 31 | { 32 | await _context.Entry(highlight).Collection(p => p.Rects).LoadAsync(); 33 | } 34 | } 35 | 36 | public IQueryable GetAllAsync(string userId, bool loadRelationships = false) 37 | { 38 | if (loadRelationships) 39 | { 40 | return _context.Books.Where(book => book.UserId == userId) 41 | .Include(b => b.Tags) 42 | .Include(b => b.Bookmarks) 43 | .Include(b => b.Highlights).ThenInclude(h => h.Rects); 44 | } 45 | 46 | return _context.Books.Where(book => book.UserId == userId); 47 | } 48 | 49 | public async Task ExistsAsync(string userId, Guid bookGuid) 50 | { 51 | return await _context.Books.AnyAsync(book => book.UserId == userId && 52 | book.BookId == bookGuid); 53 | } 54 | 55 | public void DeleteBook(Book book) 56 | { 57 | _context.Remove(book); 58 | } 59 | 60 | public async Task GetUsedBookStorage(string userId) 61 | { 62 | var coverStorage = await _context.Books.Where(book => book.UserId == userId).SumAsync(book => book.CoverSize); 63 | var books = await _context.Books.Where(book => book.UserId == userId).ToListAsync(); 64 | var bookStorage = books.Sum(book => GetBytesFromSizeString(book.DocumentSize)); 65 | 66 | return coverStorage + (long)bookStorage; 67 | } 68 | 69 | private double GetBytesFromSizeString(string size) 70 | { 71 | size = size.Replace(" ", string.Empty); 72 | size = size.Replace(",", "."); 73 | 74 | int typeBegining = -1; 75 | for (int i = 0; i < size.Length; i++) 76 | { 77 | if (!char.IsDigit(size[i]) && size[i] != '.') 78 | { 79 | typeBegining = i; 80 | break; 81 | } 82 | } 83 | 84 | var numberString = size.Substring(0, typeBegining); 85 | var provider = new System.Globalization.NumberFormatInfo(); 86 | provider.NumberDecimalSeparator = "."; 87 | provider.NumberGroupSeparator = ","; 88 | var numbers = Convert.ToDouble(numberString,provider); 89 | 90 | var type = size[typeBegining..]; 91 | return type.ToLower() switch 92 | { 93 | "b" => numbers, 94 | "kb" => numbers * 1000, 95 | "mb" => numbers * 1000 * 1000, 96 | "gb" => numbers * 1000 * 1000 97 | }; 98 | } 99 | } -------------------------------------------------------------------------------- /src/Application/Managers/UserLocalStorageManager.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.Exceptions; 2 | using Application.Interfaces.Managers; 3 | using Microsoft.AspNetCore.WebUtilities; 4 | using Microsoft.Net.Http.Headers; 5 | 6 | namespace Application.Managers; 7 | 8 | public class UserLocalStorageManager : IUserBlobStorageManager 9 | { 10 | private string _profilesDir; 11 | 12 | public UserLocalStorageManager() 13 | { 14 | string baseDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); 15 | string dataDir = baseDir + "/librum_storage"; 16 | _profilesDir = dataDir + "/profiles"; 17 | 18 | if(!Directory.Exists(dataDir)) 19 | Directory.CreateDirectory(dataDir); 20 | if(!Directory.Exists(_profilesDir)) 21 | Directory.CreateDirectory(_profilesDir); 22 | 23 | Console.WriteLine ("Profile pictures are stored in: " + _profilesDir); 24 | } 25 | 26 | 27 | public Task DownloadProfilePicture(string guid) 28 | { 29 | var filename=_profilesDir + "/" + guid; 30 | if (!File.Exists(filename)) 31 | throw new CommonErrorException(400, "file not exists " + filename, 0); 32 | 33 | return Task.FromResult(File.OpenRead(filename)); 34 | } 35 | 36 | public async Task ChangeProfilePicture(string guid, MultipartReader reader) 37 | { 38 | var filename = _profilesDir + "/" + guid; 39 | Stream dest; 40 | try 41 | { 42 | dest = File.Create (filename); 43 | } 44 | catch (Exception e) 45 | { 46 | if (e is UnauthorizedAccessException) 47 | { 48 | FileAttributes attr = (new FileInfo(filename)).Attributes; 49 | if ((attr & FileAttributes.ReadOnly) > 0) 50 | Console.Write("The file is read-only.Can't overwrite."); 51 | throw new CommonErrorException(400, "Can't overwrite file for picture profile", 0); 52 | } 53 | 54 | Console.WriteLine(e.Message); 55 | throw new CommonErrorException(400, "Failed creating file at: " + filename, 0); 56 | } 57 | 58 | var section = await reader.ReadNextSectionAsync(); 59 | while (section != null) 60 | { 61 | var hasContentDispositionHeader = 62 | ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out var contentDisposition); 63 | 64 | if (!hasContentDispositionHeader) 65 | continue; 66 | 67 | if (!HasFileContentDisposition(contentDisposition)) 68 | { 69 | var message = "Missing content disposition header"; 70 | throw new CommonErrorException(400, message, 0); 71 | } 72 | 73 | await section.Body.CopyToAsync(dest); 74 | section = await reader.ReadNextSectionAsync(); 75 | } 76 | 77 | dest.Close(); 78 | File.SetUnixFileMode(filename,UnixFileMode.UserRead | UnixFileMode.UserWrite); 79 | } 80 | 81 | private static bool HasFileContentDisposition( 82 | ContentDispositionHeaderValue contentDisposition) 83 | { 84 | // Content-Disposition: form-data; name="myfile1"; filename="Misc 002.jpg" 85 | return contentDisposition != null && 86 | contentDisposition.DispositionType.Equals("form-data") && 87 | (!string.IsNullOrEmpty(contentDisposition.FileName.Value) || 88 | !string.IsNullOrEmpty(contentDisposition.FileNameStar.Value)); 89 | } 90 | 91 | public async Task DeleteProfilePicture(string guid) 92 | { 93 | var path = _profilesDir + "/" + guid; 94 | await Task.Run(() => File.Delete(path)); 95 | } 96 | } -------------------------------------------------------------------------------- /src/Presentation/Controllers/AuthenticationController.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.DTOs.Users; 2 | using Application.Common.Exceptions; 3 | using Application.Interfaces.Services; 4 | using Microsoft.AspNetCore.Authorization; 5 | using Microsoft.AspNetCore.Mvc; 6 | 7 | namespace Presentation.Controllers; 8 | 9 | [ApiController] 10 | [Route("[controller]")] 11 | public class AuthenticationController : ControllerBase 12 | { 13 | private readonly IAuthenticationService _authenticationService; 14 | private readonly ILogger _logger; 15 | 16 | 17 | public AuthenticationController(IAuthenticationService authenticationService, 18 | ILogger logger) 19 | { 20 | _authenticationService = authenticationService; 21 | _logger = logger; 22 | } 23 | 24 | [AllowAnonymous] 25 | [HttpPost("register")] 26 | public async Task RegisterUser([FromBody] RegisterDto registerDto) 27 | { 28 | try 29 | { 30 | await _authenticationService.RegisterUserAsync(registerDto); 31 | return StatusCode(201); 32 | } 33 | catch (CommonErrorException e) 34 | { 35 | _logger.LogWarning("{ExceptionMessage}", e.Message); 36 | return StatusCode(e.Error.Status, e.Error); 37 | } 38 | } 39 | 40 | [AllowAnonymous] 41 | [HttpPost("login")] 42 | public async Task> LoginUser([FromBody] LoginDto loginDto) 43 | { 44 | try 45 | { 46 | var result = await _authenticationService.LoginUserAsync(loginDto); 47 | return Ok(result); 48 | } 49 | catch (CommonErrorException e) 50 | { 51 | _logger.LogWarning("{ExceptionMessage}", e.Message); 52 | return StatusCode(e.Error.Status, e.Error); 53 | } 54 | } 55 | 56 | [AllowAnonymous] 57 | [HttpGet("confirmEmail")] 58 | public async Task ConfirmEmail(string email, string token) 59 | { 60 | try 61 | { 62 | await _authenticationService.ConfirmEmail(email, token); 63 | 64 | var filePath = Path.Combine(Directory.GetCurrentDirectory(), 65 | "wwwroot", 66 | "EmailConfirmationSucceeded.html"); 67 | var successContent = await System.IO.File.ReadAllTextAsync(filePath); 68 | 69 | return base.Content(successContent, "text/html"); 70 | } 71 | catch (CommonErrorException e) 72 | { 73 | _logger.LogWarning("{ExceptionMessage}", e.Message); 74 | 75 | var filePath = Path.Combine(Directory.GetCurrentDirectory(), 76 | "wwwroot", 77 | "EmailConfirmationFailed.html"); 78 | var successContent = await System.IO.File.ReadAllTextAsync(filePath); 79 | return base.Content(successContent, "text/html"); 80 | } 81 | } 82 | 83 | [AllowAnonymous] 84 | [HttpGet("checkIfEmailConfirmed/{email}")] 85 | public async Task> CheckIfEmailIsConfirmed(string email) 86 | { 87 | var confirmed = await _authenticationService.CheckIfEmailIsConfirmed(email); 88 | return Ok(confirmed); 89 | } 90 | 91 | [AllowAnonymous] 92 | [HttpPost("recaptchaVerify")] 93 | public async Task ReCaptchaVerify(string userToken) 94 | { 95 | var result = await _authenticationService.VerifyReCaptcha(userToken); 96 | 97 | return Ok(result); 98 | } 99 | } -------------------------------------------------------------------------------- /Librum-Server.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.30114.105 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{5615A81A-02F2-4E42-B19E-7D3B575AB00C}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain", "src\Domain\Domain.csproj", "{89F08172-EF15-4F4B-9C56-3BADDB27F37A}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Presentation", "src\Presentation\Presentation.csproj", "{C48D904B-AD88-4011-82E1-E394CA25EED4}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Application", "src\Application\Application.csproj", "{9CE7AE60-2DFD-4581-9C30-59DF211CDE87}" 13 | EndProject 14 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure", "src\Infrastructure\Infrastructure.csproj", "{7EDD9683-7182-4555-ACA6-E089D092E16F}" 15 | EndProject 16 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{2A2F2933-BA93-42D2-AEE5-1DBDAE3FD259}" 17 | EndProject 18 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Application.UnitTests", "tests\Application.UnitTests\Application.UnitTests.csproj", "{F2C7FDE0-3E06-426F-A128-F3B0BEE3ED66}" 19 | EndProject 20 | Global 21 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 22 | Debug|Any CPU = Debug|Any CPU 23 | Release|Any CPU = Release|Any CPU 24 | EndGlobalSection 25 | GlobalSection(SolutionProperties) = preSolution 26 | HideSolutionNode = FALSE 27 | EndGlobalSection 28 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 29 | {89F08172-EF15-4F4B-9C56-3BADDB27F37A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 30 | {89F08172-EF15-4F4B-9C56-3BADDB27F37A}.Debug|Any CPU.Build.0 = Debug|Any CPU 31 | {89F08172-EF15-4F4B-9C56-3BADDB27F37A}.Release|Any CPU.ActiveCfg = Release|Any CPU 32 | {89F08172-EF15-4F4B-9C56-3BADDB27F37A}.Release|Any CPU.Build.0 = Release|Any CPU 33 | {C48D904B-AD88-4011-82E1-E394CA25EED4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 34 | {C48D904B-AD88-4011-82E1-E394CA25EED4}.Debug|Any CPU.Build.0 = Debug|Any CPU 35 | {C48D904B-AD88-4011-82E1-E394CA25EED4}.Release|Any CPU.ActiveCfg = Release|Any CPU 36 | {C48D904B-AD88-4011-82E1-E394CA25EED4}.Release|Any CPU.Build.0 = Release|Any CPU 37 | {9CE7AE60-2DFD-4581-9C30-59DF211CDE87}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 38 | {9CE7AE60-2DFD-4581-9C30-59DF211CDE87}.Debug|Any CPU.Build.0 = Debug|Any CPU 39 | {9CE7AE60-2DFD-4581-9C30-59DF211CDE87}.Release|Any CPU.ActiveCfg = Release|Any CPU 40 | {9CE7AE60-2DFD-4581-9C30-59DF211CDE87}.Release|Any CPU.Build.0 = Release|Any CPU 41 | {7EDD9683-7182-4555-ACA6-E089D092E16F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 42 | {7EDD9683-7182-4555-ACA6-E089D092E16F}.Debug|Any CPU.Build.0 = Debug|Any CPU 43 | {7EDD9683-7182-4555-ACA6-E089D092E16F}.Release|Any CPU.ActiveCfg = Release|Any CPU 44 | {7EDD9683-7182-4555-ACA6-E089D092E16F}.Release|Any CPU.Build.0 = Release|Any CPU 45 | {F2C7FDE0-3E06-426F-A128-F3B0BEE3ED66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 46 | {F2C7FDE0-3E06-426F-A128-F3B0BEE3ED66}.Debug|Any CPU.Build.0 = Debug|Any CPU 47 | {F2C7FDE0-3E06-426F-A128-F3B0BEE3ED66}.Release|Any CPU.ActiveCfg = Release|Any CPU 48 | {F2C7FDE0-3E06-426F-A128-F3B0BEE3ED66}.Release|Any CPU.Build.0 = Release|Any CPU 49 | EndGlobalSection 50 | GlobalSection(NestedProjects) = preSolution 51 | {89F08172-EF15-4F4B-9C56-3BADDB27F37A} = {5615A81A-02F2-4E42-B19E-7D3B575AB00C} 52 | {C48D904B-AD88-4011-82E1-E394CA25EED4} = {5615A81A-02F2-4E42-B19E-7D3B575AB00C} 53 | {9CE7AE60-2DFD-4581-9C30-59DF211CDE87} = {5615A81A-02F2-4E42-B19E-7D3B575AB00C} 54 | {7EDD9683-7182-4555-ACA6-E089D092E16F} = {5615A81A-02F2-4E42-B19E-7D3B575AB00C} 55 | {F2C7FDE0-3E06-426F-A128-F3B0BEE3ED66} = {2A2F2933-BA93-42D2-AEE5-1DBDAE3FD259} 56 | EndGlobalSection 57 | EndGlobal 58 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [main] 7 | pull_request: 8 | branches: [main] 9 | release: 10 | types: [published] 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | permissions: 17 | packages: write 18 | 19 | jobs: 20 | build_and_push: 21 | name: Build and Push 22 | runs-on: ubuntu-latest 23 | strategy: 24 | # Prevent a failure in one image from stopping the other builds 25 | fail-fast: false 26 | matrix: 27 | include: 28 | - context: "." 29 | file: "Dockerfile" 30 | image: "librum-server" 31 | # ARM not working. Needs further research. 32 | platforms: "linux/arm64,linux/amd64" 33 | #platforms: "linux/amd64" 34 | 35 | steps: 36 | - name: Checkout 37 | uses: actions/checkout@v4 38 | 39 | - name: Set up QEMU 40 | uses: docker/setup-qemu-action@v3.0.0 41 | 42 | - name: Set up Docker Buildx 43 | uses: docker/setup-buildx-action@v3.0.0 44 | # Workaround to fix error: 45 | # failed to push: failed to copy: io: read/write on closed pipe 46 | # See https://github.com/docker/build-push-action/issues/761 47 | with: 48 | driver-opts: | 49 | image=moby/buildkit:v0.10.6 50 | 51 | - name: Login to GitHub Container Registry 52 | uses: docker/login-action@v3 53 | # Skip when PR from a fork 54 | if: ${{ !github.event.pull_request.head.repo.fork }} 55 | with: 56 | registry: ghcr.io 57 | username: ${{ github.repository_owner }} 58 | password: ${{ secrets.GITHUB_TOKEN }} 59 | 60 | - name: Sanitize unfortunate names 61 | id: sanitize_names 62 | uses: ASzc/change-string-case-action@v5 63 | with: 64 | string: ${{ github.repository_owner }} 65 | 66 | - name: Generate docker image tags 67 | id: metadata 68 | uses: docker/metadata-action@v5 69 | with: 70 | flavor: | 71 | latest=true 72 | images: | 73 | name=ghcr.io/${{ steps.sanitize_names.outputs.lowercase }}/${{matrix.image}} 74 | tags: | 75 | # Tag with branch name 76 | type=ref,event=branch 77 | # Tag with pr-number 78 | type=ref,event=pr 79 | # Tag with git tag on release 80 | type=ref,event=tag 81 | type=raw,value=release,enable=${{ github.event_name == 'release' }} 82 | 83 | - name: Determine build cache output 84 | id: cache-target 85 | run: | 86 | if [[ "${{ github.event_name }}" == "pull_request" ]]; then 87 | # Essentially just ignore the cache output (PR can't write to registry cache) 88 | echo "cache-to=type=local,dest=/tmp/discard,ignore-error=true" >> $GITHUB_OUTPUT 89 | else 90 | echo "cache-to=type=registry,mode=max,ref=ghcr.io/${{ steps.sanitize_names.outputs.lowercase }}/librum-build-cache:${{ matrix.image }}" >> $GITHUB_OUTPUT 91 | fi 92 | 93 | - name: Build and push image 94 | uses: docker/build-push-action@v5.1.0 95 | with: 96 | context: ${{ matrix.context }} 97 | file: ${{ matrix.file }} 98 | platforms: ${{ matrix.platforms }} 99 | # Skip pushing when PR from a fork 100 | push: ${{ !github.event.pull_request.head.repo.fork }} 101 | cache-from: type=registry,ref=ghcr.io/${{ steps.sanitize_names.outputs.lowercase }}/librum-build-cache:${{matrix.image}} 102 | cache-to: ${{ steps.cache-target.outputs.cache-to }} 103 | tags: ${{ steps.metadata.outputs.tags }} 104 | labels: ${{ steps.metadata.outputs.labels }} -------------------------------------------------------------------------------- /src/Application/Common/DTOs/Books/BookInDto.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using Application.Common.DataAnnotations; 3 | using Application.Common.DTOs.Bookmarks; 4 | using Application.Common.DTOs.Highlights; 5 | using Application.Common.DTOs.Tags; 6 | 7 | 8 | namespace Application.Common.DTOs.Books; 9 | 10 | 11 | public class BookInDto 12 | { 13 | [Required] 14 | public Guid Guid { get; set; } 15 | 16 | [Required] 17 | [MinLength(2, ErrorMessage = "The title is too short")] 18 | [MaxLength(2000, ErrorMessage = "The title is too long")] 19 | public string Title { get; set; } 20 | 21 | [Required] 22 | [Range(0, int.MaxValue)] 23 | public int PageCount { get; set; } 24 | 25 | [Required] 26 | [Range(0, int.MaxValue)] 27 | public int CurrentPage { get; set; } 28 | 29 | [Required] 30 | [MinLength(2, ErrorMessage = "The format is too short")] 31 | [MaxLength(100, ErrorMessage = "The format is too long")] 32 | public string Format { get; set; } 33 | 34 | [MaxLength(500, ErrorMessage = "The extension is too long")] 35 | public string Extension { get; set; } 36 | 37 | [EmptyOrMinLength(2, ErrorMessage = "The language is too short")] 38 | [MaxLength(100, ErrorMessage = "The language is too long")] 39 | public string Language { get; set; } 40 | 41 | [Required] 42 | [MinLength(2, ErrorMessage = "The document size is too short")] 43 | [MaxLength(60, ErrorMessage = "The document size is too long")] 44 | public string DocumentSize { get; set; } 45 | 46 | [EmptyOrMinLength(2, ErrorMessage = "The pages size is too short")] 47 | [MaxLength(600, ErrorMessage = "The pages size is too long")] 48 | public string PagesSize { get; set; } 49 | 50 | [MaxLength(2000, ErrorMessage = "The creator is too long")] 51 | public string Creator { get; set; } 52 | 53 | [MaxLength(2000, ErrorMessage = "The authors are too long")] 54 | public string Authors { get; set; } 55 | 56 | [MaxLength(140, ErrorMessage = "The creation date is too long")] 57 | public string CreationDate { get; set; } 58 | 59 | [Required] 60 | [MinLength(4, ErrorMessage = "The added to library date is too short")] 61 | [MaxLength(100, ErrorMessage = "The added to library date is too long")] 62 | public string AddedToLibrary { get; set; } 63 | 64 | [EmptyOrMinLength(4, ErrorMessage = "The last opened is too short")] 65 | [MaxLength(100, ErrorMessage = "The last opened is too long")] 66 | public string LastOpened { get; set; } 67 | 68 | [Required] 69 | [MinLength(4, ErrorMessage = "The last modified is too short")] 70 | [MaxLength(100, ErrorMessage = "The last modified is too long")] 71 | public string LastModified { get; set; } 72 | 73 | [Required] 74 | [MinLength(4, ErrorMessage = "The cover last modified is too short")] 75 | [MaxLength(100, ErrorMessage = "The cover last modified is too long")] 76 | public string CoverLastModified { get; set; } 77 | 78 | [Required] 79 | public bool HasCover { get; set; } 80 | 81 | public int ProjectGutenbergId { get; set; } = 0; 82 | 83 | [MaxLength(100, ErrorMessage = "ColorTheme is too long")] 84 | public string ColorTheme { get; set; } 85 | 86 | [MaxLength(2000, ErrorMessage = "FileHash is too long")] 87 | public string FileHash { get; set; } 88 | 89 | [MaxLength(100, ErrorMessage = "ParentFolderId is too long")] 90 | public string ParentFolderId { get; set; } 91 | 92 | public ICollection Tags { get; set; } = new List(); 93 | 94 | public ICollection Highlights { get; set; } = new List(); 95 | 96 | public ICollection Bookmarks { get; set; } = new List(); 97 | 98 | 99 | public bool IsValid => CurrentPage <= PageCount; 100 | } -------------------------------------------------------------------------------- /src/Presentation/wwwroot/error.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/Application/Services/FolderService.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.DTOs.Folders; 2 | using Application.Common.Exceptions; 3 | using Application.Interfaces.Repositories; 4 | using Application.Interfaces.Services; 5 | using Domain.Entities; 6 | 7 | namespace Application.Services; 8 | 9 | public class FolderService(IUserRepository userRepository, IFolderRepository folderRepository) : IFolderService 10 | { 11 | public IUserRepository UserRepository { get; } = userRepository; 12 | public IFolderRepository FolderRepository { get; } = folderRepository; 13 | 14 | public async Task UpdateFoldersAsync(string email, FolderInDto folderInDto) 15 | { 16 | var user = await UserRepository.GetAsync(email, true); 17 | if (user == null) 18 | throw new CommonErrorException(400, "No user with this email exists", 17); 19 | 20 | var folder = FolderInDtoToFolder(folderInDto); 21 | // If the user has no root folder, create it and set it as the user's root folder 22 | if(user.RootFolderId == Guid.Empty) 23 | { 24 | await FolderRepository.CreateFolderAsync(folder); 25 | user.RootFolderId = folder.FolderId; 26 | 27 | await UserRepository.SaveChangesAsync(); 28 | return; 29 | } 30 | 31 | if(folder.FolderId != user.RootFolderId) 32 | throw new CommonErrorException(400, "Folder id does not match user root folder id", 0); 33 | 34 | var existingFolder = await FolderRepository.GetFolderAsync(user.RootFolderId); 35 | if(existingFolder == null) 36 | throw new CommonErrorException(400, "User has no root folder", 0); 37 | 38 | FolderRepository.RemoveFolder(existingFolder); 39 | await FolderRepository.CreateFolderAsync(folder); 40 | await FolderRepository.SaveChangesAsync(); 41 | } 42 | 43 | private Folder FolderInDtoToFolder(FolderInDto folderInDto) 44 | { 45 | var folder = new Folder 46 | { 47 | FolderId = new Guid(folderInDto.Guid), 48 | Name = folderInDto.Name, 49 | Color = folderInDto.Color, 50 | Icon = folderInDto.Icon, 51 | Description = folderInDto.Description, 52 | LastModified = folderInDto.LastModified, 53 | IndexInParent = folderInDto.IndexInParent, 54 | Children = new List() 55 | }; 56 | 57 | foreach (var child in folderInDto.Children) 58 | { 59 | var childFolder = FolderInDtoToFolder(child); 60 | folder.Children.Add(childFolder); 61 | } 62 | 63 | return folder; 64 | } 65 | 66 | public async Task GetFoldersAsync(string email) 67 | { 68 | var user = await UserRepository.GetAsync(email, false); 69 | if (user == null) 70 | { 71 | throw new CommonErrorException(400, "No user with this email exists", 17); 72 | } 73 | 74 | if(user.RootFolderId == Guid.Empty) 75 | { 76 | throw new CommonErrorException(400, "User has no root folder", 22); 77 | } 78 | 79 | var folder = await FolderRepository.GetFolderAsync(user.RootFolderId); 80 | return await FolderToFolderOutDto(folder); 81 | } 82 | 83 | private async Task FolderToFolderOutDto(Folder folder) 84 | { 85 | var folderOutDto = new FolderOutDto 86 | { 87 | Guid = folder.FolderId.ToString(), 88 | Name = folder.Name, 89 | Color = folder.Color, 90 | Icon = folder.Icon, 91 | LastModified = folder.LastModified, 92 | IndexInParent = folder.IndexInParent, 93 | Description = folder.Description 94 | }; 95 | 96 | foreach (var child in folder.Children) 97 | { 98 | var childFolderOutDto = await FolderToFolderOutDto(child); 99 | folderOutDto.Children.Add(childFolderOutDto); 100 | } 101 | 102 | return folderOutDto; 103 | } 104 | } -------------------------------------------------------------------------------- /src/Presentation/Program.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.Middleware; 2 | using Stripe; 3 | using Azure.Identity; 4 | using Domain.Entities; 5 | using Infrastructure.Persistence; 6 | using Microsoft.AspNetCore.Identity; 7 | using Presentation; 8 | 9 | 10 | var builder = WebApplication.CreateBuilder(args); 11 | 12 | 13 | // Add AzureKeyVault and Stripe as configuration provider if not self-hosted 14 | if (builder.Configuration["LIBRUM_SELFHOSTED"] != "true") 15 | { 16 | var keyVaultUrl = new Uri(builder.Configuration.GetSection("AzureKeyVaultUri").Value!); 17 | var azureCredential = new DefaultAzureCredential(); 18 | builder.Configuration.AddAzureKeyVault(keyVaultUrl, azureCredential); 19 | 20 | StripeConfiguration.ApiKey = builder.Configuration["StripeSecretKey"]; 21 | } 22 | else 23 | { 24 | Console.WriteLine("Running in selfhosted mode, skipping AzureKeyVault and Stripe configuration"); 25 | } 26 | 27 | // Services 28 | builder.Services.AddControllers().AddNewtonsoftJson(); 29 | builder.Services.AddApplicationServices(builder.Configuration); 30 | builder.Services.ConfigureIdentity(); 31 | builder.Services.ConfigureJwt(builder.Configuration); 32 | builder.Services.AddCors(p => p.AddPolicy("corspolicy", 33 | build => 34 | { 35 | build.WithOrigins("*").AllowAnyMethod() 36 | .AllowAnyHeader(); 37 | })); 38 | 39 | var app = builder.Build(); 40 | 41 | 42 | 43 | // Startup action 44 | using (var scope = app.Services.CreateScope()) 45 | { 46 | var services = scope.ServiceProvider; 47 | 48 | // Initialize the local database if self-hosted 49 | if (builder.Configuration["LIBRUM_SELFHOSTED"] == "true"){ 50 | var context = services.GetRequiredService(); 51 | context.Database.EnsureCreated(); 52 | } 53 | 54 | // Configure Logging 55 | var loggerFactory = services.GetRequiredService(); 56 | loggerFactory.AddFile(Directory.GetCurrentDirectory() + "/Data/Logs/"); 57 | 58 | // Add Roles 59 | var roleManager = services.GetRequiredService>(); 60 | var roles = new[] { "Admin", "Basic" }; 61 | 62 | foreach (var role in roles) 63 | { 64 | if (!await roleManager.RoleExistsAsync(role)) 65 | await roleManager.CreateAsync(new IdentityRole(role)); 66 | } 67 | 68 | await SeedWithAdminUser(services); 69 | } 70 | 71 | 72 | 73 | // Http pipeline 74 | if (builder.Configuration["LIBRUM_SELFHOSTED"] == "true"){ 75 | app.MapGet("/", () => "Librum-Server is not a web application, so it's not supposed to have a main page.
This page is just for health-status checking."); 76 | } 77 | app.UseMiddleware(); 78 | app.UseMiddleware(); 79 | app.UseHttpsRedirection(); 80 | app.UseCors("corspolicy"); 81 | app.UseAuthentication(); 82 | app.UseAuthorization(); 83 | app.UseStaticFiles(); 84 | app.MapControllers(); 85 | app.Run(); 86 | 87 | 88 | 89 | async Task SeedWithAdminUser(IServiceProvider services) 90 | { 91 | var config = services.GetRequiredService(); 92 | string name = "Admin"; 93 | string email = config["AdminEmail"]; 94 | string password = config["AdminPassword"]; 95 | 96 | var userManager = services.GetRequiredService>(); 97 | if (await userManager.FindByEmailAsync(email) == null) 98 | { 99 | var user = new User 100 | { 101 | Name = name, 102 | Email = email, 103 | UserName = email, 104 | AccountCreation = DateTime.UtcNow 105 | }; 106 | 107 | await userManager.CreateAsync(user, password); 108 | var token = await userManager.GenerateEmailConfirmationTokenAsync(user); 109 | await userManager.ConfirmEmailAsync(user, token); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Application/Managers/AuthenticationManager.cs: -------------------------------------------------------------------------------- 1 | using System.IdentityModel.Tokens.Jwt; 2 | using System.Security.Claims; 3 | using System.Text; 4 | using Application.Common.DTOs.Users; 5 | using Application.Common.Exceptions; 6 | using Application.Interfaces.Managers; 7 | using Domain.Entities; 8 | using Microsoft.AspNetCore.Identity; 9 | using Microsoft.Extensions.Configuration; 10 | using Microsoft.IdentityModel.Tokens; 11 | 12 | namespace Application.Managers; 13 | 14 | public class AuthenticationManager : IAuthenticationManager 15 | { 16 | private readonly IConfiguration _configuration; 17 | private readonly UserManager _userManager; 18 | 19 | 20 | public AuthenticationManager(IConfiguration configuration, 21 | UserManager userManager) 22 | { 23 | _configuration = configuration; 24 | _userManager = userManager; 25 | } 26 | 27 | 28 | public async Task CreateUserAsync(User user, string password) 29 | { 30 | var result = await _userManager.CreateAsync(user, password); 31 | return result.Succeeded; 32 | } 33 | 34 | public async Task UserExistsAsync(string email, string password) 35 | { 36 | var user = await _userManager.FindByEmailAsync(email); 37 | if (user == null) 38 | return false; 39 | 40 | return await _userManager.CheckPasswordAsync(user, password); 41 | } 42 | 43 | public async Task EmailAlreadyExistsAsync(string email) 44 | { 45 | var user = await _userManager.FindByEmailAsync(email); 46 | 47 | return user != null; 48 | } 49 | 50 | public async Task CreateTokenAsync(LoginDto loginDto) 51 | { 52 | var signingCredentials = GetSigningCredentials(); 53 | var claims = await GetClaimsAsync(loginDto); 54 | var tokenOptions = GenerateTokenOptions(signingCredentials, claims); 55 | 56 | return new JwtSecurityTokenHandler().WriteToken(tokenOptions); 57 | } 58 | 59 | public async Task GetEmailConfirmationLinkAsync(User user) 60 | { 61 | var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); 62 | return token; 63 | } 64 | 65 | public async Task ConfirmEmailAsync(string email, string token) 66 | { 67 | var user = await _userManager.FindByNameAsync(email); 68 | if (user == null) 69 | throw new CommonErrorException(400, "No user with this email address was found", 17); 70 | 71 | var result = await _userManager.ConfirmEmailAsync(user, token); 72 | return result.Succeeded; 73 | } 74 | 75 | public async Task IsEmailConfirmed(string email) 76 | { 77 | var user = await _userManager.FindByNameAsync(email); 78 | if (user == null) 79 | throw new CommonErrorException(400, "No user with this email address was found", 17); 80 | 81 | return user.EmailConfirmed; 82 | } 83 | 84 | private SigningCredentials GetSigningCredentials() 85 | { 86 | var key = Encoding.UTF8.GetBytes(_configuration["JWTKey"]!); 87 | var secret = new SymmetricSecurityKey(key); 88 | 89 | return new SigningCredentials(secret, SecurityAlgorithms.HmacSha256); 90 | } 91 | 92 | private async Task> GetClaimsAsync(LoginDto loginDto) 93 | { 94 | var user = await _userManager.FindByEmailAsync(loginDto?.Email); 95 | if (user == null) 96 | { 97 | const string message = "Getting claims failed: User does not exist"; 98 | throw new ArgumentException(message); 99 | } 100 | 101 | var claims = new List 102 | { 103 | new Claim(ClaimTypes.Name, user.UserName) 104 | }; 105 | 106 | return claims; 107 | } 108 | 109 | private JwtSecurityToken GenerateTokenOptions(SigningCredentials signingCredentials, 110 | IEnumerable claims) 111 | { 112 | var tokenOptions = new JwtSecurityToken 113 | ( 114 | issuer: _configuration["JWTValidIssuer"], 115 | audience: "librumapi", 116 | claims: claims, 117 | expires: DateTime.Now.AddMonths(2), 118 | signingCredentials: signingCredentials 119 | ); 120 | 121 | return tokenOptions; 122 | } 123 | } -------------------------------------------------------------------------------- /src/Application/Managers/BookBlobStorageManager.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.Exceptions; 2 | using Application.Interfaces.Managers; 3 | using Azure.Storage.Blobs; 4 | using Microsoft.AspNetCore.WebUtilities; 5 | using Microsoft.Net.Http.Headers; 6 | 7 | namespace Application.Managers; 8 | 9 | public class BookBlobStorageManager : IBookBlobStorageManager 10 | { 11 | private readonly BlobServiceClient _blobServiceClient; 12 | private readonly string _bookCoverPrefix = "cover_"; 13 | 14 | public BookBlobStorageManager(BlobServiceClient blobServiceClient) 15 | { 16 | _blobServiceClient = blobServiceClient; 17 | } 18 | 19 | public Task DownloadBookBlob(Guid guid) 20 | { 21 | var containerClient = 22 | _blobServiceClient.GetBlobContainerClient("books"); 23 | var blobClient = containerClient.GetBlobClient(guid.ToString()); 24 | 25 | return blobClient.OpenReadAsync(); 26 | } 27 | 28 | public async Task UploadBookBlob(Guid guid, MultipartReader reader) 29 | { 30 | var containerClient = 31 | _blobServiceClient.GetBlobContainerClient("books"); 32 | var blobClient = containerClient.GetBlobClient(guid.ToString()); 33 | 34 | await using var dest = await blobClient.OpenWriteAsync(true); 35 | 36 | 37 | var section = await reader.ReadNextSectionAsync(); 38 | while (section != null) 39 | { 40 | var hasContentDispositionHeader = 41 | ContentDispositionHeaderValue.TryParse( 42 | section.ContentDisposition, 43 | out var contentDisposition); 44 | 45 | if (!hasContentDispositionHeader) 46 | continue; 47 | 48 | if (!HasFileContentDisposition(contentDisposition)) 49 | { 50 | var message = "Missing content disposition header"; 51 | throw new CommonErrorException(400, message, 0); 52 | } 53 | 54 | await section.Body.CopyToAsync(dest); 55 | 56 | section = await reader.ReadNextSectionAsync(); 57 | } 58 | } 59 | 60 | private static bool HasFileContentDisposition( 61 | ContentDispositionHeaderValue contentDisposition) 62 | { 63 | // Content-Disposition: form-data; name="myfile1"; filename="Misc 002.jpg" 64 | return contentDisposition != null && 65 | contentDisposition.DispositionType.Equals("form-data") && 66 | (!string.IsNullOrEmpty(contentDisposition.FileName.Value) || 67 | !string.IsNullOrEmpty(contentDisposition.FileNameStar.Value)); 68 | } 69 | 70 | 71 | public async Task DeleteBookBlob(Guid guid) 72 | { 73 | var containerClient = 74 | _blobServiceClient.GetBlobContainerClient("books"); 75 | var blobClient = containerClient.GetBlobClient(guid.ToString()); 76 | 77 | await blobClient.DeleteAsync(); 78 | } 79 | 80 | public async Task ChangeBookCover(Guid guid, MultipartReader reader) 81 | { 82 | var containerClient = 83 | _blobServiceClient.GetBlobContainerClient("books"); 84 | var blobClient = containerClient.GetBlobClient(_bookCoverPrefix + guid); 85 | 86 | await using var dest = await blobClient.OpenWriteAsync(true); 87 | 88 | long coverSize = 0; 89 | var section = await reader.ReadNextSectionAsync(); 90 | while (section != null) 91 | { 92 | var hasContentDispositionHeader = 93 | ContentDispositionHeaderValue.TryParse( 94 | section.ContentDisposition, 95 | out var contentDisposition); 96 | 97 | if (!hasContentDispositionHeader) 98 | continue; 99 | 100 | if (!HasFileContentDisposition(contentDisposition)) 101 | { 102 | var message = "Missing content disposition header"; 103 | throw new CommonErrorException(400, message, 0); 104 | } 105 | 106 | await section.Body.CopyToAsync(dest); 107 | coverSize += section.Body.Length; 108 | 109 | section = await reader.ReadNextSectionAsync(); 110 | } 111 | 112 | return coverSize; 113 | } 114 | 115 | public Task DownloadBookCover(Guid guid) 116 | { 117 | var containerClient = 118 | _blobServiceClient.GetBlobContainerClient("books"); 119 | var blobClient = containerClient.GetBlobClient(_bookCoverPrefix + guid); 120 | 121 | return blobClient.OpenReadAsync(); 122 | } 123 | 124 | public async Task DeleteBookCover(Guid guid) 125 | { 126 | var containerClient = 127 | _blobServiceClient.GetBlobContainerClient("books"); 128 | var blobClient = containerClient.GetBlobClient(_bookCoverPrefix + guid); 129 | 130 | await blobClient.DeleteAsync(); 131 | } 132 | } -------------------------------------------------------------------------------- /src/Application/Services/AuthenticationService.cs: -------------------------------------------------------------------------------- 1 | using System.Web; 2 | using Application.Common.DTOs.Users; 3 | using Application.Common.Exceptions; 4 | using Application.Interfaces.Managers; 5 | using Application.Interfaces.Repositories; 6 | using Application.Interfaces.Services; 7 | using Application.Interfaces.Utility; 8 | using AutoMapper; 9 | using Domain.Entities; 10 | using Microsoft.Extensions.Configuration; 11 | 12 | namespace Application.Services; 13 | 14 | public class AuthenticationService : IAuthenticationService 15 | { 16 | private readonly IMapper _mapper; 17 | private readonly IAuthenticationManager _authenticationManager; 18 | private readonly IEmailSender _emailSender; 19 | private readonly IHttpClientFactory _httpClientFactory; 20 | private readonly IConfiguration _configuration; 21 | private readonly IProductRepository _productRepository; 22 | 23 | 24 | public AuthenticationService(IMapper mapper, 25 | IAuthenticationManager authenticationManager, 26 | IEmailSender emailSender, 27 | IHttpClientFactory httpClientFactory, 28 | IConfiguration configuration, 29 | IProductRepository productRepository) 30 | { 31 | _mapper = mapper; 32 | _authenticationManager = authenticationManager; 33 | _emailSender = emailSender; 34 | _httpClientFactory = httpClientFactory; 35 | _configuration = configuration; 36 | _productRepository = productRepository; 37 | } 38 | 39 | 40 | public async Task LoginUserAsync(LoginDto loginDto) 41 | { 42 | var email = loginDto.Email; 43 | var password = loginDto.Password; 44 | 45 | if (!await _authenticationManager.UserExistsAsync(email, password)) 46 | { 47 | const string message = "Invalid email or password"; 48 | throw new CommonErrorException(401, message, 1); 49 | } 50 | 51 | if (!await _authenticationManager.IsEmailConfirmed(email)) 52 | { 53 | const string message = "Email is not confirmed"; 54 | throw new CommonErrorException(401, message, 18); 55 | } 56 | 57 | return await _authenticationManager.CreateTokenAsync(loginDto); 58 | } 59 | 60 | public async Task RegisterUserAsync(RegisterDto registerDto) 61 | { 62 | if (await _authenticationManager.EmailAlreadyExistsAsync(registerDto.Email)) 63 | { 64 | const string message = "A user with this email already exists"; 65 | throw new CommonErrorException(400, message, 2); 66 | } 67 | 68 | var user = _mapper.Map(registerDto); 69 | 70 | // Assign the free product by default 71 | var freeProduct = _productRepository.GetAll().SingleOrDefault(p => p.Price == 0.0 && p.LiveMode == true); 72 | if (freeProduct != null) 73 | user.ProductId = freeProduct.ProductId; 74 | 75 | var success = 76 | await _authenticationManager.CreateUserAsync(user, registerDto.Password); 77 | if (!success) 78 | { 79 | const string message = "The provided data was invalid"; 80 | throw new CommonErrorException(400, message, 3); 81 | } 82 | 83 | var token = await _authenticationManager.GetEmailConfirmationLinkAsync(user); 84 | 85 | if (_configuration["LIBRUM_SELFHOSTED"] != "true") 86 | { 87 | await _emailSender.SendEmailConfirmationEmail(user, token); 88 | } 89 | else 90 | { 91 | // Automatically confirm the email if self-hosted 92 | await _authenticationManager.ConfirmEmailAsync(user.Email, token); 93 | } 94 | } 95 | 96 | public async Task ConfirmEmail(string email, string token) 97 | { 98 | var result = await _authenticationManager.ConfirmEmailAsync(email, token); 99 | if (!result) 100 | throw new CommonErrorException(400, "Failed confirming email", 0); 101 | } 102 | 103 | public async Task CheckIfEmailIsConfirmed(string email) 104 | { 105 | try 106 | { 107 | return await _authenticationManager.IsEmailConfirmed(email); 108 | } 109 | catch (Exception e) 110 | { 111 | return false; 112 | } 113 | } 114 | 115 | public async Task VerifyReCaptcha(string userToken) 116 | { 117 | var baseUrl = "https://www.google.com/recaptcha/api/siteverify"; 118 | var queryParams = HttpUtility.ParseQueryString(string.Empty); 119 | queryParams["secret"] = _configuration["ReCaptchaSecret"]; 120 | queryParams["response"] = userToken; 121 | 122 | var requestUrl = baseUrl + "?" + queryParams.ToString(); 123 | var httpClient = _httpClientFactory.CreateClient(); 124 | 125 | var response = await httpClient.PostAsync(requestUrl, null); 126 | return response.Content.ReadAsStringAsync().Result; 127 | } 128 | } -------------------------------------------------------------------------------- /tests/Application.UnitTests/Services/UserServiceTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Application.Common.DTOs.Users; 4 | using Application.Common.Exceptions; 5 | using Application.Common.Mappings; 6 | using Application.Interfaces.Managers; 7 | using Application.Interfaces.Repositories; 8 | using Application.Interfaces.Utility; 9 | using Application.Services; 10 | using AutoMapper; 11 | using Domain.Entities; 12 | using Microsoft.AspNetCore.Identity; 13 | using Microsoft.AspNetCore.JsonPatch; 14 | using Microsoft.AspNetCore.Mvc; 15 | using Microsoft.AspNetCore.Mvc.ModelBinding; 16 | using Microsoft.Extensions.Configuration; 17 | using Moq; 18 | using Newtonsoft.Json; 19 | using Xunit; 20 | 21 | namespace Application.UnitTests.Services; 22 | 23 | public class UserServiceTests 24 | { 25 | private readonly IMapper _mapper; 26 | private readonly Mock _userRepositoryMock = new(); 27 | private readonly Mock _bookRepositoryMock = new(); 28 | private readonly Mock _userBlobStorageManagerMock = new(); 29 | private readonly Mock _bookBlobStorageManagerMock = new(); 30 | private readonly Mock _emailSenderMock = new(); 31 | private readonly Mock _configurationMock = new(); 32 | private readonly Mock> _userManagerMock = 33 | new(new Mock>().Object, null, null, null, null, null, null, null, null); 34 | private readonly Mock _controllerBaseMock = new(); 35 | private readonly Mock _productRepositoryMock = new(); 36 | private readonly UserService _userService; 37 | 38 | 39 | public UserServiceTests() 40 | { 41 | var mapperConfig = new MapperConfiguration(cfg => 42 | { 43 | cfg.AddProfile(); 44 | }); 45 | 46 | _mapper = new Mapper(mapperConfig); 47 | _userService = new UserService(_userRepositoryMock.Object, 48 | _bookRepositoryMock.Object, 49 | _userBlobStorageManagerMock.Object, 50 | _bookBlobStorageManagerMock.Object, 51 | _mapper, _emailSenderMock.Object, 52 | _configurationMock.Object, 53 | _userManagerMock.Object, 54 | _productRepositoryMock.Object 55 | ); 56 | } 57 | 58 | [Fact] 59 | public async Task AUserService_SucceedsDeletingAUser() 60 | { 61 | // Arrange 62 | const string userEmail = "johnDoe@gmail.com"; 63 | 64 | _userRepositoryMock.Setup(x => x.GetAsync(It.IsAny(), 65 | It.IsAny())) 66 | .ReturnsAsync(new User()); 67 | 68 | 69 | // Act 70 | await _userService.DeleteUserAsync(userEmail); 71 | 72 | // Assert 73 | _userRepositoryMock.Verify(x => x.Delete(It.IsAny()), Times.Once); 74 | _userRepositoryMock.Verify(x => x.SaveChangesAsync(), Times.Once); 75 | } 76 | 77 | [Fact] 78 | public async Task AUserService_SucceedsPatchingAUser() 79 | { 80 | // Arrange 81 | var patchDoc = new JsonPatchDocument(); 82 | patchDoc.Add(x => x.FirstName, "John"); 83 | patchDoc.Add(x => x.LastName, "Doe"); 84 | 85 | _userRepositoryMock.Setup(x => x.GetAsync(It.IsAny(), 86 | It.IsAny())) 87 | .ReturnsAsync(new User()); 88 | 89 | _controllerBaseMock.Setup(x => x.TryValidateModel( 90 | It.IsAny())) 91 | .Returns(true); 92 | 93 | _userRepositoryMock.Setup(x => x.SaveChangesAsync()) 94 | .ReturnsAsync(1); 95 | 96 | 97 | // Act 98 | await _userService.PatchUserAsync("JohnDoe@gmail.com", patchDoc, 99 | _controllerBaseMock.Object); 100 | 101 | // Assert 102 | _userRepositoryMock.Verify(x => x.SaveChangesAsync(), Times.Once); 103 | } 104 | 105 | [Fact] 106 | public async Task AUserService_FailsPatchingAUserIfDataIsIncorrect() 107 | { 108 | // Arrange 109 | var localControllerBaseMock = new Mock(); 110 | localControllerBaseMock.Object.ModelState.AddModelError("key", "fail"); 111 | 112 | _userRepositoryMock.Setup(x => x.GetAsync(It.IsAny(), 113 | It.IsAny())) 114 | .ReturnsAsync(new User()); 115 | 116 | localControllerBaseMock.Setup(x => x.TryValidateModel( 117 | It.IsAny())) 118 | .Returns(true); 119 | 120 | 121 | // Assert 122 | await Assert.ThrowsAsync( 123 | () => _userService.PatchUserAsync("JohnDoe@gmail.com", 124 | new JsonPatchDocument(), 125 | localControllerBaseMock.Object)); 126 | } 127 | } -------------------------------------------------------------------------------- /tests/Application.UnitTests/AuthenticationManagerTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using Application.Common.DTOs.Users; 5 | using Application.Managers; 6 | using Domain.Entities; 7 | using Microsoft.AspNetCore.Identity; 8 | using Microsoft.Extensions.Configuration; 9 | using Moq; 10 | using Xunit; 11 | 12 | namespace Application.UnitTests; 13 | 14 | public class AuthenticationManagerTests 15 | { 16 | private readonly AuthenticationManager _authenticationManager; 17 | private readonly Mock _configurationMock = new(); 18 | private readonly Mock> _userManagerMock = 19 | TestHelpers.MockUserManager(); 20 | 21 | 22 | public AuthenticationManagerTests() 23 | { 24 | _authenticationManager = new AuthenticationManager(_configurationMock.Object, 25 | _userManagerMock.Object); 26 | } 27 | 28 | 29 | [Fact] 30 | public async Task AnAuthenticationManager_SucceedsCheckingIfAUserExists() 31 | { 32 | // Arrange 33 | _userManagerMock.Setup(x => x.FindByEmailAsync(It.IsAny())) 34 | .ReturnsAsync(new User()); 35 | 36 | _userManagerMock.Setup(x => x.CheckPasswordAsync(It.IsAny(), 37 | It.IsAny())) 38 | .ReturnsAsync(true); 39 | 40 | // Act 41 | var result = await _authenticationManager.UserExistsAsync("JohnDoe@gmail.com", 42 | "MyPassword123"); 43 | 44 | // Assert 45 | Assert.True(result); 46 | } 47 | 48 | [Fact] 49 | public async Task AnAuthenticationManager_FailsCheckingIfAUserExistsIfEmailIsWrong() 50 | { 51 | // Arrange 52 | _userManagerMock.Setup(x => x.FindByEmailAsync(It.IsAny())) 53 | .ReturnsAsync(() => null); 54 | 55 | // Act 56 | var result = await _authenticationManager.UserExistsAsync("JohnDoe@gmail.com", 57 | "MyPassword123"); 58 | 59 | // Assert 60 | Assert.False(result); 61 | } 62 | 63 | [Fact] 64 | public async Task 65 | AnAuthenticationManager_FailsCheckingIfAUserExistsIfPasswordIsWrong() 66 | { 67 | // Arrange 68 | _userManagerMock.Setup(x => x.FindByEmailAsync(It.IsAny())) 69 | .ReturnsAsync(new User()); 70 | 71 | _userManagerMock.Setup(x => x.CheckPasswordAsync(It.IsAny(), 72 | It.IsAny())) 73 | .ReturnsAsync(false); 74 | 75 | // Act 76 | var result = await _authenticationManager.UserExistsAsync("JohnDoe@gmail.com", 77 | "MyPassword123"); 78 | 79 | // Assert 80 | Assert.False(result); 81 | } 82 | 83 | [Fact] 84 | public async Task AnAuthenticationManager_SucceedsCheckingIfTheEmailExists() 85 | { 86 | // Arrange 87 | string email = "SomeEmail"; 88 | 89 | _userManagerMock.Setup(x => x.FindByEmailAsync(It.IsAny())) 90 | .ReturnsAsync(new User()); 91 | 92 | // Act 93 | var result = await _authenticationManager.EmailAlreadyExistsAsync(email); 94 | 95 | // Assert 96 | Assert.True(result); 97 | } 98 | 99 | [Fact] 100 | public async Task 101 | AnAuthenticationManager_FailsCheckingIfTheEmailExistsIfEmailDoesNotExist() 102 | { 103 | // Arrange 104 | string email = "SomeEmail"; 105 | 106 | _userManagerMock.Setup(x => x.FindByEmailAsync(It.IsAny())) 107 | .ReturnsAsync(() => null); 108 | 109 | // Act 110 | var result = await _authenticationManager.EmailAlreadyExistsAsync(email); 111 | 112 | // Assert 113 | Assert.False(result); 114 | } 115 | 116 | [Fact] 117 | public async Task AnAuthenticationManager_SucceedsCreatingToken() 118 | { 119 | // Arrange 120 | var user = new User 121 | { 122 | Email = "johnDoe@gmail.com", 123 | UserName = "johnDoe@gmail.com", 124 | AccountCreation = DateTime.Now, 125 | FirstName = "John", 126 | LastName = "Doe" 127 | }; 128 | 129 | var loginDto = new LoginDto 130 | { 131 | Email = "JohnDoe@gmail.com", 132 | Password = "MyPassword123" 133 | }; 134 | 135 | _configurationMock.Setup(x => x[It.IsAny()]) 136 | .Returns("SomeLoooooooooooooooooooooooooooooooooongString12345"); 137 | 138 | _userManagerMock.Setup(x => x.FindByEmailAsync(It.IsNotNull())) 139 | .ReturnsAsync(user); 140 | 141 | _userManagerMock.Setup(x => x.GetRolesAsync(It.IsAny())) 142 | .ReturnsAsync(new List() {"Manager", "User"}); 143 | 144 | 145 | // Act 146 | var result = await _authenticationManager.CreateTokenAsync(loginDto); 147 | 148 | // Assert 149 | Assert.NotEmpty(result); 150 | } 151 | 152 | [Fact] 153 | public async Task AnAuthenticationManager_FailsCreatingTokenIfUserDoesNotExist() 154 | { 155 | // Arrange 156 | _configurationMock.Setup(p => p[It.IsAny()]) 157 | .Returns("SomeString12345"); 158 | 159 | // Assert 160 | await Assert.ThrowsAsync( 161 | () => _authenticationManager.CreateTokenAsync(null)); 162 | } 163 | } -------------------------------------------------------------------------------- /src/Application/Managers/BookLocalStorageManager.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.Exceptions; 2 | using Application.Interfaces.Managers; 3 | using Microsoft.AspNetCore.WebUtilities; 4 | using Microsoft.Net.Http.Headers; 5 | 6 | namespace Application.Managers; 7 | 8 | public class BookLocalStorageManager : IBookBlobStorageManager 9 | { 10 | private readonly string _booksDir; 11 | private readonly string _coversDir; 12 | 13 | public BookLocalStorageManager() 14 | { 15 | string baseDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); 16 | string dataDir = baseDir + "/librum_storage"; 17 | _booksDir = dataDir + "/books"; 18 | _coversDir = _booksDir + "/covers"; 19 | 20 | // create folders 21 | if (!Directory.Exists(dataDir)) 22 | Directory.CreateDirectory(dataDir); 23 | if (!Directory.Exists(_booksDir)) 24 | Directory.CreateDirectory(_booksDir); 25 | if (!Directory.Exists(_coversDir)) 26 | Directory.CreateDirectory(_coversDir); 27 | 28 | Console.WriteLine ("Books are stored in: " + dataDir); 29 | } 30 | 31 | public Task DownloadBookBlob(Guid guid) 32 | { 33 | var filename= _booksDir + "/" + guid; 34 | if (!File.Exists(filename)) 35 | throw new CommonErrorException(400, "File does not exist: " + filename, 0); 36 | 37 | return Task.FromResult(File.OpenRead(filename)); 38 | } 39 | 40 | public async Task UploadBookBlob(Guid guid, MultipartReader reader) 41 | { 42 | var filename= _booksDir + "/" + guid; 43 | if (File.Exists(filename)) 44 | throw new CommonErrorException(400, "File already exists: " + filename, 0); 45 | 46 | Stream dest; 47 | try 48 | { 49 | dest = File.Create(filename); 50 | } 51 | catch (Exception _) 52 | { 53 | throw new CommonErrorException(400, "Can't create file", 0); 54 | } 55 | 56 | var section = await reader.ReadNextSectionAsync(); 57 | while (section != null) 58 | { 59 | var hasContentDispositionHeader = 60 | ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out var contentDisposition); 61 | 62 | if (!hasContentDispositionHeader) 63 | continue; 64 | 65 | if (!HasFileContentDisposition(contentDisposition)) 66 | { 67 | var message = "Missing content disposition header"; 68 | throw new CommonErrorException(400, message, 0); 69 | } 70 | 71 | await section.Body.CopyToAsync(dest); 72 | section = await reader.ReadNextSectionAsync(); 73 | } 74 | 75 | dest.Close(); 76 | File.SetUnixFileMode(filename,UnixFileMode.UserRead | UnixFileMode.UserWrite); 77 | } 78 | 79 | private static bool HasFileContentDisposition( 80 | ContentDispositionHeaderValue contentDisposition) 81 | { 82 | // Content-Disposition: form-data; name="myfile1"; filename="Misc 002.jpg" 83 | return contentDisposition != null && 84 | contentDisposition.DispositionType.Equals("form-data") && 85 | (!string.IsNullOrEmpty(contentDisposition.FileName.Value) || 86 | !string.IsNullOrEmpty(contentDisposition.FileNameStar.Value)); 87 | } 88 | 89 | 90 | public async Task DeleteBookBlob(Guid guid) 91 | { 92 | var path = _booksDir + "/" + guid; 93 | await Task.Run(() => File.Delete(path)); 94 | } 95 | 96 | public async Task ChangeBookCover(Guid guid, MultipartReader reader) 97 | { 98 | var filename=_coversDir + "/" + guid; 99 | Stream dest; 100 | try 101 | { 102 | dest = File.Create (filename); 103 | } 104 | catch (Exception e) 105 | { 106 | if (e is UnauthorizedAccessException) 107 | { 108 | FileAttributes attr = (new FileInfo(filename)).Attributes; 109 | if ((attr & FileAttributes.ReadOnly) > 0) 110 | Console.Write("The file is read-only"); 111 | throw new CommonErrorException(400, "Can't overwrite the book cover file", 0); 112 | } 113 | 114 | Console.WriteLine(e.Message); 115 | throw new CommonErrorException(400, "Can't create file for book cover", 0); 116 | } 117 | 118 | long coverSize = 0; 119 | var section = await reader.ReadNextSectionAsync(); 120 | while (section != null) 121 | { 122 | var hasContentDispositionHeader = 123 | ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out var contentDisposition); 124 | 125 | if (!hasContentDispositionHeader) 126 | continue; 127 | 128 | if (!HasFileContentDisposition(contentDisposition)) 129 | { 130 | var message = "Missing content disposition header"; 131 | throw new CommonErrorException(400, message, 0); 132 | } 133 | 134 | await section.Body.CopyToAsync(dest); 135 | coverSize += section.Body.Length; 136 | 137 | section = await reader.ReadNextSectionAsync(); 138 | } 139 | 140 | dest.Close(); 141 | File.SetUnixFileMode(filename,UnixFileMode.UserRead | UnixFileMode.UserWrite); 142 | return coverSize; 143 | } 144 | 145 | public Task DownloadBookCover(Guid guid) 146 | { 147 | var filename = _coversDir + "/" + guid; 148 | if (!File.Exists(filename)) 149 | throw new CommonErrorException(400, "file not exists "+filename, 0); 150 | 151 | return Task.FromResult(File.OpenRead(filename)); 152 | } 153 | 154 | public async Task DeleteBookCover(Guid guid) 155 | { 156 | var path = _coversDir + "/" + guid; 157 | await Task.Run(() => File.Delete(path)); 158 | } 159 | } -------------------------------------------------------------------------------- /src/Application/BackgroundServices/DeleteBooksOfDowngradedAccounts.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using Application.Interfaces.Repositories; 3 | using Application.Interfaces.Services; 4 | using Domain.Entities; 5 | using Microsoft.EntityFrameworkCore; 6 | using Microsoft.Extensions.DependencyInjection; 7 | using Microsoft.Extensions.Hosting; 8 | using Microsoft.Extensions.Logging; 9 | 10 | namespace Application.BackgroundServices; 11 | 12 | public class DeleteBooksOfDowngradedAccounts(IServiceProvider serviceProvider) : BackgroundService 13 | { 14 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 15 | { 16 | while (!stoppingToken.IsCancellationRequested) 17 | { 18 | using (var scope = serviceProvider.CreateScope()) 19 | { 20 | var logger = scope.ServiceProvider 21 | .GetService>(); 22 | 23 | var userRepository = scope.ServiceProvider.GetService(); 24 | var downgradedUsers = await userRepository.GetUsersWhoDowngradedMoreThanAWeekAgo(); 25 | 26 | var bookService = scope.ServiceProvider.GetService(); 27 | var bookRepository = scope.ServiceProvider.GetService(); 28 | var productRepository = scope.ServiceProvider.GetService(); 29 | 30 | int deletedBooks = 0; 31 | foreach (var user in downgradedUsers) 32 | { 33 | var product = await productRepository.GetAll().FirstOrDefaultAsync(p => p.ProductId == user.ProductId); 34 | if (product == null) 35 | { 36 | logger.LogWarning($"User with email {user.Email} has an invalid product id"); 37 | continue; 38 | } 39 | 40 | var allowedBookStorage = product.BookStorageLimit; 41 | var usedBookStorage = await bookRepository.GetUsedBookStorage(user.Id); 42 | long difference = usedBookStorage - allowedBookStorage; 43 | if (difference <= 0) 44 | continue; 45 | 46 | var books = await bookRepository.GetAllAsync(user.Id, loadRelationships: true).ToListAsync(); 47 | SortBooksByAddedToLibrary(books); 48 | 49 | // We allow adding one more book as long as the currently used storage is less than the max storage, 50 | // so we need to check if the difference is less than the size of the latest book. 51 | if (difference <= (long)GetBytesFromSizeString(books.First().DocumentSize)) 52 | continue; 53 | 54 | deletedBooks += await DeleteLatestBooks(books, user.Email, difference, bookService); 55 | 56 | // We have already dealt with them, avoid checking them again unless their tier changes. 57 | var trackingUser = await userRepository.GetAsync(user.Email, trackChanges: true); 58 | trackingUser.AccountLastDowngraded = DateTime.MaxValue; 59 | } 60 | 61 | logger.LogWarning($"Deleted a total of {deletedBooks} books from downgraded users."); 62 | } 63 | 64 | // Repeat the check every 12 hours. 65 | await Task.Delay(TimeSpan.FromHours(12), stoppingToken); 66 | } 67 | } 68 | 69 | private void SortBooksByAddedToLibrary(List books) 70 | { 71 | var ci = new CultureInfo("de-DE"); 72 | books.Sort((b1, b2) => 73 | { 74 | var b1Dt = DateTime.ParseExact(b1.AddedToLibrary, "HH:mm:ss - dd.MM.yyyy", ci); 75 | var b2Dt = DateTime.ParseExact(b2.AddedToLibrary, "HH:mm:ss - dd.MM.yyyy", ci); 76 | return b1Dt.CompareTo(b2Dt); 77 | }); 78 | 79 | books.Reverse(); 80 | } 81 | 82 | private async Task DeleteLatestBooks(List books, string email, long difference, IBookService bookService) 83 | { 84 | List booksToDelete = new(); 85 | while (difference - (long)GetBytesFromSizeString(books.First().DocumentSize) > 0) 86 | { 87 | var book = books.First(); 88 | booksToDelete.Add(book.BookId); 89 | difference -= (long)GetBytesFromSizeString(book.DocumentSize); 90 | books.Remove(book); 91 | } 92 | 93 | await bookService.DeleteBooksAsync(email, booksToDelete); 94 | return booksToDelete.Count; 95 | } 96 | 97 | private double GetBytesFromSizeString(string size) 98 | { 99 | size = size.Replace(" ", string.Empty); 100 | size = size.Replace(",", "."); 101 | 102 | int typeBegining = -1; 103 | for (int i = 0; i < size.Length; i++) 104 | { 105 | if (!char.IsDigit(size[i]) && size[i] != '.') 106 | { 107 | typeBegining = i; 108 | break; 109 | } 110 | } 111 | 112 | var numberString = size.Substring(0, typeBegining); 113 | var provider = new System.Globalization.NumberFormatInfo(); 114 | provider.NumberDecimalSeparator = "."; 115 | provider.NumberGroupSeparator = ","; 116 | var numbers = Convert.ToDouble(numberString,provider); 117 | 118 | var type = size[typeBegining..]; 119 | return type.ToLower() switch 120 | { 121 | "b" => numbers, 122 | "kb" => numbers * 1000, 123 | "mb" => numbers * 1000 * 1000, 124 | "gb" => numbers * 1000 * 1000 125 | }; 126 | } 127 | } -------------------------------------------------------------------------------- /src/Application/Services/ProductService.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.DTOs.Product; 2 | using Application.Common.Exceptions; 3 | using Application.Interfaces.Repositories; 4 | using Application.Interfaces.Services; 5 | using AutoMapper; 6 | using Domain.Entities; 7 | using Microsoft.EntityFrameworkCore; 8 | 9 | namespace Application.Services; 10 | 11 | public class ProductService(IMapper mapper, IProductRepository productRepository) : IProductService 12 | { 13 | private IMapper Mapper { get; } = mapper; 14 | private IProductRepository ProductRepository { get; } = productRepository; 15 | 16 | public async Task> GetAllProductsAsync() 17 | { 18 | var products = await productRepository.GetAll().ToListAsync(); 19 | return products.Select(product => Mapper.Map(product)); 20 | } 21 | 22 | public async Task CreateProductAsync(ProductInDto productInDto) 23 | { 24 | var product = Mapper.Map(productInDto); 25 | foreach(var feature in productInDto.Features) 26 | { 27 | product.Features.Add(new ProductFeature 28 | { 29 | Name = feature 30 | }); 31 | } 32 | productRepository.CreateProduct(product); 33 | 34 | await productRepository.SaveChangesAsync(); 35 | } 36 | 37 | public async Task UpdateProductAsync(ProductForUpdateDto productUpdateDto) 38 | { 39 | var product = await productRepository.GetByIdAsync(productUpdateDto.Id); 40 | 41 | // The update gets sent at the same time as the product is created, we need to wait for the product to be created 42 | int tries = 0; 43 | while(product == null && tries < 3) 44 | { 45 | await Task.Delay(200); 46 | product = await productRepository.GetByIdAsync(productUpdateDto.Id); 47 | tries++; 48 | } 49 | 50 | if (product == null) 51 | { 52 | const string message = "No product with this id exists"; 53 | throw new CommonErrorException(404, message, 0); 54 | } 55 | 56 | bool hasChanged = false; 57 | var dtoProperties = productUpdateDto.GetType().GetProperties(); 58 | foreach (var dtoProperty in dtoProperties) 59 | { 60 | // Manually handle certain properties 61 | switch (dtoProperty.Name) 62 | { 63 | case "Id": 64 | continue; // Can't modify the GUID 65 | case "Features": 66 | { 67 | // Remove all existing features 68 | product.Features.Clear(); 69 | 70 | // Add all new features 71 | foreach (var feature in productUpdateDto.Features) 72 | { 73 | product.Features.Add(new ProductFeature 74 | { 75 | Name = feature 76 | }); 77 | } 78 | 79 | hasChanged = true; 80 | continue; 81 | } 82 | } 83 | 84 | var productProperty = product.GetType().GetProperty(dtoProperty.Name); 85 | var productValue = productProperty.GetValue(product); 86 | var dtoValue = dtoProperty.GetValue(productUpdateDto); 87 | if (productValue == dtoValue) 88 | continue; 89 | 90 | // Handle this after the check if the values are the same 91 | if (dtoProperty.Name == "Price") 92 | { 93 | if (productUpdateDto.Price == 0) 94 | continue; 95 | 96 | product.Price = productUpdateDto.Price; 97 | } 98 | 99 | // Update any other property via reflection 100 | var value = dtoProperty.GetValue(productUpdateDto); 101 | SetPropertyOnProduct(product, dtoProperty.Name, value); 102 | hasChanged = true; 103 | } 104 | 105 | if(hasChanged) 106 | await ProductRepository.SaveChangesAsync(); 107 | } 108 | 109 | private void SetPropertyOnProduct(Product product, string property, object value) 110 | { 111 | var bookProperty = product.GetType().GetProperty(property); 112 | if (bookProperty == null) 113 | { 114 | var message = "Product has no property called: " + property; 115 | throw new CommonErrorException(400, message, 0); 116 | } 117 | 118 | bookProperty.SetValue(product, value); 119 | } 120 | 121 | public async Task DeleteProductAsync(string id) 122 | { 123 | var product = await productRepository.GetByIdAsync(id); 124 | if (product == null) 125 | { 126 | const string message = "No product with this id exists"; 127 | throw new CommonErrorException(404, message, 0); 128 | } 129 | 130 | productRepository.DeleteProduct(product); 131 | await productRepository.SaveChangesAsync(); 132 | } 133 | 134 | public async Task AddPriceToProductAsync(string id, string priceId, int price) 135 | { 136 | var product = await productRepository.GetByIdAsync(id); 137 | 138 | // The price gets sent at the same time as the product is created, we need to wait for the product to be created 139 | int tries = 0; 140 | while(product == null && tries < 3) 141 | { 142 | await Task.Delay(300); 143 | product = await productRepository.GetByIdAsync(id); 144 | tries++; 145 | } 146 | 147 | if (product == null) 148 | { 149 | const string message = "No product with this id exists"; 150 | throw new CommonErrorException(404, message, 0); 151 | } 152 | 153 | product.Price = price; 154 | product.PriceId = priceId; 155 | await productRepository.SaveChangesAsync(); 156 | } 157 | } -------------------------------------------------------------------------------- /self-hosting/self-host-installation.md: -------------------------------------------------------------------------------- 1 | # Librum-Server 2 | The build and deploy process was tested on Ubuntu 22.04. It should work on any other linux distribution, but the commands might need to be adjusted. 3 | 4 |
5 | 6 | ## Dependencies 7 | 8 | You will need `dotnet`, `openssl` and `mariadb-server`. 9 |
10 |
11 | To download dotnet7 follow: https://learn.microsoft.com/en-us/dotnet/core/install/linux (if you run into problems with the dotnet7 installation on ubuntu, this: https://stackoverflow.com/a/77059342 might help). 12 |
13 |
14 | download the other packages via: 15 | ``` 16 | sudo apt install openssl mariadb-server 17 | ``` 18 | to install all dependencies. 19 | 20 |
21 | 22 | ## Build 23 | 24 | To build the server, clone the repository and use `dotnet publish` 25 | 26 | ``` 27 | git clone https://github.com/Librum-Reader/Librum-Server.git 28 | cd Librum-Server 29 | dotnet restore 30 | cd src/Presentation 31 | dotnet publish -c Release -o build --no-restore --verbosity m 32 | 33 | ``` 34 | 35 |
36 | 37 | ## Install 38 | ### Create a `librum-server` group and user 39 | 40 | ``` 41 | sudo groupadd -r -f librum-server 42 | sudo useradd -r -g librum-server -d /var/lib/librum-server --shell /usr/sbin/nologin librum-server 43 | ``` 44 | 45 | ### Install the .service file for systemd 46 | 47 | ``` 48 | cd ../.. 49 | sudo install -d /etc/systemd/system/ 50 | sudo install self-hosting/librum-server.service -m660 /etc/systemd/system/ 51 | ``` 52 | 53 | ### Install the .conf file that contains the environment variables 54 | 55 | ``` 56 | sudo install -d /etc/librum-server/ 57 | sudo install -m660 self-hosting/librum-server.conf /etc/librum-server/ 58 | ``` 59 | 60 | ### Install the server 61 | 62 | ``` 63 | sudo mkdir -p /var/lib/librum-server/srv 64 | sudo cp src/Presentation/build/* /var/lib/librum-server/srv --recursive 65 | sudo chmod --recursive 660 /var/lib/librum-server/ 66 | sudo chmod 770 /var/lib/librum-server 67 | sudo chmod 770 /var/lib/librum-server/srv 68 | sudo install self-hosting/run.sh -m770 /var/lib/librum-server/srv 69 | sudo chown --recursive librum-server /var/lib/librum-server/ 70 | ``` 71 | 72 | ### Install the manpage 73 | 74 | ``` 75 | mkdir -p /usr/share/man/man7 76 | sudo install -m664 self-hosting/librum-server.7 /usr/share/man/man7 77 | ``` 78 | 79 | ### Insall readme 80 | 81 | ``` 82 | sudo install -m664 self-hosting/self-host-installation.md /var/lib/librum-server/srv 83 | ``` 84 | 85 | ### Create the SSL certificate for the server 86 | 87 | ``` 88 | KEYOUT=/var/lib/librum-server/srv/librum-server.key 89 | CRTOUT=/var/lib/librum-server/srv/librum-server.crt 90 | PFXOUT=/var/lib/librum-server/srv/librum-server.pfx 91 | sudo /usr/bin/openssl req -x509 -newkey rsa:4096 -sha256 -days 365 -nodes -keyout $KEYOUT -out $CRTOUT -subj "/CN=librum-server" -extensions v3_ca -extensions v3_req 92 | sudo openssl pkcs12 -export -passout pass: -out $PFXOUT -inkey $KEYOUT -in $CRTOUT 93 | sudo chown librum-server $PFXOUT 94 | ``` 95 | 96 | ### Configure the server ports 97 | 98 | Edit `/var/lib/librum-server/srv/appsettings.json` and change it to look like the following: 99 | 100 | ``` 101 | { 102 | "Kestrel": { 103 | "EndPoints": { 104 | "Http": { 105 | "Url": "http://127.0.0.1:5000" 106 | }, 107 | "Https": { 108 | "Url": "https://127.0.0.1:5001", 109 | "Certificate": { 110 | "Path": "librum-server.pfx" 111 | } 112 | } 113 | } 114 | }, 115 | "Logging": { 116 | "LogLevel": { 117 | "Default": "Warning", 118 | "Microsoft.AspNetCore": "Warning" 119 | } 120 | }, 121 | "AllowedHosts": "*", 122 | "AzureKeyVaultUri": "https://librum-keyvault.vault.azure.net/", 123 | "IpRateLimiting": { 124 | "EnableEndpointRateLimiting": true, 125 | "StackBlockedRequests": false, 126 | "RealIpHeader": "X-Real-IP", 127 | "ClientIdHeader": "X-ClientId", 128 | "HttpStatusCode": 429, 129 | "GeneralRules": [ 130 | { 131 | "Endpoint": "post:/api/register", 132 | "Period": "15m", 133 | "Limit": 6 134 | } 135 | ] 136 | } 137 | } 138 | ``` 139 | 140 |
141 | 142 | ## Run 143 | 144 | ### Install and configure MariaDB 145 | 146 | Edit `/etc/mysql/mariadb.conf.d/50-server.cnf` (called differently on other linux distros e.g. `/etc/my.cnf.d/server.cnf` or `my.cnf`). 147 | 148 | Set `bind-adress` to `127.0.0.1` and if a `skip-networking` section exists, comment it out by adding a `#` infront of it. 149 | 150 | Then restart the mariaDB service: 151 | 152 | ``` 153 | systemctl restart mysqld 154 | ``` 155 | 156 | #### Create Mysql user and password 157 | For example: 158 | 159 | ``` 160 | sudo mysql_secure_installation 161 | 162 | Switch to unix_socket authentication [Y/n] n 163 | Change the root password? [Y/n] y 164 | Remove anonymous users? [Y/n] y 165 | Disallow root login remotely? [Y/n] y 166 | Remove test database and access to it? [Y/n] y 167 | Reload privilege tables now? [Y/n] y 168 | ``` 169 | 170 | ### Run the librum-server 171 | Firstly you must edit `/etc/librum-server/librum-server.conf` and change the variables following the comments above them. 172 | 173 | Then you can run: 174 | 175 | ``` 176 | sudo systemctl daemon-reload 177 | sudo systemctl start librum-server 178 | ``` 179 | 180 | to start the service. 181 | 182 |
183 | 184 | ## Note 185 | - By default the server listens to 5000 (http) and (5001) https. You can chage it in the `/var/lib/librum-server/srv/appsettings.json` file. 186 | - The server stores its files at `/var/librum-server/data_storage` 187 | - Logs are written to `/var/librum-server/srv/Data` 188 | 189 |
190 | 191 | ## Configuration for the client application 192 | 193 | By default the Librum client application is set up to use the official servers. To connect it with your self-hosted server, you will need to edit `~/.config/Librum-Reader/Librum.conf` and set `selfHosted=true` and `serverHost` to your server's url (e.g. `serverHost=https://127.0.0.1:5001`).
194 | If there is no file at `~/.config/Librum-Reader/Librum.conf`, make sure that you have ran the application at least once before for the settings files to be generated. 195 |
196 |
197 | To switch back to the official servers, set `selfHosted=false` and `serverHost=api.librumreader.com` 198 | 199 |
200 | 201 | ## Questions 202 | 203 | If you have any questions or run into problems which you can't solve, feel free to open an issue. 204 | -------------------------------------------------------------------------------- /src/Presentation/Controllers/WebHookController.cs: -------------------------------------------------------------------------------- 1 | using Application.Common.DTOs.Product; 2 | using Application.Interfaces.Services; 3 | using Microsoft.AspNetCore.Mvc; 4 | using Stripe; 5 | 6 | namespace Presentation.Controllers; 7 | 8 | [ApiController] 9 | [Route("webhooks")] 10 | public class WebHookController(IConfiguration configuration, 11 | ILogger logger, 12 | IProductService productService, 13 | IUserService userService) : ControllerBase 14 | { 15 | private ILogger Logger { get; } = logger; 16 | private IUserService UserService { get; } = userService; 17 | private string WebhookSecret { get; } = configuration["StripeWebhookSecret"]; 18 | 19 | [HttpPost("stripe")] 20 | public async Task Stripe() 21 | { 22 | var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync(); 23 | try 24 | { 25 | var stripeEvent = EventUtility.ConstructEvent(json, 26 | Request.Headers["Stripe-Signature"], 27 | WebhookSecret, 28 | 300, 29 | (long)(DateTime.UtcNow.Subtract(new DateTime(1970, 1, 1))).TotalSeconds, 30 | false); 31 | switch (stripeEvent.Type) 32 | { 33 | case Events.ProductCreated: 34 | await CreateProduct(stripeEvent.Data.Object as Product); 35 | break; 36 | case Events.ProductDeleted: 37 | await DeleteProduct(stripeEvent.Data.Object as Product); 38 | break; 39 | case Events.ProductUpdated: 40 | await UpdateProduct(stripeEvent.Data.Object as Product); 41 | break; 42 | case Events.PriceCreated: 43 | case Events.PriceUpdated: 44 | await AddPriceToProduct(stripeEvent.Data.Object as Price); 45 | break; 46 | case Events.CustomerCreated: 47 | await AddCustomerIdToUser(stripeEvent.Data.Object as Customer); 48 | break; 49 | case Events.CustomerSubscriptionCreated: 50 | await AddTierToCustomer(stripeEvent.Data.Object as Subscription); 51 | break; 52 | case Events.CustomerSubscriptionUpdated: 53 | await UpdateSubscription(stripeEvent.Data.Object as Subscription); 54 | break; 55 | case Events.CustomerSubscriptionDeleted: 56 | case Events.CustomerSubscriptionPendingUpdateExpired: 57 | await RemoveTierFromCustomer(stripeEvent.Data.Object as Subscription); 58 | break; 59 | default: 60 | const string message = "Unhandled Stripe Event Type"; 61 | Logger.LogWarning("Unhandled Stripe Event Type"); 62 | return StatusCode(500, message); 63 | } 64 | 65 | return Ok(); 66 | } 67 | catch (StripeException e) 68 | { 69 | return BadRequest(); 70 | } 71 | } 72 | 73 | private async Task UpdateSubscription(Subscription subscription) 74 | { 75 | if (subscription.Status == "incomplete_expired") 76 | { 77 | await RemoveTierFromCustomer(subscription); 78 | } 79 | else 80 | { 81 | await AddTierToCustomer(subscription); 82 | } 83 | } 84 | 85 | private async Task AddTierToCustomer(Subscription subscription) 86 | { 87 | var customerId = subscription.CustomerId; 88 | var productId = subscription.Items.Data[0].Price.ProductId; 89 | 90 | await UserService.AddTierToUser(customerId, productId); 91 | } 92 | 93 | private async Task RemoveTierFromCustomer(Subscription subscription) 94 | { 95 | var customerId = subscription.CustomerId; 96 | 97 | await UserService.ResetUserToFreeTier(customerId); 98 | } 99 | 100 | private async Task AddCustomerIdToUser(Customer customer) 101 | { 102 | var email = customer.Email; 103 | var customerId = customer.Id; 104 | 105 | await UserService.AddCustomerIdToUser(email, customerId); 106 | } 107 | 108 | private async Task UpdateProduct(Product product) 109 | { 110 | ProductForUpdateDto productUpdateDto = new() 111 | { 112 | Id = product.Id, 113 | Name = product.Name, 114 | Description = product.Description, 115 | BookStorageLimit = long.Parse(product.Metadata["bookStorageLimit"]), 116 | AiRequestLimit = int.Parse(product.Metadata["aiRequestLimit"]), 117 | TranslationsLimit = int.Parse(product.Metadata["translationsLimit"]) 118 | }; 119 | foreach(var feature in product.Features) 120 | productUpdateDto.Features.Add(feature.Name); 121 | 122 | await productService.UpdateProductAsync(productUpdateDto); 123 | } 124 | 125 | private async Task DeleteProduct(Product product) 126 | { 127 | await productService.DeleteProductAsync(product.Id); 128 | } 129 | 130 | private async Task AddPriceToProduct(Price price) 131 | { 132 | var product = price.ProductId; 133 | var priceId = price.Id; 134 | var amount = (int)price.UnitAmount!.Value; 135 | 136 | await productService.AddPriceToProductAsync(product, priceId, amount); 137 | } 138 | 139 | private async Task CreateProduct(Product product) 140 | { 141 | ProductInDto productInDto = new() 142 | { 143 | Id = product.Id, 144 | Name = product.Name, 145 | Description = product.Description, 146 | BookStorageLimit = long.Parse(product.Metadata["bookStorageLimit"]), 147 | AiRequestLimit = int.Parse(product.Metadata["aiRequestLimit"]), 148 | TranslationsLimit = int.Parse(product.Metadata["translationsLimit"]), 149 | LiveMode = product.Livemode 150 | }; 151 | foreach(var feature in product.Features) 152 | productInDto.Features.Add(feature.Name); 153 | 154 | await productService.CreateProductAsync(productInDto); 155 | } 156 | } -------------------------------------------------------------------------------- /src/Application/Utility/EmailSender.cs: -------------------------------------------------------------------------------- 1 | using Application.Interfaces.Managers; 2 | using Application.Interfaces.Utility; 3 | using Domain.Entities; 4 | using MailKit.Net.Smtp; 5 | using Microsoft.AspNetCore.Http; 6 | using Microsoft.AspNetCore.Mvc; 7 | using Microsoft.Extensions.Configuration; 8 | using Microsoft.IdentityModel.Tokens; 9 | using MimeKit; 10 | 11 | namespace Application.Utility; 12 | 13 | public class EmailSender : IEmailSender 14 | { 15 | private readonly IUrlHelper _urlHelper; 16 | private readonly IHttpContextAccessor _httpContextAccessor; 17 | private readonly IConfiguration _configuration; 18 | 19 | public EmailSender(IAuthenticationManager authenticationManager, 20 | IUrlHelper urlHelper, 21 | IHttpContextAccessor httpContextAccessor, 22 | IConfiguration configuration) 23 | { 24 | _urlHelper = urlHelper; 25 | _httpContextAccessor = httpContextAccessor; 26 | _configuration = configuration; 27 | } 28 | 29 | public async Task SendEmailConfirmationEmail(User user, string token) 30 | { 31 | var confirmationLink = GetEmailConfirmationLink(user, token); 32 | 33 | var message = new MimeMessage(); 34 | if (_configuration["LIBRUM_SELFHOSTED"] != "true") 35 | { 36 | message.From.Add (new MailboxAddress ("Librum", "noreply@librumreader.com")); 37 | } 38 | else 39 | { 40 | var messFrom = _configuration["SMTPMailFrom"]; 41 | message.From.Add (new MailboxAddress ("Librum", messFrom)); 42 | } 43 | 44 | // Legacy - to be removed 45 | var userName = user.Name.IsNullOrEmpty() ? user.FirstName : user.Name; 46 | message.To.Add (new MailboxAddress (userName, user.Email)); 47 | message.Subject = "Confirm Your Email"; 48 | 49 | message.Body = new TextPart ("plain") { 50 | Text = $"Hello { userName },\n\nThank you for choosing Librum! " + 51 | "We are happy to tell you, that your account has successfully been created. " + 52 | "The final step remaining is to confirm it, and you're all set to go.\n" + 53 | $"To confirm your email, please click the link below:\n{confirmationLink}\n\n" + 54 | "If you didn't request this email, just ignore it." 55 | }; 56 | 57 | await SendEmail(message); 58 | } 59 | 60 | public async Task SendPasswordResetEmail(User user, string token) 61 | { 62 | // Go to librumreader.com if not self-hosted 63 | var resetLink = $"https://librumreader.com/resetPassword?email={user.Email}&token={token}"; 64 | 65 | // if self-hosted, change the resetlink 66 | if (_configuration["LIBRUM_SELFHOSTED"] == "true") 67 | { 68 | var domain = _configuration["CleanUrl"]; 69 | var encodedToken=System.Web.HttpUtility.HtmlEncode(token); 70 | resetLink = $"{domain}/user/resetPassword?email={user.Email}&token={encodedToken}"; 71 | } 72 | 73 | var message = new MimeMessage(); 74 | if (_configuration["LIBRUM_SELFHOSTED"] != "true") 75 | { 76 | message.From.Add (new MailboxAddress ("Librum", "noreply@librumreader.com")); 77 | } 78 | else 79 | { 80 | var messFrom = _configuration["SMTPMailFrom"]; 81 | message.From.Add (new MailboxAddress ("Librum",messFrom)); 82 | } 83 | 84 | // Legacy - to be removed 85 | var userName = user.Name.IsNullOrEmpty() ? user.FirstName : user.Name; 86 | message.To.Add (new MailboxAddress (userName, user.Email)); 87 | message.Subject = "Reset Your Password"; 88 | 89 | message.Body = new TextPart ("plain") { 90 | Text = $"Hello { userName },\n\nYou can find the link to reset your password below. " + 91 | "Follow the link and continue the password reset on our website.\n" + 92 | $"{resetLink}\n\n" + 93 | "If you didn't request this email, just ignore it." 94 | }; 95 | 96 | await SendEmail(message); 97 | } 98 | 99 | public async Task SendDowngradeWarningEmail(User user) 100 | { 101 | var message = new MimeMessage(); 102 | message.From.Add (new MailboxAddress ("Librum", "noreply@librumreader.com")); 103 | 104 | // Legacy - to be removed 105 | var userName = user.Name.IsNullOrEmpty() ? user.FirstName : user.Name; 106 | message.To.Add (new MailboxAddress (userName, user.Email)); 107 | message.Subject = "Your books may be deleted in 7 days! - Take Action"; 108 | 109 | message.Body = new TextPart ("plain") { 110 | Text = $"Hello { userName },\n\nYou have recently downgraded your Account. " + 111 | "Due to the downgrade your online storage was reduced and your current library " + 112 | "size may exceed your new storage limit.\n" + 113 | $"Please reduce your library size if that is the case, otherwise books from your account will automatically be DELETED until " + 114 | "your used storage does not exceed the storage your tier provides anymore.\n\n" + 115 | "You have 7 days to perform this action. If you think that this is a mistake, or you have any " + 116 | "questions, please contact us at: contact@librumreader.com" 117 | }; 118 | 119 | await SendEmail(message); 120 | } 121 | 122 | private string GetEmailConfirmationLink(User user, string token) 123 | { 124 | var endpointLink = _urlHelper.Action("ConfirmEmail", 125 | "Authentication", 126 | new 127 | { 128 | email = user.Email, 129 | token = token 130 | }); 131 | var serverUri = _httpContextAccessor.HttpContext!.Request.Scheme + "://" + 132 | _httpContextAccessor.HttpContext!.Request.Host; 133 | var confirmationLink = serverUri + endpointLink; 134 | 135 | return confirmationLink; 136 | } 137 | 138 | 139 | 140 | private async Task SendEmail(MimeMessage message) 141 | { 142 | using var client = new SmtpClient(); 143 | await client.ConnectAsync(_configuration["SMTPEndpoint"], 465, true); 144 | 145 | await client.AuthenticateAsync(_configuration["SMTPUsername"], 146 | _configuration["SMTPPassword"]); 147 | 148 | await client.SendAsync(message); 149 | await client.DisconnectAsync(true); 150 | } 151 | } --------------------------------------------------------------------------------