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