diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bff2196 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.dub +docs.json +__dummy.html +libsnooze.so +libsnooze.dylib +libsnooze.dll +libsnooze.a +libsnooze.lib +openbnet-test-* +*.exe +*.pdb +*.o +*.obj +*.lst +dub.selections.json +liblibsnooze.a diff --git a/README.md b/README.md index 9603d8d..8651938 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # OpenBNET -Lightweight web interface and API endpoint for the `third/wwwstats` unrealircd module. +Lightweight web interface and API endpoint for the [`JSON RPC`](https://www.unrealircd.org/docs/JSON-RPC) unrealircd module. @@ -40,57 +40,36 @@ Available at `/api`: You will need the following and can install them easily: -1. `python3` -2. `flask` -3. `pip` +1. `dmd` +2. `dub` -``` -apt install python3 python3-pip -pip3 install flask -``` +Visit the [D programming language website](https://dlang.org) for more information on how to install it. -You will need to configure the `third/wwwstats` module as well, information on doing so can be found [here](http://deavmi.assigned.network/projects/bonobonet/openbnet/). +You will need to configure the `JSON RPC` module as well, information on doing so can be found [here](https://deavmi.assigned.network/projects/bonobonet/network/config/monitoring/). ## Usage Firstly grab all the files in this repository, then: ``` -chmod +x obnet.py +dub build ``` The next thing to do will be to set the following environment variables: -* `OPENBNET_BIND_ADDR` - * The addresses to listen on (for the web server) -* `OPENBNET_BIND_PORT` - * The port to listen on (for the web server) -* `UNREAL_SOCKET_PATH` - * This is the path to the unrealircd `third/wwwstats` UNIX domain socket +* `RPC_ENDPOINT` + * The HTTP address to the JSON RPC endpoint You can then run it like such: ``` -OPENBNET_BIND_ADDR="::" OPENBNET_BIND_PORT=8081 UNREAL_SOCKET_PATH=/tmp/openbnet.sock ./obnet.py +RPC_ENDPOINT=https://127.0.0.1:8181 ./obnet ``` ### Systemd-unit There is an example systemd unit file included in the repository as `openbnet.service` -## Custom branding - -You can adjust the branding in `obnet.py` by taking a look at the following: - -```python -# Network information -NET_INFO = { - "networkName": "OpenBonobo", - "description": "Network statistics for the BonoboNET network", - "networkLogo": "open_bnet_banner.png", -} -``` - ## License This project uses the [AGPL version 3](https://www.gnu.org/licenses/agpl-3.0.en.html) license. diff --git a/assets/b_hash_logo.png b/assets/b_hash_logo.png new file mode 100644 index 0000000..29d099f Binary files /dev/null and b/assets/b_hash_logo.png differ diff --git a/dub.json b/dub.json new file mode 100644 index 0000000..f47c9f3 --- /dev/null +++ b/dub.json @@ -0,0 +1,17 @@ +{ + "authors": [ + "Tristan B. Velloza Kildaire", + "Rany" + ], + "copyright": "Copyright © 2023, Tristan B. Velloza Kildaire", + "dependencies": { + "gogga": "~>2.1.18", + "vibe-d": "~>0.9.5" + }, + "description": "A looking glass into unrealircd", + "license": "AGPL-3", + "name": "openbnet", + "stringImportPaths": [ + "source/views" + ] +} \ No newline at end of file diff --git a/obnet.py b/obnet.py deleted file mode 100644 index 253d3d4..0000000 --- a/obnet.py +++ /dev/null @@ -1,177 +0,0 @@ -#!/usr/bin/python3 - -""" -OpenBNET - -A simple web service that provides insight into the BonoboNET network -""" - -import json -import sys -import time -from os import environ as env -from os.path import join as path_join -from socket import AddressFamily, SocketKind, socket -from threading import Lock - -from flask import Flask, abort, render_template, Response -from flask.helpers import send_file - -# Setup the flask instance -app = Flask(__name__) - -# Network information -NET_INFO = { - "networkName": "OpenBonobo", - "description": "Network statistics for the BonoboNET network", - "networkLogo": "open_bnet_banner.png", -} - -# Socket for unrealircd -UNREAL_SOCKET_PATH = "/tmp/openbnet.sock" - - -class FetchJSON: - def __init__(self, unix_path, expires_after=60): - self.unix_path = unix_path - self.json_data = None - self.expires_after = expires_after - self.last_update = -self.expires_after - 1 - self.lock = Lock() - - def get(self): - if self.unix_path is None: - return None - - with self.lock: - if time.time() - self.last_update < self.expires_after: - return self.json_data - - try: - # Assuming it is never bigger than a certain size - sock = socket(AddressFamily.AF_UNIX, SocketKind.SOCK_STREAM) - sock.connect(self.unix_path) - bytes_recv = sock.recv(4096) - sock.close() - - str_data = bytes_recv.decode() - json_data = json.loads(str_data) - - self.json_data = json_data - self.last_update = time.time() - - return json_data - except Exception as exception: - print(exception) - - self.json_data = None - self.last_update = time.time() - - return None - - -FETCH_JSON = FetchJSON(None) - - -@app.route("/", methods=["GET"]) -def home(): - # Fetch the information form unrealircd socket - json_data = FETCH_JSON.get() - - # Grab servers - if json_data is None: - abort(Response(response="Error whilst contacting the IRC daemon", status=404)) - - # Grab general info - network_state = {} - network_state["channels"] = json_data["channels"] - network_state["clients"] = json_data["clients"] - network_state["operators"] = json_data["operators"] - network_state["messages"] = json_data["messages"] - - return render_template( - "index.html", **NET_INFO, servers=json_data["serv"], **network_state - ) - - -@app.route("/channels", methods=["GET"]) -def channels_direciory(): - # Fetch the information form unrealircd socket - json_data = FETCH_JSON.get() - - # Grab servers - if json_data is None: - abort(Response(response="Error whilst contacting the IRC daemon", status=404)) - - return render_template("channels.html", **NET_INFO, channels=json_data["chan"]) - - -@app.route("/raw", methods=["GET"]) -def raw(): - raw_data = FETCH_JSON.get() - return render_template("raw.html", raw=raw_data, **NET_INFO) - - -@app.route("/assets/", methods=["GET"]) -def assets(file): - file = file.replace("..", "") - file = file.replace("/", "") - return send_file(path_join("assets", file)) - - -@app.route("/api", methods=["GET"]) -def api(): - data = FETCH_JSON.get() - return data - - -@app.errorhandler(Exception) -def handle_exception(code): - """ - Handles all errors - """ - return render_template("error.html", **NET_INFO, code=code) - - -def init(): - """ - Starts the server - - Args: - None - - Returns: - None - """ - try: - bind_addr = str(env["OPENBNET_BIND_ADDR"]) - except KeyError: - bind_addr = "::" - - try: - bind_port = int(env["OPENBNET_BIND_PORT"]) - except (KeyError, ValueError): - bind_port = 8081 - - try: - global UNREAL_SOCKET_PATH - UNREAL_SOCKET_PATH = str(env["UNREAL_SOCKET_PATH"]) - except KeyError: - pass - - global FETCH_JSON - FETCH_JSON = FetchJSON(UNREAL_SOCKET_PATH) - - try: - # Start flask - app.run(host=bind_addr, port=bind_port) - except Exception as exception: - print( - f"Could not start OpenBONOBO: {exception.__class__.__name__} - {exception}" - ) - sys.exit(1) - - -if __name__ == "__main__": - # Start OBNET - init() diff --git a/screenshots/chan_list.png b/screenshots/chan_list.png index e4268db..309468c 100644 Binary files a/screenshots/chan_list.png and b/screenshots/chan_list.png differ diff --git a/screenshots/chan_view.png b/screenshots/chan_view.png new file mode 100644 index 0000000..fdc53ae Binary files /dev/null and b/screenshots/chan_view.png differ diff --git a/screenshots/home.png b/screenshots/home.png index cb80b1a..a4eb768 100644 Binary files a/screenshots/home.png and b/screenshots/home.png differ diff --git a/screenshots/servers.png b/screenshots/servers.png new file mode 100644 index 0000000..cf11b16 Binary files /dev/null and b/screenshots/servers.png differ diff --git a/screenshots/user_info.png b/screenshots/user_info.png new file mode 100644 index 0000000..c64abec Binary files /dev/null and b/screenshots/user_info.png differ diff --git a/source/openbnet/app.d b/source/openbnet/app.d new file mode 100644 index 0000000..718baff --- /dev/null +++ b/source/openbnet/app.d @@ -0,0 +1,366 @@ +module openbnet.app; + +import openbnet.types; +import vibe.d; +import std.functional : toDelegate; +import gogga; +import core.stdc.stdlib : getenv; +import std.string : fromStringz; +import std.net.curl : post, HTTP; + +private GoggaLogger logger; + +static this() +{ + logger = new GoggaLogger(); + logger.enableDebug(); + + version(release) + { + logger.disableDebug(); + } +} + +private string rpcEndpoint = "https://apiuser:password@127.0.0.1:8001/api"; + +public class Network +{ + public string name; + public string logo; + public string description; +} + +// TODO: A fetch channel should populate with users list inside it + +/** + * Fetches the statistics information by making the `stats.get` + * request and then parsing the results + * + * Returns: an instance of Stats containing the information + */ +private Stats fetchStats() +{ + Stats stats; + + /** + * Make the request + * + * `"jsonrpc": "2.0", "method": "stats.get", "parans" : {}, "id": 123` + */ + import std.json; + JSONValue postData; + postData["jsonrpc"] = "2.0"; + postData["method"] = "stats.get"; + + JSONValue params; + postData["params"] = params; + + + postData["id"] = 123; + + string response = cast(string)post(rpcEndpoint, postData.toPrettyString()); + + /** + * Parse the response + */ + JSONValue responseJSON = parseJSON(response); + stats = Stats.fromJSON(responseJSON["result"]); + + return stats; +} + +private Channel[] fetchChannels() +{ + Channel[] fetchedChannels; + + /** + * Make the request + * + * `"{jsonrpc": "2.0", "method": "channel.list", "params": {}, "id": 123}` + */ + import std.json; + JSONValue postData; + postData["jsonrpc"] = "2.0"; + postData["method"] = "channel.list"; + + + JSONValue params; + postData["params"] = params; + + postData["id"] = 123; + + + // FIXME: Why does DLog crash with this? + // logger.log("Post data JSON: ", postData.toPrettyString()); + + string response = cast(string)post(rpcEndpoint, postData.toPrettyString()); + + /** + * Parse the response + */ + JSONValue responseJSON = parseJSON(response); + // logger.log("Got: "~responseJSON.toPrettyString()); + import std.stdio; + writeln(responseJSON.toPrettyString()); + foreach(JSONValue curChannel; responseJSON["result"]["list"].array()) + { + fetchedChannels ~= Channel.fromJSON(curChannel); + } + + + + return fetchedChannels; +} + +private Server[] fetchServers() +{ + Server[] fetchedServers; + + /** + * Make the request + * + * `{"jsonrpc": "2.0", "method": "server.list", "params": {}, "id": 123}` + */ + import std.json; + JSONValue postData; + postData["jsonrpc"] = "2.0"; + postData["method"] = "server.list"; + + + JSONValue params; + postData["params"] = params; + + postData["id"] = 123; + + string response = cast(string)post(rpcEndpoint, postData.toPrettyString()); + + /** + * Parse the response + */ + JSONValue responseJSON = parseJSON(response); + foreach(JSONValue curServer; responseJSON["result"]["list"].array()) + { + fetchedServers ~= Server.fromJSON(curServer); + } + + + return fetchedServers; +} + +private ChannelInfo fetchChannelInfo(string channel) +{ + ChannelInfo fetchedChannelInfo; + + /** + * Make the request + * + * `{"jsonrpc": "2.0", "method": "channel.get", "params": {"channel":"#"}, "id": 123}` + */ + import std.json; + JSONValue postData; + postData["jsonrpc"] = "2.0"; + postData["method"] = "channel.get"; + + + JSONValue params; + params["channel"] = channel; + postData["params"] = params; + + postData["id"] = 123; + + string response = cast(string)post(rpcEndpoint, postData.toPrettyString()); + + /** + * Parse the response + */ + JSONValue responseJSON = parseJSON(response); + fetchedChannelInfo = ChannelInfo.fromJSON(responseJSON["result"]["channel"]); + + + return fetchedChannelInfo; +} + +private User fetchUserInfo(string user) +{ + User fetchedUserInfo; + + /** + * Make the request + * + * `{"jsonrpc": "2.0", "method": "user.get", "params": {"nick":""}, "id": 123}` + */ + import std.json; + JSONValue postData; + postData["jsonrpc"] = "2.0"; + postData["method"] = "user.get"; + + + JSONValue params; + params["nick"] = user; + postData["params"] = params; + + postData["id"] = 123; + + string response = cast(string)post(rpcEndpoint, postData.toPrettyString()); + + /** + * Parse the response + */ + JSONValue responseJSON = parseJSON(response); + import std.stdio; + logger.info(responseJSON.toPrettyString()); + fetchedUserInfo = User.fromJSON(responseJSON["result"]["client"]); + + + return fetchedUserInfo; +} + +void channelListHandler(HTTPServerRequest req, HTTPServerResponse resp) +{ + /* Fetch the channels */ + Channel[] channels = fetchChannels(); + + // TODO: Add actual network here + Network network = new Network(); + + resp.render!("channels.dt", network, channels); +} + +void serverListHandler(HTTPServerRequest req, HTTPServerResponse resp) +{ + /* Fetch the servers */ + Server[] servers = fetchServers(); + + // TODO: Add actual network here + Network network = new Network(); + + resp.render!("servers.dt", network, servers); +} + +void channelInfoHandler(HTTPServerRequest req, HTTPServerResponse resp) +{ + // TODO: Add actual network here + Network network = new Network(); + + /* Extract the parameters */ + auto params = req.query; + + logger.debug_(params); + + /* Extract name parameter */ + if(params.get("name") !is null) // TODO: Ensure channel name is not empty string + { + /* Extract the channel name */ + string channelName = strip(params["name"]); + + + + /* Fetch the channel info */ + ChannelInfo channelInfo = fetchChannelInfo(channelName); + + + resp.render!("channelinfo.dt", channelInfo, network, channelName); + + } + /* If not found, throw an error */ + else + { + logger.error("The channel name parameter is not present"); + throw new HTTPStatusException(HTTPStatus.badRequest, "Missing channel name parameter"); + } + + // TODO: Ensure we have a "name" parameter, if not throw an HTTP error +} + +void userInfoHandler(HTTPServerRequest req, HTTPServerResponse resp) +{ + // TODO: Add actual network here + Network network = new Network(); + + /* Extract the parameters */ + auto params = req.query; + + logger.debug_(params); + + /* Extract name parameter */ + if(params.get("id") !is null) // TODO: Ensure id is not empty string + { + /* Extract the user id */ + string userId = strip(params["id"]); + + + + /* Fetch the user info info */ + User user = fetchUserInfo(userId); + + + resp.render!("user.dt", user, network); + + } + /* If not found, throw an error */ + else + { + logger.error("The channel name parameter is not present"); + throw new HTTPStatusException(HTTPStatus.badRequest, "Missing channel name parameter"); + } + + // TODO: Ensure we have a "name" parameter, if not throw an HTTP error +} + +void homeHandler(HTTPServerRequest req, HTTPServerResponse resp) +{ + // TODO: Add actual network here + Network network = new Network(); + + /* Fetch the network statistics */ + Stats stats = fetchStats(); + + resp.render!("home.dt", network, stats); +} + +void errorHandler(HTTPServerRequest req, HTTPServerResponse resp, HTTPServerErrorInfo error) +{ + // TODO: FInish error page + Network network = new Network(); + + auto request = req; + resp.render!("error.dt", error, request, network); +} + +void main() +{ + logger.info("Welcome to OpenBNET!"); + + + rpcEndpoint = cast(string)fromStringz(getenv("RPC_ENDPOINT")); + if(rpcEndpoint == null) + { + logger.error("The environment variable 'RPC_ENDPOINT' was not specified"); + return; + } + logger.info("Using RPC endpoint '"~rpcEndpoint~"'"); + + HTTPServerSettings httpSettings = new HTTPServerSettings(); + httpSettings.bindAddresses = ["::"]; + httpSettings.port = 8002; + + + httpSettings.errorPageHandler = toDelegate(&errorHandler); + + URLRouter router = new URLRouter(); + + router.get("/", &homeHandler); + router.get("/channels", &channelListHandler); + router.get("/channelinfo", &channelInfoHandler); + router.get("/servers", &serverListHandler); + router.get("/user", &userInfoHandler); + + // Setup serving of static files + router.get("/assets/table.css", serveStaticFile("assets/table.css")); + router.get("/assets/open_bnet_banner.png", serveStaticFile("assets/open_bnet_banner.png")); + router.get("/favicon.ico", serveStaticFile("assets/b_hash_logo.png")); + + listenHTTP(httpSettings, router); + + runApplication(); +} diff --git a/source/openbnet/types/channel.d b/source/openbnet/types/channel.d new file mode 100644 index 0000000..c5a6c0c --- /dev/null +++ b/source/openbnet/types/channel.d @@ -0,0 +1,270 @@ +module openbnet.types.channel; + +import std.json; + +version(unittest) +{ + // import std.stdio; + // import testInputs : channeList; +} + +import std.stdio; +import testInputs : channeList; + +public Channel[] getDummyChannels() +{ + JSONValue rcpDataIn = parseJSON(channeList); + + Channel[] channels; + foreach(JSONValue curChannel; rcpDataIn["result"]["list"].array()) + { + channels ~= Channel.fromJSON(curChannel); + } + + foreach(Channel curChannel; channels) + { + writeln(curChannel); + } + + return channels; +} + +unittest +{ + + JSONValue rcpDataIn = parseJSON(channeList); + + Channel[] channels; + foreach(JSONValue curChannel; rcpDataIn["result"]["list"].array()) + { + channels ~= Channel.fromJSON(curChannel); + } + + foreach(Channel curChannel; channels) + { + writeln(curChannel); + } +} + +import std.datetime.date; +import std.conv : to; + +public class Channel +{ + private string name; + private DateTime creationTime; + private long userCount; + private string topic, topicSetBy; + private DateTime topicSetAt; + private string modes; + + // TODO: add getters for the fields above + + private this() + { + + } + + public static Channel fromJSON(JSONValue jsonIn) + { + Channel channel = new Channel(); + + channel.name = jsonIn["name"].str(); + + + // Strip off the .XXXZ + string creationTimeClean =jsonIn["creation_time"].str(); + import std.string; + long dotPos = indexOf(creationTimeClean, "."); + creationTimeClean = creationTimeClean[0..dotPos]; + channel.creationTime = DateTime.fromISOExtString(creationTimeClean); + + channel.userCount = jsonIn["num_users"].integer(); + + if("topic" in jsonIn.object()) + { + channel.topic = jsonIn["topic"].str(); + channel.topicSetBy = jsonIn["topic_set_by"].str(); + + // Strip off the .XXXZ + string topicCreationTimeClean =jsonIn["topic_set_at"].str(); + import std.string; + dotPos = indexOf(topicCreationTimeClean, "."); + topicCreationTimeClean = topicCreationTimeClean[0..dotPos]; + channel.topicSetAt = DateTime.fromISOExtString(topicCreationTimeClean); + } + + channel.modes = jsonIn["modes"].str(); + + + return channel; + } + + // TODO: Finish implementing this + public override string toString() + { + return "Channel [name: "~name~ + ", created: "~creationTime.toString()~ + ", size: "~to!(string)(userCount)~ + "]"; + } + + public string getName() + { + return name; + } + + public string getModes() + { + return modes; + } + + public long getUsers() + { + return userCount; + } + + public string getTopic() + { + return topic; + } + + public DateTime getCreationTime() + { + return creationTime; + } +} + + +unittest +{ + import testInputs : channelInfoTest = channelInfo; + + JSONValue rcpDataIn = parseJSON(channelInfoTest); + + ChannelInfo channelInfo = ChannelInfo.fromJSON(rcpDataIn["result"]["channel"]); +} + + +public ChannelInfo getDummyChannelInfo(string channelName) +{ + import testInputs : channelInfoTest = channelInfo; + JSONValue rcpDataIn = parseJSON(channelInfoTest); + + ChannelInfo channelInfo = ChannelInfo.fromJSON(rcpDataIn["result"]["channel"]); + + return channelInfo; +} + +public class MemberInfo +{ + private string name; + private string id; + + + private this() + { + + } + + + + public static MemberInfo fromJSON(JSONValue jsonIn) + { + MemberInfo info = new MemberInfo(); + + info.name = jsonIn["name"].str(); + info.id = jsonIn["id"].str(); + + return info; + } + + public string getName() + { + return name; + } + + public string getID() + { + return id; + } + + public override string toString() + { + return name~" ("~id~")"; + } +} + +/** + * Represents information that is retrived via `channel.get` + * which means that this contains more in-depth information + * for the given channel + */ +public class ChannelInfo +{ + + + private string[] bans; + private string[] banExemptions; + private string[] inviteExceptions; + private MemberInfo[] members; + + private this() + { + + } + + public static ChannelInfo fromJSON(JSONValue jsonIn) + { + ChannelInfo channelInfo = new ChannelInfo(); + + /* Parse bans array */ + JSONValue[] bansJSON = jsonIn["bans"].array(); + foreach(JSONValue curBan; bansJSON) + { + channelInfo.bans ~= curBan.str(); + } + + /* Parse ban exemptions array */ + JSONValue[] banExemptionsJSON = jsonIn["ban_exemptions"].array(); + foreach(JSONValue curBan; banExemptionsJSON) + { + channelInfo.banExemptions ~= curBan.str(); + } + + /* Parse invite exceptions array */ + JSONValue[] inviteExceptionsJSON = jsonIn["invite_exceptions"].array(); + foreach(JSONValue curInviteException; inviteExceptionsJSON) + { + channelInfo.inviteExceptions ~= curInviteException.str(); + } + + JSONValue[] membersInfoJSON = jsonIn["members"].array(); + foreach(JSONValue curMemberInfo; membersInfoJSON) + { + channelInfo.members ~= MemberInfo.fromJSON(curMemberInfo); + } + + return channelInfo; + } + + public string[] getBans() + { + return bans; + } + + public string[] getBanExemptions() + { + return banExemptions; + } + + public string[] getInviteExceptions() + { + return inviteExceptions; + } + + public MemberInfo[] getMembers() + { + return members; + } +} diff --git a/source/openbnet/types/package.d b/source/openbnet/types/package.d new file mode 100644 index 0000000..ac44b84 --- /dev/null +++ b/source/openbnet/types/package.d @@ -0,0 +1,6 @@ +module openbnet.types; + +public import openbnet.types.server; +public import openbnet.types.channel; +public import openbnet.types.stats; +public import openbnet.types.user; \ No newline at end of file diff --git a/source/openbnet/types/server.d b/source/openbnet/types/server.d new file mode 100644 index 0000000..6625467 --- /dev/null +++ b/source/openbnet/types/server.d @@ -0,0 +1,178 @@ +module openbnet.types.server; + +import std.json; + +// version(unittest) +// { + import std.stdio; + import testInputs : serverList; +// } + +public Server[] getDummyServers() +{ + JSONValue rcpDataIn = parseJSON(serverList); + + Server[] servers; + foreach(JSONValue curServer; rcpDataIn["result"]["list"].array()) + { + servers ~= Server.fromJSON(curServer); + } + + return servers; +} + + + +unittest +{ + + JSONValue rcpDataIn = parseJSON(serverList); + + Server[] servers; + foreach(JSONValue curServer; rcpDataIn["result"]["list"].array()) + { + servers ~= Server.fromJSON(curServer); + } + + foreach(Server curServer; servers) + { + writeln(curServer); + } +} + +import std.datetime.date; +import std.string; + +public class Server +{ + private string name; + private string sid; + private string hostname; // TODO: MAke an address object + private string details; + + + /* information about this server (link-wise) */ + private string infoString; + private string uplinkServer; + private long num_users; + private DateTime boot_time; + private bool synced, ulined; + + private string software; + private long protocol; + + + private this() + { + + } + + public static Server fromJSON(JSONValue jsonIn) + { + Server server = new Server(); + + import std.stdio; + writeln(jsonIn.toPrettyString()); + + server.name = jsonIn["name"].str(); + server.sid = jsonIn["id"].str(); + server.hostname = jsonIn["hostname"].str(); + server.details = jsonIn["details"].str(); + + /* Extract all information from the `server {}` block */ + JSONValue serverBlock = jsonIn["server"]; + server.infoString = serverBlock["info"].str(); + if("uplinkServer" in serverBlock.object()) + { + server.uplinkServer = serverBlock["uplink"].str(); + } + server.num_users = serverBlock["num_users"].integer(); + + // Strip off the .XXXZ + if(!serverBlock["boot_time"].isNull()) + { + string bootTime = serverBlock["boot_time"].str(); + long dotPos = indexOf(bootTime, "."); + bootTime = bootTime[0..dotPos]; + server.boot_time = DateTime.fromISOExtString(bootTime); + } + + if("synced" in serverBlock.object()) + { + server.synced = serverBlock["synced"].boolean(); + } + if("ulined" in serverBlock.object()) + { + server.ulined = serverBlock["ulined"].boolean(); + } + + /* Extract all information from the `feature {}` block within the `server {}` block */ + JSONValue featureBlock = serverBlock["features"]; + server.software = featureBlock["software"].str(); + server.protocol = featureBlock["protocol"].integer(); + + + + return server; + } + + public string getName() + { + return name; + } + + public string getSID() + { + return sid; + } + + public string getHostname() + { + return hostname; + } + + public string getDetails() + { + return details; + } + + public long getUserCount() + { + return num_users; + } + + public string getInfo() + { + return infoString; + } + + public string getUplink() + { + return uplinkServer; + } + + public DateTime getBootTime() + { + return boot_time; + } + + public bool getSynced() + { + return synced; + } + + public bool getUlined() + { + return ulined; + } + + public string getSoftware() + { + return software; + } + + public long getProtocol() + { + return protocol; + } +} \ No newline at end of file diff --git a/source/openbnet/types/stats.d b/source/openbnet/types/stats.d new file mode 100644 index 0000000..012c9f0 --- /dev/null +++ b/source/openbnet/types/stats.d @@ -0,0 +1,55 @@ +module openbnet.types.stats; + +import std.json; +import testInputs; + +public Stats getDummyStats() +{ + Stats stats = Stats.fromJSON(JSONValue()); + + + return stats; +} + +public class Stats +{ + private long serverCount, userCount, channelCount; + + private this() {} + + public static Stats fromJSON(JSONValue jsonIn) + { + Stats stats = new Stats(); + + import std.stdio; + writeln(jsonIn); + + JSONValue serverBlock = jsonIn["server"]; + stats.serverCount = serverBlock["total"].integer(); + + JSONValue userBlock = jsonIn["user"]; + stats.userCount = userBlock["total"].integer(); + + JSONValue channelBlock = jsonIn["channel"]; + stats.channelCount = channelBlock["total"].integer(); + + // TODO: Add parsing + + return stats; + } + + public long getServers() + { + return serverCount; + } + + public long getUsers() + { + return userCount; + } + + public long getChannels() + { + return channelCount; + } +} \ No newline at end of file diff --git a/source/openbnet/types/user.d b/source/openbnet/types/user.d new file mode 100644 index 0000000..e251f4d --- /dev/null +++ b/source/openbnet/types/user.d @@ -0,0 +1,87 @@ +module openbnet.types.user; + +import std.datetime.date; +import std.json; + +public class User +{ + // TODO: Fields + private string name, id, hostname, ip; + private string realname, vhost, servername, modes, cloakedHost; + + private long clientPort, serverPort; + private DateTime connectedSince, idleSince; + private long reputation; + + + + private this() {} + + public static User fromJSON(JSONValue jsonIn) + { + User user = new User(); + + user.name = jsonIn["name"].str(); + user.id = jsonIn["id"].str(); + user.hostname = jsonIn["hostname"].str(); + + user.ip = jsonIn["ip"].str(); + + + JSONValue userBlock = jsonIn["user"]; + user.realname = userBlock["realname"].str(); + user.vhost = userBlock["vhost"].str(); + user.servername = userBlock["servername"].str(); + user.reputation = userBlock["reputation"].integer(); + user.modes = userBlock["modes"].str(); + user.cloakedHost = userBlock["cloakedhost"].str(); + + + + return user; + } + + public string getRealname() + { + return realname; + } + + public string getNick() + { + return name; + } + + public string getIP() + { + return ip; + } + + public string getHostname() + { + return hostname; + } + + public string getVHost() + { + return vhost; + } + + public string getServer() + { + return servername; + } + + public long getReputation() + { + return reputation; + } + + public string getModes() + { + return modes; + } + public string getCloakedHost() + { + return cloakedHost; + } +} \ No newline at end of file diff --git a/source/testInputs.d b/source/testInputs.d new file mode 100644 index 0000000..dfd464a --- /dev/null +++ b/source/testInputs.d @@ -0,0 +1,438 @@ +module testInputs; + +import std.json; + +string channeList = ` +{ + "jsonrpc": "2.0", + "method": "channel.list", + "id": 123, + "result": { + "list": [ + { + "name": "#ccc", + "creation_time": "2023-03-19T18:19:30.000Z", + "num_users": 4, + "modes": "nt" + }, + { + "name": "#gaming", + "creation_time": "2023-03-19T19:50:26.000Z", + "num_users": 1, + "modes": "nt" + }, + { + "name": "#network", + "creation_time": "2023-03-19T19:50:28.000Z", + "num_users": 1, + "modes": "nt" + }, + { + "name": "#club45", + "creation_time": "2023-03-19T19:50:55.000Z", + "num_users": 1, + "modes": "nt" + }, + { + "name": "#Gentoousers", + "creation_time": "2023-03-19T19:52:32.000Z", + "num_users": 1, + "modes": "nt" + }, + { + "name": "#blngqisstoopid", + "creation_time": "2023-03-19T19:52:32.000Z", + "num_users": 1, + "modes": "nt" + }, + { + "name": "#openbsd", + "creation_time": "2023-03-19T19:52:32.000Z", + "num_users": 1, + "modes": "nt" + }, + { + "name": "#bnetr", + "creation_time": "2023-03-19T20:36:15.000Z", + "num_users": 1, + "modes": "nt" + }, + { + "name": "#chris", + "creation_time": "2023-03-19T20:36:15.000Z", + "num_users": 1, + "modes": "nt" + }, + { + "name": "#rednet", + "creation_time": "2023-03-19T20:36:15.000Z", + "num_users": 1, + "modes": "nt" + }, + { + "name": "#dsource", + "creation_time": "2023-03-20T11:26:52.000Z", + "num_users": 1, + "modes": "nt" + }, + { + "name": "#llcn", + "creation_time": "2023-03-20T11:26:52.000Z", + "num_users": 1, + "modes": "nt" + }, + { + "name": "#birchwood2", + "creation_time": "2023-03-20T11:26:52.000Z", + "num_users": 1, + "modes": "nt" + }, + { + "name": "#birchwoodLeave3", + "creation_time": "2023-03-20T11:26:52.000Z", + "num_users": 1, + "modes": "nt" + }, + { + "name": "#birchwoodLeave2", + "creation_time": "2023-03-20T11:26:52.000Z", + "num_users": 1, + "modes": "nt" + }, + { + "name": "#birchwoodLeave1", + "creation_time": "2023-03-20T11:26:52.000Z", + "num_users": 1, + "modes": "nt" + }, + { + "name": "#general", + "creation_time": "2023-03-19T18:19:29.000Z", + "num_users": 13, + "modes": "nt" + }, + { + "name": "#1", + "creation_time": "2023-03-20T10:41:16.000Z", + "num_users": 2, + "modes": "nt" + }, + { + "name": "#yggdrasil", + "creation_time": "2023-03-19T18:19:50.000Z", + "num_users": 10, + "modes": "nst" + }, + { + "name": "#birchwood", + "creation_time": "2023-03-20T10:41:16.000Z", + "num_users": 3, + "modes": "nt" + }, + { + "name": "#ukru", + "creation_time": "2023-03-19T19:50:36.000Z", + "num_users": 2, + "modes": "nt" + }, + { + "name": "#bnet", + "creation_time": "2023-03-19T18:19:30.000Z", + "num_users": 4, + "modes": "nt" + }, + { + "name": "#help", + "creation_time": "2023-03-20T10:41:16.000Z", + "num_users": 2, + "modes": "nt" + }, + { + "name": "#crxn", + "creation_time": "2023-03-19T18:19:30.000Z", + "num_users": 8, + "modes": "nt" + }, + { + "name": "#programming", + "creation_time": "2023-03-19T18:19:29.000Z", + "num_users": 9, + "modes": "nt" + }, + { + "name": "#networking", + "creation_time": "2023-03-19T19:50:29.000Z", + "num_users": 8, + "modes": "nt" + }, + { + "name": "#linux", + "creation_time": "2023-03-19T19:50:29.000Z", + "num_users": 4, + "modes": "nt" + }, + { + "name": "#chaox", + "creation_time": "2023-03-20T10:41:16.000Z", + "num_users": 1, + "modes": "nt" + }, + { + "name": "#fauna", + "creation_time": "2023-03-19T18:21:13.000Z", + "num_users": 3, + "modes": "nt" + }, + { + "name": "#politics", + "creation_time": "2023-03-19T18:21:13.000Z", + "num_users": 4, + "modes": "nt" + }, + { + "name": "#i2pd", + "creation_time": "2023-03-19T19:50:26.000Z", + "num_users": 8, + "modes": "nt" + }, + { + "name": "#tlang", + "creation_time": "2023-03-19T18:21:13.000Z", + "num_users": 4, + "modes": "nt" + }, + { + "name": "#dn42", + "creation_time": "2023-03-20T10:41:16.000Z", + "num_users": 1, + "modes": "nt" + }, + { + "name": "#lokinet", + "creation_time": "2023-03-19T19:50:29.000Z", + "num_users": 4, + "modes": "nt" + }, + { + "name": "#popura", + "creation_time": "2023-03-20T10:41:16.000Z", + "num_users": 1, + "modes": "nt" + }, + { + "name": "#opers", + "creation_time": "2021-02-26T19:07:17.000Z", + "num_users": 1, + "topic": "🔑️ BNET Operators | Official operators channel for BNET IRC Network | Hub migration stats: https://pad.riseup.net/p/0DW9YWWKiuwOPFgFkClM-keep", + "topic_set_by": "rany", + "topic_set_at": "2022-10-18T18:37:19.000Z", + "modes": "instHP 200:7d" + } + ] + } +}`; + +string serverList = ` +{ + "jsonrpc": "2.0", + "method": "server.list", + "id": 123, + "result": { + "list": [ + { + "name": "reddawn648.bnet", + "id": "065", + "hostname": "255.255.255.255", + "ip": null, + "details": "reddawn648.bnet", + "server": { + "info": "Reddawn's Brackenfell BNET IRC Server", + "uplink": "braveheart.bnet", + "num_users": 3, + "boot_time": "2023-03-19T20:35:18.000Z", + "synced": true, + "ulined": false, + "features": { + "software": "UnrealIRCd-6.0.7-git-56478f04a", + "protocol": 6000, + "usermodes": "diopqrstwxzBDGHIRSTWZ", + "chanmodes": [ + "beI", + "fkL", + "lH", + "cdimnprstzCDGKMNOPQRSTVZ" + ] + } + }, + "tls": { + "certfp": "b4581758e2f32eb67ffc8b1821820e4ff287b31cd6096df08f6d5ba6c9b196a8", + "cipher": "TLSv1.3-TLS_CHACHA20_POLY1305_SHA256" + } + }, + { + "name": "braveheart.bnet", + "id": "009", + "hostname": "300:7232:2b0e:d6e9:216:3eff:fe3c:c82b", + "ip": "300:7232:2b0e:d6e9:216:3eff:fe3c:c82b", + "details": "braveheart.bnet@300:7232:2b0e:d6e9:216:3eff:fe3c:c82b", + "connected_since": "2023-03-20T11:26:51.000Z", + "idle_since": "2023-03-20T11:26:51.000Z", + "server": { + "info": "Official Bri'ish Scones&Tea BNET", + "uplink": "worcester.bnet", + "num_users": 6, + "boot_time": "2023-03-19T19:50:15.000Z", + "synced": true, + "ulined": false, + "features": { + "software": "UnrealIRCd-6.0.3-git", + "protocol": 6000, + "usermodes": "diopqrstwxzBDGHIRSTWZ", + "chanmodes": [ + "beI", + "fkL", + "lH", + "cdimnprstzCDGKMNOPQRSTVZ" + ] + } + }, + "tls": { + "certfp": "0c88acd563e696fbdaffd65c8ca9a8606442db3ca3ca3900fa6d36321b8ca87f", + "cipher": "TLSv1.3-TLS_CHACHA20_POLY1305_SHA256" + } + }, + { + "name": "worcester.bnet", + "id": "011", + "hostname": "255.255.255.255", + "ip": null, + "client_port": 6667, + "details": "worcester.bnet", + "connected_since": "2023-03-20T10:41:08.000Z", + "server": { + "info": "Deavmi's Worcester Node", + "num_users": 7, + "boot_time": "2023-03-20T10:41:08.000Z", + "features": { + "software": "UnrealIRCd-6.0.7-git-e4571a5bf", + "protocol": 6000, + "usermodes": "diopqrstwxzBDGHIRSTWZ", + "chanmodes": [ + "beI", + "fkL", + "lH", + "cdimnprstzCDGKMNOPQRSTVZ" + ], + "rpc_modules": [ + { + "name": "rpc", + "version": "1.0.2" + }, + { + "name": "stats", + "version": "1.0.0" + }, + { + "name": "user", + "version": "1.0.5" + }, + { + "name": "server", + "version": "1.0.0" + }, + { + "name": "channel", + "version": "1.0.4" + }, + { + "name": "server_ban", + "version": "1.0.3" + }, + { + "name": "server_ban_exception", + "version": "1.0.1" + }, + { + "name": "name_ban", + "version": "1.0.1" + }, + { + "name": "spamfilter", + "version": "1.0.3" + } + ] + } + } + } + ] + } +} + +`; + + + +string channelInfo = ` + +{ + "jsonrpc": "2.0", + "method": "channel.get", + "id": 123, + "result": { + "channel": { + "name": "#general", + "creation_time": "2023-03-19T18:19:29.000Z", + "num_users": 15, + "modes": "nt", + "bans": [], + "ban_exemptions": [], + "invite_exceptions": [], + "members": [ + { + "name": "anontor", + "id": "009TJE7B2" + }, + { + "name": "Nikat", + "id": "065JAHE3U" + }, + { + "name": "zh", + "id": "0111ID0LO" + } + ] + } + } +} +`; + + +string stats_get = ` +{ + "jsonrpc": "2.0", + "method": "stats.get", + "id": 123, + "result": { + "server": { + "total": 4, + "ulined": 0 + }, + "user": { + "total": 18, + "ulined": 0, + "oper": 1, + "record": 22 + }, + "channel": { + "total": 31 + }, + "server_ban": { + "total": 11, + "server_ban": 3, + "spamfilter": 0, + "name_ban": 7, + "server_ban_exception": 1 + } + } +} +`; \ No newline at end of file diff --git a/source/views/banner.dt b/source/views/banner.dt new file mode 100644 index 0000000..bdb5d56 --- /dev/null +++ b/source/views/banner.dt @@ -0,0 +1,5 @@ + +img(src="/assets/open_bnet_banner.png", width=325.75, height=125) +h1(#{network.name}) + +

