Send To Device Support (#1557)

* Tweaked the logging output

* Started implementing some basic idea for devices

* Updated Email Service with new API routes

* Implemented basic DB structure and some APIs to prep for the UI and flows.

* Added an abstract class to make Unit testing easier.

* Removed dependency we don't need

* Updated the UI to be able to show devices and add new devices. Email field will update the platform if the user hasn't interacted with it already.

* Added ability to delete a device as well

* Basic ability to send files to devices works

* Refactored Action code to pass ActionItem back and allow for dynamic children based on an Observable (api).

Hooked in ability to send a chapter to a device. There is no logic in the FE to validate type.

* Fixed a broken unit test

* Implemented the ability to edit a device

* Code cleanup

* Fixed a bad success message

* Fixed broken unit test from updating mock layer
This commit is contained in:
Joseph Milazzo 2022-09-23 17:41:29 -05:00 committed by GitHub
parent ab0f13ef74
commit 9d7476a367
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
79 changed files with 3026 additions and 157 deletions

View file

@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs.Device;
using API.Extensions;
using API.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
/// <summary>
/// Responsible interacting and creating Devices
/// </summary>
public class DeviceController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly IDeviceService _deviceService;
private readonly IEmailService _emailService;
public DeviceController(IUnitOfWork unitOfWork, IDeviceService deviceService, IEmailService emailService)
{
_unitOfWork = unitOfWork;
_deviceService = deviceService;
_emailService = emailService;
}
[HttpPost("create")]
public async Task<ActionResult> CreateOrUpdateDevice(CreateDeviceDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Devices);
var device = await _deviceService.Create(dto, user);
if (device == null) return BadRequest("There was an error when creating the device");
return Ok();
}
[HttpPost("update")]
public async Task<ActionResult> UpdateDevice(UpdateDeviceDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Devices);
var device = await _deviceService.Update(dto, user);
if (device == null) return BadRequest("There was an error when updating the device");
return Ok();
}
/// <summary>
/// Deletes the device from the user
/// </summary>
/// <param name="deviceId"></param>
/// <returns></returns>
[HttpDelete]
public async Task<ActionResult> DeleteDevice(int deviceId)
{
if (deviceId <= 0) return BadRequest("Not a valid deviceId");
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Devices);
if (await _deviceService.Delete(user, deviceId)) return Ok();
return BadRequest("Could not delete device");
}
[HttpGet]
public async Task<ActionResult<IEnumerable<DeviceDto>>> GetDevices()
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.DeviceRepository.GetDevicesForUserAsync(userId));
}
[HttpPost("send-to")]
public async Task<ActionResult> SendToDevice(SendToDeviceDto dto)
{
if (dto.ChapterId < 0) return BadRequest("ChapterId must be greater than 0");
if (dto.DeviceId < 0) return BadRequest("DeviceId must be greater than 0");
if (await _emailService.IsDefaultEmailService())
return BadRequest("Send to device cannot be used with Kavita's email service. Please configure your own.");
if (await _deviceService.SendTo(dto.ChapterId, dto.DeviceId)) return Ok();
return BadRequest("There was an error sending the file to the device");
}
}

View file

@ -1,7 +1,9 @@
using System.IO;
using System;
using System.IO;
using API.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace API.Controllers;

View file

@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations;
using System.Runtime.InteropServices;
using API.Entities.Enums.Device;
namespace API.DTOs.Device;
public class CreateDeviceDto
{
[Required]
public string Name { get; set; }
/// <summary>
/// Platform of the device. If not know, defaults to "Custom"
/// </summary>
[Required]
public DevicePlatform Platform { get; set; }
[Required]
public string EmailAddress { get; set; }
}

View file

