diff --git a/self-hosting/librum-server.7 b/self-hosting/librum-server.7 new file mode 100644 index 00000000..04d697c4 --- /dev/null +++ b/self-hosting/librum-server.7 @@ -0,0 +1,89 @@ +.TH librum-server + +.SH NAME +.B librum-server +- the server for the Librum application + + +.SH DESCRIPTION +.B librum-server +is a server running as a service + +.SH INSTALLATION +.TP +After installing the librum-server package +.RS +.B 1. +Install and configure the MariaDb or MySql service +.RS +.LP +.B a) +Edit /etc/mysql/mariadb.conf.d/50-server.cnf to set bind-address=127.0.0.1 and comment out the skip-networking option +.LP +.B b) +Restart MySql server - systemctl restart mysqld +.LP +.B c) +Run mysql and create a user for the mysql database. For example: +ALTER USER 'root'@'localhost' IDENTIFIED BY 'strongPassword123'; +.RE + +.LP +.B 2. +Edit the configuration file at /etc/librum-server/librum-server.conf +You must provide: +.RS +.LP +.B JWTValidIssuer +- Any string for key provider for example "myhomeKeyProvider" +.LP +.B JWTKey +- The secret key for JWT token generation (at least 20 symbols) +.LP +.B AdminEmail +- An admin email for seeding the database with an admin account on the first run +.LP +.B AdminPassword +- A password for the admin account (5 symbols minimum) +.LP +.B DBConnectionString +- The connection string for Mysql (or MariaDB) +for example "Server=127.0.0.1;port=3306;Database=my_database_name;Uid=mysql_user;Pwd=mysql_password;" +.LP +.B SMTPEndpoint +- The smtp server endpoint used for sending emails to confirm your account +.LP +.B SMTPUsername and SMTPPassword +- The username and password for your smtp server +.LP +.B SMTPMailFrom +- It is recommended to set this variable to be exactly the same email that you are using on your mail server +.LP +.B CleanUrl +- A clean url without ports, it will be used to build the "reset password link". +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 +.RE +.LP +.B 3. +Refresh the systemd services by running: systemctl daemon-reload +.LP +.B 4. +Run the server: systemctl start librum-server +.LP +.B 5. +Check status with: systemctl status librum-server +.RE +.LP +.B 6. +Configure your librum-reader app to launch using your server. +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) + +.SH UNINSTALL +.TP +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. + +.SH DIAGNOSTICS +.PP +The activity of server is logged to /var/lib/librum-server/srv/Data/Logs and journalctl. + + diff --git a/self-hosting/librum-server.conf b/self-hosting/librum-server.conf new file mode 100644 index 00000000..52321289 --- /dev/null +++ b/self-hosting/librum-server.conf @@ -0,0 +1,31 @@ +# Valid issuer for JWT Key - string +JWTValidIssuer="exampleIssuer" + +# Secret key for JWT token generation (at least 20 symbols) +JWTKey="exampleOfALongSecretToken" + +# An admin email for seeding the database with an admin account on the first run +AdminEmail="admin@example.com" + +# A password for the admin account (5 symbols minimum) +AdminPassword="strongPassword123" + +# The connection string for Mysql (or MariaDB) +DBConnectionString="Server=127.0.0.1;port=3306;Database=my_database_name;Uid=mysql_user;Pwd=mysql_password;" + +# The smtp server endpoint used for sending emails to confirm your account +SMTPEndpoint="smtp.example.com" + +# The username and password for your smtp server +SMTPUsername="mailuser123" +SMTPPassword="smtpUserPassword123" + +# It is recommended to set this variable to be exactly the same email that you are using on your mail server +SMTPMailFrom="mailuser123@example.com" + +# A clean url without ports, it will be used to build the "reset password link". +# 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 +CleanUrl="https://127.0.0.1" + +# Your OpenAI api token - If left empty, all Ai services will simply be disabled +OpenAIToken="" diff --git a/self-hosting/librum-server.service b/self-hosting/librum-server.service new file mode 100644 index 00000000..40d27bd9 --- /dev/null +++ b/self-hosting/librum-server.service @@ -0,0 +1,19 @@ +[Unit] +Description=Server for the Librum application + +[Service] +WorkingDirectory=/var/lib/librum-server/srv +ExecStart=/var/lib/librum-server/srv/run.sh +User=librum-server +Restart=always +# Restart service after 10 seconds if the dotnet service crashes: +RestartSec=10 +KillSignal=SIGINT +SyslogIdentifier=librum-server +Environment=ASPNETCORE_ENVIRONMENT=Production +Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false +Environment=LIBRUM_SELFHOSTED=true +EnvironmentFile=/etc/librum-server/librum-server.conf + +[Install] +WantedBy=multi-user.target diff --git a/self-hosting/run.sh b/self-hosting/run.sh new file mode 100644 index 00000000..00489a62 --- /dev/null +++ b/self-hosting/run.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +cd /var/lib/librum-server/srv +dotnet Presentation.dll diff --git a/self-hosting/self-host-installation.md b/self-hosting/self-host-installation.md new file mode 100644 index 00000000..e9f87730 --- /dev/null +++ b/self-hosting/self-host-installation.md @@ -0,0 +1,203 @@ +# Librum-Server +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. + +
+ +## Dependencies + +You will need `dotnet`, `openssl` and `mariadb-server`. +
+
+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). +
+
+download the other packages via: +``` +sudo apt install openssl mariadb-server +``` +to install all dependencies. + +
+ +## Build + +To build the server, clone the repository and use `dotnet publish` + +``` +git clone https://github.com/Librum-Reader/Librum-Server.git +cd Librum-Server +dotnet restore +cd src/Presentation +dotnet publish -c Release -o build --no-restore --verbosity m + +``` + +
+ +## Install +### Create a `librum-server` group and user + +``` +groupadd -r -f librum-server +sudo useradd -r -g librum-server -d /var/lib/librum-server --shell /usr/sbin/nologin librum-server +``` + +### Install the .service file for systemd + +``` +cd ../.. +sudo install -d /etc/systemd/system/ +sudo install self-hosting/librum-server.service -m660 /etc/systemd/system/ +``` + +### Install the .conf file that contains the environment variables + +``` +sudo install -d /etc/librum-server/ +sudo install -m660 self-hosting/librum-server.conf /etc/librum-server/ +``` + +### Install the server + +``` +sudo mkdir -p /var/lib/librum-server/srv +sudo cp src/Presentation/build/* /var/lib/librum-server/srv --recursive +sudo chmod --recursive 660 /var/lib/librum-server/ +sudo chmod 770 /var/lib/librum-server +sudo chmod 770 /var/lib/librum-server/srv +sudo install self-hosting/run.sh -m770 /var/lib/librum-server/srv +sudo chown --recursive librum-server /var/lib/librum-server/ +``` + +### Install the manpage + +``` +mkdir -p /usr/share/man/man7 +sudo install -m664 self-hosting/librum-server.7 /usr/share/man/man7 +``` + +### Insall readme + +``` +sudo install -m664 self-hosting/self-host-installation.md /var/lib/librum-server/srv +``` + +### Create the SSL certificate for the server + +``` +KEYOUT=/var/lib/librum-server/srv/librum-server.key +CRTOUT=/var/lib/librum-server/srv/librum-server.crt +PFXOUT=/var/lib/librum-server/srv/librum-server.pfx +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 +sudo openssl pkcs12 -export -passout pass: -out $PFXOUT -inkey $KEYOUT -in $CRTOUT +sudo chown librum-server $PFXOUT +``` + +### Configure the server ports + +Edit `/var/lib/librum-server/srv/appsettings.json` and change it to look like the following: + +``` +{ + "Kestrel": { + "EndPoints": { + "Http": { + "Url": "http://127.0.0.1:5000" + }, + "Https": { + "Url": "https://127.0.0.1:5001", + "Certificate": { + "Path": "librum-server.pfx" + } + } + } + }, + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "AzureKeyVaultUri": "https://librum-keyvault.vault.azure.net/", + "IpRateLimiting": { + "EnableEndpointRateLimiting": true, + "StackBlockedRequests": false, + "RealIpHeader": "X-Real-IP", + "ClientIdHeader": "X-ClientId", + "HttpStatusCode": 429, + "GeneralRules": [ + { + "Endpoint": "post:/api/register", + "Period": "15m", + "Limit": 6 + } + ] + } +} +``` + +
+ +## Run + +### Install and configure MariaDB + +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`). + +Set `bind-adress` to `127.0.0.1` and if a `skip-networking` section exists, comment it out by adding a `#` infront of it. + +Then restart the mariaDB service: + +``` +systemctl restart mysqld +``` + +#### Create Mysql user and password +For example: + +``` +sudo mysql_secure_installation + +Switch to unix_socket authentication [Y/n] n +Change the root password? [Y/n] y +Remove anonymous users? [Y/n] y +Disallow root login remotely? [Y/n] y +Remove test database and access to it? [Y/n] y +Reload privilege tables now? [Y/n] y +``` + +### Run the librum-server +Firstly you must edit `/etc/librum-server/librum-server.conf` and change the variables following the comments above them. + +Then you can run: + +``` +sudo systemctl daemon-reload +sudo systemctl start librum-server +``` + +to start the service. + +
+ +## Note +- 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. +- The server stores its files at `/var/librum-server/data_storage` +- Logs are written to `/var/librum-server/srv/Data` + +
+ +## Configuration for the client application + +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`).
+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. +
+
+To switch back to the official servers, set `selfHosted=false` and `serverHost=api.librumreader.com` + +
+ +## Questions + +If you have any questions or run into problems which you can't solve, feel free to open an issue. diff --git a/src/Application/Managers/BookLocalStorageManager.cs b/src/Application/Managers/BookLocalStorageManager.cs new file mode 100644 index 00000000..6111b760 --- /dev/null +++ b/src/Application/Managers/BookLocalStorageManager.cs @@ -0,0 +1,159 @@ +using Application.Common.Exceptions; +using Application.Interfaces.Managers; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Net.Http.Headers; + +namespace Application.Managers; + +public class BookLocalStorageManager : IBookBlobStorageManager +{ + private readonly string _booksDir; + private readonly string _coversDir; + + public BookLocalStorageManager() + { + string baseDir = Environment.GetFolderPath(Environment.SpecialFolder.Personal); + string dataDir = baseDir + "/librum_storage"; + _booksDir = dataDir + "/books"; + _coversDir = _booksDir + "/covers"; + + // create folders + if (!Directory.Exists(dataDir)) + Directory.CreateDirectory(dataDir); + if (!Directory.Exists(_booksDir)) + Directory.CreateDirectory(_booksDir); + if (!Directory.Exists(_coversDir)) + Directory.CreateDirectory(_coversDir); + + Console.WriteLine ("Books are stored in: " + dataDir); + } + + public Task DownloadBookBlob(Guid guid) + { + var filename= _booksDir + "/" + guid; + if (!File.Exists(filename)) + throw new CommonErrorException(400, "File does not exist: " + filename, 0); + + return Task.FromResult(File.OpenRead(filename)); + } + + public async Task UploadBookBlob(Guid guid, MultipartReader reader) + { + var filename= _booksDir + "/" + guid; + if (File.Exists(filename)) + throw new CommonErrorException(400, "File already exists: " + filename, 0); + + Stream dest; + try + { + dest = File.Create(filename); + } + catch (Exception _) + { + throw new CommonErrorException(400, "Can't create file", 0); + } + + var section = await reader.ReadNextSectionAsync(); + while (section != null) + { + var hasContentDispositionHeader = + ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out var contentDisposition); + + if (!hasContentDispositionHeader) + continue; + + if (!HasFileContentDisposition(contentDisposition)) + { + var message = "Missing content disposition header"; + throw new CommonErrorException(400, message, 0); + } + + await section.Body.CopyToAsync(dest); + section = await reader.ReadNextSectionAsync(); + } + + dest.Close(); + File.SetUnixFileMode(filename,UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + + private static bool HasFileContentDisposition( + ContentDispositionHeaderValue contentDisposition) + { + // Content-Disposition: form-data; name="myfile1"; filename="Misc 002.jpg" + return contentDisposition != null && + contentDisposition.DispositionType.Equals("form-data") && + (!string.IsNullOrEmpty(contentDisposition.FileName.Value) || + !string.IsNullOrEmpty(contentDisposition.FileNameStar.Value)); + } + + + public async Task DeleteBookBlob(Guid guid) + { + var path = _booksDir + "/" + guid; + await Task.Run(() => File.Delete(path)); + } + + public async Task ChangeBookCover(Guid guid, MultipartReader reader) + { + var filename=_coversDir + "/" + guid; + Stream dest; + try + { + dest = File.Create (filename); + } + catch (Exception e) + { + if (e is UnauthorizedAccessException) + { + FileAttributes attr = (new FileInfo(filename)).Attributes; + if ((attr & FileAttributes.ReadOnly) > 0) + Console.Write("The file is read-only"); + throw new CommonErrorException(400, "Can't overwrite the book cover file", 0); + } + + Console.WriteLine(e.Message); + throw new CommonErrorException(400, "Can't create file for book cover", 0); + } + + long coverSize = 0; + var section = await reader.ReadNextSectionAsync(); + while (section != null) + { + var hasContentDispositionHeader = + ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out var contentDisposition); + + if (!hasContentDispositionHeader) + continue; + + if (!HasFileContentDisposition(contentDisposition)) + { + var message = "Missing content disposition header"; + throw new CommonErrorException(400, message, 0); + } + + await section.Body.CopyToAsync(dest); + coverSize += section.Body.Length; + + section = await reader.ReadNextSectionAsync(); + } + + dest.Close(); + File.SetUnixFileMode(filename,UnixFileMode.UserRead | UnixFileMode.UserWrite); + return coverSize; + } + + public Task DownloadBookCover(Guid guid) + { + var filename = _coversDir + "/" + guid; + if (!File.Exists(filename)) + throw new CommonErrorException(400, "file not exists "+filename, 0); + + return Task.FromResult(File.OpenRead(filename)); + } + + public async Task DeleteBookCover(Guid guid) + { + var path = _coversDir + "/" + guid; + await Task.Run(() => File.Delete(path)); + } +} \ No newline at end of file diff --git a/src/Application/Managers/UserLocalStorageManager.cs b/src/Application/Managers/UserLocalStorageManager.cs new file mode 100644 index 00000000..c366f23f --- /dev/null +++ b/src/Application/Managers/UserLocalStorageManager.cs @@ -0,0 +1,96 @@ +using Application.Common.Exceptions; +using Application.Interfaces.Managers; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Net.Http.Headers; + +namespace Application.Managers; + +public class UserLocalStorageManager : IUserBlobStorageManager +{ + private string _profilesDir; + + public UserLocalStorageManager() + { + string baseDir = Environment.GetFolderPath(Environment.SpecialFolder.Personal); + string dataDir = baseDir + "/librum_storage"; + _profilesDir = dataDir + "/profiles"; + + if(!Directory.Exists(dataDir)) + Directory.CreateDirectory(dataDir); + if(!Directory.Exists(_profilesDir)) + Directory.CreateDirectory(_profilesDir); + + Console.WriteLine ("Profile pictures are stored in: " + _profilesDir); + } + + + public Task DownloadProfilePicture(string guid) + { + var filename=_profilesDir + "/" + guid; + if (!File.Exists(filename)) + throw new CommonErrorException(400, "file not exists " + filename, 0); + + return Task.FromResult(File.OpenRead(filename)); + } + + public async Task ChangeProfilePicture(string guid, MultipartReader reader) + { + var filename = _profilesDir + "/" + guid; + Stream dest; + try + { + dest = File.Create (filename); + } + catch (Exception e) + { + if (e is UnauthorizedAccessException) + { + FileAttributes attr = (new FileInfo(filename)).Attributes; + if ((attr & FileAttributes.ReadOnly) > 0) + Console.Write("The file is read-only.Can't overwrite."); + throw new CommonErrorException(400, "Can't overwrite file for picture profile", 0); + } + + Console.WriteLine(e.Message); + throw new CommonErrorException(400, "Failed creating file at: " + filename, 0); + } + + var section = await reader.ReadNextSectionAsync(); + while (section != null) + { + var hasContentDispositionHeader = + ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out var contentDisposition); + + if (!hasContentDispositionHeader) + continue; + + if (!HasFileContentDisposition(contentDisposition)) + { + var message = "Missing content disposition header"; + throw new CommonErrorException(400, message, 0); + } + + await section.Body.CopyToAsync(dest); + section = await reader.ReadNextSectionAsync(); + } + + dest.Close(); + File.SetUnixFileMode(filename,UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + + private static bool HasFileContentDisposition( + ContentDispositionHeaderValue contentDisposition) + { + // Content-Disposition: form-data; name="myfile1"; filename="Misc 002.jpg" + return contentDisposition != null && + contentDisposition.DispositionType.Equals("form-data") && + (!string.IsNullOrEmpty(contentDisposition.FileName.Value) || + !string.IsNullOrEmpty(contentDisposition.FileNameStar.Value)); + } + + public async Task DeleteProfilePicture(string guid) + { + var path = _profilesDir + "/" + guid; + await Task.Run(() => File.Delete(path)); + } +} \ No newline at end of file diff --git a/src/Application/Services/AIService.cs b/src/Application/Services/AIService.cs index e6d21b58..e56572a1 100644 --- a/src/Application/Services/AIService.cs +++ b/src/Application/Services/AIService.cs @@ -34,6 +34,13 @@ public AiService(ILogger logger, IHttpClientFactory httpClientFactory public async Task ExplainAsync(string email, HttpContext context, string text, string mode) { + // The OpenAIToken needs to be provided by the user when the server is self-hosted + if (_configuration["LIBRUM_SELFHOSTED"] == "true" && String.IsNullOrEmpty(_configuration["OpenAIToken"])) + { + throw new CommonErrorException(403, "Ai explanation is unavailable when selfhosting Librum. " + + "You will need to provide an OpenAI Token yourself.", 20); + } + var user = await _userRepository.GetAsync(email, trackChanges: true); if(user.AiExplanationRequestsMadeToday >= 10) { diff --git a/src/Application/Services/BookService.cs b/src/Application/Services/BookService.cs index 7403c1b4..2be6bdcc 100644 --- a/src/Application/Services/BookService.cs +++ b/src/Application/Services/BookService.cs @@ -2,6 +2,7 @@ using Application.Common.DTOs.Bookmarks; using Application.Common.DTOs.Books; using Application.Common.DTOs.Tags; +using Application.Common.DTOs.Highlights; using Application.Common.Exceptions; using Application.Interfaces.Managers; using Application.Interfaces.Repositories; @@ -155,17 +156,8 @@ public async Task UpdateBookAsync(string email, BookForUpdateDto bookUpdateDto) MergeTags(bookUpdateDto.Tags, book, user); continue; case "Highlights": - { - Collection newHighlights = new(); - foreach (var highlightInDto in bookUpdateDto.Highlights) - { - var highlight = _mapper.Map(highlightInDto); - newHighlights.Add(highlight); - } - book.Highlights = newHighlights; - - continue; - } + MergeHighlights(bookUpdateDto.Highlights, book); + continue; case "Bookmarks": MergeBookmarks(bookUpdateDto.Bookmarks, book); continue; @@ -347,21 +339,58 @@ private void RemoveBookTagsWhichDontExistInNewTags(Book book, foreach (var tag in tagsToRemove) { book.Tags.Remove(tag); } } + private void MergeHighlights(ICollection newHighlights, Book book) + { + RemoveHighlightsWhichDontExistInNewHighlights(book, newHighlights); + + foreach (var newHighlight in newHighlights) + { + // If book already has the highlight, update it + var existingHighlight = book.Highlights.SingleOrDefault(h => h.HighlightId == newHighlight.Guid); + if (existingHighlight != null) + { + if (existingHighlight.Color != newHighlight.Color) + existingHighlight.Color = newHighlight.Color; + + continue; + } + + // Else add the highlight to book + var highlight = _mapper.Map(newHighlight); + highlight.HighlightId = highlight.HighlightId; + book.Highlights.Add(highlight); + } + } + + /// When a book is updated, a list of highlights is sent with it. This list of highlights + /// is the "source of truth" and contains all highlights that the book owns. + /// If the database book contains highlights that the updated list of highlights + /// does not contain, those old highlights shall be deleted. + private void RemoveHighlightsWhichDontExistInNewHighlights(Book book, + ICollection newHighlights) + { + var highlightsToRemove = new List(); + foreach (var highlight in book.Highlights) + { + if (newHighlights.All(h => h.Guid != highlight.HighlightId)) + highlightsToRemove.Add(highlight); + } + + foreach (var highlight in highlightsToRemove) { book.Highlights.Remove(highlight); } + } + private void MergeBookmarks(ICollection newBookmarks, Book book) { RemoveBookmarksWhichDontExistInNewBookmarks(book, newBookmarks); foreach (var newBookmark in newBookmarks) { - // If book already has the tag, update it + // If book already has the bookmark, update it var existingBookmark = book.Bookmarks.SingleOrDefault(t => t.BookmarkId == newBookmark.Guid); if (existingBookmark != null) { if (existingBookmark.Name != newBookmark.Name) - { - string prevName = existingBookmark.Name; existingBookmark.Name = newBookmark.Name; - } continue; } @@ -373,7 +402,7 @@ private void MergeBookmarks(ICollection newBookmarks, Book book) } } - /// When a book is updated, a list of bookmarks is sent with it. This list of tags + /// When a book is updated, a list of bookmarks is sent with it. This list of bookmarks /// is the "source of truth" and contains all bookmarks that the book owns. /// If the database book contains bookmarks that the updated list of bookmarks /// does not contain, those old bookmarks shall be deleted. @@ -389,4 +418,4 @@ private void RemoveBookmarksWhichDontExistInNewBookmarks(Book book, foreach (var bookmark in bookmarksToRemove) { book.Bookmarks.Remove(bookmark); } } -} \ No newline at end of file +} diff --git a/src/Application/Utility/EmailSender.cs b/src/Application/Utility/EmailSender.cs index 0ffd94f3..b64d4774 100644 --- a/src/Application/Utility/EmailSender.cs +++ b/src/Application/Utility/EmailSender.cs @@ -30,7 +30,16 @@ public async Task SendEmailConfirmationEmail(User user, string token) var confirmationLink = GetEmailConfirmationLink(user, token); var message = new MimeMessage(); - message.From.Add (new MailboxAddress ("Librum", "noreply@librumreader.com")); + if (_configuration["LIBRUM_SELFHOSTED"] != "true") + { + message.From.Add (new MailboxAddress ("Librum", "noreply@librumreader.com")); + } + else + { + var messFrom = _configuration["SMTPMailFrom"]; + message.From.Add (new MailboxAddress ("Librum", messFrom)); + } + message.To.Add (new MailboxAddress (user.FirstName, user.Email)); message.Subject = "Confirm Your Email"; @@ -47,10 +56,28 @@ public async Task SendEmailConfirmationEmail(User user, string token) public async Task SendPasswordResetEmail(User user, string token) { - var resetLink = $"https://librumreader.com/resetPassword?email={user.Email}&token={token}"; - + // Go to librumreader.com if not self-hosted + var resetLink = $"https://librumreader.com/resetPassword?email={user.Email}&token={token}"; + + // if self-hosted, change the resetlink + if (_configuration["LIBRUM_SELFHOSTED"] == "true") + { + var domain = _configuration["CleanUrl"]; + var encodedToken=System.Web.HttpUtility.HtmlEncode(token); + resetLink = $"{domain}/user/resetPassword?email={user.Email}&token={encodedToken}"; + } + var message = new MimeMessage(); - message.From.Add (new MailboxAddress ("Librum", "noreply@librumreader.com")); + if (_configuration["LIBRUM_SELFHOSTED"] != "true") + { + message.From.Add (new MailboxAddress ("Librum", "noreply@librumreader.com")); + } + else + { + var messFrom = _configuration["SMTPMailFrom"]; + message.From.Add (new MailboxAddress ("Librum",messFrom)); + } + message.To.Add (new MailboxAddress (user.FirstName, user.Email)); message.Subject = "Reset Your Password"; diff --git a/src/Infrastructure/Persistence/Repository/BookRepository.cs b/src/Infrastructure/Persistence/Repository/BookRepository.cs index ae38b372..53fa86a3 100644 --- a/src/Infrastructure/Persistence/Repository/BookRepository.cs +++ b/src/Infrastructure/Persistence/Repository/BookRepository.cs @@ -82,9 +82,12 @@ private double GetBytesFromSizeString(string size) } var numberString = size.Substring(0, typeBegining); - var numbers = double.Parse(numberString); + var provider = new System.Globalization.NumberFormatInfo(); + provider.NumberDecimalSeparator = "."; + provider.NumberGroupSeparator = ","; + var numbers = Convert.ToDouble(numberString,provider); + var type = size[typeBegining..]; - return type.ToLower() switch { "b" => numbers, diff --git a/src/Presentation/Controllers/UserController.cs b/src/Presentation/Controllers/UserController.cs index 9fc03e98..771398bb 100644 --- a/src/Presentation/Controllers/UserController.cs +++ b/src/Presentation/Controllers/UserController.cs @@ -20,8 +20,7 @@ public class UserController : ControllerBase private readonly ILogger _logger; - public UserController(IUserService userService, - ILogger logger) + public UserController(IUserService userService, ILogger logger) { _userService = userService; _logger = logger; @@ -79,7 +78,52 @@ public class PasswordResetModel public string Token { get; set; } public string Password { get; set; } } - + + + // Show a simple form for password changing the password when self-hosted. + // it's necessary because we can't rely on librumreader.com when self-hosting. + [AllowAnonymous] + [HttpGet("resetPassword")] + public ActionResult ResetPasswordPage(string email,string token){ + var content ="Reset Password"+ + "