#{network.description}

\ No newline at end of file diff --git a/source/views/channelinfo.dt b/source/views/channelinfo.dt new file mode 100644 index 0000000..c76a7a6 --- /dev/null +++ b/source/views/channelinfo.dt @@ -0,0 +1,54 @@ +doctype html +html + include header.dt + body + center + include banner.dt + +
+

Home | Channel directory | Servers | Graphs | Raw data | API dump

+
+ +
+

Channel info: #{channelName}

+

Detailed information on the channel

+
+ + table(border="1") + tr + th() + p Channel + th + p Users + th + p Bans + th + p Ban exemptions + th + p Invite exemptions + th + p Members + + tr + td + p TODO + td + p TODO + td + ul + - foreach(string ban; channelInfo.getBans()) + li #{ban} + td + ul + - foreach(string ban; channelInfo.getBanExemptions()) + li #{ban} + td + ul + - foreach(string ban; channelInfo.getInviteExceptions()) + li #{ban} + td + ul + - import openbnet.types.channel : MemberInfo; + - foreach(MemberInfo member; channelInfo.getMembers()) + li + a(href="/user?id=#{member.getID()}") #{member} \ No newline at end of file diff --git a/source/views/channels.dt b/source/views/channels.dt new file mode 100644 index 0000000..0cdcf15 --- /dev/null +++ b/source/views/channels.dt @@ -0,0 +1,58 @@ +doctype html +html + include header.dt + body + center + include banner.dt + +
+

