1
0
mirror of https://github.com/chylex/Discord-History-Tracker.git synced 2025-09-14 08:32:10 +02:00

1 Commits

Author SHA1 Message Date
b660af4be0 WIP 2023-12-23 19:59:09 +01:00
54 changed files with 752 additions and 660 deletions

View File

@@ -21,7 +21,6 @@
<PackageReference Include="Avalonia.Desktop" Version="11.0.6" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.6" Condition=" '$(Configuration)' == 'Debug' " />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.6" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.6" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.6" />
</ItemGroup>

View File

@@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using System.ComponentModel;
using DHT.Desktop.Common;
using DHT.Server;
using DHT.Server.Data.Filters;
using DHT.Server.Database;
using DHT.Utils.Models;
@@ -48,7 +47,7 @@ sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
public IEnumerable<Unit> Units => AllUnits;
private readonly State state;
private readonly IDatabaseFile db;
private readonly string verb;
private readonly AsyncValueComputer<long> matchingAttachmentCountComputer;
@@ -56,10 +55,10 @@ sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
private long? totalAttachmentCount;
[Obsolete("Designer")]
public AttachmentFilterPanelModel() : this(State.Dummy) {}
public AttachmentFilterPanelModel() : this(DummyDatabaseFile.Instance) {}
public AttachmentFilterPanelModel(State state, string verb = "Matches") {
this.state = state;
public AttachmentFilterPanelModel(IDatabaseFile db, string verb = "Matches") {
this.db = db;
this.verb = verb;
this.matchingAttachmentCountComputer = AsyncValueComputer<long>.WithResultProcessor(SetAttachmentCounts).Build();
@@ -67,11 +66,11 @@ sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
UpdateFilterStatistics();
PropertyChanged += OnPropertyChanged;
state.Db.Statistics.PropertyChanged += OnDbStatisticsChanged;
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
}
public void Dispose() {
state.Db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
}
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
@@ -82,7 +81,7 @@ sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName == nameof(DatabaseStatistics.TotalAttachments)) {
totalAttachmentCount = state.Db.Statistics.TotalAttachments;
totalAttachmentCount = db.Statistics.TotalAttachments;
UpdateFilterStatistics();
}
}
@@ -97,7 +96,7 @@ sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
else {
matchingAttachmentCount = null;
UpdateFilterStatisticsText();
matchingAttachmentCountComputer.Compute(() => state.Db.CountAttachments(filter));
matchingAttachmentCountComputer.Compute(() => db.CountAttachments(filter));
}
}

View File

