1
0
Fork 0

first functional pure-IRC client

* Log in to IRC server with v5 credentials using /AUTH
* Read and write messages on all three channels
* No history, no CSS, and no v4/v5-posting
This commit is contained in:
Lephenixnoir 2023-06-13 23:35:15 +02:00
commit e1dfaaab4f
Signed by untrusted user: Lephenixnoir
GPG Key ID: 1BBA026E13FC0495
3 changed files with 393 additions and 0 deletions

41
index.html Normal file
View File

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<title>Planète Casio Websocket IRC</title>
<script src="v5shoutbox.js"></script>
<link href="style.css" rel="stylesheet">
</head>
<body>
<div id="v5shoutbox">
<div class="status"></div>
<div class="channel-buttons">
<button data-channel="#annonces">#annonces</button>
<button data-channel="#hs">#hs</button>
<button data-channel="#projets">#projets</button>
</div>
<div class="channels">
<pre class="channel-annonces">[[[#annonces]]]
</pre>
<pre class="channel-hs">[[[#hs]]]
</pre>
<pre class="channel-projets">[[[#projets]]]
</pre>
</div>
<div class="form">
<div class="login-form">
<input type="text" class="login" placeholder="Utilisateur" />
<input type="password" class="password" placeholder="Mot de passe" />
<button class="connect">Connect!</button>
</div>
<form class="shoutbox-form">
<input type="text" name="message" class="message" />
</form>
</div>
<details>
<summary>Log des commandes IRC</summary>
<pre class="log"></pre>
</details>
</div>
</body>
</html>

0
style.css Normal file
View File

352
v5shoutbox.js Normal file
View File

@ -0,0 +1,352 @@
/*
# Planète Casio v5 shoutbox
This script contains all of the client-side logic for the Planète Casio
shoutbox. Unlike the legacy v4 shoutbox which was a poll-based HTTP service,
this shoutbox is run by an IRC server and this is mostly an IRC client.
Support for IRC features is fairly limited as the shoutbox has never been a
very fancy chat. For full functionality, use a standard IRC client.
The one unusual feature in this program is the interaction with the Planète
Casio website. This solves an issue of permissions that is difficult to manage
with pure IRC. The expectation for the shoutbox is that:
- Anyone can read but only community members can write;
- It should load immediately when embedded into website pages and allow logged
in users to read and write messages with no further interactions.
In the v4's cursed design, the shoutbox was part of the website API, so users
logged into their Planète Casio account would be recognized by the shoutbox
server (which is just the website) and authenticated by their session cookie.
This doesn't work with the IRC server, since the connections are very different
and cookies are not a reliable method of authentication. With a standard IRC
client, users would need to type in their password to connect to the chat
despite already being logged in to the website.
The chosen solution is as follows:
- Every embedded shoutbox connects to the IRC server as a read-only guest;
- Sending a message is done via a website request. The website identifies
logged in users by their session cookies and forwards the messages to the
IRC server locally;
- Messages from the website to the IRC server are attributed to their original
authors by whichever nick/plugin trick works best.
Hence, this client supports different connection modes depending on the
authentication setup: normal IRC (with password), PCv4, PCv5.
(WIP at time of writing)
*/
"use strict";
/* WebSocket IRC with HTTP-based website interface. */
let irc = new function() {
const State = Object.freeze({
/* Websocket is disconnected. */
DISCONNECTED: Symbol("DISCONNECTED"),
/* We just opened a socket to the IRC server and have yet to register. */
CONNECTING: Symbol("CONNECTING"),
/* We are authenticated and ready to use the server. */
READY: Symbol("READY"),
});
let conn = {
/* IRC server's websocket URL (wss://...). */
serverUrl: undefined,
/* The websocket connected to serverUrl */
socket: undefined,
/* HTTP-based message post URL. Undefined when using pure IRC, or a v4/v5
URL when using website authentication. */
postUrl: undefined,
/* Current connection state. */
state: State.DISCONNECTED,
/* Username with which we are logged in. Undefined when DISCONNECTED. */
username: undefined,
/* Current channel. This remains defined even when DISCONNECTED. */
channel: undefined,
/* Password for connection. Erased after authenticating. */
password: undefined,
};
/*** Accessors/mutators ***/
this.isConnected = function() {
return conn.state !== State.DISCONNECTED;
}
this.isRunning = function() {
return conn.state === State.READY;
}
this.stateString = function() {
switch(conn.state) {
case State.DISCONNECTED: return "Disconnected";
case State.CONNECTED: return "Connecting...";
case State.READY: return "Connected";
}
}
this.currentChannel = function() {
return conn.channel;
}
this.selectChannel = function(name) {
conn.channel = name;
}
/*** Overridable hooks and callbacks ***/
this.onLog = function(message) {};
this.onStateChanged = function() {};
this.onAuthenticated = function() {};
this.onNewMessage = function(channel, author, message) {};
function log(...args) {
irc.onLog(...args);
}
/*** Connection management ***/
function setState(state) {
conn.state = state;
irc.onStateChanged();
}
this.connect = function(serverUrl, postUrl, username, password) {
conn.serverUrl = serverUrl;
conn.postUrl = postUrl;
conn.socket = new WebSocket(conn.serverUrl);
log("[v5shoutbox] Connecting...");
conn.socket.onopen = function() {
log("[v5shoutbox] Connected.");
setState(State.CONNECTED);
conn.username = username;
conn.password = password;
startExchange();
}
conn.socket.onmessage = function(net) {
log("[<] " + net.data);
processMessage(parseMessage(net.data));
}
conn.socket.onclose = function() {
log("[v5shoutbox] Disconnected.");
setState(State.DISCONNECTED);
}
}
function sendRaw(command) {
log("[>] " + command);
conn.socket.send(command);
}
function send(command, args) {
if(args.slice(0,-1).some(x => x.includes(" ")))
return log("invalid spaces in non-last args: " + command.toString() +
" " + args.toString());
if(args.length && args[args.length - 1].includes(" "))
args[args.length - 1] = ":" + args[args.length - 1];
sendRaw(command + " " + args.join(" "));
}
/*** IRC protocol ***/
/* Regex for parsing messages */
const rMessage = (function(){
const rTags = /(?:@([^ ]+) +)?/;
const rSource = /(?::([^ ]+) +)?/;
// TODO: Check if a-zA-Z is OK for cmd
const rCommand = /(?:([a-zA-Z]+)|(\d{3}))/;
const rArgs = /((?: +[^\r\n :][^\r\n ]*)*)/;
const rTrailing = /(?: +:([^\r\n]*))?/;
return new RegExp([/^/, rTags, rSource, rCommand, rArgs, rTrailing, /$/]
.map(r => r.source).join(""));
})();
function parseMessage(text) {
const matches = rMessage.exec(text);
if(matches === undefined)
return undefined;
let msg = Object();
msg.tags = matches[1];
msg.source = matches[2];
if(matches[3] !== undefined) {
msg.textCommand = matches[3];
msg.numCommand = undefined;
}
else {
msg.textCommand = undefined;
msg.numCommand = parseInt(matches[4]);
}
msg.args = [];
if(matches[5] !== undefined)
msg.args = matches[5].trim().split(" ").filter(s => s);
if(matches[6] !== undefined)
msg.args.push(matches[6]);
return msg;
}
function startExchange() {
sendRaw("CAP LS 302");
send("NICK", [conn.username]);
send("USER", [conn.username, "*", "0", conn.username]);
}
function processMessage(msg) {
if(msg.textCommand === "PING")
send("PONG", msg.args);
let sendAuth = false;
if(msg.textCommand === "CAP" && msg.args[1] === "LS") {
const caps = msg.args[2].split(" ");
if(caps.includes("draft/chathistory"))
sendRaw("CAP REQ draft/chathistory");
else
sendRaw("CAP END");
}
if(msg.textCommand === "CAP" && msg.args[1] === "ACK")
sendRaw("CAP END");
if(msg.textCommand === "NOTICE" && msg.args[1].includes("/AUTH")) {
log("[v5shoutbox] AUTH command sent (not shown)");
conn.socket.send("AUTH " + conn.username + ":" + conn.password);
}
if(msg.numCommand === 900) {
log("[v5shoutbox] Authenticated.");
setState(State.READY);
conn.password = undefined;
irc.onAuthenticated();
}
if(msg.textCommand == "PRIVMSG" && msg.args.length == 2) {
let source = msg.source;
if(source.includes("!"))
source = source.substr(0, source.indexOf("!"));
irc.onNewMessage(msg.args[0], source, msg.args[1]);
}
}
this.postMessage = function(channel, message) {
send("PRIVMSG", [channel, message]);
irc.onNewMessage(channel, conn.username, message);
}
};
/* Shoutbox entry point and DOM manipulation. */
let shoutbox = new function() {
this.init = function(root) {
/* Channel views */
this.eChannels = root.querySelector(".channels");
this.eChannelButtons = root.querySelector(".channel-buttons");
/* Debugging tools */
this.eLog = root.querySelector(".log");
this.eStatus = root.querySelector(".status");
/* Elements of the login form */
this.eLoginForm = root.querySelector(".login-form");
this.eConnect = root.querySelector(".connect");
this.eLogin = root.querySelector(".login");
this.ePassword = root.querySelector(".password");
/* Elements of the shoutbox form */
this.eShoutboxForm = root.querySelector(".shoutbox-form");
this.eMessage = root.querySelector(".message");
this.eConnect.addEventListener("click", function() {
shoutbox.connect();
});
this.eShoutboxForm.addEventListener("submit", function(e) {
e.preventDefault();
shoutbox.post();
});
this.eChannelButtons.querySelectorAll("button").forEach(b => {
b.addEventListener("click", () => {
shoutbox.selectChannel(b.dataset.channel);
});
});
this.refreshView();
this.selectChannel("#annonces");
}
/*** IRC callbacks ***/
function log(message) {
/* TODO: Use a better text element x) */
this.eLog.innerHTML += message + "\n";
}
this.log = log.bind(this);
irc.onLog = log.bind(this);
irc.onAuthenticated = function() {
this.ePassword.value = "";
}.bind(this);
this.refreshView = function() {
const running = irc.isRunning();
this.eStatus.textContent = irc.stateString();
this.eStatus .style.display = running ? "none" : "block";
this.eChannelButtons .style.display = running ? "block" : "none";
this.eLoginForm .style.display = running ? "none" : "block";
this.eShoutboxForm .style.display = running ? "block" : "none";
};
irc.onStateChanged = this.refreshView.bind(this);
irc.onNewMessage = function(channel, author, message) {
/* Remove the shoutbox bridge's "[s]" suffix */
if(author.endsWith("[s]"))
author = author.substr(0, author.length - 3);
if(channel.startsWith("#"))
channel = channel.substr(1);
const eChan = this.eChannels.querySelector(".channel-" + channel);
if(eChan.length === null)
return;
/* TODO: Better text element x) */
eChan.innerHTML += "[" + channel + "] [" + author + "] " + message + "\n";
}.bind(this);
/*** User interactions ***/
this.connect = function() {
if(irc.isConnected())
return this.log("Already connected!");
if(this.eLogin.value === "" || this.ePassword.value === "")
return this.log("Need login/password to connect!");
if(this.eLogin.value.includes(" "))
return this.log("Login should not contain a space!");
irc.connect("wss://irc.planet-casio.com:8000", undefined,
this.eLogin.value, this.ePassword.value);
}
this.selectChannel = function(name) {
irc.selectChannel(name);
const cls = "channel-" + name.substr(1);
Array.from(this.eChannels.children).forEach(e => {
e.style.display = e.classList.contains(cls) ? "block" : "none";
});
}
this.post = function() {
if(this.eMessage.value === "")
return;
if(!irc.isRunning())
return log("Cannot send message while not connected!");
const channel = irc.currentChannel();
if(channel === "")
return log("Cannot send message as no channel is selected!");
irc.postMessage(channel, this.eMessage.value);
this.eMessage.value = "";
}
};
document.addEventListener("DOMContentLoaded", function() {
shoutbox.init(document.getElementById("v5shoutbox"));
});