Home | Channel directory | Servers | Graphs | Raw data | API dump

+
+ +
+

Channels

+

A complete list of all channels available on the network.

+
+ + table(border="1") + tr + th() + p Channel + th + p Users + th + p Modes + th + p Messages + th + p Created + th + p Topic + - import openbnet.types.channel; + - foreach(Channel channel; channels) + - import std.string : count, strip; + - ulong hashNumber = count(channel.getName(), "#"); + - string channelNameWithoutHash; + - for(ulong i = 0; i < hashNumber; i++) + - channelNameWithoutHash~="%23"; + - channelNameWithoutHash ~= strip(channel.getName(), "#"); + + tr + td + a(href="/channelinfo?name=#{channelNameWithoutHash}") + p #{channel.getName()} + td + p #{channel.getUsers()} + td + p #{channel.getModes()} + td + // p #{channel.getCreationTime().toString()} + td + // - channel.getMessages()(); + p #{channel.getCreationTime().toString()} + td + p #{channel.getTopic()} + + + + \ No newline at end of file diff --git a/templates/channels.html b/source/views/channels.html similarity index 90% rename from templates/channels.html rename to source/views/channels.html index 5cd0562..7ae5ec3 100644 --- a/templates/channels.html +++ b/source/views/channels.html @@ -10,7 +10,7 @@

