1 /**
2 	(module summary)
3 
4 	Copyright: © 2012-2016 RejectedSoftware e.K.
5 	License: Subject to the terms of the General Public License version 3, as written in the included LICENSE.txt file.
6 	Authors: Sönke Ludwig
7 */
8 module vibenews.controller;
9 
10 import vibenews.nntp.status;
11 import vibenews.vibenews;
12 
13 import vibe.vibe;
14 
15 import antispam.antispam;
16 import userman.db.controller;
17 
18 import std.algorithm;
19 import std.array;
20 import std.base64;
21 import std.encoding : sanitize;
22 import std.string;
23 
24 
25 class Controller {
26 	private {
27 		VibeNewsSettings m_settings;
28 		MongoCollection m_groups;
29 		MongoCollection m_groupCategories;
30 		MongoCollection m_articles;
31 		MongoCollection m_threads;
32 		UserManController m_userdb;
33 	}
34 
35 	this(VibeNewsSettings vnsettings)
36 	{
37 		m_settings = vnsettings;
38 
39 		auto settings = new UserManSettings;
40 		settings.useUserNames = false;
41 		settings.databaseURL = "mongodb://127.0.0.1:27017/"~m_settings.databaseName;
42 		settings.serviceName = m_settings.title;
43 		settings.serviceUrl = URL("http://"~m_settings.hostName~"/");
44 		settings.serviceEmail = "info@"~m_settings.hostName;
45 		settings.mailSettings = m_settings.mailSettings;
46 		settings.requireAccountValidation = m_settings.requireAccountValidation;
47 		m_userdb = createUserManController(settings);
48 
49 		auto db = connectMongoDB("127.0.0.1").getDatabase(m_settings.databaseName);
50 		m_groups = db["groups"];
51 		m_groupCategories = db["groupCategories"];
52 		m_articles = db["articles"];
53 		m_threads = db["threads"];
54 		//m_users = m_db["vibenews.users"];
55 
56 		// fixup old article format
57 		foreach (a; m_articles.find(["number": ["$exists": true]])) {
58 			GroupRef[string] grprefs;
59 			foreach (string gname, num; a["number"]) {
60 				auto grp = m_groups.findOne(["name": unescapeGroup(gname)], ["_id": true]);
61 				if( grp.isNull() ) continue;
62 
63 				// create new GroupRef instead of the simple long
64 				GroupRef grpref;
65 				grpref.articleNumber = num.get!long;
66 				grprefs[gname] = grpref;
67 			}
68 			// remove the old number field and add the group refs instead
69 			m_articles.update(["_id": a["_id"]], ["$set": ["groups": grprefs]]);
70 			m_articles.update(["_id": a["_id"]], ["$unset": ["number": true]]);
71 		}
72 
73 		// find old group auth format
74 		foreach (g; m_groups.find(["passwordHash": ["$exists": true]])){
75 			Bson[] tags;
76 			if (g["passwordHash"].length > 0) tags ~= g["name"];
77 			m_groups.update(["_id": g["_id"]], ["$set": ["readOnlyAuthTags": tags, "readWriteAuthTags": tags]]);
78 			m_groups.update(["_id": g["_id"]], ["$unset": ["username": true, "passwordHash": true]]);
79 		}
80 
81 		// create missing fields
82 		Bson[string] fields = ["caption": Bson("Group caption")];
83 		foreach( k, v; fields ){
84 			m_groups.update([k: ["$exists": false]], ["$set": [k: v]], UpdateFlags.MultiUpdate);
85 		}
86 
87 		// upgrade old peerAddress format
88 		foreach (art; m_articles.find(["$where" : "!Array.isArray(this.peerAddress)"], ["peerAddress": 1]))
89 			m_articles.update(["_id": art["_id"]], ["$set": ["peerAddress": art["peerAddress"].get!string.split(",").map!strip().array()]]);
90 
91 		// upgrade missing posterEmail field
92 		foreach (bart; m_articles.find(["posterEmail": ["$exists": false]])) () @safe {
93 			Article art;
94 			art._id = bart["_id"].get!BsonObjectID;
95 			art.headers = deserializeBson!(ArticleHeader[])(bart["headers"]);
96 			string name, email;
97 			decodeEmailAddressHeader(art.getHeader("From"), name, email);
98 			m_articles.update(["_id": art._id], ["$set": ["posterEmail": email]]);
99 		} ();
100 
101 		// fix missing Date headers
102 		foreach (bart; m_articles.find(["headers": ["$not": ["$elemMatch": ["key": "Date"]]]], ["headers": true])) {
103 			Article art;
104 			art._id = bart["_id"].get!BsonObjectID;
105 			art.headers = deserializeBson!(ArticleHeader[])(bart["headers"]);
106 			assert(!art.hasHeader("Date"));
107 			art.addHeader("Date", art._id.timeStamp.toRFC822DateTimeString());
108 			assert(art.hasHeader("Date"));
109 			m_articles.update(["_id": art._id], ["$set": ["headers": art.headers]]);
110 		}
111 
112 
113 		// create indexes
114 		import std.typecons : tuple;
115 		//m_users.ensureIndex([tuple("email", 1)], IndexFlags.Unique);
116 		m_groups.ensureIndex([tuple("name", 1)], IndexFlags.Unique);
117 		m_threads.ensureIndex([tuple("groupId", 1)]);
118 		m_threads.ensureIndex([tuple("firstArticleId", 1)]);
119 		m_threads.ensureIndex([tuple("lastArticleId", -1)]);
120 		m_articles.ensureIndex([tuple("id", 1)], IndexFlags.Unique);
121 		foreach (grp; m_groups.find(Bson.emptyObject, ["name": 1]))
122 			createGroupIndexes(grp["name"].get!string());
123 	}
124 
125 	@property VibeNewsSettings settings() { return m_settings; }
126 
127 	@property UserManController userManController() { return m_userdb; }
128 
129 	bool isEmailRegistered(string email) { return m_userdb.isEmailRegistered(email); }
130 
131 	User getUser(User.ID user_id) { return m_userdb.getUser(user_id); }
132 	User getUserByEmail(string email) { return m_userdb.getUserByEmail(email); }
133 
134 	userman.db.controller.Group getAuthGroup(userman.db.controller.Group.ID id) { return m_userdb.getGroup(id); }
135 	userman.db.controller.Group getAuthGroupByName(string name) { return m_userdb.getGroupByName(name); }
136 
137 	void enumerateUsers(int first_user, int max_count, void delegate(ref User usr) del)
138 	{
139 		m_userdb.enumerateUsers(first_user, max_count, del);
140 	}
141 
142 	long getUserCount() { return m_userdb.getUserCount(); }
143 
144 	void updateUser(User user) { m_userdb.updateUser(user); }
145 	void deleteUser(User.ID user_id) { m_userdb.deleteUser(user_id); }
146 
147 	void getUserMessageCount(string email, out ulong active_count, out ulong inactive_count)
148 	{
149 		active_count = m_articles.count(["posterEmail": Bson(email), "active": Bson(true)]);
150 		inactive_count = m_articles.count(["posterEmail": Bson(email), "active": Bson(false)]);
151 	}
152 
153 	/***************************/
154 	/* Group categories        */
155 	/***************************/
156 
157 	void enumerateGroupCategories(void delegate(size_t idx, GroupCategory) @safe del)
158 	{
159 		size_t idx = 0;
160 		foreach (bc; m_groupCategories.find()) {
161 			GroupCategory c;
162 			deserializeBson(c, bc);
163 			del(idx++, c);
164 		}
165 	}
166 
167 	GroupCategory getGroupCategory(BsonObjectID id)
168 	{
169 		auto bc = m_groupCategories.findOne(["_id": id]);
170 		enforce(!bc.isNull(), "Invalid category id");
171 		GroupCategory cat;
172 		deserializeBson(cat, bc);
173 		return cat;
174 	}
175 
176 	BsonObjectID createGroupCategory(string caption, int index)
177 	{
178 		GroupCategory cat;
179 		cat._id = BsonObjectID.generate();
180 		cat.caption = caption;
181 		cat.index = index;
182 		m_groupCategories.insert(cat);
183 		return cat._id;
184 	}
185 
186 	void updateGroupCategory(BsonObjectID category, string caption, int index, BsonObjectID[] groups)
187 	{
188 		GroupCategory cat;
189 		cat._id = category;
190 		cat.caption = caption;
191 		cat.index = index;
192 		cat.groups = groups;
193 		m_groupCategories.update(["_id": category], cat);
194 	}
195 
196 	void deleteGroupCategory(BsonObjectID id)
197 	{
198 		m_groupCategories.remove(["_id": id]);
199 	}
200 
201 	/***************************/
202 	/* Groups                  */
203 	/***************************/
204 
205 	void enumerateGroups(void delegate(size_t idx, Group) @safe cb, bool allow_inactive = false)
206 	{
207 		Group group;
208 		size_t idx = 0;
209 		foreach (bg; m_groups.find()) {
210 			if( !allow_inactive && !bg["active"].get!bool )
211 				continue;
212 			deserializeBson(group, bg);
213 			cb(idx++, group);
214 		}
215 	}
216 
217 	void enumerateNewGroups(SysTime date, void delegate(size_t idx, Group) @safe del, bool allow_inactive = false)
218 	{
219 		Group group;
220 		Bson idmatch = Bson(BsonObjectID.createDateID(date));
221 		size_t idx = 0;
222 		foreach (bg; m_groups.find(["_id": Bson(["$gte": idmatch])])) {
223 			if( !allow_inactive && !bg["active"].get!bool )
224 				continue;
225 			deserializeBson(group, bg);
226 			del(idx++, group);
227 		}
228 	}
229 
230 	bool groupExists(string name, bool allow_inactive = false)
231 	{
232 		auto bg = m_groups.findOne(["name": Bson(name)], ["active": 1]);
233 		return !bg.isNull() && (allow_inactive || bg["active"].get!bool);
234 	}
235 
236 	Group getGroup(BsonObjectID id, bool allow_inactive = false)
237 	{
238 		auto bg = m_groups.findOne(["_id": Bson(id)]);
239 		enforce(!bg.isNull() && (allow_inactive || bg["active"].get!bool), "Unknown group id!");
240 		Group ret;
241 		deserializeBson(ret, bg);
242 		return ret;
243 	}
244 
245 	Group getGroupByName(string name, bool allow_inactive = false)
246 	{
247 		auto bg = m_groups.findOne(["name": Bson(name)]);
248 		enforce(!bg.isNull() && (allow_inactive || bg["active"].get!bool), "Group "~name~" not found!");
249 		Group ret;
250 		deserializeBson(ret, bg);
251 		return ret;
252 	}
253 
254 	void addGroup(Group g)
255 	{
256 		m_groups.insert(g);
257 		createGroupIndexes(g.name);
258 	}
259 
260 	void updateGroup(Group g)
261 	{
262 		m_groups.update(["_id": g._id], g);
263 	}
264 
265 	void createGroupIndexes()(string grpname)
266 	{
267 		import std.typecons : tuple;
268 
269 		string egrp = escapeGroup(grpname);
270 		string grpfield = "groups."~egrp;
271 		m_articles.ensureIndex([tuple(grpfield~".articleNumber", 1)], IndexFlags.Sparse);
272 		m_articles.ensureIndex([tuple(grpfield~".threadId", 1)], IndexFlags.Sparse);
273 	}
274 
275 	/***************************/
276 	/* Threads                 */
277 	/***************************/
278 
279 	long getThreadCount(BsonObjectID group)
280 	{
281 		return m_threads.count(["groupId": Bson(group), "firstArticleId": serializeToBson(["$ne": BsonObjectID()])]);
282 	}
283 
284 	Thread getThread(BsonObjectID id)
285 	{
286 		auto bt = m_threads.findOne(["_id": id]);
287 		enforce(!bt.isNull(), "Unknown thread id");
288 		Thread t;
289 		deserializeBson(t, bt);
290 		return t;
291 	}
292 
293 	Thread getThreadForFirstArticle(string groupname, long articlenum)
294 	{
295 		auto art = m_articles.findOne(["groups."~escapeGroup(groupname)~".articleNumber": articlenum], ["_id": 1]);
296 		enforce(!art.isNull(), "Invalid article group/number");
297 		auto bt = m_threads.findOne(["firstArticleId": art["_id"]]);
298 		enforce(!bt.isNull(), "Article is not the first of any thread.");
299 		Thread t;
300 		deserializeBson(t, bt);
301 		return t;
302 	}
303 
304 	void enumerateThreads(BsonObjectID group, size_t skip, size_t max_count, void delegate(size_t, Thread) @safe del)
305 	{
306 		assert(skip <= int.max);
307 		size_t idx = skip;
308 		foreach( bthr; m_threads.find(["groupId": Bson(group), "firstArticleId": serializeToBson(["$ne": BsonObjectID()])], null, QueryFlags.None, cast(int)skip).sort(["lastArticleId": Bson(-1)]) ){
309 			Thread thr;
310 			deserializeBson(thr, bthr);
311 			del(idx, thr);
312 			if( ++idx >= skip+max_count ) break;
313 		}
314 	}
315 
316 	long getThreadPostCount(BsonObjectID thread, string groupname = null)
317 	{
318 		if( !groupname ) groupname = getGroup(getThread(thread).groupId).name;
319 		return m_articles.count(["groups."~escapeGroup(groupname)~".threadId" : Bson(thread), "active": Bson(true)]);
320 	}
321 
322 	void enumerateThreadPosts(BsonObjectID thread, string groupname, size_t skip, size_t max_count, void delegate(size_t, Article) @safe del)
323 	{
324 		assert(skip <= int.max);
325 		size_t idx = skip;
326 		foreach (bart; m_articles.find(["groups."~escapeGroup(groupname)~".threadId": Bson(thread), "active": Bson(true)], null, QueryFlags.None, cast(int)skip, cast(int)max_count).sort(["_id": Bson(1)])) {
327 			Article art;
328 			deserializeBson(art, bart);
329 			del(idx, art);
330 			if( ++idx >= skip+max_count ) break;
331 		}
332 	}
333 
334 	long getThreadArticleIndex(BsonObjectID thread_id, long article_number, string group_name = null)
335 	{
336 		if( group_name.length == 0 ){
337 			auto thr = m_threads.findOne(["_id": thread_id], ["groupId": true]);
338 			enforce(!thr.isNull());
339 			auto grp = m_groups.findOne(["_id": thr["groupId"]], ["name": true]);
340 			enforce(!grp.isNull());
341 			
342 			group_name = grp["name"].get!string;
343 		}
344 
345 		Bson[string] query;
346 		query["groups."~escapeGroup(group_name)~".threadId"] = Bson(thread_id);
347 		query["groups."~escapeGroup(group_name)~".articleNumber"] = serializeToBson(["$lt": article_number]);
348 		query["active"] = Bson(true);
349 
350 		return m_articles.count(query);
351 	}
352 
353 	/***************************/
354 	/* Articles                */
355 	/***************************/
356 
357 	Article getArticle(BsonObjectID id)
358 	{
359 		auto ba = m_articles.findOne(["_id": Bson(id), "active": Bson(true)]);
360 		enforce(!ba.isNull(), "Unknown article id!");
361 		Article ret;
362 		deserializeBson(ret, ba);
363 		return ret;
364 	}
365 
366 	Article getArticle(string id)
367 	{
368 		auto ba = m_articles.findOne(["id": Bson(id), "active": Bson(true)]);
369 		enforce(!ba.isNull(), "Article "~id~" not found!");
370 		Article ret;
371 		deserializeBson(ret, ba);
372 		return ret;
373 	}
374 
375 	Article getArticle(string groupname, long number, bool msgbdy = true)
376 	{
377 		auto egrp = escapeGroup(groupname);
378 		auto nummatch = Bson(number);
379 		auto ba = m_articles.findOne(["groups."~egrp~".articleNumber": nummatch, "active": Bson(true)], msgbdy ? null : ["message": 0]);
380 		enforce(!ba.isNull(), "Article "~to!string(number)~" not found for group "~groupname~"!");
381 		if( !msgbdy ) ba["message"] = Bson(BsonBinData());
382 		Article ret;
383 		deserializeBson(ret, ba);
384 		return ret;
385 	}
386 
387 	GroupRef[string] getArticleGroupRefs(BsonObjectID id)
388 	{
389 		auto art = m_articles.findOne(["_id": id], ["groups": 1]);
390 		enforce(!art.isNull(), "Unknown article id!");
391 		GroupRef[string] ret;
392 		deserializeBson(ret, art["groups"]);
393 		return ret;
394 	}
395 
396 	GroupRef[string] getArticleGroupRefs(string group_name, long article_number)
397 	{
398 		auto art = m_articles.findOne(["groups."~escapeGroup(group_name)~".articleNumber": article_number], ["groups": 1]);
399 		enforce(!art.isNull(), "Unknown article id!");
400 		GroupRef[string] ret;
401 		deserializeBson(ret, art["groups"]);
402 		return ret;
403 	}
404 
405 	void enumerateArticles(string groupname, void delegate(size_t idx, BsonObjectID _id, string msgid, long msgnum) @safe del)
406 	{
407 		auto egrp = escapeGroup(groupname);
408 		auto numkey = "groups."~egrp~".articleNumber";
409 		auto numquery = serializeToBson(["$exists": true]);
410 		size_t idx = 0;
411 		foreach (ba; m_articles.find([numkey: numquery, "active": Bson(true)], ["_id": 1, "id": 1, "groups": 1]).sort([numkey: 1])) {
412 			del(idx++, ba["_id"].get!BsonObjectID, ba["id"].get!string, ba["groups"][escapeGroup(groupname)]["articleNumber"].get!long);
413 		}
414 	}
415 
416 	void enumerateArticles(string groupname, long from, long to, void delegate(size_t idx, Article art) @safe del)
417 	{
418 		Article art;
419 		string gpne = escapeGroup(groupname);
420 		auto numkey = "groups."~gpne~".articleNumber";
421 		auto numquery = serializeToBson(["$gte": from, "$lte": to]);
422 		size_t idx = 0;
423 		foreach (ba; m_articles.find([numkey: numquery, "active": Bson(true)], ["message": 0]).sort([numkey: 1])) {
424 			ba["message"] = Bson(BsonBinData(BsonBinData.Type.Generic, null));
425 			if( ba["groups"][gpne]["articleNumber"].get!long > to )
426 				break;
427 			deserializeBson(art, ba);
428 			del(idx++, art);
429 		}
430 	}
431 
432 	void enumerateNewArticles(string groupname, SysTime date, void delegate(size_t idx, BsonObjectID _id, string msgid, long msgnum) @safe del)
433 	{
434 		Bson idmatch = Bson(BsonObjectID.createDateID(date));
435 		Bson groupmatch = Bson(true);
436 		auto egrp = escapeGroup(groupname);
437 		auto numkey = "groups."~egrp~".articleNumber";
438 		auto query = serializeToBson(["_id" : Bson(["$gte": idmatch]), numkey: Bson(["$exists": groupmatch]), "active": Bson(true)]);
439 		size_t idx = 0;
440 		foreach (ba; m_articles.find(query, ["_id": 1, "id": 1, "groups": 1]).sort([numkey: 1])) {
441 			del(idx++, ba["_id"].get!BsonObjectID, ba["id"].get!string, ba["groups"][egrp]["articleNumber"].get!long);
442 		}
443 	}
444 
445 	void enumerateAllArticlesBackwards(string groupname, int first, int count, void delegate(ref Article art) @safe del)
446 	{
447 		auto egrp = escapeGroup(groupname);
448 		auto numkey = "groups."~egrp~".articleNumber";
449 		logDebug("%s %s", groupname, egrp);
450 		size_t idx = 0;
451 		foreach (ba; m_articles.find([numkey: ["$exists": true]], null, QueryFlags.None, first, count).sort([numkey: -1])) {
452 			Article art;
453 			deserializeBson(art, ba);
454 			del(art);
455 			if (idx++ == count-1) break;
456 		}
457 	}
458 
459 	ulong getAllArticlesCount(string groupname)
460 	{
461 		return m_articles.count(["groups."~escapeGroup(groupname)~".articleNumber": ["$exists": true]]);
462 	}
463 
464 	void postArticle(ref Article art, User.ID user_id)
465 	{
466 		AntispamMessage msg = toAntispamMessage(art);
467 		bool revoke = false;
468 		outer:
469 		foreach( flt; m_settings.spamFilters ) {
470 			auto status = flt.determineImmediateSpamStatus(msg);
471 			final switch (status) {
472 				case SpamAction.amnesty: revoke = false; break outer;
473 				case SpamAction.pass: break;
474 				case SpamAction.revoke: revoke = true; break;
475 				case SpamAction.block: throw new Exception("Article is deemed to be abusive. Rejected.");
476 			}
477 		}
478 
479 
480 		string relay_version = art.getHeader("Relay-Version");
481 		string posting_version = art.getHeader("Posting-Version");
482 		string from = art.getHeader("From");
483 		string from_name, from_email;
484 		decodeEmailAddressHeader(from, from_name, from_email);
485 		string date = art.getHeader("Date");
486 		string[] newsgroups = commaSplit(art.getHeader("Newsgroups"));
487 		string subject = art.subject;
488 		string messageid = art.getHeader("Message-ID");
489 		string path = art.getHeader("Path");
490 		string reply_to = art.getHeader("In-Reply-To");
491 		if( reply_to.length == 0 ){
492 			auto refs = art.getHeader("References").split(" ");
493 			if( refs.length > 0 ) reply_to = refs[$-1];
494 		}
495 
496 		if (messageid.length) art.id = messageid;
497 		else art.addHeader("Message-ID", art.id);
498 		if (!date.length) art.addHeader("Date", Clock.currTime(UTC()).toRFC822DateTimeString());
499 		assert(art.hasHeader("Date"));
500 		art.messageLength = art.message.length;
501 		art.messageLines = countLines(art.message);
502 
503 		enforce(art.message.length > 0, "You must enter a message.");
504 
505 		// validate sender
506 		if (user_id == User.ID.init) {
507 			enforce(!isEmailRegistered(from_email), new NNTPStatusException(NNTPStatus.articleRejected, "Need to log in to send from a registered email address."));
508 		} else {
509 			User usr;
510 			User lusr = m_userdb.getUser(user_id);
511 			try usr = m_userdb.getUserByEmail(from_email);
512 			catch (Exception) {}
513 			enforce(usr.id == user_id, new NNTPStatusException(NNTPStatus.articleRejected, "Not allowed to post with a foreign email address, please use "~lusr.email~"."));
514 		}
515 
516 		// validate groups
517 		foreach( grp; newsgroups ){
518 			auto bgpre = m_groups.findOne(["name": grp]);
519 			enforce(!bgpre.isNull(), new NNTPStatusException(NNTPStatus.articleRejected, "Invalid group: "~grp));
520 			enforce(isAuthorizedForWritingGroup(user_id, grp), new NNTPStatusException(NNTPStatus.articleRejected, "Not allowed to post in "~grp));
521 		}
522 
523 		foreach( grp; newsgroups ){
524 			auto bgpre = m_groups.findAndModify(["name": grp], ["$inc": ["articleNumberCounter": 1]], ["articleNumberCounter": 1]);
525 			if( bgpre.isNull() ) continue; // ignore non-existant groups
526 			m_groups.update(["name": grp], ["$inc": ["articleCount": 1]]);
527 			logDebug("GRP: %s", bgpre.toJson());
528 
529 			// try to find the thread of any reply-to message
530 			BsonObjectID threadid;
531 			auto rart = reply_to.length ? m_articles.findOne(["id": reply_to]) : Bson(null);
532 			if( !rart.isNull() && !rart["groups"].isNull() ){
533 				auto gref = rart["groups"][escapeGroup(grp)];
534 				if( !gref.isNull() ) threadid = gref["threadId"].get!BsonObjectID;
535 			}
536 
537 			// create a new thread if necessary
538 			if( threadid == BsonObjectID() ){
539 				Thread thr;
540 				thr._id = BsonObjectID.generate();
541 				thr.groupId = bgpre["_id"].get!BsonObjectID;
542 				thr.subject = subject;
543 				thr.firstArticleId = art._id;
544 				thr.lastArticleId = art._id;
545 				m_threads.insert(thr);
546 				threadid = thr._id;
547 			} else {
548 				m_threads.update(["_id": threadid], ["$set": ["lastArticleId": art._id]]);
549 			}
550 
551 			GroupRef grpref;
552 			grpref.articleNumber = bgpre["articleNumberCounter"].get!long + 1;
553 			grpref.threadId = threadid;
554 			art.groups[escapeGroup(grp)] = grpref;
555 			m_groups.update(["name": Bson(grp), "maxArticleNumber": serializeToBson(["$lt": grpref.articleNumber])], ["$set": ["maxArticleNumber": grpref.articleNumber]]);
556 		}
557 
558 		m_articles.insert(art);
559 
560 		markAsSpam(art._id, revoke);
561 
562 		runTask({
563 			foreach (flt; m_settings.spamFilters) {
564 				auto status = flt.determineAsyncSpamStatus(msg);
565 				final switch (status) {
566 					case SpamAction.amnesty: markAsSpam(art._id, false); return;
567 					case SpamAction.pass: break;
568 					case SpamAction.revoke: revoke = true; break;
569 					case SpamAction.block: markAsSpam(art._id, true); return;
570 				}
571 				if (status == SpamAction.amnesty) break;
572 				else if (status != SpamAction.pass) {
573 					return;
574 				}
575 			}
576 			markAsSpam(art._id, revoke);
577 		});
578 	}
579 
580 	void deactivateArticle(BsonObjectID artid)
581 	{
582 		auto oldart = m_articles.findAndModify(["_id": artid], ["$set": ["active": false]]);
583 		if( !oldart["active"].get!bool ) return; // was already deactivated
584 
585 		// update the group counters
586 		foreach (string gname, grp; oldart["groups"]) {
587 			// update the group
588 			string numfield = "groups."~gname~".articleNumber";
589 			auto groupname = Bson(unescapeGroup(gname));
590 			auto articlequery = Bson([numfield: Bson(["$exists": Bson(true)]), "active": Bson(true)]);
591 			m_groups.update(["name": groupname], ["$inc": ["articleCount": -1]]);
592 			auto g = m_groups.findOne(["name": groupname]);
593 			auto num = grp["articleNumber"];
594 			if( g["minArticleNumber"] == num ){
595 				auto minorder = serializeToBson([numfield: 1]);
596 				auto minart = m_articles.findOne(Bson(["query": articlequery, "orderby": minorder]));
597 				long newnum;
598 				if (minart.isNull()) newnum = long.max;
599 				else newnum = minart["groups"][gname]["articleNumber"].get!long;
600 				m_groups.update(["name": groupname, "minArticleNumber": num], ["$set": ["minArticleNumber": newnum]]);
601 			}
602 			if( g["maxArticleNumber"] == num ){
603 				auto maxorder = serializeToBson([numfield: -1]);
604 				auto maxart = m_articles.findOne(Bson(["query": articlequery, "orderby": maxorder]));
605 				long newnum;
606 				if (!maxart.isNull()) newnum = maxart["groups"][gname]["articleNumber"].get!long;
607 				else newnum = 0;
608 				m_groups.update(["name": groupname, "maxArticleNumber": num], ["$set": ["maxArticleNumber": newnum]]);
609 			}
610 
611 			// update the matching thread
612 			auto threadid = grp["threadId"];
613 			auto newfirstart = m_articles.findOne(serializeToBson(["query": ["groups."~gname~".threadId": threadid, "active": Bson(true)], "orderby": ["_id": Bson(1)]]), ["_id": true]);
614 			auto newfirstid = newfirstart.isNull() ? BsonObjectID() : newfirstart["_id"].get!BsonObjectID;
615 			m_threads.update(["_id": threadid, "firstArticleId": oldart["_id"]], ["$set": ["firstArticleId": newfirstid]]);
616 			auto newlastart = m_articles.findOne(serializeToBson(["query": ["groups."~gname~".threadId": threadid, "active": Bson(true)], "orderby": ["_id": Bson(-1)]]), ["_id": true]);
617 			auto newlastid = newfirstart.isNull() ? BsonObjectID() : newlastart["_id"].get!BsonObjectID;
618 			m_threads.update(["_id": threadid, "lastArticleId": oldart["_id"]], ["$set": ["lastArticleId": newlastid]]);
619 		}
620 	}
621 
622 	void activateArticle(BsonObjectID artid)
623 	{
624 		auto oldart = m_articles.findAndModify(["_id": artid], ["$set": ["active": true]]);
625 		if (oldart["active"].get!bool) return; // was already activated by someone else
626 
627 		// update the group counters
628 		foreach (string gname, gref; oldart["groups"]) {
629 			auto num = gref["articleNumber"];
630 			auto threadid = gref["threadId"];
631 			string numfield = "groups."~gname~".articleNumber";
632 			auto groupname = Bson(unescapeGroup(gname));
633 			m_groups.update(["name": groupname], ["$inc": ["articleCount": 1]]);
634 			m_groups.update(["name": groupname, "maxArticleNumber": Bson(["$lt": num])], ["$set": ["maxArticleNumber": num]]);
635 			m_groups.update(["name": groupname, "minArticleNumber": Bson(["$gt": num])], ["$set": ["minArticleNumber": num]]);
636 
637 			auto first_matches = serializeToBson([["firstArticleId": Bson(["$gt": oldart["_id"]])], ["firstArticleId": Bson(BsonObjectID())]]);
638 			m_threads.update(["_id": threadid, "$or": first_matches], ["$set": ["firstArticleId": oldart["_id"]]]);
639 			m_threads.update(["_id": threadid, "lastArticleId": Bson(["$lt": oldart["_id"]])], ["$set": ["lastArticleId": oldart["_id"]]]);
640 		}
641 	}
642 
643 	void deleteArticle(BsonObjectID artid)
644 	{
645 		deactivateArticle(artid);
646 		m_articles.remove(["_id": artid]);
647 	}
648 
649 	void reclassifySpam()
650 	{
651 		foreach (flt; m_settings.spamFilters)
652 			flt.resetClassification();
653 		
654 		foreach (bart; m_articles.find()) {
655 			auto art = deserializeBson!Article(bart);
656 			foreach (flt; m_settings.spamFilters) {
657 				auto msg = toAntispamMessage(art);
658 				if (art.hasHeader("X-Spam-Status")) {
659 					flt.classify(msg, art.getHeader("X-Spam-Status").icmp("yes") == 0);
660 				} else if (art.active) flt.classify(msg, false);
661 			}
662 		}
663 	}
664 
665 	void markAsSpam(BsonObjectID article, bool is_spam)
666 	{
667 		if (is_spam) deactivateArticle(article);
668 		else activateArticle(article);
669 
670 		auto art = deserializeBson!Article(m_articles.findOne(["_id": article]));
671 
672 		auto msg = toAntispamMessage(art);
673 		bool was_spam = false;
674 		if (art.hasHeader("X-Spam-Status")) {
675 			was_spam = art.getHeader("X-Spam-Status").icmp("yes") == 0;
676 			if (was_spam == is_spam) return;
677 			foreach (flt; m_settings.spamFilters)
678 				flt.classify(msg, was_spam, true);
679 		}
680 		foreach (flt; m_settings.spamFilters)
681 			flt.classify(msg, is_spam, false);
682 
683 		art.setHeader("X-Spam-Status", is_spam ? "Yes" : "No");
684 		m_articles.update(["_id": article], ["$set": ["headers": art.headers]]);
685 	}
686 
687 	// deletes all inactive articles from the group
688 	void purgeGroup(string name)
689 	{
690 		m_articles.remove(["active": Bson(false), "groups."~escapeGroup(name)~".articleNumber": Bson(["$exists": Bson(true)])]);
691 	}
692 
693 	bool isAuthorizedForReadingGroup(User.ID user, string groupname)
694 	{
695 		import std.range : chain;
696 		auto grp = m_groups.findOne(["name": groupname], ["readOnlyAuthTags": 1, "readWriteAuthTags": 1]);
697 		if (grp.isNull()) return false;
698 		if (grp["readOnlyAuthTags"].length == 0) return true;
699 		enforce(user != User.ID.init, "Group does not allow public access.");
700 		auto usr = m_userdb.getUser(user);
701 		foreach (ag; chain(grp["readOnlyAuthTags"].get!(Bson[]), grp["readWriteAuthTags"].get!(Bson[]))) {
702 			auto agid = () @trusted { return getAuthGroupByName(ag.get!string).id; } ();
703 			foreach (gid; usr.groups)
704 				if (gid == agid)
705 					return true;
706 		}
707 		return false;
708 	}
709 
710 	bool isAuthorizedForWritingGroup(User.ID user, string groupname)
711 	{
712 		auto grp = m_groups.findOne(["name": groupname], ["readOnlyAuthTags": 1, "readWriteAuthTags": 1]);
713 		if (grp.isNull()) return false;
714 		if (grp["readOnlyAuthTags"].length == 0 && grp["readWriteAuthTags"].length == 0) return true;
715 		enforce(user != User.ID.init, "Group does not allow public access.");
716 		auto usr = m_userdb.getUser(user);
717 		foreach (ag; grp["readWriteAuthTags"]) {
718 			auto agid = () @trusted { return getAuthGroupByName(ag.get!string).id; } ();
719 			foreach (gid; usr.groups)
720 				if (gid == agid)
721 					return true;
722 		}
723 		return false;
724 	}
725 
726 	/***************************/
727 	/* DB Repair               */
728 	/***************************/
729 
730 	void repairGroupNumbers()
731 	{
732 		foreach (grp; m_groups.find()) {
733 			logInfo("Repairing group numbers of %s:", grp["name"].get!string);
734 			auto grpname = escapeGroup(grp["name"].get!string);
735 			auto numbername = "groups."~grpname~".articleNumber";
736 
737 			auto artquery = serializeToBson([numbername: Bson(["$exists": Bson(true)]), "active": Bson(true)]);
738 			auto artcnt = m_articles.count(artquery);
739 			logInfo("  article count: %s", artcnt);
740 			m_groups.update(["_id": grp["_id"], "articleCount": grp["articleCount"]], ["$set": ["articleCount": artcnt]]);
741 
742 			auto first_art = m_articles.findOne(Bson(["$query": artquery, "$orderby": serializeToBson([numbername: 1])]), ["groups": 1]);
743 			auto last_art = m_articles.findOne(Bson(["$query": artquery, "$orderby": serializeToBson([numbername: -1])]), ["groups": 1]);
744 
745 			auto first_art_num = first_art.isNull() ? 1 : first_art["groups"][grpname]["articleNumber"].get!long;
746 			auto last_art_num = last_art.isNull() ? 0 : last_art["groups"][grpname]["articleNumber"].get!long;
747 			assert(first_art.isNull() == last_art.isNull());
748 
749 			logInfo("  first article: %s", first_art_num);
750 			logInfo("  last article: %s", last_art_num);
751 
752 			m_groups.update(["_id": grp["_id"], "minArticleNumber": grp["minArticleNumber"]], ["$set": ["minArticleNumber": first_art_num]]);
753 			m_groups.update(["_id": grp["_id"], "maxArticleNumber": grp["maxArticleNumber"]], ["$set": ["maxArticleNumber": last_art_num]]);
754 		}
755 
756 		logInfo("Repair of group numbers finished.");
757 	}
758 
759 	void repairThreads()
760 	{
761 		m_threads.remove(Bson.emptyObject);
762 
763 		foreach (ba; m_articles.find(["active": Bson(true)]).sort(["_id": Bson(1)])) () @safe {
764 			Article a;
765 			deserializeBson(a, ba);
766 
767 			// extract reply-to and subject headers
768 			string repl = a.getHeader("In-Reply-To");
769 			string subject = a.subject;
770 			if( repl.length == 0 ){
771 				auto refs = a.getHeader("References").split(" ");
772 				if( refs.length > 0 ) repl = refs[$-1];
773 			}
774 			auto rart = repl.length ? m_articles.findOne(["id": repl]) : Bson(null);
775 
776 			foreach (gname; trustedRange(() @system => a.groups.byKey())) ()@safe{
777 				auto grp = m_groups.findOne(["name": unescapeGroup(gname)], ["_id": true]);
778 				//if( grp.isNull() ) continue;
779 
780 				BsonObjectID threadid;
781 
782 				// try to find the thread of any reply-to message
783 				if( !rart.isNull() ){
784 					auto gref = rart["groups"][gname];
785 					if( !gref.isNull() && m_threads.count(["_id": gref["threadId"]]) > 0 )
786 						threadid = gref["threadId"].get!BsonObjectID;
787 				}
788 
789 				// otherwise create a new thread
790 				if( threadid == BsonObjectID() ){
791 					Thread thr;
792 					thr._id = BsonObjectID.generate();
793 					thr.groupId = grp["_id"].get!BsonObjectID;
794 					thr.subject = subject;
795 					thr.firstArticleId = a._id;
796 					thr.lastArticleId = a._id;
797 					m_threads.insert(thr);
798 
799 					threadid = thr._id;
800 				} else {
801 					m_threads.update(["_id": threadid], ["$set": ["lastArticleId": a._id]]);
802 				}
803 
804 				m_articles.update(["_id": a._id], ["$set": ["groups."~gname~".threadId": threadid]]);
805 			}();
806 		}();
807 	}
808 
809 }
810 
811 AntispamMessage toAntispamMessage(in ref Article art)
812 @safe {
813 	AntispamMessage msg;
814 	foreach (hdr; art.headers) msg.headers[hdr.key] = hdr.value;
815 	msg.message = art.message;
816 	msg.peerAddress = art.peerAddress;
817 	return msg;
818 }
819 
820 
821 string escapeGroup(string str)
822 @safe {
823 	return str.translate(['.': '#'], null);
824 }
825 
826 string unescapeGroup(string str)
827 @safe {
828 	return str.translate(['#': '.'], null);
829 }
830 
831 string[] commaSplit(string str)
832 @safe {
833 	string[] ret;
834 	while(true){
835 		auto idx = str.countUntil(',');
836 		if( idx > 0 ){
837 			ret ~= strip(str[0 .. idx]);
838 			str = str[idx+1 .. $];
839 		} else {
840 			ret ~= strip(str);
841 			break;
842 		}
843 	}
844 	return ret;
845 }
846 
847 long countLines(const(ubyte)[] str)
848 @safe {
849 	long sum = 1;
850 	while(str.length > 0){
851 		auto idx = str.countUntil('\n');
852 		if( idx < 0 ) break;
853 		str = str[idx+1 .. $];
854 		sum++;
855 	}
856 	return sum;
857 }
858 
859 
860 struct Article {
861 	BsonObjectID _id;
862 	string id; // "<asdasdasd@server.com>"
863 	bool active = true;
864 	string posterEmail;
865 	GroupRef[string] groups; // num[groupname]
866 	ArticleHeader[] headers;
867 	ubyte[] message;
868 	long messageLength;
869 	long messageLines;
870 	string[] peerAddress; // list of hops starting from the original client
871 
872 	@safe:
873 
874 	@property string subject() const @trusted { return sanitize(decodeEncodedWords(getHeader("Subject"))); }
875 
876 	string getHeader(string name)
877 	const {
878 		foreach( h; headers )
879 			if( icmp(h.key, name) == 0 )
880 				return h.value;
881 		return null;
882 	}
883 
884 	bool hasHeader(string name)
885 	const {
886 		foreach( h; headers )
887 			if( icmp(h.key, name) == 0 )
888 				return true;
889 		return false;
890 	}
891 
892 	void addHeader(string name, string value)
893 	{
894 		assert(!hasHeader(name));
895 		headers ~= ArticleHeader(encode(name), encode(value));
896 	}
897 
898 	void setHeader(string name, string value)
899 	{
900 		foreach (ref h; headers)
901 			if (icmp(h.key, name) == 0) {
902 				h.value = encode(value);
903 				return;
904 			}
905 		addHeader(name, value);
906 	}
907 
908 	static string encode(string str)
909 	{
910 		size_t first_non_ascii = size_t.max, last_non_ascii = 0;
911 		foreach( i; 0 .. str.length )
912 			if( (str[i] & 0x80) ){
913 				if( first_non_ascii == size_t.max )
914 					first_non_ascii = i;
915 				last_non_ascii = i;
916 			}
917 		if( last_non_ascii < first_non_ascii ) return str;
918 
919 		auto non_ascii = str[first_non_ascii .. last_non_ascii+1];
920 
921 		return format("%s=?UTF-8?B?%s?=%s", str[0 .. first_non_ascii],
922 			cast(const(char)[])Base64.encode(cast(const(ubyte)[])non_ascii),
923 			str[last_non_ascii+1 .. $]);
924 	}
925 }
926 
927 struct GroupRef {
928 	long articleNumber;
929 	BsonObjectID threadId;
930 }
931 
932 struct ArticleHeader {
933 	string key;
934 	string value;
935 }
936 
937 struct GroupCategory {
938 	BsonObjectID _id;
939 	string caption;
940 	int index;
941 	BsonObjectID[] groups;
942 }
943 
944 struct Group {
945 	BsonObjectID _id;
946 	bool active = true;
947 	string name;
948 	string caption;
949 	string description;
950 	long articleCount = 0;
951 	long minArticleNumber = 1;
952 	long maxArticleNumber = 0;
953 	long articleNumberCounter = 0;
954 	string[] readOnlyAuthTags;
955 	string[] readWriteAuthTags;
956 }
957 
958 struct Thread {
959 	BsonObjectID _id;
960 	BsonObjectID groupId;
961 	string subject;
962 	BsonObjectID firstArticleId;
963 	BsonObjectID lastArticleId;
964 }
965 
966 enum authGroupPrefix = "vibenews.authgroup.";
967 
968 
969 private auto trustedRange(R)(scope R delegate() rng)
970 @trusted {
971 	static struct TR {
972 		R _rng;
973 		bool empty() @trusted { return _rng.empty; }
974 		auto front() @trusted { return _rng.front; }
975 		void popFront() @trusted { _rng.popFront(); }
976 	}
977 
978 	return TR(rng());
979 }