From c94808a15faade9b0250e46cf256f1d13c17dac2 Mon Sep 17 00:00:00 2001 From: chylex Date: Fri, 15 Jul 2022 11:31:14 +0200 Subject: [PATCH] Show downloaded attachments when viewing via Open Viewer --- app/Desktop/Main/Pages/ViewerPageModel.cs | 10 ++-- app/Resources/Viewer/scripts/discord.js | 14 +---- app/Server/Data/DownloadedAttachment.cs | 6 ++ app/Server/Database/DummyDatabaseFile.cs | 4 ++ .../Export/Strategy/IViewerExportStrategy.cs | 7 +++ .../Strategy/LiveViewerExportStrategy.cs | 18 ++++++ .../StandaloneViewerExportStrategy.cs | 13 +++++ .../Database/Export/ViewerJsonExport.cs | 13 +++-- app/Server/Database/IDatabaseFile.cs | 3 +- .../Database/Sqlite/SqliteDatabaseFile.cs | 22 ++++++++ app/Server/Endpoints/BaseEndpoint.cs | 29 ++++++---- app/Server/Endpoints/GetAttachmentEndpoint.cs | 25 +++++++++ app/Server/Endpoints/TrackChannelEndpoint.cs | 4 +- app/Server/Endpoints/TrackMessagesEndpoint.cs | 4 +- app/Server/Endpoints/TrackUsersEndpoint.cs | 4 +- app/Server/Service/ServerStartup.cs | 9 ++- app/Utils/Http/HttpOutput.cs | 56 +++++++++++++++++++ app/Utils/Http/IHttpOutput.cs | 8 +++ app/Utils/Utils.csproj | 3 + 19 files changed, 210 insertions(+), 42 deletions(-) create mode 100644 app/Server/Data/DownloadedAttachment.cs create mode 100644 app/Server/Database/Export/Strategy/IViewerExportStrategy.cs create mode 100644 app/Server/Database/Export/Strategy/LiveViewerExportStrategy.cs create mode 100644 app/Server/Database/Export/Strategy/StandaloneViewerExportStrategy.cs create mode 100644 app/Server/Endpoints/GetAttachmentEndpoint.cs create mode 100644 app/Utils/Http/HttpOutput.cs create mode 100644 app/Utils/Http/IHttpOutput.cs diff --git a/app/Desktop/Main/Pages/ViewerPageModel.cs b/app/Desktop/Main/Pages/ViewerPageModel.cs index 421380d..b022f88 100644 --- a/app/Desktop/Main/Pages/ViewerPageModel.cs +++ b/app/Desktop/Main/Pages/ViewerPageModel.cs @@ -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); } } diff --git a/app/Resources/Viewer/scripts/discord.js b/app/Resources/Viewer/scripts/discord.js index fe05813..9952d0d 100644 --- a/app/Resources/Viewer/scripts/discord.js +++ b/app/Resources/Viewer/scripts/discord.js @@ -129,7 +129,7 @@ const DISCORD = (function() { // noinspection HtmlUnknownTarget templateAttachmentDownload = new TEMPLATE([ - "Download {filename}" + "Download {name}" ].join("")); // noinspection HtmlUnknownTarget @@ -240,19 +240,11 @@ const DISCORD = (function() { } return value.map(attachment => { - const url = DOM.tryParseUrl(attachment.url); - - if (url != null && isImageUrl(url) && SETTINGS.enableImagePreviews) { + if (DISCORD.isImageAttachment(attachment) && SETTINGS.enableImagePreviews) { return templateEmbedImage.apply({ url: attachment.url, src: attachment.url }); } else { - const path = url == null ? attachment.url : url.pathname; - const sliced = path.split("/"); - - return templateAttachmentDownload.apply({ - "url": attachment.url, - "filename": sliced[sliced.length - 1] - }); + return templateAttachmentDownload.apply(attachment); } }).join(""); } diff --git a/app/Server/Data/DownloadedAttachment.cs b/app/Server/Data/DownloadedAttachment.cs new file mode 100644 index 0000000..97fe56f --- /dev/null +++ b/app/Server/Data/DownloadedAttachment.cs @@ -0,0 +1,6 @@ +namespace DHT.Server.Data { + public readonly struct DownloadedAttachment { + public string? Type { get; internal init; } + public byte[] Data { get; internal init; } + } +} diff --git a/app/Server/Database/DummyDatabaseFile.cs b/app/Server/Database/DummyDatabaseFile.cs index ca5affd..17602a4 100644 --- a/app/Server/Database/DummyDatabaseFile.cs +++ b/app/Server/Database/DummyDatabaseFile.cs @@ -63,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) {} diff --git a/app/Server/Database/Export/Strategy/IViewerExportStrategy.cs b/app/Server/Database/Export/Strategy/IViewerExportStrategy.cs new file mode 100644 index 0000000..30f93f1 --- /dev/null +++ b/app/Server/Database/Export/Strategy/IViewerExportStrategy.cs @@ -0,0 +1,7 @@ +using DHT.Server.Data; + +namespace DHT.Server.Database.Export.Strategy { + public interface IViewerExportStrategy { + string GetAttachmentUrl(Attachment attachment); + } +} diff --git a/app/Server/Database/Export/Strategy/LiveViewerExportStrategy.cs b/app/Server/Database/Export/Strategy/LiveViewerExportStrategy.cs new file mode 100644 index 0000000..6f1d2d9 --- /dev/null +++ b/app/Server/Database/Export/Strategy/LiveViewerExportStrategy.cs @@ -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; + } + } +} diff --git a/app/Server/Database/Export/Strategy/StandaloneViewerExportStrategy.cs b/app/Server/Database/Export/Strategy/StandaloneViewerExportStrategy.cs new file mode 100644 index 0000000..a34ac84 --- /dev/null +++ b/app/Server/Database/Export/Strategy/StandaloneViewerExportStrategy.cs @@ -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; + } + } +} diff --git a/app/Server/Database/Export/ViewerJsonExport.cs b/app/Server/Database/Export/ViewerJsonExport.cs index 69d556c..ce0b525 100644 --- a/app/Server/Database/Export/ViewerJsonExport.cs +++ b/app/Server/Database/Export/ViewerJsonExport.cs @@ -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(); @@ -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 includedMessages, Dictionary userIndices) { + private static object GenerateMessageList( List includedMessages, Dictionary userIndices, IViewerExportStrategy strategy) { var data = new Dictionary>(); foreach (var grouping in includedMessages.GroupBy(static message => message.Channel)) { @@ -164,8 +166,9 @@ namespace DHT.Server.Database.Export { } if (!message.Attachments.IsEmpty) { - obj["a"] = message.Attachments.Select(static attachment => new Dictionary { - { "url", attachment.Url } + obj["a"] = message.Attachments.Select(attachment => new Dictionary { + { "url", strategy.GetAttachmentUrl(attachment) }, + { "name", Uri.TryCreate(attachment.Url, UriKind.Absolute, out var uri) ? Path.GetFileName(uri.LocalPath) : attachment.Url } }).ToArray(); } diff --git a/app/Server/Database/IDatabaseFile.cs b/app/Server/Database/IDatabaseFile.cs index 1fb3ed8..dba7658 100644 --- a/app/Server/Database/IDatabaseFile.cs +++ b/app/Server/Database/IDatabaseFile.cs @@ -31,7 +31,8 @@ namespace DHT.Server.Database { void AddDownload(Data.Download download); List GetDownloadsWithoutData(); Data.Download GetDownloadWithData(Data.Download download); - + DownloadedAttachment? GetDownloadedAttachment(string url); + void EnqueueDownloadItems(AttachmentFilter? filter = null); List GetEnqueuedDownloadItems(int count); void RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode); diff --git a/app/Server/Database/Sqlite/SqliteDatabaseFile.cs b/app/Server/Database/Sqlite/SqliteDatabaseFile.cs index 87d25ea..d64869f 100644 --- a/app/Server/Database/Sqlite/SqliteDatabaseFile.cs +++ b/app/Server/Database/Sqlite/SqliteDatabaseFile.cs @@ -470,6 +470,28 @@ 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, MAX(a.size) FROM attachments a" + filter.GenerateWhereClause("a") + " GROUP BY a.url"); diff --git a/app/Server/Endpoints/BaseEndpoint.cs b/app/Server/Endpoints/BaseEndpoint.cs index f0dacd3..115ca60 100644 --- a/app/Server/Endpoints/BaseEndpoint.cs +++ b/app/Server/Endpoints/BaseEndpoint.cs @@ -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] : "")); + + if (token.Count != 1 || token[0] != parameters.Token) { + Log.Error("Token: " + (token.Count == 1 ? token[0] : "")); 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 Respond(HttpContext ctx); protected static async Task ReadJson(HttpContext ctx) { return await ctx.Request.ReadFromJsonAsync() ?? throw new HttpException(HttpStatusCode.UnsupportedMediaType, "This endpoint only accepts JSON."); diff --git a/app/Server/Endpoints/GetAttachmentEndpoint.cs b/app/Server/Endpoints/GetAttachmentEndpoint.cs new file mode 100644 index 0000000..16d6763 --- /dev/null +++ b/app/Server/Endpoints/GetAttachmentEndpoint.cs @@ -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 Respond(HttpContext ctx) { + string attachmentUrl = WebUtility.UrlDecode((string) ctx.Request.RouteValues["url"]!); + DownloadedAttachment? maybeDownloadedAttachment = Db.GetDownloadedAttachment(attachmentUrl); + + if (maybeDownloadedAttachment is {} downloadedAttachment) { + return Task.FromResult(new HttpOutput.File(downloadedAttachment.Type, downloadedAttachment.Data)); + } + else { + return Task.FromResult(new HttpOutput.Redirect(attachmentUrl, permanent: false)); + } + } + } +} diff --git a/app/Server/Endpoints/TrackChannelEndpoint.cs b/app/Server/Endpoints/TrackChannelEndpoint.cs index b44dac2..c01bd7e 100644 --- a/app/Server/Endpoints/TrackChannelEndpoint.cs +++ b/app/Server/Endpoints/TrackChannelEndpoint.cs @@ -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 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() { diff --git a/app/Server/Endpoints/TrackMessagesEndpoint.cs b/app/Server/Endpoints/TrackMessagesEndpoint.cs index 91b1cd3..6bc2d63 100644 --- a/app/Server/Endpoints/TrackMessagesEndpoint.cs +++ b/app/Server/Endpoints/TrackMessagesEndpoint.cs @@ -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 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() { diff --git a/app/Server/Endpoints/TrackUsersEndpoint.cs b/app/Server/Endpoints/TrackUsersEndpoint.cs index 2e8070e..02afb76 100644 --- a/app/Server/Endpoints/TrackUsersEndpoint.cs +++ b/app/Server/Endpoints/TrackUsersEndpoint.cs @@ -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 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() { diff --git a/app/Server/Service/ServerStartup.cs b/app/Server/Service/ServerStartup.cs index 3a1b1f2..73f6f26 100644 --- a/app/Server/Service/ServerStartup.cs +++ b/app/Server/Service/ServerStartup.cs @@ -34,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)); }); } } diff --git a/app/Utils/Http/HttpOutput.cs b/app/Utils/Http/HttpOutput.cs new file mode 100644 index 0000000..9f403f4 --- /dev/null +++ b/app/Utils/Http/HttpOutput.cs @@ -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; + } + } + } +} diff --git a/app/Utils/Http/IHttpOutput.cs b/app/Utils/Http/IHttpOutput.cs new file mode 100644 index 0000000..039980a --- /dev/null +++ b/app/Utils/Http/IHttpOutput.cs @@ -0,0 +1,8 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace DHT.Utils.Http { + public interface IHttpOutput { + Task WriteTo(HttpResponse response); + } +} diff --git a/app/Utils/Utils.csproj b/app/Utils/Utils.csproj index 60f260e..d55e716 100644 --- a/app/Utils/Utils.csproj +++ b/app/Utils/Utils.csproj @@ -16,6 +16,9 @@ true none + + +