diff --git a/appsv/model/build.sbt b/appsv/model/build.sbt index 8a00d4b72e..1d8fb878dd 100644 --- a/appsv/model/build.sbt +++ b/appsv/model/build.sbt @@ -21,6 +21,4 @@ libraryDependencies ++= Seq( Dependencies.Libs.scalaTest, ) -scalacOptions += "-deprecation" - compileOrder := CompileOrder.JavaThenScala diff --git a/appsv/model/src/main/scala/com/debiki/core/PagePath.scala b/appsv/model/src/main/scala/com/debiki/core/PagePath.scala index bb1b277330..6d670b460e 100644 --- a/appsv/model/src/main/scala/com/debiki/core/PagePath.scala +++ b/appsv/model/src/main/scala/com/debiki/core/PagePath.scala @@ -329,7 +329,7 @@ object PagePath { } - /** Parses the path part of a URL into a PagePath. + /** Parses the path part of a URL into a PagePath. [ty_url_fmt] * * URL path examples: * - (server)/fold/ers/-pageId [2WBG49] diff --git a/appsv/model/src/main/scala/com/debiki/core/Site.scala b/appsv/model/src/main/scala/com/debiki/core/Site.scala index c3b9902d9f..d2e412c24e 100644 --- a/appsv/model/src/main/scala/com/debiki/core/Site.scala +++ b/appsv/model/src/main/scala/com/debiki/core/Site.scala @@ -138,7 +138,7 @@ sealed abstract class SiteStatus(val IntValue: i32) { } -case class SuperAdminSitePatch( +case class SuperAdminSitePatch( // ts: interface SASitePatch siteId: SiteId, newStatus: SiteStatus, newNotes: Opt[St], diff --git a/appsv/model/src/main/scala/com/debiki/core/user.scala b/appsv/model/src/main/scala/com/debiki/core/user.scala index c9881001a1..f5cb16822f 100644 --- a/appsv/model/src/main/scala/com/debiki/core/user.scala +++ b/appsv/model/src/main/scala/com/debiki/core/user.scala @@ -1209,6 +1209,9 @@ sealed trait MemberInclDetails extends ParticipantInclDetails { RENAME // to Me */ def uiPrefs: Option[JsObject] + // Later: + //def modConf: Opt[JsObject] // the pats_t/users3.mod_conf_c column + def copyPrefs(uiPrefs: Opt[JsObject] = null, privPrefs: MemberPrivacyPrefs = null): MemberVb = { this match { case thiz: GroupVb => diff --git a/appsv/server/controllers/DraftsController.scala b/appsv/server/controllers/DraftsController.scala index 3bcec4bb84..3b0b82ead9 100644 --- a/appsv/server/controllers/DraftsController.scala +++ b/appsv/server/controllers/DraftsController.scala @@ -113,9 +113,13 @@ class DraftsController @Inject()(cc: ControllerComponents, edContext: TyContext) if (draft.isReply) { val postType = draft.postType getOrDie "TyER35SKS02GU" + val pageAuthor = + if (pageMeta.authorId == requester.id) requester + else dao.getTheParticipant(pageMeta.authorId) throwNoUnless(Authz.mayPostReply( request.theUserAndLevels, asAlias = None, dao.getOnesGroupIds(requester), - postType, pageMeta, Vector(post), dao.getAnyPrivateGroupTalkMembers(pageMeta), + postType, pageMeta, pageAuthor = pageAuthor, + Vector(post), dao.getAnyPrivateGroupTalkMembers(pageMeta), inCategoriesRootLast = categoriesRootLast, tooManyPermissions = dao.getPermsOnPages(categoriesRootLast)), "EdEZBXK3M2") } diff --git a/appsv/server/controllers/ReplyController.scala b/appsv/server/controllers/ReplyController.scala index 8f99004f0b..ef38b2c7fa 100644 --- a/appsv/server/controllers/ReplyController.scala +++ b/appsv/server/controllers/ReplyController.scala @@ -76,6 +76,9 @@ class ReplyController @Inject()(cc: ControllerComponents, edContext: TyContext) REMOVE // these 3 vals, once we're using dao.insertReplyIfAuZ() instead of // doing authz here in this fn. val pageMeta = dao.getPageMeta(pageId) getOrElse throwIndistinguishableNotFound("EdE5FKW20") + val pageAuthor = + if (pageMeta.authorId == requester.id) requester + else dao.getTheParticipant(pageMeta.authorId) val replyToPosts = dao.loadPostsAllOrError(pageId, replyToPostNrs) getOrIfBad { missingPostNr => throwNotFound(s"Post nr $missingPostNr not found", "EdEW3HPY08") } @@ -85,8 +88,9 @@ class ReplyController @Inject()(cc: ControllerComponents, edContext: TyContext) CLEAN_UP // [dupl_re_authz_chk] and see the REM OVE just above too, and COU LD below. throwNoUnless(Authz.mayPostReply( - request.theUserAndLevels, asAlias, dao.getOnesGroupIds(request.theUser), - postType, pageMeta, replyToPosts, dao.getAnyPrivateGroupTalkMembers(pageMeta), + request.theUserAndLevels, asAlias, dao.getOnesGroupIds(requester), + postType, pageMeta, pageAuthor = pageAuthor, + replyToPosts, dao.getAnyPrivateGroupTalkMembers(pageMeta), inCategoriesRootLast = categoriesRootLast, tooManyPermissions = dao.getPermsOnPages(categoriesRootLast)), "TyEM0REPLY_") @@ -119,7 +123,7 @@ class ReplyController @Inject()(cc: ControllerComponents, edContext: TyContext) def handleChatMessage: Action[JsValue] = PostJsonAction(RateLimits.PostReply, maxBytes = MaxPostSize) { request => - import request.{body, dao} + import request.{body, dao, reqr} val pageId = (body \ "pageId").as[PageId] val text = (body \ "text").as[String].trim val deleteDraftNr = (body \ "deleteDraftNr").asOpt[DraftNr] @@ -132,12 +136,16 @@ class ReplyController @Inject()(cc: ControllerComponents, edContext: TyContext) val pageMeta = dao.getPageMeta(pageId) getOrElse { throwIndistinguishableNotFound("EdE7JS2") } + val pageAuthor = + if (pageMeta.authorId == reqr.id) reqr + else dao.getTheParticipant(pageMeta.authorId) val replyToPosts = Nil // currently cannot reply to specific posts, in the chat [7YKDW3] val categoriesRootLast = dao.getAncestorCategoriesRootLast(pageMeta.categoryId) throwNoUnless(Authz.mayPostReply( request.theUserAndLevels, asAlias = None, dao.getOnesGroupIds(request.theMember), - PostType.ChatMessage, pageMeta, replyToPosts, dao.getAnyPrivateGroupTalkMembers(pageMeta), + PostType.ChatMessage, pageMeta, pageAuthor = pageAuthor, + replyToPosts, dao.getAnyPrivateGroupTalkMembers(pageMeta), inCategoriesRootLast = categoriesRootLast, tooManyPermissions = dao.getPermsOnPages(categoriesRootLast)), "EdEHDETG4K5") diff --git a/appsv/server/controllers/UploadsController.scala b/appsv/server/controllers/UploadsController.scala index de667713f6..ac4bb402fb 100644 --- a/appsv/server/controllers/UploadsController.scala +++ b/appsv/server/controllers/UploadsController.scala @@ -30,7 +30,7 @@ import play.api._ import play.api.libs.Files import play.api.libs.json.{JsString, JsValue, Json} import play.api.mvc._ - +import play.api.mvc.MultipartFormData.FilePart /** Uploads files and serves uploaded files. */ @@ -137,22 +137,24 @@ class UploadsController @Inject()(cc: ControllerComponents, edContext: TyContext if (files.length != 1) throwBadRequest("EdE7UYMF3", s"Use the multipart form data key name 'file' please") - val file = files.head + val filePart: FilePart[Files.TemporaryFile] = files.head // This far, in this endpoint, we've verified only that size <= maxBytesLargeFile. // However, the upload limit might be lower, for this user or this site. // We've checked this already [upl_sz_ck] — let's double check, and this // time, there're no form-data boundaries. - _throwForbiddenMaybe(Some(file.filename), sizeBytes = file.fileSize, request) + _throwForbiddenMaybe(Some(filePart.filename), sizeBytes = filePart.fileSize, request) + + val file: jio.File = filePart.ref.path.toFile val uploadRef = dao.addUploadedFile( - file.filename, file.ref.file, request.theReqerTrueId, request.theBrowserIdData) + filePart.filename, file, request.theReqerTrueId, request.theBrowserIdData) // Delete the temporary file. (It will be gone already, if we couldn't optimize it, // i.e. make it smaller, because then we've moved it to the uploads dir (rather than // a smaller compressed copy). Deleting file ref although gone already, doesn't do anything.) - file.ref.delete() + filePart.ref.delete() // Don't use OkSafeJson here because Dropzone doesn't understand the safe-JSON prefix. Ok(JsString(uploadRef.url)) as JSON @@ -193,31 +195,38 @@ class UploadsController @Inject()(cc: ControllerComponents, edContext: TyContext throwBadRequest("EdE35UY0", o"""Upload three images please: a tiny, a small and a medium sized avatar image — instead I got $numFilesUploaded files""") - val tinyFile = multipartFormData.files.find(_.key == "images[tiny]") getOrElse { + val fileParts: Seq[FilePart[Files.TemporaryFile]] = + multipartFormData.files + + val tinyFilePart = fileParts.find(_.key == "images[tiny]") getOrElse { throwBadRequest("EdE8GYF2", o"""Upload a tiny size avatar image please""") } - val smallFile = multipartFormData.files.find(_.key == "images[small]") getOrElse { + val smallFilePart = fileParts.find(_.key == "images[small]") getOrElse { throwBadRequest("EdE4YF21", o"""Upload a small size avatar image please""") } - val mediumFile = multipartFormData.files.find(_.key == "images[medium]") getOrElse { + val mediumFilePart = fileParts.find(_.key == "images[medium]") getOrElse { throwBadRequest("EdE8YUF2", o"""Upload a medium size avatar image please""") } + val tinyFile = tinyFilePart.ref.path.toFile + val smallFile = smallFilePart.ref.path.toFile + val mediumFile = mediumFilePart.ref.path.toFile + def throwIfTooLarge(whichFile: String, file: jio.File, maxBytes: Int): Unit = { val length = file.length if (length > maxBytes) throwForbidden("DwE7YMF2", s"The $whichFile is too large: $length bytes, max is: $maxBytes") } - throwIfTooLarge("tiny avatar image", tinyFile.ref.file, MaxAvatarTinySizeBytes) - throwIfTooLarge("small avatar image", smallFile.ref.file, MaxAvatarSmallSizeBytes) - throwIfTooLarge("medium avatar image", mediumFile.ref.file, MaxAvatarMediumSizeBytes) + throwIfTooLarge("tiny avatar image", tinyFile, MaxAvatarTinySizeBytes) + throwIfTooLarge("small avatar image", smallFile, MaxAvatarSmallSizeBytes) + throwIfTooLarge("medium avatar image", mediumFile, MaxAvatarMediumSizeBytes) - ImageUtils.throwUnlessJpegWithSideBetween(tinyFile.ref.file, "Tiny", 20, 35) - ImageUtils.throwUnlessJpegWithSideBetween(smallFile.ref.file, "Small", 40, 60) - ImageUtils.throwUnlessJpegWithSideBetween(mediumFile.ref.file, "Medium", 150, 800) + ImageUtils.throwUnlessJpegWithSideBetween(tinyFile, "Tiny", 20, 35) + ImageUtils.throwUnlessJpegWithSideBetween(smallFile, "Small", 40, 60) + ImageUtils.throwUnlessJpegWithSideBetween(mediumFile, "Medium", 150, 800) // First add metadata entries for the files and move them in place. // Then, if there were no errors, update the user so that it starts using the new @@ -226,18 +235,18 @@ class UploadsController @Inject()(cc: ControllerComponents, edContext: TyContext // (since they're unused) — deleting them is not yet implemented though [9YMU2Y]. val tinyAvatarRef = request.dao.addUploadedFile( - tinyFile.filename, tinyFile.ref.file, request.theReqerTrueId, request.theBrowserIdData) + tinyFilePart.filename, tinyFile, request.theReqerTrueId, request.theBrowserIdData) val smallAvatarRef = request.dao.addUploadedFile( - smallFile.filename, smallFile.ref.file, request.theReqerTrueId, request.theBrowserIdData) + smallFilePart.filename, smallFile, request.theReqerTrueId, request.theBrowserIdData) val mediumAvatarRef = request.dao.addUploadedFile( - mediumFile.filename, mediumFile.ref.file, request.theReqerTrueId, request.theBrowserIdData) + mediumFilePart.filename, mediumFile, request.theReqerTrueId, request.theBrowserIdData) // Delete the temporary files. - tinyFile.ref.delete() - smallFile.ref.delete() - mediumFile.ref.delete() + tinyFilePart.ref.delete() + smallFilePart.ref.delete() + mediumFilePart.ref.delete() // Now the images are in place in the uploads dir, and we've created metadata entries. // We just need to link the user to the images: diff --git a/appsv/server/debiki/ImageUtils.scala b/appsv/server/debiki/ImageUtils.scala index 4d5e669dcf..a608ab1aa8 100644 --- a/appsv/server/debiki/ImageUtils.scala +++ b/appsv/server/debiki/ImageUtils.scala @@ -145,7 +145,8 @@ object ImageUtils { val MimeTypeJpeg = "image/jpeg" - def throwUnlessJpegWithSideBetween(file: jio.File, which: String, minSide: Int, maxSide: Int): Unit = { + def throwUnlessJpegWithSideBetween(file: jio.File, which: St, + minSide: i32, maxSide: i32): U = { // java.nio.file.Files.probeContentType doesn't work in Alpine Linux + JRE 8. Instead, use Tika. // (This detects mime type based on actual document content, not just the suffix.) dupl [7YKW23] val tika = new org.apache.tika.Tika() diff --git a/appsv/server/debiki/dao/ForumDao.scala b/appsv/server/debiki/dao/ForumDao.scala index b67b53ce72..3175254d96 100644 --- a/appsv/server/debiki/dao/ForumDao.scala +++ b/appsv/server/debiki/dao/ForumDao.scala @@ -818,10 +818,11 @@ object ForumDao { * (Of course admins can change this, for their own site.) [DEFMAYEDWIKI] * Sync with dupl code in Typescript. [7KFWY025] */ - def makeFullMembersDefaultCategoryPerms(categoryId: CategoryId): PermsOnPages = - makeEveryonesDefaultCategoryPerms(categoryId).copy( - forPeopleId = Group.FullMembersId, - mayEditWiki = Some(true)) + def makeFullMembersDefaultCategoryPerms(catId: CatId): PermsOnPages = PermsOnPages( + id = NoPermissionId, + forPeopleId = Group.FullMembersId, + onCategoryId = Some(catId), + mayEditWiki = Some(true)) // Sync with dupl code in Typescript. [7KFWY025] diff --git a/appsv/server/debiki/dao/PostsDao.scala b/appsv/server/debiki/dao/PostsDao.scala index df5f2c7a70..7beefb97d4 100644 --- a/appsv/server/debiki/dao/PostsDao.scala +++ b/appsv/server/debiki/dao/PostsDao.scala @@ -77,6 +77,9 @@ trait PostsDao { val pageMeta = this.getPageMeta(pageId) getOrElse throwIndistinguishableNotFound( "TyE5FKW20", showErrCodeAnyway = reqrAndReplyer.reqrIsAdmin) + val pageAuthor = + if (pageMeta.authorId == reqrAndReplyer.reqr.id) reqrAndReplyer.reqr + else this.getTheParticipant(pageMeta.authorId) val catsRootLast = this.getAncestorCategoriesSelfFirst(pageMeta.categoryId) val tooManyPermissions = getPermsOnPages(categories = catsRootLast) @@ -94,8 +97,8 @@ trait PostsDao { reqrAndLevels, // See [4_doer_not_reqr]. asAlias = if (reqrAndReplyer.areNotTheSame) None else asAlias, - this.getOnesGroupIds(reqrAndLevels.user), - postType, pageMeta, replyToPosts, privTalkMembers, + groupIds = this.getOnesGroupIds(reqrAndLevels.user), + postType, pageMeta, pageAuthor = pageAuthor, replyToPosts, privTalkMembers, inCategoriesRootLast = catsRootLast, tooManyPermissions), "TyEM0REPLY1") @@ -103,7 +106,7 @@ trait PostsDao { val replyerAndLevels = readTx(this.loadUserAndLevels(reqrAndReplyer.targetToWho, _)) throwNoUnless(Authz.mayPostReply( replyerAndLevels, asAlias, this.getOnesGroupIds(replyerAndLevels.user), - postType, pageMeta, replyToPosts, privTalkMembers, + postType, pageMeta, pageAuthor = pageAuthor, replyToPosts, privTalkMembers, inCategoriesRootLast = catsRootLast, tooManyPermissions), "TyEM0REPLY2") } @@ -186,9 +189,12 @@ trait PostsDao { val authorMaybeAnon: Pat = SiteDao.getAliasOrTruePat( truePat = realAuthor, pageId = pageId, asAlias)(tx, IfBadAbortReq) + val pageAuthor = tx.loadTheParticipant(page.meta.authorId) + dieOrThrowNoUnless(Authz.mayPostReply( realAuthorAndLevels, asAlias /* _not_same_tx, ok */, realAuthorAndGroupIds, - postType, page.meta, replyToPosts, tx.loadAnyPrivateGroupTalkMembers(page.meta), + postType, page.meta, pageAuthor = pageAuthor, + replyToPosts, tx.loadAnyPrivateGroupTalkMembers(page.meta), tx.loadCategoryPathRootLast(page.meta.categoryId, inclSelfFirst = true), tx.loadPermsOnPages()), "EdEMAY0RE") @@ -658,14 +664,16 @@ trait PostsDao { SHOULD_OPTIMIZE // don't load all posts [2GKF0S6], because this is a chat, could be too many. val page = newPageDao(pageId, tx) + val pageAuthor = tx.loadTheParticipant(page.meta.authorId) val replyToPosts = Nil // currently cannot reply to specific posts, in the chat. [7YKDW3] val asAlias = None // [anon_chats] dieOrThrowNoUnless(Authz.mayPostReply( - authorAndLevels, asAlias = asAlias, tx.loadGroupIdsMemberIdFirst(author), - PostType.ChatMessage, page.meta, Nil, tx.loadAnyPrivateGroupTalkMembers(page.meta), - tx.loadCategoryPathRootLast(page.meta.categoryId, inclSelfFirst = true), - tx.loadPermsOnPages()), "EdEMAY0CHAT") + authorAndLevels, asAlias = asAlias, groupIds = tx.loadGroupIdsMemberIdFirst(author), + PostType.ChatMessage, page.meta, pageAuthor = pageAuthor, + replyToPosts = Nil, tx.loadAnyPrivateGroupTalkMembers(page.meta), + tx.loadCategoryPathRootLast(page.meta.categoryId, inclSelfFirst = true), + tx.loadPermsOnPages()), "EdEMAY0CHAT") val (reviewReasons: Seq[ReviewReason], _) = throwOrFindNewPostReviewReasons(page.meta, authorAndLevels, tx) diff --git a/appsv/server/debiki/dao/RenderContentService.scala b/appsv/server/debiki/dao/RenderContentService.scala index a1fc493b9a..c82dec1913 100644 --- a/appsv/server/debiki/dao/RenderContentService.scala +++ b/appsv/server/debiki/dao/RenderContentService.scala @@ -75,7 +75,11 @@ class RenderContentActor( // COULD SECURITY DoS attack: Want to enqueue this case last-in-first-out, per page & params, so won't // rerender the page more than once, even if is in the queue many times (with different hashes). // Can that be done with Akka actors in some simple way? - case (sitePageId: SitePageId, paramsAndHash: Opt[RenderParamsAndFreshHash]) => + case (sitePageId: SitePageId, paramsAndHash0: Opt[Any]) => + val paramsAndHash: Opt[RenderParamsAndFreshHash] = paramsAndHash0 flatMap { + case p: RenderParamsAndFreshHash => Some(p) + case x => logger.warn(o"""Bad type: ${classNameOf(x)} [TyE5FL205F]""") ; None + } // The page has 1) been modified, or accessed and was out-of-date. [4KGJW2] // Or 2) edited and uncached, and now it's being rerendered in advance (but // no one asked for it exactly now — paramsAndHash is None). [7BWTWR2] diff --git a/appsv/server/debiki/dao/UploadsDao.scala b/appsv/server/debiki/dao/UploadsDao.scala index b85e6a8d33..c937732202 100644 --- a/appsv/server/debiki/dao/UploadsDao.scala +++ b/appsv/server/debiki/dao/UploadsDao.scala @@ -49,7 +49,7 @@ trait UploadsDao { * directory, if stored on localhost (some file systems don't want 99999 files in a * single directory). */ - def addUploadedFile(uploadedFileName: String, tempFile: jio.File, uploadedById: TrueId, + def addUploadedFile(uploadedFileName: St, tempFile: jio.File, uploadedById: TrueId, browserIdData: BrowserIdData): UploadRef = { // Over quota? [fs_quota] @@ -363,10 +363,11 @@ object UploadsDao { private val Log4 = math.log(4) - def makeHashPath(file: jio.File, dotSuffix: String): String = { + def makeHashPath(file: jio.File, dotSuffix: St): St = { // (It's okay to truncate the hash, see e.g.: // http://crypto.stackexchange.com/questions/9435/is-truncating-a-sha512-hash-to-the-first-160-bits-as-secure-as-using-sha1 ) - val hashCode = guava.io.Files.hash(file, guava.hash.Hashing.sha256) + val hashCode: guava.hash.HashCode = + guava.io.Files.asByteSource(file).hash(guava.hash.Hashing.sha256) val hashString = base32lowercaseEncoder.encode(hashCode.asBytes) take HashLength makeHashPath(file.length().toInt, hashString, dotSuffix) } diff --git a/appsv/server/debiki/dao/UserDao.scala b/appsv/server/debiki/dao/UserDao.scala index 9034bbf5d2..90c61a42a9 100644 --- a/appsv/server/debiki/dao/UserDao.scala +++ b/appsv/server/debiki/dao/UserDao.scala @@ -1001,8 +1001,8 @@ trait UserDao { } - def getTheParticipant(userId: UserId): Participant = { - getParticipant(userId).getOrElse(throw UserNotFoundException(userId)) + def getTheParticipant(userId: UserId, anyTx: Opt[SiteTx] = None): Participant = { + getParticipant(userId, anyTx).getOrElse(throw UserNotFoundException(userId)) } diff --git a/appsv/server/talkyard/server/authz/Authz.scala b/appsv/server/talkyard/server/authz/Authz.scala index f2f0e2985d..e18d2a0740 100644 --- a/appsv/server/talkyard/server/authz/Authz.scala +++ b/appsv/server/talkyard/server/authz/Authz.scala @@ -431,6 +431,7 @@ object Authz { groupIds: immutable.Seq[GroupId], postType: PostType, pageMeta: PageMeta, + pageAuthor: Pat, replyToPosts: immutable.Seq[Post], privateGroupTalkMemberIds: Set[UserId], inCategoriesRootLast: immutable.Seq[Category], @@ -442,10 +443,7 @@ object Authz { val mayWhat = checkPermsOnPages( Some(user), asAlias = asAlias, groupIds, Some(pageMeta), - // ANON_UNIMPL: Needed, for maySeeOwn [granular_perms], and later, if there'll be - // a mayReplyOnOwn permission? - // But let's wait until true author id is incl in posts3/nodes_t. [posts3_true_id] - pageAuthor = None, + pageAuthor = Some(pageAuthor), pageMembers = Some(privateGroupTalkMemberIds), catsRootLast = inCategoriesRootLast, tooManyPermissions) @@ -852,8 +850,10 @@ object Authz { // then, pat may not see it, or any sub cat. [see_sub_cat] if (mayWhatThisCat.maySee isNot true) { // But maySeeOwn=true has precedence over maySee=false. - if (isOwnPage && mayWhatThisCat.maySeeOwn) { - // Fine, continue. + if ((isOwnPage || pageMeta.isEmpty) && mayWhatThisCat.maySeeOwn) { + // Fine: Either it's our own page (which we may see), or no page specified — then + // we may still see the category. + mayWhatThisCat = mayWhatThisCat.copy(maySee = Some(true)) } else return mayWhatThisCat @@ -1037,6 +1037,8 @@ case class MayWhat( mayCreatePage: Bo = false, mayPostComment: Bo = false, maySee: Opt[Bo] = None, + // Used to derive maySee: if is own page, and maySeeOwn, then, maySee becomest Some(true). + // Is that a bit weird? Works for now. maySeeOwn: Bo = false, // maySeePagesOneIsMembOf: Bo = false // mayListUnlistedCat diff --git a/build.sbt b/build.sbt index 2f5a3d09fc..7150329c21 100644 --- a/build.sbt +++ b/build.sbt @@ -157,6 +157,16 @@ def mainSettings = List( version := appVersion, libraryDependencies ++= appDependencies, + // Silence warnings about Ty's own deprecated stuff, so they won't clutter the + // build output and make any actually important warnings disappear in the noise. + // It's quick to search for "@deprecated" in Ty's own source code, if / when needed. + scalacOptions += ( + """-Wconf:cat=deprecation&origin=talkyard\..*:silent""" + + """,cat=deprecation&origin=controllers\..*:silent""" + + """,cat=deprecation&origin=com\.debiki\..*:silent""" + + """,cat=deprecation&origin=debiki\..*:silent""" + + """,cat=deprecation&origin=ed\..*:silent"""), + // Place tests in ./tests/app/ instead of ./test/, because there're other tests in // ./tests/, namely security/ and e2e/, and having both ./test/ and ./tests/ seems confusing. // RENAME to ./tests/appsv? (since ./app is now ./appsv) diff --git a/client/app-editor/editor/editor.editor.ts b/client/app-editor/editor/editor.editor.ts index 425f2301fc..91a31fb80d 100644 --- a/client/app-editor/editor/editor.editor.ts +++ b/client/app-editor/editor/editor.editor.ts @@ -2159,6 +2159,8 @@ export const Editor = createFactory({ ifNewPostLooksOk: function(titleErrorMessage, textErrorMessage, ifOkFn: () => Vo) { const state: EditorState = this.state; + const store: Store = state.store; + let errors = ''; if (titleErrorMessage && isBlank(state.title)) { errors += titleErrorMessage; @@ -2177,7 +2179,8 @@ export const Editor = createFactory({ // Haven't updated the tests — many would fail, if "That's a short ..." dialogs pop up. // Also, skip for staff users (if they write something short, it's probably ok) // — later, this'll be per group settings; see pats_t.mod_conf_c. - const skipProbl = isAutoTestSite() || user_isStaffOrCoreMember(state.store.me); + const skipProbl = isAutoTestSite() || user_isStaffOrCoreMember(state.store.me) || + !store_isFeatFlagOn(store, 'ffShortPostTips', true) const titleLen = state.title.trim().length; const textLen = state.text.trim().length; diff --git a/client/app-head/head-bundle.ts b/client/app-head/head-bundle.ts index 008c11f3b3..ec2e128c2b 100644 --- a/client/app-head/head-bundle.ts +++ b/client/app-head/head-bundle.ts @@ -181,6 +181,7 @@ if (_narrow) { // see: https://stackoverflow.com/a/979995/694469 for more details. try { var _searchParams = new URLSearchParams(location.search); + var _ssoHow = _searchParams.get('ssoHow'); var _class = _searchParams.get('htmlClass'); if (_class) { _doc.className += ' ' + _class.replace(/[^a-zA-Z0-9_-]/g, ' '); @@ -286,6 +287,7 @@ if (!eds.isInEmbeddedEditor) { loadGlobalStaffScript: @{ tpi.globals.loadGlobalStaffScript.toString }, */ +eds.ssoHow = _ssoHow; // Backw compat CLEAN_UP convert old js code in these 'namespaces' to Typescript instead [4KSWPY] // RENAME to tyd ("Talkyard Dynamic" things, like is-sth-ready promises?, remove 'internal' and 'v0' diff --git a/client/app-more/forum/create-category-dialog.more.ts b/client/app-more/forum/create-category-dialog.more.ts index 06393fe9be..2a2c7b82fb 100644 --- a/client/app-more/forum/create-category-dialog.more.ts +++ b/client/app-more/forum/create-category-dialog.more.ts @@ -133,11 +133,11 @@ const EditCategoryDialog = createClassAndFactory({ }); } else { - const categoryId = -1; // then the server will give it a >= 1 id [4GKWSR1] + const catIdMin1 = -1; // then the server will give it a >= 1 id [4GKWSR1] Server.loadGroups((groups: Group[]) => { if (this.isGone) return; const newCategory: CategoryPatch = { - id: categoryId, + id: catIdMin1, extId: '', parentId: store.currentPage.categoryId, sectionPageId: store.currentPageId, @@ -157,10 +157,16 @@ const EditCategoryDialog = createClassAndFactory({ canChangeDefault: true, category: newCategory, groups: groups, + // Sync these default perms with Scala code. [7KFWY025] permissions: [ - defaultPermsOnPages(-11, Groups.EveryoneId, categoryId, false), - defaultPermsOnPages(-12, Groups.FullMembersId, categoryId, false), - defaultPermsOnPages(-13, Groups.StaffId, categoryId, true)], + defaultNewCatPerms(-11, Groups.EveryoneId, catIdMin1, false), + // Full members can edit wikis, by default. Apart from that, it is safer if + // un-ticking an Everyone group permission, removes it from Full Members + // too, and only Staff have permissions explicitly granted to themselves. + // [_no_extra_def_perms] [DEFMAYEDWIKI] + { ...noPermsOnPages(-12, Groups.FullMembersId, catIdMin1), mayEditWiki: true }, + // But staff have all permissions explicitly granted to it. TyTSTAFDEFPERMS + defaultNewCatPerms(-13, Groups.StaffId, catIdMin1, true)], } as EditCatDiagState); }); } @@ -582,18 +588,25 @@ const CatSettings = createClassAndFactory({ -function defaultPermsOnPages(newPermId: PermissionId, forWhoId: PeopleId, - categoryId: CategoryId, isStaff: boolean): PermsOnPage { +function noPermsOnPages(newPermId: PermissionId, forWhoId: PeopleId, + categoryId: CatId): PermsOnPage { return { id: newPermId, forPeopleId: forWhoId, onCategoryId: categoryId, + }; +} + + +function defaultNewCatPerms(newPermId: PermissionId, forWhoId: PeopleId, + categoryId: CatId, isStaff: Bo): PermsOnPage { + return { + ...noPermsOnPages(newPermId, forWhoId, categoryId), // Setting these to false is not currently supported. [2LG5F04W] - // Sync these default perms with Scala code. [7KFWY025] mayEditPage: isStaff || undefined, mayEditComment: isStaff || undefined, mayEditWiki: isStaff || forWhoId >= Groups.FullMembersId, // [DEFMAYEDWIKI] - // If someone sees hen's own post, hen would probably get angry if hen couldn't edit it? + // If someone sees hans own post, han would probably get angry if han couldn't edit it? // And staff probably expects everyone to be allowed to edit their own posts, by default? // So, 'true' by default. mayEditOwn: true, @@ -620,7 +633,11 @@ const CatSecurity = createClassAndFactory({ newPermId = p.id - 1; } }); - const newPerm = defaultPermsOnPages(newPermId, Groups.NoUserId, category.id, false); + // Start with zero additional permissions, so the group won't accidentally + // get any unintended permission. [_no_extra_def_perms] TyTNEWPERMSEMPTY + // (We don't know which group, yet. The admin will choose a grop from the + // `SelectGroupDropdown()`.) + const newPerm = noPermsOnPages(newPermId, Groups.NoUserId, category.id); this.props.updatePermissions(permissions.concat(newPerm)); }, diff --git a/client/app-more/login/create-user-dialog.more.ts b/client/app-more/login/create-user-dialog.more.ts index ef09d1b3c9..afdfa6afaf 100644 --- a/client/app-more/login/create-user-dialog.more.ts +++ b/client/app-more/login/create-user-dialog.more.ts @@ -322,6 +322,7 @@ export var CreateUserDialogContent = createClassAndFactory({ } else if (props.afterLoginCallback || ( anyReturnToUrl && !eds.isInLoginPopup && + // We should not redirect here, if we should redirect from verification emails *only*. anyReturnToUrl.indexOf('_RedirFromVerifEmailOnly_') === -1)) { const returnToUrl = anyReturnToUrl.replace(/__dwHash__/, '#'); const currentUrl = window.location.toString(); diff --git a/client/app-slim/form/form.ts b/client/app-slim/form/form.ts index 81f9f759ae..4f9dcf29fa 100644 --- a/client/app-slim/form/form.ts +++ b/client/app-slim/form/form.ts @@ -45,7 +45,7 @@ export function activateAnyCustomForm() { Server.submitCustomFormAsNewTopic(formData); } else if (doWhat.value === 'SignUp') { - login.loginIfNeeded(LoginReason.SignUp, location.toString()); + login.loginIfNeeded(LoginReason.SignUp, ''); } else if (doWhat.value === 'SignUpSubmitUtx') { // [plugin] login.loginIfNeeded(LoginReason.SignUp, '/-/redir-to-my-last-topic', function() { diff --git a/client/app-slim/forum/forum.ts b/client/app-slim/forum/forum.ts index 8c9d035b62..9237f47baa 100644 --- a/client/app-slim/forum/forum.ts +++ b/client/app-slim/forum/forum.ts @@ -550,8 +550,14 @@ const ForumButtons = createComponent({ }, createTopic: function(category: Category) { - const anyReturnToUrl = window.location.toString().replace(/#/, '__dwHash__'); - login.loginIfNeeded(LoginReason.CreateTopic, anyReturnToUrl, () => { + // Remember any #composeTopic action (FragActionType.ComposeTopic), so we'll + // continue composing, after having signed up (if needed) and clicked any + // email verification link. Doesn't seem to work, but I don't think + // anyone anywhere knows about or uses this anyway. BUG TESTS_MISSING TyTFRAGCOMPTO + // (The ComposeTopic frag action is used only when reopening new-topic-drafts, currently.) + const loc = window.location; + const returnToRelativeUrl = loc.pathname + loc.search + loc.hash.replace(/#/, '__dwHash__'); + login.loginIfNeeded(LoginReason.CreateTopic, returnToRelativeUrl, () => { if (this.isGone) return; const newTopicTypes = category.newTopicTypes || []; if (newTopicTypes.length === 0) { diff --git a/client/app-slim/login/login-if-needed.ts b/client/app-slim/login/login-if-needed.ts index 6a9ef70a2f..e1d8c8c797 100644 --- a/client/app-slim/login/login-if-needed.ts +++ b/client/app-slim/login/login-if-needed.ts @@ -16,8 +16,8 @@ */ /// +// (Why does this behave as -already-loaded.ts? Oh well. [_5BKRF020]) /// -// or should be ...already-loaded ? (5BKRF020) //------------------------------------------------------------------------------ namespace debiki2.login { @@ -48,75 +48,122 @@ export function getAuthnNonce(): St | U { // From before React.js. Gah! This needs to be refactored :-/ Try to remove this field. -export let anyContinueAfterLoginCallback = null; +export let anyContinueAfterLoginCallback: (() => V) | U; +/// If login needed, redirects to `postNr` only if the user was signing up and had +/// to click an email verification link (the redirect link is then in the email addr +/// verification email). Otherwise, runs `onOk()`. +/// export function loginIfNeededReturnToPost( - loginReason: LoginReason, postNr: PostNr, success: () => void, - willCompose?: boolean) { + loginReason: LoginReason, postNr: PostNr, onOk: () => V, willCompose?: Bo) { // If posting a progress post, then, after login, scroll to the bottom, so one // can click that button again — it's at the bottom. const anchor = loginReason === LoginReason.PostProgressPost ? FragActionHashScrollToBottom - : (postNr < FirstReplyNr ? '' : ( + : (postNr < FirstReplyNr + ? + // UX COULD: Here it could be nice, if in embedded comments, scroll down to + // the comments section? [scroll_to_emb_comts] + '' + : ( // We use 'comment-' for embedded comments; they start on nr 1 = post 2. [2PAWC0] - eds.isInEmbeddedCommentsIframe + // (Hopefully the embedding website has no elems with ids like 'comment-NNN'.) + eds.isInIframe ? FragParamCommentNr + (postNr - 1) : FragParamPostNr + postNr)); - loginIfNeededReturnToAnchor(loginReason, anchor, success, willCompose); + loginIfNeededImpl(loginReason, anchor, null, true, onOk, willCompose); } +/// Same as `loginIfNeededReturnToPost()` above, but goes to `anchor` (a #hash-fragment) +/// after any signup, instead of to a post nr. +/// export function loginIfNeededReturnToAnchor( - loginReason: LoginReason, anchor: string, success?: () => void, willCompose?: boolean) { - const returnToUrl = makeReturnToPageHashForVerifEmail(anchor); - success = success || function() {}; + loginReason: LoginReason, anchor: St, onOk?: () => V, willCompose?: Bo) { + loginIfNeededImpl(loginReason, anchor, null, true, onOk, willCompose); +} + + +/// If login needed, always redirects to `path` afterwards and ignores `onOk()`. +/// +export function loginIfNeeded(loginReason: LoginReason, path: St, onOk?: () => V, + willCompose?: Bo) { + loginIfNeededImpl(loginReason, null, path, false, onOk, willCompose); +} + + +function loginIfNeededImpl(loginReason: LoginReason, toHash: St, toPath: St, + redirFromEmailOnly: Bo, onOk?: () => V, willCompose?: Bo) { + + onOk = onOk || function() {}; const store: Store = ReactStore.allData(); const me: Myself = store.me; + + // No login needed, or not until later when submitting any comment? if (me.isLoggedIn || (willCompose && ReactStore.mayComposeBeforeSignup())) { - success(); + onOk(); + return; } - else if (eds.isInIframe) { - // ... or only if isInSomeEmbCommentsIframe()? - anyContinueAfterLoginCallback = success; + const makeReturnToUrl = (): St => { + // This can't happen, currently. And, currently, `toPath` is a Talkyard URL path, + // not a path for any embedding website. + dieIf(toPath && eds.embeddingUrl, 'TyEREDIREMBNGPATH'); - // Don't open a dialog inside the iframe; open a popup instead. - // Need to open the popup here immediately, before loading any scripts, because if - // not done immediately after mouse click, the popup gets blocked (in Chrome at least). - // And when opening in a popup, we don't need any more scripts here in the main win anyway. - const url = origin() + '/-/login-popup?mode=' + loginReason + // [2ABKW24T] - '&isInLoginPopup&returnToUrl=' + returnToUrl; - d.i.createLoginPopup(url); - } - else { - loginIfNeeded(loginReason, returnToUrl, success); + let url = toPath ? location.origin + toPath : eds.embeddingUrl || location.toString(); + + // (This can be a Talkyard hash, e.g. #post-123. But can also be #comment-123 and + // that's for the embedd*ing* website and Talkyard's script there, which looks at + // the hash and scrolls to that comment in Ty's blog comments iframe.) + if (toHash) { + url = url.replace(/#.*/, '') + toHash; + } + return url; } -} + const returnToUrl_new = makeReturnToUrl(); + const returnToUrl_legacy = redirFromEmailOnly ? + makeReturnToPageHashForVerifEmail(toHash) : returnToUrl_new; -// Later, merge with loginIfNeededReturnToAnchor() above, and rename to loginIfNeeded, and use only -// that fn always — then will work also in iframe (will open popup). -export function loginIfNeeded(loginReason: LoginReason, returnToUrl: St, onOk?: () => Vo, - willCompose?: Bo) { - if (ReactStore.getMe().isLoggedIn || (willCompose && ReactStore.mayComposeBeforeSignup())) { - if (onOk) onOk(); + if (eds.isInIframe && eds.ssoHow !== 'RedirPage') { + // TESTS_MISSING: Compose comment before logging in? Then, we'd be TyTEMBCOMPBEFLGI + // in the *editor* iframe, now, rather than the *comments* iframe. + + anyContinueAfterLoginCallback = onOk; + + // Don't open a dialog inside the iframe; open a popup instead. + // Need to open the popup here immediately, because if not done immediately after + // mouse click, the popup gets blocked (in Chrome at least). + // + // (This'll call `LoginController.showLoginPopup()` in the app server, to show: + // ../../../appsv/server/views/authn/authnPage.scala.html in a popup. + // The popup calls `debiki2.login.getLoginDialog().openToSignUp()`. That's similar to + // the else case below, but in a popup, [_popup_or_not], with no SSO and not admin area.) + // + const url = origin() + '/-/login-popup?mode=' + loginReason + // [2ABKW24T] + '&isInLoginPopup&returnToUrl=' + returnToUrl_legacy; + d.i.createLoginPopup(url); } else { - goToSsoPageOrElse(returnToUrl, loginReason, onOk, function() { + goToSsoPageOrElse(returnToUrl_new, returnToUrl_legacy, onOk, function() { Server.loadMoreScriptsBundle(() => { + // (This is similar to above [_popup_or_not], but in the main win, not in a popup.) + // People with an account, are typically logged in already, and won't get to here often. // Instead, most people here, are new users, so show the signup dialog. // But when creating a new site, one logs in as admin (NeedToBeAdmin) if one // clicked the link (verified one's admin email), but then tries to log in in // another browser. - // (Why won't this result in a compil err? (5BKRF020)) + + // (Why won't this result in a compil err? We're including: + // ../more-bundle-not-yet-loaded.ts only, not ...-already-loaded.ts. [_5BKRF020]) const diag = debiki2.login.getLoginDialog(); const logInOrSignUp = loginReason === LoginReason.NeedToBeAdmin ? diag.openToLogIn : diag.openToSignUp; logInOrSignUp( - loginReason, returnToUrl, onOk || function() {}); + loginReason, returnToUrl_legacy, onOk || function() {}); }); }); } @@ -124,7 +171,7 @@ export function loginIfNeeded(loginReason: LoginReason, returnToUrl: St, onOk?: export function openLoginDialogToSignUp(purpose: LoginReason) { - goToSsoPageOrElse(location.toString(), purpose, null, function() { + goToSsoPageOrElse(location.toString(), null, null, function() { Server.loadMoreScriptsBundle(() => { debiki2.login.getLoginDialog().openToSignUp(purpose); }); @@ -133,7 +180,7 @@ export function openLoginDialogToSignUp(purpose: LoginReason) { export function openLoginDialog(purpose: LoginReason) { - goToSsoPageOrElse(location.toString(), purpose, null, function() { + goToSsoPageOrElse(location.toString(), null, null, function() { Server.loadMoreScriptsBundle(() => { debiki2.login.getLoginDialog().openToLogIn(purpose); }); @@ -141,26 +188,34 @@ export function openLoginDialog(purpose: LoginReason) { } -function goToSsoPageOrElse(returnToUrl: St, toDoWhat: LoginReason | U, - doAfterLogin: () => void | U, orElse: () => void) { +function goToSsoPageOrElse(returnToUrl: St, returnToUrl_legacy: St | N, + doAfterLogin: (() => V) | U, orElse: () => V): V { // Dupl code? [SSOINSTAREDIR] const store: Store = ReactStore.allData(); - const anySsoUrl: St | U = makeSsoUrl(store, returnToUrl); + const anySsoUrl: St | U = makeSsoUrl(store, returnToUrl, returnToUrl_legacy); if (anySsoUrl) { // Currently Talkyard's own SSO opens in the same window (not in a popup win) // — let's keep that behavior, for backw compatibility. - // Maybe one day will be a conf val? + // Maybe one day will be a conf val? [[Upd 2024: Yes now there is: 'RedirPage', + // so blog comments can redirect the whole embedd*ing* page.]] // However, let custom IDP SSO open in a popup — this works better // with embedded comments, [2ABKW24T] // and if logging in because sumbitting a reply — then, it's nice to // stay on the same page, and navigate away to the IDP only in a popup win, // so the editor stays open and one can submit the reply, after login. - if (store.settings.enableSso) { + if (store.settings.enableSso && eds.ssoHow === 'RedirPage') { + window.parent.postMessage(JSON.stringify(['ssoRedir', anySsoUrl]), eds.embeddingOrigin); + } + else if (store.settings.enableSso) { + // This is Ty's own SSO. + // Harmless bug: If session & local storage don't work, this redirect will // destroy the browser authn nonce. [br_authn_nonce] location.assign(anySsoUrl); // backw compat, see above } else { + // This is SSO too, but using some standard like OAuth2 (not Ty's own). + // This'll trigger the [SSOINSTAREDIR] code in login-dialog.more.ts — the // SSO url then gets reconstructed, so we don't need to include it here. // BUT, sleeping BUG: we should incl the authn nonce! [br_authn_nonce] @@ -181,10 +236,14 @@ function goToSsoPageOrElse(returnToUrl: St, toDoWhat: LoginReason | U, } +// Constructs a url to an external SSO server to which the browser should be sent +// to log in. Included in this url, is a return-to-url, so, after login, +// the exteral SSO server knows where to send the user next. +// // forTySsoTest: If we're on the Ty SSO test page, and should only generate // a SSO url if Talkyard's own SSO is in use (but not any external OIDC or OAuth2 IDP). // -export function makeSsoUrl(store: Store, returnToUrlMaybeMagicRedir: St, +export function makeSsoUrl(store: Store, returnToUrl_new: St, returnToUrlMagicRedir_legacy?: St | N, forTySsoTest?: true): St | U { const settings: SettingsVisibleClientSide = store.settings; const talkyardSsoUrl = (settings.enableSso || forTySsoTest) && settings.ssoUrl; @@ -197,46 +256,69 @@ export function makeSsoUrl(store: Store, returnToUrlMaybeMagicRedir: St, // Remove magic text that tells the Talkyard server to redirect to the return to url, // only if it sends an email address verification email. (Via a link in that email.) - const returnToUrl = returnToUrlMaybeMagicRedir.replace('_RedirFromVerifEmailOnly_', ''); + // Might still include a weird '__dwHash__' to encode '#' (instead of percent encoding). + const returnToUrl_legacy = returnToUrlMagicRedir_legacy + ? returnToUrlMagicRedir_legacy.replace('_RedirFromVerifEmailOnly_', '') + : returnToUrl_new; - const origin = location.origin; - const returnToPathQueryHash = returnToUrl.substr(origin.length, 9999); + const origin = eds.embeddingOrigin || location.origin; + const returnToPathQueryHash_new = returnToUrl_new.substring(origin.length); + const returnToPathQueryHash_legacy = returnToUrl_legacy.substring(origin.length); const [nonce, lastsAcrossReload] = login.getOrCreateAuthnNonce(); // The SSO endpoint needs to check the return to full URL or origin against a white list - // to verify that the request isn't a phishing attack — i.e. someone who sets up a site + // to verify that the request isn't a [_phishing] attack — i.e. someone who sets up a site // that looks exactly like the external website where Single Sign-On happens, - // or looks exactly like the Talkyard forum, and uses $[returnTo...} to redirect + // or looks exactly like the Talkyard forum, and uses ${returnTo...} to redirect // to the phishing site. — That's why the full url and the origin params have // Dangerous in their names. // Usually there'd be just one entry in the "white list", namely the address to the // Talkyard forum. And then, better use `${talkyardPathQueryEscHash}` instead. However, - // can be many Talkyard origins, if there's also a blog with embedded comments, - // or more than one forum, which all use the same SSO login page. + // can be many origins, if there's also a blog with embedded comments (e.g. blog.company.com), + // or more than one forum (e.g. forum.company.com), which all use the same SSO login page. const ssoUrlWithReturn = talkyardSsoUrl ? (talkyardSsoUrl - .replace('${talkyardUrlDangerous}', returnToUrl) + // Legacy: + .replace('${talkyardUrlDangerous}', returnToUrl_legacy) .replace('${talkyardOriginDangerous}', origin) - .replace('${talkyardPathQueryEscHash}', returnToPathQueryHash)) + .replace('${talkyardPathQueryEscHash}', returnToPathQueryHash_legacy) + + // Better? + // - Let's percent encode the parameters, instead of '__dwHash__'. + // - Let's prefix the origin with a reminder for the Ty SSO integration + // to look at the origin and check if it's one of their origins (and not + // a [_phishing] site, see above). This makes the origin parameter + // invalid, so they cannot forget to look at it (then, won't work). + // (In 'check_if_legit!', '!' won't get % encoded, but ':' would have been.) + // - Let's not say "Talkyard URL" or "Talkyard origin" — because if we're + // SSO logging in to blog comments, the user won't be redirected back to + // any *Talkyard* origin, but to the embedd*ing* webiste, e.g. a Ghost + // blog or static website. (If they need to know it's for Talkyard SSO, + // they can add an `&isTalkyard=true` query string param themselves.) + .replace('${returnToOrigin}', encodeURIComponent('check_if_legit!' + origin)) + // The external SSO server should send the user to the return-to-origin + // plus this /path/and/maybe/?query=and#hash. + .replace('${returnToPathQueryHash}', encodeURIComponent(returnToPathQueryHash_new))) + // + nonce, later [br_authn_nonce] : ( `${UrlPaths.AuthnRoot}${customSsoIdp.protocol}/${customSsoIdp.alias}` + - `?returnToUrl=${returnToPathQueryHash}` + + `?returnToUrl=${returnToPathQueryHash_legacy}` + `&nonce=${nonce}` ); return ssoUrlWithReturn; } -function makeReturnToPageHashForVerifEmail(hash) { +function makeReturnToPageHashForVerifEmail(hash: St): St { // The magic '__Redir...' string tells the server to use the return-to-URL only if it // needs to send an email address verification email (it'd include the return // to URL on a welcome page show via a link in the email). // '__dwHash__' is an encoded hash that won't be lost when included in a GET URL. // The server replaces it with '#' later on. // If we're showing embedded comments in an