mirror of
				https://github.com/chylex/Discord-History-Tracker.git
				synced 2025-10-31 02:17:15 +01:00 
			
		
		
		
	Compare commits
	
		
			1 Commits
		
	
	
		
			c3bf7d5dc3
			...
			wip-viewer
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| b660af4be0 | 
| @@ -66,6 +66,8 @@ sealed class ViewerPageModel : BaseModel, IDisposable { | ||||
| 		string viewerTemplate = indexFile.Replace("/*[JS]*/", await Resources.ReadJoinedAsync("Viewer/scripts/", '\n')) | ||||
| 		                                 .Replace("/*[CSS]*/", await Resources.ReadJoinedAsync("Viewer/styles/", '\n')); | ||||
| 		 | ||||
| 		viewerTemplate = strategy.ProcessViewerTemplate(viewerTemplate); | ||||
|  | ||||
| 		int viewerArchiveTagStart = viewerTemplate.IndexOf(ArchiveTag); | ||||
| 		int viewerArchiveTagEnd = viewerArchiveTagStart + ArchiveTag.Length; | ||||
|  | ||||
|   | ||||
| @@ -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