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 }