@@ -8,7 +8,6 @@ using Avalonia.Controls;
using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.CheckBox;
using DHT.Desktop.Dialogs.Message;
using DHT.Server;
using DHT.Server.Data;
using DHT.Server.Data.Filters;
using DHT.Server.Database;
@@ -63,7 +62,7 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
}
public HashSet<ulong> IncludedChannels {
get => includedChannels ?? state.Db.GetAllChannels().Select(static channel => channel.Id).ToHashSet();
get => includedChannels ?? db.GetAllChannels().Select(static channel => channel.Id).ToHashSet();
set => Change(ref includedChannels, value);
}
@@ -73,7 +72,7 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
}
public HashSet<ulong> IncludedUsers {
get => includedUsers ?? state.Db.GetAllUsers().Select(static user => user.Id).ToHashSet();
get => includedUsers ?? db.GetAllUsers().Select(static user => user.Id).ToHashSet();
set => Change(ref includedUsers, value);
}
@@ -92,7 +91,7 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
}
private readonly Window window;
private readonly State state;
private readonly IDatabaseFile db;
private readonly string verb;
private readonly AsyncValueComputer<long> exportedMessageCountComputer;
@@ -100,11 +99,11 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
private long? totalMessageCount;
[Obsolete("Designer")]
public MessageFilterPanelModel() : this(null!, State.Dummy) {}
public MessageFilterPanelModel() : this(null!, DummyDatabaseFile.Instance) {}
public MessageFilterPanelModel(Window window, State state, string verb = "Matches") {
public MessageFilterPanelModel(Window window, IDatabaseFile db, string verb = "Matches") {
this.window = window;
this.state = state;
this.db = db;
this.verb = verb;
this.exportedMessageCountComputer = AsyncValueComputer<long>.WithResultProcessor(SetExportedMessageCount).Build();
@@ -114,11 +113,11 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
UpdateUserFilterLabel();
PropertyChanged += OnPropertyChanged;
state.Db.Statistics.PropertyChanged += OnDbStatisticsChanged;
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
}
public void Dispose() {
state.Db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
}
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
@@ -137,7 +136,7 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName == nameof(DatabaseStatistics.TotalMessages)) {
totalMessageCount = state.Db.Statistics.TotalMessages;
totalMessageCount = db.Statistics.TotalMessages;
UpdateFilterStatistics();
}
else if (e.PropertyName == nameof(DatabaseStatistics.TotalChannels)) {
@@ -158,7 +157,7 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
else {
exportedMessageCount = null;
UpdateFilterStatisticsText();
exportedMessageCountComputer.Compute(() => state.Db.CountMessages(filter));
exportedMessageCountComputer.Compute(() => db.CountMessages(filter));
}
}
@@ -176,11 +175,11 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
}
public async void OpenChannelFilterDialog() {
var servers = state.Db.GetAllServers().ToDictionary(static server => server.Id);
var servers = db.GetAllServers().ToDictionary(static server => server.Id);
var items = new List<CheckBoxItem<ulong>>();
var included = IncludedChannels;
foreach (var channel in state.Db.GetAllChannels()) {
foreach (var channel in db.GetAllChannels()) {
var channelId = channel.Id;
var channelName = channel.Name;
@@ -224,7 +223,7 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
var items = new List<CheckBoxItem<ulong>>();
var included = IncludedUsers;
foreach (var user in state.Db.GetAllUsers()) {
foreach (var user in db.GetAllUsers()) {
var name = user.Name;
var discriminator = user.Discriminator;
@@ -241,13 +240,13 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
}
private void UpdateChannelFilterLabel() {
long total = state.Db.Statistics.TotalChannels;
long total = db.Statistics.TotalChannels;
long included = FilterByChannel ? IncludedChannels.Count : total;
ChannelFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("channel") + ".";
}
private void UpdateUserFilterLabel() {
long total = state.Db.Statistics.TotalUsers;
long total = db.Statistics.TotalUsers;
long included = FilterByUser ? IncludedUsers.Count : total;
UserFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("user") + ".";
}

View File

@@ -1,19 +1,14 @@
using System;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Threading;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Server;
using DHT.Server;
using DHT.Server.Database;
using DHT.Server.Service;
using DHT.Utils.Logging;
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Controls;
sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
private static readonly Log Log = Log.ForType<ServerConfigurationPanelModel>();
private string inputPort;
public string InputPort {
@@ -34,7 +29,7 @@ sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
}
}
public bool HasMadeChanges => ServerConfiguration.Port.ToString() != InputPort || ServerConfiguration.Token != InputToken;
public bool HasMadeChanges => ServerManager.Port.ToString() != InputPort || ServerManager.Token != InputToken;
private bool isToggleServerButtonEnabled = true;
@@ -43,69 +38,59 @@ sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
set => Change(ref isToggleServerButtonEnabled, value);
}
public string ToggleServerButtonText => server.IsRunning ? "Stop Server" : "Start Server";
public string ToggleServerButtonText => serverManager.IsRunning ? "Stop Server" : "Start Server";
public event EventHandler<StatusBarModel.Status>? ServerStatusChanged;
private readonly Window window;
private readonly ServerManager server;
private readonly ServerManager serverManager;
[Obsolete("Designer")]
public ServerConfigurationPanelModel() : this(null!, State.Dummy) {}
public ServerConfigurationPanelModel() : this(null!, new ServerManager(DummyDatabaseFile.Instance)) {}
public ServerConfigurationPanelModel(Window window, State state) {
public ServerConfigurationPanelModel(Window window, ServerManager serverManager) {
this.window = window;
this.server = state.Server;
this.inputPort = ServerConfiguration.Port.ToString();
this.inputToken = ServerConfiguration.Token;
this.serverManager = serverManager;
this.inputPort = ServerManager.Port.ToString();
this.inputToken = ServerManager.Token;
}
server.StatusChanged += OnServerStatusChanged;
public void Initialize() {
ServerLauncher.ServerStatusChanged += ServerLauncherOnServerStatusChanged;
}
public void Dispose() {
server.StatusChanged -= OnServerStatusChanged;
ServerLauncher.ServerStatusChanged -= ServerLauncherOnServerStatusChanged;
}
private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
Dispatcher.UIThread.InvokeAsync(UpdateServerStatus);
}
private void UpdateServerStatus() {
private void ServerLauncherOnServerStatusChanged(object? sender, EventArgs e) {
ServerStatusChanged?.Invoke(this, serverManager.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped);
OnPropertyChanged(nameof(ToggleServerButtonText));
}
private async Task StartServer() {
IsToggleServerButtonEnabled = false;
try {
await server.Start(ServerConfiguration.Port, ServerConfiguration.Token);
} catch (Exception e) {
Log.Error(e);
await Dialog.ShowOk(window, "Internal Server Error", e.Message);
}
UpdateServerStatus();
IsToggleServerButtonEnabled = true;
}
private async Task StopServer() {
private void BeforeServerStart() {
IsToggleServerButtonEnabled = false;
try {
await server.Stop();
} catch (Exception e) {
Log.Error(e);
await Dialog.ShowOk(window, "Internal Server Error", e.Message);
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Starting);
}
UpdateServerStatus();
IsToggleServerButtonEnabled = true;
private void StartServer() {
BeforeServerStart();
serverManager.Launch();
}
public async Task OnClickToggleServerButton() {
if (server.IsRunning) {
await StopServer();
private void StopServer() {
IsToggleServerButtonEnabled = false;
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Stopping);
serverManager.Stop();
}
public void OnClickToggleServerButton() {
if (serverManager.IsRunning) {
StopServer();
}
else {
await StartServer();
StartServer();
}
}
@@ -113,21 +98,19 @@ sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
InputToken = ServerUtils.GenerateRandomToken(20);
}
public async Task OnClickApplyChanges() {
public async void OnClickApplyChanges() {
if (!ushort.TryParse(InputPort, out ushort port)) {
await Dialog.ShowOk(window, "Invalid Port", "Port must be a number between 0 and 65535.");
return;
}
ServerConfiguration.Port = port;
ServerConfiguration.Token = inputToken;
await StartServer();
BeforeServerStart();
serverManager.Relaunch(port, InputToken);
OnPropertyChanged(nameof(HasMadeChanges));
}
public void OnClickCancelChanges() {
InputPort = ServerConfiguration.Port.ToString();
InputToken = ServerConfiguration.Token;
InputPort = ServerManager.Port.ToString();
InputToken = ServerManager.Token;
}
}

View File

@@ -40,22 +40,22 @@
<StackPanel Orientation="Horizontal" Margin="6 3">
<StackPanel Orientation="Vertical" Width="65">
<TextBlock Classes="label">Status</TextBlock>
<TextBlock FontSize="12" Margin="0 3 0 0" Text="{Binding ServerStatusText}" />
<TextBlock FontSize="12" Margin="0 3 0 0" Text="{Binding StatusText}" />
</StackPanel>
<Rectangle />
<StackPanel Orientation="Vertical">
<TextBlock Classes="label">Servers</TextBlock>
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalServers, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalServers, Converter={StaticResource NumberValueConverter}}" />
</StackPanel>
<Rectangle />
<StackPanel Orientation="Vertical">
<TextBlock Classes="label">Channels</TextBlock>
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalChannels, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalChannels, Converter={StaticResource NumberValueConverter}}" />
</StackPanel>
<Rectangle />
<StackPanel Orientation="Vertical">
<TextBlock Classes="label">Messages</TextBlock>
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalMessages, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalMessages, Converter={StaticResource NumberValueConverter}}" />
</StackPanel>
</StackPanel>

View File

@@ -1,46 +1,45 @@
using System;
using Avalonia.Threading;
using DHT.Server;
using DHT.Server.Database;
using DHT.Server.Service;
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Controls;
sealed class StatusBarModel : BaseModel, IDisposable {
sealed class StatusBarModel : BaseModel {
public DatabaseStatistics DatabaseStatistics { get; }
private ServerManager.Status serverStatus;
private Status status = Status.Stopped;
public string ServerStatusText => serverStatus switch {
ServerManager.Status.Starting => "STARTING",
ServerManager.Status.Started => "READY",
ServerManager.Status.Stopping => "STOPPING",
ServerManager.Status.Stopped => "STOPPED",
public Status CurrentStatus {
get => status;
set {
status = value;
OnPropertyChanged(nameof(StatusText));
}
}
public string StatusText {
get {
return CurrentStatus switch {
Status.Starting => "STARTING",
Status.Ready => "READY",
Status.Stopping => "STOPPING",
Status.Stopped => "STOPPED",
_ => ""
};
private readonly State state;
}
}
[Obsolete("Designer")]
public StatusBarModel() : this(State.Dummy) {}
public StatusBarModel() : this(new DatabaseStatistics()) {}
public StatusBarModel(State state) {
this.state = state;
this.DatabaseStatistics = state.Db.Statistics;
state.Server.StatusChanged += OnServerStatusChanged;
serverStatus = state.Server.IsRunning ? ServerManager.Status.Started : ServerManager.Status.Stopped;
public StatusBarModel(DatabaseStatistics databaseStatistics) {
this.DatabaseStatistics = databaseStatistics;
}
public void Dispose() {
state.Server.StatusChanged += OnServerStatusChanged;
}
private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
Dispatcher.UIThread.InvokeAsync(() => {
serverStatus = e;
OnPropertyChanged(nameof(ServerStatusText));
});
public enum Status {
Starting,
Ready,
Stopping,
Stopped
}
}

View File

@@ -11,7 +11,7 @@
Width="800" Height="500"
MinWidth="520" MinHeight="300"
WindowStartupLocation="CenterScreen"
Closing="OnClosing">
Closed="OnClosed">
<Design.DataContext>
<main:MainWindowModel />

View File

@@ -1,18 +1,14 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Threading.Tasks;
using Avalonia.Controls;
using DHT.Desktop.Main.Pages;
using DHT.Utils.Logging;
using JetBrains.Annotations;
namespace DHT.Desktop.Main;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed partial class MainWindow : Window {
private static readonly Log Log = Log.ForType<MainWindow>();
[UsedImplicitly]
public MainWindow() {
InitializeComponent();
@@ -24,24 +20,9 @@ public sealed partial class MainWindow : Window {
DataContext = new MainWindowModel(this, args);
}
public async void OnClosing(object? sender, WindowClosingEventArgs e) {
e.Cancel = true;
Closing -= OnClosing;
try {
await Dispose();
} finally {
Close();
}
}
private async Task Dispose() {
if (DataContext is MainWindowModel model) {
try {
await model.DisposeAsync();
} catch (Exception ex) {
Log.Error("Caught exception while disposing window: " + ex);
}
public void OnClosed(object? sender, EventArgs e) {
if (DataContext is IDisposable disposable) {
disposable.Dispose();
}
foreach (var temporaryFile in ViewerPageModel.TemporaryFiles) {

View File

@@ -7,17 +7,14 @@ using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Main.Screens;
using DHT.Desktop.Server;
using DHT.Server;
using DHT.Utils.Logging;
using DHT.Server.Database;
using DHT.Utils.Models;
namespace DHT.Desktop.Main;
sealed class MainWindowModel : BaseModel, IAsyncDisposable {
sealed class MainWindowModel : BaseModel, IDisposable {
private const string DefaultTitle = "Discord History Tracker";
private static readonly Log Log = Log.ForType<MainWindowModel>();
public string Title { get; private set; } = DefaultTitle;
public UserControl CurrentScreen { get; private set; }
@@ -30,7 +27,7 @@ sealed class MainWindowModel : BaseModel, IAsyncDisposable {
private readonly Window window;
private State? state;
private IDatabaseFile? db;
[Obsolete("Designer")]
public MainWindowModel() : this(null!, Arguments.Empty) {}
@@ -66,11 +63,11 @@ sealed class MainWindowModel : BaseModel, IAsyncDisposable {
}
if (args.ServerPort != null) {
ServerConfiguration.Port = args.ServerPort.Value;
ServerManager.Port = args.ServerPort.Value;
}
if (args.ServerToken != null) {
ServerConfiguration.Token = args.ServerToken;
ServerManager.Token = args.ServerToken;
}
}
@@ -81,29 +78,18 @@ sealed class MainWindowModel : BaseModel, IAsyncDisposable {
mainContentScreenModel.Dispose();
}
if (state != null) {
await state.DisposeAsync();
}
db?.Dispose();
db = welcomeScreenModel.Db;
if (welcomeScreenModel.Db == null) {
state = null;
if (db == null) {
Title = DefaultTitle;
mainContentScreenModel = null;
mainContentScreen = null;
CurrentScreen = welcomeScreen;
}
else {
state = new State(welcomeScreenModel.Db);
try {
await state.Server.Start(ServerConfiguration.Port, ServerConfiguration.Token);
} catch (Exception ex) {
Log.Error(ex);
await Dialog.ShowOk(window, "Internal Server Error", ex.Message);
}
Title = Path.GetFileName(state.Db.Path) + " - " + DefaultTitle;
mainContentScreenModel = new MainContentScreenModel(window, state);
Title = Path.GetFileName(db.Path) + " - " + DefaultTitle;
mainContentScreenModel = new MainContentScreenModel(window, db);
await mainContentScreenModel.Initialize();
mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
mainContentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
@@ -121,14 +107,10 @@ sealed class MainWindowModel : BaseModel, IAsyncDisposable {
welcomeScreenModel.CloseDatabase();
}
public async ValueTask DisposeAsync() {
mainContentScreenModel?.Dispose();
if (state != null) {
await state.DisposeAsync();
state = null;
}
public void Dispose() {
welcomeScreenModel.Dispose();
mainContentScreenModel?.Dispose();
db?.Dispose();
db = null;
}
}

View File

@@ -2,7 +2,8 @@ using System;
using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Main.Controls;
using DHT.Server;
using DHT.Desktop.Server;
using DHT.Server.Database;
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Pages;
@@ -11,16 +12,20 @@ sealed class AdvancedPageModel : BaseModel, IDisposable {
public ServerConfigurationPanelModel ServerConfigurationModel { get; }
private readonly Window window;
private readonly State state;
private readonly IDatabaseFile db;
[Obsolete("Designer")]
public AdvancedPageModel() : this(null!, State.Dummy) {}
public AdvancedPageModel() : this(null!, DummyDatabaseFile.Instance, new ServerManager(DummyDatabaseFile.Instance)) {}
public AdvancedPageModel(Window window, State state) {
public AdvancedPageModel(Window window, IDatabaseFile db, ServerManager serverManager) {
this.window = window;
this.state = state;
this.db = db;
ServerConfigurationModel = new ServerConfigurationPanelModel(window, state);
ServerConfigurationModel = new ServerConfigurationPanelModel(window, serverManager);
}
public void Initialize() {
ServerConfigurationModel.Initialize();
}
public void Dispose() {
@@ -28,7 +33,7 @@ sealed class AdvancedPageModel : BaseModel, IDisposable {
}
public async void VacuumDatabase() {
state.Db.Vacuum();
db.Vacuum();
await Dialog.ShowOk(window, "Vacuum Database", "Done.");
}
}

View File

@@ -33,7 +33,7 @@
<StackPanel Orientation="Vertical" Spacing="20">
<DockPanel>
<Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" DockPanel.Dock="Left" />
<TextBlock Text="{Binding DownloadMessage}" MinWidth="100" Margin="10 0 0 0" VerticalAlignment="Center" TextAlignment="Right" DockPanel.Dock="Left" />
<TextBlock Text="{Binding DownloadMessage}" Margin="10 0 0 0" VerticalAlignment="Center" DockPanel.Dock="Left" />
<ProgressBar Value="{Binding DownloadProgress}" IsVisible="{Binding IsDownloading}" Margin="15 0" VerticalAlignment="Center" DockPanel.Dock="Right" />
</DockPanel>
<controls:AttachmentFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !IsDownloading, RelativeSource={RelativeSource AncestorType=pages:AttachmentsPageModel}}" />
@@ -42,8 +42,8 @@
<DataGrid ItemsSource="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True">
<DataGrid.Columns>
<DataGridTextColumn Header="State" Binding="{Binding State}" Width="*" />
<DataGridTextColumn Header="Attachments" Binding="{Binding Items, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" Width="*" CellStyleClasses="right" />
<DataGridTextColumn Header="Size" Binding="{Binding Size, Mode=OneWay, Converter={StaticResource BytesValueConverter}}" Width="*" CellStyleClasses="right" />
<DataGridTextColumn Header="Attachments" Binding="{Binding Items, Converter={StaticResource NumberValueConverter}}" Width="*" CellStyleClasses="right" />
<DataGridTextColumn Header="Size" Binding="{Binding Size, Converter={StaticResource BytesValueConverter}}" Width="*" CellStyleClasses="right" />
</DataGrid.Columns>
</DataGrid>
</Expander>

View File

@@ -1,16 +1,15 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Reactive.Linq;
using System.Threading.Tasks;
using Avalonia.ReactiveUI;
using System.Threading;
using Avalonia.Threading;
using DHT.Desktop.Common;
using DHT.Desktop.Main.Controls;
using DHT.Server;
using DHT.Server.Data;
using DHT.Server.Data.Aggregations;
using DHT.Server.Data.Filters;
using DHT.Server.Database;
using DHT.Server.Download;
using DHT.Utils.Models;
using DHT.Utils.Tasks;
@@ -19,14 +18,13 @@ namespace DHT.Desktop.Main.Pages;
sealed class AttachmentsPageModel : BaseModel, IDisposable {
private static readonly DownloadItemFilter EnqueuedItemFilter = new() {
IncludeStatuses = new HashSet<DownloadStatus> {
DownloadStatus.Enqueued,
DownloadStatus.Downloading
DownloadStatus.Enqueued
}
};
private bool isThreadDownloadButtonEnabled = true;
public string ToggleDownloadButtonText => IsDownloading ? "Stop Downloading" : "Start Downloading";
public string ToggleDownloadButtonText => downloadThread == null ? "Start Downloading" : "Stop Downloading";
public bool IsToggleDownloadButtonEnabled {
get => isThreadDownloadButtonEnabled;
@@ -34,7 +32,7 @@ sealed class AttachmentsPageModel : BaseModel, IDisposable {
}
public string DownloadMessage { get; set; } = "";
public double DownloadProgress => totalItemsToDownloadCount is null or 0 ? 0.0 : 100.0 * doneItemsCount / totalItemsToDownloadCount.Value;
public double DownloadProgress => allItemsCount is null or 0 ? 0.0 : 100.0 * doneItemsCount / allItemsCount.Value;
public AttachmentFilterPanelModel FilterModel { get; }
@@ -54,34 +52,33 @@ sealed class AttachmentsPageModel : BaseModel, IDisposable {
}
}
public bool IsDownloading => state.Downloader.IsDownloading;
public bool IsDownloading => downloadThread != null;
public bool HasFailedDownloads => statisticsFailed.Items > 0;
private readonly State state;
private readonly IDatabaseFile db;
private readonly AsyncValueComputer<DownloadStatusStatistics>.Single downloadStatisticsComputer;
private BackgroundDownloadThread? downloadThread;
private IDisposable? finishedItemsSubscription;
private int doneItemsCount;
private int initialFinishedCount;
private int? totalItemsToDownloadCount;
private int? allItemsCount;
public AttachmentsPageModel() : this(State.Dummy) {}
public AttachmentsPageModel() : this(DummyDatabaseFile.Instance) {}
public AttachmentsPageModel(State state) {
this.state = state;
public AttachmentsPageModel(IDatabaseFile db) {
this.db = db;
this.FilterModel = new AttachmentFilterPanelModel(db);
FilterModel = new AttachmentFilterPanelModel(state);
this.downloadStatisticsComputer = AsyncValueComputer<DownloadStatusStatistics>.WithResultProcessor(UpdateStatistics).WithOutdatedResults().BuildWithComputer(db.GetDownloadStatusStatistics);
this.downloadStatisticsComputer.Recompute();
downloadStatisticsComputer = AsyncValueComputer<DownloadStatusStatistics>.WithResultProcessor(UpdateStatistics).WithOutdatedResults().BuildWithComputer(state.Db.GetDownloadStatusStatistics);
downloadStatisticsComputer.Recompute();
state.Db.Statistics.PropertyChanged += OnDbStatisticsChanged;
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
}
public void Dispose() {
state.Db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
finishedItemsSubscription?.Dispose();
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
FilterModel.Dispose();
DisposeDownloadThread();
}
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
@@ -101,7 +98,7 @@ sealed class AttachmentsPageModel : BaseModel, IDisposable {
private void EnqueueDownloadItems() {
var filter = FilterModel.CreateFilter();
filter.DownloadItemRule = AttachmentFilter.DownloadItemRules.OnlyNotPresent;
state.Db.EnqueueDownloadItems(filter);
db.EnqueueDownloadItems(filter);
downloadStatisticsComputer.Recompute();
}
@@ -127,76 +124,75 @@ sealed class AttachmentsPageModel : BaseModel, IDisposable {
OnPropertyChanged(nameof(HasFailedDownloads));
}
totalItemsToDownloadCount = statisticsEnqueued.Items + statisticsDownloaded.Items + statisticsFailed.Items - initialFinishedCount;
allItemsCount = doneItemsCount + statisticsEnqueued.Items;
UpdateDownloadMessage();
}
private void UpdateDownloadMessage() {
DownloadMessage = IsDownloading ? doneItemsCount.Format() + " / " + (totalItemsToDownloadCount?.Format() ?? "?") : "";
DownloadMessage = IsDownloading ? doneItemsCount.Format() + " / " + (allItemsCount?.Format() ?? "?") : "";
OnPropertyChanged(nameof(DownloadMessage));
OnPropertyChanged(nameof(DownloadProgress));
}
private void OnItemsFinished(int finishedItemCount) {
doneItemsCount += finishedItemCount;
UpdateDownloadMessage();
private void DownloadThreadOnOnItemFinished(object? sender, DownloadItem e) {
Interlocked.Increment(ref doneItemsCount);
Dispatcher.UIThread.Invoke(UpdateDownloadMessage);
downloadStatisticsComputer.Recompute();
}
public async Task OnClickToggleDownload() {
IsToggleDownloadButtonEnabled = false;
if (IsDownloading) {
await state.Downloader.Stop();
finishedItemsSubscription?.Dispose();
finishedItemsSubscription = null;
private void DownloadThreadOnOnServerStopped(object? sender, EventArgs e) {
downloadStatisticsComputer.Recompute();
IsToggleDownloadButtonEnabled = true;
}
state.Db.RemoveDownloadItems(EnqueuedItemFilter, FilterRemovalMode.RemoveMatching);
doneItemsCount = 0;
initialFinishedCount = 0;
totalItemsToDownloadCount = null;
UpdateDownloadMessage();
public void OnClickToggleDownload() {
if (downloadThread == null) {
EnqueueDownloadItems();
downloadThread = new BackgroundDownloadThread(db);
downloadThread.OnItemFinished += DownloadThreadOnOnItemFinished;
downloadThread.OnServerStopped += DownloadThreadOnOnServerStopped;
}
else {
var finishedItems = await state.Downloader.Start();
IsToggleDownloadButtonEnabled = false;
DisposeDownloadThread();
initialFinishedCount = statisticsDownloaded.Items + statisticsFailed.Items;
finishedItemsSubscription = finishedItems.Select(static _ => true)
.Buffer(TimeSpan.FromMilliseconds(100))
.Select(static items => items.Count)
.Where(static items => items > 0)
.ObserveOn(AvaloniaScheduler.Instance)
.Subscribe(OnItemsFinished);
db.RemoveDownloadItems(EnqueuedItemFilter, FilterRemovalMode.RemoveMatching);
EnqueueDownloadItems();
doneItemsCount = 0;
allItemsCount = null;
UpdateDownloadMessage();
}
OnPropertyChanged(nameof(ToggleDownloadButtonText));
OnPropertyChanged(nameof(IsDownloading));
IsToggleDownloadButtonEnabled = true;
}
public void OnClickRetryFailedDownloads() {
var allExceptFailedFilter = new DownloadItemFilter {
IncludeStatuses = new HashSet<DownloadStatus> {
DownloadStatus.Enqueued,
DownloadStatus.Downloading,
DownloadStatus.Success
}
};
state.Db.RemoveDownloadItems(allExceptFailedFilter, FilterRemovalMode.KeepMatching);
db.RemoveDownloadItems(allExceptFailedFilter, FilterRemovalMode.KeepMatching);
if (IsDownloading) {
EnqueueDownloadItems();
}
}
private void DisposeDownloadThread() {
if (downloadThread != null) {
downloadThread.OnItemFinished -= DownloadThreadOnOnItemFinished;
downloadThread.StopThread();
}
downloadThread = null;
}
public sealed class StatisticsRow {
public string State { get; }
public int Items { get; set; }

View File

@@ -14,7 +14,6 @@ using DHT.Desktop.Dialogs.File;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress;
using DHT.Desktop.Dialogs.TextBox;
using DHT.Server;
using DHT.Server.Data;
using DHT.Server.Database;
using DHT.Server.Database.Import;
@@ -34,11 +33,11 @@ sealed class DatabasePageModel : BaseModel {
private readonly Window window;
[Obsolete("Designer")]
public DatabasePageModel() : this(null!, State.Dummy) {}
public DatabasePageModel() : this(null!, DummyDatabaseFile.Instance) {}
public DatabasePageModel(Window window, State state) {
public DatabasePageModel(Window window, IDatabaseFile db) {
this.window = window;
this.Db = state.Db;
this.Db = db;
}
public async void OpenDatabaseFolder() {

View File

@@ -6,8 +6,8 @@ using System.Threading.Tasks;
using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress;
using DHT.Server;
using DHT.Server.Data;
using DHT.Server.Database;
using DHT.Server.Service;
using DHT.Utils.Models;
@@ -18,14 +18,14 @@ namespace DHT.Desktop.Main.Pages {
public string GenerateMessages { get; set; } = "0";
private readonly Window window;
private readonly State state;
private readonly IDatabaseFile db;
[Obsolete("Designer")]
public DebugPageModel() : this(null!, State.Dummy) {}
public DebugPageModel() : this(null!, DummyDatabaseFile.Instance) {}
public DebugPageModel(Window window, State state) {
public DebugPageModel(Window window, IDatabaseFile db) {
this.window = window;
this.state = state;
this.db = db;
}
public async void OnClickAddRandomDataToDatabase() {
@@ -83,11 +83,11 @@ namespace DHT.Desktop.Main.Pages {
Discriminator = rand.Next(0, 9999).ToString(),
}).ToArray();
state.Db.AddServer(server);
state.Db.AddUsers(users);
db.AddServer(server);
db.AddUsers(users);
foreach (var channel in channels) {
state.Db.AddChannel(channel);
db.AddChannel(channel);
}
var now = DateTimeOffset.Now;
@@ -117,7 +117,7 @@ namespace DHT.Desktop.Main.Pages {
};
}).ToArray();
state.Db.AddMessages(messages);
db.AddMessages(messages);
messageCount -= BatchSize;
await callback.Update("Adding messages in batches of " + BatchSize, ++batchIndex, batchCount);

View File

@@ -54,7 +54,7 @@ sealed class TrackingPageModel : BaseModel {
}
public async Task<bool> OnClickCopyTrackingScript() {
string url = $"http://127.0.0.1:{ServerConfiguration.Port}/get-tracking-script?token={HttpUtility.UrlEncode(ServerConfiguration.Token)}";
string url = $"http://127.0.0.1:{ServerManager.Port}/get-tracking-script?token={HttpUtility.UrlEncode(ServerManager.Token)}";
string script = (await Resources.ReadTextAsync("tracker-loader.js")).Trim().Replace("{url}", url);
var clipboard = window.Clipboard;

View File

@@ -13,8 +13,8 @@ using DHT.Desktop.Dialogs.File;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Main.Controls;
using DHT.Desktop.Server;
using DHT.Server;
using DHT.Server.Data.Filters;
using DHT.Server.Database;
using DHT.Server.Database.Export;
using DHT.Server.Database.Export.Strategy;
using DHT.Utils.Models;
@@ -38,16 +38,16 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
public MessageFilterPanelModel FilterModel { get; }
private readonly Window window;
private readonly State state;
private readonly IDatabaseFile db;
[Obsolete("Designer")]
public ViewerPageModel() : this(null!, State.Dummy) {}
public ViewerPageModel() : this(null!, DummyDatabaseFile.Instance) {}
public ViewerPageModel(Window window, State state) {
public ViewerPageModel(Window window, IDatabaseFile db) {
this.window = window;
this.state = state;
this.db = db;
FilterModel = new MessageFilterPanelModel(window, state, "Will export");
FilterModel = new MessageFilterPanelModel(window, db, "Will export");
FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
}
@@ -66,13 +66,15 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
string viewerTemplate = indexFile.Replace("/*[JS]*/", await Resources.ReadJoinedAsync("Viewer/scripts/", '\n'))
.Replace("/*[CSS]*/", await Resources.ReadJoinedAsync("Viewer/styles/", '\n'));
viewerTemplate = strategy.ProcessViewerTemplate(viewerTemplate);
int viewerArchiveTagStart = viewerTemplate.IndexOf(ArchiveTag);
int viewerArchiveTagEnd = viewerArchiveTagStart + ArchiveTag.Length;
string jsonTempFile = path + ".tmp";
await using (var jsonStream = new FileStream(jsonTempFile, FileMode.Create, FileAccess.ReadWrite, FileShare.Read)) {
await ViewerJsonExport.Generate(jsonStream, strategy, state.Db, FilterModel.CreateFilter());
await ViewerJsonExport.Generate(jsonStream, strategy, db, FilterModel.CreateFilter());
char[] jsonBuffer = new char[Math.Min(32768, jsonStream.Position)];
jsonStream.Position = 0;
@@ -98,7 +100,7 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
public async void OnClickOpenViewer() {
string rootPath = Path.Combine(Path.GetTempPath(), "DiscordHistoryTracker");
string filenameBase = Path.GetFileNameWithoutExtension(state.Db.Path) + "-" + DateTime.Now.ToString("yyyy-MM-dd");
string filenameBase = Path.GetFileNameWithoutExtension(db.Path) + "-" + DateTime.Now.ToString("yyyy-MM-dd");
string fullPath = Path.Combine(rootPath, filenameBase + ".html");
int counter = 0;
@@ -110,7 +112,7 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
TemporaryFiles.Add(fullPath);
Directory.CreateDirectory(rootPath);
await WriteViewerFile(fullPath, new LiveViewerExportStrategy(ServerConfiguration.Port, ServerConfiguration.Token));
await WriteViewerFile(fullPath, new LiveViewerExportStrategy(ServerManager.Port, ServerManager.Token));
Process.Start(new ProcessStartInfo(fullPath) { UseShellExecute = true });
}
@@ -123,8 +125,8 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
string? path = await window.StorageProvider.SaveFile(new FilePickerSaveOptions {
Title = "Save Viewer",
FileTypeChoices = ViewerFileTypes,
SuggestedFileName = Path.GetFileNameWithoutExtension(state.Db.Path) + ".html",
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(state.Db.Path)),
SuggestedFileName = Path.GetFileNameWithoutExtension(db.Path) + ".html",
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(db.Path)),
});
if (path != null) {
@@ -136,13 +138,13 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
var filter = FilterModel.CreateFilter();
if (DatabaseToolFilterModeKeep) {
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Keep Matching Messages in This Database", state.Db.CountMessages(filter).Pluralize("message") + " will be kept, and the rest will be removed from this database. This action cannot be undone. Proceed?")) {
state.Db.RemoveMessages(filter, FilterRemovalMode.KeepMatching);
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Keep Matching Messages in This Database", db.CountMessages(filter).Pluralize("message") + " will be kept, and the rest will be removed from this database. This action cannot be undone. Proceed?")) {
db.RemoveMessages(filter, FilterRemovalMode.KeepMatching);
}
}
else if (DatabaseToolFilterModeRemove) {
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Remove Matching Messages in This Database", state.Db.CountMessages(filter).Pluralize("message") + " will be removed from this database. This action cannot be undone. Proceed?")) {
state.Db.RemoveMessages(filter, FilterRemovalMode.RemoveMatching);
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Remove Matching Messages in This Database", db.CountMessages(filter).Pluralize("message") + " will be removed from this database. This action cannot be undone. Proceed?")) {
db.RemoveMessages(filter, FilterRemovalMode.RemoveMatching);
}
}
}

View File

@@ -1,9 +1,12 @@
using System;
using System.Threading.Tasks;
using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Main.Controls;
using DHT.Desktop.Main.Pages;
using DHT.Server;
using DHT.Desktop.Server;
using DHT.Server.Database;
using DHT.Server.Service;
using DHT.Utils.Logging;
namespace DHT.Desktop.Main.Screens;
@@ -46,43 +49,71 @@ sealed class MainContentScreenModel : IDisposable {
}
}
[Obsolete("Designer")]
public MainContentScreenModel() : this(null!, State.Dummy) {}
private readonly Window window;
private readonly ServerManager serverManager;
public MainContentScreenModel(Window window, State state) {
DatabasePageModel = new DatabasePageModel(window, state);
[Obsolete("Designer")]
public MainContentScreenModel() : this(null!, DummyDatabaseFile.Instance) {}
public MainContentScreenModel(Window window, IDatabaseFile db) {
this.window = window;
this.serverManager = new ServerManager(db);
ServerLauncher.ServerManagementExceptionCaught += ServerLauncherOnServerManagementExceptionCaught;
DatabasePageModel = new DatabasePageModel(window, db);
DatabasePage = new DatabasePage { DataContext = DatabasePageModel };
TrackingPageModel = new TrackingPageModel(window);
TrackingPage = new TrackingPage { DataContext = TrackingPageModel };
AttachmentsPageModel = new AttachmentsPageModel(state);
AttachmentsPageModel = new AttachmentsPageModel(db);
AttachmentsPage = new AttachmentsPage { DataContext = AttachmentsPageModel };
ViewerPageModel = new ViewerPageModel(window, state);
ViewerPageModel = new ViewerPageModel(window, db);
ViewerPage = new ViewerPage { DataContext = ViewerPageModel };
AdvancedPageModel = new AdvancedPageModel(window, state);
AdvancedPageModel = new AdvancedPageModel(window, db, serverManager);
AdvancedPage = new AdvancedPage { DataContext = AdvancedPageModel };
#if DEBUG
DebugPageModel = new DebugPageModel(window, state);
DebugPageModel = new DebugPageModel(window, db);
DebugPage = new DebugPage { DataContext = DebugPageModel };
#else
DebugPage = null;
#endif
StatusBarModel = new StatusBarModel(state);
StatusBarModel = new StatusBarModel(db.Statistics);
AdvancedPageModel.ServerConfigurationModel.ServerStatusChanged += OnServerStatusChanged;
DatabaseClosed += OnDatabaseClosed;
StatusBarModel.CurrentStatus = serverManager.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped;
}
public async Task Initialize() {
await TrackingPageModel.Initialize();
AdvancedPageModel.Initialize();
serverManager.Launch();
}
public void Dispose() {
ServerLauncher.ServerManagementExceptionCaught -= ServerLauncherOnServerManagementExceptionCaught;
AttachmentsPageModel.Dispose();
ViewerPageModel.Dispose();
AdvancedPageModel.Dispose();
StatusBarModel.Dispose();
serverManager.Dispose();
}
private void OnServerStatusChanged(object? sender, StatusBarModel.Status e) {
StatusBarModel.CurrentStatus = e;
}
private void OnDatabaseClosed(object? sender, EventArgs e) {
serverManager.Stop();
}
private async void ServerLauncherOnServerManagementExceptionCaught(object? sender, Exception ex) {
Log.Error(ex);
await Dialog.ShowOk(window, "Internal Server Error", ex.Message);
}
}

View File

@@ -1,8 +0,0 @@
using DHT.Server.Service;
namespace DHT.Desktop.Server;
static class ServerConfiguration {
public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(50000, 60000);
public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20);
}

View File

@@ -0,0 +1,50 @@
using System;
using DHT.Server.Database;
using DHT.Server.Service;
namespace DHT.Desktop.Server;
sealed class ServerManager : IDisposable {
public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(50000, 60000);
public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20);
private static ServerManager? instance;
public bool IsRunning => ServerLauncher.IsRunning;
private readonly IDatabaseFile db;
public ServerManager(IDatabaseFile db) {
if (db != DummyDatabaseFile.Instance) {
if (instance != null) {
throw new InvalidOperationException("Only one instance of ServerManager can exist at the same time!");
}
instance = this;
}
this.db = db;
}
public void Launch() {
ServerLauncher.Relaunch(Port, Token, db);
}
public void Relaunch(ushort port, string token) {
Port = port;
Token = token;
Launch();
}
public void Stop() {
ServerLauncher.Stop();
}
public void Dispose() {
Stop();
if (instance == this) {
instance = null;
}
}
}

View File

@@ -6,6 +6,8 @@
<script type="text/javascript">
window.DHT_EMBEDDED = "/*[ARCHIVE]*/";
window.DHT_SERVER_URL = "/*[SERVER_URL]*/";
window.DHT_SERVER_TOKEN = "/*[SERVER_TOKEN]*/";
/*[JS]*/
</script>
<style>

View File

@@ -182,15 +182,32 @@ const STATE = (function() {
return null;
};
const getMessageList = function() {
const getMessageList = async function(abortSignal) {
if (!loadedMessages) {
return [];
}
const messages = getMessages(selectedChannel);
const startIndex = messagesPerPage * (root.getCurrentPage() - 1);
const slicedMessages = loadedMessages.slice(startIndex, !messagesPerPage ? undefined : startIndex + messagesPerPage);
return loadedMessages.slice(startIndex, !messagesPerPage ? undefined : startIndex + messagesPerPage).map(key => {
let messageTexts = null;
if (window.DHT_SERVER_URL !== null) {
const messageIds = new Set(slicedMessages);
for (const key of slicedMessages) {
const message = messages[key];
if ("r" in message) {
messageIds.add(message.r);
}
}
messageTexts = await getMessageTextsFromServer(messageIds, abortSignal);
}
return slicedMessages.map(key => {
/**
* @type {{}}
* @property {Number} u
@@ -216,6 +233,9 @@ const STATE = (function() {
if ("m" in message) {
obj["contents"] = message.m;
}
else if (messageTexts && key in messageTexts) {
obj["contents"] = messageTexts[key];
}
if ("e" in message) {
obj["embeds"] = message.e.map(embed => JSON.parse(embed));
@@ -230,15 +250,16 @@ const STATE = (function() {
}
if ("r" in message) {
const replyMessage = getMessageById(message.r);
const replyId = message.r;
const replyMessage = getMessageById(replyId);
const replyUser = replyMessage ? getUser(replyMessage.u) : null;
const replyAvatar = replyUser && replyUser.avatar ? { id: getUserId(replyMessage.u), path: replyUser.avatar } : null;
obj["reply"] = replyMessage ? {
"id": message.r,
"id": replyId,
"user": replyUser,
"avatar": replyAvatar,
"contents": replyMessage.m
"contents": messageTexts != null && replyId in messageTexts ? messageTexts[replyId] : replyMessage.m,
} : null;
}
@@ -250,9 +271,35 @@ const STATE = (function() {
});
};
const getMessageTextsFromServer = async function(messageIds, abortSignal) {
let idParams = "";
for (const messageId of messageIds) {
idParams += "id=" + encodeURIComponent(messageId) + "&";
}
const response = await fetch(DHT_SERVER_URL + "/get-messages?" + idParams + "token=" + encodeURIComponent(DHT_SERVER_TOKEN), {
method: "GET",
headers: {
"Content-Type": "application/json",
},
credentials: "omit",
redirect: "error",
signal: abortSignal
});
if (response.status === 200) {
return response.json();
}
else {
throw new Error("Server returned status " + response.status + " " + response.statusText);
}
};
let eventOnUsersRefreshed;
let eventOnChannelsRefreshed;
let eventOnMessagesRefreshed;
let messageLoaderAborter = null;
const triggerUsersRefreshed = function() {
eventOnUsersRefreshed && eventOnUsersRefreshed(getUserList());
@@ -263,7 +310,22 @@ const STATE = (function() {
};
const triggerMessagesRefreshed = function() {
eventOnMessagesRefreshed && eventOnMessagesRefreshed(getMessageList());
if (!eventOnMessagesRefreshed) {
return;
}
if (messageLoaderAborter != null) {
messageLoaderAborter.abort();
}
const aborter = new AbortController();
messageLoaderAborter = aborter;
getMessageList(aborter.signal).then(eventOnMessagesRefreshed).finally(() => {
if (messageLoaderAborter === aborter) {
messageLoaderAborter = null;
}
});
};
const getFilteredMessageKeys = function(channel) {

View File

@@ -8,6 +8,5 @@ namespace DHT.Server.Data;
public enum DownloadStatus {
Enqueued = 0,
GenericError = 1,
Downloading = 2,
Success = HttpStatusCode.OK
}

View File

@@ -44,7 +44,7 @@ public sealed class DummyDatabaseFile : IDatabaseFile {
return 0;
}
public List<Message> GetMessages(MessageFilter? filter = null) {
public List<Message> GetMessages(MessageFilter? filter = null, bool includeText = true) {
return new();
}
@@ -74,7 +74,7 @@ public sealed class DummyDatabaseFile : IDatabaseFile {
public void EnqueueDownloadItems(AttachmentFilter? filter = null) {}
public List<DownloadItem> PullEnqueuedDownloadItems(int count) {
public List<DownloadItem> GetEnqueuedDownloadItems(int count) {
return new();
}

View File

@@ -3,5 +3,7 @@ using DHT.Server.Data;
namespace DHT.Server.Database.Export.Strategy;
public interface IViewerExportStrategy {
bool IncludeMessageText { get; }
string ProcessViewerTemplate(string template);
string GetAttachmentUrl(Attachment attachment);
}

View File

@@ -12,6 +12,13 @@ public sealed class LiveViewerExportStrategy : IViewerExportStrategy {
this.safeToken = WebUtility.UrlEncode(token);
}
public bool IncludeMessageText => false;
public string ProcessViewerTemplate(string template) {
return template.Replace("/*[SERVER_URL]*/", "http://127.0.0.1:" + safePort)
.Replace("/*[SERVER_TOKEN]*/", WebUtility.UrlEncode(safeToken));
}
public string GetAttachmentUrl(Attachment attachment) {
return "http://127.0.0.1:" + safePort + "/get-attachment/" + WebUtility.UrlEncode(attachment.NormalizedUrl) + "?token=" + safeToken;
}

View File

@@ -7,6 +7,13 @@ public sealed class StandaloneViewerExportStrategy : IViewerExportStrategy {
private StandaloneViewerExportStrategy() {}
public bool IncludeMessageText => true;
public string ProcessViewerTemplate(string template) {
return template.Replace("\"/*[SERVER_URL]*/\"", "null")
.Replace("\"/*[SERVER_TOKEN]*/\"", "null");
}
public string GetAttachmentUrl(Attachment attachment) {
// The normalized URL will not load files from Discord CDN once the time limit is enforced.

View File

@@ -21,7 +21,7 @@ public static class ViewerJsonExport {
var includedChannelIds = new HashSet<ulong>();
var includedServerIds = new HashSet<ulong>();
var includedMessages = db.GetMessages(filter);
var includedMessages = db.GetMessages(filter, strategy.IncludeMessageText);
var includedChannels = new List<Channel>();
foreach (var message in includedMessages) {

View File

@@ -23,7 +23,7 @@ public interface IDatabaseFile : IDisposable {
void AddMessages(Message[] messages);
int CountMessages(MessageFilter? filter = null);
List<Message> GetMessages(MessageFilter? filter = null);
List<Message> GetMessages(MessageFilter? filter = null, bool includeText = true);
HashSet<ulong> GetMessageIds(MessageFilter? filter = null);
void RemoveMessages(MessageFilter filter, FilterRemovalMode mode);
@@ -35,7 +35,7 @@ public interface IDatabaseFile : IDisposable {
DownloadedAttachment? GetDownloadedAttachment(string url);
void EnqueueDownloadItems(AttachmentFilter? filter = null);
List<DownloadItem> PullEnqueuedDownloadItems(int count);
List<DownloadItem> GetEnqueuedDownloadItems(int count);
void RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode);
DownloadStatusStatistics GetDownloadStatusStatistics();

View File

@@ -174,8 +174,8 @@ sealed class Schema {
int processedUrls = -1;
await using (var updateCmd = conn.Command("UPDATE attachments SET download_url = url, url = :normalized_url WHERE attachment_id = :attachment_id")) {
updateCmd.Add(":attachment_id", SqliteType.Integer);
updateCmd.Add(":normalized_url", SqliteType.Text);
updateCmd.Parameters.Add(":attachment_id", SqliteType.Integer);
updateCmd.Parameters.Add(":normalized_url", SqliteType.Text);
foreach (var (attachmentId, normalizedUrl) in normalizedUrls) {
if (++processedUrls % 1000 == 0) {
@@ -235,8 +235,8 @@ sealed class Schema {
tx = conn.BeginTransaction();
await using (var updateCmd = conn.Command("UPDATE downloads SET download_url = :download_url, url = :normalized_url WHERE url = :download_url")) {
updateCmd.Add(":normalized_url", SqliteType.Text);
updateCmd.Add(":download_url", SqliteType.Text);
updateCmd.Parameters.Add(":normalized_url", SqliteType.Text);
updateCmd.Parameters.Add(":download_url", SqliteType.Text);
foreach (var (normalizedUrl, downloadUrl) in normalizedUrlsToOriginalUrls) {
if (++processedUrls % 100 == 0) {

View File

@@ -360,7 +360,7 @@ public sealed class SqliteDatabaseFile : IDatabaseFile {
return reader.Read() ? reader.GetInt32(0) : 0;
}
public List<Message> GetMessages(MessageFilter? filter = null) {
public List<Message> GetMessages(MessageFilter? filter = null, bool includeText = true) {
var perf = log.Start();
var list = new List<Message>();
@@ -370,7 +370,7 @@ public sealed class SqliteDatabaseFile : IDatabaseFile {
using var conn = pool.Take();
using var cmd = conn.Command($"""
SELECT m.message_id, m.sender_id, m.channel_id, m.text, m.timestamp, et.edit_timestamp, rt.replied_to_id
SELECT m.message_id, m.sender_id, m.channel_id, {(includeText ? "m.text" : "NULL")}, m.timestamp, et.edit_timestamp, rt.replied_to_id
FROM messages m
LEFT JOIN edit_timestamps et ON m.message_id = et.message_id
LEFT JOIN replied_to rt ON m.message_id = rt.message_id
@@ -385,7 +385,7 @@ public sealed class SqliteDatabaseFile : IDatabaseFile {
Id = id,
Sender = reader.GetUint64(1),
Channel = reader.GetUint64(2),
Text = reader.GetString(3),
Text = includeText ? reader.GetString(3) : string.Empty,
Timestamp = reader.GetInt64(4),
EditTimestamp = reader.IsDBNull(5) ? null : reader.GetInt64(5),
RepliedToId = reader.IsDBNull(6) ? null : reader.GetUint64(6),
@@ -522,42 +522,25 @@ public sealed class SqliteDatabaseFile : IDatabaseFile {
cmd.ExecuteNonQuery();
}
public List<DownloadItem> PullEnqueuedDownloadItems(int count) {
var found = new List<DownloadItem>();
var pulled = new List<DownloadItem>();
public List<DownloadItem> GetEnqueuedDownloadItems(int count) {
var list = new List<DownloadItem>();
using var conn = pool.Take();
using (var cmd = conn.Command("SELECT normalized_url, download_url, size FROM downloads WHERE status = :enqueued LIMIT :limit")) {
using var cmd = conn.Command("SELECT normalized_url, download_url, size FROM downloads WHERE status = :enqueued LIMIT :limit");
cmd.AddAndSet(":enqueued", SqliteType.Integer, (int) DownloadStatus.Enqueued);
cmd.AddAndSet(":limit", SqliteType.Integer, Math.Max(0, count));
using var reader = cmd.ExecuteReader();
while (reader.Read()) {
found.Add(new DownloadItem {
list.Add(new DownloadItem {
NormalizedUrl = reader.GetString(0),
DownloadUrl = reader.GetString(1),
Size = reader.GetUint64(2),
});
}
}
if (found.Count != 0) {
using var cmd = conn.Command("UPDATE downloads SET status = :downloading WHERE normalized_url = :normalized_url AND status = :enqueued");
cmd.AddAndSet(":enqueued", SqliteType.Integer, (int) DownloadStatus.Enqueued);
cmd.AddAndSet(":downloading", SqliteType.Integer, (int) DownloadStatus.Downloading);
cmd.Add(":normalized_url", SqliteType.Text);
foreach (var item in found) {
cmd.Set(":normalized_url", item.NormalizedUrl);
if (cmd.ExecuteNonQuery() == 1) {
pulled.Add(item);
}
}
}
return pulled;
return list;
}
public void RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode) {
@@ -579,16 +562,15 @@ public sealed class SqliteDatabaseFile : IDatabaseFile {
static void LoadSuccessStatistics(ISqliteConnection conn, DownloadStatusStatistics result) {
using var cmd = conn.Command("""
SELECT
IFNULL(SUM(CASE WHEN status IN (:enqueued, :downloading) THEN 1 ELSE 0 END), 0),
IFNULL(SUM(CASE WHEN status IN (:enqueued, :downloading) THEN size ELSE 0 END), 0),
IFNULL(SUM(CASE WHEN status = :enqueued THEN 1 ELSE 0 END), 0),
IFNULL(SUM(CASE WHEN status = :enqueued THEN size ELSE 0 END), 0),
IFNULL(SUM(CASE WHEN status = :success THEN 1 ELSE 0 END), 0),
IFNULL(SUM(CASE WHEN status = :success THEN size ELSE 0 END), 0),
IFNULL(SUM(CASE WHEN status NOT IN (:enqueued, :downloading) AND status != :success THEN 1 ELSE 0 END), 0),
IFNULL(SUM(CASE WHEN status NOT IN (:enqueued, :downloading) AND status != :success THEN size ELSE 0 END), 0)
IFNULL(SUM(CASE WHEN status != :enqueued AND status != :success THEN 1 ELSE 0 END), 0),
IFNULL(SUM(CASE WHEN status != :enqueued AND status != :success THEN size ELSE 0 END), 0)
FROM downloads
""");
cmd.AddAndSet(":enqueued", SqliteType.Integer, (int) DownloadStatus.Enqueued);
cmd.AddAndSet(":downloading", SqliteType.Integer, (int) DownloadStatus.Downloading);
cmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success);
using var reader = cmd.ExecuteReader();

View File

@@ -62,10 +62,6 @@ static class SqliteExtensions {
}
}
public static void Add(this SqliteCommand cmd, string key, SqliteType type) {
cmd.Parameters.Add(key, type);
}
public static void AddAndSet(this SqliteCommand cmd, string key, SqliteType type, object? value) {
cmd.Parameters.Add(key, type).Value = value ?? DBNull.Value;
}

View File

@@ -0,0 +1,130 @@
using System;
using System.Collections.Concurrent;
using System.Net.Http;
using System.Threading;
using DHT.Server.Database;
using DHT.Utils.Logging;
using DHT.Utils.Models;
namespace DHT.Server.Download;
public sealed class BackgroundDownloadThread : BaseModel {
private static readonly Log Log = Log.ForType<BackgroundDownloadThread>();
public event EventHandler<DownloadItem>? OnItemFinished {
add => parameters.OnItemFinished += value;
remove => parameters.OnItemFinished -= value;
}
public event EventHandler? OnServerStopped {
add => parameters.OnServerStopped += value;
remove => parameters.OnServerStopped -= value;
}
private readonly CancellationTokenSource cancellationTokenSource;
private readonly ThreadInstance.Parameters parameters;
public BackgroundDownloadThread(IDatabaseFile db) {
this.cancellationTokenSource = new CancellationTokenSource();
this.parameters = new ThreadInstance.Parameters(db, cancellationTokenSource);
var thread = new Thread(new ThreadInstance().Work) {
Name = "DHT download thread"
};
thread.Start(parameters);
}
public void StopThread() {
try {
cancellationTokenSource.Cancel();
} catch (ObjectDisposedException) {
Log.Warn("Attempted to stop background download thread after the cancellation token has been disposed.");
}
}
private sealed class ThreadInstance {
private const int QueueSize = 32;
public sealed class Parameters {
public event EventHandler<DownloadItem>? OnItemFinished;
public event EventHandler? OnServerStopped;
public IDatabaseFile Db { get; }
public CancellationTokenSource CancellationTokenSource { get; }
public Parameters(IDatabaseFile db, CancellationTokenSource cancellationTokenSource) {
Db = db;
CancellationTokenSource = cancellationTokenSource;
}
public void FireOnItemFinished(DownloadItem item) {
OnItemFinished?.Invoke(null, item);
}
public void FireOnServerStopped() {
OnServerStopped?.Invoke(null, EventArgs.Empty);
}
}
private readonly HttpClient client = new ();
public ThreadInstance() {
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36");
}
public async void Work(object? obj) {
var parameters = (Parameters) obj!;
var cancellationTokenSource = parameters.CancellationTokenSource;
var cancellationToken = cancellationTokenSource.Token;
var db = parameters.Db;
var queue = new ConcurrentQueue<DownloadItem>();
try {
while (!cancellationToken.IsCancellationRequested) {
FillQueue(db, queue, cancellationToken);
while (!cancellationToken.IsCancellationRequested && queue.TryDequeue(out var item)) {
var downloadUrl = item.DownloadUrl;
Log.Debug("Downloading " + downloadUrl + "...");
try {
db.AddDownload(Data.Download.NewSuccess(item, await client.GetByteArrayAsync(downloadUrl, cancellationToken)));
} catch (HttpRequestException e) {
db.AddDownload(Data.Download.NewFailure(item, e.StatusCode, item.Size));
Log.Error(e);
} catch (Exception e) {
db.AddDownload(Data.Download.NewFailure(item, null, item.Size));
Log.Error(e);
} finally {
parameters.FireOnItemFinished(item);
}
}
}
} catch (OperationCanceledException) {
//
} catch (ObjectDisposedException) {
//
} finally {
cancellationTokenSource.Dispose();
parameters.FireOnServerStopped();
}
}
private static void FillQueue(IDatabaseFile db, ConcurrentQueue<DownloadItem> queue, CancellationToken cancellationToken) {
while (!cancellationToken.IsCancellationRequested && queue.IsEmpty) {
var newItems = db.GetEnqueuedDownloadItems(QueueSize);
if (newItems.Count == 0) {
Thread.Sleep(TimeSpan.FromMilliseconds(50));
}
else {
foreach (var item in newItems) {
queue.Enqueue(item);
}
}
}
}
}
}

View File

@@ -1,40 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using DHT.Server.Database;
namespace DHT.Server.Download;
public sealed class Downloader {
private DownloaderTask? current;
public bool IsDownloading => current != null;
private readonly IDatabaseFile db;
private readonly SemaphoreSlim semaphore = new (1, 1);
internal Downloader(IDatabaseFile db) {
this.db = db;
}
public async Task<IObservable<DownloadItem>> Start() {
await semaphore.WaitAsync();
try {
current ??= new DownloaderTask(db);
return current.FinishedItems;
} finally {
semaphore.Release();
}
}
public async Task Stop() {
await semaphore.WaitAsync();
try {
if (current != null) {
await current.DisposeAsync();
current = null;
}
} finally {
semaphore.Release();
}
}
}

View File

@@ -1,110 +0,0 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Reactive.Subjects;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using DHT.Server.Database;
using DHT.Utils.Logging;
using DHT.Utils.Tasks;
namespace DHT.Server.Download;
sealed class DownloaderTask : IAsyncDisposable {
private static readonly Log Log = Log.ForType<DownloaderTask>();
private const int DownloadTasks = 4;
private const int QueueSize = 25;
private const string UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
private readonly Channel<DownloadItem> downloadQueue = Channel.CreateBounded<DownloadItem>(new BoundedChannelOptions(QueueSize) {
SingleReader = false,
SingleWriter = true,
AllowSynchronousContinuations = false,
FullMode = BoundedChannelFullMode.Wait
});
private readonly CancellationTokenSource cancellationTokenSource = new ();
private readonly CancellationToken cancellationToken;
private readonly IDatabaseFile db;
private readonly Subject<DownloadItem> finishedItemPublisher = new ();
private readonly Task queueWriterTask;
private readonly Task[] downloadTasks;
public IObservable<DownloadItem> FinishedItems => finishedItemPublisher;
internal DownloaderTask(IDatabaseFile db) {
this.db = db;
this.cancellationToken = cancellationTokenSource.Token;
this.queueWriterTask = Task.Run(RunQueueWriterTask);
this.downloadTasks = Enumerable.Range(1, DownloadTasks).Select(taskIndex => Task.Run(() => RunDownloadTask(taskIndex))).ToArray();
}
private async Task RunQueueWriterTask() {
while (await downloadQueue.Writer.WaitToWriteAsync(cancellationToken)) {
var newItems = db.PullEnqueuedDownloadItems(QueueSize);
if (newItems.Count == 0) {
await Task.Delay(TimeSpan.FromMilliseconds(50), cancellationToken);
continue;
}
foreach (var newItem in newItems) {
await downloadQueue.Writer.WriteAsync(newItem, cancellationToken);
}
}
}
private async Task RunDownloadTask(int taskIndex) {
var log = Log.ForType<DownloaderTask>("Task " + taskIndex);
var client = new HttpClient();
client.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent);
client.Timeout = TimeSpan.FromSeconds(30);
while (!cancellationToken.IsCancellationRequested) {
var item = await downloadQueue.Reader.ReadAsync(cancellationToken);
log.Debug("Downloading " + item.DownloadUrl + "...");
try {
var downloadedBytes = await client.GetByteArrayAsync(item.DownloadUrl, cancellationToken);
db.AddDownload(Data.Download.NewSuccess(item, downloadedBytes));
} catch (OperationCanceledException) {
// Ignore.
} catch (HttpRequestException e) {
db.AddDownload(Data.Download.NewFailure(item, e.StatusCode, item.Size));
log.Error(e);
} catch (Exception e) {
db.AddDownload(Data.Download.NewFailure(item, null, item.Size));
log.Error(e);
} finally {
try {
finishedItemPublisher.OnNext(item);
} catch (Exception e) {
log.Error("Caught exception in event handler: " + e);
}
}
}
}
public async ValueTask DisposeAsync() {
try {
await cancellationTokenSource.CancelAsync();
} catch (Exception) {
Log.Warn("Attempted to stop background download twice.");
return;
}
downloadQueue.Writer.Complete();
try {
await queueWriterTask.WaitIgnoringCancellation();
await Task.WhenAll(downloadTasks).WaitIgnoringCancellation();
} finally {
cancellationTokenSource.Dispose();
finishedItemPublisher.OnCompleted();
}
}
}

View File

@@ -0,0 +1,34 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using DHT.Server.Data.Filters;
using DHT.Server.Database;
using DHT.Utils.Http;
using Microsoft.AspNetCore.Http;
using GetMessagesJsonContext = DHT.Server.Endpoints.Responses.GetMessagesJsonContext;
namespace DHT.Server.Endpoints;
sealed class GetMessagesEndpoint : BaseEndpoint {
public GetMessagesEndpoint(IDatabaseFile db) : base(db) {}
protected override Task<IHttpOutput> Respond(HttpContext ctx) {
HashSet<ulong> messageIdSet;
try {
var messageIds = ctx.Request.Query["id"];
messageIdSet = messageIds.Select(ulong.Parse!).ToHashSet();
} catch (Exception) {
throw new HttpException(HttpStatusCode.BadRequest, "Invalid message ids.");
}
var messageFilter = new MessageFilter {
MessageIds = messageIdSet
};
var messages = Db.GetMessages(messageFilter).ToDictionary(static message => message.Id, static message => message.Text);
var response = new HttpOutput.Json<Dictionary<ulong, string>>(messages, GetMessagesJsonContext.Default.DictionaryUInt64String);
return Task.FromResult<IHttpOutput>(response);
}
}

View File

@@ -0,0 +1,8 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace DHT.Server.Endpoints.Responses;
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, GenerationMode = JsonSourceGenerationMode.Default)]
[JsonSerializable(typeof(Dictionary<ulong, string>))]
sealed partial class GetMessagesJsonContext : JsonSerializerContext {}

View File

@@ -12,7 +12,6 @@
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.0" />
<PackageReference Include="System.Reactive" Version="6.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,126 @@
using System;
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using DHT.Server.Database;
using DHT.Utils.Logging;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.DependencyInjection;
namespace DHT.Server.Service;
public static class ServerLauncher {
private static readonly Log Log = Log.ForType(typeof(ServerLauncher));
private static IWebHost? Server { get; set; } = null;
public static bool IsRunning { get; private set; }
public static event EventHandler? ServerStatusChanged;
public static event EventHandler<Exception>? ServerManagementExceptionCaught;
private static Thread? ManagementThread { get; set; } = null;
private static readonly Mutex ManagementThreadLock = new();
private static readonly BlockingCollection<IMessage> Messages = new(new ConcurrentQueue<IMessage>());
private static void EnqueueMessage(IMessage message) {
ManagementThreadLock.WaitOne();
try {
if (ManagementThread == null) {
ManagementThread = new Thread(RunManagementThread) {
Name = "DHT server management thread",
IsBackground = true
};
ManagementThread.Start();
}
Messages.Add(message);
} finally {
ManagementThreadLock.ReleaseMutex();
}
}
[SuppressMessage("ReSharper", "FunctionNeverReturns")]
private static void RunManagementThread() {
foreach (IMessage message in Messages.GetConsumingEnumerable()) {
try {
switch (message) {
case IMessage.StartServer start:
StopServerFromManagementThread();
StartServerFromManagementThread(start.Port, start.Token, start.Db);
break;
case IMessage.StopServer:
StopServerFromManagementThread();
break;
}
} catch (Exception e) {
ServerManagementExceptionCaught?.Invoke(null, e);
}
}
}
private static void StartServerFromManagementThread(ushort port, string token, IDatabaseFile db) {
Log.Info("Starting server on port " + port + "...");
void AddServices(IServiceCollection services) {
services.AddSingleton(typeof(IDatabaseFile), db);
services.AddSingleton(typeof(ServerParameters), new ServerParameters(port, token));
}
void SetKestrelOptions(KestrelServerOptions options) {
options.Limits.MaxRequestBodySize = null;
options.Limits.MinResponseDataRate = null;
options.ListenLocalhost(port, static listenOptions => listenOptions.Protocols = HttpProtocols.Http1);
}
Server = new WebHostBuilder()
.ConfigureServices(AddServices)
.UseKestrel(SetKestrelOptions)
.UseStartup<Startup>()
.Build();
Server.Start();
Log.Info("Server started");
IsRunning = true;
ServerStatusChanged?.Invoke(null, EventArgs.Empty);
}
private static void StopServerFromManagementThread() {
if (Server != null) {
Log.Info("Stopping server...");
Server.StopAsync().Wait();
Server.Dispose();
Server = null;
Log.Info("Server stopped");
IsRunning = false;
ServerStatusChanged?.Invoke(null, EventArgs.Empty);
}
}
public static void Relaunch(ushort port, string token, IDatabaseFile db) {
EnqueueMessage(new IMessage.StartServer(port, token, db));
}
public static void Stop() {
EnqueueMessage(new IMessage.StopServer());
}
private interface IMessage {
public sealed class StartServer : IMessage {
public ushort Port { get; }
public string Token { get; }
public IDatabaseFile Db { get; }
public StartServer(ushort port, string token, IDatabaseFile db) {
this.Port = port;
this.Token = token;
this.Db = db;
}
}
public sealed class StopServer : IMessage {}
}
}

View File

@@ -1,107 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using DHT.Server.Database;
using DHT.Utils.Logging;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.DependencyInjection;
namespace DHT.Server.Service;
public sealed class ServerManager {
private static readonly Log Log = Log.ForType(typeof(ServerManager));
private IWebHost? server;
public bool IsRunning => server != null;
public event EventHandler<Status>? StatusChanged;
public enum Status {
Starting,
Started,
Stopping,
Stopped
}
private readonly IDatabaseFile db;
private readonly SemaphoreSlim semaphore = new (1, 1);
internal ServerManager(IDatabaseFile db) {
this.db = db;
}
public async Task Start(ushort port, string token) {
await semaphore.WaitAsync();
try {
await StartInternal(port, token);
} finally {
semaphore.Release();
}
}
public async Task Stop() {
await semaphore.WaitAsync();
try {
await StopInternal();
} finally {
semaphore.Release();
}
}
private async Task StartInternal(ushort port, string token) {
await StopInternal();
StatusChanged?.Invoke(this, Status.Starting);
void AddServices(IServiceCollection services) {
services.AddSingleton(typeof(IDatabaseFile), db);
services.AddSingleton(typeof(ServerParameters), new ServerParameters(port, token));
}
void SetKestrelOptions(KestrelServerOptions options) {
options.Limits.MaxRequestBodySize = null;
options.Limits.MinResponseDataRate = null;
options.ListenLocalhost(port, static listenOptions => listenOptions.Protocols = HttpProtocols.Http1);
}
var newServer = new WebHostBuilder()
.ConfigureServices(AddServices)
.UseKestrel(SetKestrelOptions)
.UseStartup<Startup>()
.Build();
Log.Info("Starting server on port " + port + "...");
try {
await newServer.StartAsync();
} catch (Exception) {
Log.Error("Server could not start");
StatusChanged?.Invoke(this, Status.Stopped);
throw;
}
Log.Info("Server started");
server = newServer;
StatusChanged?.Invoke(this, Status.Started);
}
private async Task StopInternal() {
if (server == null) {
return;
}
StatusChanged?.Invoke(this, Status.Stopping);
Log.Info("Stopping server...");
await server.StopAsync();
Log.Info("Server stopped");
server.Dispose();
server = null;
StatusChanged?.Invoke(this, Status.Stopped);
}
}

View File

@@ -16,6 +16,7 @@ sealed class Startup {
"https://ptb.discord.com",
"https://canary.discord.com",
"https://discordapp.com",
"null" // For file:// protocol in the Viewer
};
public void ConfigureServices(IServiceCollection services) {
@@ -41,6 +42,7 @@ sealed class Startup {
app.UseEndpoints(endpoints => {
endpoints.MapGet("/get-tracking-script", new GetTrackingScriptEndpoint(db, parameters).Handle);
endpoints.MapGet("/get-messages", new GetMessagesEndpoint(db).Handle);
endpoints.MapGet("/get-attachment/{url}", new GetAttachmentEndpoint(db).Handle);
endpoints.MapPost("/track-channel", new TrackChannelEndpoint(db).Handle);
endpoints.MapPost("/track-users", new TrackUsersEndpoint(db).Handle);

View File

@@ -1,27 +0,0 @@
using System;
using System.Threading.Tasks;
using DHT.Server.Database;
using DHT.Server.Download;
using DHT.Server.Service;
namespace DHT.Server;
public sealed class State : IAsyncDisposable {
public static State Dummy { get; } = new (DummyDatabaseFile.Instance);
public IDatabaseFile Db { get; }
public Downloader Downloader { get; }
public ServerManager Server { get; }
public State(IDatabaseFile db) {
Db = db;
Downloader = new Downloader(db);
Server = new ServerManager(db);
}
public async ValueTask DisposeAsync() {
await Downloader.Stop();
await Server.Stop();
Db.Dispose();
}
}

View File

@@ -1,4 +1,5 @@
using System.Text;
using System.Text.Json.Serialization.Metadata;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
@@ -25,6 +26,20 @@ public static class HttpOutput {
}
}
public sealed class Json<TValue> : IHttpOutput {
private readonly TValue value;
private readonly JsonTypeInfo<TValue> typeInfo;
public Json(TValue value, JsonTypeInfo<TValue> typeInfo) {
this.value = value;
this.typeInfo = typeInfo;
}
public Task WriteTo(HttpResponse response) {
return response.WriteAsJsonAsync(value, typeInfo);
}
}
public sealed class File : IHttpOutput {
private readonly string? contentType;
private readonly byte[] bytes;

View File

@@ -1,12 +0,0 @@
using System;
using System.Threading.Tasks;
namespace DHT.Utils.Tasks;
public static class TaskExtensions {
public static async Task WaitIgnoringCancellation(this Task task) {
try {
await task;
} catch (OperationCanceledException) {}
}
}