{{ networkName }}

{{ description }}


-

Home | Channel directory | Raw data | API dump

+

Home | Channel directory | Graphs | Raw data | API dump


diff --git a/source/views/error.dt b/source/views/error.dt new file mode 100644 index 0000000..a4a1e5d --- /dev/null +++ b/source/views/error.dt @@ -0,0 +1,28 @@ +doctype html +html + include header.dt + body + center + include banner.dt + +
+

Home | Channel directory | Servers | Graphs | Raw data | API dump

+
+ + center + div + h1 Looks like you broke something + br + h2 #{error.message} (#{error.code}) + - if(error.code == 403) + p This normally occurs if you are logged out + br + br + h3 On action + code #{request} + br + - if(!(error.exception is null)) + h3 Here's the log if it helps + pre #{error.exception} + - else + h3 No further log available \ No newline at end of file diff --git a/templates/error.html b/source/views/error.html similarity index 78% rename from templates/error.html rename to source/views/error.html index e9c4999..174b430 100644 --- a/templates/error.html +++ b/source/views/error.html @@ -9,7 +9,7 @@

{{ networkName }}

{{ description }}


-

Home | Channel directory | Raw data | API dump

+

Home | Channel directory | Graphs | Raw data | API dump


Error

diff --git a/source/views/graphs.html b/source/views/graphs.html new file mode 100644 index 0000000..b09b785 --- /dev/null +++ b/source/views/graphs.html @@ -0,0 +1,32 @@ + + + {{ networkName }} + + + +
+ +

