First implementation of sveltekit for webinterface

This commit is contained in:
Infi
2025-09-20 16:07:46 +02:00
parent 9b3e3f79e3
commit 94b345c6a3
40 changed files with 3074 additions and 97 deletions
+2 -2
View File
@@ -7,8 +7,8 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="JetBrains.Annotations" Version="2025.2.0" /> <PackageReference Include="JetBrains.Annotations" Version="2025.2.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="MSTest.TestAdapter" Version="3.6.3" /> <PackageReference Include="MSTest.TestAdapter" Version="3.6.3" />
<PackageReference Include="MSTest.TestFramework" Version="3.6.3" /> <PackageReference Include="MSTest.TestFramework" Version="3.6.3" />
</ItemGroup> </ItemGroup>
+5 -2
View File
@@ -1,4 +1,4 @@
<Project Sdk="Dalamud.NET.Sdk/13.0.0"> <Project Sdk="Dalamud.NET.Sdk/13.1.0">
<PropertyGroup> <PropertyGroup>
<Version>1.31.2</Version> <Version>1.31.2</Version>
<TargetFramework>net9.0-windows</TargetFramework> <TargetFramework>net9.0-windows</TargetFramework>
@@ -18,7 +18,7 @@
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.0" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.0" />
<PackageReference Include="Pidgin" Version="3.3.0" /> <PackageReference Include="Pidgin" Version="3.3.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
<PackageReference Include="Watson.Lite" Version="6.2.3" /> <PackageReference Include="Watson.Lite" Version="6.3.13" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -47,5 +47,8 @@
<Content Include="Http\templates\*.*"> <Content Include="Http\templates\*.*">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content> </Content>
<Content Include="Http\Frontend\build\**">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup> </ItemGroup>
</Project> </Project>
+1 -1
View File
@@ -1,6 +1,6 @@
namespace ChatTwo.Code; namespace ChatTwo.Code;
internal enum InputChannel : uint public enum InputChannel : uint
{ {
Tell = 0, Tell = 0,
Say = 1, Say = 1,
+1 -1
View File
@@ -120,7 +120,7 @@ internal class Configuration : IPluginConfiguration
public bool WebinterfaceAutoStart; public bool WebinterfaceAutoStart;
public string WebinterfacePassword = WebinterfaceUtil.GenerateSimpleAuthCode(); public string WebinterfacePassword = WebinterfaceUtil.GenerateSimpleAuthCode();
public int WebinterfacePort = 9000; public int WebinterfacePort = 9000;
public ConcurrentDictionary<string, bool> SessionTokens = []; public HashSet<string> AuthStore = [];
public int WebinterfaceMaxLinesToSend = 1000; // 1-10000 public int WebinterfaceMaxLinesToSend = 1000; // 1-10000
internal void UpdateFrom(Configuration other, bool backToOriginal) internal void UpdateFrom(Configuration other, bool backToOriginal)
+23
View File
@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+38
View File
@@ -0,0 +1,38 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"svelte": "^5.39.2",
"svelte-check": "^4.0.0",
"sveltekit-sse": "^0.14.3",
"typescript": "^5.0.0",
"vite": "^7.0.4"
},
"dependencies": {
"@sveltestrap/sveltestrap": "^7.1.0"
}
}
+23
View File
@@ -0,0 +1,23 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
interface Error {
code: string;
id: string;
}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
interface Warning {
hasWarning: boolean;
content: string;
}
}
interface Element { scrollTopMax: number } // Firefox only property
}
export {};
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>%sveltekit.error.message%</title>
</head>
<body>
<p>Status: %sveltekit.status%</p>
<p>Message: %sveltekit.error.message%</p>
</body>
</html>
+426
View File
@@ -0,0 +1,426 @@
import {type Source, source} from "sveltekit-sse";
interface ChatElements {
channelHint: HTMLElement | null,
channelSelect: HTMLElement | null,
messagesContainer: Element | null,
messagesList: HTMLElement | null,
timestampWidthProbe: HTMLElement | null,
inputForm: Element | null,
chatInput: HTMLElement | null,
}
// ref `DataStructure.Messages`
interface Messages {
messages: MessageResponse[]
}
// ref `DataStructure.MessageResponse`
interface MessageResponse {
timestamp: string;
templates: Template[];
}
// ref `DataStructure.MessageTemplate`
interface Template {
id: number;
payload: string;
content: string;
color: number;
}
// ref `DataStructure.SwitchChannel`
interface SwitchChannel {
channelName: Template[];
channelLocked: boolean;
}
// ref `DataStructure.ChannelList`
interface ChannelList {
channels: {[key: string]: number};
}
// ref `DataStructure.ChatTab`
interface ChatTab {
name: string;
index: number;
}
// ref `DataStructure.ChatTabList`
interface ChatTabList {
tabs: ChatTab[];
}
export class ChatTwoWeb {
elements!: ChatElements;
maxTimestampWidth: number = 0;
scrolledToBottom: boolean = true;
channelLocked: boolean = false;
sse!: EventSource;
connection!: Source;
constructor() {
this.setupDOMElements();
this.setupSSEConnection();
}
setupDOMElements() {
this.elements = {
channelHint: document.getElementById('channel-hint'),
channelSelect: document.getElementById('channel-select'),
messagesContainer: document.querySelector('#messages > .scroll-container')!,
messagesList: document.getElementById('messages-list'),
timestampWidthProbe: document.getElementById('timestamp-width-probe'),
inputForm: document.querySelector('#input > form'),
chatInput: document.getElementById('chat-input')
};
// channel selector
this.elements.channelSelect?.addEventListener('change', async (event) => {
if (event.currentTarget === null)
return;
const rawResponse = await fetch('/channel', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ channel: (event.currentTarget as HTMLSelectElement).value })
});
// const content = await rawResponse.json();
// TODO: use the response
});
// add indicator signaling more messages below
this.elements.messagesContainer?.addEventListener('scroll', (event) => {
if (event.currentTarget === null)
return;
let parentElement = (event.currentTarget as HTMLDivElement).parentElement;
if (!this.messagesAreScrolledToBottom()) {
parentElement?.classList.add('more-messages');
} else {
parentElement?.classList.remove('more-messages');
}
});
// adjust scroll when the window size changes; mostly for mobile (opening/closing the keyboard)
window.addEventListener('resize', () => {
if (this.scrolledToBottom) {
this.scrollMessagesToBottom();
}
})
// handle message sending
this.elements.inputForm?.addEventListener('submit', async (event) => {
if (this.elements.chatInput === null)
return;
event.preventDefault();
// @ts-ignore
const message = this.elements.chatInput.value;
if (message.length > 500) {
return;
}
const rawResponse = await fetch('/send', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ message: message })
});
// const content = await rawResponse.json();
// TODO: use the response
// @ts-ignore
this.elements.chatInput.value = '';
});
}
messagesAreScrolledToBottom() {
if (this.elements.messagesContainer === null) {
return this.scrolledToBottom;
}
if (this.elements.messagesContainer.scrollTopMax) {
this.scrolledToBottom = this.elements.messagesContainer.scrollTop === this.elements.messagesContainer.scrollTopMax;
} else {
this.scrolledToBottom =
(
this.elements.messagesContainer.scrollHeight -
this.elements.messagesContainer.clientHeight -
this.elements.messagesContainer.scrollTop
) < 1;
}
return this.scrolledToBottom;
}
updateChannelHint(channel: SwitchChannel) {
if (this.elements.channelHint === null || this.elements.channelSelect === null)
return;
this.elements.channelHint.innerHTML = '';
const channelElement = this.processTemplate(channel.channelName);
// Makes the channel selector unclickable if the channel is fixed
this.channelLocked = channel.channelLocked;
if (this.channelLocked) {
if (channelElement.firstChild === null)
return;
// @ts-ignore
channelElement.firstChild.innerText = `(Locked) ${channelElement.firstChild.innerText}`;
this.elements.channelSelect.style.pointerEvents = 'none';
} else {
this.elements.channelSelect.style.removeProperty('pointer-events');
}
this.elements.channelHint.appendChild(channelElement);
}
updateChannels(channelList: ChannelList) {
if (this.elements.channelSelect === null)
return;
this.elements.channelSelect.innerHTML = '';
for (const [ label, channel ] of Object.entries(channelList.channels)) {
const option = document.createElement('option');
option.value = channel.toString();
option.innerText = label;
this.elements.channelSelect.appendChild(option);
}
}
// 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. then there's AM/PM vs 24 hour, and so on
calculateTimestampWidth(timestamp: string) {
if (this.elements.timestampWidthProbe === null)
return;
this.elements.timestampWidthProbe.innerText = timestamp;
if (this.elements.timestampWidthProbe.clientWidth > this.maxTimestampWidth) {
this.maxTimestampWidth = this.elements.timestampWidthProbe.clientWidth;
document.body.style.setProperty('--timestamp-width', (Math.ceil(this.maxTimestampWidth) + 1) + 'px');
}
}
scrollMessagesToBottom() {
if (this.elements.messagesContainer === null || this.elements.messagesList === null)
return;
if (this.elements.messagesContainer.scrollTopMax) {
this.elements.messagesContainer.scrollTop = this.elements.messagesContainer.scrollTopMax;
} else {
if (this.elements.messagesList.lastElementChild === null)
return;
this.elements.messagesList.lastElementChild.scrollIntoView();
}
}
addMessage(messageData: MessageResponse) {
if (this.elements.messagesList === null)
return;
const scrolledToBottom = this.messagesAreScrolledToBottom();
this.calculateTimestampWidth(messageData.timestamp);
const liMessage = document.createElement('li');
const spanTimestamp = document.createElement('span');
spanTimestamp.classList.add('timestamp');
spanTimestamp.innerText = messageData.timestamp;
const spanMessage = document.createElement('span');
spanMessage.classList.add('message');
spanMessage.appendChild(this.processTemplate(messageData.templates))
liMessage.appendChild(spanTimestamp);
liMessage.appendChild(spanMessage);
this.elements.messagesList.appendChild(liMessage);
if (scrolledToBottom) {
this.scrollMessagesToBottom();
}
}
processTemplate(templates: Template[]) {
const frag = document.createDocumentFragment();
for( const template of templates ) {
const spanElement = document.createElement('span');
switch (template.payload) {
case 'text':
this.processTextTemplate(template, spanElement);
break;
case 'url':
this.processUrlTemplate(template, spanElement);
break;
case 'emote':
this.processEmote(template, spanElement);
break;
case 'icon':
this.processIcon(template, spanElement);
break;
case 'empty':
continue;
}
frag.appendChild(spanElement);
}
return frag;
}
processTextTemplate(template: Template, spanElement: HTMLSpanElement) {
spanElement.innerText = template.content;
if (template.color !== 0)
{
this.processColor(template, spanElement);
}
}
processUrlTemplate(template: Template, spanElement: HTMLSpanElement) {
const urlElement = document.createElement('a');
urlElement.innerText = template.content;
urlElement.href = encodeURI(template.content);
urlElement.target = '_blank'
if (template.color !== 0)
{
this.processColor(template, spanElement);
}
}
// converts a RGBA uint number to components
processColor(template: Template, spanElement: HTMLSpanElement) {
const r = (template.color & 0xFF000000) >>> 24;
const g = (template.color & 0xFF0000) >>> 16;
const b = (template.color & 0xFF00) >>> 8;
const a = (template.color & 0xFF) / 255.0;
spanElement.style.color = `rgba(${r}, ${g}, ${b}, ${a})`;
}
processEmote(template: Template, spanElement: HTMLSpanElement) {
const imgElement = document.createElement('img');
imgElement.src = `/emote/${template.content}`;
spanElement.classList.add('emote-icon');
spanElement.appendChild(imgElement);
}
processIcon(template: Template, spanElement: HTMLSpanElement) {
spanElement.classList.add('gfd-icon');
spanElement.classList.add(`gfd-icon-hq-${template.id}`);
}
clearAllMessages() {
if (this.elements.messagesList === null)
return;
this.elements.messagesList.innerHTML = '';
}
setupSSEConnection() {
this.connection = source('/sse')
this.connection.select('close').subscribe((data: string) => {
console.log(`Data received: ${data}`)
if (data) {
console.log('Closing SSE connection.');
this.connection.close();
}
});
// new messages to be appended to the message list
this.connection.select('new-message').subscribe((data: string) => {
console.log(`Data received: ${data}`)
if (data) {
try {
let message: MessageResponse = JSON.parse(data);
this.addMessage(message);
} catch (error) {
console.error(error);
}
}
});
// a bulk of new messages, with a clear of the message list beforehand
this.connection.select('bulk-messages').subscribe((data: string) => {
console.log(`Data received: ${data}`)
if (data) {
this.clearAllMessages();
try {
let messages: Messages = JSON.parse(data);
for (const message of messages.messages) {
this.addMessage(message);
}
} catch (error) {
console.error(error);
}
}
});
this.connection.select('channel-switched').subscribe((data: string) => {
console.log(`Data received: ${data}`)
if (data) {
try {
let channel: SwitchChannel = JSON.parse(data);
this.updateChannelHint(channel);
} catch (error) {
console.error(error);
}
}
});
// list of all channels
this.connection.select('channel-list').subscribe((data: string) => {
console.log(`Data received: ${data}`)
if (data) {
try {
let channelList: ChannelList = JSON.parse(data);
this.updateChannels(channelList);
} catch (error) {
console.error(error);
}
}
});
// tab switched
this.connection.select('tab-switched').subscribe((data: string) => {
console.log(`Data received: ${data}`)
if (data) {
try {
let chatTab: ChatTab = JSON.parse(data);
} catch (error) {
console.error(error);
}
}
});
// list of all tabs
this.connection.select('tab-list').subscribe((data: string) => {
console.log(`Data received: ${data}`)
if (data) {
try {
let chatTabLit: ChatTabList = JSON.parse(data);
} catch (error) {
console.error(error);
}
}
});
}
}
+134
View File
@@ -0,0 +1,134 @@
// from kizer, gfd icons
interface GdfEntry {
id: number,
left: number,
top: number,
width: number,
height: number,
unk0A: number,
redirect: number,
unk0E: number,
}
interface StylesheetEntry {
ids: number[],
style1: string,
style2: string,
width: number,
}
export async function addGfdStylesheet(gfdPath: string, texPath: string) {
const texPromise = loadTexAsBlob(texPath);
const gfdPromise = loadGfd(gfdPath);
const texUrl = URL.createObjectURL(await texPromise);
const gfd = await gfdPromise;
const stylesheets: {[id: number]: StylesheetEntry} = [];
for (const entry of gfd) {
if (entry.width * entry.height <= 0)
continue;
if (entry.redirect !== 0) {
stylesheets[entry.redirect].ids.push(entry.id);
continue;
}
stylesheets[entry.id] = {
ids: [entry.id],
style1: [
`background-position: -${entry.left}px -${entry.top}px`,
`background-image: url('${texUrl}')`,
`width: ${entry.width}px`,
`height: ${entry.height}px`
].join(';'),
style2: [
`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`
].join(';'),
width: entry.width
};
}
let stylesheet = '';
for (const entry of Object.values(stylesheets)) {
if (!entry)
continue;
stylesheet += `\n${entry.ids.map(x => `.gfd-icon.gfd-icon-${x}::before`).join(', ')}{${entry.style1};}`;
stylesheet += `\n${entry.ids.map(x => `.gfd-icon.gfd-icon-hq-${x}::before`).join(', ')}{${entry.style2};}`;
stylesheet += `\n${entry.ids.map(x => `.gfd-icon.gfd-icon-${x}`).join(', ')}{width:${entry.width}px;}`;
stylesheet += `\n${entry.ids.map(x => `.gfd-icon.gfd-icon-hq-${x}`).join(', ')}{width:${entry.width * 2}px;}`;
}
const styleNode = document.createElement('style');
styleNode.appendChild(document.createTextNode(stylesheet));
document.head.appendChild(styleNode);
}
async function loadTexAsBlob(path: string) {
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: string) {
const buffer = new DataView(await (await fetch(path)).arrayBuffer());
const count = buffer.getInt32(8, true);
const entries: GdfEntry[] = 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: 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);
const arraySize = buffer.getInt8(15);
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,
};
}
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
@@ -0,0 +1,11 @@
<script lang="ts">
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/static/bootstrap.min.css">
<link rel="stylesheet" href="/static/start.css">
</svelte:head>
{@render children?.()}
@@ -0,0 +1 @@
export const prerender = true;
@@ -0,0 +1,33 @@
<script lang="ts">
import { page } from '$app/state'
import { Alert } from '@sveltestrap/sveltestrap';
let data: App.Warning | null = null;
$effect.pre(() => {
if (page.url.searchParams.has('message')) {
data = {
hasWarning: true,
content: page.url.searchParams.get('message') ?? '',
};
} else {
data = {
hasWarning: false,
content: '',
};
}
});
</script>
<main class="auth">
<h1>Authcode</h1>
{#if data?.hasWarning }
<Alert content={data.content} color="warning" dismissible={true}/>
{/if}
<form action="/auth" method="POST">
<label><input type="password" name="authcode"></label>
<button type="submit" class="submitButton">Submit</button>
</form>
<div data-sveltekit-preload-data="false">
<img src="/emote/Sure" alt=":Sure:" data-sveltekit-preload-data="off">
</div>
</main>
@@ -0,0 +1,68 @@
<script lang="ts">
import { page } from '$app/state'
import {Alert} from "@sveltestrap/sveltestrap";
import { onMount } from 'svelte';
import { ChatTwoWeb } from '$lib/chat'
import {addGfdStylesheet} from "$lib/gfd";
let data: App.Warning | null = null;
$effect.pre(() => {
if (page.url.searchParams.has('message')) {
data = {
hasWarning: true,
content: page.url.searchParams.get('message') ?? '',
};
} else {
data = {
hasWarning: false,
content: '',
};
}
});
onMount(() => {
console.log('the component has mounted');
// Populate the stylesheet with gfd data
addGfdStylesheet('/files/gfdata.gfd', '/files/fonticon_ps5.tex');
// Load all web functions in the background
const _ = new ChatTwoWeb();
});
</script>
<main class="chat">
<section id="messages">
<div class="scroll-container">
<ol id="messages-list"></ol>
</div>
<div id="more-messages-indicator">
<!-- "arrow-down" icon from https://github.com/feathericons/feather, under MIT license -->
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
</div>
</section>
{#if data?.hasWarning }
<section id="warnings">
<Alert content={data.content} color="warning" dismissible={true}/>
</section>
{/if}
<section id="input">
<form>
<div class="select-container">
<select id="channel-select"></select>
</div>
<div class="input-container">
<input type="text" id="chat-input" autocomplete="off" placeholder="Message" enterkeyhint="send" maxlength="500">
<div id="channel-hint"></div>
</div>
<button type="submit">Send</button>
</form>
</section>
</main>
<div id="timestamp-width-probe"></div>
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:
@@ -0,0 +1,377 @@
/* 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', 'calt' 0; /* calt appears to be on by default */
}
html {
font-size: 16px;
}
span > a {
color: inherit;
}
/* layout and global styles */
body {
padding: 50px;
height: 100dvh;
background-color: var(--bg-input-hover);
}
body > div > main.chat {
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);
}
body > div > main.auth {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
width: 100%;
height: 100%;
h1 {
font-size: 1.5rem;
font-weight: 400;
}
input { width: 150px; }
input, .submitButton {
padding: 5px 20px;
font-size: 1rem;
border: 3px solid transparent;
border-radius: 20px;
background-color: var(--bg-input);
&:focus {
outline: 2px solid var(--focus-color);
}
}
.submitButton {
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);
}
}
}
/* 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);
}
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;
}
.message {
white-space: pre-wrap;
}
}
#more-messages-indicator {
position: absolute;
display: none;
right: 30px;
bottom: 0;
pointer-events: none;
svg {
filter: drop-shadow(0 0 5px #60a0ff) drop-shadow(0 0 15px #60a0ff);
}
}
&.more-messages #more-messages-indicator {
display: block;
}
}
#timestamp-width-probe {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
opacity: 0;
}
/* alerts */
section#warnings {
flex-grow: 0;
padding: 20px 20px 0 20px;
}
/* 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;
max-width: 100%;
overflow: hidden;
font-size: 1.1rem;
font-weight: 550;
white-space: nowrap;
text-overflow: ellipsis;
pointer-events: none;
}
}
}
/* icons, emotes */
.gfd-icon {
display: inline-block;
position: relative;
vertical-align: middle;
zoom: 0.75;
&::before {
content: '';
display: block;
position: absolute;
top: 0;
left: 0;
transform: translateY(-50%);
pointer-events: none;
}
}
.emote-icon {
display: inline-block;
position: relative;
width: 2rem;
height: 1px;
vertical-align: middle;
overflow: visible;
img {
display: block;
position: absolute;
top: 0;
left: 0;
width: 2rem;
height: 2rem;
transform: translateY(-50%);
pointer-events: none;
}
}
/*** mobile ***/
@media ((max-width: 600px) and (orientation: portrait)) or (max-width: 400px) {
body {
padding: 0;
}
body > main.chat {
border-radius: 0;
box-shadow: none;
}
section#messages {
font-size: 0.9rem;
li {
align-items: baseline;
.timestamp {
font-size: 0.8rem;
}
}
}
#timestamp-width-probe {
font-size: 0.8rem;
}
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%);
}
.input-container #channel-hint {
font-size: 0.9rem;
}
}
.gfd-icon { zoom: 0.65; }
.emote-icon {
width: 1.5rem;
img {
width: 1.5rem;
height: 1.5rem;
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
prerender: {
handleHttpError: 'warn'
},
adapter: adapter({
// default options are shown. On some platforms
// these options are set automatically — see below
pages: 'build',
assets: 'build',
fallback: undefined,
precompress: false,
strict: true,
})
},
};
export default config;
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}
+6
View File
@@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});
+3 -3
View File
@@ -17,7 +17,7 @@ public class HostContext
internal readonly List<SSEConnection> EventConnections = []; internal readonly List<SSEConnection> EventConnections = [];
internal readonly CancellationTokenSource TokenSource = new(); internal readonly CancellationTokenSource TokenSource = new();
internal readonly string StaticDir = Path.Combine(Plugin.Interface.AssemblyLocation.DirectoryName!, "Http"); internal readonly string StaticDir = Path.Combine(Plugin.Interface.AssemblyLocation.DirectoryName!, "Http/Frontend/build");
public HostContext(Plugin plugin) public HostContext(Plugin plugin)
{ {
@@ -120,14 +120,14 @@ public class HostContext
private async Task CheckAuthenticationCookie(HttpContextBase ctx) private async Task CheckAuthenticationCookie(HttpContextBase ctx)
{ {
if (Plugin.Config.SessionTokens.IsEmpty) if (Plugin.Config.AuthStore.Count == 0)
{ {
await RouteController.Redirect(ctx, "/", ("message", "Invalid session token.")); await RouteController.Redirect(ctx, "/", ("message", "Invalid session token."));
return; return;
} }
var cookies = WebserverUtil.GetCookieData(ctx.Request.Headers.Get("Cookie") ?? ""); var cookies = WebserverUtil.GetCookieData(ctx.Request.Headers.Get("Cookie") ?? "");
if (!cookies.TryGetValue("ChatTwo-token", out var token) || !Plugin.Config.SessionTokens.ContainsKey(token)) if (!cookies.TryGetValue("ChatTwo-token", out var token) || !Plugin.Config.AuthStore.Contains(token))
await RouteController.Redirect(ctx, "/", ("message", "Invalid session token.")); await RouteController.Redirect(ctx, "/", ("message", "Invalid session token."));
// Do nothing to let auth pass // Do nothing to let auth pass
+35 -7
View File
@@ -1,8 +1,26 @@
using Newtonsoft.Json; using ChatTwo.Code;
using Newtonsoft.Json;
namespace ChatTwo.Http.MessageProtocol; namespace ChatTwo.Http.MessageProtocol;
#region Outgoing SSE #region Outgoing SSE
/// <summary>
/// Contains a valid tab with its assigned index
/// </summary>
public struct ChatTab(string name, int index)
{
[JsonProperty("name")] public string Name = name;
[JsonProperty("index")] public int Index = index;
}
/// <summary>
/// Contains a number of tabs that are valid for the user to pick from
/// </summary>
public struct ChatTabList(ChatTab[] tabs)
{
[JsonProperty("tabs")] public ChatTab[] Tabs = tabs;
}
/// <summary> /// <summary>
/// Contains the current channel name /// Contains the current channel name
/// </summary> /// </summary>
@@ -13,7 +31,7 @@ public struct SwitchChannel((MessageTemplate[] ChannelName, bool Locked) channel
} }
/// <summary> /// <summary>
/// Contains one or multiple channels that are valid for the user to pick from /// Contains a number of channels that are valid for the user to pick from
/// </summary> /// </summary>
public struct ChannelList(Dictionary<string, uint> channels) public struct ChannelList(Dictionary<string, uint> channels)
{ {
@@ -50,7 +68,8 @@ public struct MessageTemplate()
/// url = Simple url that should be clickable /// url = Simple url that should be clickable
/// text = Simple text content of the message /// text = Simple text content of the message
/// ///
/// empty = Ignore /// Note:
/// Empty is used for invalid payloads
/// </summary> /// </summary>
[JsonProperty("payload")] public required string Payload; [JsonProperty("payload")] public required string Payload;
@@ -60,14 +79,15 @@ public struct MessageTemplate()
[JsonProperty("content")] public string Content = ""; [JsonProperty("content")] public string Content = "";
/// <summary> /// <summary>
/// Used for icon. /// Used for an icon.
/// </summary> /// </summary>
[JsonProperty("id")] public uint Id; [JsonProperty("id")] public uint Id;
/// <summary> /// <summary>
/// Used for text and url /// Used for text and url
/// ///
/// Ignore if 0! /// Note:
/// 0 is used for invalid colors
/// </summary> /// </summary>
[JsonProperty("color")] public uint Color; [JsonProperty("color")] public uint Color;
@@ -99,10 +119,18 @@ public struct IncomingMessage()
} }
/// <summary> /// <summary>
/// Channel must be a valid uint number /// The channel type must be a valid <see cref="InputChannel"/>
/// </summary> /// </summary>
public struct IncomingChannel() public struct IncomingChannel()
{ {
[JsonProperty("channel")] public uint Channel = uint.MaxValue; [JsonProperty("channel")] public InputChannel Channel = InputChannel.Invalid;
}
/// <summary>
/// The tabs index must be a valid int
/// </summary>
public struct IncomingTab()
{
[JsonProperty("index")] public int Index = -1;
} }
#endregion #endregion
@@ -3,15 +3,20 @@ using Newtonsoft.Json;
namespace ChatTwo.Http.MessageProtocol; namespace ChatTwo.Http.MessageProtocol;
// General
public class CloseEvent() : BaseEvent("close"); public class CloseEvent() : BaseEvent("close");
// Tab related
public class ChatTabListEvent(ChatTabList list) : BaseEvent("tab-list", JsonConvert.SerializeObject(list));
public class ChatTabSwitchedEvent(ChatTab chatTab) : BaseEvent("tab-switched", JsonConvert.SerializeObject(chatTab));
// Input channel related
public class ChannelListEvent(ChannelList channelList) : BaseEvent("channel-list", JsonConvert.SerializeObject(channelList)); public class ChannelListEvent(ChannelList channelList) : BaseEvent("channel-list", JsonConvert.SerializeObject(channelList));
public class SwitchChannelEvent(SwitchChannel switchChannel) : BaseEvent("channel-switched", JsonConvert.SerializeObject(switchChannel));
public class SwitchChannelEvent(SwitchChannel switchChannel) : BaseEvent("switch-channel", JsonConvert.SerializeObject(switchChannel)); // Chat message related
public class BulkMessagesEvent(Messages messages) : BaseEvent("bulk-messages", JsonConvert.SerializeObject(messages)); public class BulkMessagesEvent(Messages messages) : BaseEvent("bulk-messages", JsonConvert.SerializeObject(messages));
public class NewMessageEvent(MessageResponse message) : BaseEvent("new-message", JsonConvert.SerializeObject(message));
public class NewMessageEvent(Messages messages) : BaseEvent("new-message", JsonConvert.SerializeObject(messages));
public class BaseEvent(string eventType, string? data = null) public class BaseEvent(string eventType, string? data = null)
{ {
+41 -6
View File
@@ -43,14 +43,20 @@ public class Processing
internal async Task PrepareNewClient(SSEConnection sse) internal async Task PrepareNewClient(SSEConnection sse)
{ {
var messages = await WebserverUtil.FrameworkWrapper(ReadMessageList); // This takes long, so keep it outside the next frame
var channels = await Plugin.Framework.RunOnTick(Plugin.ChatLogWindow.GetAvailableChannels); var messages = await GetAllMessages();
var channel = await Plugin.Framework.RunOnTick(() => ReadChannelName(Plugin.ChatLogWindow.PreviousChannel));
// Using the bulk message event to clear everything on the client side that may still exist // Using the bulk message event to clear everything on the client side that may still exist
sse.OutboundQueue.Enqueue(new BulkMessagesEvent(new Messages(messages))); await Plugin.Framework.RunOnTick(() =>
sse.OutboundQueue.Enqueue(new SwitchChannelEvent(new SwitchChannel(channel))); {
sse.OutboundQueue.Enqueue(new ChannelListEvent(new ChannelList(channels.ToDictionary(pair => pair.Key, pair => (uint)pair.Value)))); sse.OutboundQueue.Enqueue(new BulkMessagesEvent(messages));
sse.OutboundQueue.Enqueue(new SwitchChannelEvent(GetCurrentChannel()));
sse.OutboundQueue.Enqueue(new ChannelListEvent(GetValidChannels()));
sse.OutboundQueue.Enqueue(new ChatTabSwitchedEvent(GetCurrentTab()));
sse.OutboundQueue.Enqueue(new ChatTabListEvent(GetAllTabs()));
});
} }
private MessageTemplate ProcessChunk(Chunk chunk) private MessageTemplate ProcessChunk(Chunk chunk)
@@ -95,4 +101,33 @@ public class Processing
return MessageTemplate.Empty; return MessageTemplate.Empty;
} }
private async Task<Messages> GetAllMessages()
{
var messages = await WebserverUtil.FrameworkWrapper(ReadMessageList);
return new Messages(messages);
}
private SwitchChannel GetCurrentChannel()
{
var channel = ReadChannelName(Plugin.ChatLogWindow.PreviousChannel);
return new SwitchChannel(channel);
}
private ChannelList GetValidChannels()
{
var channels = Plugin.ChatLogWindow.GetValidChannels();
return new ChannelList(channels.ToDictionary(pair => pair.Key, pair => (uint)pair.Value));
}
private ChatTab GetCurrentTab()
{
return new ChatTab(Plugin.CurrentTab.Name, Plugin.LastTab);
}
private ChatTabList GetAllTabs()
{
var tabs = Plugin.Config.Tabs.Select((tab, idx) => new ChatTab(tab.Name, idx)).ToArray();
return new ChatTabList(tabs);
}
} }
+66 -23
View File
@@ -1,4 +1,5 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Net;
using System.Web; using System.Web;
using ChatTwo.Code; using ChatTwo.Code;
using ChatTwo.Http.MessageProtocol; using ChatTwo.Http.MessageProtocol;
@@ -31,8 +32,8 @@ public class RouteController
Plugin = plugin; Plugin = plugin;
Core = core; Core = core;
AuthTemplate = File.ReadAllText(Path.Combine(Core.StaticDir, "templates", "auth.html")); AuthTemplate = File.ReadAllText(Path.Combine(Core.StaticDir, "index.html"));
ChatBoxTemplate = File.ReadAllText(Path.Combine(Core.StaticDir, "templates", "chat.html")); ChatBoxTemplate = File.ReadAllText(Path.Combine(Core.StaticDir, "chat.html"));
// Pre Auth // Pre Auth
Core.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/", AuthRoute, ExceptionRoute); Core.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/", AuthRoute, ExceptionRoute);
@@ -42,15 +43,19 @@ public class RouteController
Core.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/FFXIV_Lodestone_SSF.ttf", GetLodestoneFont, ExceptionRoute); Core.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/FFXIV_Lodestone_SSF.ttf", GetLodestoneFont, ExceptionRoute);
Core.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/favicon.ico", GetFavicon, ExceptionRoute); Core.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/favicon.ico", GetFavicon, ExceptionRoute);
Core.Host.Routes.PreAuthentication.Parameter.Add(HttpMethod.GET, "/emote/{name}", GetEmote, ExceptionRoute); Core.Host.Routes.PreAuthentication.Parameter.Add(HttpMethod.GET, "/emote/{name}", GetEmote, ExceptionRoute);
Core.Host.Routes.PreAuthentication.Content.Add("/static", true, ExceptionRoute);
// Post Auth // Post Auth
Core.Host.Routes.PostAuthentication.Static.Add(HttpMethod.GET, "/chat", ChatBoxRoute, ExceptionRoute); Core.Host.Routes.PostAuthentication.Static.Add(HttpMethod.GET, "/chat", ChatBoxRoute, ExceptionRoute);
Core.Host.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/send", ReceiveMessage, ExceptionRoute); Core.Host.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/send", ReceiveMessage, ExceptionRoute);
Core.Host.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/channel", ReceiveChannelSwitch, ExceptionRoute); Core.Host.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/channel", ReceiveChannelSwitch, ExceptionRoute);
Core.Host.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/tab", ReceiveTabSwitch, ExceptionRoute);
// Ship all other static files dynamically
Core.Host.Routes.PreAuthentication.Content.Add("/_app/", true, ExceptionRoute);
Core.Host.Routes.PreAuthentication.Content.Add("/static/", true, ExceptionRoute);
// Server-Sent Events Route // Server-Sent Events Route
Core.Host.Routes.PostAuthentication.Static.Add(HttpMethod.GET, "/sse", NewSSEConnection, ExceptionRoute); Core.Host.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/sse", NewSSEConnection, ExceptionRoute);
} }
private async Task ExceptionRoute(HttpContextBase ctx, Exception _) private async Task ExceptionRoute(HttpContextBase ctx, Exception _)
@@ -61,6 +66,16 @@ public class RouteController
private async Task AuthRoute(HttpContextBase ctx) private async Task AuthRoute(HttpContextBase ctx)
{ {
if (Plugin.Config.AuthStore.Count > 0)
{
var cookies = WebserverUtil.GetCookieData(ctx.Request.Headers.Get("Cookie") ?? "");
if (cookies.TryGetValue("ChatTwo-token", out var value) && Plugin.Config.AuthStore.Contains(value))
{
await Redirect(ctx, "/chat");
return;
}
}
await ctx.Response.Send(AuthTemplate); await ctx.Response.Send(AuthTemplate);
} }
@@ -133,7 +148,7 @@ public class RouteController
if (RateLimit.TryGetValue(ctx.Request.Source.IpAddress, out var timestamp) && timestamp > currentTick) if (RateLimit.TryGetValue(ctx.Request.Source.IpAddress, out var timestamp) && timestamp > currentTick)
{ {
_ = ctx.Request.DataAsString; // Temp fix for Watson.Lite bug #155 _ = ctx.Request.DataAsString; // Temp fix for Watson.Lite bug #155
return await Redirect(ctx, "/", ("message", "Rate limit active.")); return await Redirect(ctx, "/", ("message", "Rate limit active (10s)"));
} }
// The next request will be rate limited for 10s // The next request will be rate limited for 10s
@@ -141,10 +156,10 @@ public class RouteController
var authcode = HttpUtility.ParseQueryString(ctx.Request.DataAsString ?? "").Get("authcode"); var authcode = HttpUtility.ParseQueryString(ctx.Request.DataAsString ?? "").Get("authcode");
if (authcode == null || authcode != Plugin.Config.WebinterfacePassword) if (authcode == null || authcode != Plugin.Config.WebinterfacePassword)
return await Redirect(ctx, "/", ("message", "Authentication failed.")); return await Redirect(ctx, "/", ("message", "Authentication failed"));
var token = WebinterfaceUtil.GenerateSimpleToken(); var token = WebinterfaceUtil.GenerateSimpleToken();
Plugin.Config.SessionTokens.TryAdd(token, true); Plugin.Config.AuthStore.Add(token);
ctx.Response.Headers.Add("Set-Cookie", $"ChatTwo-token={token}"); ctx.Response.Headers.Add("Set-Cookie", $"ChatTwo-token={token}");
return await Redirect(ctx, "/chat"); return await Redirect(ctx, "/chat");
@@ -159,12 +174,8 @@ public class RouteController
private async Task ReceiveMessage(HttpContextBase ctx) private async Task ReceiveMessage(HttpContextBase ctx)
{ {
if (ctx.Request.ContentType != "application/json") if (!await EnforceMediaType(ctx, "application/json"))
{
ctx.Response.StatusCode = 415;
await ctx.Response.Send(JsonConvert.SerializeObject(new ErrorResponse("Request contains wrong media type.")));
return; return;
}
var content = JsonConvert.DeserializeObject<IncomingMessage>(ctx.Request.DataAsString, JsonSettings); var content = JsonConvert.DeserializeObject<IncomingMessage>(ctx.Request.DataAsString, JsonSettings);
if (content.Message.Length is < 2 or > 500) if (content.Message.Length is < 2 or > 500)
@@ -186,28 +197,40 @@ public class RouteController
private async Task ReceiveChannelSwitch(HttpContextBase ctx) private async Task ReceiveChannelSwitch(HttpContextBase ctx)
{ {
if (ctx.Request.ContentType != "application/json") if (!await EnforceMediaType(ctx, "application/json"))
{
ctx.Response.StatusCode = 415;
await ctx.Response.Send(JsonConvert.SerializeObject(new ErrorResponse("Request contains wrong media type.")));
return; return;
}
var channel = JsonConvert.DeserializeObject<IncomingChannel>(ctx.Request.DataAsString, JsonSettings); var channel = JsonConvert.DeserializeObject<IncomingChannel>(ctx.Request.DataAsString, JsonSettings);
if (!Enum.IsDefined(typeof(InputChannel), channel.Channel)) if (!Enum.IsDefined((InputChannel)channel.Channel))
{ {
ctx.Response.StatusCode = 400; ctx.Response.StatusCode = 400;
await ctx.Response.Send(JsonConvert.SerializeObject(new ErrorResponse("Invalid channel received."))); await ctx.Response.Send(JsonConvert.SerializeObject(new ErrorResponse("Invalid channel received.")));
return; return;
} }
await Plugin.Framework.RunOnFrameworkThread(() => await Plugin.Framework.RunOnFrameworkThread(() => { Plugin.ChatLogWindow.SetChannel((InputChannel)channel.Channel); });
{
Plugin.ChatLogWindow.SetChannel((InputChannel)channel.Channel);
});
ctx.Response.StatusCode = 201; ctx.Response.StatusCode = 201;
await ctx.Response.Send(JsonConvert.SerializeObject(new OkResponse("Channel switch got initiated."))); await ctx.Response.Send(JsonConvert.SerializeObject(new OkResponse("Channel switch was initiated.")));
}
private async Task ReceiveTabSwitch(HttpContextBase ctx)
{
if (!await EnforceMediaType(ctx, "application/json"))
return;
var tab = JsonConvert.DeserializeObject<IncomingTab>(ctx.Request.DataAsString, JsonSettings);
if (tab.Index < 0 || tab.Index >= Plugin.Config.Tabs.Count)
{
ctx.Response.StatusCode = 400;
await ctx.Response.Send(JsonConvert.SerializeObject(new ErrorResponse("Invalid tab received.")));
return;
}
await Plugin.Framework.RunOnFrameworkThread(() => { Plugin.WantedTab = tab.Index; });
ctx.Response.StatusCode = 201;
await ctx.Response.Send(JsonConvert.SerializeObject(new OkResponse("Tab switch was initiated.")));
} }
private async Task NewSSEConnection(HttpContextBase ctx) private async Task NewSSEConnection(HttpContextBase ctx)
@@ -245,4 +268,24 @@ public class RouteController
return await ctx.Response.Send(); return await ctx.Response.Send();
} }
#endregion #endregion
#region PreChecks
/// <summary>
/// Check that the request has the correct media type that the functions expects.
/// </summary>
/// <param name="ctx"></param>
/// <param name="requiredMediaType"></param>
/// <returns>True if media type is correct, otherwise handled and false</returns>
private async Task<bool> EnforceMediaType(HttpContextBase ctx, string requiredMediaType)
{
if (ctx.Request.ContentType == requiredMediaType)
return true;
ctx.Response.StatusCode = 415;
await ctx.Response.Send(JsonConvert.SerializeObject(new ErrorResponse("Request contains wrong media type.")));
return false;
}
#endregion
} }
+2 -2
View File
@@ -34,7 +34,7 @@ public class SSEConnection
if (!OutboundQueue.TryDequeue(out var outgoingEvent)) if (!OutboundQueue.TryDequeue(out var outgoingEvent))
continue; continue;
if (!await ctx.Response.SendChunk(outgoingEvent.Build(), Token)) if (!await ctx.Response.SendChunk(outgoingEvent.Build(), false, Token))
{ {
Plugin.Log.Information("SSE connection was unable to send new data"); Plugin.Log.Information("SSE connection was unable to send new data");
Plugin.Log.Information($"Client disconnected: {ctx.Guid}"); Plugin.Log.Information($"Client disconnected: {ctx.Guid}");
@@ -53,7 +53,7 @@ public class SSEConnection
finally finally
{ {
// "No Content" (204) didn't work for Firefox, so manually closing the connection on client side // "No Content" (204) didn't work for Firefox, so manually closing the connection on client side
await ctx.Response.SendFinalChunk(new CloseEvent().Build()); await ctx.Response.SendChunk(new CloseEvent().Build(), true, Token);
// Manually confirm that we have finished our connection, even if the final response failed // Manually confirm that we have finished our connection, even if the final response failed
// This can happen if the client disconnects before the server does // This can happen if the client disconnects before the server does
+3 -3
View File
@@ -23,7 +23,7 @@ public class ServerCore : IAsyncDisposable
{ {
Plugin.Framework.RunOnTick(() => Plugin.Framework.RunOnTick(() =>
{ {
var bundledResponse = new NewMessageEvent(new Messages([HostContext.Processing.ReadMessageContent(message)])); var bundledResponse = new NewMessageEvent(HostContext.Processing.ReadMessageContent(message));
foreach (var eventServer in HostContext.EventConnections) foreach (var eventServer in HostContext.EventConnections)
eventServer.OutboundQueue.Enqueue(bundledResponse); eventServer.OutboundQueue.Enqueue(bundledResponse);
}); });
@@ -82,7 +82,7 @@ public class ServerCore : IAsyncDisposable
{ {
Plugin.Framework.RunOnTick(() => Plugin.Framework.RunOnTick(() =>
{ {
var channels = Plugin.ChatLogWindow.GetAvailableChannels(); var channels = Plugin.ChatLogWindow.GetValidChannels();
var bundledResponse = new ChannelListEvent(new ChannelList(channels.ToDictionary(pair => pair.Key, pair => (uint)pair.Value))); var bundledResponse = new ChannelListEvent(new ChannelList(channels.ToDictionary(pair => pair.Key, pair => (uint)pair.Value)));
foreach (var eventServer in HostContext.EventConnections) foreach (var eventServer in HostContext.EventConnections)
eventServer.OutboundQueue.Enqueue(bundledResponse); eventServer.OutboundQueue.Enqueue(bundledResponse);
@@ -119,7 +119,7 @@ public class ServerCore : IAsyncDisposable
if (!HostContext.IsActive) if (!HostContext.IsActive)
return; return;
Plugin.Config.SessionTokens.Clear(); Plugin.Config.AuthStore.Clear();
Plugin.SaveConfig(); Plugin.SaveConfig();
} }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -1,6 +1,7 @@
/* fonts */ /* fonts */
@font-face { @font-face {
font-family: Lodestone; font-family: Lodestone;
/*noinspection CssUnknownTarget*/
src: url('/files/FFXIV_Lodestone_SSF.ttf') format('truetype'); src: url('/files/FFXIV_Lodestone_SSF.ttf') format('truetype');
unicode-range: U+E020-E0DB; unicode-range: U+E020-E0DB;
} }
@@ -82,7 +83,7 @@ body > main.auth {
input { width: 150px; } input { width: 150px; }
input, button { input, .submitButton {
padding: 5px 20px; padding: 5px 20px;
font-size: 1rem; font-size: 1rem;
border: 3px solid transparent; border: 3px solid transparent;
@@ -94,7 +95,7 @@ body > main.auth {
} }
} }
button { .submitButton {
padding: 5px 15px; padding: 5px 15px;
border: 3px solid var(--bg-input); border: 3px solid var(--bg-input);
background-image: var(--gradient-clickable); background-image: var(--gradient-clickable);
+33 -2
View File
@@ -1,20 +1,51 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" data-bs-theme="dark">
<head> <head>
<title>Authentication</title> <title>Authentication</title>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link href="static/bootstrap.min.css" rel="stylesheet">
<script src="static//bootstrap.bundle.min.js"></script>
<link rel="stylesheet" href="static/start.css"> <link rel="stylesheet" href="static/start.css">
<script>
function appendAlert(message, type) {
const alertPlaceholder = document.getElementById('alert-placeholder')
const wrapper = document.createElement('div')
wrapper.innerHTML = [
`<div class="alert alert-${type} alert-dismissible" role="alert">`,
` <div>${message}</div>`,
' <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>',
'</div>'
].join('')
alertPlaceholder.append(wrapper)
}
document.addEventListener("DOMContentLoaded", () => {
const params = new Proxy(new URLSearchParams(window.location.search), {
get: (searchParams, prop) => searchParams.get(prop),
});
let value = params.message;
if (value) {
appendAlert(value, "warning")
}
});
</script>
</head> </head>
<body> <body>
<main class="auth"> <main class="auth">
<h1>Authcode</h1> <h1>Authcode</h1>
<div id="alert-placeholder"></div>
<form action="/auth" method="POST"> <form action="/auth" method="POST">
<label><input type="password" name="authcode"></label> <label><input type="password" name="authcode"></label>
<button type="submit">Submit</button> <button type="submit" class="submitButton">Submit</button>
</form> </form>
<img src="/emote/Sure"> <img src="/emote/Sure">
</main> </main>
+1 -1
View File
@@ -64,7 +64,7 @@ public sealed class Plugin : IDalamudPlugin
internal DateTime GameStarted { get; } internal DateTime GameStarted { get; }
// Tab managed needs to happen outside the chatlog window class for access reasons // Tab management needs to happen outside the chatlog window class for access reasons
internal int LastTab { get; set; } internal int LastTab { get; set; }
internal int? WantedTab { get; set; } internal int? WantedTab { get; set; }
internal Tab CurrentTab internal Tab CurrentTab
+14 -14
View File
@@ -365,9 +365,8 @@ public sealed class ChatLogWindow : Window
ChangeTab(newIndex); ChangeTab(newIndex);
} }
private void TabChannelSwitch(Tab newTab, Tab previousTab) private void TabSwitched(Tab newTab, Tab previousTab)
{ {
Plugin.Log.Information("Channel switch");
// Use the fixed channel if set by the user, or set it to the current tabs channel if this tab wasn't accessed before // Use the fixed channel if set by the user, or set it to the current tabs channel if this tab wasn't accessed before
if (newTab.Channel is not null) if (newTab.Channel is not null)
newTab.CurrentChannel.Channel = newTab.Channel.Value; newTab.CurrentChannel.Channel = newTab.Channel.Value;
@@ -566,7 +565,7 @@ public sealed class ChatLogWindow : Window
{ {
if (popup) if (popup)
{ {
var channels = GetAvailableChannels(); var channels = GetValidChannels();
foreach (var (name, channel) in channels) foreach (var (name, channel) in channels)
if (ImGui.Selectable(name)) if (ImGui.Selectable(name))
SetChannel(channel); SetChannel(channel);
@@ -713,7 +712,7 @@ public sealed class ChatLogWindow : Window
GameFunctions.GameFunctions.ClickNoviceNetworkButton(); GameFunctions.GameFunctions.ClickNoviceNetworkButton();
} }
internal Dictionary<string, InputChannel> GetAvailableChannels() internal Dictionary<string, InputChannel> GetValidChannels()
{ {
var channels = new Dictionary<string, InputChannel>(); var channels = new Dictionary<string, InputChannel>();
foreach (var channel in Enum.GetValues<InputChannel>()) foreach (var channel in Enum.GetValues<InputChannel>())
@@ -1233,20 +1232,21 @@ public sealed class ChatLogWindow : Window
var flags = ImGuiTabItemFlags.None; var flags = ImGuiTabItemFlags.None;
if (Plugin.WantedTab == tabI) if (Plugin.WantedTab == tabI)
flags |= ImGuiTabItemFlags.SetSelected; flags |= ImGuiTabItemFlags.SetSelected;
using var tabItem = ImRaii.TabItem($"{tab.Name}{unread}###log-tab-{tabI}", flags); using var tabItem = ImRaii.TabItem($"{tab.Name}{unread}###log-tab-{tabI}", flags);
DrawTabContextMenu(tab, tabI); DrawTabContextMenu(tab, tabI);
if (!tabItem.Success) if (!tabItem.Success)
continue; continue;
var switchedTab = Plugin.LastTab != tabI; var hasTabSwitched = Plugin.LastTab != tabI;
Plugin.LastTab = tabI; Plugin.LastTab = tabI;
if (switchedTab)
TabChannelSwitch(tab, previousTab); if (hasTabSwitched)
TabSwitched(tab, previousTab);
tab.Unread = 0; tab.Unread = 0;
DrawMessageLog(tab, PayloadHandler, GetRemainingHeightForMessageLog(), switchedTab); DrawMessageLog(tab, PayloadHandler, GetRemainingHeightForMessageLog(), hasTabSwitched);
} }
Plugin.WantedTab = null; Plugin.WantedTab = null;
@@ -1264,7 +1264,7 @@ public sealed class ChatLogWindow : Window
ImGui.TableNextColumn(); ImGui.TableNextColumn();
var switchedTab = false; var hasTabSwitched = false;
var childHeight = GetRemainingHeightForMessageLog(); var childHeight = GetRemainingHeightForMessageLog();
using (var child = ImRaii.Child("##chat2-tab-sidebar", new Vector2(-1, childHeight))) using (var child = ImRaii.Child("##chat2-tab-sidebar", new Vector2(-1, childHeight)))
{ {
@@ -1285,10 +1285,10 @@ public sealed class ChatLogWindow : Window
continue; continue;
currentTab = tabI; currentTab = tabI;
switchedTab = Plugin.LastTab != tabI; hasTabSwitched = Plugin.LastTab != tabI;
Plugin.LastTab = tabI; Plugin.LastTab = tabI;
if (switchedTab) if (hasTabSwitched)
TabChannelSwitch(tab, previousTab); TabSwitched(tab, previousTab);
} }
} }
} }
@@ -1302,7 +1302,7 @@ public sealed class ChatLogWindow : Window
} }
if (currentTab > -1) if (currentTab > -1)
DrawMessageLog(Plugin.Config.Tabs[currentTab], PayloadHandler, childHeight, switchedTab); DrawMessageLog(Plugin.Config.Tabs[currentTab], PayloadHandler, childHeight, hasTabSwitched);
Plugin.WantedTab = null; Plugin.WantedTab = null;
} }
+24 -24
View File
@@ -4,9 +4,9 @@
"net9.0-windows7.0": { "net9.0-windows7.0": {
"DalamudPackager": { "DalamudPackager": {
"type": "Direct", "type": "Direct",
"requested": "[13.0.0, )", "requested": "[13.1.0, )",
"resolved": "13.0.0", "resolved": "13.1.0",
"contentHash": "Mb3cUDSK/vDPQ8gQIeuCw03EMYrej1B4J44a1AvIJ9C759p9XeqdU9Hg4WgOmlnlPe0G7ILTD32PKSUpkQNa8w==" "contentHash": "XdoNhJGyFby5M/sdcRhnc5xTop9PHy+H50PTWpzLhJugjB19EDBiHD/AsiDF66RETM+0qKUdJBZrNuebn7qswQ=="
}, },
"DotNet.ReproducibleBuilds": { "DotNet.ReproducibleBuilds": {
"type": "Direct", "type": "Direct",
@@ -50,23 +50,23 @@
}, },
"Watson.Lite": { "Watson.Lite": {
"type": "Direct", "type": "Direct",
"requested": "[6.2.3, )", "requested": "[6.3.13, )",
"resolved": "6.2.3", "resolved": "6.3.13",
"contentHash": "NGVWwQTulT016tiSuwK/AUU+Ebgyq0r0xLaWxll29xv/LTV0w4pHtoPskUPRqnzV8Po+ZndCd5dsUzGCjP0BVA==", "contentHash": "AoDQXFPTf/C38Afj4A1p+Siw46sqgr6SOZV6tL2gWmfoAAuiCJ3DymCD9OcY2ZasvvDOQk2NDNSLyXthkS5xhg==",
"dependencies": { "dependencies": {
"CavemanTcp": "2.0.3", "CavemanTcp": "2.0.9",
"Watson.Core": "6.2.3" "Watson.Core": "6.3.13"
} }
}, },
"CavemanTcp": { "CavemanTcp": {
"type": "Transitive", "type": "Transitive",
"resolved": "2.0.3", "resolved": "2.0.9",
"contentHash": "/feSFO+5A1tS69Vv9XgD8+Ahz9iRcPj8r1kXIIVxeL+VjUzpPpVoAOtNG70oyu+b8M1RqthZAszaVQHpSPzuwg==" "contentHash": "KgIwYhPhGkBTm+wwVAmWonkKPw4xYVnutzzlIeqOLcX1fti+8d+MEGTvbern1smf3S/UpjFjihkf6XRziTddzQ=="
}, },
"IpMatcher": { "IpMatcher": {
"type": "Transitive", "type": "Transitive",
"resolved": "1.0.4.4", "resolved": "1.0.5",
"contentHash": "93zP6KaDdRttUI3fFet6mYQGed1gTVK2amIOxpq4tDl3NRY+2tP/iA30hvRrs+ZY8u8KGF/J7kX6l7jrhKHfZA==" "contentHash": "WXNlWERj+0GN699AnMNsuJ7PfUAbU4xhOHP3nrNXLHqbOaBxybu25luSYywX1133NSlitA4YkSNmJuyPvea4sw=="
}, },
"MessagePack.Annotations": { "MessagePack.Annotations": {
"type": "Transitive", "type": "Transitive",
@@ -93,8 +93,8 @@
}, },
"RegexMatcher": { "RegexMatcher": {
"type": "Transitive", "type": "Transitive",
"resolved": "1.0.8", "resolved": "1.0.9",
"contentHash": "h3wa6sKJnyojCH2wJpvHdQRGWn+eKiSYGKPInIzvdl+SaKhUN9Qpr0CK4A3aqrYFBTEJKFN7pzYlNR4S5gZnaw==" "contentHash": "RkQGXIrqHjD5h1mqefhgCbkaSdRYNRG5rrbzyw5zeLWiS0K1wq9xR3cNhQdzYR2MsKZ3GN523yRUsEQIMPxh3Q=="
}, },
"SQLitePCLRaw.bundle_e_sqlite3": { "SQLitePCLRaw.bundle_e_sqlite3": {
"type": "Transitive", "type": "Transitive",
@@ -138,24 +138,24 @@
}, },
"Timestamps": { "Timestamps": {
"type": "Transitive", "type": "Transitive",
"resolved": "1.0.9", "resolved": "1.0.11",
"contentHash": "xdCVp+T4VFXOT+Ube2Cz527fnFXFUxQK24uPMGcCmICIpIcGCXPtI7vKLnWsJ6Nfc1B92tNBK9e/I8NDq2ee6g==" "contentHash": "SnWhXm3FkEStQGgUTfWMh9mKItNW032o/v8eAtFrOGqG0/ejvPPA1LdLZx0N/qqoY0TH3x11+dO00jeVcM8xNQ=="
}, },
"UrlMatcher": { "UrlMatcher": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0", "resolved": "3.0.1",
"contentHash": "IavFDKxhcg5ehi3baGEejxnZoj4bqfkDnoMxTV+6Y4WHVxW8wgxTzdSl9q5WSMi366LG0amDfOxaUJxuHawNLw==" "contentHash": "hHBZVzFSfikrx4XsRsnCIwmGLgbNKtntnlqf4z+ygcNA6Y/L/J0x5GiZZWfXdTfpxhy5v7mlt2zrZs/L9SvbOA=="
}, },
"Watson.Core": { "Watson.Core": {
"type": "Transitive", "type": "Transitive",
"resolved": "6.2.3", "resolved": "6.3.13",
"contentHash": "ccdGhC60dHTV1CA+C+U8zZOTQD4yLoPUDV0ZyXqF4dYmQ/zgwhegAxBGkV3WIw+2fqnTKYwMhJFU/tbkykybvA==", "contentHash": "f1qVBxEOYvRGNVfqoQDBuRK5lMsfYRpsWxv9qRiGH22eRtXvvaloQbxeouVtd1X2oiamdICPNAAhBVj/KYZ9Mw==",
"dependencies": { "dependencies": {
"IpMatcher": "1.0.4.4", "IpMatcher": "1.0.5",
"RegexMatcher": "1.0.8", "RegexMatcher": "1.0.9",
"System.Text.Json": "8.0.5", "System.Text.Json": "8.0.5",
"Timestamps": "1.0.9", "Timestamps": "1.0.11",
"UrlMatcher": "3.0.0" "UrlMatcher": "3.0.1"
} }
} }
} }