Repository: thelounge/thelounge
Pull request #4351 (2021-11-02 20:52:49)

Allow wildcards in hostmask
According to https://modern.ircdocs.horse/#wildcard-expressions
masks should support "*" and "?" wildcards.
Within TL this only impacts the /ignore functionality.

The reasoning for doing this is to ignore say GuestNNNN!*@* with
guest*!*@* and be done with it if someone spams a gateway.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5babfce6..a6df656e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,36 +4,6 @@ All notable changes to this project will be documented in this file.
 
 <!-- New entries go after this line -->
 
-## v4.3.0-pre.6 - 2021-11-04 [Pre-release]
-
-[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.0-pre.5...v4.3.0-pre.6)
-
-This is a pre-release for v4.3.0 to offer latest changes without having to wait for a stable release.
-At this stage, features may still be added or modified until the first release candidate for this version gets released.
-
-Please refer to the commit list given above for a complete list of changes, or wait for the stable release to get a thoroughly prepared change log entry.
-
-As with all pre-releases, this version requires explicit use of the `next` tag to be installed:
-
-```sh
-yarn global add thelounge@next
-```
-
-## v4.3.0-pre.5 - 2021-11-03 [Pre-release]
-
-[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.0-pre.4...v4.3.0-pre.5)
-
-This is a pre-release for v4.3.0 to offer latest changes without having to wait for a stable release.
-At this stage, features may still be added or modified until the first release candidate for this version gets released.
-
-Please refer to the commit list given above for a complete list of changes, or wait for the stable release to get a thoroughly prepared change log entry.
-
-As with all pre-releases, this version requires explicit use of the `next` tag to be installed:
-
-```sh
-yarn global add thelounge@next
-```
-
 ## v4.3.0-pre.4 - 2021-07-01 [Pre-release]
 
 [See the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.0-pre.3...v4.3.0-pre.4)
diff --git a/client/components/Windows/SearchResults.vue b/client/components/Windows/SearchResults.vue
index 159b702c..08ce21ff 100644
--- a/client/components/Windows/SearchResults.vue
+++ b/client/components/Windows/SearchResults.vue
@@ -124,7 +124,7 @@ export default {
 				return [];
 			}
 
