mirror of
				https://github.com/chylex/Discord-History-Tracker.git
				synced 2025-11-03 18:40:12 +01:00 
			
		
		
		
	Compare commits
	
		
			1 Commits
		
	
	
		
			0fff3e8eaf
			...
			wip-viewer
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						
						
							
						
						b660af4be0
	
				 | 
					
					
						
@@ -65,6 +65,8 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
 | 
			
		||||
		string indexFile = await Resources.ReadTextAsync("Viewer/index.html");
 | 
			
		||||
		string viewerTemplate = indexFile.Replace("/*[JS]*/", await Resources.ReadJoinedAsync("Viewer/scripts/", '\n'))
 | 
			
		||||
		                                 .Replace("/*[CSS]*/", await Resources.ReadJoinedAsync("Viewer/styles/", '\n'));
 | 
			
		||||
		
 | 
			
		||||
		viewerTemplate = strategy.ProcessViewerTemplate(viewerTemplate);
 | 
			
		||||
 | 
			
		||||
		int viewerArchiveTagStart = viewerTemplate.IndexOf(ArchiveTag);
 | 
			
		||||
		int viewerArchiveTagEnd = viewerArchiveTagStart + ArchiveTag.Length;
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,8 @@
 | 
			
		||||
    
 | 
			
		||||
    <script type="text/javascript">
 | 
			
		||||
		window.DHT_EMBEDDED = "/*[ARCHIVE]*/";
 | 
			
		||||
		window.DHT_SERVER_URL = "/*[SERVER_URL]*/";
 | 
			
		||||
    window.DHT_SERVER_TOKEN = "/*[SERVER_TOKEN]*/";
 | 
			
		||||
		/*[JS]*/
 | 
			
		||||
    </script>
 | 
			
		||||
    <style>
 | 
			
		||||
 
 | 
			
		||||