@ -0,0 +1,33 @@
using System;
using API.Entities.Enums.Device;
namespace API.DTOs.Device;
/// <summary>
/// A Device is an entity that can receive data from Kavita (kindle)
/// </summary>
public class DeviceDto
{
/// <summary>
/// The device Id
/// </summary>
public int Id { get; set; }
/// <summary>
/// A name given to this device
/// </summary>
/// <remarks>If this device is web, this will be the browser name</remarks>
/// <example>Pixel 3a, John's Kindle</example>
public string Name { get; set; }
/// <summary>
/// An email address associated with the device (ie Kindle). Will be used with Send to functionality
/// </summary>
public string EmailAddress { get; set; }
/// <summary>
/// Platform (ie) Windows 10
/// </summary>
public DevicePlatform Platform { get; set; }
/// <summary>
/// Last time this device was used to send a file
/// </summary>
public DateTime LastUsed { get; set; }
}

View file

@ -0,0 +1,7 @@
namespace API.DTOs.Device;
public class SendToDeviceDto
{
public int DeviceId { get; set; }
public int ChapterId { get; set; }
}

View file

@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
using API.Entities.Enums.Device;
namespace API.DTOs.Device;
public class UpdateDeviceDto
{
[Required]
public int Id { get; set; }
[Required]
public string Name { get; set; }
/// <summary>
/// Platform of the device. If not know, defaults to "Custom"
/// </summary>
[Required]
public DevicePlatform Platform { get; set; }
[Required]
public string EmailAddress { get; set; }
}

View file

@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace API.DTOs.Email;
public class SendToDto
{
public string DestinationEmail { get; set; }
public IEnumerable<string> FilePaths { get; set; }
}

View file

@ -44,6 +44,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
public DbSet<SiteTheme> SiteTheme { get; set; }
public DbSet<SeriesRelation> SeriesRelation { get; set; }
public DbSet<FolderPath> FolderPath { get; set; }
public DbSet<Device> Device { get; set; }
protected override void OnModelCreating(ModelBuilder builder)

View file

@ -162,7 +162,15 @@ public static class DbFactory
FilePath = filePath,
Format = format,
Pages = pages,
LastModified = File.GetLastWriteTime(filePath) // NOTE: Changed this from DateTime.Now
LastModified = File.GetLastWriteTime(filePath)
};
}
public static Device Device(string name)
{
return new Device()
{
Name = name,
};
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,73 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class DeviceSupport : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_SeriesRelation_Series_TargetSeriesId",
table: "SeriesRelation");
migrationBuilder.CreateTable(
name: "Device",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
IpAddress = table.Column<string>(type: "TEXT", nullable: true),
Name = table.Column<string>(type: "TEXT", nullable: true),
EmailAddress = table.Column<string>(type: "TEXT", nullable: true),
Platform = table.Column<int>(type: "INTEGER", nullable: false),
AppUserId = table.Column<int>(type: "INTEGER", nullable: false),
LastUsed = table.Column<DateTime>(type: "TEXT", nullable: false),
Created = table.Column<DateTime>(type: "TEXT", nullable: false),
LastModified = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Device", x => x.Id);
table.ForeignKey(
name: "FK_Device_AspNetUsers_AppUserId",
column: x => x.AppUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Device_AppUserId",
table: "Device",
column: "AppUserId");
migrationBuilder.AddForeignKey(
name: "FK_SeriesRelation_Series_TargetSeriesId",
table: "SeriesRelation",
column: "TargetSeriesId",
principalTable: "Series",
principalColumn: "Id");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_SeriesRelation_Series_TargetSeriesId",
table: "SeriesRelation");
migrationBuilder.DropTable(
name: "Device");
migrationBuilder.AddForeignKey(
name: "FK_SeriesRelation_Series_TargetSeriesId",
table: "SeriesRelation",
column: "TargetSeriesId",
principalTable: "Series",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}

View file