-			return this.search.results;
+			return this.search.results.slice().reverse();
 		},
 		chan() {
 			const chanId = parseInt(this.$route.params.id, 10);
diff --git a/client/css/style.css b/client/css/style.css
index 1ce106b6..d8bd2c2d 100644
--- a/client/css/style.css
+++ b/client/css/style.css
@@ -1713,7 +1713,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
 
 #chat .userlist .search {
 	color: var(--body-color);
-	appearance: none;
 	border: 0;
 	background: none;
 	font: inherit;
diff --git a/package.json b/package.json
index af2d8f86..93d4e3d0 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "thelounge",
   "description": "The self-hosted Web IRC client",
-  "version": "4.3.0-pre.6",
+  "version": "4.3.0-pre.4",
   "preferGlobal": true,
   "bin": {
     "thelounge": "index.js"
diff --git a/src/helper.js b/src/helper.js
index c12fde10..27352b53 100644
--- a/src/helper.js
+++ b/src/helper.js
@@ -43,6 +43,7 @@ const Helper = {
 	getDefaultNick,
 	parseHostmask,
 	compareHostmask,
+	compareWithWildcard,
 
 	password: {
 		hash: passwordHash,
@@ -314,8 +315,27 @@ function parseHostmask(hostmask) {
 
 function compareHostmask(a, b) {
 	return (
-		(a.nick.toLowerCase() === b.nick.toLowerCase() || a.nick === "*") &&
-		(a.ident.toLowerCase() === b.ident.toLowerCase() || a.ident === "*") &&
-		(a.hostname.toLowerCase() === b.hostname.toLowerCase() || a.hostname === "*")
+		compareWithWildcard(a.nick, b.nick) &&
+		compareWithWildcard(a.ident, b.ident) &&
+		compareWithWildcard(a.hostname, b.hostname)
 	);
 }
+
+function compareWithWildcard(a, b) {
+	// we allow '*' and '?' wildcards in our comparison.
+	// this is mostly aligned with https://modern.ircdocs.horse/#wildcard-expressions
+	// but we do not support the escaping. The ABNF does not seem to be clear as to
+	// how to escape the escape char '\', which is valid in a nick,
+	// whereas the wildcards tend not to be (as per RFC1459).
+
+	// The "*" wildcard is ".*" in regex, "?" is "."
+	// so we tokenize and join with the proper char back together,
+	// escaping any other regex modifier
+	const wildmany_split = a.split("*").map((sub) => {
+		const wildone_split = sub.split("?").map((p) => _.escapeRegExp(p));
+		return wildone_split.join(".");
+	});
+	const user_regex = wildmany_split.join(".*");
+	const re = new RegExp(`^${user_regex}$`, "i"); // case insensitive
+	return re.test(b);
+}
diff --git a/src/plugins/messageStorage/sqlite.js b/src/plugins/messageStorage/sqlite.js
index 981a28b5..cc8ccdf8 100644
--- a/src/plugins/messageStorage/sqlite.js
+++ b/src/plugins/messageStorage/sqlite.js
@@ -236,7 +236,7 @@ class MessageStorage {
 						target: query.channelName,
 						networkUuid: query.networkUuid,
 						offset: query.offset,
-						results: parseSearchRowsToMessages(query.offset, rows).reverse(),
+						results: parseSearchRowsToMessages(query.offset, rows),
 					};
 					resolve(response);
 				}


Test patch:
diff --git a/test/plugins/sqlite.js b/test/plugins/sqlite.js
index e886d64e..0971d00f 100644
--- a/test/plugins/sqlite.js
+++ b/test/plugins/sqlite.js
@@ -37,9 +37,10 @@ describe("SQLite Message Storage", function () {
 		fs.rmdir(path.join(Helper.getHomePath(), "logs"), done);
 	});
 
-	it("should resolve an empty array when disabled", function () {
-		return store.getMessages(null, null).then((messages) => {
+	it("should resolve an empty array when disabled", function (done) {
+		store.getMessages(null, null).then((messages) => {
 			expect(messages).to.be.empty;
+			done();
 		});
 	});
 
@@ -53,134 +54,91 @@ describe("SQLite Message Storage", function () {
 	});
 
 	it("should create tables", function (done) {
-		store.database.all(
-			"SELECT name, tbl_name, sql FROM sqlite_master WHERE type = 'table'",
-			(err, row) => {
-				expect(err).to.be.null;
-				expect(row).to.deep.equal([
-					{
-						name: "options",
-						tbl_name: "options",
-						sql:
-							"CREATE TABLE options (name TEXT, value TEXT, CONSTRAINT name_unique UNIQUE (name))",
-					},
-					{
-						name: "messages",
-						tbl_name: "messages",
-						sql:
-							"CREATE TABLE messages (network TEXT, channel TEXT, time INTEGER, type TEXT, msg TEXT)",
-					},
-				]);
-
-				done();
-			}
+		store.database.serialize(() =>
+			store.database.all(
+				"SELECT name, tbl_name, sql FROM sqlite_master WHERE type = 'table'",
+				(err, row) => {
+					expect(err).to.be.null;
+					expect(row).to.deep.equal([
+						{
+							name: "options",
+							tbl_name: "options",
+							sql:
+								"CREATE TABLE options (name TEXT, value TEXT, CONSTRAINT name_unique UNIQUE (name))",
+						},
+						{
+							name: "messages",
+							tbl_name: "messages",
+							sql:
+								"CREATE TABLE messages (network TEXT, channel TEXT, time INTEGER, type TEXT, msg TEXT)",
+						},
+					]);
+
+					done();
+				}
+			)
 		);
 	});
 
 	it("should insert schema version to options table", function (done) {
-		store.database.get(
-			"SELECT value FROM options WHERE name = 'schema_version'",
-			(err, row) => {
-				expect(err).to.be.null;
+		store.database.serialize(() =>
+			store.database.get(
+				"SELECT value FROM options WHERE name = 'schema_version'",
+				(err, row) => {
+					expect(err).to.be.null;
 
-				// Should be sqlite.currentSchemaVersion,
-				// compared as string because it's returned as such from the database
-				expect(row.value).to.equal("1520239200");
+					// Should be sqlite.currentSchemaVersion,
+					// compared as string because it's returned as such from the database
+					expect(row.value).to.equal("1520239200");
 
-				done();
-			}
-		);
-	});
-
-	it("should store a message", function () {
-		store.index(
-			{
-				uuid: "this-is-a-network-guid",
-			},
-			{
-				name: "#thisISaCHANNEL",
-			},
-			new Msg({
-				time: 123456789,
-				text: "Hello from sqlite world!",
-			})
+					done();
+				}
+			)
 		);
 	});
 
-	it("should retrieve previously stored message", function () {
-		return store
-			.getMessages(
+	it("should store a message", function (done) {
+		store.database.serialize(() => {
+			store.index(
 				{
 					uuid: "this-is-a-network-guid",
 				},
 				{
-					name: "#thisisaCHANNEL",
-				}
-			)
-			.then((messages) => {
-				expect(messages).to.have.lengthOf(1);
-
-				const msg = messages[0];
+					name: "#thisISaCHANNEL",
+				},
+				new Msg({
+					time: 123456789,
+					text: "Hello from sqlite world!",
+				})
+			);
 
-				expect(msg.text).to.equal("Hello from sqlite world!");
-				expect(msg.type).to.equal(Msg.Type.MESSAGE);
-				expect(msg.time.getTime()).to.equal(123456789);
-			});
+			done();
+		});
 	});
 
-	it("should retrieve latest LIMIT messages in order", function () {
-		const originalMaxHistory = Helper.config.maxHistory;
-
-		try {
-			Helper.config.maxHistory = 2;
-
-			for (let i = 0; i < 200; ++i) {
-				store.index(
-					{uuid: "retrieval-order-test-network"},
-					{name: "#channel"},
-					new Msg({
-						time: 123456789 + i,
-						text: `msg ${i}`,
-					})
-				);
-			}
-
-			return store
-				.getMessages({uuid: "retrieval-order-test-network"}, {name: "#channel"})
+	it("should retrieve previously stored message", function (done) {
+		store.database.serialize(() =>
+			store
+				.getMessages(
+					{
+						uuid: "this-is-a-network-guid",
+					},
+					{
+						name: "#thisisaCHANNEL",
+					}
+				)
 				.then((messages) => {
-					expect(messages).to.have.lengthOf(2);
-					expect(messages.map((i) => i.text)).to.deep.equal(["msg 198", "msg 199"]);
-				});
-		} finally {
-			Helper.config.maxHistory = originalMaxHistory;
-		}
-	});
+					expect(messages).to.have.lengthOf(1);
 
-	it("should search messages", function () {
-		const originalMaxHistory = Helper.config.maxHistory;
+					const msg = messages[0];
 
-		try {
-			Helper.config.maxHistory = 2;
+					expect(msg.text).to.equal("Hello from sqlite world!");
+					expect(msg.type).to.equal(Msg.Type.MESSAGE);
+					expect(msg.time.getTime()).to.equal(123456789);
 
-			return store
-				.search({
-					searchTerm: "msg",
-					networkUuid: "retrieval-order-test-network",
+					done();
 				})
-				.then((messages) => {
-					expect(messages.results).to.have.lengthOf(100);
-
-					const expectedMessages = [];
-
-					for (let i = 100; i < 200; ++i) {
-						expectedMessages.push(`msg ${i}`);
-					}
-
-					expect(messages.results.map((i) => i.text)).to.deep.equal(expectedMessages);
-				});
-		} finally {
-			Helper.config.maxHistory = originalMaxHistory;
-		}
+		);
 	});
 
 	it("should close database", function (done) {
diff --git a/test/tests/hostmask.js b/test/tests/hostmask.js
index 406238c2..b1e84c5e 100644
--- a/test/tests/hostmask.js
+++ b/test/tests/hostmask.js
@@ -48,7 +48,14 @@ describe("Hostmask", function () {
 
 	it(".compareHostmask (wildcard)", function () {
 		const a = Helper.parseHostmask("nick!user@host");
-		const b = Helper.parseHostmask("nick!*@*");
+		const b = Helper.parseHostmask("n?ck!*@*");
+		expect(Helper.compareHostmask(b, a)).to.be.true;
+		expect(Helper.compareHostmask(a, b)).to.be.false;
+	});
+
+	it(".compareHostmask (wildcard - partial)", function () {
+		const a = Helper.parseHostmask("nicky!user@host");
+		const b = Helper.parseHostmask("nick*!*e?@?os*");
 		expect(Helper.compareHostmask(b, a)).to.be.true;
 		expect(Helper.compareHostmask(a, b)).to.be.false;
 	});
@@ -60,3 +67,47 @@ describe("Hostmask", function () {
 		expect(Helper.compareHostmask(a, b)).to.be.true;
 	});
 });
+
+describe("compareWithWildcard", function () {
+	const goodPairs = [
+		["asdf", "asdf"],
+		["AsDf", "asdf"],
+		["a?df*", "asdf"],
+		["*asdf*", "asdf"],
+		["*asdf", "asdf"],
+		["asd?", "asdf"],
+		["asd?*", "asdf"],
+		["a??f", "asdf"],
+		["a*", "asdf"],
+		["*f", "asdf"],
+		["*s*", "asdf"],
+		["*", ""],
+		["**", ""],
+	];
+
+	for (const t of goodPairs) {
+		it(`("${t[0]}", "${t[1]}")`, function () {
+			expect(Helper.compareWithWildcard(t[0], t[1])).to.be.true;
+		});
+	}
+
+	const badPairs = [
+		["asdf", "fdsa"],
+		["a?df*", "adfg"],
+		["?", ""],
+		["?asdf", "asdf"],
+		["?*", ""],
+		["*?*", ""],
+		["*?", ""],
+		["asd", "asdf"],
+		["sdf", "asdf"],
+		["sd", "asdf"],
+		["", "asdf"],
+	];
+
+	for (const t of badPairs) {
+		it(`("${t[0]}", "${t[1]}")`, function () {
+			expect(Helper.compareWithWildcard(t[0], t[1])).to.be.false;
+		});
+	}
+});