@@ -182,15 +182,32 @@ const STATE = (function() {
 | 
			
		||||
		return null;
 | 
			
		||||
	};
 | 
			
		||||
	
 | 
			
		||||
	const getMessageList = function() {
 | 
			
		||||
	const getMessageList = async function(abortSignal) {
 | 
			
		||||
		if (!loadedMessages) {
 | 
			
		||||
			return [];
 | 
			
		||||
		}
 | 
			
		||||
		
 | 
			
		||||
		const messages = getMessages(selectedChannel);
 | 
			
		||||
		const startIndex = messagesPerPage * (root.getCurrentPage() - 1);
 | 
			
		||||
		const slicedMessages = loadedMessages.slice(startIndex, !messagesPerPage ? undefined : startIndex + messagesPerPage);
 | 
			
		||||
		
 | 
			
		||||
		return loadedMessages.slice(startIndex, !messagesPerPage ? undefined : startIndex + messagesPerPage).map(key => {
 | 
			
		||||
		let messageTexts = null;
 | 
			
		||||
		
 | 
			
		||||
		if (window.DHT_SERVER_URL !== null) {
 | 
			
		||||
			const messageIds = new Set(slicedMessages);
 | 
			
		||||
			
 | 
			
		||||
			for (const key of slicedMessages) {
 | 
			
		||||
				const message = messages[key];
 | 
			
		||||
				
 | 
			
		||||
				if ("r" in message) {
 | 
			
		||||
					messageIds.add(message.r);
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
			
 | 
			
		||||
			messageTexts = await getMessageTextsFromServer(messageIds, abortSignal);
 | 
			
		||||
		}
 | 
			
		||||
		
 | 
			
		||||
		return slicedMessages.map(key => {
 | 
			
		||||
			/**
 | 
			
		||||
			 * @type {{}}
 | 
			
		||||
			 * @property {Number} u
 | 
			
		||||
@@ -216,6 +233,9 @@ const STATE = (function() {
 | 
			
		||||
			if ("m" in message) {
 | 
			
		||||
				obj["contents"] = message.m;
 | 
			
		||||
			}
 | 
			
		||||
			else if (messageTexts && key in messageTexts) {
 | 
			
		||||
				obj["contents"] = messageTexts[key];
 | 
			
		||||
			}
 | 
			
		||||
			
 | 
			
		||||
			if ("e" in message) {
 | 
			
		||||
				obj["embeds"] = message.e.map(embed => JSON.parse(embed));
 | 
			
		||||
@@ -230,15 +250,16 @@ const STATE = (function() {
 | 
			
		||||
			}
 | 
			
		||||
			
 | 
			
		||||
			if ("r" in message) {
 | 
			
		||||
				const replyMessage = getMessageById(message.r);
 | 
			
		||||
				const replyId = message.r;
 | 
			
		||||
				const replyMessage = getMessageById(replyId);
 | 
			
		||||
				const replyUser = replyMessage ? getUser(replyMessage.u) : null;
 | 
			
		||||
				const replyAvatar = replyUser && replyUser.avatar ? { id: getUserId(replyMessage.u), path: replyUser.avatar } : null;
 | 
			
		||||
				
 | 
			
		||||
				obj["reply"] = replyMessage ? {
 | 
			
		||||
					"id": message.r,
 | 
			
		||||
					"id": replyId,
 | 
			
		||||
					"user": replyUser,
 | 
			
		||||
					"avatar": replyAvatar,
 | 
			
		||||
					"contents": replyMessage.m
 | 
			
		||||
					"contents": messageTexts != null && replyId in messageTexts ? messageTexts[replyId] : replyMessage.m,
 | 
			
		||||
				} : null;
 | 
			
		||||
			}
 | 
			
		||||
			
 | 
			
		||||
@@ -250,9 +271,35 @@ const STATE = (function() {
 | 
			
		||||
		});
 | 
			
		||||
	};
 | 
			
		||||
	
 | 
			
		||||
	const getMessageTextsFromServer = async function(messageIds, abortSignal) {
 | 
			
		||||
		let idParams = "";
 | 
			
		||||
		
 | 
			
		||||
		for (const messageId of messageIds) {
 | 
			
		||||
			idParams += "id=" + encodeURIComponent(messageId) + "&";
 | 
			
		||||
		}
 | 
			
		||||
		
 | 
			
		||||
		const response = await fetch(DHT_SERVER_URL + "/get-messages?" + idParams + "token=" + encodeURIComponent(DHT_SERVER_TOKEN), {
 | 
			
		||||
			method: "GET",
 | 
			
		||||
			headers: {
 | 
			
		||||
				"Content-Type": "application/json",
 | 
			
		||||
			},
 | 
			
		||||
			credentials: "omit",
 | 
			
		||||
			redirect: "error",
 | 
			
		||||
			signal: abortSignal
 | 
			
		||||
		});
 | 
			
		||||
		
 | 
			
		||||
		if (response.status === 200) {
 | 
			
		||||
			return response.json();
 | 
			
		||||
		}
 | 
			
		||||
		else {
 | 
			
		||||
			throw new Error("Server returned status " + response.status + " " + response.statusText);
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
	
 | 
			
		||||
	let eventOnUsersRefreshed;
 | 
			
		||||
	let eventOnChannelsRefreshed;
 | 
			
		||||
	let eventOnMessagesRefreshed;
 | 
			
		||||
	let messageLoaderAborter = null;
 | 
			
		||||
	
 | 
			
		||||
	const triggerUsersRefreshed = function() {
 | 
			
		||||
		eventOnUsersRefreshed && eventOnUsersRefreshed(getUserList());
 | 
			
		||||
@@ -263,7 +310,22 @@ const STATE = (function() {
 | 
			
		||||
	};
 | 
			
		||||
	
 | 
			
		||||
	const triggerMessagesRefreshed = function() {
 | 
			
		||||
		eventOnMessagesRefreshed && eventOnMessagesRefreshed(getMessageList());
 | 
			
		||||
		if (!eventOnMessagesRefreshed) {
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
		
 | 
			
		||||
		if (messageLoaderAborter != null) {
 | 
			
		||||
			messageLoaderAborter.abort();
 | 
			
		||||
		}
 | 
			
		||||
		
 | 
			
		||||
		const aborter = new AbortController();
 | 
			
		||||
		messageLoaderAborter = aborter;
 | 
			
		||||
		
 | 
			
		||||
		getMessageList(aborter.signal).then(eventOnMessagesRefreshed).finally(() => {
 | 
			
		||||
			if (messageLoaderAborter === aborter) {
 | 
			
		||||
				messageLoaderAborter = null;
 | 
			
		||||
			}
 | 
			
		||||
		});
 | 
			
		||||
	};
 | 
			
		||||
	
 | 
			
		||||
	const getFilteredMessageKeys = function(channel) {
 | 
			
		||||
 
 | 
			
		||||
@@ -44,7 +44,7 @@ public sealed class DummyDatabaseFile : IDatabaseFile {
 | 
			
		||||
		return 0;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public List<Message> GetMessages(MessageFilter? filter = null) {
 | 
			
		||||
	public List<Message> GetMessages(MessageFilter? filter = null, bool includeText = true) {
 | 
			
		||||
		return new();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -3,5 +3,7 @@ using DHT.Server.Data;
 | 
			
		||||
namespace DHT.Server.Database.Export.Strategy;
 | 
			
		||||
 | 
			
		||||
public interface IViewerExportStrategy {
 | 
			
		||||
	bool IncludeMessageText { get; }
 | 
			
		||||
	string ProcessViewerTemplate(string template);
 | 
			
		||||
	string GetAttachmentUrl(Attachment attachment);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -12,6 +12,13 @@ public sealed class LiveViewerExportStrategy : IViewerExportStrategy {
 | 
			
		||||
		this.safeToken = WebUtility.UrlEncode(token);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public bool IncludeMessageText => false;
 | 
			
		||||
 | 
			
		||||
	public string ProcessViewerTemplate(string template) {
 | 
			
		||||
		return template.Replace("/*[SERVER_URL]*/", "http://127.0.0.1:" + safePort)
 | 
			
		||||
		               .Replace("/*[SERVER_TOKEN]*/", WebUtility.UrlEncode(safeToken));
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public string GetAttachmentUrl(Attachment attachment) {
 | 
			
		||||
		return "http://127.0.0.1:" + safePort + "/get-attachment/" + WebUtility.UrlEncode(attachment.NormalizedUrl) + "?token=" + safeToken;
 | 
			
		||||
	}
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,13 @@ public sealed class StandaloneViewerExportStrategy : IViewerExportStrategy {
 | 
			
		||||
 | 
			
		||||
	private StandaloneViewerExportStrategy() {}
 | 
			
		||||
 | 
			
		||||
	public bool IncludeMessageText => true;
 | 
			
		||||
 | 
			
		||||
	public string ProcessViewerTemplate(string template) {
 | 
			
		||||
		return template.Replace("\"/*[SERVER_URL]*/\"", "null")
 | 
			
		||||
		               .Replace("\"/*[SERVER_TOKEN]*/\"", "null");
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public string GetAttachmentUrl(Attachment attachment) {
 | 
			
		||||
		// The normalized URL will not load files from Discord CDN once the time limit is enforced.
 | 
			
		||||
		
 | 
			
		||||
 
 | 
			
		||||
@@ -21,7 +21,7 @@ public static class ViewerJsonExport {
 | 
			
		||||
		var includedChannelIds = new HashSet<ulong>();
 | 
			
		||||
		var includedServerIds = new HashSet<ulong>();
 | 
			
		||||
 | 
			
		||||
		var includedMessages = db.GetMessages(filter);
 | 
			
		||||
		var includedMessages = db.GetMessages(filter, strategy.IncludeMessageText);
 | 
			
		||||
		var includedChannels = new List<Channel>();
 | 
			
		||||
 | 
			
		||||
		foreach (var message in includedMessages) {
 | 
			
		||||
 
 | 
			
		||||
@@ -23,7 +23,7 @@ public interface IDatabaseFile : IDisposable {
 | 
			
		||||
 | 
			
		||||
	void AddMessages(Message[] messages);
 | 
			
		||||
	int CountMessages(MessageFilter? filter = null);
 | 
			
		||||
	List<Message> GetMessages(MessageFilter? filter = null);
 | 
			
		||||
	List<Message> GetMessages(MessageFilter? filter = null, bool includeText = true);
 | 
			
		||||
	HashSet<ulong> GetMessageIds(MessageFilter? filter = null);
 | 
			
		||||
	void RemoveMessages(MessageFilter filter, FilterRemovalMode mode);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -360,7 +360,7 @@ public sealed class SqliteDatabaseFile : IDatabaseFile {
 | 
			
		||||
		return reader.Read() ? reader.GetInt32(0) : 0;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public List<Message> GetMessages(MessageFilter? filter = null) {
 | 
			
		||||
	public List<Message> GetMessages(MessageFilter? filter = null, bool includeText = true) {
 | 
			
		||||
		var perf = log.Start();
 | 
			
		||||
		var list = new List<Message>();
 | 
			
		||||
 | 
			
		||||
@@ -370,7 +370,7 @@ public sealed class SqliteDatabaseFile : IDatabaseFile {
 | 
			
		||||
 | 
			
		||||
		using var conn = pool.Take();
 | 
			
		||||
		using var cmd = conn.Command($"""
 | 
			
		||||
		                              SELECT m.message_id, m.sender_id, m.channel_id, m.text, m.timestamp, et.edit_timestamp, rt.replied_to_id
 | 
			
		||||
		                              SELECT m.message_id, m.sender_id, m.channel_id, {(includeText ? "m.text" : "NULL")}, m.timestamp, et.edit_timestamp, rt.replied_to_id
 | 
			
		||||
		                              FROM messages m
 | 
			
		||||
		                              LEFT JOIN edit_timestamps et ON m.message_id = et.message_id
 | 
			
		||||
		                              LEFT JOIN replied_to rt ON m.message_id = rt.message_id
 | 
			
		||||
@@ -385,7 +385,7 @@ public sealed class SqliteDatabaseFile : IDatabaseFile {
 | 
			
		||||
				Id = id,
 | 
			
		||||
				Sender = reader.GetUint64(1),
 | 
			
		||||
				Channel = reader.GetUint64(2),
 | 
			
		||||
				Text = reader.GetString(3),
 | 
			
		||||
				Text = includeText ? reader.GetString(3) : string.Empty,
 | 
			
		||||
				Timestamp = reader.GetInt64(4),
 | 
			
		||||
				EditTimestamp = reader.IsDBNull(5) ? null : reader.GetInt64(5),
 | 
			
		||||
				RepliedToId = reader.IsDBNull(6) ? null : reader.GetUint64(6),
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										34
									
								
								app/Server/Endpoints/GetMessagesEndpoint.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								app/Server/Endpoints/GetMessagesEndpoint.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,34 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Linq;
 | 
			
		||||
using System.Net;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using DHT.Server.Data.Filters;
 | 
			
		||||
using DHT.Server.Database;
 | 
			
		||||
using DHT.Utils.Http;
 | 
			
		||||
using Microsoft.AspNetCore.Http;
 | 
			
		||||
using GetMessagesJsonContext = DHT.Server.Endpoints.Responses.GetMessagesJsonContext;
 | 
			
		||||
 | 
			
		||||
namespace DHT.Server.Endpoints;
 | 
			
		||||
 | 
			
		||||
sealed class GetMessagesEndpoint : BaseEndpoint {
 | 
			
		||||
	public GetMessagesEndpoint(IDatabaseFile db) : base(db) {}
 | 
			
		||||
 | 
			
		||||
	protected override Task<IHttpOutput> Respond(HttpContext ctx) {
 | 
			
		||||
		HashSet<ulong> messageIdSet;
 | 
			
		||||
		try {
 | 
			
		||||
			var messageIds = ctx.Request.Query["id"];
 | 
			
		||||
			messageIdSet = messageIds.Select(ulong.Parse!).ToHashSet();
 | 
			
		||||
		} catch (Exception) {
 | 
			
		||||
			throw new HttpException(HttpStatusCode.BadRequest, "Invalid message ids.");
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		var messageFilter = new MessageFilter {
 | 
			
		||||
			MessageIds = messageIdSet
 | 
			
		||||
		};
 | 
			
		||||
		
 | 
			
		||||
		var messages = Db.GetMessages(messageFilter).ToDictionary(static message => message.Id, static message => message.Text);
 | 
			
		||||
		var response = new HttpOutput.Json<Dictionary<ulong, string>>(messages, GetMessagesJsonContext.Default.DictionaryUInt64String);
 | 
			
		||||
		return Task.FromResult<IHttpOutput>(response);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										8
									
								
								app/Server/Endpoints/Responses/GetMessagesJsonContext.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								app/Server/Endpoints/Responses/GetMessagesJsonContext.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Text.Json.Serialization;
 | 
			
		||||
 | 
			
		||||
namespace DHT.Server.Endpoints.Responses;
 | 
			
		||||
 | 
			
		||||
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, GenerationMode = JsonSourceGenerationMode.Default)]
 | 
			
		||||
[JsonSerializable(typeof(Dictionary<ulong, string>))]
 | 
			
		||||
sealed partial class GetMessagesJsonContext : JsonSerializerContext {}
 | 
			
		||||
@@ -16,6 +16,7 @@ sealed class Startup {
 | 
			
		||||
		"https://ptb.discord.com",
 | 
			
		||||
		"https://canary.discord.com",
 | 
			
		||||
		"https://discordapp.com",
 | 
			
		||||
		"null" // For file:// protocol in the Viewer
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	public void ConfigureServices(IServiceCollection services) {
 | 
			
		||||
@@ -41,6 +42,7 @@ sealed class Startup {
 | 
			
		||||
		
 | 
			
		||||
		app.UseEndpoints(endpoints => {
 | 
			
		||||
			endpoints.MapGet("/get-tracking-script", new GetTrackingScriptEndpoint(db, parameters).Handle);
 | 
			
		||||
			endpoints.MapGet("/get-messages", new GetMessagesEndpoint(db).Handle);
 | 
			
		||||
			endpoints.MapGet("/get-attachment/{url}", new GetAttachmentEndpoint(db).Handle);
 | 
			
		||||
			endpoints.MapPost("/track-channel", new TrackChannelEndpoint(db).Handle);
 | 
			
		||||
			endpoints.MapPost("/track-users", new TrackUsersEndpoint(db).Handle);
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
using System.Text;
 | 
			
		||||
using System.Text.Json.Serialization.Metadata;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using Microsoft.AspNetCore.Http;
 | 
			
		||||
 | 
			
		||||
@@ -25,6 +26,20 @@ public static class HttpOutput {
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public sealed class Json<TValue> : IHttpOutput {
 | 
			
		||||
		private readonly TValue value;
 | 
			
		||||
		private readonly JsonTypeInfo<TValue> typeInfo;
 | 
			
		||||
 | 
			
		||||
		public Json(TValue value, JsonTypeInfo<TValue> typeInfo) {
 | 
			
		||||
			this.value = value;
 | 
			
		||||
			this.typeInfo = typeInfo;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		public Task WriteTo(HttpResponse response) {
 | 
			
		||||
			return response.WriteAsJsonAsync(value, typeInfo);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public sealed class File : IHttpOutput {
 | 
			
		||||
		private readonly string? contentType;
 | 
			
		||||
		private readonly byte[] bytes;
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user