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