Some fixes from last release (#1884)

* Removed SecurityEvent middleware solution. It was out of scope originally.

* Fixed manage users still calling pending when the api is no more

* Added back the online indicator on manage users
This commit is contained in:
Joe Milazzo 2023-03-16 19:03:56 -05:00 committed by GitHub
parent 93bd7d7c19
commit d070da2834
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1940 additions and 177 deletions

View File

@ -47,7 +47,6 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
public DbSet<FolderPath> FolderPath { get; set; } = null!; public DbSet<FolderPath> FolderPath { get; set; } = null!;
public DbSet<Device> Device { get; set; } = null!; public DbSet<Device> Device { get; set; } = null!;
public DbSet<ServerStatistics> ServerStatistics { get; set; } = null!; public DbSet<ServerStatistics> ServerStatistics { get; set; } = null!;
public DbSet<SecurityEvent> SecurityEvent { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,40 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class RemoveSecurityEvent : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SecurityEvent");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SecurityEvent",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
CreatedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
IpAddress = table.Column<string>(type: "TEXT", nullable: true),
RequestMethod = table.Column<string>(type: "TEXT", nullable: true),
RequestPath = table.Column<string>(type: "TEXT", nullable: true),
UserAgent = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_SecurityEvent", x => x.Id);
});
}
}
}

View File