@ -15,7 +15,7 @@ namespace API.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "6.0.7");
modelBuilder.HasAnnotation("ProductVersion", "6.0.9");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
@ -442,6 +442,43 @@ namespace API.Data.Migrations
b.ToTable("CollectionTag");
});
modelBuilder.Entity("API.Entities.Device", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<string>("EmailAddress")
.HasColumnType("TEXT");
b.Property<string>("IpAddress")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<DateTime>("LastUsed")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("Device");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
{
b.Property<int>("Id")
@ -1262,6 +1299,17 @@ namespace API.Data.Migrations
b.Navigation("Volume");
});
modelBuilder.Entity("API.Entities.Device", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Devices")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
{
b.HasOne("API.Entities.Library", "Library")
@ -1306,7 +1354,7 @@ namespace API.Data.Migrations
b.HasOne("API.Entities.Series", "TargetSeries")
.WithMany("RelationOf")
.HasForeignKey("TargetSeriesId")
.OnDelete(DeleteBehavior.Cascade)
.OnDelete(DeleteBehavior.ClientCascade)
.IsRequired();
b.Navigation("Series");
@ -1551,6 +1599,8 @@ namespace API.Data.Migrations
{
b.Navigation("Bookmarks");
b.Navigation("Devices");
b.Navigation("Progresses");
b.Navigation("Ratings");

View file

@ -0,0 +1,50 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs.Device;
using API.Entities;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories;
public interface IDeviceRepository
{
void Update(Device device);
Task<IEnumerable<DeviceDto>> GetDevicesForUserAsync(int userId);
Task<Device> GetDeviceById(int deviceId);
}
public class DeviceRepository : IDeviceRepository
{
private readonly DataContext _context;
private readonly IMapper _mapper;
public DeviceRepository(DataContext context, IMapper mapper)
{
_context = context;
_mapper = mapper;
}
public void Update(Device device)
{
_context.Entry(device).State = EntityState.Modified;
}
public async Task<IEnumerable<DeviceDto>> GetDevicesForUserAsync(int userId)
{
return await _context.Device
.Where(d => d.AppUserId == userId)
.OrderBy(d => d.LastUsed)
.ProjectTo<DeviceDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<Device> GetDeviceById(int deviceId)
{
return await _context.Device
.Where(d => d.Id == deviceId)
.SingleOrDefaultAsync();
}
}

View file

@ -27,6 +27,7 @@ public enum AppUserIncludes
UserPreferences = 32,
WantToRead = 64,
ReadingListsWithItems = 128,
Devices = 256,
}
@ -194,6 +195,11 @@ public class UserRepository : IUserRepository
query = query.Include(u => u.WantToRead);
}
if (includeFlags.HasFlag(AppUserIncludes.Devices))
{
query = query.Include(u => u.Devices);
}
return query;

View file

@ -24,6 +24,7 @@ public interface IUnitOfWork
ITagRepository TagRepository { get; }
ISiteThemeRepository SiteThemeRepository { get; }
IMangaFileRepository MangaFileRepository { get; }
IDeviceRepository DeviceRepository { get; }
bool Commit();
Task<bool> CommitAsync();
bool HasChanges();
@ -60,6 +61,7 @@ public class UnitOfWork : IUnitOfWork
public ITagRepository TagRepository => new TagRepository(_context, _mapper);
public ISiteThemeRepository SiteThemeRepository => new SiteThemeRepository(_context, _mapper);
public IMangaFileRepository MangaFileRepository => new MangaFileRepository(_context, _mapper);
public IDeviceRepository DeviceRepository => new DeviceRepository(_context, _mapper);
/// <summary>
/// Commits changes to the DB. Completes the open transaction.

View file

@ -16,6 +16,9 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
public ICollection<AppUserProgress> Progresses { get; set; }
public ICollection<AppUserRating> Ratings { get; set; }
public AppUserPreferences UserPreferences { get; set; }
/// <summary>
/// Bookmarks associated with this User
/// </summary>
public ICollection<AppUserBookmark> Bookmarks { get; set; }
/// <summary>
/// Reading lists associated with this user
@ -26,6 +29,10 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
/// </summary>
public ICollection<Series> WantToRead { get; set; }
/// <summary>
/// A list of Devices which allows the user to send files to
/// </summary>
public ICollection<Device> Devices { get; set; }
/// <summary>
/// An API Key to interact with external services, like OPDS
/// </summary>
public string ApiKey { get; set; }

49
API/Entities/Device.cs Normal file
View file

@ -0,0 +1,49 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Net;
using API.Entities.Enums.Device;
using API.Entities.Interfaces;
namespace API.Entities;
/// <summary>
/// A Device is an entity that can receive data from Kavita (kindle)
/// </summary>
public class Device : IEntityDate
{
public int Id { get; set; }
/// <summary>
/// Last Seen IP Address of the device
/// </summary>
public string IpAddress { get; set; }
/// <summary>
/// A name given to this device
/// </summary>
/// <remarks>If this device is web, this will be the browser name</remarks>
/// <example>Pixel 3a, John's Kindle</example>
public string Name { get; set; }
/// <summary>
/// An email address associated with the device (ie Kindle). Will be used with Send to functionality
/// </summary>
public string EmailAddress { get; set; }
/// <summary>
/// Platform (ie) Windows 10
/// </summary>
public DevicePlatform Platform { get; set; }
//public ICollection<string> SupportedExtensions { get; set; } // TODO: This requires some sort of information at mangaFile level (unless i repack)
public int AppUserId { get; set; }
public AppUser AppUser { get; set; }
/// <summary>
/// Last time this device was used to send a file
/// </summary>
public DateTime LastUsed { get; set; }
public DateTime Created { get; set; }
public DateTime LastModified { get; set; }
}

View file

@ -0,0 +1,25 @@
using System.ComponentModel;
namespace API.Entities.Enums.Device;
public enum DevicePlatform
{
[Description("Custom")]
Custom = 0,
/// <summary>
/// PocketBook device, email ends in @pbsync.com
/// </summary>
[Description("PocketBook")]
PocketBook = 1,
/// <summary>
/// Kindle device, email ends in @kindle.com
/// </summary>
[Description("Kindle")]
Kindle = 2,
/// <summary>
/// Kobo device,
/// </summary>
[Description("Kobo")]
Kobo = 3,
}

View file

@ -8,18 +8,18 @@ namespace API.Entities.Metadata;
/// A relation flows between one series and another.
/// Series ---kind---> target
/// </summary>
public class SeriesRelation
public sealed class SeriesRelation
{
public int Id { get; set; }
public RelationKind RelationKind { get; set; }
public virtual Series TargetSeries { get; set; }
public Series TargetSeries { get; set; }
/// <summary>
/// A is Sequel to B. In this example, TargetSeries is A. B will hold the foreign key.
/// </summary>
public int TargetSeriesId { get; set; }
// Relationships
public virtual Series Series { get; set; }
public Series Series { get; set; }
public int SeriesId { get; set; }
}

View file

@ -49,6 +49,7 @@ public static class ApplicationServiceExtensions
services.AddScoped<ISeriesService, SeriesService>();
services.AddScoped<IProcessSeries, ProcessSeries>();
services.AddScoped<IReadingListService, ReadingListService>();
services.AddScoped<IDeviceService, DeviceService>();
services.AddScoped<IScannerService, ScannerService>();
services.AddScoped<IMetadataService, MetadataService>();

View file

@ -2,6 +2,7 @@
using System.Linq;
using API.DTOs;
using API.DTOs.CollectionTags;
using API.DTOs.Device;
using API.DTOs.Metadata;
using API.DTOs.Reader;
using API.DTOs.ReadingLists;
@ -97,11 +98,6 @@ public class AutoMapperProfiles : Profile
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor)));
// CreateMap<SeriesRelation, RelatedSeriesDto>()
// .ForMember(dest => dest.Adaptations,
// opt =>
// opt.MapFrom(src => src.Where(p => p.Role == PersonRole.Writer)))
CreateMap<AppUser, UserDto>();
CreateMap<SiteTheme, SiteThemeDto>();
CreateMap<AppUserPreferences, UserPreferencesDto>()
@ -144,5 +140,7 @@ public class AutoMapperProfiles : Profile
CreateMap<IEnumerable<ServerSetting>, ServerSettingDto>()
.ConvertUsing<ServerSettingConverter>();
CreateMap<Device, DeviceDto>();
}
}

