mirror of
				https://github.com/chylex/Discord-History-Tracker.git
				synced 2025-11-04 03:40:12 +01:00 
			
		
		
		
	Compare commits
	
		
			14 Commits
		
	
	
		
			8aeb590bb3
			...
			wip-viewer
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						
						
							
						
						b660af4be0
	
				 | 
					
					
						|||
| 
						
						
							
						
						3d9d6a454a
	
				 | 
					
					
						|||
| 
						
						
							
						
						ee39780928
	
				 | 
					
					
						|||
| 
						
						
							
						
						7b58f973a0
	
				 | 
					
					
						|||
| 
						
						
							
						
						93fe018343
	
				 | 
					
					
						|||
| 
						
						
							
						
						4f5e27f651
	
				 | 
					
					
						|||
| 
						
						
							
						
						cbf81ec95a
	
				 | 
					
					
						|||
| 
						
						
							
						
						8a80cb8c20
	
				 | 
					
					
						|||
| 
						
						
							
						
						865deb356a
	
				 | 
					
					
						|||
| 
						
						
							
						
						069ab97196
	
				 | 
					
					
						|||
| 
						
						
							
						
						caab038eaa
	
				 | 
					
					
						|||
| 
						
						
							
						
						fb837374fc
	
				 | 
					
					
						|||
| 
						
						
							
						
						65d935cca1
	
				 | 
					
					
						|||
| 
						
						
							
						
						6e64c86d7a
	
				 | 
					
					
						
@@ -1,4 +1,3 @@
 | 
			
		||||
using System;
 | 
			
		||||
using Avalonia;
 | 
			
		||||
using Avalonia.Controls.ApplicationLifetimes;
 | 
			
		||||
using Avalonia.Markup.Xaml;
 | 
			
		||||