{{ networkName }}

+

{{ description }}

+ +
+

Home | Channel directory | Graphs | Raw data | API dump

+
+ +

Channels

+ + +

Clients

+ + +

Operators

+ + +

Messages

+ +
+ + + + + diff --git a/source/views/header.dt b/source/views/header.dt new file mode 100644 index 0000000..172660e --- /dev/null +++ b/source/views/header.dt @@ -0,0 +1,4 @@ +head + title #{network.name} + link(rel="stylesheet", href="/assets/table.css") + meta(charset="utf-8") \ No newline at end of file diff --git a/source/views/home.dt b/source/views/home.dt new file mode 100644 index 0000000..b4b69b3 --- /dev/null +++ b/source/views/home.dt @@ -0,0 +1,25 @@ +doctype html +html + include header.dt + body + center + include banner.dt + +
+

Home | Channel directory | Servers | Graphs | Raw data | API dump

+
+ +
+

Home

+

General overview of the network

+
+ + p There are #{stats.getUsers()} many users online with #{stats.getServers()} many servers + p with a total of #{stats.getChannels()} channels on the network + +
+ +

Network map

+ + pre PUT A MAP HERE +
diff --git a/templates/index.html b/source/views/index.html similarity index 92% rename from templates/index.html rename to source/views/index.html index 2fc5c3a..557fad1 100644 --- a/templates/index.html +++ b/source/views/index.html @@ -10,7 +10,7 @@