View file

@ -13,7 +13,7 @@ public interface ICacheHelper
bool CoverImageExists(string path);
bool HasFileNotChangedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile firstFile);
bool IsFileUnmodifiedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile firstFile);
bool HasFileChangedSinceLastScan(DateTime lastScan, bool forceUpdate, MangaFile firstFile);
}
@ -49,13 +49,13 @@ public class CacheHelper : ICacheHelper
}
/// <summary>
/// Has the file been modified since last scan or is user forcing an update
/// Has the file been not been modified since last scan or is user forcing an update
/// </summary>
/// <param name="chapter"></param>
/// <param name="forceUpdate"></param>
/// <param name="firstFile"></param>
/// <returns></returns>
public bool HasFileNotChangedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile firstFile)
public bool IsFileUnmodifiedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile firstFile)
{
return firstFile != null &&
(!forceUpdate &&

View file

@ -117,7 +117,7 @@ public class Program
Log.Fatal(ex, "Host terminated unexpectedly");
} finally
{
Log.CloseAndFlush();
await Log.CloseAndFlushAsync();
}
}

View file

@ -0,0 +1,123 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.DTOs.Device;
using API.DTOs.Email;
using API.Entities;
using API.Entities.Enums;
using Kavita.Common;
using Microsoft.Extensions.Logging;
namespace API.Services;
public interface IDeviceService
{
Task<Device> Create(CreateDeviceDto dto, AppUser userWithDevices);
Task<Device> Update(UpdateDeviceDto dto, AppUser userWithDevices);
Task<bool> Delete(AppUser userWithDevices, int deviceId);
Task<bool> SendTo(int chapterId, int deviceId);
}
public class DeviceService : IDeviceService
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<DeviceService> _logger;
private readonly IEmailService _emailService;
public DeviceService(IUnitOfWork unitOfWork, ILogger<DeviceService> logger, IEmailService emailService)
{
_unitOfWork = unitOfWork;
_logger = logger;
_emailService = emailService;
}
#nullable enable
public async Task<Device?> Create(CreateDeviceDto dto, AppUser userWithDevices)
{
try
{
userWithDevices.Devices ??= new List<Device>();
var existingDevice = userWithDevices.Devices.SingleOrDefault(d => d.Name.Equals(dto.Name));
if (existingDevice != null) throw new KavitaException("A device with this name already exists");
existingDevice = DbFactory.Device(dto.Name);
existingDevice.Platform = dto.Platform;
existingDevice.EmailAddress = dto.EmailAddress;
userWithDevices.Devices.Add(existingDevice);
_unitOfWork.UserRepository.Update(userWithDevices);
if (!_unitOfWork.HasChanges()) return existingDevice;
if (await _unitOfWork.CommitAsync()) return existingDevice;
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an error when creating your device");
await _unitOfWork.RollbackAsync();
}
return null;
}
public async Task<Device?> Update(UpdateDeviceDto dto, AppUser userWithDevices)
{
try
{
var existingDevice = userWithDevices.Devices.SingleOrDefault(d => d.Id == dto.Id);
if (existingDevice == null) throw new KavitaException("This device doesn't exist yet. Please create first");
existingDevice.Name = dto.Name;
existingDevice.Platform = dto.Platform;
existingDevice.EmailAddress = dto.EmailAddress;
if (!_unitOfWork.HasChanges()) return existingDevice;
if (await _unitOfWork.CommitAsync()) return existingDevice;
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an error when updating your device");
await _unitOfWork.RollbackAsync();
}
return null;
}
#nullable disable
public async Task<bool> Delete(AppUser userWithDevices, int deviceId)
{
try
{
userWithDevices.Devices = userWithDevices.Devices.Where(d => d.Id != deviceId).ToList();
_unitOfWork.UserRepository.Update(userWithDevices);
if (!_unitOfWork.HasChanges()) return true;
if (await _unitOfWork.CommitAsync()) return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an issue with deleting the device, {DeviceId} for user {UserName}", deviceId, userWithDevices.UserName);
}
return false;
}
public async Task<bool> SendTo(int chapterId, int deviceId)
{
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId);
if (files.Any(f => f.Format is not (MangaFormat.Epub or MangaFormat.Pdf)))
throw new KavitaException("Cannot Send non Epub or Pdf to devices as not supported");
var device = await _unitOfWork.DeviceRepository.GetDeviceById(deviceId);
if (device == null) throw new KavitaException("Device doesn't exist");
device.LastUsed = DateTime.Now;
_unitOfWork.DeviceRepository.Update(device);
await _unitOfWork.CommitAsync();
var success = await _emailService.SendFilesToEmail(new SendToDto()
{
DestinationEmail = device.EmailAddress,
FilePaths = files.Select(m => m.FilePath)
});
return success;
}
}

