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/");