Add pre-testing version of the webinterface

This commit is contained in:
Infi
2024-08-24 03:05:33 +02:00
parent 117d9fc45c
commit 5e93732183
27 changed files with 1498 additions and 129 deletions
@@ -0,0 +1,21 @@
using Newtonsoft.Json;
namespace ChatTwo.Http.MessageProtocol;
public struct MessageResponse()
{
[JsonProperty("timestamp")] public string Timestamp = "";
[JsonProperty("messageHTML")] public string Message = "";
}
public class WebSocketNewMessage(MessageResponse[] messages) : BaseOutboundMessage(MessageName)
{
private const string MessageName = "chat-message";
[JsonProperty("messages")] public MessageResponse[] Messages { get; set; } = messages;
}
public class BaseOutboundMessage(string messageType)
{
[JsonProperty("messageType")] public string MessageType { get; set; } = messageType;
}
+96
View File
@@ -0,0 +1,96 @@
using System.Globalization;
using ChatTwo.Code;
using ChatTwo.Http.MessageProtocol;
using ChatTwo.Util;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using Ganss.Xss;
namespace ChatTwo.Http;
public class Processing
{
private readonly Plugin Plugin;
private readonly HtmlSanitizer Sanitizer = new();
public Processing(Plugin plugin)
{
Plugin = plugin;
}
public string ReadChannelName()
{
var messages = new List<string>();
foreach (var chunk in Plugin.ChatLogWindow.ReadChannelName(Plugin.ChatLogWindow.CurrentTab))
messages.Add(ProcessChunk(chunk, noColor: true));
return string.Join("", messages);
}
internal async Task<List<MessageResponse>> ReadMessageList()
{
var tabMessages = await Plugin.ChatLogWindow.CurrentTab!.Messages.GetCopy();
return tabMessages.Select(ReadMessageContent).ToList();
}
internal MessageResponse ReadMessageContent(Message message)
{
var response = new MessageResponse
{
Timestamp = message.Date.ToLocalTime().ToString("t", !Plugin.Config.Use24HourClock ? null : CultureInfo.CreateSpecificCulture("es-ES"))
};
var content = "";
if (message.Sender.Count > 0)
content = message.Sender.Aggregate(content, (current, chunk) => current + ProcessChunk(chunk));
content = message.Content.Aggregate(content, (current, chunk) => current + ProcessChunk(chunk));
response.Message = content;
return response;
}
private string ProcessChunk(Chunk chunk, bool noColor = false)
{
if (chunk is IconChunk { } icon)
{
return IconUtil.GfdFileView.TryGetEntry((uint) icon.Icon, out _)
? $"<span class=\"gfd-icon gfd-icon-hq-{(uint)icon.Icon}\" style=\"zoom:calc(16 * 4 / 3 / 40 * 1.4)\"></span>"
: "";
}
if (chunk is TextChunk { } text)
{
if (chunk.Link is EmotePayload emotePayload && Plugin.Config.ShowEmotes)
{
var image = EmoteCache.GetEmote(emotePayload.Code);
if (image is { Failed: false })
return $"<span style\"height: 1em\"><img src=\"/emote/{emotePayload.Code}\"></span>";
}
var colour = text.Foreground;
if (colour == null && text.FallbackColour != null)
{
var type = text.FallbackColour.Value;
colour = Plugin.Config.ChatColours.TryGetValue(type, out var col) ? col : type.DefaultColor();
}
var color = ColourUtil.RgbaToComponents(colour ?? 0);
var userContent = text.Content ?? "";
if (Plugin.ChatLogWindow.ScreenshotMode)
{
if (chunk.Link is PlayerPayload playerPayload)
userContent = Plugin.ChatLogWindow.HidePlayerInString(userContent, playerPayload.PlayerName, playerPayload.World.RowId);
else if (Plugin.ClientState.LocalPlayer is { } player)
userContent = Plugin.ChatLogWindow.HidePlayerInString(userContent, player.Name.TextValue, player.HomeWorld.Id);
}
userContent = Sanitizer.Sanitize(userContent);
return noColor
? userContent
: $"<span style=\"color:rgba({color.r}, {color.g}, {color.b}, {color.a})\">{userContent}</span>";
}
return string.Empty;
}
}
+153
View File
@@ -0,0 +1,153 @@
using Lumina.Data.Files;
using WatsonWebserver.Core;
using HttpMethod = WatsonWebserver.Core.HttpMethod;
namespace ChatTwo.Http;
public class RouteController
{
private readonly Plugin Plugin;
private readonly ServerCore Core;
private readonly string AuthTemplate;
private readonly string ChatBoxTemplate;
public RouteController(Plugin plugin, ServerCore core)
{
Plugin = plugin;
Core = core;
AuthTemplate = File.ReadAllText(Path.Combine(Core.StaticDir, "templates", "auth.html"));
ChatBoxTemplate = File.ReadAllText(Path.Combine(Core.StaticDir, "templates", "start.html"));
// Pre Auth
Core.HostContext.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/", AuthRoute, ExceptionRoute);
Core.HostContext.Routes.PreAuthentication.Static.Add(HttpMethod.POST, "/auth", AuthenticateClient, ExceptionRoute);
Core.HostContext.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/gfdata.gfd", GetGfdData, ExceptionRoute);
Core.HostContext.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/fonticon_ps5.tex", GetTexData, ExceptionRoute);
Core.HostContext.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/FFXIV_Lodestone_SSF.ttf", GetLodestoneFont, ExceptionRoute);
Core.HostContext.Routes.PreAuthentication.Content.Add("/static", true, ExceptionRoute);
// Post Auth
Core.HostContext.Routes.PostAuthentication.Static.Add(HttpMethod.GET, "/chat", ChatBoxRoute, ExceptionRoute);
Core.HostContext.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/send", ReceiveMessage, ExceptionRoute);
Core.HostContext.Routes.PostAuthentication.Parameter.Add(HttpMethod.GET, "/emote/{name}", GetEmote, ExceptionRoute);
}
private async Task ExceptionRoute(HttpContextBase ctx, Exception _)
{
ctx.Response.StatusCode = 500;
await ctx.Response.Send("Internal Server Error, please try again");
}
private async Task AuthRoute(HttpContextBase ctx)
{
await ctx.Response.Send(AuthTemplate);
}
public void Dispose()
{
}
#region FileHandlerRoutes
private async Task GetTexData(HttpContextBase ctx)
{
var data = Plugin.DataManager.GetFile<TexFile>("common/font/fonticon_ps5.tex")!.Data;
await ctx.Response.Send(data);
}
private async Task GetGfdData(HttpContextBase ctx)
{
var data = Plugin.DataManager.GetFile("common/font/gfdata.gfd")!.Data;
await ctx.Response.Send(data);
}
private async Task GetLodestoneFont(HttpContextBase ctx)
{
var data = Plugin.FontManager.GameSymFont;
await ctx.Response.Send(data);
}
private async Task GetEmote(HttpContextBase ctx)
{
var name = ctx.Request.Url.Parameters["name"] ?? "";
if (name == "" || !EmoteCache.Exists(name))
{
ctx.Response.StatusCode = 400;
await ctx.Response.Send("Malformed emote name.");
return;
}
var emote = EmoteCache.GetEmote(name);
if (emote is not { IsLoaded: true })
{
ctx.Response.StatusCode = 400;
await ctx.Response.Send("Emote not valid.");
return;
}
ctx.Response.Headers.Add("Cache-Control", "max-age=86400");
await ctx.Response.Send(emote.RawData);
}
#endregion
#region PreAuthRoutes
private async Task AuthenticateClient(HttpContextBase ctx)
{
var receivedPassword = ctx.Request.DataAsString ?? "";
if (!receivedPassword.StartsWith("authcode="))
{
ctx.Response.StatusCode = 400;
await ctx.Response.Send("Authentication content invalid.");
return;
}
receivedPassword = receivedPassword[9..];
if (receivedPassword != Plugin.Config.WebinterfacePassword)
{
ctx.Response.StatusCode = 401;
await ctx.Response.Send("Authentication failed.");
return;
}
ctx.Response.Headers.Add("Set-Cookie", $"auth={Plugin.Config.WebinterfacePassword}");
ctx.Response.Headers.Add("Location", "/chat");
ctx.Response.StatusCode = 302;
await ctx.Response.Send();
}
#endregion
#region PostAuthRoutes
private async Task ChatBoxRoute(HttpContextBase ctx)
{
if (Plugin.ChatLogWindow.CurrentTab == null)
{
await ctx.Response.Send("No valid chat tab!");
return;
}
await ctx.Response.Send(ChatBoxTemplate);
}
private async Task ReceiveMessage(HttpContextBase ctx)
{
var content = ctx.Request.DataAsString;
if (content.Length is > 500 or < 2)
{
await ctx.Response.Send("Invalid length for a chat message received.");
return;
}
await Plugin.Framework.RunOnFrameworkThread(() =>
{
Plugin.ChatLogWindow.Chat = content;
Plugin.ChatLogWindow.SendChatBox(Plugin.ChatLogWindow.CurrentTab);
});
await ctx.Response.Send("Message was send to the channel.");
}
#endregion
}
+134
View File
@@ -0,0 +1,134 @@
using ChatTwo.Http.MessageProtocol;
using EmbedIO;
using WatsonWebserver.Core;
using WatsonWebserver.Lite;
using ExceptionEventArgs = WatsonWebserver.Core.ExceptionEventArgs;
namespace ChatTwo.Http;
public class ServerCore : IDisposable
{
private readonly Plugin Plugin;
private readonly Processing Processing;
private readonly RouteController RouteController;
internal readonly WebserverLite HostContext;
private readonly WebSocketServer Websocket;
private readonly WebServer Host;
internal readonly CancellationTokenSource TokenSource = new();
internal readonly string StaticDir = Path.Combine(Plugin.Interface.AssemblyLocation.DirectoryName!, "Http");
public ServerCore(Plugin plugin)
{
Plugin = plugin;
HostContext = new WebserverLite(new WebserverSettings("*", 9000), DefaultRoute);
Processing = new Processing(plugin);
RouteController = new RouteController(plugin, this);
HostContext.Routes.PreAuthentication.Content.BaseDirectory = StaticDir;
HostContext.Routes.AuthenticateRequest = CheckAuthenticationCookie;
HostContext.Events.ExceptionEncountered += ExceptionEncountered;
// Settings
HostContext.Settings.Debug.Requests = true;
HostContext.Settings.Debug.Routing = true;
HostContext.Settings.Debug.Responses = true;
HostContext.Settings.Debug.AccessControl = true;
HostContext.Events.Logger = logMessage => Plugin.Log.Information(logMessage);
// Websocket
Host = new WebServer(o => o
.WithUrlPrefixes($"http://*:9001")
.WithMode(HttpListenerMode.EmbedIO)
);
Websocket = new WebSocketServer("/ws");
Host.WithModule(Websocket);
Websocket.OnClientConnected += ClientConnected;
}
#region WebsocketFunctions
private void ClientConnected(object? sender, EventArgs args)
{
Task.Run(async () =>
{
var messages = await WebserverUtil.FrameworkWrapper(Processing.ReadMessageList);
Websocket.BroadcastMessage(new WebSocketNewMessage(messages.ToArray()));
});
}
internal void SendNewMessage(Message message)
{
try
{
Plugin.Framework.RunOnTick(() =>
{
Websocket.BroadcastMessage(new WebSocketNewMessage([Processing.ReadMessageContent(message)]));
});
}
catch (Exception ex)
{
Plugin.Log.Error(ex, "Send message to websockets failed.");
}
}
#endregion
#region GeneralHandlers
private static void ExceptionEncountered(object? _, ExceptionEventArgs args)
{
Plugin.Log.Error(args.Exception, "Webserver threw an exception.");
}
private async Task DefaultRoute(HttpContextBase ctx)
{
await ctx.Response.Send("Nothing to see here.");
}
#endregion
private async Task CheckAuthenticationCookie(HttpContextBase ctx)
{
var cookie = ctx.Request.Headers.Get("Cookie") ?? "";
if (!cookie.StartsWith("auth=") || cookie[5..] != Plugin.Config.WebinterfacePassword)
{
ctx.Response.StatusCode = 401;
await ctx.Response.Send("Your session auth code was invalid");
}
// Do nothing to let auth pass
}
public bool GetStats()
{
return HostContext.IsListening;
}
public void Start()
{
try
{
HostContext.Start(TokenSource.Token);
Host.Start(TokenSource.Token);
}
catch (Exception ex)
{
Plugin.Log.Error(ex, "Startup failed with an error.");
}
}
public void Dispose()
{
TokenSource.Cancel();
HostContext.Stop();
HostContext.Dispose();
Websocket.Dispose();
Host.Dispose();
RouteController.Dispose();
}
}
+37
View File
@@ -0,0 +1,37 @@
namespace ChatTwo.Http;
public static class WebserverUtil
{
public static async Task<T> FrameworkWrapper<T>(Func<Task<T>> func)
{
return await Plugin.Framework.RunOnTick(func).ConfigureAwait(false);
}
public class DisposableWrapper : IDisposable {
private readonly Action Down;
private bool Disposed;
public DisposableWrapper(Action down) {
Down = down;
}
public void Dispose() {
if (Disposed) return;
Down();
Disposed = true;
GC.SuppressFinalize(this);
}
}
}
public static class AsyncUtils {
public static async Task<IDisposable> UseWaitAsync(this SemaphoreSlim semaphore, CancellationToken ct = default) {
await semaphore.WaitAsync(ct).ConfigureAwait(false);
return new WebserverUtil.DisposableWrapper(() => {
semaphore.Release();
});
}
}
+48
View File
@@ -0,0 +1,48 @@
using ChatTwo.Http.MessageProtocol;
using EmbedIO.WebSockets;
using Newtonsoft.Json;
namespace ChatTwo.Http;
public class WebSocketServer : WebSocketModule {
private readonly SemaphoreSlim SendLock = new(1, 1);
public event EventHandler? OnClientConnected;
public WebSocketServer(string urlPath) : base(urlPath, true) {
}
protected override async Task OnMessageReceivedAsync(IWebSocketContext context, byte[] buffer, IWebSocketReceiveResult result)
{
// Unused method
}
protected override Task OnClientConnectedAsync(IWebSocketContext context)
{
Plugin.Log.Information($"Client connected: {context.Id}");
OnClientConnected?.Invoke(this, EventArgs.Empty);
return base.OnClientConnectedAsync(context);
}
protected override Task OnClientDisconnectedAsync(IWebSocketContext context)
{
Plugin.Log.Information($"Client disconnected: {context.Id}");
return base.OnClientConnectedAsync(context);
}
protected override void Dispose(bool disposing) {
base.Dispose(disposing);
SendLock.Dispose();
}
public void BroadcastMessage(BaseOutboundMessage message) {
Task.Run(async () => {
using (await SendLock.UseWaitAsync()) {
var serializedData = JsonConvert.SerializeObject(message);
await BroadcastAsync(serializedData);
}
});
}
}
Binary file not shown.
+243
View File
@@ -0,0 +1,243 @@
/* fonts */
@font-face {
font-family: Lodestone;
src: url('files/FFXIV_Lodestone_SSF.ttf') format('truetype');
unicode-range: U+E020-E0DB;
}
@font-face {
font-family: 'Inter var';
font-weight: 100 900;
font-style: oblique 0deg 10deg;
src: url('static/Inter.var.woff2') format('woff2');
}
/* variables */
:root {
--fg: white;
--fg-faint: #a0a0a0;
--fg-scrollbar: #404040;
--bg: #101010;
--bg-input: #202020;
--bg-input-hover: #282828;
--focus-color: #4060a0;
--gradient-clickable: linear-gradient(to bottom, #404040, var(--bg-input) 65%, var(--bg-input));
--gradient-clickable-hover: linear-gradient(to bottom, #505050, var(--bg-input-hover) 65%, var(--bg-input-hover));
--timestamp-width: 70px;
}
/* reset */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
* {
color: var(--fg);
font-family: Lodestone, 'Inter var', sans-serif;
font-feature-settings: 'tnum';
}
html {
font-size: 16px;
}
/* layout and global styles */
body {
padding: 50px;
height: 100dvh;
background-color: var(--bg-input-hover);
}
body > main {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background-color: var(--bg);
border-radius: 20px;
box-shadow: 0 0 50px 0 rgba(0, 0, 0, 0.5);
}
/* message list */
section#messages {
position: relative;
flex: 1;
min-height: 0;
padding: 20px;
line-height: 1.5;
.scroll-container {
height: 100%;
overflow-y: scroll;
scrollbar-color: var(--fg-scrollbar) var(--bg);
&.more-messages::before {
content: '';
position: absolute;
bottom: -20px;
left: 100px;
width: calc(100% - 200px);
height: 200px;
background-image: radial-gradient(50% 20% at 50% 100%, #60a0ff40, transparent);
}
}
ol {
list-style-type: none;
}
li {
display: flex;
align-items: flex-start;
gap: 10px;
.timestamp {
flex: 0 0 var(--timestamp-width);
color: var(--fg-faint);
text-align: right;
}
}
}
#timestamp-width-probe {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
opacity: 0;
}
/* input bar, channel selector, ... */
section#input {
flex-grow: 0;
padding: 20px;
form {
display: flex;
gap: 10px;
}
input, button, select {
font-size: 1rem;
border: 3px solid transparent;
border-radius: 20px;
background-color: var(--bg-input);
&:focus {
outline: 2px solid var(--focus-color);
}
}
button, select {
padding: 5px 15px;
border: 3px solid var(--bg-input);
background-image: var(--gradient-clickable);
cursor: pointer;
&:hover {
border-color: var(--bg-input-hover);
background-color: var(--bg-input-hover);
background-image: var(--gradient-clickable-hover);
}
}
.select-container, button {
position: relative;
flex-grow: 0;
flex-shrink: 0;
}
.select-container {
flex-basis: 0;
&::before {
/* "message-circle" icon from https://github.com/feathericons/feather, under MIT license */
mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>');
}
select {
width: 100%;
padding-left: 1.5rem;
padding-right: 1.5rem;
appearance: none;
color: transparent;
}
}
button {
padding-left: calc(20px + 1.5rem);
&::before {
/* "send" icon from https://github.com/feathericons/feather, under MIT license */
mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>');
}
}
.select-container::before, button::before {
content: '';
position: absolute;
left: 15px;
top: 50%;
transform: translateY(-50%);
width: 1.3rem;
height: 1.3rem;
background-color: var(--fg);
mask-size: 100%;
pointer-events: none;
}
.select-container::before {
left: 50%;
transform: translateX(-50%) translateY(-50%);
}
.input-container {
flex: 1;
position: relative;
#chat-input {
width: 100%;
padding: 5px 20px;
}
#channel-hint {
position: absolute;
top: -1.2em;
left: 23px;
font-size: 1.1rem;
font-weight: 550;
pointer-events: none;
}
}
}
/*** mobile ***/
@media ((max-width: 600px) and (orientation: portrait)) or (max-width: 400px) {
body {
padding: 0;
}
body > main {
border-radius: 0;
box-shadow: none;
}
section#input {
button {
max-width: 0;
padding-left: 1.5rem;
padding-right: 1.5rem;
color: transparent;
}
button::before {
left: 50%;
transform: translateX(-50%) translateY(-50%);
}
}
}
+243
View File
@@ -0,0 +1,243 @@
// websocket connection
class WSConnection {
constructor() {
this.socket = new WebSocket('ws://192.168.2.106:9001/ws');
this.socket.addEventListener('open', this.onWSOpen.bind(this));
this.socket.addEventListener('close', this.onWSClose.bind(this));
this.socket.addEventListener('message', this.onWSMessage.bind(this));
}
onWSOpen() {
// send request for initial data (channels, currently selected channel)
}
onWSClose() {
// open new websocket here? for mobile
}
onWSMessage(event) {
try {
let eventData = JSON.parse(event.data);
for (let message of eventData.messages)
{
addMessage(message);
}
} catch (error) {
// TODO: error handling?
return;
}
}
send(message) {
this.socket.send(message);
}
}
const ws = new WSConnection();
// channel switcher
function updateChannelHint(label) {
document.getElementById('channel-hint').innerText = label;
}
document.getElementById('channel-select').addEventListener('change', (event) => {
updateChannelHint(event.target.value);
// TODO: send new channel to "backend"
// ws.send(...);
});
// functions for handling the message list
function scrollMessagesToBottom() {
const messagesContainer = document.querySelector('#messages > .scroll-container');
messagesContainer.scrollTop = messagesContainer.scrollHeight - messagesContainer.offsetHeight;
}
function messagesContainerIsScrolledToBottom() {
const messagesContainer = document.querySelector('#messages > .scroll-container');
return messagesContainer.scrollTop == messagesContainer.scrollHeight - messagesContainer.offsetHeight;
}
function addMessage(messageData) {
const scrolledToBottom = messagesContainerIsScrolledToBottom();
const liMessage = document.createElement('li');
const spanTimestamp = document.createElement('span');
spanTimestamp.classList.add('timestamp');
const spanMessage = document.createElement('span');
spanMessage.classList.add('message');
// suggestions, update with actual data model
spanTimestamp.innerText = messageData.timestamp;
spanMessage.innerHTML = messageData.messageHTML; // or build HTML in here
liMessage.appendChild(spanTimestamp);
liMessage.appendChild(spanMessage);
document.getElementById('messages-list').appendChild(liMessage);
if (scrolledToBottom) {
scrollMessagesToBottom();
}
}
function clearMessages() {
document.getElementById('messages-list').innerHTML = '';
}
//
document.querySelector('#messages > .scroll-container').addEventListener('scroll', () => {
const messagesContainer = document.querySelector('#messages > .scroll-container');
if (!messagesContainerIsScrolledToBottom()) {
messagesContainer.classList.add('more-messages');
} else {
messagesContainer.classList.remove('more-messages');
}
});
// handle message sending
document.querySelector('#input > form').addEventListener('submit', (event) => {
event.preventDefault();
const chatInput = document.getElementById('chat-input');
const message = chatInput.value;
fetch('/send', {
method: 'POST',
body: message,
headers: {
'Content-type': 'application/txt; charset=UTF-8'
}
});
chatInput.value = '';
});
// calculate timestamp width
// to ensure that all timestamps have the same width. some typefaces have the same width across
// all number glyphs, others do not. the solution below is very rudimentary; at the very least,
// delaying it to account for font loading might make sense. perhaps there's an even better way?
window.setTimeout(() => {
const widthProbe = document.getElementById('timestamp-width-probe');
widthProbe.innerText = '88:88'; // assume 8 to be widest glyph
document.body.style.setProperty('--timestamp-width', Math.ceil(widthProbe.clientWidth) + 'px');
}, 100);
// From kizer
async function AddGfdStylesheet(gfdPath, texPath) {
const texPromise = LoadTexAsBlob(texPath);
const gfdPromise = LoadGfd(gfdPath);
const texUrl = URL.createObjectURL(await texPromise);
const gfd = await gfdPromise;
let stylesheets = [];
for (const entry of gfd) {
if (entry.width * entry.height <= 0)
continue;
if (entry.redirect !== 0) {
stylesheets[entry.redirect][0].push(entry.id);
continue;
}
stylesheets[entry.id] = [
[entry.id],
[
`background-position: -${entry.left}px -${entry.top}px`,
`background-image: url('${texUrl}')`,
`width: ${entry.width}px`,
`height: ${entry.height}px`,
`margin-bottom: -20px`
].join(";"),
[
`background-position: -${entry.left * 2}px -${entry.top * 2 + 341}px`,
`background-image: url('${texUrl}')`,
`width: ${entry.width * 2}px`,
`height: ${entry.height * 2}px`,
`margin-bottom: -40px`
].join(";")
];
}
let stylesheet = ".gfd-icon::before { content: ''; display: inline-block; overflow: hidden; vertical-align: top; height:0; }";
for (const entry of stylesheets) {
if (!entry)
continue;
stylesheet += `\n${entry[0].map(x => `.gfd-icon.gfd-icon-${x}::before`).join(', ')}{${entry[1]};}`;
stylesheet += `\n${entry[0].map(x => `.gfd-icon.gfd-icon-hq-${x}::before`).join(', ')}{${entry[2]};}`;
}
const styleNode = document.createElement("style");
styleNode.type = "text/css";
styleNode.appendChild(document.createTextNode(stylesheet));
document.head.appendChild(styleNode);
}
async function LoadTexAsBlob(path) {
const tex = ParseTex(await (await fetch(path)).arrayBuffer());
if (tex.format !== 0x1450) // B8G8R8A8
throw "Not supported";
const dataArray = new Uint8ClampedArray(tex.buffer, tex.offsetToSurface[0], tex.width * tex.height * 4);
for (let i = 0; i < dataArray.length; i += 4) {
const t = dataArray[i];
dataArray[i] = dataArray[i + 2];
dataArray[i + 2] = t;
}
const imageData = new ImageData(dataArray, tex.width, tex.height);
const bitmap = await createImageBitmap(imageData);
const canvas = new OffscreenCanvas(tex.width, tex.height);
canvas.getContext('bitmaprenderer').transferFromImageBitmap(bitmap);
return await canvas.convertToBlob();
}
async function LoadGfd(path) {
const buffer = new DataView(await (await fetch(path)).arrayBuffer());
const count = buffer.getInt32(8, true);
const entries = new Array(count);
for (let i = 0; i < count; i++) {
const offset = 0x10 + (i * 0x10);
entries[i] = {
id: buffer.getInt16(offset, true),
left: buffer.getInt16(offset + 2, true),
top: buffer.getInt16(offset + 4, true),
width: buffer.getInt16(offset + 6, true),
height: buffer.getInt16(offset + 8, true),
unk0A: buffer.getInt16(offset + 10, true),
redirect: buffer.getInt16(offset + 12, true),
unk0E: buffer.getInt16(offset + 14, true),
};
}
return entries;
}
function ParseTex(arrayBuffer) {
const buffer = new DataView(arrayBuffer);
const type = buffer.getInt32(0, true);
const format = buffer.getInt32(4, true);
const width = buffer.getInt16(8, true);
const height = buffer.getInt16(10, true);
const depth = buffer.getInt16(12, true);
const mipsAndFlag = buffer.getInt8(14, true);
const arraySize = buffer.getInt8(15, true);
const lodOffsets = [buffer.getInt32(16, true), buffer.getInt32(20, true), buffer.getInt32(24, true)];
const offsetToSurface = [buffer.getInt32(28, true), buffer.getInt32(32, true), buffer.getInt32(36, true), buffer.getInt32(40, true), buffer.getInt32(44, true), buffer.getInt32(48, true), buffer.getInt32(52, true), buffer.getInt32(56, true), buffer.getInt32(60, true), buffer.getInt32(64, true), buffer.getInt32(68, true), buffer.getInt32(72, true), buffer.getInt32(76, true)];
return {
buffer: arrayBuffer,
type,
format,
width,
height,
depth,
mipsAndFlag,
arraySize,
lodOffsets,
offsetToSurface,
};
}
AddGfdStylesheet("/files/gfdata.gfd", "/files/fonticon_ps5.tex");
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Authentication</title>
<link rel="stylesheet" href="/static/start.css">
</head>
<body>
<div style="text-align: center;">
<h3 style="color: #fff">Authcode</h3>
<form action="/auth" method="POST">
<input style="color: #fff; background: #121212" type="password" id="authcode" name="authcode">
<input style="color: #fff; background: #121212" type="submit" value="Submit">
</form>
<img src="http://192.168.2.106:9000/emote/Sure">
</div>
</body>
</html>
Binary file not shown.