Reset Password

"+ + "

"+ + "
"+ + "
"+ + "
"; + + return new ContentResult() + { + Content = content, + ContentType = "text/html", + }; + + } + + // To change password when self-hosted - using the data posted from simple html form + [AllowAnonymous] + [HttpPost("resetPasswordLocal")] + public async Task ResetPasswordWithTokenLocal([FromForm] PasswordResetModel model) + { + try + { + // in self hosted version we need to replace spaces with "+" for it to work + var token = System.Web.HttpUtility.HtmlDecode(model.Token.Replace(" ","+")); + await _userService.ChangePasswordWithTokenAsync(model.Email, token, model.Password); + return new ContentResult + { + Content = "password successfully chaged", + ContentType = "text/html", + }; + } + catch (CommonErrorException e) + { + _logger.LogWarning("{ErrorMessage}", e.Message); + return StatusCode(e.Error.Status, e.Error); + } + } + [AllowAnonymous] [HttpPost("resetPassword")] public async Task ResetPasswordWithToken([FromBody] PasswordResetModel model) diff --git a/src/Presentation/DependencyInjection.cs b/src/Presentation/DependencyInjection.cs index 23b44884..856249ea 100644 --- a/src/Presentation/DependencyInjection.cs +++ b/src/Presentation/DependencyInjection.cs @@ -43,13 +43,25 @@ public static IServiceCollection AddApplicationServices( services.AddScoped(); services.AddHostedService(); services.AddHostedService(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(x => new BlobServiceClient( - configuration["AzureBlobStorageConnectionString"])); + + // If not self hosted add the managers that store data to Azure + if (configuration["LIBRUM_SELFHOSTED"] != "true") + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(x => new BlobServiceClient( + configuration["AzureBlobStorageConnectionString"])); + } + // Else use the classes that store the data locally + else + { + services.AddSingleton(); + services.AddSingleton(); + } + services.AddHttpContextAccessor(); services.AddSingleton(); - services.AddScoped(x => + services.AddScoped(x => { var actionContext = x.GetRequiredService().ActionContext; var factory = x.GetRequiredService(); @@ -70,7 +82,17 @@ public static IServiceCollection AddApplicationServices( } options.EnableSensitiveDataLogging(true); - options.UseSqlServer(connectionString); + + // If not self-hosted, use MSSQL + if (configuration["LIBRUM_SELFHOSTED"] != "true"){ + options.UseSqlServer(connectionString); + } + // Else use MySql + else + { + var serverVersion = new MySqlServerVersion(new Version(8, 0, 30)); + options.UseMySql(connectionString, serverVersion); + } }); diff --git a/src/Presentation/Presentation.csproj b/src/Presentation/Presentation.csproj index d641e8cc..bcb810f4 100644 --- a/src/Presentation/Presentation.csproj +++ b/src/Presentation/Presentation.csproj @@ -17,6 +17,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Presentation/Program.cs b/src/Presentation/Program.cs index 730c0e75..447c3947 100644 --- a/src/Presentation/Program.cs +++ b/src/Presentation/Program.cs @@ -48,7 +48,13 @@ using (var scope = app.Services.CreateScope()) { var services = scope.ServiceProvider; - + + // Initialize the local database if self-hosted + if (builder.Configuration["LIBRUM_SELFHOSTED"] == "true"){ + var context = services.GetRequiredService(); + context.Database.EnsureCreated(); + } + // Configure Logging var loggerFactory = services.GetRequiredService(); loggerFactory.AddFile(Directory.GetCurrentDirectory() + "/Data/Logs/");