{{ networkName }}

{{ description }}


-

Home | Channel directory | Raw data | API dump

+

Home | Channel directory | Graphs | Raw data | API dump


diff --git a/templates/raw.html b/source/views/raw.html similarity index 84% rename from templates/raw.html rename to source/views/raw.html index cfb0f78..94105f4 100644 --- a/templates/raw.html +++ b/source/views/raw.html @@ -9,7 +9,7 @@

{{ networkName }}

{{ description }}


-

Home | Channel directory | Raw data | API dump

+

Home | Channel directory | Graphs | Raw data | API dump


diff --git a/source/views/servers.dt b/source/views/servers.dt new file mode 100644 index 0000000..fa9225e --- /dev/null +++ b/source/views/servers.dt @@ -0,0 +1,62 @@ +doctype html +html + include header.dt + body + center + include banner.dt + +
+

Home | Channel directory | Servers | Graphs | Raw data | API dump

+
+ +
+

Servers

+

A complete list of all servers linked into the network.

+
+ + table(border="1") + tr + th() + p name + th + p sid + th + p hostname + th + p details + th + p users + th + p info + th + p uplink + th + p boot time + th + p synced/ulined status + th + p software (version) + + - import openbnet.types.server; + - foreach(Server curServer; servers) + tr + td + p #{curServer.getName()} + td + p #{curServer.getSID()} + td + p #{curServer.getHostname()} + td + p #{curServer.getDetails()} + td + p #{curServer.getUserCount()} + td + p #{curServer.getInfo()} + td + p #{curServer.getUplink()} + td + p #{curServer.getBootTime()} + td + p #{curServer.getSynced()}/#{curServer.getUlined()} + td + p #{curServer.getSoftware()} (#{curServer.getProtocol()}) \ No newline at end of file diff --git a/source/views/user.dt b/source/views/user.dt new file mode 100644 index 0000000..09f5968 --- /dev/null +++ b/source/views/user.dt @@ -0,0 +1,25 @@ +doctype html +html + include header.dt + body + center + include banner.dt + +
+

Home | Channel directory | Servers | Graphs | Raw data | API dump

+
+ +
+

#{user.getRealname()}

+

#{user.getNick()}@#{user.getVHost()}

+ + Reputation: #{user.getReputation()} +
+ Modes: #{user.getModes()} +
+ Server: #{user.getServer()} +
+ Hostname: #{user.getHostname()} +
+ Cloaked host: #{user.getCloakedHost()} +