@ -944,35 +944,6 @@ namespace API.Data.Migrations
b.ToTable("ReadingListItem"); b.ToTable("ReadingListItem");
}); });
modelBuilder.Entity("API.Entities.SecurityEvent", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("IpAddress")
.HasColumnType("TEXT");
b.Property<string>("RequestMethod")
.HasColumnType("TEXT");
b.Property<string>("RequestPath")
.HasColumnType("TEXT");
b.Property<string>("UserAgent")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("SecurityEvent");
});
modelBuilder.Entity("API.Entities.Series", b => modelBuilder.Entity("API.Entities.Series", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")

View File

@ -1,27 +0,0 @@
using API.Entities;
using AutoMapper;
using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories;
public interface ISecurityEventRepository
{
void Add(SecurityEvent securityEvent);
}
public class SecurityEventRepository : ISecurityEventRepository
{
private readonly DataContext _context;
private readonly IMapper _mapper;
public SecurityEventRepository(DataContext context, IMapper mapper)
{
_context = context;
_mapper = mapper;
}
public void Add(SecurityEvent securityEvent)
{
_context.SecurityEvent.Add(securityEvent);
}
}

View File

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

View File

@ -1,14 +0,0 @@
using System;
namespace API.Entities;
public class SecurityEvent
{
public int Id { get; set; }
public string IpAddress { get; set; }
public string RequestMethod { get; set; }
public string RequestPath { get; set; }
public string UserAgent { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime CreatedAtUtc { get; set; }
}

View File

@ -13,7 +13,6 @@ namespace API.Logging;
public static class LogLevelOptions public static class LogLevelOptions
{ {
public const string LogFile = "config/logs/kavita.log"; public const string LogFile = "config/logs/kavita.log";
public const string SecurityLogFile = "config/logs/security.log";
public const bool LogRollingEnabled = true; public const bool LogRollingEnabled = true;
/// <summary> /// <summary>
/// Controls the Logging Level of the Application /// Controls the Logging Level of the Application
@ -59,7 +58,7 @@ public static class LogLevelOptions
.Filter.ByIncludingOnly(ShouldIncludeLogStatement); .Filter.ByIncludingOnly(ShouldIncludeLogStatement);
} }
private static bool ShouldIncludeLogStatement(LogEvent e) private static bool ShouldIncludeLogStatement(LogEvent e)
{ {
var isRequestLoggingMiddleware = e.Properties.ContainsKey("SourceContext") && var isRequestLoggingMiddleware = e.Properties.ContainsKey("SourceContext") &&
e.Properties["SourceContext"].ToString().Replace("\"", string.Empty) == e.Properties["SourceContext"].ToString().Replace("\"", string.Empty) ==

View File

@ -1,62 +0,0 @@
using System;
using System.IO;
using System.Security.AccessControl;
using System.Threading.Tasks;
using API.Data;
using API.DTOs;
using API.Entities;
using API.Logging;
using API.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Serilog;
using Serilog.Core;
using ILogger = Serilog.ILogger;
namespace API.Middleware;
public class SecurityEventMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger _logger;
public SecurityEventMiddleware(RequestDelegate next)
{
_next = next;
_logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.File(Path.Join(Directory.GetCurrentDirectory(), "config/logs/", "security.log"), rollingInterval: RollingInterval.Day)
.CreateLogger();
}
public async Task InvokeAsync(HttpContext context)
{
var ipAddress = context.Connection.RemoteIpAddress?.ToString();
var requestMethod = context.Request.Method;
var requestPath = context.Request.Path;
var userAgent = context.Request.Headers["User-Agent"];
var securityEvent = new SecurityEvent
{
IpAddress = ipAddress,
RequestMethod = requestMethod,
RequestPath = requestPath,
UserAgent = userAgent,
CreatedAt = DateTime.Now,
CreatedAtUtc = DateTime.UtcNow,
};
using (var scope = context.RequestServices.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<DataContext>();
dbContext.Add(securityEvent);
await dbContext.SaveChangesAsync();
_logger.Debug("Request Processed: {@SecurityEvent}", securityEvent);
}
await _next(context);
}
}

View File

@ -274,7 +274,6 @@ public class Startup
app.UseMiddleware<ExceptionMiddleware>(); app.UseMiddleware<ExceptionMiddleware>();
app.UseMiddleware<SecurityEventMiddleware>();
if (env.IsDevelopment()) if (env.IsDevelopment())
{ {

View File

@ -309,7 +309,7 @@ public static class Configuration
} }
#endregion #endregion
private class AppSettings private sealed class AppSettings
{ {
public string TokenKey { get; set; } public string TokenKey { get; set; }
public int Port { get; set; } public int Port { get; set; }

View File

@ -36,10 +36,6 @@ export class MemberService {
return this.httpClient.get<boolean>(this.baseUrl + 'users/has-reading-progress?libraryId=' + librayId); return this.httpClient.get<boolean>(this.baseUrl + 'users/has-reading-progress?libraryId=' + librayId);
} }
getPendingInvites() {
return this.httpClient.get<Array<Member>>(this.baseUrl + 'users/pending');
}
addSeriesToWantToRead(seriesIds: Array<number>) { addSeriesToWantToRead(seriesIds: Array<number>) {
return this.httpClient.post<Array<Member>>(this.baseUrl + 'want-to-read/add-series', {seriesIds}); return this.httpClient.post<Array<Member>>(this.baseUrl + 'want-to-read/add-series', {seriesIds});
} }

View File

@ -96,7 +96,7 @@ export class MessageHubService {
private hubConnection!: HubConnection; private hubConnection!: HubConnection;
private messagesSource = new ReplaySubject<Message<any>>(1); private messagesSource = new ReplaySubject<Message<any>>(1);
private onlineUsersSource = new BehaviorSubject<number[]>([]); // UserIds private onlineUsersSource = new BehaviorSubject<string[]>([]); // UserNames
/** /**
* Any events that come from the backend * Any events that come from the backend
@ -142,7 +142,7 @@ export class MessageHubService {
.start() .start()
.catch(err => console.error(err)); .catch(err => console.error(err));
this.hubConnection.on(EVENTS.OnlineUsers, (usernames: number[]) => { this.hubConnection.on(EVENTS.OnlineUsers, (usernames: string[]) => {
this.onlineUsersSource.next(usernames); this.onlineUsersSource.next(usernames);
}); });

View File

@ -5,7 +5,7 @@
</div> </div>
<div class="modal-body scrollable-modal"> <div class="modal-body scrollable-modal">
<p> <p>
Invite a user to your server. Enter their email in and we will send them an email to create an account. If you do not want to use our email service, you can <a href="https://wiki.kavitareader.com/en/guides/misc/email" rel="noopener noreferrer" target="_blank" rel="noopener noreferrer">host your own</a> Invite a user to your server. Enter their email in and we will send them an email to create an account. If you do not want to use our email service, you can <a href="https://wiki.kavitareader.com/en/guides/misc/email" rel="noopener noreferrer" target="_blank">host your own</a>
email service or use a fake email (Forgot User will not work). A link will be presented regardless and can be used to setup the account manually. email service or use a fake email (Forgot User will not work). A link will be presented regardless and can be used to setup the account manually.
</p> </p>

View File

@ -10,7 +10,6 @@
<li *ngFor="let member of members; let idx = index;" class="list-group-item no-hover"> <li *ngFor="let member of members; let idx = index;" class="list-group-item no-hover">
<div> <div>
<h4> <h4>
<i class="presence fa fa-circle" title="Active" aria-hidden="true" *ngIf="false && (messageHub.onlineUsers$ | async)?.includes(member.id)"></i>
<span id="member-name--{{idx}}">{{member.username | titlecase}} </span> <span id="member-name--{{idx}}">{{member.username | titlecase}} </span>
<span *ngIf="member.username === loggedInUsername"> <span *ngIf="member.username === loggedInUsername">
<i class="fas fa-star" aria-hidden="true"></i> <i class="fas fa-star" aria-hidden="true"></i>
@ -30,7 +29,7 @@
<div>Last Active: <div>Last Active:
<span *ngIf="member.lastActive === '0001-01-01T00:00:00'; else activeDate">Never</span> <span *ngIf="member.lastActive === '0001-01-01T00:00:00'; else activeDate">Never</span>
<ng-template #activeDate> <ng-template #activeDate>
{{member.lastActive | date: 'short'}} {{member.lastActive | date: 'short'}} <i class="presence fa fa-circle ms-1" title="Online Now" aria-hidden="true" *ngIf="(messageHub.onlineUsers$ | async)?.includes(member.username)"></i>
</ng-template> </ng-template>
</div> </div>
<div *ngIf="!hasAdminRole(member)">Sharing: {{formatLibraries(member)}}</div> <div *ngIf="!hasAdminRole(member)">Sharing: {{formatLibraries(member)}}</div>

View File

@ -1,5 +1,6 @@
.presence { .presence {
font-size: 12px; font-size: 12px;
color: var(--primary-color);
} }
.user-info > div { .user-info > div {

View File

@ -1,9 +1,8 @@
import { Component, OnDestroy, OnInit } from '@angular/core'; import { Component, OnDestroy, OnInit } from '@angular/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { catchError, take } from 'rxjs/operators'; import { take } from 'rxjs/operators';
import { MemberService } from 'src/app/_services/member.service'; import { MemberService } from 'src/app/_services/member.service';
import { Member } from 'src/app/_models/auth/member'; import { Member } from 'src/app/_models/auth/member';
import { User } from 'src/app/_models/user';
import { AccountService } from 'src/app/_services/account.service'; import { AccountService } from 'src/app/_services/account.service';
import { ToastrService } from 'ngx-toastr'; import { ToastrService } from 'ngx-toastr';
import { ResetPasswordModalComponent } from '../_modals/reset-password-modal/reset-password-modal.component'; import { ResetPasswordModalComponent } from '../_modals/reset-password-modal/reset-password-modal.component';
@ -23,7 +22,6 @@ import { Router } from '@angular/router';
export class ManageUsersComponent implements OnInit, OnDestroy { export class ManageUsersComponent implements OnInit, OnDestroy {
members: Member[] = []; members: Member[] = [];
pendingInvites: Member[] = [];
loggedInUsername = ''; loggedInUsername = '';
loadingMembers = false; loadingMembers = false;
@ -47,8 +45,6 @@ export class ManageUsersComponent implements OnInit, OnDestroy {
ngOnInit(): void { ngOnInit(): void {
this.loadMembers(); this.loadMembers();
this.loadPendingInvites();
} }
ngOnDestroy() { ngOnDestroy() {
@ -75,24 +71,6 @@ export class ManageUsersComponent implements OnInit, OnDestroy {
}); });
} }
loadPendingInvites() {
this.pendingInvites = [];
this.memberService.getPendingInvites().subscribe(members => {
this.pendingInvites = members;
// Show logged in user at the top of the list
this.pendingInvites.sort((a: Member, b: Member) => {
if (a.username === this.loggedInUsername) return 1;
if (b.username === this.loggedInUsername) return 1;
const nameA = a.username.toUpperCase();
const nameB = b.username.toUpperCase();
if (nameA < nameB) return -1;
if (nameA > nameB) return 1;
return 0;
})
});
}
canEditMember(member: Member): boolean { canEditMember(member: Member): boolean {
return this.loggedInUsername !== member.username; return this.loggedInUsername !== member.username;
} }
@ -111,7 +89,6 @@ export class ManageUsersComponent implements OnInit, OnDestroy {
this.memberService.deleteMember(member.username).subscribe(() => { this.memberService.deleteMember(member.username).subscribe(() => {
setTimeout(() => { setTimeout(() => {
this.loadMembers(); this.loadMembers();
this.loadPendingInvites();
this.toastr.success(member.username + ' has been deleted.'); this.toastr.success(member.username + ' has been deleted.');
}, 30); // SetTimeout because I've noticed this can run super fast and not give enough time for data to flush }, 30); // SetTimeout because I've noticed this can run super fast and not give enough time for data to flush
}); });
@ -121,10 +98,12 @@ export class ManageUsersComponent implements OnInit, OnDestroy {
inviteUser() { inviteUser() {
const modalRef = this.modalService.open(InviteUserComponent, {size: 'lg'}); const modalRef = this.modalService.open(InviteUserComponent, {size: 'lg'});
modalRef.closed.subscribe((successful: boolean) => { modalRef.closed.subscribe((successful: boolean) => {
this.loadPendingInvites(); this.loadMembers();
}); });
} }
log(o: any) {console.log(o)}
resendEmail(member: Member) { resendEmail(member: Member) {
this.serverService.isServerAccessible().subscribe(canAccess => { this.serverService.isServerAccessible().subscribe(canAccess => {
this.accountService.resendConfirmationEmail(member.id).subscribe(async (email) => { this.accountService.resendConfirmationEmail(member.id).subscribe(async (email) => {

View File

@ -5,9 +5,17 @@
</ng-container> </ng-container>
<ng-template #useLink> <ng-template #useLink>
<a class="side-nav-item" href="javascript:void(0);" [ngClass]="{'closed': (navService.sideNavCollapsed$ | async), 'active': highlighted}" [routerLink]="link"> <ng-container *ngIf="external; else internal">
<ng-container [ngTemplateOutlet]="inner"></ng-container> <a class="side-nav-item" [href]="link" [ngClass]="{'closed': (navService.sideNavCollapsed$ | async), 'active': highlighted}" rel="noopener noreferrer" target="_blank">
</a> <ng-container [ngTemplateOutlet]="inner"></ng-container>
</a>
</ng-container>
<ng-template #internal>
<a class="side-nav-item" href="javascript:void(0);" [ngClass]="{'closed': (navService.sideNavCollapsed$ | async), 'active': highlighted}" [routerLink]="link">
<ng-container [ngTemplateOutlet]="inner"></ng-container>
</a>
</ng-template>
</ng-template> </ng-template>

View File

@ -25,6 +25,10 @@ export class SideNavItemComponent implements OnInit, OnDestroy {
* If a link should be generated when clicked. By default (undefined), no link will be generated * If a link should be generated when clicked. By default (undefined), no link will be generated
*/ */
@Input() link: string | undefined; @Input() link: string | undefined;
/**
* If external, link will be used as full href and rel will be applied
*/
@Input() external: boolean = false;
@Input() comparisonMethod: 'startsWith' | 'equals' = 'equals'; @Input() comparisonMethod: 'startsWith' | 'equals' = 'equals';

View File

@ -17,6 +17,7 @@
</app-side-nav-item> </app-side-nav-item>
<app-side-nav-item icon="fa-bookmark" title="Bookmarks" link="/bookmarks/"></app-side-nav-item> <app-side-nav-item icon="fa-bookmark" title="Bookmarks" link="/bookmarks/"></app-side-nav-item>
<app-side-nav-item icon="fa-regular fa-rectangle-list" title="All Series" link="/all-series/" *ngIf="libraries.length > 0"></app-side-nav-item> <app-side-nav-item icon="fa-regular fa-rectangle-list" title="All Series" link="/all-series/" *ngIf="libraries.length > 0"></app-side-nav-item>
<app-side-nav-item icon="fa-heart" title="Donate" link="https://opencollective.com/kavita" [external]="true"></app-side-nav-item>
<div class="mb-2 mt-3 ms-2 me-2" *ngIf="libraries.length > 10 && (navService?.sideNavCollapsed$ | async) === false"> <div class="mb-2 mt-3 ms-2 me-2" *ngIf="libraries.length > 10 && (navService?.sideNavCollapsed$ | async) === false">
<label for="filter" class="form-label visually-hidden">Filter</label> <label for="filter" class="form-label visually-hidden">Filter</label>
<div class="form-group"> <div class="form-group">

View File

@ -7,7 +7,7 @@
"name": "GPL-3.0", "name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
}, },
"version": "0.7.1.22" "version": "0.7.1.23"
}, },
"servers": [ "servers": [
{ {