mirror of
https://github.com/chylex/Discord-History-Tracker.git
synced 2025-09-14 00:32:08 +02:00
Compare commits
29 Commits
daa2feb445
...
v37.1
Author | SHA1 | Date | |
---|---|---|---|
8c68438fbb
|
|||
f625a39b4d
|
|||
7fd644449c
|
|||
e4a09515b0
|
|||
9ac9f2246f
|
|||
bbc734ba9b
|
|||
6837b05b0d
|
|||
c94808a15f
|
|||
739e87c5ab
|
|||
d463b407f4
|
|||
cd418f4871
|
|||
176a81e055 | |||
![]() |
1cf3e76644 | ||
33f5ab7cce
|
|||
b9a5664740
|
|||
845ac1b0fa
|
|||
1bead42a0e
|
|||
8f1c91b2cc
|
|||
9ae5ece24b
|
|||
053ab5b091
|
|||
71c628fdf8
|
|||
af621b8d46
|
|||
31fe6aed35
|
|||
c25426af55
|
|||
59129ba20a
|
|||
f7bfe052ca
|
|||
c9bb46c8c7
|
|||
73f4c70325
|
|||
de5a8b690b
|
@@ -7,6 +7,7 @@
|
||||
<entry key="Desktop/Dialogs/CheckBox/CheckBoxDialog.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Dialogs/Message/MessageDialog.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Dialogs/Progress/ProgressDialog.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Dialogs/TextBox/TextBoxDialog.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/AboutWindow.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Controls/AttachmentFilterPanel.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Controls/MessageFilterPanel.axaml" value="Desktop/Desktop.csproj" />
|
||||
|
@@ -7,7 +7,7 @@
|
||||
<Application.Styles>
|
||||
|
||||
<FluentTheme Mode="Light" />
|
||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Default.xaml"/>
|
||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Default.xaml" />
|
||||
|
||||
<Style Selector="Button, CheckBox, RadioButton, Expander /template/ ToggleButton#ExpanderHeader">
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
@@ -33,8 +33,19 @@
|
||||
<Style Selector="TextBox:focus /template/ Border#PART_BorderElement">
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="TextBox:disabled"><!-- TODO bug in Avalonia (https://github.com/AvaloniaUI/Avalonia/pull/7792) -->
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextControlForegroundDisabled}" />
|
||||
<Style Selector="TextBox:error DataValidationErrors">
|
||||
<Style.Resources>
|
||||
<ControlTemplate x:Key="InlineDataValidationContentTemplate" TargetType="DataValidationErrors">
|
||||
<ContentPresenter Name="PART_ContentPresenter"
|
||||
Padding="{TemplateBinding Padding}"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="{TemplateBinding CornerRadius}"
|
||||
Content="{TemplateBinding Content}"
|
||||
ContentTemplate="{TemplateBinding ContentTemplate}" />
|
||||
</ControlTemplate>
|
||||
</Style.Resources>
|
||||
<Setter Property="Template" Value="{StaticResource InlineDataValidationContentTemplate}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Expander /template/ ToggleButton#ExpanderHeader">
|
||||
|
@@ -4,12 +4,26 @@ using Avalonia.Data.Converters;
|
||||
|
||||
namespace DHT.Desktop.Common {
|
||||
sealed class BytesValueConverter : IValueConverter {
|
||||
private static readonly string[] Units = {
|
||||
"B",
|
||||
"kB",
|
||||
"MB",
|
||||
"GB",
|
||||
"TB"
|
||||
private sealed class Unit {
|
||||
private readonly string label;
|
||||
private readonly string numberFormat;
|
||||
|
||||
public Unit(string label, int decimalPlaces) {
|
||||
this.label = label;
|
||||
this.numberFormat = "{0:n" + decimalPlaces + "}";
|
||||
}
|
||||
|
||||
public string Format(double size) {
|
||||
return string.Format(Program.Culture, numberFormat, size) + " " + label;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly Unit[] Units = {
|
||||
new ("B", decimalPlaces: 0),
|
||||
new ("kB", decimalPlaces: 0),
|
||||
new ("MB", decimalPlaces: 1),
|
||||
new ("GB", decimalPlaces: 1),
|
||||
new ("TB", decimalPlaces: 1)
|
||||
};
|
||||
|
||||
private const int Scale = 1000;
|
||||
@@ -17,13 +31,7 @@ namespace DHT.Desktop.Common {
|
||||
private static string Convert(ulong size) {
|
||||
int power = size == 0L ? 0 : (int) Math.Log(size, Scale);
|
||||
int unit = power >= Units.Length ? Units.Length - 1 : power;
|
||||
if (unit == 0) {
|
||||
return string.Format(Program.Culture, "{0:n0}", size) + " " + Units[unit];
|
||||
}
|
||||
else {
|
||||
double humanReadableSize = size / Math.Pow(Scale, unit);
|
||||
return string.Format(Program.Culture, "{0:n0}", humanReadableSize) + " " + Units[unit];
|
||||
}
|
||||
return Units[unit].Format(unit == 0 ? size : size / Math.Pow(Scale, unit));
|
||||
}
|
||||
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
||||
|
@@ -21,16 +21,20 @@
|
||||
<DebugType>none</DebugType>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="0.10.14" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="0.10.14" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="0.10.14" />
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="0.10.14" Condition=" '$(Configuration)' == 'Debug' " />
|
||||
<PackageReference Include="Avalonia" Version="0.10.16" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="0.10.16" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="0.10.16" />
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="0.10.16" Condition=" '$(Configuration)' == 'Debug' " />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Server\Server.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="..\Version.cs" Link="Version.cs" />
|
||||
<Compile Update="Dialogs\TextBox\TextBoxDialog.axaml.cs">
|
||||
<DependentUpon>CheckBoxDialog.axaml</DependentUpon>
|
||||
<SubType>Code</SubType>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Resources/icon.ico" />
|
||||
|
56
app/Desktop/Dialogs/TextBox/TextBoxDialog.axaml
Normal file
56
app/Desktop/Dialogs/TextBox/TextBoxDialog.axaml
Normal file
@@ -0,0 +1,56 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.TextBox"
|
||||
mc:Ignorable="d" d:DesignWidth="500"
|
||||
x:Class="DHT.Desktop.Dialogs.TextBox.TextBoxDialog"
|
||||
Title="{Binding Title}"
|
||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||
Width="500" SizeToContent="Height" CanResize="False"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Window.DataContext>
|
||||
<namespace:TextBoxDialogModel />
|
||||
</Window.DataContext>
|
||||
|
||||
<Window.Styles>
|
||||
<Style Selector="Panel.buttons">
|
||||
<Setter Property="Margin" Value="0 20 0 0" />
|
||||
</Style>
|
||||
<Style Selector="Panel.buttons > WrapPanel.right">
|
||||
<Setter Property="HorizontalAlignment" Value="Right" />
|
||||
</Style>
|
||||
<Style Selector="Panel.buttons Button">
|
||||
<Setter Property="MinWidth" Value="80" />
|
||||
<Setter Property="Margin" Value="8 0 0 0" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<StackPanel Margin="20">
|
||||
<ScrollViewer MaxHeight="400">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" />
|
||||
<ItemsRepeater Items="{Binding Items}">
|
||||
<ItemsRepeater.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<DockPanel Margin="0 5 25 0">
|
||||
<TextBox Name="Input" Text="{Binding Value}" Width="180" VerticalAlignment="Top" DockPanel.Dock="Right" />
|
||||
<Label Target="Input" VerticalAlignment="Center" DockPanel.Dock="Left">
|
||||
<TextBlock Text="{Binding Title}" TextWrapping="Wrap" />
|
||||
</Label>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
</ItemsRepeater.ItemTemplate>
|
||||
</ItemsRepeater>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
<Panel Classes="buttons">
|
||||
<WrapPanel Classes="right">
|
||||
<Button Click="ClickOk" IsEnabled="{Binding !HasErrors}">OK</Button>
|
||||
<Button Click="ClickCancel">Cancel</Button>
|
||||
</WrapPanel>
|
||||
</Panel>
|
||||
</StackPanel>
|
||||
|
||||
</Window>
|
31
app/Desktop/Dialogs/TextBox/TextBoxDialog.axaml.cs
Normal file
31
app/Desktop/Dialogs/TextBox/TextBoxDialog.axaml.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.TextBox {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class TextBoxDialog : Window {
|
||||
public TextBoxDialog() {
|
||||
InitializeComponent();
|
||||
#if DEBUG
|
||||
this.AttachDevTools();
|
||||
#endif
|
||||
}
|
||||
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public void ClickOk(object? sender, RoutedEventArgs e) {
|
||||
Close(DialogResult.OkCancel.Ok);
|
||||
}
|
||||
|
||||
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
||||
Close(DialogResult.OkCancel.Cancel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
47
app/Desktop/Dialogs/TextBox/TextBoxDialogModel.cs
Normal file
47
app/Desktop/Dialogs/TextBox/TextBoxDialogModel.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.TextBox {
|
||||
class TextBoxDialogModel : BaseModel {
|
||||
public string Title { get; init; } = "";
|
||||
public string Description { get; init; } = "";
|
||||
|
||||
private IReadOnlyList<TextBoxItem> items = Array.Empty<TextBoxItem>();
|
||||
|
||||
public IReadOnlyList<TextBoxItem> Items {
|
||||
get => items;
|
||||
|
||||
protected set {
|
||||
foreach (var item in items) {
|
||||
item.ErrorsChanged -= OnItemErrorsChanged;
|
||||
}
|
||||
|
||||
items = value;
|
||||
|
||||
foreach (var item in items) {
|
||||
item.ErrorsChanged += OnItemErrorsChanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasErrors => Items.Any(static item => !item.IsValid);
|
||||
|
||||
private void OnItemErrorsChanged(object? sender, DataErrorsChangedEventArgs e) {
|
||||
OnPropertyChanged(nameof(HasErrors));
|
||||
}
|
||||
}
|
||||
|
||||
sealed class TextBoxDialogModel<T> : TextBoxDialogModel {
|
||||
public new IReadOnlyList<TextBoxItem<T>> Items { get; }
|
||||
|
||||
public IEnumerable<TextBoxItem<T>> ValidItems => Items.Where(static item => item.IsValid);
|
||||
|
||||
public TextBoxDialogModel(IEnumerable<TextBoxItem<T>> items) {
|
||||
this.Items = new List<TextBoxItem<T>>(items);
|
||||
base.Items = this.Items;
|
||||
}
|
||||
}
|
||||
}
|
42
app/Desktop/Dialogs/TextBox/TextBoxItem.cs
Normal file
42
app/Desktop/Dialogs/TextBox/TextBoxItem.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.ComponentModel;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.TextBox {
|
||||
class TextBoxItem : BaseModel, INotifyDataErrorInfo {
|
||||
public string Title { get; init; } = "";
|
||||
public object? Item { get; init; } = null;
|
||||
|
||||
public Func<string, bool> ValidityCheck { get; init; } = static _ => true;
|
||||
public bool IsValid => ValidityCheck(Value);
|
||||
|
||||
private string value = string.Empty;
|
||||
|
||||
public string Value {
|
||||
get => this.value;
|
||||
set {
|
||||
Change(ref this.value, value);
|
||||
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value)));
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerable GetErrors(string? propertyName) {
|
||||
if (propertyName == nameof(Value) && !IsValid) {
|
||||
yield return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasErrors => !IsValid;
|
||||
public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
|
||||
}
|
||||
|
||||
sealed class TextBoxItem<T> : TextBoxItem {
|
||||
public new T Item { get; }
|
||||
|
||||
public TextBoxItem(T item) {
|
||||
this.Item = item;
|
||||
base.Item = item;
|
||||
}
|
||||
}
|
||||
}
|
@@ -9,7 +9,7 @@ using DHT.Utils.Tasks;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls {
|
||||
sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
|
||||
public sealed record Unit(string Name, int Scale);
|
||||
public sealed record Unit(string Name, uint Scale);
|
||||
|
||||
private static readonly Unit[] AllUnits = {
|
||||
new ("B", 1),
|
||||
@@ -26,7 +26,7 @@ namespace DHT.Desktop.Main.Controls {
|
||||
public string FilterStatisticsText { get; private set; } = "";
|
||||
|
||||
private bool limitSize = false;
|
||||
private int maximumSize = 0;
|
||||
private ulong maximumSize = 0L;
|
||||
private Unit maximumSizeUnit = AllUnits[0];
|
||||
|
||||
public bool LimitSize {
|
||||
@@ -34,7 +34,7 @@ namespace DHT.Desktop.Main.Controls {
|
||||
set => Change(ref limitSize, value);
|
||||
}
|
||||
|
||||
public int MaximumSize {
|
||||
public ulong MaximumSize {
|
||||
get => maximumSize;
|
||||
set => Change(ref maximumSize, value);
|
||||
}
|
||||
@@ -116,7 +116,11 @@ namespace DHT.Desktop.Main.Controls {
|
||||
AttachmentFilter filter = new();
|
||||
|
||||
if (LimitSize) {
|
||||
filter.MaxBytes = maximumSize * maximumSizeUnit.Scale;
|
||||
try {
|
||||
filter.MaxBytes = maximumSize * maximumSizeUnit.Scale;
|
||||
} catch (ArithmeticException) {
|
||||
// set no size limit, because the overflown size is larger than any file could possibly be
|
||||
}
|
||||
}
|
||||
|
||||
return filter;
|
||||
|
@@ -169,7 +169,7 @@ namespace DHT.Desktop.Main.Controls {
|
||||
var exportedMessageCountStr = exportedMessageCount?.Format() ?? "(...)";
|
||||
var totalMessageCountStr = totalMessageCount?.Format() ?? "(...)";
|
||||
|
||||
FilterStatisticsText = verb + " " + exportedMessageCountStr + " out of " + totalMessageCountStr + " message" + (totalMessageCount is null or 0 ? "." : "s.");
|
||||
FilterStatisticsText = verb + " " + exportedMessageCountStr + " out of " + totalMessageCountStr + " message" + (totalMessageCount is null or 1 ? "." : "s.");
|
||||
OnPropertyChanged(nameof(FilterStatisticsText));
|
||||
}
|
||||
|
||||
|
@@ -175,7 +175,6 @@ namespace DHT.Desktop.Main.Pages {
|
||||
};
|
||||
|
||||
db.RemoveDownloadItems(allExceptFailedFilter, FilterRemovalMode.KeepMatching);
|
||||
downloadStatisticsComputer.Recompute();
|
||||
|
||||
if (IsDownloading) {
|
||||
EnqueueDownloadItems();
|
||||
|
@@ -24,6 +24,7 @@
|
||||
<WrapPanel>
|
||||
<Button Command="{Binding OpenDatabaseFolder}">Open Database Folder</Button>
|
||||
<Button Command="{Binding MergeWithDatabase}">Merge with Database(s)...</Button>
|
||||
<Button Command="{Binding ImportLegacyArchive}">Import Legacy Archive(s)...</Button>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
|
||||
|
@@ -1,6 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -9,7 +11,10 @@ using Avalonia.Threading;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Dialogs.Progress;
|
||||
using DHT.Desktop.Dialogs.TextBox;
|
||||
using DHT.Server.Data;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Database.Import;
|
||||
using DHT.Utils.Logging;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
@@ -92,13 +97,13 @@ namespace DHT.Desktop.Main.Pages {
|
||||
|
||||
return DialogResult.YesNo.Yes == upgradeResult;
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
|
||||
|
||||
if (db == null) {
|
||||
return false;
|
||||
}
|
||||
@@ -112,6 +117,73 @@ namespace DHT.Desktop.Main.Pages {
|
||||
});
|
||||
}
|
||||
|
||||
public async void ImportLegacyArchive() {
|
||||
var fileDialog = new OpenFileDialog {
|
||||
Title = "Open Legacy DHT Archive",
|
||||
Directory = Path.GetDirectoryName(Db.Path),
|
||||
AllowMultiple = true
|
||||
};
|
||||
|
||||
string[]? paths = await fileDialog.ShowAsync(window);
|
||||
if (paths == null || paths.Length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
ProgressDialog progressDialog = new ProgressDialog();
|
||||
progressDialog.DataContext = new ProgressDialogModel(async callback => await ImportLegacyArchiveFromPaths(Db, paths, progressDialog, callback)) {
|
||||
Title = "Legacy Archive Import"
|
||||
};
|
||||
|
||||
await progressDialog.ShowDialog(window);
|
||||
}
|
||||
|
||||
private static async Task ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
||||
var fakeSnowflake = new FakeSnowflake();
|
||||
|
||||
await PerformImport(target, paths, dialog, callback, "Legacy Archive Import", "Legacy Archive Error", "archive file", async path => {
|
||||
await using var jsonStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
|
||||
return await LegacyArchiveImport.Read(jsonStream, target, fakeSnowflake, async servers => {
|
||||
SynchronizationContext? prevSyncContext = SynchronizationContext.Current;
|
||||
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
|
||||
Dictionary<DHT.Server.Data.Server, ulong>? result = await Dispatcher.UIThread.InvokeAsync(() => AskForServerIds(dialog, servers));
|
||||
SynchronizationContext.SetSynchronizationContext(prevSyncContext);
|
||||
return result;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<DHT.Server.Data.Server, ulong>?> AskForServerIds(Window window, DHT.Server.Data.Server[] servers) {
|
||||
static bool IsValidSnowflake(string value) {
|
||||
return string.IsNullOrEmpty(value) || ulong.TryParse(value, out _);
|
||||
}
|
||||
|
||||
var items = new List<TextBoxItem<DHT.Server.Data.Server>>();
|
||||
|
||||
foreach (var server in servers.OrderBy(static server => server.Type).ThenBy(static server => server.Name)) {
|
||||
items.Add(new TextBoxItem<DHT.Server.Data.Server>(server) {
|
||||
Title = server.Name + " (" + ServerTypes.ToNiceString(server.Type) + ")",
|
||||
ValidityCheck = IsValidSnowflake
|
||||
});
|
||||
}
|
||||
|
||||
var model = new TextBoxDialogModel<DHT.Server.Data.Server>(items) {
|
||||
Title = "Imported Server IDs",
|
||||
Description = "Please fill in the IDs of servers and direct messages. First enable Developer Mode in Discord, then right-click each server or direct message, click 'Copy ID', and paste it into the input field. If a server no longer exists, leave its input field empty to use a random ID."
|
||||
};
|
||||
|
||||
var dialog = new TextBoxDialog { DataContext = model };
|
||||
var result = await dialog.ShowDialog<DialogResult.OkCancel>(window);
|
||||
|
||||
if (result != DialogResult.OkCancel.Ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return model.ValidItems
|
||||
.Where(static item => !string.IsNullOrEmpty(item.Value))
|
||||
.ToDictionary(static item => item.Item, static item => ulong.Parse(item.Value));
|
||||
}
|
||||
|
||||
private static async Task PerformImport(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback, string neutralDialogTitle, string errorDialogTitle, string itemName, Func<string, Task<bool>> performImport) {
|
||||
int total = paths.Length;
|
||||
var oldStatistics = target.SnapshotStatistics();
|
||||
|
@@ -11,9 +11,11 @@ using Avalonia.Controls;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Main.Controls;
|
||||
using DHT.Desktop.Server;
|
||||
using DHT.Server.Data.Filters;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Database.Export;
|
||||
using DHT.Server.Database.Export.Strategy;
|
||||
using DHT.Utils.Models;
|
||||
using static DHT.Desktop.Program;
|
||||
|
||||
@@ -55,7 +57,7 @@ namespace DHT.Desktop.Main.Pages {
|
||||
HasFilters = FilterModel.HasAnyFilters;
|
||||
}
|
||||
|
||||
private async Task WriteViewerFile(string path) {
|
||||
private async Task WriteViewerFile(string path, IViewerExportStrategy strategy) {
|
||||
const string ArchiveTag = "/*[ARCHIVE]*/";
|
||||
|
||||
string indexFile = await Resources.ReadTextAsync("Viewer/index.html");
|
||||
@@ -68,7 +70,7 @@ namespace DHT.Desktop.Main.Pages {
|
||||
string jsonTempFile = path + ".tmp";
|
||||
|
||||
await using (var jsonStream = new FileStream(jsonTempFile, FileMode.Create, FileAccess.ReadWrite, FileShare.Read)) {
|
||||
await ViewerJsonExport.Generate(jsonStream, db, FilterModel.CreateFilter());
|
||||
await ViewerJsonExport.Generate(jsonStream, strategy, db, FilterModel.CreateFilter());
|
||||
|
||||
char[] jsonBuffer = new char[Math.Min(32768, jsonStream.Position)];
|
||||
jsonStream.Position = 0;
|
||||
@@ -106,7 +108,7 @@ namespace DHT.Desktop.Main.Pages {
|
||||
TemporaryFiles.Add(fullPath);
|
||||
|
||||
Directory.CreateDirectory(rootPath);
|
||||
await WriteViewerFile(fullPath);
|
||||
await WriteViewerFile(fullPath, new LiveViewerExportStrategy(ServerManager.Port, ServerManager.Token));
|
||||
|
||||
Process.Start(new ProcessStartInfo(fullPath) { UseShellExecute = true });
|
||||
}
|
||||
@@ -126,7 +128,7 @@ namespace DHT.Desktop.Main.Pages {
|
||||
|
||||
string? path = await dialog;
|
||||
if (!string.IsNullOrEmpty(path)) {
|
||||
await WriteViewerFile(path);
|
||||
await WriteViewerFile(path, StandaloneViewerExportStrategy.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -91,11 +91,13 @@ class DISCORD {
|
||||
static getMessageElementProps(ele) {
|
||||
const props = DOM.getReactProps(ele);
|
||||
|
||||
if (props.children && props.children.length >= 4) {
|
||||
const childProps = props.children[3].props;
|
||||
|
||||
if ("message" in childProps && "channel" in childProps) {
|
||||
return childProps;
|
||||
if (props.children && props.children.length) {
|
||||
for (let i = 3; i < props.children.length; i++) {
|
||||
const childProps = props.children[i].props;
|
||||
|
||||
if (childProps && "message" in childProps && "channel" in childProps) {
|
||||
return childProps;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,16 +112,20 @@ class DISCORD {
|
||||
const messages = [];
|
||||
|
||||
for (const ele of this.getMessageElements()) {
|
||||
const props = this.getMessageElementProps(ele);
|
||||
|
||||
if (props != null) {
|
||||
messages.push(props.message);
|
||||
try {
|
||||
const props = this.getMessageElementProps(ele);
|
||||
|
||||
if (props != null) {
|
||||
messages.push(props.message);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[DHT] Error extracing message data, skipping it.", e, ele, DOM.tryGetReactProps(ele));
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
console.error("[DHT] Error retrieving messages.", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
@@ -71,4 +71,15 @@ class DOM {
|
||||
key = keys.find(key => key.startsWith("__reactProps$"));
|
||||
return key ? ele[key] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns internal React state object of an element, or null if the retrieval throws.
|
||||
*/
|
||||
static tryGetReactProps(ele) {
|
||||
try {
|
||||
return this.getReactProps(ele);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -251,6 +251,11 @@ const STATE = (function() {
|
||||
mapped.type = attachment.content_type;
|
||||
}
|
||||
|
||||
if (attachment.width && attachment.height) {
|
||||
mapped.width = attachment.width;
|
||||
mapped.height = attachment.height;
|
||||
}
|
||||
|
||||
return mapped;
|
||||
});
|
||||
}
|
||||
|
@@ -1,9 +1,5 @@
|
||||
#app-mount div[class*="app-"] {
|
||||
margin-bottom: 48px !important;
|
||||
}
|
||||
|
||||
#app-mount div[class*="app-"] > div[class*="app-"] {
|
||||
margin-bottom: 0 !important;
|
||||
#app-mount {
|
||||
height: calc(100% - 48px) !important;
|
||||
}
|
||||
|
||||
#dht-ctrl {
|
||||
|
@@ -1,7 +1,7 @@
|
||||
const DISCORD = (function() {
|
||||
const regex = {
|
||||
formatBold: /\*\*([\s\S]+?)\*\*(?!\*)/g,
|
||||
formatItalic: /(.)?\*([\s\S]+?)\*(?!\*)/g,
|
||||
formatItalic: /(.)?([_*])([\s\S]+?)\2(?!\2)/g,
|
||||
formatUnderline: /__([\s\S]+?)__(?!_)/g,
|
||||
formatStrike: /~~([\s\S]+?)~~(?!~)/g,
|
||||
formatCodeInline: /(`+)\s*([\s\S]*?[^`])\s*\1(?!`)/g,
|
||||
@@ -9,7 +9,7 @@ const DISCORD = (function() {
|
||||
formatUrl: /(\b(?:https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/ig,
|
||||
formatUrlNoEmbed: /<(\b(?:https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])>/ig,
|
||||
specialEscapedBacktick: /\\`/g,
|
||||
specialEscapedSingle: /\\([*\\])/g,
|
||||
specialEscapedSingle: /\\([*_\\])/g,
|
||||
specialEscapedDouble: /\\__|_\\_|\\_\\_|\\~~|~\\~|\\~\\~/g,
|
||||
specialUnescaped: /([*_~\\])/g,
|
||||
mentionRole: /<@&(\d+?)>/g,
|
||||
@@ -26,6 +26,7 @@ const DISCORD = (function() {
|
||||
let templateUserAvatar;
|
||||
let templateAttachmentDownload;
|
||||
let templateEmbedImage;
|
||||
let templateEmbedImageWithSize;
|
||||
let templateEmbedRich;
|
||||
let templateEmbedRichNoDescription;
|
||||
let templateEmbedUrl;
|
||||
@@ -46,8 +47,8 @@ const DISCORD = (function() {
|
||||
.replace(regex.specialEscapedSingle, escapeHtmlMatch)
|
||||
.replace(regex.specialEscapedDouble, full => full.replace(/\\/g, "").replace(/(.)/g, escapeHtmlMatch))
|
||||
.replace(regex.formatBold, "<b>$1</b>")
|
||||
.replace(regex.formatItalic, (full, pre, match) => pre === "\\" ? full : (pre || "") + "<i>" + match + "</i>")
|
||||
.replace(regex.formatUnderline, "<u>$1</u>")
|
||||
.replace(regex.formatItalic, (full, pre, char, match) => pre === "\\" ? full : (pre || "") + "<i>" + match + "</i>")
|
||||
.replace(regex.formatStrike, "<s>$1</s>");
|
||||
}
|
||||
|
||||
@@ -64,6 +65,25 @@ const DISCORD = (function() {
|
||||
return "<p>" + processed + "</p>";
|
||||
};
|
||||
|
||||
const getImageEmbed = function(url, image) {
|
||||
if (!SETTINGS.enableImagePreviews) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (image.width && image.height) {
|
||||
return templateEmbedImageWithSize.apply({ url, src: image.url, width: image.width, height: image.height });
|
||||
}
|
||||
else {
|
||||
return templateEmbedImage.apply({ url, src: image.url });
|
||||
}
|
||||
};
|
||||
|
||||
const isImageUrl = function(url) {
|
||||
const dot = url.pathname.lastIndexOf(".");
|
||||
const ext = dot === -1 ? "" : url.pathname.substring(dot).toLowerCase();
|
||||
return ext === ".png" || ext === ".gif" || ext === ".jpg" || ext === ".jpeg";
|
||||
};
|
||||
|
||||
return {
|
||||
setup() {
|
||||
templateChannelServer = new TEMPLATE([
|
||||
@@ -109,12 +129,17 @@ const DISCORD = (function() {
|
||||
|
||||
// noinspection HtmlUnknownTarget
|
||||
templateAttachmentDownload = new TEMPLATE([
|
||||
"<a href='{url}' class='embed download'>Download {filename}</a>"
|
||||
"<a href='{url}' class='embed download'>Download {name}</a>"
|
||||
].join(""));
|
||||
|
||||
// noinspection HtmlUnknownTarget
|
||||
templateEmbedImage = new TEMPLATE([
|
||||
"<a href='{url}' class='embed thumbnail'><img src='{src}' alt='(image attachment not found)'></a><br>"
|
||||
"<a href='{url}' class='embed thumbnail loading'><img src='{src}' alt='' onload='DISCORD.handleImageLoad(this)' onerror='DISCORD.handleImageLoadError(this)'></a><br>"
|
||||
].join(""));
|
||||
|
||||
// noinspection HtmlUnknownTarget
|
||||
templateEmbedImageWithSize = new TEMPLATE([
|
||||
"<a href='{url}' class='embed thumbnail loading'><img src='{src}' width='{width}' height='{height}' alt='' onload='DISCORD.handleImageLoad(this)' onerror='DISCORD.handleImageLoadError(this)'></a><br>"
|
||||
].join(""));
|
||||
|
||||
// noinspection HtmlUnknownTarget
|
||||
@@ -145,10 +170,20 @@ const DISCORD = (function() {
|
||||
].join(""));
|
||||
},
|
||||
|
||||
handleImageLoad(ele) {
|
||||
ele.parentElement.classList.remove("loading");
|
||||
},
|
||||
|
||||
handleImageLoadError(ele) {
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
ele.onerror = null;
|
||||
ele.parentElement.classList.remove("loading");
|
||||
ele.setAttribute("alt", "(image attachment not found)");
|
||||
},
|
||||
|
||||
isImageAttachment(attachment) {
|
||||
const dot = attachment.url.lastIndexOf(".");
|
||||
const ext = dot === -1 ? "" : attachment.url.substring(dot).toLowerCase();
|
||||
return ext === ".png" || ext === ".gif" || ext === ".jpg" || ext === ".jpeg";
|
||||
const url = DOM.tryParseUrl(attachment.url);
|
||||
return url != null && isImageUrl(url);
|
||||
},
|
||||
|
||||
getChannelHTML(channel) { // noinspection FunctionWithInconsistentReturnsJS
|
||||
@@ -183,10 +218,10 @@ const DISCORD = (function() {
|
||||
return templateEmbedUnsupported.apply(embed);
|
||||
}
|
||||
else if ("image" in embed && embed.image.url) {
|
||||
return SETTINGS.enableImagePreviews ? templateEmbedImage.apply({ url: embed.url, src: embed.image.url }) : "";
|
||||
return getImageEmbed(embed.url, embed.image);
|
||||
}
|
||||
else if ("thumbnail" in embed && embed.thumbnail.url) {
|
||||
return SETTINGS.enableImagePreviews ? templateEmbedImage.apply({ url: embed.url, src: embed.thumbnail.url }) : "";
|
||||
return getImageEmbed(embed.url, embed.thumbnail);
|
||||
}
|
||||
else if ("title" in embed && "description" in embed) {
|
||||
return templateEmbedRich.apply(embed);
|
||||
@@ -205,16 +240,14 @@ const DISCORD = (function() {
|
||||
}
|
||||
|
||||
return value.map(attachment => {
|
||||
if (this.isImageAttachment(attachment) && SETTINGS.enableImagePreviews) {
|
||||
return templateEmbedImage.apply({ url: attachment.url, src: attachment.url });
|
||||
if (!DISCORD.isImageAttachment(attachment) || !SETTINGS.enableImagePreviews) {
|
||||
return templateAttachmentDownload.apply(attachment);
|
||||
}
|
||||
else if ("width" in attachment && "height" in attachment) {
|
||||
return templateEmbedImageWithSize.apply({ url: attachment.url, src: attachment.url, width: attachment.width, height: attachment.height });
|
||||
}
|
||||
else {
|
||||
const sliced = attachment.url.split("/");
|
||||
|
||||
return templateAttachmentDownload.apply({
|
||||
"url": attachment.url,
|
||||
"filename": sliced[sliced.length - 1]
|
||||
});
|
||||
return templateEmbedImage.apply({ url: attachment.url, src: attachment.url });
|
||||
}
|
||||
}).join("");
|
||||
}
|
||||
|
@@ -51,4 +51,15 @@ class DOM {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString() + ", " + date.toLocaleTimeString();
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a url string into a URL object and returns it. If the parsing fails, returns null.
|
||||
*/
|
||||
static tryParseUrl(url) {
|
||||
try {
|
||||
return new URL(url);
|
||||
} catch (ignore) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -107,11 +107,25 @@
|
||||
}
|
||||
|
||||
.message .thumbnail {
|
||||
position: relative;
|
||||
max-width: calc(100% - 20px);
|
||||
max-height: 320px;
|
||||
}
|
||||
|
||||
.message .thumbnail.loading::after {
|
||||
content: "";
|
||||
background: rgba(0, 0, 0, 0.75)
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300' preserveAspectRatio='xMidYMid'%3E %3Ccircle cx='150' cy='150' fill='none' stroke='%237983f5' stroke-width='8' r='42' stroke-dasharray='198 68'%3E %3CanimateTransform attributeName='transform' type='rotate' repeatCount='indefinite' dur='1.25s' values='0 150 150;360 150 150' keyTimes='0;1' /%3E %3C/circle%3E %3C/svg%3E")
|
||||
no-repeat center center;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.message .thumbnail img {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
max-height: 320px;
|
||||
border-radius: 3px;
|
||||
|
@@ -5,5 +5,7 @@ namespace DHT.Server.Data {
|
||||
public string? Type { get; internal init; }
|
||||
public string Url { get; internal init; }
|
||||
public ulong Size { get; internal init; }
|
||||
public int? Width { get; internal init; }
|
||||
public int? Height { get; internal init; }
|
||||
}
|
||||
}
|
||||
|
6
app/Server/Data/DownloadedAttachment.cs
Normal file
6
app/Server/Data/DownloadedAttachment.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace DHT.Server.Data {
|
||||
public readonly struct DownloadedAttachment {
|
||||
public string? Type { get; internal init; }
|
||||
public byte[] Data { get; internal init; }
|
||||
}
|
||||
}
|
@@ -1,6 +1,6 @@
|
||||
namespace DHT.Server.Data.Filters {
|
||||
public sealed class AttachmentFilter {
|
||||
public long? MaxBytes { get; set; } = null;
|
||||
public ulong? MaxBytes { get; set; } = null;
|
||||
|
||||
public DownloadItemRules? DownloadItemRule { get; set; } = null;
|
||||
|
||||
|
@@ -24,6 +24,15 @@ namespace DHT.Server.Data {
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToNiceString(ServerType? type) {
|
||||
return type switch {
|
||||
ServerType.Server => "Server",
|
||||
ServerType.Group => "Group",
|
||||
ServerType.DirectMessage => "DM",
|
||||
_ => "Unknown"
|
||||
};
|
||||
}
|
||||
|
||||
internal static string ToJsonViewerString(ServerType? type) {
|
||||
return type switch {
|
||||
ServerType.Server => "server",
|
||||
|
@@ -1,16 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
using DHT.Server.Data;
|
||||
|
||||
namespace DHT.Server.Database {
|
||||
public static class DatabaseExtensions {
|
||||
public static void AddFrom(this IDatabaseFile target, IDatabaseFile source) {
|
||||
foreach (var server in source.GetAllServers()) {
|
||||
target.AddServer(server);
|
||||
}
|
||||
|
||||
foreach (var channel in source.GetAllChannels()) {
|
||||
target.AddChannel(channel);
|
||||
}
|
||||
|
||||
target.AddServers(source.GetAllServers());
|
||||
target.AddChannels(source.GetAllChannels());
|
||||
target.AddUsers(source.GetAllUsers().ToArray());
|
||||
target.AddMessages(source.GetMessages().ToArray());
|
||||
|
||||
@@ -18,5 +13,17 @@ namespace DHT.Server.Database {
|
||||
target.AddDownload(download.Status == DownloadStatus.Success ? source.GetDownloadWithData(download) : download);
|
||||
}
|
||||
}
|
||||
|
||||
internal static void AddServers(this IDatabaseFile target, IEnumerable<Data.Server> servers) {
|
||||
foreach (var server in servers) {
|
||||
target.AddServer(server);
|
||||
}
|
||||
}
|
||||
|
||||
internal static void AddChannels(this IDatabaseFile target, IEnumerable<Channel> channels) {
|
||||
foreach (var channel in channels) {
|
||||
target.AddChannel(channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -45,6 +45,10 @@ namespace DHT.Server.Database {
|
||||
return new();
|
||||
}
|
||||
|
||||
public HashSet<ulong> GetMessageIds(MessageFilter? filter = null) {
|
||||
return new();
|
||||
}
|
||||
|
||||
public void RemoveMessages(MessageFilter filter, FilterRemovalMode mode) {}
|
||||
|
||||
public int CountAttachments(AttachmentFilter? filter = null) {
|
||||
@@ -59,6 +63,10 @@ namespace DHT.Server.Database {
|
||||
return download;
|
||||
}
|
||||
|
||||
public DownloadedAttachment? GetDownloadedAttachment(string url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
public void AddDownload(Data.Download download) {}
|
||||
|
||||
public void EnqueueDownloadItems(AttachmentFilter? filter = null) {}
|
||||
|
@@ -0,0 +1,7 @@
|
||||
using DHT.Server.Data;
|
||||
|
||||
namespace DHT.Server.Database.Export.Strategy {
|
||||
public interface IViewerExportStrategy {
|
||||
string GetAttachmentUrl(Attachment attachment);
|
||||
}
|
||||
}
|
@@ -0,0 +1,18 @@
|
||||
using System.Net;
|
||||
using DHT.Server.Data;
|
||||
|
||||
namespace DHT.Server.Database.Export.Strategy {
|
||||
public sealed class LiveViewerExportStrategy : IViewerExportStrategy {
|
||||
private readonly string safePort;
|
||||
private readonly string safeToken;
|
||||
|
||||
public LiveViewerExportStrategy(ushort port, string token) {
|
||||
this.safePort = port.ToString();
|
||||
this.safeToken = WebUtility.UrlEncode(token);
|
||||
}
|
||||
|
||||
public string GetAttachmentUrl(Attachment attachment) {
|
||||
return "http://127.0.0.1:" + safePort + "/get-attachment/" + WebUtility.UrlEncode(attachment.Url) + "?token=" + safeToken;
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,13 @@
|
||||
using DHT.Server.Data;
|
||||
|
||||
namespace DHT.Server.Database.Export.Strategy {
|
||||
public sealed class StandaloneViewerExportStrategy : IViewerExportStrategy {
|
||||
public static StandaloneViewerExportStrategy Instance { get; } = new ();
|
||||
|
||||
private StandaloneViewerExportStrategy() {}
|
||||
|
||||
public string GetAttachmentUrl(Attachment attachment) {
|
||||
return attachment.Url;
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
@@ -5,13 +6,14 @@ using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using DHT.Server.Data;
|
||||
using DHT.Server.Data.Filters;
|
||||
using DHT.Server.Database.Export.Strategy;
|
||||
using DHT.Utils.Logging;
|
||||
|
||||
namespace DHT.Server.Database.Export {
|
||||
public static class ViewerJsonExport {
|
||||
private static readonly Log Log = Log.ForType(typeof(ViewerJsonExport));
|
||||
|
||||
public static async Task Generate(Stream stream, IDatabaseFile db, MessageFilter? filter = null) {
|
||||
public static async Task Generate(Stream stream, IViewerExportStrategy strategy, IDatabaseFile db, MessageFilter? filter = null) {
|
||||
var perf = Log.Start();
|
||||
|
||||
var includedUserIds = new HashSet<ulong>();
|
||||
@@ -41,7 +43,7 @@ namespace DHT.Server.Database.Export {
|
||||
|
||||
var value = new {
|
||||
meta = new { users, userindex, servers, channels },
|
||||
data = GenerateMessageList(includedMessages, userIndices)
|
||||
data = GenerateMessageList(includedMessages, userIndices, strategy)
|
||||
};
|
||||
|
||||
perf.Step("Generate value object");
|
||||
@@ -138,7 +140,7 @@ namespace DHT.Server.Database.Export {
|
||||
return channels;
|
||||
}
|
||||
|
||||
private static object GenerateMessageList(List<Message> includedMessages, Dictionary<ulong, object> userIndices) {
|
||||
private static object GenerateMessageList( List<Message> includedMessages, Dictionary<ulong, object> userIndices, IViewerExportStrategy strategy) {
|
||||
var data = new Dictionary<string, Dictionary<string, object>>();
|
||||
|
||||
foreach (var grouping in includedMessages.GroupBy(static message => message.Channel)) {
|
||||
@@ -164,8 +166,18 @@ namespace DHT.Server.Database.Export {
|
||||
}
|
||||
|
||||
if (!message.Attachments.IsEmpty) {
|
||||
obj["a"] = message.Attachments.Select(static attachment => new Dictionary<string, object> {
|
||||
{ "url", attachment.Url }
|
||||
obj["a"] = message.Attachments.Select(attachment => {
|
||||
var a = new Dictionary<string, object> {
|
||||
{ "url", strategy.GetAttachmentUrl(attachment) },
|
||||
{ "name", Uri.TryCreate(attachment.Url, UriKind.Absolute, out var uri) ? Path.GetFileName(uri.LocalPath) : attachment.Url }
|
||||
};
|
||||
|
||||
if (attachment.Width != null && attachment.Height != null) {
|
||||
a["width"] = attachment.Width;
|
||||
a["height"] = attachment.Height;
|
||||
}
|
||||
|
||||
return a;
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
@@ -188,7 +200,7 @@ namespace DHT.Server.Database.Export {
|
||||
r["a"] = reaction.EmojiFlags.HasFlag(EmojiFlags.Animated);
|
||||
r["c"] = reaction.Count;
|
||||
return r;
|
||||
});
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
channelData[message.Id.ToString()] = obj;
|
||||
|
@@ -23,6 +23,7 @@ namespace DHT.Server.Database {
|
||||
void AddMessages(Message[] messages);
|
||||
int CountMessages(MessageFilter? filter = null);
|
||||
List<Message> GetMessages(MessageFilter? filter = null);
|
||||
HashSet<ulong> GetMessageIds(MessageFilter? filter = null);
|
||||
void RemoveMessages(MessageFilter filter, FilterRemovalMode mode);
|
||||
|
||||
int CountAttachments(AttachmentFilter? filter = null);
|
||||
@@ -30,7 +31,8 @@ namespace DHT.Server.Database {
|
||||
void AddDownload(Data.Download download);
|
||||
List<Data.Download> GetDownloadsWithoutData();
|
||||
Data.Download GetDownloadWithData(Data.Download download);
|
||||
|
||||
DownloadedAttachment? GetDownloadedAttachment(string url);
|
||||
|
||||
void EnqueueDownloadItems(AttachmentFilter? filter = null);
|
||||
List<DownloadItem> GetEnqueuedDownloadItems(int count);
|
||||
void RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode);
|
||||
|
21
app/Server/Database/Import/FakeSnowflake.cs
Normal file
21
app/Server/Database/Import/FakeSnowflake.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
|
||||
namespace DHT.Server.Database.Import {
|
||||
/// <summary>
|
||||
/// https://discord.com/developers/docs/reference#snowflakes
|
||||
/// </summary>
|
||||
public sealed class FakeSnowflake {
|
||||
private const ulong DiscordEpoch = 1420070400000UL;
|
||||
|
||||
private ulong id;
|
||||
|
||||
public FakeSnowflake() {
|
||||
var unixMillis = (ulong) (DateTime.UtcNow.Subtract(DateTime.UnixEpoch).Ticks / TimeSpan.TicksPerMillisecond);
|
||||
this.id = (unixMillis - DiscordEpoch) << 22;
|
||||
}
|
||||
|
||||
internal ulong Next() {
|
||||
return id++;
|
||||
}
|
||||
}
|
||||
}
|
263
app/Server/Database/Import/LegacyArchiveImport.cs
Normal file
263
app/Server/Database/Import/LegacyArchiveImport.cs
Normal file
@@ -0,0 +1,263 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using DHT.Server.Data;
|
||||
using DHT.Utils.Collections;
|
||||
using DHT.Utils.Http;
|
||||
using DHT.Utils.Logging;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
|
||||
namespace DHT.Server.Database.Import {
|
||||
public static class LegacyArchiveImport {
|
||||
private static readonly Log Log = Log.ForType(typeof(LegacyArchiveImport));
|
||||
|
||||
private static readonly FileExtensionContentTypeProvider ContentTypeProvider = new ();
|
||||
|
||||
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);
|
||||
|
||||
try {
|
||||
var meta = root.RequireObject("meta");
|
||||
var data = root.RequireObject("data");
|
||||
|
||||
perf.Step("Deserialize JSON");
|
||||
|
||||
var users = ReadUserList(meta);
|
||||
var servers = ReadServerList(meta, fakeSnowflake);
|
||||
|
||||
var newServersOnly = new HashSet<Data.Server>(servers);
|
||||
var oldServersById = db.GetAllServers().ToDictionary(static server => server.Id, static server => server);
|
||||
|
||||
var oldChannels = db.GetAllChannels();
|
||||
var oldChannelsById = oldChannels.ToDictionary(static channel => channel.Id, static channel => channel);
|
||||
|
||||
foreach (var (channelId, serverIndex) in ReadChannelToServerIndexMapping(meta, servers)) {
|
||||
if (oldChannelsById.TryGetValue(channelId, out var oldChannel) && oldServersById.TryGetValue(oldChannel.Server, out var oldServer) && newServersOnly.Remove(servers[serverIndex])) {
|
||||
servers[serverIndex] = oldServer;
|
||||
}
|
||||
}
|
||||
|
||||
perf.Step("Read server and user list");
|
||||
|
||||
if (newServersOnly.Count > 0) {
|
||||
var askedServerIds = await askForServerIds(newServersOnly.ToArray());
|
||||
if (askedServerIds == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
perf.Step("Ask for server IDs");
|
||||
|
||||
for (var i = 0; i < servers.Length; i++) {
|
||||
var server = servers[i];
|
||||
if (askedServerIds.TryGetValue(server, out var serverId)) {
|
||||
servers[i] = new Data.Server {
|
||||
Id = serverId,
|
||||
Name = server.Name,
|
||||
Type = server.Type
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var channels = ReadChannelList(meta, servers);
|
||||
|
||||
perf.Step("Read channel list");
|
||||
|
||||
var oldMessageIds = db.GetMessageIds();
|
||||
var newMessages = channels.SelectMany(channel => ReadMessages(data, channel, users, fakeSnowflake))
|
||||
.Where(message => !oldMessageIds.Contains(message.Id))
|
||||
.ToArray();
|
||||
|
||||
perf.Step("Read messages");
|
||||
|
||||
db.AddUsers(users);
|
||||
db.AddServers(servers);
|
||||
db.AddChannels(channels);
|
||||
db.AddMessages(newMessages);
|
||||
|
||||
perf.Step("Import into database");
|
||||
} catch (HttpException e) {
|
||||
throw new JsonException(e.Message);
|
||||
}
|
||||
|
||||
perf.End();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static User[] ReadUserList(JsonElement meta) {
|
||||
const string UsersPath = "meta.users[]";
|
||||
|
||||
static ulong ParseUserIndex(JsonElement element, int index) {
|
||||
return ulong.Parse(element.GetString() ?? throw new JsonException("Expected key 'meta.userindex[" + index + "]' to be a string."));
|
||||
}
|
||||
|
||||
var userindex = meta.RequireArray("userindex", "meta")
|
||||
.Select(static (item, index) => (ParseUserIndex(item, index), index))
|
||||
.ToDictionary();
|
||||
|
||||
var users = new User[userindex.Count];
|
||||
|
||||
foreach (var item in meta.RequireObject("users", "meta").EnumerateObject()) {
|
||||
var path = UsersPath + "." + item.Name;
|
||||
var userId = ulong.Parse(item.Name);
|
||||
var userObj = item.Value;
|
||||
|
||||
users[userindex[userId]] = new User {
|
||||
Id = userId,
|
||||
Name = userObj.RequireString("name", path),
|
||||
AvatarUrl = userObj.HasKey("avatar") ? userObj.RequireString("avatar", path) : null,
|
||||
Discriminator = userObj.HasKey("tag") ? userObj.RequireString("tag", path) : null
|
||||
};
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
private static Data.Server[] ReadServerList(JsonElement meta, FakeSnowflake fakeSnowflake) {
|
||||
const string ServersPath = "meta.servers[]";
|
||||
|
||||
return meta.RequireArray("servers", "meta").Select(serverObj => new Data.Server {
|
||||
Id = fakeSnowflake.Next(),
|
||||
Name = serverObj.RequireString("name", ServersPath),
|
||||
Type = ServerTypes.FromString(serverObj.RequireString("type", ServersPath))
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
private const string ChannelsPath = "meta.channels";
|
||||
|
||||
private static Dictionary<ulong, int> ReadChannelToServerIndexMapping(JsonElement meta, Data.Server[] servers) {
|
||||
return meta.RequireObject("channels", "meta").EnumerateObject().Select(item => {
|
||||
var path = ChannelsPath + "." + item.Name;
|
||||
var channelId = ulong.Parse(item.Name);
|
||||
var channelObj = item.Value;
|
||||
|
||||
return (channelId, channelObj.RequireInt("server", path, min: 0, max: servers.Length - 1));
|
||||
}).ToDictionary();
|
||||
}
|
||||
|
||||
private static Channel[] ReadChannelList(JsonElement meta, Data.Server[] servers) {
|
||||
return meta.RequireObject("channels", "meta").EnumerateObject().Select(item => {
|
||||
var path = ChannelsPath + "." + item.Name;
|
||||
var channelId = ulong.Parse(item.Name);
|
||||
var channelObj = item.Value;
|
||||
|
||||
return new Channel {
|
||||
Id = channelId,
|
||||
Server = servers[channelObj.RequireInt("server", path, min: 0, max: servers.Length - 1)].Id,
|
||||
Name = channelObj.RequireString("name", path),
|
||||
Position = channelObj.HasKey("position") ? channelObj.RequireInt("position", path, min: 0) : null,
|
||||
Topic = channelObj.HasKey("topic") ? channelObj.RequireString("topic", path) : null,
|
||||
Nsfw = channelObj.HasKey("nsfw") ? channelObj.RequireBool("nsfw", path) : null
|
||||
};
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
private static Message[] ReadMessages(JsonElement data, Channel channel, User[] users, FakeSnowflake fakeSnowflake) {
|
||||
const string DataPath = "data";
|
||||
|
||||
var channelId = channel.Id;
|
||||
var channelIdStr = channelId.ToString();
|
||||
|
||||
var messagesObj = data.HasKey(channelIdStr) ? data.RequireObject(channelIdStr, DataPath) : (JsonElement?) null;
|
||||
if (messagesObj == null) {
|
||||
return Array.Empty<Message>();
|
||||
}
|
||||
|
||||
return messagesObj.Value.EnumerateObject().Select(item => {
|
||||
var path = DataPath + "." + item.Name;
|
||||
var messageId = ulong.Parse(item.Name);
|
||||
var messageObj = item.Value;
|
||||
|
||||
return new Message {
|
||||
Id = messageId,
|
||||
Sender = users[messageObj.RequireInt("u", path, min: 0, max: users.Length - 1)].Id,
|
||||
Channel = channelId,
|
||||
Text = messageObj.HasKey("m") ? messageObj.RequireString("m", path) : string.Empty,
|
||||
Timestamp = messageObj.RequireLong("t", path),
|
||||
EditTimestamp = messageObj.HasKey("te") ? messageObj.RequireLong("te", path) : null,
|
||||
RepliedToId = messageObj.HasKey("r") ? messageObj.RequireSnowflake("r", path) : null,
|
||||
Attachments = messageObj.HasKey("a") ? ReadMessageAttachments(messageObj.RequireArray("a", path), fakeSnowflake, path + ".a[]").ToImmutableArray() : ImmutableArray<Attachment>.Empty,
|
||||
Embeds = messageObj.HasKey("e") ? ReadMessageEmbeds(messageObj.RequireArray("e", path), path + ".e[]").ToImmutableArray() : ImmutableArray<Embed>.Empty,
|
||||
Reactions = messageObj.HasKey("re") ? ReadMessageReactions(messageObj.RequireArray("re", path), path + ".re[]").ToImmutableArray() : ImmutableArray<Reaction>.Empty
|
||||
};
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
[SuppressMessage("ReSharper", "ConvertToLambdaExpression")]
|
||||
private static IEnumerable<Attachment> ReadMessageAttachments(JsonElement.ArrayEnumerator attachmentsArray, FakeSnowflake fakeSnowflake, string path) {
|
||||
return attachmentsArray.Select(attachmentObj => {
|
||||
string url = attachmentObj.RequireString("url", path);
|
||||
string name = url[(url.LastIndexOf('/') + 1)..];
|
||||
string? type = ContentTypeProvider.TryGetContentType(name, out var contentType) ? contentType : null;
|
||||
|
||||
return new Attachment {
|
||||
Id = fakeSnowflake.Next(),
|
||||
Name = name,
|
||||
Type = type,
|
||||
Url = url,
|
||||
Size = 0 // unknown size
|
||||
};
|
||||
}).DistinctByKeyStable(static attachment => {
|
||||
// Some Discord messages have duplicate attachments with the same id for unknown reasons.
|
||||
return attachment.Id;
|
||||
});
|
||||
}
|
||||
|
||||
private static IEnumerable<Embed> ReadMessageEmbeds(JsonElement.ArrayEnumerator embedsArray, string path) {
|
||||
// Some rich embeds are missing URLs which causes a missing 'url' key.
|
||||
return embedsArray.Where(static embedObj => embedObj.HasKey("url")).Select(embedObj => {
|
||||
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 }
|
||||
};
|
||||
|
||||
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)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private static IEnumerable<Reaction> ReadMessageReactions(JsonElement.ArrayEnumerator reactionsArray, string path) {
|
||||
return reactionsArray.Select(reactionObj => {
|
||||
var id = reactionObj.HasKey("id") ? reactionObj.RequireSnowflake("id", path) : (ulong?) null;
|
||||
var name = reactionObj.HasKey("n") ? reactionObj.RequireString("n", path) : null;
|
||||
|
||||
if (id == null && name == null) {
|
||||
throw new JsonException("Expected key '" + path + ".id' and/or '" + path + ".n' to be present.");
|
||||
}
|
||||
|
||||
return new Reaction {
|
||||
EmojiId = id,
|
||||
EmojiName = name,
|
||||
EmojiFlags = reactionObj.HasKey("an") && reactionObj.RequireBool("an", path) ? EmojiFlags.Animated : EmojiFlags.None,
|
||||
Count = reactionObj.RequireInt("c", path, min: 0)
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@@ -6,7 +6,7 @@ using DHT.Utils.Logging;
|
||||
|
||||
namespace DHT.Server.Database.Sqlite {
|
||||
sealed class Schema {
|
||||
internal const int Version = 4;
|
||||
internal const int Version = 5;
|
||||
|
||||
private static readonly Log Log = Log.ForType<Schema>();
|
||||
|
||||
@@ -79,7 +79,9 @@ namespace DHT.Server.Database.Sqlite {
|
||||
name TEXT NOT NULL,
|
||||
type TEXT,
|
||||
url TEXT NOT NULL,
|
||||
size INTEGER NOT NULL)");
|
||||
size INTEGER NOT NULL,
|
||||
width INTEGER,
|
||||
height INTEGER)");
|
||||
|
||||
Execute(@"CREATE TABLE embeds (
|
||||
message_id INTEGER NOT NULL,
|
||||
@@ -159,6 +161,12 @@ namespace DHT.Server.Database.Sqlite {
|
||||
perf.Step("Upgrade to version 4");
|
||||
}
|
||||
|
||||
if (dbVersion <= 4) {
|
||||
Execute("ALTER TABLE attachments ADD width INTEGER");
|
||||
Execute("ALTER TABLE attachments ADD height INTEGER");
|
||||
perf.Step("Upgrade to version 5");
|
||||
}
|
||||
|
||||
perf.End();
|
||||
}
|
||||
}
|
||||
|
@@ -252,7 +252,9 @@ namespace DHT.Server.Database.Sqlite {
|
||||
("name", SqliteType.Text),
|
||||
("type", SqliteType.Text),
|
||||
("url", SqliteType.Text),
|
||||
("size", SqliteType.Integer)
|
||||
("size", SqliteType.Integer),
|
||||
("width", SqliteType.Integer),
|
||||
("height", SqliteType.Integer)
|
||||
});
|
||||
|
||||
using var embedCmd = conn.Insert("embeds", new[] {
|
||||
@@ -307,6 +309,8 @@ namespace DHT.Server.Database.Sqlite {
|
||||
attachmentCmd.Set(":type", attachment.Type);
|
||||
attachmentCmd.Set(":url", attachment.Url);
|
||||
attachmentCmd.Set(":size", attachment.Size);
|
||||
attachmentCmd.Set(":width", attachment.Width);
|
||||
attachmentCmd.Set(":height", attachment.Height);
|
||||
attachmentCmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
@@ -386,22 +390,34 @@ LEFT JOIN replied_to rt ON m.message_id = rt.message_id" + filter.GenerateWhereC
|
||||
return list;
|
||||
}
|
||||
|
||||
public void RemoveMessages(MessageFilter filter, FilterRemovalMode mode) {
|
||||
var whereClause = filter.GenerateWhereClause(invert: mode == FilterRemovalMode.KeepMatching);
|
||||
|
||||
if (!string.IsNullOrEmpty(whereClause)) {
|
||||
var perf = log.Start();
|
||||
|
||||
DeleteFromTable("messages", whereClause);
|
||||
totalMessagesComputer.Recompute();
|
||||
|
||||
perf.End();
|
||||
public HashSet<ulong> GetMessageIds(MessageFilter? filter = null) {
|
||||
var perf = log.Start();
|
||||
var ids = new HashSet<ulong>();
|
||||
|
||||
using var conn = pool.Take();
|
||||
using var cmd = conn.Command("SELECT message_id FROM messages" + filter.GenerateWhereClause());
|
||||
using var reader = cmd.ExecuteReader();
|
||||
|
||||
while (reader.Read()) {
|
||||
ids.Add(reader.GetUint64(0));
|
||||
}
|
||||
|
||||
perf.End();
|
||||
return ids;
|
||||
}
|
||||
|
||||
public void RemoveMessages(MessageFilter filter, FilterRemovalMode mode) {
|
||||
var perf = log.Start();
|
||||
|
||||
DeleteFromTable("messages", filter.GenerateWhereClause(invert: mode == FilterRemovalMode.KeepMatching));
|
||||
totalMessagesComputer.Recompute();
|
||||
|
||||
perf.End();
|
||||
}
|
||||
|
||||
public int CountAttachments(AttachmentFilter? filter = null) {
|
||||
using var conn = pool.Take();
|
||||
using var cmd = conn.Command("SELECT COUNT(*) FROM attachments a" + filter.GenerateWhereClause("a"));
|
||||
using var cmd = conn.Command("SELECT COUNT(DISTINCT url) FROM attachments a" + filter.GenerateWhereClause("a"));
|
||||
using var reader = cmd.ExecuteReader();
|
||||
|
||||
return reader.Read() ? reader.GetInt32(0) : 0;
|
||||
@@ -458,9 +474,31 @@ LEFT JOIN replied_to rt ON m.message_id = rt.message_id" + filter.GenerateWhereC
|
||||
}
|
||||
}
|
||||
|
||||
public DownloadedAttachment? GetDownloadedAttachment(string url) {
|
||||
using var conn = pool.Take();
|
||||
using var cmd = conn.Command(@"
|
||||
SELECT a.type, d.blob FROM downloads d
|
||||
LEFT JOIN attachments a ON d.url = a.url
|
||||
WHERE d.url = :url AND d.status = :success AND d.blob IS NOT NULL");
|
||||
|
||||
cmd.AddAndSet(":url", SqliteType.Text, url);
|
||||
cmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success);
|
||||
|
||||
using var reader = cmd.ExecuteReader();
|
||||
|
||||
if (!reader.Read()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new DownloadedAttachment {
|
||||
Type = reader.IsDBNull(0) ? null : reader.GetString(0),
|
||||
Data = (byte[]) reader["blob"]
|
||||
};
|
||||
}
|
||||
|
||||
public void EnqueueDownloadItems(AttachmentFilter? filter = null) {
|
||||
using var conn = pool.Take();
|
||||
using var cmd = conn.Command("INSERT INTO downloads (url, status, size) SELECT a.url, :enqueued, a.size FROM attachments a" + filter.GenerateWhereClause("a"));
|
||||
using var cmd = conn.Command("INSERT INTO downloads (url, status, size) SELECT a.url, :enqueued, MAX(a.size) FROM attachments a" + filter.GenerateWhereClause("a") + " GROUP BY a.url");
|
||||
cmd.AddAndSet(":enqueued", SqliteType.Integer, (int) DownloadStatus.Enqueued);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
@@ -486,16 +524,13 @@ LEFT JOIN replied_to rt ON m.message_id = rt.message_id" + filter.GenerateWhereC
|
||||
}
|
||||
|
||||
public void RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode) {
|
||||
var whereClause = filter.GenerateWhereClause(invert: mode == FilterRemovalMode.KeepMatching);
|
||||
|
||||
if (!string.IsNullOrEmpty(whereClause)) {
|
||||
DeleteFromTable("downloads", whereClause);
|
||||
}
|
||||
DeleteFromTable("downloads", filter.GenerateWhereClause(invert: mode == FilterRemovalMode.KeepMatching));
|
||||
totalDownloadsComputer.Recompute();
|
||||
}
|
||||
|
||||
public DownloadStatusStatistics GetDownloadStatusStatistics() {
|
||||
static void LoadUndownloadedStatistics(ISqliteConnection conn, DownloadStatusStatistics result) {
|
||||
using var cmd = conn.Command("SELECT IFNULL(COUNT(filtered.size), 0), IFNULL(SUM(filtered.size), 0) FROM (SELECT DISTINCT a.url, a.size FROM attachments a WHERE a.url NOT IN (SELECT d.url FROM downloads d)) filtered");
|
||||
using var cmd = conn.Command("SELECT IFNULL(COUNT(size), 0), IFNULL(SUM(size), 0) FROM (SELECT MAX(a.size) size FROM attachments a WHERE a.url NOT IN (SELECT d.url FROM downloads d) GROUP BY a.url)");
|
||||
using var reader = cmd.ExecuteReader();
|
||||
|
||||
if (reader.Read()) {
|
||||
@@ -540,7 +575,7 @@ FROM downloads");
|
||||
var dict = new MultiDictionary<ulong, Attachment>();
|
||||
|
||||
using var conn = pool.Take();
|
||||
using var cmd = conn.Command("SELECT message_id, attachment_id, name, type, url, size FROM attachments");
|
||||
using var cmd = conn.Command("SELECT message_id, attachment_id, name, type, url, size, width, height FROM attachments");
|
||||
using var reader = cmd.ExecuteReader();
|
||||
|
||||
while (reader.Read()) {
|
||||
@@ -551,7 +586,9 @@ FROM downloads");
|
||||
Name = reader.GetString(2),
|
||||
Type = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
Url = reader.GetString(4),
|
||||
Size = reader.GetUint64(5)
|
||||
Size = reader.GetUint64(5),
|
||||
Width = reader.IsDBNull(6) ? null : reader.GetInt32(6),
|
||||
Height = reader.IsDBNull(7) ? null : reader.GetInt32(7)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -639,7 +676,7 @@ FROM downloads");
|
||||
|
||||
private long ComputeAttachmentStatistics() {
|
||||
using var conn = pool.Take();
|
||||
return conn.SelectScalar("SELECT COUNT(*) FROM attachments") as long? ?? 0L;
|
||||
return conn.SelectScalar("SELECT COUNT(DISTINCT url) FROM attachments") as long? ?? 0L;
|
||||
}
|
||||
|
||||
private void UpdateAttachmentStatistics(long totalAttachments) {
|
||||
|
@@ -45,22 +45,21 @@ namespace DHT.Server.Database.Sqlite.Utils {
|
||||
}
|
||||
|
||||
public ISqliteConnection Take() {
|
||||
PooledConnection? conn = null;
|
||||
|
||||
while (conn == null) {
|
||||
while (true) {
|
||||
ThrowIfDisposed();
|
||||
|
||||
lock (monitor) {
|
||||
if (free.TryTake(out conn, TimeSpan.FromMilliseconds(rand.Next(100, 200)))) {
|
||||
if (free.TryTake(out var conn)) {
|
||||
used.Add(conn);
|
||||
break;
|
||||
return conn;
|
||||
}
|
||||
else {
|
||||
Log.ForType<SqliteConnectionPool>().Warn("Thread " + Thread.CurrentThread.ManagedThreadId + " is starving for connections.");
|
||||
}
|
||||
}
|
||||
|
||||
Thread.Sleep(TimeSpan.FromMilliseconds(rand.Next(100, 200)));
|
||||
}
|
||||
|
||||
return conn;
|
||||
}
|
||||
|
||||
private void Return(PooledConnection conn) {
|
||||
|
@@ -8,6 +8,7 @@ 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 {
|
||||
abstract class BaseEndpoint {
|
||||
@@ -21,26 +22,22 @@ namespace DHT.Server.Endpoints {
|
||||
this.parameters = parameters;
|
||||
}
|
||||
|
||||
public async Task Handle(HttpContext ctx) {
|
||||
private async Task Handle(HttpContext ctx, StringValues token) {
|
||||
var request = ctx.Request;
|
||||
var response = ctx.Response;
|
||||
|
||||
Log.Info("Request: " + request.GetDisplayUrl() + " (" + request.ContentLength + " B)");
|
||||
|
||||
var requestToken = request.Headers["X-DHT-Token"];
|
||||
if (requestToken.Count != 1 || requestToken[0] != parameters.Token) {
|
||||
Log.Error("Token: " + (requestToken.Count == 1 ? requestToken[0] : "<missing>"));
|
||||
|
||||
if (token.Count != 1 || token[0] != parameters.Token) {
|
||||
Log.Error("Token: " + (token.Count == 1 ? token[0] : "<missing>"));
|
||||
response.StatusCode = (int) HttpStatusCode.Forbidden;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var (statusCode, output) = await Respond(ctx);
|
||||
response.StatusCode = (int) statusCode;
|
||||
|
||||
if (output != null) {
|
||||
await response.WriteAsJsonAsync(output);
|
||||
}
|
||||
response.StatusCode = (int) HttpStatusCode.OK;
|
||||
var output = await Respond(ctx);
|
||||
await output.WriteTo(response);
|
||||
} catch (HttpException e) {
|
||||
Log.Error(e);
|
||||
response.StatusCode = (int) e.StatusCode;
|
||||
@@ -51,7 +48,15 @@ namespace DHT.Server.Endpoints {
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract Task<(HttpStatusCode, object?)> Respond(HttpContext ctx);
|
||||
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.");
|
||||
|
25
app/Server/Endpoints/GetAttachmentEndpoint.cs
Normal file
25
app/Server/Endpoints/GetAttachmentEndpoint.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
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) {}
|
||||
|
||||
protected override Task<IHttpOutput> Respond(HttpContext ctx) {
|
||||
string attachmentUrl = WebUtility.UrlDecode((string) ctx.Request.RouteValues["url"]!);
|
||||
DownloadedAttachment? maybeDownloadedAttachment = Db.GetDownloadedAttachment(attachmentUrl);
|
||||
|
||||
if (maybeDownloadedAttachment is {} downloadedAttachment) {
|
||||
return Task.FromResult<IHttpOutput>(new HttpOutput.File(downloadedAttachment.Type, downloadedAttachment.Data));
|
||||
}
|
||||
else {
|
||||
return Task.FromResult<IHttpOutput>(new HttpOutput.Redirect(attachmentUrl, permanent: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -11,7 +11,7 @@ namespace DHT.Server.Endpoints {
|
||||
sealed class TrackChannelEndpoint : BaseEndpoint {
|
||||
public TrackChannelEndpoint(IDatabaseFile db, ServerParameters parameters) : base(db, parameters) {}
|
||||
|
||||
protected override async Task<(HttpStatusCode, object?)> Respond(HttpContext ctx) {
|
||||
protected override async Task<IHttpOutput> Respond(HttpContext ctx) {
|
||||
var root = await ReadJson(ctx);
|
||||
var server = ReadServer(root.RequireObject("server"), "server");
|
||||
var channel = ReadChannel(root.RequireObject("channel"), "channel", server.Id);
|
||||
@@ -19,7 +19,7 @@ namespace DHT.Server.Endpoints {
|
||||
Db.AddServer(server);
|
||||
Db.AddChannel(channel);
|
||||
|
||||
return (HttpStatusCode.OK, null);
|
||||
return HttpOutput.None;
|
||||
}
|
||||
|
||||
private static Data.Server ReadServer(JsonElement json, string path) => new() {
|
||||
|
@@ -17,7 +17,7 @@ namespace DHT.Server.Endpoints {
|
||||
sealed class TrackMessagesEndpoint : BaseEndpoint {
|
||||
public TrackMessagesEndpoint(IDatabaseFile db, ServerParameters parameters) : base(db, parameters) {}
|
||||
|
||||
protected override async Task<(HttpStatusCode, object?)> Respond(HttpContext ctx) {
|
||||
protected override async Task<IHttpOutput> Respond(HttpContext ctx) {
|
||||
var root = await ReadJson(ctx);
|
||||
|
||||
if (root.ValueKind != JsonValueKind.Array) {
|
||||
@@ -39,7 +39,7 @@ namespace DHT.Server.Endpoints {
|
||||
|
||||
Db.AddMessages(messages);
|
||||
|
||||
return (HttpStatusCode.OK, anyNewMessages ? 1 : 0);
|
||||
return new HttpOutput.Json(anyNewMessages ? 1 : 0);
|
||||
}
|
||||
|
||||
private static Message ReadMessage(JsonElement json, string path) => new() {
|
||||
@@ -61,7 +61,9 @@ namespace DHT.Server.Endpoints {
|
||||
Name = ele.RequireString("name", path),
|
||||
Type = ele.HasKey("type") ? ele.RequireString("type", path) : null,
|
||||
Url = ele.RequireString("url", path),
|
||||
Size = (ulong) ele.RequireLong("size", path)
|
||||
Size = (ulong) ele.RequireLong("size", path),
|
||||
Width = ele.HasKey("width") ? ele.RequireInt("width", path) : null,
|
||||
Height = ele.HasKey("height") ? ele.RequireInt("height", path) : null
|
||||
}).DistinctByKeyStable(static attachment => {
|
||||
// Some Discord messages have duplicate attachments with the same id for unknown reasons.
|
||||
return attachment.Id;
|
||||
|
@@ -11,7 +11,7 @@ namespace DHT.Server.Endpoints {
|
||||
sealed class TrackUsersEndpoint : BaseEndpoint {
|
||||
public TrackUsersEndpoint(IDatabaseFile db, ServerParameters parameters) : base(db, parameters) {}
|
||||
|
||||
protected override async Task<(HttpStatusCode, object?)> Respond(HttpContext ctx) {
|
||||
protected override async Task<IHttpOutput> Respond(HttpContext ctx) {
|
||||
var root = await ReadJson(ctx);
|
||||
|
||||
if (root.ValueKind != JsonValueKind.Array) {
|
||||
@@ -27,7 +27,7 @@ namespace DHT.Server.Endpoints {
|
||||
|
||||
Db.AddUsers(users);
|
||||
|
||||
return (HttpStatusCode.OK, null);
|
||||
return HttpOutput.None;
|
||||
}
|
||||
|
||||
private static User ReadUser(JsonElement json, string path) => new() {
|
||||
|
@@ -20,7 +20,7 @@
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="6.0.5" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="6.0.7" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Utils\Utils.csproj" />
|
||||
|
@@ -9,6 +9,13 @@ using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace DHT.Server.Service {
|
||||
sealed class Startup {
|
||||
private static readonly string[] AllowedOrigins = {
|
||||
"https://discord.com",
|
||||
"https://ptb.discord.com",
|
||||
"https://canary.discord.com",
|
||||
"https://discordapp.com"
|
||||
};
|
||||
|
||||
public void ConfigureServices(IServiceCollection services) {
|
||||
services.Configure<JsonOptions>(static options => {
|
||||
options.SerializerOptions.NumberHandling = JsonNumberHandling.Strict;
|
||||
@@ -16,7 +23,7 @@ namespace DHT.Server.Service {
|
||||
|
||||
services.AddCors(static cors => {
|
||||
cors.AddDefaultPolicy(static builder => {
|
||||
builder.WithOrigins("https://discord.com", "https://discordapp.com").AllowCredentials().AllowAnyMethod().AllowAnyHeader();
|
||||
builder.WithOrigins(AllowedOrigins).AllowCredentials().AllowAnyMethod().AllowAnyHeader();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -27,13 +34,16 @@ namespace DHT.Server.Service {
|
||||
app.UseCors();
|
||||
app.UseEndpoints(endpoints => {
|
||||
TrackChannelEndpoint trackChannel = new(db, parameters);
|
||||
endpoints.MapPost("/track-channel", async context => await trackChannel.Handle(context));
|
||||
endpoints.MapPost("/track-channel", async context => await trackChannel.HandlePost(context));
|
||||
|
||||
TrackUsersEndpoint trackUsers = new(db, parameters);
|
||||
endpoints.MapPost("/track-users", async context => await trackUsers.Handle(context));
|
||||
endpoints.MapPost("/track-users", async context => await trackUsers.HandlePost(context));
|
||||
|
||||
TrackMessagesEndpoint trackMessages = new(db, parameters);
|
||||
endpoints.MapPost("/track-messages", async context => await trackMessages.Handle(context));
|
||||
endpoints.MapPost("/track-messages", async context => await trackMessages.HandlePost(context));
|
||||
|
||||
GetAttachmentEndpoint getAttachment = new(db, parameters);
|
||||
endpoints.MapGet("/get-attachment/{url}", async context => await getAttachment.HandleGet(context));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace DHT.Utils.Collections {
|
||||
public static class LinqExtensions {
|
||||
@@ -14,5 +15,9 @@ namespace DHT.Utils.Collections {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static Dictionary<TKey, TValue> ToDictionary<TKey, TValue>(this IEnumerable<(TKey, TValue)> collection) where TKey : notnull {
|
||||
return collection.ToDictionary(static item => item.Item1, static item => item.Item2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
56
app/Utils/Http/HttpOutput.cs
Normal file
56
app/Utils/Http/HttpOutput.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace DHT.Utils.Http {
|
||||
public static class HttpOutput {
|
||||
public static IHttpOutput None { get; } = new NoneImpl();
|
||||
|
||||
private sealed class NoneImpl : IHttpOutput {
|
||||
public Task WriteTo(HttpResponse response) {
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class Json : IHttpOutput {
|
||||
private readonly object? obj;
|
||||
|
||||
public Json(object? obj) {
|
||||
this.obj = obj;
|
||||
}
|
||||
|
||||
public Task WriteTo(HttpResponse response) {
|
||||
return response.WriteAsJsonAsync(obj);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class File : IHttpOutput {
|
||||
private readonly string? contentType;
|
||||
private readonly byte[] bytes;
|
||||
|
||||
public File(string? contentType, byte[] bytes) {
|
||||
this.contentType = contentType;
|
||||
this.bytes = bytes;
|
||||
}
|
||||
|
||||
public async Task WriteTo(HttpResponse response) {
|
||||
response.ContentType = contentType ?? string.Empty;
|
||||
await response.Body.WriteAsync(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class Redirect : IHttpOutput {
|
||||
private readonly string url;
|
||||
private readonly bool permanent;
|
||||
|
||||
public Redirect(string url, bool permanent) {
|
||||
this.url = url;
|
||||
this.permanent = permanent;
|
||||
}
|
||||
|
||||
public Task WriteTo(HttpResponse response) {
|
||||
response.Redirect(url, permanent);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
8
app/Utils/Http/IHttpOutput.cs
Normal file
8
app/Utils/Http/IHttpOutput.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace DHT.Utils.Http {
|
||||
public interface IHttpOutput {
|
||||
Task WriteTo(HttpResponse response);
|
||||
}
|
||||
}
|
@@ -17,7 +17,10 @@
|
||||
<DebugType>none</DebugType>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="JetBrains.Annotations" Version="10.3.0" />
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="JetBrains.Annotations" Version="2022.1.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="..\Version.cs" Link="Version.cs" />
|
||||
|
@@ -7,6 +7,6 @@ using DHT.Utils;
|
||||
|
||||
namespace DHT.Utils {
|
||||
static class Version {
|
||||
public const string Tag = "36.0.0.0";
|
||||
public const string Tag = "37.1.0.0";
|
||||
}
|
||||
}
|
||||
|
BIN
app/empty.dht
BIN
app/empty.dht
Binary file not shown.
Reference in New Issue
Block a user