diff --git a/web/lib/captchaLib.ssjs b/web/lib/captchaLib.ssjs
new file mode 100644
index 0000000000000000000000000000000000000000..8c39a1714a19a6d4d9702481110d29412b0bf91c
--- /dev/null
+++ b/web/lib/captchaLib.ssjs
@@ -0,0 +1,47 @@
+// ASCII Art Captcha for Synchronet 3.16+
+// echicken -at- bbs.electronicchicken.com
+// (Carried over from ecWeb v2)
+
+function insertCaptcha() {
+	var d = directory("../web/lib/captchaAnsis/*");
+	var randomFont = Math.floor(Math.random() * (d.length));
+	while(d[randomFont].match(/CVS/) != null) {
+		randomFont = Math.floor(Math.random() * (d.length));
+	}
+	var f = directory(d[randomFont] + "*.a??");
+	var captchaString = "";
+	print("<div style='background-color:black;height:100px;float:left;'>");
+	for(i = 0; i < webIni.captchaLength; i++) {
+		var randomLetter = Math.floor(Math.random() * (f.length));
+		var g = new File(f[randomLetter]);
+		g.open("r");
+		var h = g.read();
+		g.close();
+		h = html_encode(h);
+		print("<pre style='font-face:monospace;font-family:courier new,courier,fixedsys,monospace;background-color:black;float:left;padding-right:5px;'>" + h + "</pre>");
+		captchaString = captchaString + file_getname(f[randomLetter]).replace(file_getext(f[randomLetter]), "")
+	}
+	print("</div><br style=clear:both;/><br />");
+	print("<input class='border font' type=text size=" + webIni.captchaLength + " name=letters1> Enter the letters shown above (<a class=link href=./lib/captchaLib.ssjs?font=" + randomFont + " target=_blank>Help</a>)<br /><br />");
+	print("<input type=hidden name=letters2 value=" + md5_calc(captchaString.toUpperCase(), hex=true) + ">");
+}
+
+if(http_request.query.hasOwnProperty("font")) {
+	load('webInit.ssjs');
+	openPage("Captcha Help"); 
+	print("<span class=headingFont>CAPTCHA Help</span><br /><br />");
+	print("Having trouble reading the CAPTCHA? Compare what you see in the CAPTCHA box to the letters in the alphabet below.<br />(Note: this CAPTCHA uses letters, not numbers, and is not case sensitive.)<br /><br />");
+	var d = directory("../web/lib/captchaAnsis/*");
+	if(parseInt(http_request.query.font) < d.length) {
+		var f = directory(d[parseInt(http_request.query.font)] + "/*");
+		for(g in f) {
+			var h = new File(f[g]);
+			h.open("r");
+			i = h.read();
+			h.close();
+			i = html_encode(i);
+			print("<pre style='font-face:monospace;font-family:courier new,courier,fixedsys,monospace;background-color:black;height:100px;padding-right:5px;float:left;'>" + i + "</pre>");
+		}
+	}
+	closePage();
+}
diff --git a/web/lib/forum.ssjs b/web/lib/forum.ssjs
new file mode 100644
index 0000000000000000000000000000000000000000..86feae27dd475578181656cc9e4c31df9a6a7391
--- /dev/null
+++ b/web/lib/forum.ssjs
@@ -0,0 +1,198 @@
+// Forum functions for ecWeb v3
+// echicken -at- bbs.electronicchicken.com
+
+load('msgutils.js'); // Supplies getMessageThreads(sub)
+
+function getSig() {
+	var sigFile = format("%04s", user.number.toString()) + ".sig";
+	if(file_exists(system.data_dir + "user/" + sigFile)) {
+		var f = new File(system.data_dir + "user/" + sigFile);
+		f.open("r");
+		var sig = f.read();
+		f.close();
+	}
+	if(sig !== undefined)
+		return sig;
+	else
+		return false;
+}
+
+function linkify(body) {
+	// Magical URL-ify
+	urlRE=/(?:https?|ftp|telnet|ssh|gopher|rlogin|news):\/\/[^\s'"'<>()]*|[-\w.+]+@(?:[-\w]+\.)+[\w]{2,6}/gi;
+	body=body.replace(urlRE, 
+		function (str) {
+			var ret=''
+			var p=0;
+			var link=str.replace(/\.*$/, '');
+			var linktext=link;
+			if(link.indexOf('://')==-1)
+				link='mailto:'+link;
+			return('<a href="'+link+'">'+linktext+'</a>'+str.substr(linktext.length));
+		}
+	);
+	return(body);
+}
+
+function printBoards() {
+	out = "";
+	for(var g = 0; g < msg_area.grp_list.length; g++) {
+		out += "<div class='border box msg'>";
+		out += format(
+			"<a class='ulLink' onclick='toggleVisibility(\"group-%s\")'>%s</a><br />",
+			msg_area.grp_list[g].name, msg_area.grp_list[g].name
+			);
+		out += msg_area.grp_list[g].sub_list.length + " sub-boards";
+		out += "</div>";
+		out += "<div id='group-" + msg_area.grp_list[g].name + "' style='display:none;'>";
+		for(var s = 0; s < msg_area.grp_list[g].sub_list.length; s++) {
+			out += "<div class='border msg indentBox1'>";
+			out += format(
+				"<a class='ulLink' onclick='loadThreads(\"http://%s:%s/%s/forum-async.ssjs\", \"%s\")'>%s</a><br />",
+				webIni.HostName, webIni.HTTPPort, webIni.appendURL, msg_area.grp_list[g].sub_list[s].code, msg_area.grp_list[g].sub_list[s].name
+			);
+			var msgBase = new MsgBase(msg_area.grp_list[g].sub_list[s].code);
+			msgBase.open();
+			out += msgBase.total_msgs + " messages.<br />";
+			var h = msgBase.get_msg_header(msgBase.last_msg);
+			msgBase.close();
+			if(h !== null)
+				out += format("Latest: %s, by %s on %s", h.subject, h.from, system.timestr(h.when_written_time));
+			if(user.alias != webIni.WebGuest && user.compare_ars(msgBase.cfg.post_ars)) {
+				out += format(
+					"<br /><a class='ulLink' onclick='addPost(\"http://%s:%s/%s/forum-async.ssjs\", \"%s\", \"%s\", \"%s\", \"%s\")'>Post a new message</a>",
+					webIni.HostName, webIni.HTTPPort, webIni.appendURL, msg_area.grp_list[g].sub_list[s].code, user.alias, user.name
+				);
+				out += format("<div id='sub-%s-newMsgBox'></div>", msg_area.grp_list[g].sub_list[s].code);
+			}
+			out += "</div>";
+			out += format(
+				"<div id='sub-%s-info' class='border msg indentBox2' style='display:none;'></div>",
+				msg_area.grp_list[g].sub_list[s].code
+			);
+			out += format("<div id='sub-%s' style='display:none;'></div>", msg_area.grp_list[g].sub_list[s].code);
+		}
+		out += "</div>";
+	}
+	print(out);
+	return;
+}
+
+function printThreads(sub) {
+	var msgBase = new MsgBase(sub);
+	if(!msgBase.open())
+		return false;
+	msgBase.close();
+	var threads = getMessageThreads(sub);
+	var out = "";	
+	for(var t in threads.order) {
+		var header = threads.thread[threads.order[t]].messages[0];
+		out += format("<a name='thread-%s'></a>", header.number);
+		out += "<div class='border indentBox2 msg'>";
+		out += format(
+			"<a class='ulLink' onclick='loadThread(\"http://%s:%s/%s/forum-async.ssjs\", \"%s\", \"%s\")'>%s</a><br />",
+			webIni.HostName, webIni.HTTPPort, webIni.appendURL, sub, threads.order[t], header.subject
+			);
+		out += format("Started by %s on %s<br />", header.from, system.timestr(header.when_written_time));
+		if(threads.thread[threads.order[t]].messages.length > 1) {
+			out += "Latest reply from ";
+			out += threads.thread[threads.order[t]].messages[threads.thread[threads.order[t]].messages.length - 1].from;
+			out += " on ";
+			out += system.timestr(threads.thread[threads.order[t]].messages[threads.thread[threads.order[t]].messages.length - 1].when_written_time);
+		}
+		out += "</div>";
+		out += format("<div id='sub-%s-thread-%s' style='display:none;'></div>", sub, threads.order[t]);
+		out += format("<div id='sub-%s-thread-%s-info' class='border msg indentBox2' style='display:none;'></div>", sub, threads.order[t]);
+	}
+	print(out);
+}
+
+function printThread(sub, t) {
+	var msgBase = new MsgBase(sub);
+	if(!msgBase.open())
+		return false;
+	var threads = getMessageThreads(sub);
+	var out = "";
+	for(var m in threads.thread[t].messages) {
+		var header = threads.thread[t].messages[m];
+		var body = msgBase.get_msg_body(header.number, strip_ctrl_a=true);
+		if(body === null)
+			continue;
+		out += format("<a name='%s-%s'></a>", sub, header.number);
+		out += format("<div class='border indentBox3 msg' id='sub-%s-thread-%s-%s'>", sub, t, header.number);
+		out += format(
+			"From <b>%s</b> to <b>%s</b> on <b>%s</b><br /><br />",
+			header.from, header.to, system.timestr(header.when_written_time)
+		);
+		out += linkify(strip_exascii(body).replace(/\r\n/g, "<br />").replace(/\n/g, "<br />"));
+		out += "<br /><br />";
+		out += format(
+			"<a href='./index.xjs?page=002-forum.ssjs&board=%s&sub=%s&thread=%s#thread-%s'>Thread URL</a> - ",
+			msgBase.cfg.grp_name, sub, t, t
+		);
+		out += format(
+			"<a href='./index.xjs?page=002-forum.ssjs&board=%s&sub=%s&thread=%s&message=%s#%s-%s'>Message URL</a> - ",
+			msgBase.cfg.grp_name, sub, t, header.number, sub, header.number
+		);
+		out += format("<a class=ulLink onclick='toggleVisibility(\"sub-%s-thread-%s\")'>Collapse Thread</a>", sub, t);
+		if(user.alias != webIni.WebGuest && user.compare_ars(msgBase.cfg.post_ars))
+			out += format(
+				" - <a class='ulLink' onclick='addReply(\"http://%s:%s/%s/forum-async.ssjs\", \"%s\", \"%s\", \"%s\", \"%s\", \"%s\", \"%s\")'>Reply</a>",
+				webIni.HostName, webIni.HTTPPort, webIni.appendURL, sub, t, header.number, header.from, user.alias, header.subject
+		);
+		out += "</div>";
+	}
+	print(out);
+	msgBase.close();
+}
+
+function printMessage(sub, number) {
+	var msgBase = new MsgBase(sub);
+	if(!msgBase.open())
+		return false;
+	if(!user.compare_ars(msgBase.cfg.read_ars) || !user.compare_ars(msgBase.cfg.post_ars))
+		return false;
+	var ret = { "header" : msgBase.get_msg_header(number) };
+	if(ret.header === null)
+		return false;
+	ret.body = strip_exascii(msgBase.get_msg_body(number));
+	msgBase.close();
+	ret.user = { 
+		"alias" : user.alias,
+		"name" : user.name
+	};
+	var sig = getSig();
+	if(sig)
+		ret.sig = sig;
+	print(JSON.stringify(ret));
+}
+
+function postMessage(sub, irt, to, from, subject, body) {
+	for(var a = 0; a < arguments.length; a++) {
+		if(a == "irt")
+			continue;
+		if(arguments[a] === undefined || arguments[a] === null || arguments[a] == "")
+			return false;
+	}
+	if(user.alias != from && user.name != from)
+		return false
+	var msgBase = new MsgBase(sub);
+	if(!msgBase.open())
+		return false;
+	if(!user.compare_ars(msgBase.cfg.post_ars))
+		return false;
+	var header = {
+		"to" : to,
+		"from" : from,
+		"from_ext" : user.number,
+		"replyto" : from,
+		"replyto_ext" : user.number,
+		"subject" : subject
+	}
+	if(irt !== undefined && parseInt(irt) > 0)
+		header.thread_back = irt;
+	msgBase.save_msg(header, body);
+	msgBase.close();
+	print("Your message has been posted.");
+	return true;
+}
\ No newline at end of file