@@ -13,7 +12,7 @@ sealed class App : Application {
 | 
			
		||||
 | 
			
		||||
	public override void OnFrameworkInitializationCompleted() {
 | 
			
		||||
		if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
 | 
			
		||||
			desktop.MainWindow = new MainWindow(new Arguments(desktop.Args ?? Array.Empty<string>()));
 | 
			
		||||
			desktop.MainWindow = new MainWindow(Program.Arguments);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		base.OnFrameworkInitializationCompleted();
 | 
			
		||||
 
 | 
			
		||||
@@ -6,25 +6,32 @@ namespace DHT.Desktop;
 | 
			
		||||
sealed class Arguments {
 | 
			
		||||
	private static readonly Log Log = Log.ForType<Arguments>();
 | 
			
		||||
	
 | 
			
		||||
	private const int FirstArgument = 1;
 | 
			
		||||
 | 
			
		||||
	public static Arguments Empty => new(Array.Empty<string>());
 | 
			
		||||
 | 
			
		||||
	public bool Console { get; }
 | 
			
		||||
	public string? DatabaseFile { get; }
 | 
			
		||||
	public ushort? ServerPort { get; }
 | 
			
		||||
	public string? ServerToken { get; }
 | 
			
		||||
 | 
			
		||||
	public Arguments(string[] args) {
 | 
			
		||||
		for (int i = 0; i < args.Length; i++) {
 | 
			
		||||
		for (int i = FirstArgument; i < args.Length; i++) {
 | 
			
		||||
			string key = args[i];
 | 
			
		||||
 | 
			
		||||
			switch (key) {
 | 
			
		||||
				case "-debug":
 | 
			
		||||
					Log.IsDebugEnabled = true;
 | 
			
		||||
					continue;
 | 
			
		||||
				
 | 
			
		||||
				case "-console":
 | 
			
		||||
					Console = true;
 | 
			
		||||
					continue;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			string value;
 | 
			
		||||
 | 
			
		||||
			if (i == 0 && !key.StartsWith('-')) {
 | 
			
		||||
			if (i == FirstArgument && !key.StartsWith('-')) {
 | 
			
		||||
				value = key;
 | 
			
		||||
				key = "-db";
 | 
			
		||||
			}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,11 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.IO;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using Avalonia.Controls;
 | 
			
		||||
using Avalonia.Platform.Storage;
 | 
			
		||||
using Avalonia.Threading;
 | 
			
		||||
using DHT.Desktop.Dialogs.File;
 | 
			
		||||
using DHT.Desktop.Dialogs.Message;
 | 
			
		||||
using DHT.Server.Database;
 | 
			
		||||
@@ -41,11 +43,16 @@ static class DatabaseGui {
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, Func<Task<bool>> checkCanUpgradeDatabase) {
 | 
			
		||||
	public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, ISchemaUpgradeCallbacks schemaUpgradeCallbacks) {
 | 
			
		||||
		var prevSynchronizationContext = SynchronizationContext.Current;
 | 
			
		||||
		SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
 | 
			
		||||
		var taskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
 | 
			
		||||
		SynchronizationContext.SetSynchronizationContext(prevSynchronizationContext);
 | 
			
		||||
		
 | 
			
		||||
		IDatabaseFile? file = null;
 | 
			
		||||
 | 
			
		||||
		try {
 | 
			
		||||
			file = await SqliteDatabaseFile.OpenOrCreate(path, checkCanUpgradeDatabase);
 | 
			
		||||
			file = await SqliteDatabaseFile.OpenOrCreate(path, schemaUpgradeCallbacks, taskScheduler);
 | 
			
		||||
		} catch (InvalidDatabaseVersionException ex) {
 | 
			
		||||
			await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' appears to be corrupted (invalid version: " + ex.Version + ").");
 | 
			
		||||
		} catch (DatabaseTooNewException ex) {
 | 
			
		||||
 
 | 
			
		||||
@@ -9,6 +9,7 @@
 | 
			
		||||
  <PropertyGroup>
 | 
			
		||||
    <OutputType>WinExe</OutputType>
 | 
			
		||||
    <ApplicationIcon>./Resources/icon.ico</ApplicationIcon>
 | 
			
		||||
    <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
 | 
			
		||||
    <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
 | 
			
		||||
    <SatelliteResourceLanguages>en</SatelliteResourceLanguages>
 | 
			
		||||
  </PropertyGroup>
 | 
			
		||||
 
 | 
			
		||||
@@ -5,6 +5,7 @@
 | 
			
		||||
        xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.CheckBox"
 | 
			
		||||
        mc:Ignorable="d" d:DesignWidth="500"
 | 
			
		||||
        x:Class="DHT.Desktop.Dialogs.CheckBox.CheckBoxDialog"
 | 
			
		||||
        x:DataType="namespace:CheckBoxDialogModel"
 | 
			
		||||
        Title="{Binding Title}"
 | 
			
		||||
        Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
 | 
			
		||||
        Width="500" SizeToContent="Height" CanResize="False"
 | 
			
		||||
 
 | 
			
		||||
@@ -5,6 +5,7 @@
 | 
			
		||||
        xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Message"
 | 
			
		||||
        mc:Ignorable="d" d:DesignWidth="500"
 | 
			
		||||
        x:Class="DHT.Desktop.Dialogs.Message.MessageDialog"
 | 
			
		||||
        x:DataType="namespace:MessageDialogModel"
 | 
			
		||||
        Title="{Binding Title}"
 | 
			
		||||
        Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
 | 
			
		||||
        Width="500" SizeToContent="Height" CanResize="False"
 | 
			
		||||
 
 | 
			
		||||
@@ -4,4 +4,5 @@ namespace DHT.Desktop.Dialogs.Progress;
 | 
			
		||||
 | 
			
		||||
interface IProgressCallback {
 | 
			
		||||
	Task Update(string message, int finishedItems, int totalItems);
 | 
			
		||||
	Task Hide();
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -5,6 +5,7 @@
 | 
			
		||||
        xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Progress"
 | 
			
		||||
        mc:Ignorable="d" d:DesignWidth="500"
 | 
			
		||||
        x:Class="DHT.Desktop.Dialogs.Progress.ProgressDialog"
 | 
			
		||||
        x:DataType="namespace:ProgressDialogModel"
 | 
			
		||||
        Title="{Binding Title}"
 | 
			
		||||
        Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
 | 
			
		||||
        Opened="OnOpened"
 | 
			
		||||
@@ -31,12 +32,18 @@
 | 
			
		||||
        </Style>
 | 
			
		||||
    </Window.Styles>
 | 
			
		||||
 | 
			
		||||
    <StackPanel Margin="20">
 | 
			
		||||
        <DockPanel>
 | 
			
		||||
            <TextBlock DockPanel.Dock="Right" Text="{Binding Items}" Classes="items" />
 | 
			
		||||
            <TextBlock DockPanel.Dock="Left" Text="{Binding Message}" />
 | 
			
		||||
        </DockPanel>
 | 
			
		||||
        <ProgressBar Value="{Binding Progress}" />
 | 
			
		||||
    </StackPanel>
 | 
			
		||||
    <ItemsRepeater ItemsSource="{Binding Items}" Margin="0 10">
 | 
			
		||||
        <ItemsRepeater.ItemTemplate>
 | 
			
		||||
            <DataTemplate>
 | 
			
		||||
                <StackPanel Margin="20 10" IsHitTestVisible="{Binding IsVisible}" Opacity="{Binding Opacity}">
 | 
			
		||||
                    <DockPanel>
 | 
			
		||||
                        <TextBlock DockPanel.Dock="Right" Text="{Binding Items}" Classes="items" />
 | 
			
		||||
                        <TextBlock DockPanel.Dock="Left" Text="{Binding Message}" />
 | 
			
		||||
                    </DockPanel>
 | 
			
		||||
                    <ProgressBar Value="{Binding Progress}" />
 | 
			
		||||
                </StackPanel>
 | 
			
		||||
            </DataTemplate>
 | 
			
		||||
        </ItemsRepeater.ItemTemplate>
 | 
			
		||||
    </ItemsRepeater>
 | 
			
		||||
 | 
			
		||||
</Window>
 | 
			
		||||
 
 | 
			
		||||
@@ -8,6 +8,7 @@ namespace DHT.Desktop.Dialogs.Progress;
 | 
			
		||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
 | 
			
		||||
public sealed partial class ProgressDialog : Window {
 | 
			
		||||
	private bool isFinished = false;
 | 
			
		||||
	private Task progressTask = Task.CompletedTask;
 | 
			
		||||
 | 
			
		||||
	public ProgressDialog() {
 | 
			
		||||
		InitializeComponent();
 | 
			
		||||
@@ -15,7 +16,8 @@ public sealed partial class ProgressDialog : Window {
 | 
			
		||||
 | 
			
		||||
	public void OnOpened(object? sender, EventArgs e) {
 | 
			
		||||
		if (DataContext is ProgressDialogModel model) {
 | 
			
		||||
			Task.Run(model.StartTask).ContinueWith(OnFinished, TaskScheduler.FromCurrentSynchronizationContext());
 | 
			
		||||
			progressTask = Task.Run(model.StartTask);
 | 
			
		||||
			progressTask.ContinueWith(OnFinished, TaskScheduler.FromCurrentSynchronizationContext());
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -27,4 +29,9 @@ public sealed partial class ProgressDialog : Window {
 | 
			
		||||
		isFinished = true;
 | 
			
		||||
		Close();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public async Task ShowProgressDialog(Window owner) {
 | 
			
		||||
		await ShowDialog(owner);
 | 
			
		||||
		await progressTask;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,6 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Linq;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using Avalonia.Threading;
 | 
			
		||||
using DHT.Desktop.Common;
 | 
			
		||||
@@ -9,57 +11,43 @@ namespace DHT.Desktop.Dialogs.Progress;
 | 
			
		||||
sealed class ProgressDialogModel : BaseModel {
 | 
			
		||||
	public string Title { get; init; } = "";
 | 
			
		||||
 | 
			
		||||
	private string message = "";
 | 
			
		||||
 | 
			
		||||
	public string Message {
 | 
			
		||||
		get => message;
 | 
			
		||||
		private set => Change(ref message, value);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private string items = "";
 | 
			
		||||
 | 
			
		||||
	public string Items {
 | 
			
		||||
		get => items;
 | 
			
		||||
		private set => Change(ref items, value);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private int progress = 0;
 | 
			
		||||
 | 
			
		||||
	public int Progress {
 | 
			
		||||
		get => progress;
 | 
			
		||||
		private set => Change(ref progress, value);
 | 
			
		||||
	}
 | 
			
		||||
	public IReadOnlyList<ProgressItem> Items { get; } = Array.Empty<ProgressItem>();
 | 
			
		||||
 | 
			
		||||
	private readonly TaskRunner? task;
 | 
			
		||||
 | 
			
		||||
	[Obsolete("Designer")]
 | 
			
		||||
	public ProgressDialogModel() {}
 | 
			
		||||
 | 
			
		||||
	public ProgressDialogModel(TaskRunner task) {
 | 
			
		||||
	public ProgressDialogModel(TaskRunner task, int progressItems = 1) {
 | 
			
		||||
		this.Items = Enumerable.Range(0, progressItems).Select(static _ => new ProgressItem()).ToArray();
 | 
			
		||||
		this.task = task;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	internal async Task StartTask() {
 | 
			
		||||
		if (task != null) {
 | 
			
		||||
			await task(new Callback(this));
 | 
			
		||||
			await task(Items.Select(static item => new Callback(item)).ToArray());
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public delegate Task TaskRunner(IProgressCallback callback);
 | 
			
		||||
	public delegate Task TaskRunner(IReadOnlyList<IProgressCallback> callbacks);
 | 
			
		||||
 | 
			
		||||
	private sealed class Callback : IProgressCallback {
 | 
			
		||||
		private readonly ProgressDialogModel model;
 | 
			
		||||
		private readonly ProgressItem item;
 | 
			
		||||
 | 
			
		||||
		public Callback(ProgressDialogModel model) {
 | 
			
		||||
			this.model = model;
 | 
			
		||||
		public Callback(ProgressItem item) {
 | 
			
		||||
			this.item = item;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		async Task IProgressCallback.Update(string message, int finishedItems, int totalItems) {
 | 
			
		||||
		public async Task Update(string message, int finishedItems, int totalItems) {
 | 
			
		||||
			await Dispatcher.UIThread.InvokeAsync(() => {
 | 
			
		||||
				model.Message = message;
 | 
			
		||||
				model.Items = finishedItems.Format() + " / " + totalItems.Format();
 | 
			
		||||
				model.Progress = 100 * finishedItems / totalItems;
 | 
			
		||||
				item.Message = message;
 | 
			
		||||
				item.Items = totalItems == 0 ? string.Empty : finishedItems.Format() + " / " + totalItems.Format();
 | 
			
		||||
				item.Progress = totalItems == 0 ? 0 : 100 * finishedItems / totalItems;
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		public Task Hide() {
 | 
			
		||||
			return Update(string.Empty, 0, 0);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										41
									
								
								app/Desktop/Dialogs/Progress/ProgressItem.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								app/Desktop/Dialogs/Progress/ProgressItem.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,41 @@
 | 
			
		||||
using DHT.Utils.Models;
 | 
			
		||||
 | 
			
		||||
namespace DHT.Desktop.Dialogs.Progress; 
 | 
			
		||||
 | 
			
		||||
sealed class ProgressItem : BaseModel {
 | 
			
		||||
	private bool isVisible = false;
 | 
			
		||||
 | 
			
		||||
	public bool IsVisible {
 | 
			
		||||
		get => isVisible;
 | 
			
		||||
		private set {
 | 
			
		||||
			Change(ref isVisible, value);
 | 
			
		||||
			OnPropertyChanged(nameof(Opacity));
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	
 | 
			
		||||
	public double Opacity => IsVisible ? 1.0 : 0.0;
 | 
			
		||||
 | 
			
		||||
	private string message = "";
 | 
			
		||||
 | 
			
		||||
	public string Message {
 | 
			
		||||
		get => message;
 | 
			
		||||
		set {
 | 
			
		||||
			Change(ref message, value);
 | 
			
		||||
			IsVisible = !string.IsNullOrEmpty(value);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private string items = "";
 | 
			
		||||
 | 
			
		||||
	public string Items {
 | 
			
		||||
		get => items;
 | 
			
		||||
		set => Change(ref items, value);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private int progress = 0;
 | 
			
		||||
 | 
			
		||||
	public int Progress {
 | 
			
		||||
		get => progress;
 | 
			
		||||
		set => Change(ref progress, value);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
@@ -5,6 +5,7 @@
 | 
			
		||||
        xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.TextBox"
 | 
			
		||||
        mc:Ignorable="d" d:DesignWidth="500"
 | 
			
		||||
        x:Class="DHT.Desktop.Dialogs.TextBox.TextBoxDialog"
 | 
			
		||||
        x:DataType="namespace:TextBoxDialogModel"
 | 
			
		||||
        Title="{Binding Title}"
 | 
			
		||||
        Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
 | 
			
		||||
        Width="500" SizeToContent="Height" CanResize="False"
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,9 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Diagnostics.CodeAnalysis;
 | 
			
		||||
using System.IO;
 | 
			
		||||
using System.Runtime.InteropServices;
 | 
			
		||||
using System.Text.Json;
 | 
			
		||||
using System.Text.Json.Nodes;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using DHT.Utils.Logging;
 | 
			
		||||
using static System.Environment.SpecialFolder;
 | 
			
		||||
@@ -47,12 +47,12 @@ static class DiscordAppSettings {
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private static bool AreDevToolsEnabled(Dictionary<string, object?> json) {
 | 
			
		||||
		return json.TryGetValue(JsonKeyDevTools, out var value) && value is JsonElement { ValueKind: JsonValueKind.True };
 | 
			
		||||
	private static bool AreDevToolsEnabled(JsonObject json) {
 | 
			
		||||
		return json.TryGetPropertyValue(JsonKeyDevTools, out var node) && node?.GetValueKind() == JsonValueKind.True;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public static async Task<SettingsJsonResult> ConfigureDevTools(bool enable) {
 | 
			
		||||
		Dictionary<string, object?> json;
 | 
			
		||||
		JsonObject json;
 | 
			
		||||
 | 
			
		||||
		try {
 | 
			
		||||
			json = await ReadSettingsJson();
 | 
			
		||||
@@ -109,13 +109,13 @@ static class DiscordAppSettings {
 | 
			
		||||
		return SettingsJsonResult.Success;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private static async Task<Dictionary<string, object?>> ReadSettingsJson() {
 | 
			
		||||
	private static async Task<JsonObject> ReadSettingsJson() {
 | 
			
		||||
		await using var stream = new FileStream(JsonFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
 | 
			
		||||
		return await JsonSerializer.DeserializeAsync<Dictionary<string, object?>?>(stream) ?? throw new JsonException();
 | 
			
		||||
		return await JsonSerializer.DeserializeAsync(stream, DiscordAppSettingsJsonContext.Default.JsonObject) ?? throw new JsonException();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private static async Task WriteSettingsJson(Dictionary<string, object?> json) {
 | 
			
		||||
	private static async Task WriteSettingsJson(JsonObject json) {
 | 
			
		||||
		await using var stream = new FileStream(JsonFilePath, FileMode.Truncate, FileAccess.Write, FileShare.None);
 | 
			
		||||
		await JsonSerializer.SerializeAsync(stream, json, new JsonSerializerOptions { WriteIndented = true });
 | 
			
		||||
		await JsonSerializer.SerializeAsync(stream, json, DiscordAppSettingsJsonContext.Default.JsonObject);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										8
									
								
								app/Desktop/Discord/DiscordAppSettingsJsonContext.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								app/Desktop/Discord/DiscordAppSettingsJsonContext.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
			
		||||
using System.Text.Json.Nodes;
 | 
			
		||||
using System.Text.Json.Serialization;
 | 
			
		||||
 | 
			
		||||
namespace DHT.Desktop.Discord;
 | 
			
		||||
 | 
			
		||||
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Default, WriteIndented = true)]
 | 
			
		||||
[JsonSerializable(typeof(JsonObject))]
 | 
			
		||||
sealed partial class DiscordAppSettingsJsonContext : JsonSerializerContext {}
 | 
			
		||||
@@ -5,6 +5,7 @@
 | 
			
		||||
        xmlns:main="clr-namespace:DHT.Desktop.Main"
 | 
			
		||||
        mc:Ignorable="d" d:DesignWidth="480" d:DesignHeight="295"
 | 
			
		||||
        x:Class="DHT.Desktop.Main.AboutWindow"
 | 
			
		||||
        x:DataType="main:AboutWindowModel"
 | 
			
		||||
        Title="About Discord History Tracker"
 | 
			
		||||
        Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
 | 
			
		||||
        Width="480" Height="295" CanResize="False"
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,8 @@
 | 
			
		||||
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
 | 
			
		||||
             xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
 | 
			
		||||
             mc:Ignorable="d"
 | 
			
		||||
             x:Class="DHT.Desktop.Main.Controls.AttachmentFilterPanel">
 | 
			
		||||
             x:Class="DHT.Desktop.Main.Controls.AttachmentFilterPanel"
 | 
			
		||||
             x:DataType="controls:AttachmentFilterPanelModel">
 | 
			
		||||
 | 
			
		||||
    <Design.DataContext>
 | 
			
		||||
        <controls:AttachmentFilterPanelModel />
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,8 @@
 | 
			
		||||
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
 | 
			
		||||
             xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
 | 
			
		||||
             mc:Ignorable="d"
 | 
			
		||||
             x:Class="DHT.Desktop.Main.Controls.MessageFilterPanel">
 | 
			
		||||
             x:Class="DHT.Desktop.Main.Controls.MessageFilterPanel"
 | 
			
		||||
             x:DataType="controls:MessageFilterPanelModel">
 | 
			
		||||
 | 
			
		||||
    <Design.DataContext>
 | 
			
		||||
        <controls:MessageFilterPanelModel />
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,8 @@
 | 
			
		||||
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
 | 
			
		||||
             xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
 | 
			
		||||
             mc:Ignorable="d"
 | 
			
		||||
             x:Class="DHT.Desktop.Main.Controls.ServerConfigurationPanel">
 | 
			
		||||
             x:Class="DHT.Desktop.Main.Controls.ServerConfigurationPanel"
 | 
			
		||||
             x:DataType="controls:ServerConfigurationPanelModel">
 | 
			
		||||
 | 
			
		||||
    <Design.DataContext>
 | 
			
		||||
        <controls:ServerConfigurationPanelModel />
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,8 @@
 | 
			
		||||
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
 | 
			
		||||
             xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
 | 
			
		||||
             mc:Ignorable="d"
 | 
			
		||||
             x:Class="DHT.Desktop.Main.Controls.StatusBar">
 | 
			
		||||
             x:Class="DHT.Desktop.Main.Controls.StatusBar"
 | 
			
		||||
             x:DataType="controls:StatusBarModel">
 | 
			
		||||
 | 
			
		||||
    <Design.DataContext>
 | 
			
		||||
        <controls:StatusBarModel />
 | 
			
		||||
 
 | 
			
		||||
@@ -5,6 +5,7 @@
 | 
			
		||||
        xmlns:main="clr-namespace:DHT.Desktop.Main"
 | 
			
		||||
        mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
 | 
			
		||||
        x:Class="DHT.Desktop.Main.MainWindow"
 | 
			
		||||
        x:DataType="main:MainWindowModel"
 | 
			
		||||
        Title="{Binding Title}"
 | 
			
		||||
        Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
 | 
			
		||||
        Width="800" Height="500"
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,8 @@
 | 
			
		||||
             xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
 | 
			
		||||
             xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
 | 
			
		||||
             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
 | 
			
		||||
             x:Class="DHT.Desktop.Main.Pages.AdvancedPage">
 | 
			
		||||
             x:Class="DHT.Desktop.Main.Pages.AdvancedPage"
 | 
			
		||||
             x:DataType="pages:AdvancedPageModel">
 | 
			
		||||
 | 
			
		||||
    <Design.DataContext>
 | 
			
		||||
        <pages:AdvancedPageModel />
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,8 @@
 | 
			
		||||
             xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
 | 
			
		||||
             xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
 | 
			
		||||
             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
 | 
			
		||||
             x:Class="DHT.Desktop.Main.Pages.AttachmentsPage">
 | 
			
		||||
             x:Class="DHT.Desktop.Main.Pages.AttachmentsPage"
 | 
			
		||||
             x:DataType="pages:AttachmentsPageModel">
 | 
			
		||||
 | 
			
		||||
    <Design.DataContext>
 | 
			
		||||
        <pages:AttachmentsPageModel />
 | 
			
		||||
@@ -35,7 +36,7 @@
 | 
			
		||||
            <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 !DataContext.IsDownloading, RelativeSource={RelativeSource AncestorType=UserControl}}" />
 | 
			
		||||
        <controls:AttachmentFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !IsDownloading, RelativeSource={RelativeSource AncestorType=pages:AttachmentsPageModel}}" />
 | 
			
		||||
        <StackPanel Orientation="Vertical" Spacing="12">
 | 
			
		||||
            <Expander Header="Download Status" IsExpanded="True">
 | 
			
		||||
                <DataGrid ItemsSource="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True">
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,8 @@
 | 
			
		||||
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
 | 
			
		||||
             xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
 | 
			
		||||
             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
 | 
			
		||||
             x:Class="DHT.Desktop.Main.Pages.DatabasePage">
 | 
			
		||||
             x:Class="DHT.Desktop.Main.Pages.DatabasePage"
 | 
			
		||||
             x:DataType="pages:DatabasePageModel">
 | 
			
		||||
 | 
			
		||||
    <Design.DataContext>
 | 
			
		||||
        <pages:DatabasePageModel />
 | 
			
		||||
 
 | 
			
		||||
@@ -17,6 +17,7 @@ using DHT.Desktop.Dialogs.TextBox;
 | 
			
		||||
using DHT.Server.Data;
 | 
			
		||||
using DHT.Server.Database;
 | 
			
		||||
using DHT.Server.Database.Import;
 | 
			
		||||
using DHT.Server.Database.Sqlite;
 | 
			
		||||
using DHT.Utils.Logging;
 | 
			
		||||
using DHT.Utils.Models;
 | 
			
		||||
 | 
			
		||||
@@ -77,31 +78,18 @@ sealed class DatabasePageModel : BaseModel {
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		ProgressDialog progressDialog = new ProgressDialog();
 | 
			
		||||
		progressDialog.DataContext = new ProgressDialogModel(async callback => await MergeWithDatabaseFromPaths(Db, paths, progressDialog, callback)) {
 | 
			
		||||
		progressDialog.DataContext = new ProgressDialogModel(async callbacks => await MergeWithDatabaseFromPaths(Db, paths, progressDialog, callbacks[0])) {
 | 
			
		||||
			Title = "Database Merge"
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		await progressDialog.ShowDialog(window);
 | 
			
		||||
		await progressDialog.ShowProgressDialog(window);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
 | 
			
		||||
		int total = paths.Length;
 | 
			
		||||
 | 
			
		||||
		DialogResult.YesNo? upgradeResult = null;
 | 
			
		||||
 | 
			
		||||
		async Task<bool> CheckCanUpgradeDatabase() {
 | 
			
		||||
			upgradeResult ??= total > 1
 | 
			
		||||
				                  ? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog)
 | 
			
		||||
				                  : await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog);
 | 
			
		||||
 | 
			
		||||
			return DialogResult.YesNo.Yes == upgradeResult;
 | 
			
		||||
		}
 | 
			
		||||
		var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, paths.Length);
 | 
			
		||||
		
 | 
			
		||||
		await PerformImport(target, paths, dialog, callback, "Database Merge", "Database Error", "database file", async path => {
 | 
			
		||||
			SynchronizationContext? prevSyncContext = SynchronizationContext.Current;
 | 
			
		||||
			SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
 | 
			
		||||
			IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, CheckCanUpgradeDatabase);
 | 
			
		||||
			SynchronizationContext.SetSynchronizationContext(prevSyncContext);
 | 
			
		||||
			IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, schemaUpgradeCallbacks);
 | 
			
		||||
 | 
			
		||||
			if (db == null) {
 | 
			
		||||
				return false;
 | 
			
		||||
@@ -116,6 +104,41 @@ sealed class DatabasePageModel : BaseModel {
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
 | 
			
		||||
		private readonly ProgressDialog dialog;
 | 
			
		||||
		private readonly int total;
 | 
			
		||||
		private bool? decision;
 | 
			
		||||
		
 | 
			
		||||
		public SchemaUpgradeCallbacks(ProgressDialog dialog, int total) {
 | 
			
		||||
			this.total = total;
 | 
			
		||||
			this.dialog = dialog;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		public async Task<bool> CanUpgrade() {
 | 
			
		||||
			return decision ??= (total > 1
 | 
			
		||||
				                     ? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog)
 | 
			
		||||
				                     : await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog)) == DialogResult.YesNo.Yes;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		public Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
 | 
			
		||||
			return doUpgrade(new NullReporter());
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		private sealed class NullReporter : ISchemaUpgradeCallbacks.IProgressReporter {
 | 
			
		||||
			public Task NextVersion() {
 | 
			
		||||
				return Task.CompletedTask;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			public Task MainWork(string message, int finishedItems, int totalItems) {
 | 
			
		||||
				return Task.CompletedTask;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			public Task SubWork(string message, int finishedItems, int totalItems) {
 | 
			
		||||
				return Task.CompletedTask;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public async void ImportLegacyArchive() {
 | 
			
		||||
		var paths = await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
 | 
			
		||||
			Title = "Open Legacy DHT Archive",
 | 
			
		||||
@@ -128,11 +151,11 @@ sealed class DatabasePageModel : BaseModel {
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		ProgressDialog progressDialog = new ProgressDialog();
 | 
			
		||||
		progressDialog.DataContext = new ProgressDialogModel(async callback => await ImportLegacyArchiveFromPaths(Db, paths, progressDialog, callback)) {
 | 
			
		||||
		progressDialog.DataContext = new ProgressDialogModel(async callbacks => await ImportLegacyArchiveFromPaths(Db, paths, progressDialog, callbacks[0])) {
 | 
			
		||||
			Title = "Legacy Archive Import"
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		await progressDialog.ShowDialog(window);
 | 
			
		||||
		await progressDialog.ShowProgressDialog(window);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private static async Task ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,8 @@
 | 
			
		||||
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
 | 
			
		||||
             xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
 | 
			
		||||
             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
 | 
			
		||||
             x:Class="DHT.Desktop.Main.Pages.DebugPage">
 | 
			
		||||
             x:Class="DHT.Desktop.Main.Pages.DebugPage"
 | 
			
		||||
             x:DataType="pages:DebugPageModel">
 | 
			
		||||
    
 | 
			
		||||
    <Design.DataContext>
 | 
			
		||||
        <pages:DebugPageModel />
 | 
			
		||||
 
 | 
			
		||||
@@ -45,12 +45,12 @@ namespace DHT.Desktop.Main.Pages {
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			ProgressDialog progressDialog = new ProgressDialog {
 | 
			
		||||
				DataContext = new ProgressDialogModel(async callback => await GenerateRandomData(channels, users, messages, callback)) {
 | 
			
		||||
				DataContext = new ProgressDialogModel(async callbacks => await GenerateRandomData(channels, users, messages, callbacks[0])) {
 | 
			
		||||
					Title = "Generating Random Data"
 | 
			
		||||
				}
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			await progressDialog.ShowDialog(window);
 | 
			
		||||
			await progressDialog.ShowProgressDialog(window);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		private const int BatchSize = 500;
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,8 @@
 | 
			
		||||
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
 | 
			
		||||
             xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
 | 
			
		||||
             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
 | 
			
		||||
             x:Class="DHT.Desktop.Main.Pages.TrackingPage">
 | 
			
		||||
             x:Class="DHT.Desktop.Main.Pages.TrackingPage"
 | 
			
		||||
             x:DataType="pages:TrackingPageModel">
 | 
			
		||||
 | 
			
		||||
    <Design.DataContext>
 | 
			
		||||
        <pages:TrackingPageModel />
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,8 @@
 | 
			
		||||
             xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
 | 
			
		||||
             xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
 | 
			
		||||
             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
 | 
			
		||||
             x:Class="DHT.Desktop.Main.Pages.ViewerPage">
 | 
			
		||||
             x:Class="DHT.Desktop.Main.Pages.ViewerPage"
 | 
			
		||||
             x:DataType="pages:ViewerPageModel">
 | 
			
		||||
 | 
			
		||||
    <Design.DataContext>
 | 
			
		||||
        <pages:ViewerPageModel />
 | 
			
		||||
 
 | 
			
		||||
@@ -35,7 +35,7 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
 | 
			
		||||
		set => Change(ref hasFilters, value);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private MessageFilterPanelModel FilterModel { get; }
 | 
			
		||||
	public MessageFilterPanelModel FilterModel { get; }
 | 
			
		||||
 | 
			
		||||
	private readonly Window window;
 | 
			
		||||
	private readonly IDatabaseFile db;
 | 
			
		||||
@@ -66,6 +66,8 @@ 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;
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,8 @@
 | 
			
		||||
             xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
 | 
			
		||||
             xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
 | 
			
		||||
             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
 | 
			
		||||
             x:Class="DHT.Desktop.Main.Screens.MainContentScreen">
 | 
			
		||||
             x:Class="DHT.Desktop.Main.Screens.MainContentScreen"
 | 
			
		||||
             x:DataType="screens:MainContentScreenModel">
 | 
			
		||||
 | 
			
		||||
    <Design.DataContext>
 | 
			
		||||
        <screens:MainContentScreenModel />
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,8 @@
 | 
			
		||||
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
 | 
			
		||||
             xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
 | 
			
		||||
             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
 | 
			
		||||
             x:Class="DHT.Desktop.Main.Screens.WelcomeScreen">
 | 
			
		||||
             x:Class="DHT.Desktop.Main.Screens.WelcomeScreen"
 | 
			
		||||
             x:DataType="screens:WelcomeScreenModel">
 | 
			
		||||
 | 
			
		||||
    <Design.DataContext>
 | 
			
		||||
        <screens:WelcomeScreenModel />
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,13 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.IO;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using Avalonia.Controls;
 | 
			
		||||
using DHT.Desktop.Common;
 | 
			
		||||
using DHT.Desktop.Dialogs.Message;
 | 
			
		||||
using DHT.Desktop.Dialogs.Progress;
 | 
			
		||||
using DHT.Server.Database;
 | 
			
		||||
using DHT.Server.Database.Sqlite;
 | 
			
		||||
using DHT.Utils.Models;
 | 
			
		||||
 | 
			
		||||
namespace DHT.Desktop.Main.Screens;
 | 
			
		||||
@@ -39,14 +42,71 @@ sealed class WelcomeScreenModel : BaseModel, IDisposable {
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		dbFilePath = path;
 | 
			
		||||
		Db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, CheckCanUpgradeDatabase);
 | 
			
		||||
		Db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, new SchemaUpgradeCallbacks(window));
 | 
			
		||||
 | 
			
		||||
		OnPropertyChanged(nameof(Db));
 | 
			
		||||
		OnPropertyChanged(nameof(HasDatabase));
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private async Task<bool> CheckCanUpgradeDatabase() {
 | 
			
		||||
		return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window);
 | 
			
		||||
	private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
 | 
			
		||||
		private readonly Window window;
 | 
			
		||||
		
 | 
			
		||||
		public SchemaUpgradeCallbacks(Window window) {
 | 
			
		||||
			this.window = window;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		public async Task<bool> CanUpgrade() {
 | 
			
		||||
			return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		public async Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
 | 
			
		||||
			async Task StartUpgrade(IReadOnlyList<IProgressCallback> callbacks) {
 | 
			
		||||
				var reporter = new ProgressReporter(versionSteps, callbacks);
 | 
			
		||||
				await reporter.NextVersion();
 | 
			
		||||
				await Task.Delay(TimeSpan.FromMilliseconds(800));
 | 
			
		||||
				await doUpgrade(reporter);
 | 
			
		||||
				await Task.Delay(TimeSpan.FromMilliseconds(600));
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			await new ProgressDialog {
 | 
			
		||||
				DataContext = new ProgressDialogModel(StartUpgrade, progressItems: 3) {
 | 
			
		||||
					Title = "Upgrading Database"
 | 
			
		||||
				}
 | 
			
		||||
			}.ShowProgressDialog(window);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		private sealed class ProgressReporter : ISchemaUpgradeCallbacks.IProgressReporter {
 | 
			
		||||
			private readonly IReadOnlyList<IProgressCallback> callbacks;
 | 
			
		||||
			
 | 
			
		||||
			private readonly int versionSteps;
 | 
			
		||||
			private int versionProgress = 0;
 | 
			
		||||
			
 | 
			
		||||
			public ProgressReporter(int versionSteps, IReadOnlyList<IProgressCallback> callbacks) {
 | 
			
		||||
				this.callbacks = callbacks;
 | 
			
		||||
				this.versionSteps = versionSteps;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			public async Task NextVersion() {
 | 
			
		||||
				await callbacks[0].Update("Upgrading schema version...", versionProgress++, versionSteps);
 | 
			
		||||
				await HideChildren(0);
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			public async Task MainWork(string message, int finishedItems, int totalItems) {
 | 
			
		||||
				await callbacks[1].Update(message, finishedItems, totalItems);
 | 
			
		||||
				await HideChildren(1);
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			public async Task SubWork(string message, int finishedItems, int totalItems) {
 | 
			
		||||
				await callbacks[2].Update(message, finishedItems, totalItems);
 | 
			
		||||
				await HideChildren(2);
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			private async Task HideChildren(int parentIndex) {
 | 
			
		||||
				for (int i = parentIndex + 1; i < callbacks.Count; i++) {
 | 
			
		||||
					await callbacks[i].Hide();
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public void CloseDatabase() {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,8 @@
 | 
			
		||||
using System.Globalization;
 | 
			
		||||
using System;
 | 
			
		||||
using System.Globalization;
 | 
			
		||||
using System.Reflection;
 | 
			
		||||
using Avalonia;
 | 
			
		||||
using DHT.Utils.Logging;
 | 
			
		||||
using DHT.Utils.Resources;
 | 
			
		||||
 | 
			
		||||
namespace DHT.Desktop;
 | 
			
		||||
@@ -9,6 +11,7 @@ static class Program {
 | 
			
		||||
	public static string Version { get; }
 | 
			
		||||
	public static CultureInfo Culture { get; }
 | 
			
		||||
	public static ResourceLoader Resources { get; }
 | 
			
		||||
	public static Arguments Arguments { get; }
 | 
			
		||||
 | 
			
		||||
	static Program() {
 | 
			
		||||
		var assembly = Assembly.GetExecutingAssembly();
 | 
			
		||||
@@ -25,10 +28,21 @@ static class Program {
 | 
			
		||||
		CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture;
 | 
			
		||||
 | 
			
		||||
		Resources = new ResourceLoader(assembly);
 | 
			
		||||
		Arguments = new Arguments(Environment.GetCommandLineArgs());
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public static void Main(string[] args) {
 | 
			
		||||
		BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
 | 
			
		||||
		if (Arguments.Console && OperatingSystem.IsWindows()) {
 | 
			
		||||
			WindowsConsole.AllocConsole();
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		try {
 | 
			
		||||
			BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
 | 
			
		||||
		} finally {
 | 
			
		||||
			if (Arguments.Console && OperatingSystem.IsWindows()) {
 | 
			
		||||
				WindowsConsole.FreeConsole();
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private static AppBuilder BuildAvaloniaApp() {
 | 
			
		||||
 
 | 
			
		||||
@@ -19,9 +19,21 @@
 | 
			
		||||
  </PropertyGroup>
 | 
			
		||||
  
 | 
			
		||||
  <PropertyGroup>
 | 
			
		||||
    <SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
 | 
			
		||||
    <PublishTrimmed>true</PublishTrimmed>
 | 
			
		||||
    <TrimMode>partial</TrimMode>
 | 
			
		||||
    <JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
 | 
			
		||||
    <EnableUnsafeBinaryFormatterSerialization>false</EnableUnsafeBinaryFormatterSerialization>
 | 
			
		||||
    <EnableUnsafeUTF7Encoding>false</EnableUnsafeUTF7Encoding>
 | 
			
		||||
    <EventSourceSupport>false</EventSourceSupport>
 | 
			
		||||
    <HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
 | 
			
		||||
    <JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
 | 
			
		||||
  </PropertyGroup>
 | 
			
		||||
  
 | 
			
		||||
  <PropertyGroup>
 | 
			
		||||
    <PublishSingleFile>true</PublishSingleFile>
 | 
			
		||||
    <PublishReadyToRun>false</PublishReadyToRun>
 | 
			
		||||
    <EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
 | 
			
		||||
    <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
 | 
			
		||||
  </PropertyGroup>
 | 
			
		||||
  
 | 
			
		||||
  <PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
 
 | 
			
		||||
@@ -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) {
 | 
			
		||||
 
 | 
			
		||||
@@ -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();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										3
									
								
								app/Server/Database/Export/Snowflake.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								app/Server/Database/Export/Snowflake.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
namespace DHT.Server.Database.Export; 
 | 
			
		||||
 | 
			
		||||
readonly record struct Snowflake(ulong Id);
 | 
			
		||||
							
								
								
									
										23
									
								
								app/Server/Database/Export/SnowflakeJsonSerializer.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								app/Server/Database/Export/SnowflakeJsonSerializer.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Text.Json;
 | 
			
		||||
using System.Text.Json.Serialization;
 | 
			
		||||
 | 
			
		||||
namespace DHT.Server.Database.Export;
 | 
			
		||||
 | 
			
		||||
sealed class SnowflakeJsonSerializer : JsonConverter<Snowflake> {
 | 
			
		||||
	public override Snowflake Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
 | 
			
		||||
		return new Snowflake(ulong.Parse(reader.GetString()!));
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public override void Write(Utf8JsonWriter writer, Snowflake value, JsonSerializerOptions options) {
 | 
			
		||||
		writer.WriteStringValue(value.Id.ToString());
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public override Snowflake ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
 | 
			
		||||
		return new Snowflake(ulong.Parse(reader.GetString()!));
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public override void WriteAsPropertyName(Utf8JsonWriter writer, Snowflake value, JsonSerializerOptions options) {
 | 
			
		||||
		writer.WritePropertyName(value.Id.ToString());
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
@@ -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);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
	}
 | 
			
		||||
 
 | 
			
		||||
@@ -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.
 | 
			
		||||
		
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										93
									
								
								app/Server/Database/Export/ViewerJson.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								app/Server/Database/Export/ViewerJson.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,93 @@
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Text.Json.Serialization;
 | 
			
		||||
 | 
			
		||||
namespace DHT.Server.Database.Export;
 | 
			
		||||
 | 
			
		||||
sealed class ViewerJson {
 | 
			
		||||
	public required JsonMeta Meta { get; init; }
 | 
			
		||||
	public required Dictionary<Snowflake, Dictionary<Snowflake, JsonMessage>> Data { get; init; }
 | 
			
		||||
 | 
			
		||||
	public sealed class JsonMeta {
 | 
			
		||||
		public required Dictionary<Snowflake, JsonUser> Users { get; init; }
 | 
			
		||||
		public required List<Snowflake> Userindex { get; init; }
 | 
			
		||||
		public required List<JsonServer> Servers { get; init; }
 | 
			
		||||
		public required Dictionary<Snowflake, JsonChannel> Channels { get; init; }
 | 
			
		||||
	}
 | 
			
		||||
	
 | 
			
		||||
	public sealed class JsonUser {
 | 
			
		||||
		public required string Name { get; init; }
 | 
			
		||||
		
 | 
			
		||||
		[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
 | 
			
		||||
		public string? Avatar { get; init; }
 | 
			
		||||
		
 | 
			
		||||
		[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
 | 
			
		||||
		public string? Tag { get; init; }
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public sealed class JsonServer {
 | 
			
		||||
		public required string Name { get; init; }
 | 
			
		||||
		public required string Type { get; init; }
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public sealed class JsonChannel {
 | 
			
		||||
		public required int Server { get; init; }
 | 
			
		||||
		public required string Name { get; init; }
 | 
			
		||||
		
 | 
			
		||||
		[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
 | 
			
		||||
		public string? Parent { get; init; }
 | 
			
		||||
		
 | 
			
		||||
		[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
 | 
			
		||||
		public int? Position { get; init; }
 | 
			
		||||
		
 | 
			
		||||
		[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
 | 
			
		||||
		public string? Topic { get; init; }
 | 
			
		||||
		
 | 
			
		||||
		[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
 | 
			
		||||
		public bool? Nsfw { get; init; }
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public sealed class JsonMessage {
 | 
			
		||||
		public required int U { get; init; }
 | 
			
		||||
		public required long T { get; init; }
 | 
			
		||||
 | 
			
		||||
		[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
 | 
			
		||||
		public string? M { get; init; }
 | 
			
		||||
 | 
			
		||||
		[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
 | 
			
		||||
		public long? Te { get; init; }
 | 
			
		||||
 | 
			
		||||
		[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
 | 
			
		||||
		public string? R { get; init; }
 | 
			
		||||
 | 
			
		||||
		[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
 | 
			
		||||
		public JsonMessageAttachment[]? A { get; init; }
 | 
			
		||||
		
 | 
			
		||||
		[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
 | 
			
		||||
		public string[]? E { get; init; }
 | 
			
		||||
		
 | 
			
		||||
		[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
 | 
			
		||||
		public JsonMessageReaction[]? Re { get; init; }
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public sealed class JsonMessageAttachment {
 | 
			
		||||
		public required string Url { get; init; }
 | 
			
		||||
		public required string Name { get; init; }
 | 
			
		||||
		
 | 
			
		||||
		[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
 | 
			
		||||
		public int? Width { get; set; }
 | 
			
		||||
		
 | 
			
		||||
		[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
 | 
			
		||||
		public int? Height { get; set; }
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public sealed class JsonMessageReaction {
 | 
			
		||||
		[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
 | 
			
		||||
		public string? Id { get; init; }
 | 
			
		||||
		
 | 
			
		||||
		[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
 | 
			
		||||
		public string? N { get; init; }
 | 
			
		||||
 | 
			
		||||
		public required bool A { get; init; }
 | 
			
		||||
		public required int C { get; init; }
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										11
									
								
								app/Server/Database/Export/ViewerJsonContext.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								app/Server/Database/Export/ViewerJsonContext.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
using System.Text.Json.Serialization;
 | 
			
		||||
 | 
			
		||||
namespace DHT.Server.Database.Export;
 | 
			
		||||
 | 
			
		||||
[JsonSourceGenerationOptions(
 | 
			
		||||
	Converters = new [] { typeof(SnowflakeJsonSerializer) },
 | 
			
		||||
	PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
 | 
			
		||||
	GenerationMode = JsonSourceGenerationMode.Default
 | 
			
		||||
)]
 | 
			
		||||
[JsonSerializable(typeof(ViewerJson))]
 | 
			
		||||
sealed partial class ViewerJsonContext : JsonSerializerContext {}
 | 
			
		||||
@@ -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) {
 | 
			
		||||
@@ -42,26 +42,28 @@ public static class ViewerJsonExport {
 | 
			
		||||
 | 
			
		||||
		perf.Step("Collect database data");
 | 
			
		||||
 | 
			
		||||
		var value = new {
 | 
			
		||||
			meta = new { users, userindex, servers, channels },
 | 
			
		||||
			data = GenerateMessageList(includedMessages, userIndices, strategy),
 | 
			
		||||
		var value = new ViewerJson {
 | 
			
		||||
			Meta = new ViewerJson.JsonMeta {
 | 
			
		||||
				Users = users,
 | 
			
		||||
				Userindex = userindex,
 | 
			
		||||
				Servers = servers,
 | 
			
		||||
				Channels = channels
 | 
			
		||||
			},
 | 
			
		||||
			Data = GenerateMessageList(includedMessages, userIndices, strategy)
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		perf.Step("Generate value object");
 | 
			
		||||
 | 
			
		||||
		var opts = new JsonSerializerOptions();
 | 
			
		||||
		opts.Converters.Add(new ViewerJsonSnowflakeSerializer());
 | 
			
		||||
 | 
			
		||||
		await JsonSerializer.SerializeAsync(stream, value, opts);
 | 
			
		||||
		await JsonSerializer.SerializeAsync(stream, value, ViewerJsonContext.Default.ViewerJson);
 | 
			
		||||
 | 
			
		||||
		perf.Step("Serialize to JSON");
 | 
			
		||||
		perf.End();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private static Dictionary<string, object> GenerateUserList(IDatabaseFile db, HashSet<ulong> userIds, out List<string> userindex, out Dictionary<ulong, object> userIndices) {
 | 
			
		||||
		var users = new Dictionary<string, object>();
 | 
			
		||||
		userindex = new List<string>();
 | 
			
		||||
		userIndices = new Dictionary<ulong, object>();
 | 
			
		||||
	private static Dictionary<Snowflake, ViewerJson.JsonUser> GenerateUserList(IDatabaseFile db, HashSet<ulong> userIds, out List<Snowflake> userindex, out Dictionary<ulong, int> userIndices) {
 | 
			
		||||
		var users = new Dictionary<Snowflake, ViewerJson.JsonUser>();
 | 
			
		||||
		userindex = new List<Snowflake>();
 | 
			
		||||
		userIndices = new Dictionary<ulong, int>();
 | 
			
		||||
 | 
			
		||||
		foreach (var user in db.GetAllUsers()) {
 | 
			
		||||
			var id = user.Id;
 | 
			
		||||
@@ -69,30 +71,23 @@ public static class ViewerJsonExport {
 | 
			
		||||
				continue;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			var obj = new Dictionary<string, object> {
 | 
			
		||||
				["name"] = user.Name
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			if (user.AvatarUrl != null) {
 | 
			
		||||
				obj["avatar"] = user.AvatarUrl;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if (user.Discriminator != null) {
 | 
			
		||||
				obj["tag"] = user.Discriminator;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			var idStr = id.ToString();
 | 
			
		||||
			var idSnowflake = new Snowflake(id);
 | 
			
		||||
			userIndices[id] = users.Count;
 | 
			
		||||
			userindex.Add(idStr);
 | 
			
		||||
			users[idStr] = obj;
 | 
			
		||||
			userindex.Add(idSnowflake);
 | 
			
		||||
			
 | 
			
		||||
			users[idSnowflake] = new ViewerJson.JsonUser {
 | 
			
		||||
				Name = user.Name,
 | 
			
		||||
				Avatar = user.AvatarUrl,
 | 
			
		||||
				Tag = user.Discriminator
 | 
			
		||||
			};
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return users;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private static List<object> GenerateServerList(IDatabaseFile db, HashSet<ulong> serverIds, out Dictionary<ulong, object> serverIndices) {
 | 
			
		||||
		var servers = new List<object>();
 | 
			
		||||
		serverIndices = new Dictionary<ulong, object>();
 | 
			
		||||
	private static List<ViewerJson.JsonServer> GenerateServerList(IDatabaseFile db, HashSet<ulong> serverIds, out Dictionary<ulong, int> serverIndices) {
 | 
			
		||||
		var servers = new List<ViewerJson.JsonServer>();
 | 
			
		||||
		serverIndices = new Dictionary<ulong, int>();
 | 
			
		||||
 | 
			
		||||
		foreach (var server in db.GetAllServers()) {
 | 
			
		||||
			var id = server.Id;
 | 
			
		||||
@@ -101,113 +96,78 @@ public static class ViewerJsonExport {
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			serverIndices[id] = servers.Count;
 | 
			
		||||
			servers.Add(new Dictionary<string, object> {
 | 
			
		||||
				["name"] = server.Name,
 | 
			
		||||
				["type"] = ServerTypes.ToJsonViewerString(server.Type),
 | 
			
		||||
			
 | 
			
		||||
			servers.Add(new ViewerJson.JsonServer {
 | 
			
		||||
				Name = server.Name,
 | 
			
		||||
				Type = ServerTypes.ToJsonViewerString(server.Type)
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return servers;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private static Dictionary<string, object> GenerateChannelList(List<Channel> includedChannels, Dictionary<ulong, object> serverIndices) {
 | 
			
		||||
		var channels = new Dictionary<string, object>();
 | 
			
		||||
	private static Dictionary<Snowflake, ViewerJson.JsonChannel> GenerateChannelList(List<Channel> includedChannels, Dictionary<ulong, int> serverIndices) {
 | 
			
		||||
		var channels = new Dictionary<Snowflake, ViewerJson.JsonChannel>();
 | 
			
		||||
 | 
			
		||||
		foreach (var channel in includedChannels) {
 | 
			
		||||
			var obj = new Dictionary<string, object> {
 | 
			
		||||
				["server"] = serverIndices[channel.Server],
 | 
			
		||||
				["name"] = channel.Name,
 | 
			
		||||
			var channelIdSnowflake = new Snowflake(channel.Id);
 | 
			
		||||
			
 | 
			
		||||
			channels[channelIdSnowflake] = new ViewerJson.JsonChannel {
 | 
			
		||||
				Server = serverIndices[channel.Server],
 | 
			
		||||
				Name = channel.Name,
 | 
			
		||||
				Parent = channel.ParentId?.ToString(),
 | 
			
		||||
				Position = channel.Position,
 | 
			
		||||
				Topic = channel.Topic,
 | 
			
		||||
				Nsfw = channel.Nsfw
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			if (channel.ParentId != null) {
 | 
			
		||||
				obj["parent"] = channel.ParentId;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if (channel.Position != null) {
 | 
			
		||||
				obj["position"] = channel.Position;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if (channel.Topic != null) {
 | 
			
		||||
				obj["topic"] = channel.Topic;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if (channel.Nsfw != null) {
 | 
			
		||||
				obj["nsfw"] = channel.Nsfw;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			channels[channel.Id.ToString()] = obj;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return channels;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private static Dictionary<string, Dictionary<string, object>> GenerateMessageList( List<Message> includedMessages, Dictionary<ulong, object> userIndices, IViewerExportStrategy strategy) {
 | 
			
		||||
		var data = new Dictionary<string, Dictionary<string, object>>();
 | 
			
		||||
	private static Dictionary<Snowflake, Dictionary<Snowflake, ViewerJson.JsonMessage>> GenerateMessageList(List<Message> includedMessages, Dictionary<ulong, int> userIndices, IViewerExportStrategy strategy) {
 | 
			
		||||
		var data = new Dictionary<Snowflake, Dictionary<Snowflake, ViewerJson.JsonMessage>>();
 | 
			
		||||
 | 
			
		||||
		foreach (var grouping in includedMessages.GroupBy(static message => message.Channel)) {
 | 
			
		||||
			var channel = grouping.Key.ToString();
 | 
			
		||||
			var channelData = new Dictionary<string, object>();
 | 
			
		||||
			var channelIdSnowflake = new Snowflake(grouping.Key);
 | 
			
		||||
			var channelData = new Dictionary<Snowflake, ViewerJson.JsonMessage>();
 | 
			
		||||
 | 
			
		||||
			foreach (var message in grouping) {
 | 
			
		||||
				var obj = new Dictionary<string, object> {
 | 
			
		||||
					["u"] = userIndices[message.Sender],
 | 
			
		||||
					["t"] = message.Timestamp,
 | 
			
		||||
				};
 | 
			
		||||
				var messageIdSnowflake = new Snowflake(message.Id);
 | 
			
		||||
				
 | 
			
		||||
				if (!string.IsNullOrEmpty(message.Text)) {
 | 
			
		||||
					obj["m"] = message.Text;
 | 
			
		||||
				}
 | 
			
		||||
				channelData[messageIdSnowflake] = new ViewerJson.JsonMessage {
 | 
			
		||||
					U = userIndices[message.Sender],
 | 
			
		||||
					T = message.Timestamp,
 | 
			
		||||
					M = string.IsNullOrEmpty(message.Text) ? null : message.Text,
 | 
			
		||||
					Te = message.EditTimestamp,
 | 
			
		||||
					R = message.RepliedToId?.ToString(),
 | 
			
		||||
					
 | 
			
		||||
				if (message.EditTimestamp != null) {
 | 
			
		||||
					obj["te"] = message.EditTimestamp;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				if (message.RepliedToId != null) {
 | 
			
		||||
					obj["r"] = message.RepliedToId.Value;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				if (!message.Attachments.IsEmpty) {
 | 
			
		||||
					obj["a"] = message.Attachments.Select(attachment => {
 | 
			
		||||
						var a = new Dictionary<string, object> {
 | 
			
		||||
							{ "url", strategy.GetAttachmentUrl(attachment) },
 | 
			
		||||
							{ "name", Uri.TryCreate(attachment.NormalizedUrl, UriKind.Absolute, out var uri) ? Path.GetFileName(uri.LocalPath) : attachment.NormalizedUrl },
 | 
			
		||||
					A = message.Attachments.IsEmpty ? null : message.Attachments.Select(attachment => {
 | 
			
		||||
						var a = new ViewerJson.JsonMessageAttachment {
 | 
			
		||||
							Url = strategy.GetAttachmentUrl(attachment),
 | 
			
		||||
							Name = Uri.TryCreate(attachment.NormalizedUrl, UriKind.Absolute, out var uri) ? Path.GetFileName(uri.LocalPath) : attachment.NormalizedUrl
 | 
			
		||||
						};
 | 
			
		||||
 | 
			
		||||
						if (attachment is { Width: not null, Height: not null }) {
 | 
			
		||||
							a["width"] = attachment.Width;
 | 
			
		||||
							a["height"] = attachment.Height;
 | 
			
		||||
							a.Width = attachment.Width;
 | 
			
		||||
							a.Height = attachment.Height;
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
						return a;
 | 
			
		||||
					}).ToArray();
 | 
			
		||||
				}
 | 
			
		||||
					}).ToArray(),
 | 
			
		||||
					
 | 
			
		||||
				if (!message.Embeds.IsEmpty) {
 | 
			
		||||
					obj["e"] = message.Embeds.Select(static embed => embed.Json).ToArray();
 | 
			
		||||
				}
 | 
			
		||||
					E = message.Embeds.IsEmpty ? null : message.Embeds.Select(static embed => embed.Json).ToArray(),
 | 
			
		||||
					
 | 
			
		||||
				if (!message.Reactions.IsEmpty) {
 | 
			
		||||
					obj["re"] = message.Reactions.Select(static reaction => {
 | 
			
		||||
						var r = new Dictionary<string, object>();
 | 
			
		||||
 | 
			
		||||
						if (reaction.EmojiId != null) {
 | 
			
		||||
							r["id"] = reaction.EmojiId.Value;
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
						if (reaction.EmojiName != null) {
 | 
			
		||||
							r["n"] = reaction.EmojiName;
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
						r["a"] = reaction.EmojiFlags.HasFlag(EmojiFlags.Animated);
 | 
			
		||||
						r["c"] = reaction.Count;
 | 
			
		||||
						return r;
 | 
			
		||||
					}).ToArray();
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				channelData[message.Id.ToString()] = obj;
 | 
			
		||||
					Re = message.Reactions.IsEmpty ? null : message.Reactions.Select(static reaction => new ViewerJson.JsonMessageReaction {
 | 
			
		||||
						Id = reaction.EmojiId?.ToString(),
 | 
			
		||||
						N = reaction.EmojiName,
 | 
			
		||||
						A = reaction.EmojiFlags.HasFlag(EmojiFlags.Animated),
 | 
			
		||||
						C = reaction.Count
 | 
			
		||||
					}).ToArray()
 | 
			
		||||
				};
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			data[channel] = channelData;
 | 
			
		||||
			data[channelIdSnowflake] = channelData;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return data;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,15 +0,0 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Text.Json;
 | 
			
		||||
using System.Text.Json.Serialization;
 | 
			
		||||
 | 
			
		||||
namespace DHT.Server.Database.Export;
 | 
			
		||||
 | 
			
		||||
sealed class ViewerJsonSnowflakeSerializer : JsonConverter<ulong> {
 | 
			
		||||
	public override ulong Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
 | 
			
		||||
		return ulong.Parse(reader.GetString()!);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public override void Write(Utf8JsonWriter writer, ulong value, JsonSerializerOptions options) {
 | 
			
		||||
		writer.WriteStringValue(value.ToString());
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
@@ -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);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										23
									
								
								app/Server/Database/Import/DiscordEmbedLegacyJson.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								app/Server/Database/Import/DiscordEmbedLegacyJson.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
			
		||||
using System.Text.Json.Serialization;
 | 
			
		||||
 | 
			
		||||
namespace DHT.Server.Database.Import; 
 | 
			
		||||
 | 
			
		||||
sealed class DiscordEmbedLegacyJson {
 | 
			
		||||
	public required string Url { get; init; }
 | 
			
		||||
	public required string Type { get; init; }
 | 
			
		||||
	
 | 
			
		||||
	public bool DhtLegacy { get; } = true;
 | 
			
		||||
 | 
			
		||||
	[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
 | 
			
		||||
	public string? Title { get; init; }
 | 
			
		||||
	
 | 
			
		||||
	[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
 | 
			
		||||
	public string? Description { get; init; }
 | 
			
		||||
 | 
			
		||||
	[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
 | 
			
		||||
	public ImageJson? Image { get; init; }
 | 
			
		||||
 | 
			
		||||
	public sealed class ImageJson {
 | 
			
		||||
		public required string Url { get; init; }
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,7 @@
 | 
			
		||||
using System.Text.Json.Serialization;
 | 
			
		||||
 | 
			
		||||
namespace DHT.Server.Database.Import;
 | 
			
		||||
 | 
			
		||||
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, GenerationMode = JsonSourceGenerationMode.Default)]
 | 
			
		||||
[JsonSerializable(typeof(DiscordEmbedLegacyJson))]
 | 
			
		||||
sealed partial class DiscordEmbedLegacyJsonContext : JsonSerializerContext {}
 | 
			
		||||
@@ -21,7 +21,7 @@ public static class LegacyArchiveImport {
 | 
			
		||||
 | 
			
		||||
	public static async Task<bool> Read(Stream stream, IDatabaseFile db, FakeSnowflake fakeSnowflake, Func<Data.Server[], Task<Dictionary<Data.Server, ulong>?>> askForServerIds) {
 | 
			
		||||
		var perf = Log.Start();
 | 
			
		||||
		var root = await JsonSerializer.DeserializeAsync<JsonElement>(stream);
 | 
			
		||||
		var root = await JsonSerializer.DeserializeAsync(stream, JsonElementContext.Default.JsonElement);
 | 
			
		||||
 | 
			
		||||
		try {
 | 
			
		||||
			var meta = root.RequireObject("meta");
 | 
			
		||||
@@ -213,29 +213,16 @@ public static class LegacyArchiveImport {
 | 
			
		||||
			string url = embedObj.RequireString("url", path);
 | 
			
		||||
			string type = embedObj.RequireString("type", path);
 | 
			
		||||
			
 | 
			
		||||
			var embedJson = new Dictionary<string, object> {
 | 
			
		||||
				{ "url", url },
 | 
			
		||||
				{ "type", type },
 | 
			
		||||
				{ "dht_legacy", true },
 | 
			
		||||
			var embed = new DiscordEmbedLegacyJson {
 | 
			
		||||
				Url = url,
 | 
			
		||||
				Type = type,
 | 
			
		||||
				Title = type == "rich" && embedObj.HasKey("t") ? embedObj.RequireString("t", path) : null,
 | 
			
		||||
				Description = type == "rich" && embedObj.HasKey("d") ? embedObj.RequireString("d", path) : null,
 | 
			
		||||
				Image = type == "image" ? new DiscordEmbedLegacyJson.ImageJson { Url = url } : null
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			if (type == "image") {
 | 
			
		||||
				embedJson["image"] = new Dictionary<string, string> {
 | 
			
		||||
					{ "url", url }
 | 
			
		||||
				};
 | 
			
		||||
			}
 | 
			
		||||
			else if (type == "rich") {
 | 
			
		||||
				if (embedObj.HasKey("t")) {
 | 
			
		||||
					embedJson["title"] = embedObj.RequireString("t", path);
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				if (embedObj.HasKey("d")) {
 | 
			
		||||
					embedJson["description"] = embedObj.RequireString("d", path);
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			return new Embed {
 | 
			
		||||
				Json = JsonSerializer.Serialize(embedJson)
 | 
			
		||||
				Json = JsonSerializer.Serialize(embed, DiscordEmbedLegacyJsonContext.Default.DiscordEmbedLegacyJson)
 | 
			
		||||
			};
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										15
									
								
								app/Server/Database/Sqlite/ISchemaUpgradeCallbacks.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								app/Server/Database/Sqlite/ISchemaUpgradeCallbacks.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,15 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
 | 
			
		||||
namespace DHT.Server.Database.Sqlite; 
 | 
			
		||||
 | 
			
		||||
public interface ISchemaUpgradeCallbacks {
 | 
			
		||||
	Task<bool> CanUpgrade();
 | 
			
		||||
	Task Start(int versionSteps, Func<IProgressReporter, Task> doUpgrade);
 | 
			
		||||
 | 
			
		||||
	public interface IProgressReporter {
 | 
			
		||||
		Task NextVersion();
 | 
			
		||||
		Task MainWork(string message, int finishedItems, int totalItems);
 | 
			
		||||
		Task SubWork(string message, int finishedItems, int totalItems);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
@@ -1,4 +1,3 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using DHT.Server.Database.Exceptions;
 | 
			
		||||
@@ -20,12 +19,8 @@ sealed class Schema {
 | 
			
		||||
		this.conn = conn;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private void Execute(string sql) {
 | 
			
		||||
		conn.Command(sql).ExecuteNonQuery();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public async Task<bool> Setup(Func<Task<bool>> checkCanUpgradeSchemas) {
 | 
			
		||||
		Execute(@"CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT)");
 | 
			
		||||
	public async Task<bool> Setup(ISchemaUpgradeCallbacks callbacks) {
 | 
			
		||||
		conn.Execute(@"CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT)");
 | 
			
		||||
 | 
			
		||||
		var dbVersionStr = conn.SelectScalar("SELECT value FROM metadata WHERE key = 'version'");
 | 
			
		||||
		if (dbVersionStr == null) {
 | 
			
		||||
@@ -38,131 +33,133 @@ sealed class Schema {
 | 
			
		||||
			throw new DatabaseTooNewException(dbVersion);
 | 
			
		||||
		}
 | 
			
		||||
		else if (dbVersion < Version) {
 | 
			
		||||
			var proceed = await checkCanUpgradeSchemas();
 | 
			
		||||
			var proceed = await callbacks.CanUpgrade();
 | 
			
		||||
			if (!proceed) {
 | 
			
		||||
				return false;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			UpgradeSchemas(dbVersion);
 | 
			
		||||
			await callbacks.Start(Version - dbVersion, async reporter => await UpgradeSchemas(dbVersion, reporter));
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return true;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private void InitializeSchemas() {
 | 
			
		||||
		Execute("""
 | 
			
		||||
				CREATE TABLE users (
 | 
			
		||||
					id            INTEGER PRIMARY KEY NOT NULL,
 | 
			
		||||
					name          TEXT NOT NULL,
 | 
			
		||||
					avatar_url    TEXT,
 | 
			
		||||
					discriminator TEXT
 | 
			
		||||
				)
 | 
			
		||||
				""");
 | 
			
		||||
		conn.Execute("""
 | 
			
		||||
		             CREATE TABLE users (
 | 
			
		||||
		             	id            INTEGER PRIMARY KEY NOT NULL,
 | 
			
		||||
		             	name          TEXT NOT NULL,
 | 
			
		||||
		             	avatar_url    TEXT,
 | 
			
		||||
		             	discriminator TEXT
 | 
			
		||||
		             )
 | 
			
		||||
		             """);
 | 
			
		||||
 | 
			
		||||
		Execute("""
 | 
			
		||||
				CREATE TABLE servers (
 | 
			
		||||
					id   INTEGER PRIMARY KEY NOT NULL,
 | 
			
		||||
					name TEXT NOT NULL,
 | 
			
		||||
					type TEXT NOT NULL
 | 
			
		||||
				)
 | 
			
		||||
				""");
 | 
			
		||||
		conn.Execute("""
 | 
			
		||||
		             CREATE TABLE servers (
 | 
			
		||||
		             	id   INTEGER PRIMARY KEY NOT NULL,
 | 
			
		||||
		             	name TEXT NOT NULL,
 | 
			
		||||
		             	type TEXT NOT NULL
 | 
			
		||||
		             )
 | 
			
		||||
		             """);
 | 
			
		||||
 | 
			
		||||
		Execute("""
 | 
			
		||||
				CREATE TABLE channels (
 | 
			
		||||
					id        INTEGER PRIMARY KEY NOT NULL,
 | 
			
		||||
					server    INTEGER NOT NULL,
 | 
			
		||||
					name      TEXT NOT NULL,
 | 
			
		||||
					parent_id INTEGER,
 | 
			
		||||
					position  INTEGER,
 | 
			
		||||
					topic     TEXT,
 | 
			
		||||
					nsfw      INTEGER
 | 
			
		||||
				)
 | 
			
		||||
				""");
 | 
			
		||||
		conn.Execute("""
 | 
			
		||||
		             CREATE TABLE channels (
 | 
			
		||||
		             	id        INTEGER PRIMARY KEY NOT NULL,
 | 
			
		||||
		             	server    INTEGER NOT NULL,
 | 
			
		||||
		             	name      TEXT NOT NULL,
 | 
			
		||||
		             	parent_id INTEGER,
 | 
			
		||||
		             	position  INTEGER,
 | 
			
		||||
		             	topic     TEXT,
 | 
			
		||||
		             	nsfw      INTEGER
 | 
			
		||||
		             )
 | 
			
		||||
		             """);
 | 
			
		||||
 | 
			
		||||
		Execute("""
 | 
			
		||||
				CREATE TABLE messages (
 | 
			
		||||
					message_id INTEGER PRIMARY KEY NOT NULL,
 | 
			
		||||
					sender_id  INTEGER NOT NULL,
 | 
			
		||||
					channel_id INTEGER NOT NULL,
 | 
			
		||||
					text       TEXT NOT NULL,
 | 
			
		||||
					timestamp  INTEGER NOT NULL
 | 
			
		||||
				)
 | 
			
		||||
				""");
 | 
			
		||||
		conn.Execute("""
 | 
			
		||||
		             CREATE TABLE messages (
 | 
			
		||||
		             	message_id INTEGER PRIMARY KEY NOT NULL,
 | 
			
		||||
		             	sender_id  INTEGER NOT NULL,
 | 
			
		||||
		             	channel_id INTEGER NOT NULL,
 | 
			
		||||
		             	text       TEXT NOT NULL,
 | 
			
		||||
		             	timestamp  INTEGER NOT NULL
 | 
			
		||||
		             )
 | 
			
		||||
		             """);
 | 
			
		||||
 | 
			
		||||
		Execute("""
 | 
			
		||||
				CREATE TABLE attachments (
 | 
			
		||||
					message_id     INTEGER NOT NULL,
 | 
			
		||||
					attachment_id  INTEGER NOT NULL PRIMARY KEY NOT NULL,
 | 
			
		||||
					name           TEXT NOT NULL,
 | 
			
		||||
					type           TEXT,
 | 
			
		||||
					normalized_url TEXT NOT NULL,
 | 
			
		||||
					download_url   TEXT,
 | 
			
		||||
					size           INTEGER NOT NULL,
 | 
			
		||||
					width          INTEGER,
 | 
			
		||||
					height         INTEGER
 | 
			
		||||
				)
 | 
			
		||||
				""");
 | 
			
		||||
		conn.Execute("""
 | 
			
		||||
		             CREATE TABLE attachments (
 | 
			
		||||
		             	message_id     INTEGER NOT NULL,
 | 
			
		||||
		             	attachment_id  INTEGER NOT NULL PRIMARY KEY NOT NULL,
 | 
			
		||||
		             	name           TEXT NOT NULL,
 | 
			
		||||
		             	type           TEXT,
 | 
			
		||||
		             	normalized_url TEXT NOT NULL,
 | 
			
		||||
		             	download_url   TEXT,
 | 
			
		||||
		             	size           INTEGER NOT NULL,
 | 
			
		||||
		             	width          INTEGER,
 | 
			
		||||
		             	height         INTEGER
 | 
			
		||||
		             )
 | 
			
		||||
		             """);
 | 
			
		||||
 | 
			
		||||
		Execute("""
 | 
			
		||||
				CREATE TABLE embeds (
 | 
			
		||||
					message_id INTEGER NOT NULL,
 | 
			
		||||
					json       TEXT NOT NULL
 | 
			
		||||
				)
 | 
			
		||||
				""");
 | 
			
		||||
		conn.Execute("""
 | 
			
		||||
		             CREATE TABLE embeds (
 | 
			
		||||
		             	message_id INTEGER NOT NULL,
 | 
			
		||||
		             	json       TEXT NOT NULL
 | 
			
		||||
		             )
 | 
			
		||||
		             """);
 | 
			
		||||
 | 
			
		||||
		Execute("""
 | 
			
		||||
				CREATE TABLE downloads (
 | 
			
		||||
					normalized_url TEXT NOT NULL PRIMARY KEY,
 | 
			
		||||
					download_url   TEXT,
 | 
			
		||||
					status         INTEGER NOT NULL,
 | 
			
		||||
					size           INTEGER NOT NULL,
 | 
			
		||||
					blob           BLOB
 | 
			
		||||
				)
 | 
			
		||||
				""");
 | 
			
		||||
		conn.Execute("""
 | 
			
		||||
		             CREATE TABLE downloads (
 | 
			
		||||
		             	normalized_url TEXT NOT NULL PRIMARY KEY,
 | 
			
		||||
		             	download_url   TEXT,
 | 
			
		||||
		             	status         INTEGER NOT NULL,
 | 
			
		||||
		             	size           INTEGER NOT NULL,
 | 
			
		||||
		             	blob           BLOB
 | 
			
		||||
		             )
 | 
			
		||||
		             """);
 | 
			
		||||
		
 | 
			
		||||
		Execute("""
 | 
			
		||||
				CREATE TABLE reactions (
 | 
			
		||||
					message_id  INTEGER NOT NULL,
 | 
			
		||||
					emoji_id    INTEGER,
 | 
			
		||||
					emoji_name  TEXT,
 | 
			
		||||
					emoji_flags INTEGER NOT NULL,
 | 
			
		||||
					count       INTEGER NOT NULL
 | 
			
		||||
				)
 | 
			
		||||
				""");
 | 
			
		||||
		conn.Execute("""
 | 
			
		||||
		             CREATE TABLE reactions (
 | 
			
		||||
		             	message_id  INTEGER NOT NULL,
 | 
			
		||||
		             	emoji_id    INTEGER,
 | 
			
		||||
		             	emoji_name  TEXT,
 | 
			
		||||
		             	emoji_flags INTEGER NOT NULL,
 | 
			
		||||
		             	count       INTEGER NOT NULL
 | 
			
		||||
		             )
 | 
			
		||||
		             """);
 | 
			
		||||
 | 
			
		||||
		CreateMessageEditTimestampTable();
 | 
			
		||||
		CreateMessageRepliedToTable();
 | 
			
		||||
 | 
			
		||||
		Execute("CREATE INDEX attachments_message_ix ON attachments(message_id)");
 | 
			
		||||
		Execute("CREATE INDEX embeds_message_ix ON embeds(message_id)");
 | 
			
		||||
		Execute("CREATE INDEX reactions_message_ix ON reactions(message_id)");
 | 
			
		||||
		conn.Execute("CREATE INDEX attachments_message_ix ON attachments(message_id)");
 | 
			
		||||
		conn.Execute("CREATE INDEX embeds_message_ix ON embeds(message_id)");
 | 
			
		||||
		conn.Execute("CREATE INDEX reactions_message_ix ON reactions(message_id)");
 | 
			
		||||
 | 
			
		||||
		Execute("INSERT INTO metadata (key, value) VALUES ('version', " + Version + ")");
 | 
			
		||||
		conn.Execute("INSERT INTO metadata (key, value) VALUES ('version', " + Version + ")");
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private void CreateMessageEditTimestampTable() {
 | 
			
		||||
		Execute("""
 | 
			
		||||
				CREATE TABLE edit_timestamps (
 | 
			
		||||
					message_id     INTEGER PRIMARY KEY NOT NULL,
 | 
			
		||||
					edit_timestamp INTEGER NOT NULL
 | 
			
		||||
				)
 | 
			
		||||
				""");
 | 
			
		||||
		conn.Execute("""
 | 
			
		||||
		             CREATE TABLE edit_timestamps (
 | 
			
		||||
		             	message_id     INTEGER PRIMARY KEY NOT NULL,
 | 
			
		||||
		             	edit_timestamp INTEGER NOT NULL
 | 
			
		||||
		             )
 | 
			
		||||
		             """);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private void CreateMessageRepliedToTable() {
 | 
			
		||||
		Execute("""
 | 
			
		||||
				CREATE TABLE replied_to (
 | 
			
		||||
					message_id    INTEGER PRIMARY KEY NOT NULL,
 | 
			
		||||
					replied_to_id INTEGER NOT NULL
 | 
			
		||||
				)
 | 
			
		||||
				""");
 | 
			
		||||
		conn.Execute("""
 | 
			
		||||
		             CREATE TABLE replied_to (
 | 
			
		||||
		             	message_id    INTEGER PRIMARY KEY NOT NULL,
 | 
			
		||||
		             	replied_to_id INTEGER NOT NULL
 | 
			
		||||
		             )
 | 
			
		||||
		             """);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private void NormalizeAttachmentUrls() {
 | 
			
		||||
	private async Task NormalizeAttachmentUrls(ISchemaUpgradeCallbacks.IProgressReporter reporter) {
 | 
			
		||||
		await reporter.SubWork("Preparing attachments...", 0, 0);
 | 
			
		||||
		
 | 
			
		||||
		var normalizedUrls = new Dictionary<long, string>();
 | 
			
		||||
 | 
			
		||||
		using (var selectCmd = conn.Command("SELECT attachment_id, url FROM attachments")) {
 | 
			
		||||
			using var reader = selectCmd.ExecuteReader();
 | 
			
		||||
		await using (var selectCmd = conn.Command("SELECT attachment_id, url FROM attachments")) {
 | 
			
		||||
			await using var reader = await selectCmd.ExecuteReaderAsync();
 | 
			
		||||
			
 | 
			
		||||
			while (reader.Read()) {
 | 
			
		||||
				var attachmentId = reader.GetInt64(0);
 | 
			
		||||
@@ -171,28 +168,39 @@ sealed class Schema {
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		using var tx = conn.BeginTransaction();
 | 
			
		||||
		await using var tx = conn.BeginTransaction();
 | 
			
		||||
 | 
			
		||||
		using (var updateCmd = conn.Command("UPDATE attachments SET download_url = url, url = :normalized_url WHERE attachment_id = :attachment_id")) {
 | 
			
		||||
		int totalUrls = normalizedUrls.Count;
 | 
			
		||||
		int processedUrls = -1;
 | 
			
		||||
 | 
			
		||||
		await using (var updateCmd = conn.Command("UPDATE attachments SET download_url = url, url = :normalized_url WHERE attachment_id = :attachment_id")) {
 | 
			
		||||
			updateCmd.Parameters.Add(":attachment_id", SqliteType.Integer);
 | 
			
		||||
			updateCmd.Parameters.Add(":normalized_url", SqliteType.Text);
 | 
			
		||||
				
 | 
			
		||||
			foreach (var (attachmentId, normalizedUrl) in normalizedUrls) {
 | 
			
		||||
				if (++processedUrls % 1000 == 0) {
 | 
			
		||||
					await reporter.SubWork("Updating URLs...", processedUrls, totalUrls);
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				updateCmd.Set(":attachment_id", attachmentId);
 | 
			
		||||
				updateCmd.Set(":normalized_url", normalizedUrl);
 | 
			
		||||
				updateCmd.ExecuteNonQuery();
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		
 | 
			
		||||
		tx.Commit();
 | 
			
		||||
		await reporter.SubWork("Updating URLs...", totalUrls, totalUrls);
 | 
			
		||||
		
 | 
			
		||||
		await tx.CommitAsync();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private void NormalizeDownloadUrls() {
 | 
			
		||||
	private async Task NormalizeDownloadUrls(ISchemaUpgradeCallbacks.IProgressReporter reporter) {
 | 
			
		||||
		await reporter.SubWork("Preparing downloads...", 0, 0);
 | 
			
		||||
		
 | 
			
		||||
		var normalizedUrlsToOriginalUrls = new Dictionary<string, string>();
 | 
			
		||||
		var duplicateUrlsToDelete = new HashSet<string>();
 | 
			
		||||
 | 
			
		||||
		using (var selectCmd = conn.Command("SELECT url FROM downloads ORDER BY CASE WHEN status = 200 THEN 0 ELSE 1 END")) {
 | 
			
		||||
			using var reader = selectCmd.ExecuteReader();
 | 
			
		||||
		await using (var selectCmd = conn.Command("SELECT url FROM downloads ORDER BY CASE WHEN status = 200 THEN 0 ELSE 1 END")) {
 | 
			
		||||
			await using var reader = await selectCmd.ExecuteReaderAsync();
 | 
			
		||||
 | 
			
		||||
			while (reader.Read()) {
 | 
			
		||||
				var originalUrl = reader.GetString(0);
 | 
			
		||||
@@ -204,96 +212,144 @@ sealed class Schema {
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		using var tx = conn.BeginTransaction();
 | 
			
		||||
		conn.Execute("PRAGMA cache_size = -20000");
 | 
			
		||||
 | 
			
		||||
		using (var deleteCmd = conn.Delete("downloads", ("url", SqliteType.Text))) {
 | 
			
		||||
			foreach (var duplicateUrl in duplicateUrlsToDelete) {
 | 
			
		||||
				deleteCmd.Set(":url", duplicateUrl);
 | 
			
		||||
				deleteCmd.ExecuteNonQuery();
 | 
			
		||||
		SqliteTransaction tx;
 | 
			
		||||
		
 | 
			
		||||
		await using (tx = conn.BeginTransaction()) {
 | 
			
		||||
			await reporter.SubWork("Deleting duplicates...", 0, 0);
 | 
			
		||||
 | 
			
		||||
			await using (var deleteCmd = conn.Delete("downloads", ("url", SqliteType.Text))) {
 | 
			
		||||
				foreach (var duplicateUrl in duplicateUrlsToDelete) {
 | 
			
		||||
					deleteCmd.Set(":url", duplicateUrl);
 | 
			
		||||
					deleteCmd.ExecuteNonQuery();
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
			
 | 
			
		||||
			await tx.CommitAsync();
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		using (var updateCmd = conn.Command("UPDATE downloads SET download_url = :download_url, url = :normalized_url WHERE url = :download_url")) {
 | 
			
		||||
		int totalUrls = normalizedUrlsToOriginalUrls.Count;
 | 
			
		||||
		int processedUrls = -1;
 | 
			
		||||
 | 
			
		||||
		tx = conn.BeginTransaction();
 | 
			
		||||
		
 | 
			
		||||
		await using (var updateCmd = conn.Command("UPDATE downloads SET download_url = :download_url, url = :normalized_url WHERE url = :download_url")) {
 | 
			
		||||
			updateCmd.Parameters.Add(":normalized_url", SqliteType.Text);
 | 
			
		||||
			updateCmd.Parameters.Add(":download_url", SqliteType.Text);
 | 
			
		||||
			
 | 
			
		||||
			foreach (var (normalizedUrl, downloadUrl) in normalizedUrlsToOriginalUrls) {
 | 
			
		||||
				if (++processedUrls % 100 == 0) {
 | 
			
		||||
					await reporter.SubWork("Updating URLs...", processedUrls, totalUrls);
 | 
			
		||||
					
 | 
			
		||||
					// Not proper way of dealing with transactions, but it avoids a long commit at the end.
 | 
			
		||||
					// Schema upgrades are already non-atomic anyways, so this doesn't make it worse.
 | 
			
		||||
					await tx.CommitAsync();
 | 
			
		||||
					await tx.DisposeAsync();
 | 
			
		||||
					
 | 
			
		||||
					tx = conn.BeginTransaction();
 | 
			
		||||
					updateCmd.Transaction = tx;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				updateCmd.Set(":normalized_url", normalizedUrl);
 | 
			
		||||
				updateCmd.Set(":download_url", downloadUrl);
 | 
			
		||||
				updateCmd.ExecuteNonQuery();
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		
 | 
			
		||||
		tx.Commit();
 | 
			
		||||
		await reporter.SubWork("Updating URLs...", totalUrls, totalUrls);
 | 
			
		||||
		
 | 
			
		||||
		await tx.CommitAsync();
 | 
			
		||||
		await tx.DisposeAsync();
 | 
			
		||||
		
 | 
			
		||||
		conn.Execute("PRAGMA cache_size = -2000");
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private void UpgradeSchemas(int dbVersion) {
 | 
			
		||||
	private async Task UpgradeSchemas(int dbVersion, ISchemaUpgradeCallbacks.IProgressReporter reporter) {
 | 
			
		||||
		var perf = Log.Start("from version " + dbVersion);
 | 
			
		||||
 | 
			
		||||
		Execute("UPDATE metadata SET value = " + Version + " WHERE key = 'version'");
 | 
			
		||||
		conn.Execute("UPDATE metadata SET value = " + Version + " WHERE key = 'version'");
 | 
			
		||||
 | 
			
		||||
		if (dbVersion <= 1) {
 | 
			
		||||
			Execute("ALTER TABLE channels ADD parent_id INTEGER");
 | 
			
		||||
			await reporter.MainWork("Applying schema changes...", 0, 1);
 | 
			
		||||
			conn.Execute("ALTER TABLE channels ADD parent_id INTEGER");
 | 
			
		||||
			
 | 
			
		||||
			perf.Step("Upgrade to version 2");
 | 
			
		||||
			await reporter.NextVersion();
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (dbVersion <= 2) {
 | 
			
		||||
			await reporter.MainWork("Applying schema changes...", 0, 1);
 | 
			
		||||
			
 | 
			
		||||
			CreateMessageEditTimestampTable();
 | 
			
		||||
			CreateMessageRepliedToTable();
 | 
			
		||||
 | 
			
		||||
			Execute("""
 | 
			
		||||
					INSERT INTO edit_timestamps (message_id, edit_timestamp)
 | 
			
		||||
					SELECT message_id, edit_timestamp
 | 
			
		||||
					FROM messages
 | 
			
		||||
					WHERE edit_timestamp IS NOT NULL
 | 
			
		||||
					""");
 | 
			
		||||
			conn.Execute("""
 | 
			
		||||
			             INSERT INTO edit_timestamps (message_id, edit_timestamp)
 | 
			
		||||
			             SELECT message_id, edit_timestamp
 | 
			
		||||
			             FROM messages
 | 
			
		||||
			             WHERE edit_timestamp IS NOT NULL
 | 
			
		||||
			             """);
 | 
			
		||||
 | 
			
		||||
			Execute("""
 | 
			
		||||
					INSERT INTO replied_to (message_id, replied_to_id)
 | 
			
		||||
					SELECT message_id, replied_to_id
 | 
			
		||||
					FROM messages
 | 
			
		||||
					WHERE replied_to_id IS NOT NULL
 | 
			
		||||
					""");
 | 
			
		||||
			conn.Execute("""
 | 
			
		||||
			             INSERT INTO replied_to (message_id, replied_to_id)
 | 
			
		||||
			             SELECT message_id, replied_to_id
 | 
			
		||||
			             FROM messages
 | 
			
		||||
			             WHERE replied_to_id IS NOT NULL
 | 
			
		||||
			             """);
 | 
			
		||||
 | 
			
		||||
			Execute("ALTER TABLE messages DROP COLUMN replied_to_id");
 | 
			
		||||
			Execute("ALTER TABLE messages DROP COLUMN edit_timestamp");
 | 
			
		||||
			conn.Execute("ALTER TABLE messages DROP COLUMN replied_to_id");
 | 
			
		||||
			conn.Execute("ALTER TABLE messages DROP COLUMN edit_timestamp");
 | 
			
		||||
 | 
			
		||||
			perf.Step("Upgrade to version 3");
 | 
			
		||||
			
 | 
			
		||||
			Execute("VACUUM");
 | 
			
		||||
			await reporter.MainWork("Vacuuming the database...", 1, 1);
 | 
			
		||||
			conn.Execute("VACUUM");
 | 
			
		||||
			perf.Step("Vacuum");
 | 
			
		||||
			
 | 
			
		||||
			await reporter.NextVersion();
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (dbVersion <= 3) {
 | 
			
		||||
			Execute("""
 | 
			
		||||
					CREATE TABLE downloads (
 | 
			
		||||
						url    TEXT NOT NULL PRIMARY KEY,
 | 
			
		||||
						status INTEGER NOT NULL,
 | 
			
		||||
						size   INTEGER NOT NULL,
 | 
			
		||||
						blob   BLOB
 | 
			
		||||
					)
 | 
			
		||||
					""");
 | 
			
		||||
			conn.Execute("""
 | 
			
		||||
			             CREATE TABLE downloads (
 | 
			
		||||
			             	url    TEXT NOT NULL PRIMARY KEY,
 | 
			
		||||
			             	status INTEGER NOT NULL,
 | 
			
		||||
			             	size   INTEGER NOT NULL,
 | 
			
		||||
			             	blob   BLOB
 | 
			
		||||
			             )
 | 
			
		||||
			             """);
 | 
			
		||||
			
 | 
			
		||||
			perf.Step("Upgrade to version 4");
 | 
			
		||||
			await reporter.NextVersion();
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (dbVersion <= 4) {
 | 
			
		||||
			Execute("ALTER TABLE attachments ADD width INTEGER");
 | 
			
		||||
			Execute("ALTER TABLE attachments ADD height INTEGER");
 | 
			
		||||
			await reporter.MainWork("Applying schema changes...", 0, 1);
 | 
			
		||||
			conn.Execute("ALTER TABLE attachments ADD width INTEGER");
 | 
			
		||||
			conn.Execute("ALTER TABLE attachments ADD height INTEGER");
 | 
			
		||||
			
 | 
			
		||||
			perf.Step("Upgrade to version 5");
 | 
			
		||||
			await reporter.NextVersion();
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (dbVersion <= 5) {
 | 
			
		||||
			Execute("ALTER TABLE attachments ADD download_url TEXT");
 | 
			
		||||
			Execute("ALTER TABLE downloads ADD download_url TEXT");
 | 
			
		||||
			await reporter.MainWork("Applying schema changes...", 0, 3);
 | 
			
		||||
			conn.Execute("ALTER TABLE attachments ADD download_url TEXT");
 | 
			
		||||
			conn.Execute("ALTER TABLE downloads ADD download_url TEXT");
 | 
			
		||||
			
 | 
			
		||||
			NormalizeAttachmentUrls();
 | 
			
		||||
			NormalizeDownloadUrls();
 | 
			
		||||
			await reporter.MainWork("Updating attachments...", 1, 3);
 | 
			
		||||
			await NormalizeAttachmentUrls(reporter);
 | 
			
		||||
			
 | 
			
		||||
			Execute("ALTER TABLE attachments RENAME COLUMN url TO normalized_url");
 | 
			
		||||
			Execute("ALTER TABLE downloads RENAME COLUMN url TO normalized_url");
 | 
			
		||||
			await reporter.MainWork("Updating downloads...", 2, 3);
 | 
			
		||||
			await NormalizeDownloadUrls(reporter);
 | 
			
		||||
			
 | 
			
		||||
			await reporter.MainWork("Applying schema changes...", 3, 3);
 | 
			
		||||
			conn.Execute("ALTER TABLE attachments RENAME COLUMN url TO normalized_url");
 | 
			
		||||
			conn.Execute("ALTER TABLE downloads RENAME COLUMN url TO normalized_url");
 | 
			
		||||
			
 | 
			
		||||
			perf.Step("Upgrade to version 6");
 | 
			
		||||
			await reporter.NextVersion();
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		perf.End();
 | 
			
		||||
 
 | 
			
		||||
@@ -18,7 +18,7 @@ namespace DHT.Server.Database.Sqlite;
 | 
			
		||||
public sealed class SqliteDatabaseFile : IDatabaseFile {
 | 
			
		||||
	private const int DefaultPoolSize = 5;
 | 
			
		||||
 | 
			
		||||
	public static async Task<SqliteDatabaseFile?> OpenOrCreate(string path, Func<Task<bool>> checkCanUpgradeSchemas) {
 | 
			
		||||
	public static async Task<SqliteDatabaseFile?> OpenOrCreate(string path, ISchemaUpgradeCallbacks schemaUpgradeCallbacks, TaskScheduler computeTaskResultScheduler) {
 | 
			
		||||
		var connectionString = new SqliteConnectionStringBuilder {
 | 
			
		||||
			DataSource = path,
 | 
			
		||||
			Mode = SqliteOpenMode.ReadWriteCreate,
 | 
			
		||||
@@ -27,12 +27,16 @@ public sealed class SqliteDatabaseFile : IDatabaseFile {
 | 
			
		||||
		var pool = new SqliteConnectionPool(connectionString, DefaultPoolSize);
 | 
			
		||||
		bool wasOpened;
 | 
			
		||||
 | 
			
		||||
		using (var conn = pool.Take()) {
 | 
			
		||||
			wasOpened = await new Schema(conn).Setup(checkCanUpgradeSchemas);
 | 
			
		||||
		try {
 | 
			
		||||
			using var conn = pool.Take();
 | 
			
		||||
			wasOpened = await new Schema(conn).Setup(schemaUpgradeCallbacks);
 | 
			
		||||
		} catch (Exception) {
 | 
			
		||||
			pool.Dispose();
 | 
			
		||||
			throw;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (wasOpened) {
 | 
			
		||||
			return new SqliteDatabaseFile(path, pool);
 | 
			
		||||
			return new SqliteDatabaseFile(path, pool, computeTaskResultScheduler);
 | 
			
		||||
		}
 | 
			
		||||
		else {
 | 
			
		||||
			pool.Dispose();
 | 
			
		||||
@@ -49,13 +53,13 @@ public sealed class SqliteDatabaseFile : IDatabaseFile {
 | 
			
		||||
	private readonly AsyncValueComputer<long>.Single totalAttachmentsComputer;
 | 
			
		||||
	private readonly AsyncValueComputer<long>.Single totalDownloadsComputer;
 | 
			
		||||
 | 
			
		||||
	private SqliteDatabaseFile(string path, SqliteConnectionPool pool) {
 | 
			
		||||
	private SqliteDatabaseFile(string path, SqliteConnectionPool pool, TaskScheduler computeTaskResultScheduler) {
 | 
			
		||||
		this.log = Log.ForType(typeof(SqliteDatabaseFile), System.IO.Path.GetFileName(path));
 | 
			
		||||
		this.pool = pool;
 | 
			
		||||
 | 
			
		||||
		this.totalMessagesComputer = AsyncValueComputer<long>.WithResultProcessor(UpdateMessageStatistics).WithOutdatedResults().BuildWithComputer(ComputeMessageStatistics);
 | 
			
		||||
		this.totalAttachmentsComputer = AsyncValueComputer<long>.WithResultProcessor(UpdateAttachmentStatistics).WithOutdatedResults().BuildWithComputer(ComputeAttachmentStatistics);
 | 
			
		||||
		this.totalDownloadsComputer = AsyncValueComputer<long>.WithResultProcessor(UpdateDownloadStatistics).WithOutdatedResults().BuildWithComputer(ComputeDownloadStatistics);
 | 
			
		||||
		this.totalMessagesComputer = AsyncValueComputer<long>.WithResultProcessor(UpdateMessageStatistics, computeTaskResultScheduler).WithOutdatedResults().BuildWithComputer(ComputeMessageStatistics);
 | 
			
		||||
		this.totalAttachmentsComputer = AsyncValueComputer<long>.WithResultProcessor(UpdateAttachmentStatistics, computeTaskResultScheduler).WithOutdatedResults().BuildWithComputer(ComputeAttachmentStatistics);
 | 
			
		||||
		this.totalDownloadsComputer = AsyncValueComputer<long>.WithResultProcessor(UpdateDownloadStatistics, computeTaskResultScheduler).WithOutdatedResults().BuildWithComputer(ComputeDownloadStatistics);
 | 
			
		||||
 | 
			
		||||
		this.Path = path;
 | 
			
		||||
		this.Statistics = new DatabaseStatistics();
 | 
			
		||||
@@ -356,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>();
 | 
			
		||||
 | 
			
		||||
@@ -366,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
 | 
			
		||||
@@ -381,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),
 | 
			
		||||
 
 | 
			
		||||
@@ -5,14 +5,19 @@ using Microsoft.Data.Sqlite;
 | 
			
		||||
namespace DHT.Server.Database.Sqlite.Utils;
 | 
			
		||||
 | 
			
		||||
static class SqliteExtensions {
 | 
			
		||||
	public static SqliteTransaction BeginTransaction(this ISqliteConnection conn) {
 | 
			
		||||
		return conn.InnerConnection.BeginTransaction();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public static SqliteCommand Command(this ISqliteConnection conn, string sql) {
 | 
			
		||||
		var cmd = conn.InnerConnection.CreateCommand();
 | 
			
		||||
		cmd.CommandText = sql;
 | 
			
		||||
		return cmd;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public static SqliteTransaction BeginTransaction(this ISqliteConnection conn) {
 | 
			
		||||
		return conn.InnerConnection.BeginTransaction();
 | 
			
		||||
	public static void Execute(this ISqliteConnection conn, string sql) {
 | 
			
		||||
		using var cmd = conn.Command(sql);
 | 
			
		||||
		cmd.ExecuteNonQuery();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public static object? SelectScalar(this ISqliteConnection conn, string sql) {
 | 
			
		||||
 
 | 
			
		||||
@@ -3,12 +3,9 @@ using System.Net;
 | 
			
		||||
using System.Text.Json;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using DHT.Server.Database;
 | 
			
		||||
using DHT.Server.Service;
 | 
			
		||||
using DHT.Utils.Http;
 | 
			
		||||
using DHT.Utils.Logging;
 | 
			
		||||
using Microsoft.AspNetCore.Http;
 | 
			
		||||
using Microsoft.AspNetCore.Http.Extensions;
 | 
			
		||||
using Microsoft.Extensions.Primitives;
 | 
			
		||||
 | 
			
		||||
namespace DHT.Server.Endpoints;
 | 
			
		||||
 | 
			
		||||
@@ -16,25 +13,14 @@ abstract class BaseEndpoint {
 | 
			
		||||
	private static readonly Log Log = Log.ForType<BaseEndpoint>();
 | 
			
		||||
 | 
			
		||||
	protected IDatabaseFile Db { get; }
 | 
			
		||||
	protected ServerParameters Parameters { get; }
 | 
			
		||||
 | 
			
		||||
	protected BaseEndpoint(IDatabaseFile db, ServerParameters parameters) {
 | 
			
		||||
	protected BaseEndpoint(IDatabaseFile db) {
 | 
			
		||||
		this.Db = db;
 | 
			
		||||
		this.Parameters = parameters;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private async Task Handle(HttpContext ctx, StringValues token) {
 | 
			
		||||
		var request = ctx.Request;
 | 
			
		||||
	public async Task Handle(HttpContext ctx) {
 | 
			
		||||
		var response = ctx.Response;
 | 
			
		||||
 | 
			
		||||
		Log.Info("Request: " + request.GetDisplayUrl() + " (" + request.ContentLength + " B)");
 | 
			
		||||
 | 
			
		||||
		if (token.Count != 1 || token[0] != Parameters.Token) {
 | 
			
		||||
			Log.Error("Token: " + (token.Count == 1 ? token[0] : "<missing>"));
 | 
			
		||||
			response.StatusCode = (int) HttpStatusCode.Forbidden;
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		try {
 | 
			
		||||
			response.StatusCode = (int) HttpStatusCode.OK;
 | 
			
		||||
			var output = await Respond(ctx);
 | 
			
		||||
@@ -49,17 +35,13 @@ abstract class BaseEndpoint {
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public async Task HandleGet(HttpContext ctx) {
 | 
			
		||||
		await Handle(ctx, ctx.Request.Query["token"]);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public async Task HandlePost(HttpContext ctx) {
 | 
			
		||||
		await Handle(ctx, ctx.Request.Headers["X-DHT-Token"]);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	protected abstract Task<IHttpOutput> Respond(HttpContext ctx);
 | 
			
		||||
 | 
			
		||||
	protected static async Task<JsonElement> ReadJson(HttpContext ctx) {
 | 
			
		||||
		return await ctx.Request.ReadFromJsonAsync<JsonElement?>() ?? throw new HttpException(HttpStatusCode.UnsupportedMediaType, "This endpoint only accepts JSON.");
 | 
			
		||||
		try {
 | 
			
		||||
			return await ctx.Request.ReadFromJsonAsync(JsonElementContext.Default.JsonElement);
 | 
			
		||||
		} catch (JsonException) {
 | 
			
		||||
			throw new HttpException(HttpStatusCode.UnsupportedMediaType, "This endpoint only accepts JSON.");
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -2,14 +2,13 @@ using System.Net;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using DHT.Server.Data;
 | 
			
		||||
using DHT.Server.Database;
 | 
			
		||||
using DHT.Server.Service;
 | 
			
		||||
using DHT.Utils.Http;
 | 
			
		||||
using Microsoft.AspNetCore.Http;
 | 
			
		||||
 | 
			
		||||
namespace DHT.Server.Endpoints;
 | 
			
		||||
 | 
			
		||||
sealed class GetAttachmentEndpoint : BaseEndpoint {
 | 
			
		||||
	public GetAttachmentEndpoint(IDatabaseFile db, ServerParameters parameters) : base(db, parameters) {}
 | 
			
		||||
	public GetAttachmentEndpoint(IDatabaseFile db) : base(db) {}
 | 
			
		||||
 | 
			
		||||
	protected override Task<IHttpOutput> Respond(HttpContext ctx) {
 | 
			
		||||
		string attachmentUrl = WebUtility.UrlDecode((string) ctx.Request.RouteValues["url"]!);
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										34
									
								
								app/Server/Endpoints/GetMessagesEndpoint.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								app/Server/Endpoints/GetMessagesEndpoint.cs
									
									
									
									
									
										Normal 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);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
@@ -13,12 +13,16 @@ namespace DHT.Server.Endpoints;
 | 
			
		||||
sealed class GetTrackingScriptEndpoint : BaseEndpoint {
 | 
			
		||||
	private static ResourceLoader Resources { get; } = new (Assembly.GetExecutingAssembly());
 | 
			
		||||
	
 | 
			
		||||
	public GetTrackingScriptEndpoint(IDatabaseFile db, ServerParameters parameters) : base(db, parameters) {}
 | 
			
		||||
	private readonly ServerParameters serverParameters;
 | 
			
		||||
	
 | 
			
		||||
	public GetTrackingScriptEndpoint(IDatabaseFile db, ServerParameters parameters) : base(db) {
 | 
			
		||||
		serverParameters = parameters;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	protected override async Task<IHttpOutput> Respond(HttpContext ctx) {
 | 
			
		||||
		string bootstrap = await Resources.ReadTextAsync("Tracker/bootstrap.js");
 | 
			
		||||
		string script = bootstrap.Replace("= 0; /*[PORT]*/", "= " + Parameters.Port + ";")
 | 
			
		||||
		                         .Replace("/*[TOKEN]*/", HttpUtility.JavaScriptStringEncode(Parameters.Token))
 | 
			
		||||
		string script = bootstrap.Replace("= 0; /*[PORT]*/", "= " + serverParameters.Port + ";")
 | 
			
		||||
		                         .Replace("/*[TOKEN]*/", HttpUtility.JavaScriptStringEncode(serverParameters.Token))
 | 
			
		||||
		                         .Replace("/*[IMPORTS]*/", await Resources.ReadJoinedAsync("Tracker/scripts/", '\n'))
 | 
			
		||||
		                         .Replace("/*[CSS-CONTROLLER]*/", await Resources.ReadTextAsync("Tracker/styles/controller.css"))
 | 
			
		||||
		                         .Replace("/*[CSS-SETTINGS]*/", await Resources.ReadTextAsync("Tracker/styles/settings.css"))
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										8
									
								
								app/Server/Endpoints/Responses/GetMessagesJsonContext.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								app/Server/Endpoints/Responses/GetMessagesJsonContext.cs
									
									
									
									
									
										Normal 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 {}
 | 
			
		||||
@@ -3,14 +3,13 @@ using System.Text.Json;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using DHT.Server.Data;
 | 
			
		||||
using DHT.Server.Database;
 | 
			
		||||
using DHT.Server.Service;
 | 
			
		||||
using DHT.Utils.Http;
 | 
			
		||||
using Microsoft.AspNetCore.Http;
 | 
			
		||||
 | 
			
		||||
namespace DHT.Server.Endpoints;
 | 
			
		||||
 | 
			
		||||
sealed class TrackChannelEndpoint : BaseEndpoint {
 | 
			
		||||
	public TrackChannelEndpoint(IDatabaseFile db, ServerParameters parameters) : base(db, parameters) {}
 | 
			
		||||
	public TrackChannelEndpoint(IDatabaseFile db) : base(db) {}
 | 
			
		||||
 | 
			
		||||
	protected override async Task<IHttpOutput> Respond(HttpContext ctx) {
 | 
			
		||||
		var root = await ReadJson(ctx);
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,6 @@ using DHT.Server.Data;
 | 
			
		||||
using DHT.Server.Data.Filters;
 | 
			
		||||
using DHT.Server.Database;
 | 
			
		||||
using DHT.Server.Download;
 | 
			
		||||
using DHT.Server.Service;
 | 
			
		||||
using DHT.Utils.Collections;
 | 
			
		||||
using DHT.Utils.Http;
 | 
			
		||||
using Microsoft.AspNetCore.Http;
 | 
			
		||||
@@ -17,7 +16,10 @@ using Microsoft.AspNetCore.Http;
 | 
			
		||||
namespace DHT.Server.Endpoints;
 | 
			
		||||
 | 
			
		||||
sealed class TrackMessagesEndpoint : BaseEndpoint {
 | 
			
		||||
	public TrackMessagesEndpoint(IDatabaseFile db, ServerParameters parameters) : base(db, parameters) {}
 | 
			
		||||
	private const string HasNewMessages = "1";
 | 
			
		||||
	private const string NoNewMessages = "0";
 | 
			
		||||
	
 | 
			
		||||
	public TrackMessagesEndpoint(IDatabaseFile db) : base(db) {}
 | 
			
		||||
 | 
			
		||||
	protected override async Task<IHttpOutput> Respond(HttpContext ctx) {
 | 
			
		||||
		var root = await ReadJson(ctx);
 | 
			
		||||
@@ -41,7 +43,7 @@ sealed class TrackMessagesEndpoint : BaseEndpoint {
 | 
			
		||||
 | 
			
		||||
		Db.AddMessages(messages);
 | 
			
		||||
 | 
			
		||||
		return new HttpOutput.Json(anyNewMessages ? 1 : 0);
 | 
			
		||||
		return new HttpOutput.Text(anyNewMessages ? HasNewMessages : NoNewMessages);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private static Message ReadMessage(JsonElement json, string path) => new() {
 | 
			
		||||
 
 | 
			
		||||
@@ -3,14 +3,13 @@ using System.Text.Json;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using DHT.Server.Data;
 | 
			
		||||
using DHT.Server.Database;
 | 
			
		||||
using DHT.Server.Service;
 | 
			
		||||
using DHT.Utils.Http;
 | 
			
		||||
using Microsoft.AspNetCore.Http;
 | 
			
		||||
 | 
			
		||||
namespace DHT.Server.Endpoints;
 | 
			
		||||
 | 
			
		||||
sealed class TrackUsersEndpoint : BaseEndpoint {
 | 
			
		||||
	public TrackUsersEndpoint(IDatabaseFile db, ServerParameters parameters) : base(db, parameters) {}
 | 
			
		||||
	public TrackUsersEndpoint(IDatabaseFile db) : base(db) {}
 | 
			
		||||
 | 
			
		||||
	protected override async Task<IHttpOutput> Respond(HttpContext ctx) {
 | 
			
		||||
		var root = await ReadJson(ctx);
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,44 @@
 | 
			
		||||
using System.Net;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using DHT.Utils.Logging;
 | 
			
		||||
using Microsoft.AspNetCore.Http;
 | 
			
		||||
using Microsoft.Extensions.Primitives;
 | 
			
		||||
 | 
			
		||||
namespace DHT.Server.Service.Middlewares;
 | 
			
		||||
 | 
			
		||||
sealed class ServerAuthorizationMiddleware {
 | 
			
		||||
	private static readonly Log Log = Log.ForType<ServerAuthorizationMiddleware>();
 | 
			
		||||
 | 
			
		||||
	private readonly RequestDelegate next;
 | 
			
		||||
	private readonly ServerParameters serverParameters;
 | 
			
		||||
 | 
			
		||||
	public ServerAuthorizationMiddleware(RequestDelegate next, ServerParameters serverParameters) {
 | 
			
		||||
		this.next = next;
 | 
			
		||||
		this.serverParameters = serverParameters;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public async Task InvokeAsync(HttpContext context) {
 | 
			
		||||
		var request = context.Request;
 | 
			
		||||
 | 
			
		||||
		bool success = HttpMethods.IsGet(request.Method)
 | 
			
		||||
			               ? CheckToken(request.Query["token"])
 | 
			
		||||
			               : CheckToken(request.Headers["X-DHT-Token"]);
 | 
			
		||||
 | 
			
		||||
		if (success) {
 | 
			
		||||
			await next(context);
 | 
			
		||||
		}
 | 
			
		||||
		else {
 | 
			
		||||
			context.Response.StatusCode = (int) HttpStatusCode.Forbidden;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private bool CheckToken(StringValues token) {
 | 
			
		||||
		if (token.Count == 1 && token[0] == serverParameters.Token) {
 | 
			
		||||
			return true;
 | 
			
		||||
		}
 | 
			
		||||
		else {
 | 
			
		||||
			Log.Error("Invalid token: " + (token.Count == 1 ? token[0] : "<missing>"));
 | 
			
		||||
			return false;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										29
									
								
								app/Server/Service/Middlewares/ServerLoggingMiddleware.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								app/Server/Service/Middlewares/ServerLoggingMiddleware.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,29 @@
 | 
			
		||||
using System.Diagnostics;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using DHT.Utils.Logging;
 | 
			
		||||
using Microsoft.AspNetCore.Http;
 | 
			
		||||
using Microsoft.AspNetCore.Http.Extensions;
 | 
			
		||||
 | 
			
		||||
namespace DHT.Server.Service.Middlewares; 
 | 
			
		||||
 | 
			
		||||
sealed class ServerLoggingMiddleware {
 | 
			
		||||
	private static readonly Log Log = Log.ForType<ServerLoggingMiddleware>();
 | 
			
		||||
	
 | 
			
		||||
	private readonly RequestDelegate next;
 | 
			
		||||
	
 | 
			
		||||
	public ServerLoggingMiddleware(RequestDelegate next) {
 | 
			
		||||
		this.next = next;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public async Task InvokeAsync(HttpContext context) {
 | 
			
		||||
		var stopwatch = Stopwatch.StartNew();
 | 
			
		||||
		await next(context);
 | 
			
		||||
		stopwatch.Stop();
 | 
			
		||||
		
 | 
			
		||||
		var request = context.Request;
 | 
			
		||||
		var requestLength = request.ContentLength ?? 0L;
 | 
			
		||||
		var responseStatus = context.Response.StatusCode;
 | 
			
		||||
		var elapsedMs = stopwatch.ElapsedMilliseconds;
 | 
			
		||||
		Log.Debug("Request to " + request.GetEncodedPathAndQuery() + " (" + requestLength + " B) returned " + responseStatus + ", took " + elapsedMs + " ms");
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
@@ -4,7 +4,6 @@ using System.Diagnostics.CodeAnalysis;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using DHT.Server.Database;
 | 
			
		||||
using DHT.Utils.Logging;
 | 
			
		||||
using Microsoft.AspNetCore;
 | 
			
		||||
using Microsoft.AspNetCore.Hosting;
 | 
			
		||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
 | 
			
		||||
using Microsoft.Extensions.DependencyInjection;
 | 
			
		||||
@@ -75,11 +74,11 @@ public static class ServerLauncher {
 | 
			
		||||
			options.ListenLocalhost(port, static listenOptions => listenOptions.Protocols = HttpProtocols.Http1);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		Server = WebHost.CreateDefaultBuilder()
 | 
			
		||||
		                .ConfigureServices(AddServices)
 | 
			
		||||
		                .UseKestrel(SetKestrelOptions)
 | 
			
		||||
		                .UseStartup<Startup>()
 | 
			
		||||
		                .Build();
 | 
			
		||||
		Server = new WebHostBuilder()
 | 
			
		||||
		         .ConfigureServices(AddServices)
 | 
			
		||||
		         .UseKestrel(SetKestrelOptions)
 | 
			
		||||
		         .UseStartup<Startup>()
 | 
			
		||||
		         .Build();
 | 
			
		||||
 | 
			
		||||
		Server.Start();
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis;
 | 
			
		||||
using System.Text.Json.Serialization;
 | 
			
		||||
using DHT.Server.Database;
 | 
			
		||||
using DHT.Server.Endpoints;
 | 
			
		||||
using DHT.Server.Service.Middlewares;
 | 
			
		||||
using Microsoft.AspNetCore.Builder;
 | 
			
		||||
using Microsoft.AspNetCore.Http.Json;
 | 
			
		||||
using Microsoft.Extensions.DependencyInjection;
 | 
			
		||||
@@ -15,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) {
 | 
			
		||||
@@ -27,27 +29,24 @@ sealed class Startup {
 | 
			
		||||
				builder.WithOrigins(AllowedOrigins).AllowCredentials().AllowAnyMethod().AllowAnyHeader().WithExposedHeaders("X-DHT");
 | 
			
		||||
			});
 | 
			
		||||
		});
 | 
			
		||||
		
 | 
			
		||||
		services.AddRoutingCore();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	[SuppressMessage("ReSharper", "UnusedMember.Global")]
 | 
			
		||||
	public void Configure(IApplicationBuilder app, IHostApplicationLifetime lifetime, IDatabaseFile db, ServerParameters parameters) {
 | 
			
		||||
		app.UseRouting();
 | 
			
		||||
		app.UseMiddleware<ServerLoggingMiddleware>();
 | 
			
		||||
		app.UseCors();
 | 
			
		||||
		app.UseMiddleware<ServerAuthorizationMiddleware>();
 | 
			
		||||
		app.UseRouting();
 | 
			
		||||
		
 | 
			
		||||
		app.UseEndpoints(endpoints => {
 | 
			
		||||
			GetTrackingScriptEndpoint getTrackingScript = new (db, parameters);
 | 
			
		||||
			endpoints.MapGet("/get-tracking-script", context => getTrackingScript.HandleGet(context));
 | 
			
		||||
			
 | 
			
		||||
			TrackChannelEndpoint trackChannel = new (db, parameters);
 | 
			
		||||
			endpoints.MapPost("/track-channel", context => trackChannel.HandlePost(context));
 | 
			
		||||
 | 
			
		||||
			TrackUsersEndpoint trackUsers = new (db, parameters);
 | 
			
		||||
			endpoints.MapPost("/track-users", context => trackUsers.HandlePost(context));
 | 
			
		||||
 | 
			
		||||
			TrackMessagesEndpoint trackMessages = new (db, parameters);
 | 
			
		||||
			endpoints.MapPost("/track-messages", context => trackMessages.HandlePost(context));
 | 
			
		||||
 | 
			
		||||
			GetAttachmentEndpoint getAttachment = new (db, parameters);
 | 
			
		||||
			endpoints.MapGet("/get-attachment/{url}", context => getAttachment.HandleGet(context));
 | 
			
		||||
			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);
 | 
			
		||||
			endpoints.MapPost("/track-messages", new TrackMessagesEndpoint(db).Handle);
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,5 @@
 | 
			
		||||
using System.Text;
 | 
			
		||||
using System.Text.Json.Serialization.Metadata;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using Microsoft.AspNetCore.Http;
 | 
			
		||||
 | 
			
		||||
@@ -12,15 +14,29 @@ public static class HttpOutput {
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public sealed class Json : IHttpOutput {
 | 
			
		||||
		private readonly object? obj;
 | 
			
		||||
	public sealed class Text : IHttpOutput {
 | 
			
		||||
		private readonly string text;
 | 
			
		||||
 | 
			
		||||
		public Json(object? obj) {
 | 
			
		||||
			this.obj = obj;
 | 
			
		||||
		public Text(string text) {
 | 
			
		||||
			this.text = text;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		public Task WriteTo(HttpResponse response) {
 | 
			
		||||
			return response.WriteAsJsonAsync(obj);
 | 
			
		||||
			return response.WriteAsync(text, Encoding.UTF8);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	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);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										8
									
								
								app/Utils/Http/JsonElementContext.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								app/Utils/Http/JsonElementContext.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
			
		||||
using System.Text.Json;
 | 
			
		||||
using System.Text.Json.Serialization;
 | 
			
		||||
 | 
			
		||||
namespace DHT.Utils.Http;
 | 
			
		||||
 | 
			
		||||
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, GenerationMode = JsonSourceGenerationMode.Default)]
 | 
			
		||||
[JsonSerializable(typeof(JsonElement))]
 | 
			
		||||
public sealed partial class JsonElementContext : JsonSerializerContext {}
 | 
			
		||||
							
								
								
									
										13
									
								
								app/Utils/Logging/WindowsConsole.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								app/Utils/Logging/WindowsConsole.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
			
		||||
using System.Runtime.InteropServices;
 | 
			
		||||
using System.Runtime.Versioning;
 | 
			
		||||
 | 
			
		||||
namespace DHT.Utils.Logging; 
 | 
			
		||||
 | 
			
		||||
[SupportedOSPlatform("windows")]
 | 
			
		||||
public static partial class WindowsConsole {
 | 
			
		||||
	[LibraryImport("kernel32.dll", SetLastError = true)]
 | 
			
		||||
	public static partial void AllocConsole();
 | 
			
		||||
 | 
			
		||||
	[LibraryImport("kernel32.dll", SetLastError = true)]
 | 
			
		||||
	public static partial void FreeConsole();
 | 
			
		||||
}
 | 
			
		||||
@@ -6,6 +6,10 @@
 | 
			
		||||
    <PackageId>DiscordHistoryTrackerUtils</PackageId>
 | 
			
		||||
  </PropertyGroup>
 | 
			
		||||
  
 | 
			
		||||
  <PropertyGroup>
 | 
			
		||||
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
 | 
			
		||||
  </PropertyGroup>
 | 
			
		||||
  
 | 
			
		||||
  <ItemGroup>
 | 
			
		||||
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
 | 
			
		||||
  </ItemGroup>
 | 
			
		||||
 
 | 
			
		||||
@@ -8,5 +8,5 @@ using DHT.Utils;
 | 
			
		||||
namespace DHT.Utils; 
 | 
			
		||||
 | 
			
		||||
static class Version {
 | 
			
		||||
	public const string Tag = "39.0.0.0";
 | 
			
		||||
	public const string Tag = "39.1.0.0";
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -4,11 +4,11 @@ set list=win-x64 linux-x64 osx-x64
 | 
			
		||||
rmdir /S /Q bin
 | 
			
		||||
 | 
			
		||||
(for %%a in (%list%) do (
 | 
			
		||||
  dotnet publish Desktop -c Release -r %%a -o ./bin/%%a -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishReadyToRun=false --self-contained true
 | 
			
		||||
  dotnet publish Desktop -c Release -r %%a -o ./bin/%%a --self-contained true
 | 
			
		||||
  powershell "Compress-Archive -Path ./bin/%%a/* -DestinationPath ./bin/%%a.zip -CompressionLevel Optimal"
 | 
			
		||||
))
 | 
			
		||||
 | 
			
		||||
dotnet publish Desktop -c Release -o ./bin/portable -p:PublishTrimmed=false --self-contained false
 | 
			
		||||
dotnet publish Desktop -c Release -o ./bin/portable -p:PublishSingleFile=false -p:PublishTrimmed=false --self-contained false
 | 
			
		||||
powershell "Compress-Archive -Path ./bin/portable/* -DestinationPath ./bin/portable.zip -CompressionLevel Optimal"
 | 
			
		||||
 | 
			
		||||
echo Done
 | 
			
		||||
 
 | 
			
		||||
@@ -17,9 +17,9 @@ rm -rf "./bin"
 | 
			
		||||
configurations=(win-x64 linux-x64 osx-x64)
 | 
			
		||||
 | 
			
		||||
for cfg in ${configurations[@]}; do
 | 
			
		||||
	dotnet publish Desktop -c Release -r "$cfg" -o "./bin/$cfg" -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishReadyToRun=false --self-contained true
 | 
			
		||||
	dotnet publish Desktop -c Release -r "$cfg" -o "./bin/$cfg" --self-contained true
 | 
			
		||||
	makezip "$cfg"
 | 
			
		||||
done
 | 
			
		||||
 | 
			
		||||
dotnet publish Desktop -c Release -o "./bin/portable" -p:PublishTrimmed=false --self-contained false
 | 
			
		||||
dotnet publish Desktop -c Release -o "./bin/portable" -p:PublishSingleFile=false -p:PublishTrimmed=false --self-contained false
 | 
			
		||||
makezip "portable"
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user