1 /**
2 	(module summary)
3 
4 	Copyright: © 2012-2014 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.web;
9 
10 import vibenews.controller;
11 import vibenews.message;
12 import vibenews.vibenews;
13 
14 import antispam.antispam;
15 import userman.web : UserManController, UserManWebAuthenticator, User, updateProfile, registerUserManWebInterface;
16 
17 import vibe.core.core;
18 import vibe.core.log;
19 import vibe.crypto.passwordhash;
20 import vibe.data.bson;
21 import vibe.http.router;
22 import vibe.http.server;
23 import vibe.http.fileserver;
24 import vibe.inet.message;
25 import vibe.inet.path;
26 import vibe.textfilter.markdown;
27 import vibe.textfilter.urlencode;
28 import vibe.utils.string;
29 import vibe.utils.validation;
30 import vibe.web.web;
31 
32 import std.algorithm : canFind, filter, map, sort;
33 import std.array;
34 import std.base64;
35 import std.conv;
36 import std.datetime;
37 import std.encoding;
38 import std.exception;
39 import std.string;
40 import std.utf;
41 import std.variant;
42 
43 
44 void startVibeNewsWebFrontend(Controller ctrl)
45 {
46 	auto settings = new HTTPServerSettings;
47 	settings.port = ctrl.settings.webPort;
48 	settings.bindAddresses = ctrl.settings.webBindAddresses;
49 	settings.sessionStore = new MemorySessionStore;
50 
51 	auto router = new URLRouter;
52 	router.registerVibeNewsWebFrontend(ctrl);
53 
54 	listenHTTP(settings, router);
55 }
56 
57 void registerVibeNewsWebFrontend(URLRouter router, Controller ctrl)
58 {
59 	auto web = new WebInterface(ctrl);
60 	router.registerWebInterface(web);
61 
62 	auto settings = new HTTPFileServerSettings;
63 	static if (is(typeof(router.prefix))) // vibe.d 0.7.20 and up
64 		settings.serverPathPrefix = router.prefix;
65 	router.get("*", serveStaticFiles("public", settings));
66 
67 	registerUserManWebInterface(router, ctrl.userManController);
68 }
69 
70 
71 deprecated("Use startVibeNewsWebFrontend instead.")
72 void listen(WebInterface intf)
73 {
74 	auto settings = new HTTPServerSettings;
75 	settings.port = intf.m_settings.webPort;
76 	settings.bindAddresses = intf.m_settings.webBindAddresses;
77 	settings.sessionStore = new MemorySessionStore;
78 
79 	auto router = new URLRouter;
80 	register(intf, router);
81 
82 	listenHTTP(settings, router);
83 }
84 
85 deprecated("Use registerVibeNewsWebFrontend instead.")
86 void register(WebInterface intf, URLRouter router)
87 {
88 	router.registerWebInterface(intf);
89 
90 	auto settings = new HTTPFileServerSettings;
91 	static if (is(typeof(router.prefix))) // vibe.d 0.7.20 and up
92 		settings.serverPathPrefix = router.prefix;
93 	router.get("*", serveStaticFiles("public", settings));
94 
95 	registerUserManWebInterface(router, intf.m_ctrl.userManController);
96 }
97 
98 
99 class WebInterface {
100 	private {
101 		Controller m_ctrl;
102 		VibeNewsSettings m_settings;
103 		UserManWebAuthenticator m_userAuth;
104 		size_t m_postsPerPage = 10;
105 	}
106 
107 	this(Controller ctrl)
108 	{
109 		m_ctrl = ctrl;
110 		m_settings = ctrl.settings;
111 		m_userAuth = new UserManWebAuthenticator(ctrl.userManController);
112 	}
113 
114 	void get(HTTPServerRequest req, HTTPServerResponse res)
115 	{
116 		static struct Info1 {
117 			VibeNewsSettings settings;
118 			Category[] categories;
119 		}
120 		Info1 info;
121 		info.settings = m_settings;
122 
123 		string[] authTags;
124 		if( req.session && req.session.isKeySet("userEmail") ){
125 			auto email = req.session.get!string("userEmail");
126 			assert(m_ctrl !is null);
127 			auto usr = m_ctrl.getUserByEmail(email);
128 			foreach (g; usr.groups)
129 				authTags ~= m_ctrl.getAuthGroup(g).name;
130 		}
131 
132 		Group[] groups;
133 		m_ctrl.enumerateGroups((idx, grp){
134 			auto alltags = grp.readOnlyAuthTags;
135 			if (alltags.length > 0) {
136 				bool found = false;
137 				foreach (t; alltags)
138 					if (authTags.canFind(t)) {
139 						found = true;
140 						break;
141 					}
142 				if( !found ) return;
143 			}
144 			groups ~= grp;
145 		});
146 		m_ctrl.enumerateGroupCategories((idx, cat) @trusted { info.categories ~= Category(cat, groups, m_ctrl); });
147 
148 		if( !info.categories.length ) info.categories ~= Category("All", groups, m_ctrl);
149 
150 		info.categories.sort!"a.index < b.index"();
151 
152 		render!("vibenews.web.index.dt", info);
153 	}
154 
155 	void getGroups()
156 	{
157 		redirect("/");
158 	}
159 
160 	@auth
161 	void getProfile(HTTPServerRequest req, User user, string _error = null)
162 	{
163 		struct Info {
164 			VibeNewsSettings settings;
165 			Group[] groups;
166 			string error;
167 		}
168 
169 		enforceHTTP(req.session && req.session.isKeySet("userEmail"), HTTPStatus.forbidden, "Please log in to change your profile information.");
170 
171 		Info info;
172 		info.settings = m_settings;
173 		info.error = _error;
174 		req.form["email"] = user.email;
175 		req.form["full_name"] = user.fullName;
176 		if (_error.length) req.params["error"] = _error;
177 
178 		m_ctrl.enumerateGroups((idx, grp){ info.groups ~= grp; });
179 
180 		render!("vibenews.web.edit_profile.dt", info);
181 	}
182 
183 	@auth @errorDisplay!getProfile
184 	void postProfile(HTTPServerRequest req, User user)
185 	{
186 		.updateProfile(m_ctrl.userManController, user, req);
187 
188 		// TODO: notifications
189 
190 		redirect(req.path);
191 	}
192 
193 	@path("/groups/post")
194 	void getPostArticle(HTTPServerRequest req, HTTPServerResponse res, string _error = null)
195 	{
196 		string groupname;
197 		if( auto pg = "group" in req.query ) groupname = *pg;
198 		else groupname = req.form["group"];
199 		auto grp = m_ctrl.getGroupByName(groupname);
200 
201 		if (!enforceAuth(req, res, grp, true))
202 			return;
203 
204 		static struct Info5 {
205 			VibeNewsSettings settings;
206 			GroupInfo group;
207 			bool loggedIn = false;
208 			string threadSubject;
209 			string error;
210 			string name;
211 			string email;
212 			string subject;
213 			string message;
214 		}
215 
216 		Info5 info;
217 		info.settings = m_settings;
218 
219 		if( req.session ){
220 			if( req.session.isKeySet("userEmail") ){
221 				info.loggedIn = true;
222 				info.name = req.session.get!string("userFullName");
223 				info.email = req.session.get!string("userEmail");
224 			} else {
225 				info.name = req.session.get!string("lastUsedName");
226 				info.email = req.session.get!string("lastUsedEmail");
227 			}
228 		}
229 
230 		if( "reply-to" in req.query ){
231 			auto repartnum = req.query["reply-to"].to!long();
232 			auto repart = m_ctrl.getArticle(grp.name, repartnum);
233 			info.subject = repart.subject;
234 			if( !info.subject.startsWith("Re:") ) info.subject = "Re: " ~ info.subject;
235 			info.message = "On "~repart.getHeader("Date")~", "~PosterInfo(repart.getHeader("From")).name~" wrote:\r\n";
236 			info.message ~= map!(ln => ln.startsWith(">") ? ">" ~ ln : "> " ~ ln)(splitLines(decodeMessage(repart))).join("\r\n");
237 			info.message ~= "\r\n\r\n";
238 		}
239 		if ("thread" in req.query) {
240 			info.threadSubject = m_ctrl.getArticle(grp.name, req.query["thread"].to!long).subject;
241 		}
242 		info.group = GroupInfo(grp, m_ctrl);
243 
244 		// recover old values if showPostArticle was called because of an error
245 		info.error = _error;
246 		if( auto pnm = "name" in req.form ) info.name = *pnm;
247 		if( auto pem = "email" in req.form ) info.email = *pem;
248 		if( auto psj = "subject" in req.form ) info.subject = *psj;
249 		if( auto pmg = "message" in req.form ) info.message = *pmg;
250 
251 		render!("vibenews.web.reply.dt", info);
252 	}
253 
254 	@path("/groups/post") @errorDisplay!getPostArticle
255 	void postArticle(HTTPServerRequest req, HTTPServerResponse res, string group, string subject, string message)
256 	{
257 		auto grp = m_ctrl.getGroupByName(group);
258 
259 		User.ID user_id;
260 		if( !enforceAuth(req, res, grp, true, &user_id) )
261 			return;
262 
263 		bool loggedin = req.session && req.session.isKeySet("userEmail");
264 		string email = loggedin ? req.session.get!string("userEmail") : req.form["email"].strip();
265 		string name = loggedin ? req.session.get!string("userFullName") : req.form["name"].strip();
266 
267 		validateEmail(email);
268 		validateString(name, 3, 64, "The poster name");
269 		validateString(subject, 1, 128, "The message subject");
270 		validateString(message, 1, 128*1024, "The message body");
271 
272 		if( !loggedin ){
273 			enforce(!m_ctrl.isEmailRegistered(email), "The email address is already in use by a registered account. Please log in to use it.");
274 		}
275 
276 		Article art;
277 		art._id = BsonObjectID.generate();
278 		art.id = "<"~art._id.toString()~"@"~m_settings.hostName~">";
279 		art.addHeader("Subject", subject);
280 		art.addHeader("From", "\""~name~"\" <"~email~">");
281 		art.addHeader("Newsgroups", grp.name);
282 		art.addHeader("Date", Clock.currTime(UTC()).toRFC822DateTimeString());
283 		art.addHeader("User-Agent", "VibeNews Web");
284 		art.addHeader("Content-Type", "text/x-markdown; charset=UTF-8; format=flowed");
285 		art.addHeader("Content-Transfer-Encoding", "8bit");
286 
287 		if( auto prepto = "reply-to" in req.form ){
288 			auto repartnum = to!long(*prepto);
289 			auto repart = m_ctrl.getArticle(grp.name, repartnum, false);
290 			auto refs = repart.getHeader("References");
291 			if( refs.length ) refs ~= " ";
292 			refs ~= repart.id;
293 			art.addHeader("In-Reply-To", repart.id);
294 			art.addHeader("References", refs);
295 		}
296 
297 		if( auto pp = "X-Forwarded-For" in req.headers )
298 			art.peerAddress = split(*pp, ",").map!strip().array() ~ req.peer;
299 		else art.peerAddress = [req.peer];
300 		art.message = cast(ubyte[])(message ~ "\r\n");
301 
302 		m_ctrl.postArticle(art, user_id);
303 
304 		if( !req.session ) req.session = res.startSession();
305 		req.session.set("lastUsedName", name.idup);
306 		req.session.set("lastUsedEmail", email.idup);
307 
308 		redirectToThreadPost(res, Path(req.path).parentPath.toString(), grp.name, art.groups[escapeGroup(grp.name)].articleNumber, art.groups[escapeGroup(grp.name)].threadId);
309 	}
310 
311 	@path("/groups/:group/")
312 	void getGroup(HTTPServerRequest req, HTTPServerResponse res, string _group)
313 	{
314 		auto grp = m_ctrl.getGroupByName(_group);
315 
316 		if( !enforceAuth(req, res, grp, false) )
317 			return;
318 
319 		static struct Info2 {
320 			VibeNewsSettings settings;
321 			GroupInfo group;
322 			ThreadInfo[] threads;
323 			size_t page = 0;
324 			size_t pageSize = 10;
325 			size_t pageCount;
326 		}
327 		Info2 info;
328 		info.settings = m_settings;
329 		if( auto ps = "page" in req.query ) info.page = to!size_t(*ps)-1;
330 
331 		info.group = GroupInfo(grp, m_ctrl);
332 		m_ctrl.enumerateThreads(grp._id, info.page*info.pageSize, info.pageSize, (idx, thr) @trusted {
333 			info.threads ~= ThreadInfo(thr, m_ctrl, info.pageSize, grp.name);
334 		});
335 		
336 		info.pageCount = (info.group.numberOfTopics + info.pageSize-1) / info.pageSize;
337 
338 		res.render!("vibenews.web.view_group.dt", req, info);
339 	}
340 
341 	@path("/groups/:group/thread/:thread/")
342 	void getThread(HTTPServerRequest req, HTTPServerResponse res, string _group, long _thread)
343 	{
344 		auto grp = m_ctrl.getGroupByName(_group);
345 
346 		if( !enforceAuth(req, res, grp, false) )
347 			return;
348 
349 		static struct Info3 {
350 			VibeNewsSettings settings;
351 			GroupInfo group;
352 			PostInfo[] posts;
353 			ThreadInfo thread;
354 			size_t page;
355 			size_t postCount;
356 			size_t pageSize = 10;
357 			size_t pageCount;
358 		}
359 
360 		Info3 info;
361 		info.settings = m_settings;
362 		info.pageSize = m_postsPerPage;
363 		if( auto ps = "page" in req.query ) info.page = to!size_t(*ps) - 1;
364 		try info.thread = ThreadInfo(m_ctrl.getThreadForFirstArticle(grp.name, _thread), m_ctrl, info.pageSize, grp.name);
365 		catch( Exception e ){
366 			redirectToThreadPost(res, (Path(req.path) ~ "../../../").toString(), grp.name, _thread);
367 			return;
368 		}
369 		info.group = GroupInfo(grp, m_ctrl);
370 		info.postCount = info.thread.postCount;
371 		info.pageCount = info.thread.pageCount;
372 
373 		m_ctrl.enumerateThreadPosts(info.thread.id, grp.name, info.page*info.pageSize, info.pageSize, (idx, art) @trusted {
374 			Article replart;
375 			try replart = m_ctrl.getArticle(art.getHeader("In-Reply-To"));
376 			catch( Exception ){}
377 			info.posts ~= PostInfo(art, replart, info.group.name);
378 		});
379 
380 		res.render!("vibenews.web.view_thread.dt", req, info);
381 	}
382 
383 	@path("/groups/:group/post/:post")
384 	void getPost(HTTPServerRequest req, HTTPServerResponse res, string _group, long _post)
385 	{
386 		auto grp = m_ctrl.getGroupByName(_group);
387 
388 		if( !enforceAuth(req, res, grp, false) )
389 			return;
390 
391 		static struct Info4 {
392 			VibeNewsSettings settings;
393 			GroupInfo group;
394 			PostInfo post;
395 			ThreadInfo thread;
396 		}
397 
398 		Info4 info;
399 		info.settings = m_settings;
400 		info.group = GroupInfo(grp, m_ctrl);
401 
402 		auto art = m_ctrl.getArticle(grp.name, _post);
403 		Article replart;
404 		try replart = m_ctrl.getArticle(art.getHeader("In-Reply-To"));
405 		catch( Exception ){}
406 		info.post = PostInfo(art, replart, info.group.name);
407 		info.thread = ThreadInfo(m_ctrl.getThread(art.groups[escapeGroup(grp.name)].threadId), m_ctrl, 0, grp.name);
408 
409 		res.render!("vibenews.web.view_post.dt", req, info);
410 	}
411 
412 	// deprecated
413 	@path("/groups/:group/thread/:thread/:post")
414 	void getRedirectShowPost(HTTPServerRequest req, HTTPServerResponse res, string _group, long _thread, string _post)
415 	{
416 		res.redirect((Path(req.path)~"../../../post/"~_post).toString(), HTTPStatus.movedPermanently);
417 	}
418 
419 
420 	void postMarkup(HTTPServerRequest req, HTTPServerResponse res, string message)
421 	{
422 		validateString(message, 0, 128*1024, "The message body");
423 		res.writeBody(filterMarkdown(message, MarkdownFlags.forumDefault), "text/html");
424 	}
425 
426 	private void redirectToThreadPost(HTTPServerResponse res, string groups_path, string groupname, long article_number, BsonObjectID thread_id = BsonObjectID(), HTTPStatus redirect_status_code = HTTPStatus.Found)
427 	{
428 		if( thread_id == BsonObjectID() ){
429 			auto refs = m_ctrl.getArticleGroupRefs(groupname, article_number);
430 			thread_id = refs[escapeGroup(groupname)].threadId;
431 		}
432 		auto thr = m_ctrl.getThread(thread_id);
433 		auto first_art_refs = m_ctrl.getArticleGroupRefs(thr.firstArticleId);
434 		auto first_art_num = first_art_refs[escapeGroup(groupname)].articleNumber;
435 		auto url = groups_path~groupname~"/thread/"~first_art_num.to!string()~"/";
436 		if( article_number != first_art_num ){
437 			auto index = m_ctrl.getThreadArticleIndex(thr._id, article_number, groupname);
438 			auto page = index / m_postsPerPage + 1;
439 			if( page > 1 ) url ~= "?page="~to!string(page);
440 			url ~= "#post-"~to!string(article_number);
441 		}
442 		res.redirect(url, redirect_status_code);
443 	}
444 
445 	private bool enforceAuth(HTTPServerRequest req, HTTPServerResponse res, ref Group grp, bool read_write, User.ID* user_id = null)
446 	{
447 		if( user_id ) *user_id = User.ID.init;
448 		User.ID uid;
449 		string[] authTags;
450 		if( req.session && req.session.isKeySet("userEmail") ){
451 			auto email = req.session.get!string("userEmail");
452 			auto usr = m_ctrl.getUserByEmail(email);
453 			foreach (g; usr.groups)
454 				authTags ~= m_ctrl.getAuthGroup(g).name;
455 			if( user_id ) *user_id = usr.id;
456 			uid = usr.id;
457 		}
458 
459 		if (!read_write && grp.readOnlyAuthTags.empty)
460 			return true;
461 
462 		if( grp.readOnlyAuthTags.empty && grp.readWriteAuthTags.empty )
463 			return true;
464 
465 		auto alltags = grp.readWriteAuthTags;
466 		if( !read_write ) alltags ~= grp.readOnlyAuthTags;
467 
468 		bool found = false;
469 		foreach (t; alltags)
470 			if (authTags.canFind(t)) {
471 				found = true;
472 				break;
473 			}
474 		if( !found ){
475 			if (uid == User.ID.init) {
476 				res.redirect("/login?redirect="~urlEncode(req.requestURL));
477 				return false;
478 			} else {
479 				throw new HTTPStatusException(HTTPStatus.forbidden, "Group is protected.");
480 			}
481 		}
482 		return true;
483 	}
484 
485 	enum auth = before!performAuth("user");
486 
487 	mixin PrivateAccessProxy;
488 
489 	private User performAuth(HTTPServerRequest req, HTTPServerResponse res)
490 	{
491 		return m_userAuth.performAuth(req, res);
492 	}
493 }
494 
495 struct GroupInfo {
496 	this(Group grp, Controller ctrl)
497 	{
498 		try {
499 			lastPostNumber = grp.maxArticleNumber;
500 			auto lastpost = ctrl.getArticle(grp.name, grp.maxArticleNumber);
501 			lastPoster = PosterInfo(lastpost.getHeader("From"));
502 			lastPostDate = lastpost.getHeader("Date");//.parseRFC822DateTimeString();
503 		} catch( Exception ){}
504 
505 		name = grp.name;
506 		caption = grp.caption;
507 		description = grp.description;
508 		numberOfPosts = cast(size_t)grp.articleCount;
509 		numberOfTopics = cast(size_t)ctrl.getThreadCount(grp._id);
510 	}
511 
512 	string name;
513 	string caption;
514 	string description;
515 	size_t numberOfTopics;
516 	size_t numberOfPosts;
517 	PosterInfo lastPoster;
518 	//SysTime lastPostDate;
519 	string lastPostDate;
520 	long lastPostNumber;
521 }
522 
523 struct ThreadInfo {
524 	this(Thread thr, Controller ctrl, size_t page_size, string groupname)
525 	{
526 		id = thr._id;
527 		subject = thr.subject;
528 		postCount = cast(size_t)ctrl.getThreadPostCount(thr._id, groupname);
529 		if( page_size ) pageCount = (postCount + page_size-1) / page_size;
530 		pageSize = page_size;
531 
532 		try {
533 			auto firstpost = ctrl.getArticle(thr.firstArticleId);
534 			firstPost.poster = PosterInfo(firstpost.getHeader("From"));
535 			firstPost.date = firstpost.getHeader("Date");//.parseRFC822DateTimeString();
536 			firstPost.number = firstpost.groups[escapeGroup(groupname)].articleNumber;
537 			firstPost.subject = firstpost.subject;
538 			
539 			auto lastpost = ctrl.getArticle(thr.lastArticleId);
540 			lastPost.poster = PosterInfo(lastpost.getHeader("From"));
541 			lastPost.date = lastpost.getHeader("Date");//.parseRFC822DateTimeString();
542 			lastPost.number = lastpost.groups[escapeGroup(groupname)].articleNumber;
543 			lastPost.subject = lastpost.subject;
544 		} catch( Exception ){}
545 	}
546 
547 	BsonObjectID id;
548 	string subject;
549 	PostInfo firstPost;
550 	PostInfo lastPost;
551 	size_t pageSize;
552 	size_t pageCount;
553 	size_t postCount;
554 }
555 
556 struct PostInfo {
557 	this(Article art, Article repl_art, string groupname)
558 	{
559 		id = art._id;
560 		subject = art.subject;
561 		poster = PosterInfo(art.getHeader("From"));
562 		repliedToPoster = PosterInfo(repl_art.getHeader("From"));
563 		if( auto pg = escapeGroup(groupname) in repl_art.groups )
564 			repliedToPostNumber = pg.articleNumber;
565 		date = art.getHeader("Date");
566 		message = decodeMessage(art);
567 		number = art.groups[escapeGroup(groupname)].articleNumber;
568 	}
569 
570 	BsonObjectID id;
571 	long number;
572 	string subject;
573 	PosterInfo poster;
574 	PosterInfo repliedToPoster;
575 	long repliedToPostNumber;
576 	//SysTime date;
577 	string date;
578 	string message;
579 }
580 
581 struct PosterInfo {
582 	this(string str)
583 	{
584 		if( str.length ){
585 			decodeEmailAddressHeader(str, name, email);
586 		}
587 	}
588 
589 	string name;
590 	string email;
591 }
592 
593 struct Category {
594 	string title;
595 	int index;
596 	GroupInfo[] groups;
597 
598 	this(GroupCategory cat, Group[] groups, Controller ctrl)
599 	{
600 		title = cat.caption;
601 		index = cat.index;
602 		foreach( id; cat.groups )
603 			foreach( grp; groups )
604 				if( grp._id == id )
605 					this.groups ~= GroupInfo(grp, ctrl);
606 	}
607 
608 	this(string title, Group[] groups, Controller ctrl)
609 	{
610 		this.title = title;
611 		foreach( grp; groups )
612 			this.groups ~= GroupInfo(grp, ctrl);
613 	}
614 }