View file

@ -1,6 +1,9 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using API.Data;
using API.DTOs.Email;
@ -11,6 +14,7 @@ using Kavita.Common.EnvironmentInfo;
using Kavita.Common.Helpers;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Net.Http.Headers;
namespace API.Services;
@ -20,23 +24,27 @@ public interface IEmailService
Task<bool> CheckIfAccessible(string host);
Task<bool> SendMigrationEmail(EmailMigrationDto data);
Task<bool> SendPasswordResetEmail(PasswordResetEmailDto data);
Task<bool> SendFilesToEmail(SendToDto data);
Task<EmailTestResultDto> TestConnectivity(string emailUrl);
Task<bool> IsDefaultEmailService();
}
public class EmailService : IEmailService
{
private readonly ILogger<EmailService> _logger;
private readonly IUnitOfWork _unitOfWork;
private readonly IDownloadService _downloadService;
/// <summary>
/// This is used to initially set or reset the ServerSettingKey. Do not access from the code, access via UnitOfWork
/// </summary>
public const string DefaultApiUrl = "https://email.kavitareader.com";
public EmailService(ILogger<EmailService> logger, IUnitOfWork unitOfWork)
public EmailService(ILogger<EmailService> logger, IUnitOfWork unitOfWork, IDownloadService downloadService)
{
_logger = logger;
_unitOfWork = unitOfWork;
_downloadService = downloadService;
FlurlHttp.ConfigureClient(DefaultApiUrl, cli =>
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
@ -58,7 +66,7 @@ public class EmailService : IEmailService
result.Successful = false;
result.ErrorMessage = "This is a local IP address";
}
result.Successful = await SendEmailWithGet(emailUrl + "/api/email/test");
result.Successful = await SendEmailWithGet(emailUrl + "/api/test");
}
catch (KavitaException ex)
{
@ -69,10 +77,16 @@ public class EmailService : IEmailService
return result;
}
public async Task<bool> IsDefaultEmailService()
{
return (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value
.Equals(DefaultApiUrl);
}
public async Task SendConfirmationEmail(ConfirmationEmailDto data)
{
var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value;
var success = await SendEmailWithPost(emailLink + "/api/email/confirm", data);
var success = await SendEmailWithPost(emailLink + "/api/invite/confirm", data);
if (!success)
{
_logger.LogError("There was a critical error sending Confirmation email");
@ -85,7 +99,7 @@ public class EmailService : IEmailService
try
{
if (IsLocalIpAddress(host)) return false;
return await SendEmailWithGet(DefaultApiUrl + "/api/email/reachable?host=" + host);
return await SendEmailWithGet(DefaultApiUrl + "/api/reachable?host=" + host);
}
catch (Exception)
{
@ -96,13 +110,20 @@ public class EmailService : IEmailService
public async Task<bool> SendMigrationEmail(EmailMigrationDto data)
{
var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value;
return await SendEmailWithPost(emailLink + "/api/email/email-migration", data);
return await SendEmailWithPost(emailLink + "/api/invite/email-migration", data);
}
public async Task<bool> SendPasswordResetEmail(PasswordResetEmailDto data)
{
var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value;
return await SendEmailWithPost(emailLink + "/api/email/email-password-reset", data);
return await SendEmailWithPost(emailLink + "/api/invite/email-password-reset", data);
}
public async Task<bool> SendFilesToEmail(SendToDto data)
{
if (await IsDefaultEmailService()) return false;
var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value;
return await SendEmailWithFiles(emailLink + "/api/sendto", data.FilePaths, data.DestinationEmail);
}
private static async Task<bool> SendEmailWithGet(string url, int timeoutSecs = 30)
@ -156,6 +177,41 @@ public class EmailService : IEmailService
return true;
}
private async Task<bool> SendEmailWithFiles(string url, IEnumerable<string> filePaths, string destEmail, int timeoutSecs = 30)
{
try
{
var response = await (url)
.WithHeader("User-Agent", "Kavita")
.WithHeader("x-api-key", "MsnvA2DfQqxSK5jh")
.WithHeader("x-kavita-version", BuildInfo.Version)
.WithTimeout(TimeSpan.FromSeconds(timeoutSecs))
.PostMultipartAsync(mp =>
{
mp.AddString("email", destEmail);
var index = 1;
foreach (var filepath in filePaths)
{
mp.AddFile("file" + index, filepath, _downloadService.GetContentTypeFromFile(filepath));
index++;
}
}
);
if (response.StatusCode != StatusCodes.Status200OK)
{
return false;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an exception when sending Email for SendTo");
return false;
}
return true;
}
private static bool IsLocalIpAddress(string url)
{
var host = url.Split(':')[0];

View file

@ -89,7 +89,7 @@ public class MetadataService : IMetadataService
private void UpdateChapterLastModified(Chapter chapter, bool forceUpdate)
{
var firstFile = chapter.Files.MinBy(x => x.Chapter);
if (firstFile == null || _cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, forceUpdate, firstFile)) return;
if (firstFile == null || _cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, forceUpdate, firstFile)) return;
firstFile.UpdateLastModified();
}

View file

@ -40,8 +40,8 @@ public class ReaderService : IReaderService
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<ReaderService> _logger;
private readonly IEventHub _eventHub;
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
private readonly ChapterSortComparer _chapterSortComparer = ChapterSortComparer.Default;
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = ChapterSortComparerZeroFirst.Default;
private const float MinWordsPerHour = 10260F;
private const float MaxWordsPerHour = 30000F;

View file

@ -83,6 +83,8 @@ public class LibraryWatcher : ILibraryWatcher
watcher.Created += OnCreated;
watcher.Deleted += OnDeleted;
watcher.Error += OnError;
watcher.Disposed += (sender, args) =>
_logger.LogError("[LibraryWatcher] watcher was disposed when it shouldn't have been");
watcher.Filter = "*.*";
watcher.IncludeSubdirectories = true;
@ -108,7 +110,6 @@ public class LibraryWatcher : ILibraryWatcher
fileSystemWatcher.Created -= OnCreated;
fileSystemWatcher.Deleted -= OnDeleted;
fileSystemWatcher.Error -= OnError;
fileSystemWatcher.Dispose();
}
FileWatchers.Clear();
WatcherDictionary.Clear();

View file

@ -458,7 +458,7 @@ public class ProcessSeries : IProcessSeries
foreach (var chapter in volume.Chapters)
{
var firstFile = chapter.Files.MinBy(x => x.Chapter);
if (firstFile == null || _cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false, firstFile)) continue;
if (firstFile == null || _cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, firstFile)) continue;
try
{
var firstChapterInfo = infos.SingleOrDefault(i => i.FullFilePath.Equals(firstFile.FilePath));
@ -583,7 +583,7 @@ public class ProcessSeries : IProcessSeries
{
var firstFile = chapter.Files.MinBy(x => x.Chapter);
if (firstFile == null ||
_cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false, firstFile)) return;
_cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, firstFile)) return;
var comicInfo = info;
if (info == null)

View file

@ -53,6 +53,7 @@ public class Startup
public void ConfigureServices(IServiceCollection services)
{
services.AddApplicationServices(_config, _env);
services.AddControllers(options =>
{
options.CacheProfiles.Add("Images",