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 }