diff --git a/appsv/model/src/main/scala/com/debiki/core/Post.scala b/appsv/model/src/main/scala/com/debiki/core/Post.scala index fde998b68a..fca0a1a028 100644 --- a/appsv/model/src/main/scala/com/debiki/core/Post.scala +++ b/appsv/model/src/main/scala/com/debiki/core/Post.scala @@ -354,7 +354,7 @@ case class Draft( deletedAt: Option[When] = None, topicType: Option[PageType] = None, postType: Option[PostType] = None, - doAsAnon: Opt[WhichAnon], + doAsAnon: Opt[WhichAliasId], title: String, text: String) { diff --git a/appsv/model/src/main/scala/com/debiki/core/SiteTransaction.scala b/appsv/model/src/main/scala/com/debiki/core/SiteTransaction.scala index dcc3dfa1bf..3be4ec1a2b 100644 --- a/appsv/model/src/main/scala/com/debiki/core/SiteTransaction.scala +++ b/appsv/model/src/main/scala/com/debiki/core/SiteTransaction.scala @@ -494,6 +494,8 @@ trait SiteTransaction { RENAME // to SiteTx — already started with a type Si def insertAnonym(anonym: Anonym): U + def loadAnyAnon(userId: UserId, pageId: PageId, anonStatus: AnonStatus): Opt[Anonym] + def nextMemberId: UserId def insertMember(user: UserInclDetails): Unit diff --git a/appsv/model/src/main/scala/com/debiki/core/dao-db.scala b/appsv/model/src/main/scala/com/debiki/core/dao-db.scala index 57c6858a60..29657be6bf 100644 --- a/appsv/model/src/main/scala/com/debiki/core/dao-db.scala +++ b/appsv/model/src/main/scala/com/debiki/core/dao-db.scala @@ -69,6 +69,7 @@ object DbDao { object BadPasswordException extends QuickMessageException("Bad password") object UserDeletedException extends QuickMessageException("User deleted") + RENAME // to DuplicateActionEx? case object DuplicateVoteException extends RuntimeException("Duplicate vote") class PageNotFoundException(message: String) extends RuntimeException(message) diff --git a/appsv/model/src/main/scala/com/debiki/core/package.scala b/appsv/model/src/main/scala/com/debiki/core/package.scala index 99a87290c7..e8ff6a3874 100644 --- a/appsv/model/src/main/scala/com/debiki/core/package.scala +++ b/appsv/model/src/main/scala/com/debiki/core/package.scala @@ -939,20 +939,6 @@ package object core { } - /* - sealed abstract class AnonLevel(val IntVal: i32) { def toInt: i32 = IntVal } - object AnonLevel { - case object NotAnon extends AnonLevel(10) - case object AnonymPerPage extends AnonLevel(50) - - def fromInt(value: i32): Opt[AnonLevel] = Some(value match { - case NotAnon.IntVal => NotAnon - case AnonymPerPage.IntVal => AnonymPerPage - case _ => return None - }) - }*/ - - /** A bitfield. Currently only None, 65535 = IsAnonOnlySelfCanDeanon * and 2097151 = IsAnonCanAutoDeanon are supported. @@ -1080,27 +1066,92 @@ package object core { } - sealed abstract class WhichAnon() { - require(anySameAnonId.isDefined != anyNewAnonStatus.isDefined, "TyE6G0FM2TF3") + /** For before an alias has been looked up — we know only its id. Or, + * if it's a lazy-created anon, we don't know its id (doesn't yet exist), + * instead, we only know what type of anon it's going to be, that is, its + * future anon status (currently, either temporarily anonymous, + * for ideation, or permanently, for sensitive discussions). + */ + sealed abstract class WhichAliasId() { + // Remove later. [chk_alias_status] + require(anySameAliasId.isEmpty || anyAnonStatus.isEmpty, "TyE6G0FM2TF3") + + def anyAnonStatus: Opt[AnonStatus] + def anySameAliasId: Opt[AnonId] + } + + + object WhichAliasId { + + /** For doing sth as oneself (even if anonymity is the default) — "Yourself Mode". */ + case object Oneself extends WhichAliasId { + def anySameAliasId: Opt[AnonId] = None + def anyAnonStatus: Opt[AnonStatus] = None + } + + // Later: [pseudonyms_later] + //case class SamePseudonym(sameAliasId: PatId) extends WhichAliasId with SameAlias { + // def anySameAliasId: Opt[PatId] = Some(sameAliasId) + // def anyAnonStatus: Opt[AnonStatus] = None + //} + + COULD // add anonStatus, error if mismatch? [chk_alias_status] + case class SameAnon(sameAnonId: PatId) extends WhichAliasId { + require(sameAnonId <= Pat.MaxAnonId, s"Not an anon id: $sameAnonId") + def anySameAliasId: Opt[AnonId] = Some(sameAnonId) + def anyAnonStatus: Opt[AnonStatus] = None + } + + case class LazyCreatedAnon(anonStatus: AnonStatus) extends WhichAliasId { + require(anonStatus != AnonStatus.NotAnon, "WhichAliasId.anonStatus is NotAnon [TyE2M068G]") + def anySameAliasId: Opt[AnonId] = None + def anyAnonStatus: Opt[AnonStatus] = Some(anonStatus) + } + } + - // Either ... - def anyNewAnonStatus: Opt[AnonStatus] = None - // ... or. - def anySameAnonId: Opt[AnonId] = None + /** For after the alias has been looked up by any id, when we have an Anonym or Pseudonym, + * not just an id. (Or still just a to-be-lazy-created anonym, with a future anon status.) + */ + sealed trait WhichAliasPat { + def anyPat: Opt[Pat] } - object WhichAnon { - case class NewAnon(anonStatus: AnonStatus) extends WhichAnon { - require(anonStatus != AnonStatus.NotAnon, "WhichAnon is NotAnon [TyE2MC06Y8G]") - override def anyNewAnonStatus: Opt[AnonStatus] = Some(anonStatus) + + object WhichAliasPat { + // Later: [pseudonyms_later] + // Create a Pseudonym class? Pat / PatBr has unnecessary stuff, e.g. sso id. + //case class SamePseudonym(pseudonym: Pseudonym) extends WhichAliasPat { + // def anyPat: Opt[Pat] = Some(pseudonym) + //} + + case class SameAnon(anon: Anonym) extends WhichAliasPat { + def anyPat: Opt[Pat] = Some(anon) } - case class SameAsBefore(sameAnonId: PatId) extends WhichAnon { - override def anySameAnonId: Opt[AnonId] = Some(sameAnonId) + /** Reuses any already existing anonym with the same anon status, + * on the same page. + * + * If there're many, on the relevant page, then what? Throw an error? + * Can't happen, yet, because [one_anon_per_page]. + */ + case class LazyCreatedAnon(anonStatus: AnonStatus) extends WhichAliasPat { + def anyPat: Opt[Pat] = None // might not yet exist } + + // Let's not support creating more than one anonym per user & page, for now. + //case class NewAnon(anonStatus: AnonStatus) extends WhichAliasPat { + // def anyPat: Opt[Pat] = None + //} } + sealed abstract class AnyUserAndLevels { + def anyUser: Opt[Pat] + def trustLevel: TrustLevel + def threatLevel: ThreatLevel + } + /** * @param user, (RENAME to patOrPseudonym?) — the id of the requester, can be a pseudonym. But not an anonym. * @param trustLevel — if patOrPseudonym is a pseudonym, then this is the pseudonym's @@ -1111,13 +1162,17 @@ package object core { user: Pat, trustLevel: TrustLevel, threatLevel: ThreatLevel, - ) { + ) extends AnyUserAndLevels { + def anyUser = Some(user) def id: UserId = user.id def isStaff: Boolean = user.isStaff def nameHashId: String = user.nameHashId } - case class AnyUserAndThreatLevel(user: Option[Participant], threatLevel: ThreatLevel) + case class StrangerAndThreatLevel(threatLevel: ThreatLevel) extends AnyUserAndLevels { + def anyUser: Opt[Pat] = None + def trustLevel: TrustLevel = TrustLevel.Stranger + } sealed trait OrderBy { def isDescending: Boolean = false } @@ -2057,6 +2112,7 @@ package object core { implicit class RichBoolean(underlying: Boolean) { + // (For find-by-similar-name: "oneIfTrue".) def toZeroOne: i32 = if (underlying) 1 else 0 } diff --git a/appsv/model/src/main/scala/com/debiki/core/permissions.scala b/appsv/model/src/main/scala/com/debiki/core/permissions.scala index 8edbfab568..1029242a5f 100644 --- a/appsv/model/src/main/scala/com/debiki/core/permissions.scala +++ b/appsv/model/src/main/scala/com/debiki/core/permissions.scala @@ -123,6 +123,7 @@ case class PermsOnPages( // [exp] ok use. Missing, fine: may_see_private_flagge mayCreatePage: Opt[Bo] = None, mayPostComment: Opt[Bo] = None, maySee: Opt[Bo] = None, + // Wants index: pages_i_authorid_catid_createdat_pageid maySeeOwn: Opt[Bo] = None) { // maySeeIfEmbeddedAlthoughLoginRequired [emb_login_req] 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 0047326680..c9881001a1 100644 --- a/appsv/model/src/main/scala/com/debiki/core/user.scala +++ b/appsv/model/src/main/scala/com/debiki/core/user.scala @@ -1024,6 +1024,10 @@ trait MemberMaybeDetails { } +trait Alias { + def aliasForPatId: PatId +} + case class Anonym( id: AnonId, @@ -1032,12 +1036,18 @@ case class Anonym( anonForPatId: MembId, anonOnPageId: PageId, // deanonymizedById: Opt[MembId], // later - ) extends Pat with GuestOrAnon with Someone { + ) extends Pat with GuestOrAnon with Someone with Alias { override def trueId2: TrueId = TrueId(id, anyTrueId = Some(anonForPatId)) + def aliasForPatId = anonForPatId def anyUsername: Opt[St] = None + + // Not that much in use — client side code shows the anon status instead [anon_2_str] + // (e.g. "Temp Anonym" or "Aonymous"), in different languages. But is used in + // "Written by ..." in email notifications? def nameOrUsername: St = "Anonym" + override def anyName: Opt[St] = Some(nameOrUsername) override def usernameOrGuestName: St = nameOrUsername diff --git a/appsv/rdb/src/main/resources/db/migration/db-wip.sql b/appsv/rdb/src/main/resources/db/migration/db-wip.sql index f669691c5b..09a25cd532 100644 --- a/appsv/rdb/src/main/resources/db/migration/db-wip.sql +++ b/appsv/rdb/src/main/resources/db/migration/db-wip.sql @@ -64,6 +64,21 @@ alter domain alnum_plusdashdot_arr_d add -- Odd, last_approved_edit_at can be not null, also if approved_at is null. -- Harmless but maybe surprising in the future. +-- For listing pages by someone in a specific category. Helpful, for categories where +-- one may post topics, but not see others' posts. That is: +-- PermsOnPages( +-- mayCreatePage = true, +-- mayPostComment = true, +-- maySee = false <—— +-- maySeeOwn = true <—— +-- ...) + +create index pages_i_authorid_catid_createdat_pageid on pages3 ( + site_id, author_id, category_id, created_at desc, page_id desc); + +-- No longer needed. Same as pages_i_createdby_catid but only on: (site_id, author_id). +drop index dw2_pages_createdby__i; + --============================================================================= -- Upload refs diff --git a/appsv/rdb/src/main/resources/db/migration/y2024/wip_v427__alias.sql b/appsv/rdb/src/main/resources/db/migration/y2024/wip_v427__alias.sql new file mode 100644 index 0000000000..e100926e9b --- /dev/null +++ b/appsv/rdb/src/main/resources/db/migration/y2024/wip_v427__alias.sql @@ -0,0 +1,7 @@ + + +alter table page_users3 add column prefer_alias_id_c pat_id_d; +-- + +-- fk deferred +-- ix + diff --git a/appsv/rdb/src/main/scala/com/debiki/dao/rdb/DraftsSiteDaoMixin.scala b/appsv/rdb/src/main/scala/com/debiki/dao/rdb/DraftsSiteDaoMixin.scala index b7c7e34147..79be362082 100644 --- a/appsv/rdb/src/main/scala/com/debiki/dao/rdb/DraftsSiteDaoMixin.scala +++ b/appsv/rdb/src/main/scala/com/debiki/dao/rdb/DraftsSiteDaoMixin.scala @@ -111,8 +111,8 @@ trait DraftsSiteDaoMixin extends SiteTransaction { locator.postId.orNullInt, draft.postType.map(_.toInt).orNullInt, locator.toUserId.orNullInt, - draft.doAsAnon.flatMap(_.anySameAnonId.map(_.toInt)).orNullInt, - draft.doAsAnon.flatMap(_.anyNewAnonStatus.map(_.toInt)).orNullInt, + draft.doAsAnon.flatMap(_.anySameAliasId.map(_.toInt)).orNullInt, + draft.doAsAnon.flatMap(_.anyAnonStatus.map(_.toInt)).orNullInt, draft.title, draft.text)) } @@ -215,7 +215,7 @@ trait DraftsSiteDaoMixin extends SiteTransaction { Draft( byUserId = getInt(rs, "by_user_id"), - doAsAnon = parseWhichAnon(rs), + doAsAnon = parseWhichAliasId(rs), draftNr = getInt(rs, "draft_nr"), forWhat = draftLocator, createdAt = getWhen(rs, "created_at"), @@ -228,21 +228,26 @@ trait DraftsSiteDaoMixin extends SiteTransaction { } - /** Sync w talkyard.server.parser.parseWhichAnonJson(). + /** Sync w talkyard.server.parser.parseWhichAliasIdJson(). */ - def parseWhichAnon(rs: js.ResultSet): Opt[WhichAnon] = { + private def parseWhichAliasId(rs: js.ResultSet): Opt[WhichAliasId] = { val sameAnonId = getOptInt(rs, "post_as_id_c") + + // Would need to remember anonStatus in new_anon_status_c, to [chk_alias_status] + // be able to check if the alias still has the same status as when the user + // started composing the draft. (If different, could notify han.) + // // PostgreSQL custom domain anonym_status_d has verified that the value is valid. val newAnonStatus = AnonStatus.fromOptInt(getOptInt(rs, "new_anon_status_c")) dieIf(sameAnonId.isDefined && newAnonStatus.isDefined, "TyE6023RAKJ5", "Both post_as_id_c and new_anon_status_c non-null") if (sameAnonId.isDefined) { - Some(WhichAnon.SameAsBefore(sameAnonId.get)) + Some(WhichAliasId.SameAnon(sameAnonId.get)) } else if (newAnonStatus.isDefined) { val anonStatus = newAnonStatus.get if (anonStatus == AnonStatus.NotAnon) return None - Some(WhichAnon.NewAnon(anonStatus)) + Some(WhichAliasId.LazyCreatedAnon(anonStatus)) } else { None diff --git a/appsv/rdb/src/main/scala/com/debiki/dao/rdb/PostsSiteDaoMixin.scala b/appsv/rdb/src/main/scala/com/debiki/dao/rdb/PostsSiteDaoMixin.scala index 73c0c0d8ed..62e8d27f85 100644 --- a/appsv/rdb/src/main/scala/com/debiki/dao/rdb/PostsSiteDaoMixin.scala +++ b/appsv/rdb/src/main/scala/com/debiki/dao/rdb/PostsSiteDaoMixin.scala @@ -1291,30 +1291,60 @@ trait PostsSiteDaoMixin extends SiteTransaction { case vote: PostVote => insertPostActionImpl( postId = vote.uniqueId, pageId = vote.pageId, postNr = vote.postNr, - actionType = vote.voteType, doerId = vote.doerId, doneAt = vote.doneAt) + actionType = vote.voteType, doerId = vote.doerId, doneAt = vote.doneAt, + manyOk = false) case flag: PostFlag => insertPostActionImpl( postId = flag.uniqueId, pageId = flag.pageId, postNr = flag.postNr, - actionType = flag.flagType, doerId = flag.doerId, doneAt = flag.doneAt) + actionType = flag.flagType, doerId = flag.doerId, doneAt = flag.doneAt, + manyOk = true) case rel: PatNodeRel[_] => // This covers owner-of (or will owner-of be in pat_node_multi_rels_t?), // author-of and assigned-to. // (The other approach: PostVote and PostFlag, above, is deprecated.) insertPostActionImpl( postId = rel.uniqueId, pageId = rel.pageId, postNr = rel.postNr, - actionType = rel.relType, doerId = rel.fromPatId, doneAt = rel.addedAt) + actionType = rel.relType, doerId = rel.fromPatId, doneAt = rel.addedAt, + manyOk = false) } } private def insertPostActionImpl(postId: PostId, pageId: PageId, postNr: PostNr, - actionType: PostActionType, doerId: PatIds, doneAt: When) { - val statement = """ + actionType: PostActionType, doerId: PatIds, doneAt: When, manyOk: Bo) { + + val subTypeOne: i32 = 1 + + // Has the same person done this already (e.g. voted), using another persona? + if (!manyOk) { + // Let's run a `select`, so we'll know for sure what's wrong. If we instead + // use `insert into ... where not exists (...)`, we can't know if 0 updated rows + // is because of duplicated actions, or a SQL query or values bug. + TESTS_MISSING // TyTALIVOTES + val query = s""" + select * from post_actions3 + where site_id = ? + and to_post_id_c = ? + and rel_type_c = ? + and (from_pat_id_c = ? or from_true_id_c = ?) + and sub_type_c = $subTypeOne + -- Let's skip, for now — otherwise might run into conflicts, if + -- undoing the deletion of a vote? + -- and deleted_at is null + limit 1 """ + val values = List(siteId.asAnyRef, postId.asAnyRef, toActionTypeInt(actionType), + doerId.trueId.asAnyRef, doerId.trueId.asAnyRef) + runQueryFindMany(query, values, rs => { + throw DbDao.DuplicateVoteException + }) + } + + val statement = s""" insert into post_actions3(site_id, to_post_id_c, page_id, post_nr, rel_type_c, from_pat_id_c, from_true_id_c, created_at, sub_type_c) - values (?, ?, ?, ?, ?, ?, ?, ?, 1) - """ + values (?, ?, ?, ?, ?, ?, ?, ?, $subTypeOne) """ + val values = List[AnyRef](siteId.asAnyRef, postId.asAnyRef, pageId, postNr.asAnyRef, toActionTypeInt(actionType), doerId.pubId.asAnyRef, doerId.anyTrueId.orNullInt32, doneAt.asTimestamp) diff --git a/appsv/rdb/src/main/scala/com/debiki/dao/rdb/RdbUtil.scala b/appsv/rdb/src/main/scala/com/debiki/dao/rdb/RdbUtil.scala index 8677c5c16a..cf11a4a637 100644 --- a/appsv/rdb/src/main/scala/com/debiki/dao/rdb/RdbUtil.scala +++ b/appsv/rdb/src/main/scala/com/debiki/dao/rdb/RdbUtil.scala @@ -290,7 +290,7 @@ object RdbUtil { def createdAt = getWhen(rs, "u_created_at") val emailNotfPrefs = { - if (isGuestId(userId)) + if (isGuestId(userId) && anonStatus.isEmpty) _toEmailNotfs(rs.getString("g_email_notfs")) else _toEmailNotfs(rs.getString("u_email_notfs")) @@ -313,8 +313,7 @@ object RdbUtil { createdAt = createdAt, anonForPatId = getInt32(rs, "u_true_id_c"), anonStatus = anonStatus.get, - anonOnPageId = getString(rs, "u_anon_on_page_id_st_c"), - ) + anonOnPageId = getString(rs, "u_anon_on_page_id_st_c")) } else if (isGuestId(userId)) { Guest( diff --git a/appsv/rdb/src/main/scala/com/debiki/dao/rdb/UserSiteDaoMixin.scala b/appsv/rdb/src/main/scala/com/debiki/dao/rdb/UserSiteDaoMixin.scala index fdb72081e5..46d9670a89 100644 --- a/appsv/rdb/src/main/scala/com/debiki/dao/rdb/UserSiteDaoMixin.scala +++ b/appsv/rdb/src/main/scala/com/debiki/dao/rdb/UserSiteDaoMixin.scala @@ -617,6 +617,21 @@ trait UserSiteDaoMixin extends SiteTransaction { // RENAME; QUICK // to UserSit } + def loadAnyAnon(userId: UserId, pageId: PageId, anonStatus: AnonStatus): Opt[Anonym] = { + val query = s""" + select $UserSelectListItemsNoGuests + from users3 u + where site_id = ? + and true_id_c = ? + and anon_on_page_id_st_c = ? + and anonym_status_c = ? """ + val values = List(siteId.asAnyRef, userId.asAnyRef, pageId, anonStatus.toInt.asAnyRef) + // One or none: Should be at most [one_anon_per_page] of the same type (anon status). + runQueryFindOneOrNone(query, values, rs => + getParticipant(rs).asAnonOrThrow) + } + + def insertMember(user: UserInclDetails) { try { runUpdate(""" diff --git a/appsv/server/controllers/CloseCollapseController.scala b/appsv/server/controllers/CloseCollapseController.scala index 5b4e5c8c78..f7439a2818 100644 --- a/appsv/server/controllers/CloseCollapseController.scala +++ b/appsv/server/controllers/CloseCollapseController.scala @@ -67,7 +67,10 @@ class CloseCollapseController @Inject()(cc: ControllerComponents, edContext: TyC val pageId = (apiReq.body \ "pageId").as[PageId] val postNr = (apiReq.body \ "postNr").as[PostNr] - dao.changePostStatus(postNr, pageId = pageId, action, apiReq.reqrIds) + ANON_UNIMPL // hide, close, collapse comment trees + // Later: SiteDao.checkAliasOrThrowForbidden + + dao.changePostStatus(postNr, pageId = pageId, action, apiReq.reqrIds, asAlias = None) OkSafeJson(dao.jsonMaker.postToJson2(postNr, pageId = pageId, // COULD stop including post in reply? It'd be annoying if other unrelated changes were loaded just because the post was toggled open? includeUnapproved = true)) diff --git a/appsv/server/controllers/CustomFormController.scala b/appsv/server/controllers/CustomFormController.scala index cc4f8065f2..2fdd8b627d 100644 --- a/appsv/server/controllers/CustomFormController.scala +++ b/appsv/server/controllers/CustomFormController.scala @@ -21,6 +21,7 @@ import com.debiki.core._ import com.debiki.core.Prelude._ import debiki._ import debiki.EdHttp._ +import debiki.dao.CreatePageResult import talkyard.server._ import talkyard.server.authz.Authz import javax.inject.Inject @@ -52,15 +53,13 @@ class CustomFormController @Inject()(cc: ControllerComponents, edContext: TyCont val categoriesRootLast = dao.getAncestorCategoriesRootLast(pageMeta.categoryId) - // (A bit weird, here we authz with Authz.maySubmitCustomForm(), but later in - // PostsDao.insertReply via Authz.mayPostReply() — but works okay.) throwNoUnless(Authz.maySubmitCustomForm( request.userAndLevels, dao.getGroupIdsOwnFirst(request.user), pageMeta, inCategoriesRootLast = categoriesRootLast, tooManyPermissions = dao.getPermsOnPages(categoriesRootLast)), "EdE2TE4A0") - request.dao.insertReply(textAndHtml, pageId, Set.empty, PostType.CompletedForm, + request.dao.insertReplySkipAuZ(textAndHtml, pageId, Set.empty, PostType.CompletedForm, deleteDraftNr = None, request.whoOrUnknown, request.spamRelatedStuff) Ok } @@ -83,12 +82,25 @@ class CustomFormController @Inject()(cc: ControllerComponents, edContext: TyCont val category = request.dao.getCategoryBySlug(categorySlug).getOrThrowBadArgument( "EsE0FYK42", s"No category with slug: $categorySlug") - val pagePath = request.dao.createPage(pageType, PageStatus.Published, Some(category.id), - anyFolder = None, anySlug = None, titleSourceAndHtml, bodyTextAndHtml, - showId = true, deleteDraftNr = None, - request.who, request.spamRelatedStuff) - - OkSafeJson(Json.obj("newPageId" -> pagePath.pageId)) + val res: CreatePageResult = dao.createPageIfAuZ( + pageType, + PageStatus.Published, + inCatId = Some(category.id), + withTags = Nil, + anyFolder = None, + anySlug = None, + title = titleSourceAndHtml, + bodyTextAndHtml = bodyTextAndHtml, + showId = true, + deleteDraftNr = None, + reqrAndCreator = request.reqrTargetSelf, + spamRelReqStuff = request.spamRelatedStuff, + asAlias = None, + discussionIds = Set.empty, + embeddingUrl = None, + refId = None) + + OkSafeJson(Json.obj("newPageId" -> res.path.pageId)) } diff --git a/appsv/server/controllers/DebugTestController.scala b/appsv/server/controllers/DebugTestController.scala index 1264395762..26990098d4 100644 --- a/appsv/server/controllers/DebugTestController.scala +++ b/appsv/server/controllers/DebugTestController.scala @@ -95,7 +95,8 @@ class DebugTestController @Inject()(cc: ControllerComponents, edContext: TyConte * endpoint, which logs it, so we'll get to know about client side errors. */ def logBrowserErrors: Action[JsValue] = PostJsonAction( - RateLimits.BrowserError, MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 10000) { + RateLimits.BrowserError, MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 10000, + ignoreAlias = true) { request => val allErrorMessages = request.body.as[Seq[String]] // If there are super many errors, perhaps all of them is the same error. Don't log too many. diff --git a/appsv/server/controllers/DraftsController.scala b/appsv/server/controllers/DraftsController.scala index fca3609730..3bcec4bb84 100644 --- a/appsv/server/controllers/DraftsController.scala +++ b/appsv/server/controllers/DraftsController.scala @@ -41,7 +41,11 @@ class DraftsController @Inject()(cc: ControllerComponents, edContext: TyContext) def upsertDraft: Action[JsValue] = PostJsonAction(RateLimits.DraftSomething, - MinAuthnStrength.EmbeddingStorageSid12, maxBytes = MaxPostSize) { + MinAuthnStrength.EmbeddingStorageSid12, maxBytes = MaxPostSize, + // We remember which persona this draft should be posted as, independently + // of any current persona mode (since there's a post-as dropdown in the + // editor that can be set to sth else than any current persona mode). [_See_ignoreAlias] + ignoreAlias = true) { request: JsonPostRequest => upsertDraftImpl(request.body, request) } @@ -51,7 +55,9 @@ class DraftsController @Inject()(cc: ControllerComponents, edContext: TyContext) */ def upsertDraftBeacon: Action[String] = PostTextAction( RateLimits.DraftSomething, - MinAuthnStrength.EmbeddingStorageSid12, maxBytes = MaxPostSize) { request => + MinAuthnStrength.EmbeddingStorageSid12, maxBytes = MaxPostSize, + // _See_ignoreAlias in upsertDraft() above. + ignoreAlias = true) { request => val bodyXsrfTokenRemoved = request.body.dropWhile(_ != '\n') // [7GKW20TD] val json = Json.parse(bodyXsrfTokenRemoved) upsertDraftImpl(json, request) @@ -108,21 +114,27 @@ class DraftsController @Inject()(cc: ControllerComponents, edContext: TyContext) if (draft.isReply) { val postType = draft.postType getOrDie "TyER35SKS02GU" throwNoUnless(Authz.mayPostReply( - request.theUserAndLevels, dao.getOnesGroupIds(requester), - postType, pageMeta, Vector(post), dao.getAnyPrivateGroupTalkMembers(pageMeta), - inCategoriesRootLast = categoriesRootLast, - tooManyPermissions = dao.getPermsOnPages(categoriesRootLast)), "EdEZBXK3M2") + request.theUserAndLevels, asAlias = None, dao.getOnesGroupIds(requester), + postType, pageMeta, Vector(post), dao.getAnyPrivateGroupTalkMembers(pageMeta), + inCategoriesRootLast = categoriesRootLast, + tooManyPermissions = dao.getPermsOnPages(categoriesRootLast)), "EdEZBXK3M2") } else { - val anyOtherAuthor = - if (post.createdById == requester.id) None - else dao.getParticipant(post.createdById) + // Won't need later, when true id stored in posts3/nodes_t? [posts3_true_id] + val postAuthor: Pat = + if (post.createdById == requester.id) requester + else dao.getParticipant(post.createdById) getOrDie "TyE2FLU58" + val pageAuthor = + if (pageMeta.authorId == requester.id) requester + else dao.getTheParticipant(pageMeta.authorId) throwNoUnless(Authz.mayEditPost( - request.theUserAndLevels, dao.getOnesGroupIds(requester), - post, otherAuthor = anyOtherAuthor, pageMeta, - dao.getAnyPrivateGroupTalkMembers(pageMeta), - inCategoriesRootLast = categoriesRootLast, - tooManyPermissions = dao.getPermsOnPages(categoriesRootLast)), "TyEZBXK3M3") + request.theUserAndLevels, asAlias = None, dao.getOnesGroupIds(requester), + post, postAuthor = postAuthor, pageMeta, pageAuthor = pageAuthor, + dao.getAnyPrivateGroupTalkMembers(pageMeta), + inCategoriesRootLast = categoriesRootLast, + tooManyPermissions = dao.getPermsOnPages(categoriesRootLast), + // We're just saving a draft, can choose an ok alias later if needed. + ignoreAlias = true), "TyEZBXK3M3") } } else { @@ -194,14 +206,14 @@ class DraftsController @Inject()(cc: ControllerComponents, edContext: TyContext) def deleteDrafts: Action[JsValue] = PostJsonAction(RateLimits.DraftSomething, - MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 1000) { + MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 1000, ignoreAlias = true) { request: JsonPostRequest => deleteDraftsImpl(request.body, request) } def deleteDraftsBeacon: Action[String] = PostTextAction(RateLimits.DraftSomething, - MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 1000) { + MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 1000, ignoreAlias = true) { request: ApiRequest[String] => val bodyXsrfTokenRemoved = request.body.dropWhile(_ != '\n') // [7GKW20TD] val json = Json.parse(bodyXsrfTokenRemoved) diff --git a/appsv/server/controllers/EditController.scala b/appsv/server/controllers/EditController.scala index 0c56d54407..61af4e5bcc 100644 --- a/appsv/server/controllers/EditController.scala +++ b/appsv/server/controllers/EditController.scala @@ -20,6 +20,7 @@ package controllers import com.debiki.core._ import com.debiki.core.Prelude._ import debiki._ +import debiki.dao.SiteDao import debiki.EdHttp._ import debiki.JsonUtils.asJsObject import talkyard.server.linkpreviews.{LinkPreviewRenderer, PreviewResult, LinkPreviewProblem} @@ -28,6 +29,7 @@ import talkyard.server.{TyContext, TyController} import talkyard.server.parser import javax.inject.Inject import play.api.mvc.{Action, ControllerComponents} +import com.debiki.core.Prelude.JsEmptyObj2 import play.api.libs.json._ import EditController._ import scala.concurrent.ExecutionContext @@ -142,17 +144,26 @@ class EditController @Inject()(cc: ControllerComponents, edContext: TyContext) val pageMeta = dao.getPageMeta(pageId) getOrElse throwIndistinguishableNotFound("EdE4JBR01") val post = dao.loadPost(pageId, postNr) getOrElse throwIndistinguishableNotFound("EdE0DK9WY3") val categoriesRootLast = dao.getAncestorCategoriesRootLast(pageMeta.categoryId) - val anyOtherAuthor = - if (post.createdById == requester.id) None - else dao.getParticipant(post.createdById) + + // Won't need later, when true id stored in posts3/nodes_t? [posts3_true_id] + val postAuthor: Pat = + if (post.createdById == requester.id) requester + else dao.getParticipant(post.createdById) getOrElse throwNotFound( + "TyEATR0FND03", s"Author of post ${post.id} missing") + val pageAuthor = + if (pageMeta.authorId == requester.id) requester + else dao.getTheParticipant(pageMeta.authorId) CHECK_AUTHN_STRENGTH throwNoUnless(Authz.mayEditPost( - request.theUserAndLevels, dao.getOnesGroupIds(request.theUser), - post, otherAuthor = anyOtherAuthor, pageMeta, dao.getAnyPrivateGroupTalkMembers(pageMeta), - inCategoriesRootLast = categoriesRootLast, - tooManyPermissions = dao.getPermsOnPages(categoriesRootLast)), "EdEZBXKSM2") + request.theUserAndLevels, asAlias = None, dao.getOnesGroupIds(request.theUser), + post, postAuthor = postAuthor, pageMeta, pageAuthor = pageAuthor, + dao.getAnyPrivateGroupTalkMembers(pageMeta), + inCategoriesRootLast = categoriesRootLast, + tooManyPermissions = dao.getPermsOnPages(categoriesRootLast), + // We're just loading the draft text + ignoreAlias = true), "EdEZBXKSM2") val draftLocator = DraftLocator( DraftType.Edit, @@ -181,7 +192,7 @@ class EditController @Inject()(cc: ControllerComponents, edContext: TyContext) /** Edits posts. */ def edit: Action[JsValue] = PostJsonAction(RateLimits.EditPost, - MinAuthnStrength.EmbeddingStorageSid12, maxBytes = MaxPostSize) { + MinAuthnStrength.EmbeddingStorageSid12, maxBytes = MaxPostSize, canUseAlias = true) { request: JsonPostRequest => import request.{dao, theRequester => requester} val body = asJsObject(request.body, "request body") @@ -190,10 +201,10 @@ class EditController @Inject()(cc: ControllerComponents, edContext: TyContext) val anyPostId: Option[PostId] = (body \ "postId").asOpt[PostId] val newText = (body \ "text").as[String] val deleteDraftNr = (body \ "deleteDraftNr").asOpt[DraftNr] + TESTS_MISSING // Do as anon TyTANONEDIT - val doAsAnon: Opt[WhichAnon] = parser.parseWhichAnonJson(body) getOrIfBad { prob => - throwBadReq("TyEANONPARED", s"Bad anon params: $prob") - } + val asAlias: Opt[WhichAliasPat] = + debiki.dao.SiteDao.checkAliasOrThrowForbidden(body, requester, request.anyAliasPat)(dao) if (postNr == PageParts.TitleNr) throwForbidden("DwE5KEWF4", "Edit the title via /-/edit-title-save-settings instead") @@ -219,13 +230,20 @@ class EditController @Inject()(cc: ControllerComponents, edContext: TyContext) CHECK_AUTHN_STRENGTH - val anyOtherAuthor = - if (post.createdById == requester.id) None - else dao.getParticipant(post.createdById) + // Won't need later, when true id stored in posts3/nodes_t? [posts3_true_id] + val postAuthor: Pat = + if (post.createdById == requester.id) requester + else dao.getParticipant(post.createdById) getOrElse throwNotFound( + "TyEATR0FND05", s"Author of post ${post.id} missing") + val pageAuthor = + if (pageMeta.authorId == requester.id) requester + else dao.getTheParticipant(pageMeta.authorId) + // [dupl_ed_perm_chk]? throwNoUnless(Authz.mayEditPost( - request.theUserAndLevels, dao.getOnesGroupIds(request.theUser), - post, otherAuthor = anyOtherAuthor, pageMeta, dao.getAnyPrivateGroupTalkMembers(pageMeta), + request.theUserAndLevels, asAlias, groupIds = dao.getOnesGroupIds(request.reqr), + post, postAuthor = postAuthor, pageMeta, pageAuthor = pageAuthor, + dao.getAnyPrivateGroupTalkMembers(pageMeta), inCategoriesRootLast = categoriesRootLast, tooManyPermissions = dao.getPermsOnPages(categoriesRootLast)), "EdE4JBTYE8") @@ -241,7 +259,8 @@ class EditController @Inject()(cc: ControllerComponents, edContext: TyContext) followLinks = postNr == PageParts.BodyNr && pageMeta.pageType.shallFollowLinks) request.dao.editPostIfAuth(pageId = pageId, postNr = postNr, deleteDraftNr = deleteDraftNr, - request.who, request.spamRelatedStuff, newTextAndHtml, doAsAnon) + request.who, // [alias_4_principal] + request.spamRelatedStuff, newTextAndHtml, asAlias) OkSafeJson(dao.jsonMaker.postToJson2(postNr = postNr, pageId = pageId, includeUnapproved = true)) @@ -358,11 +377,12 @@ class EditController @Inject()(cc: ControllerComponents, edContext: TyContext) def deletePost: Action[JsValue] = PostJsonAction(RateLimits.DeletePost, - MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 5000) { request => - import request.dao - val pageId = (request.body \ "pageId").as[PageId] - val postNr = (request.body \ "postNr").as[PostNr] - val repliesToo = (request.body \ "repliesToo").asOpt[Boolean] getOrElse false + MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 5000, canUseAlias = true) { req => + import req.dao + val body = asJsObject(req.body, "Delete post request body") + val pageId = (body \ "pageId").as[PageId] + val postNr = (body \ "postNr").as[PostNr] + val repliesToo = (body \ "repliesToo").asOpt[Boolean] getOrElse false val action = if (repliesToo) PostStatusAction.DeleteTree @@ -370,7 +390,10 @@ class EditController @Inject()(cc: ControllerComponents, edContext: TyContext) CHECK_AUTHN_STRENGTH - val result = dao.changePostStatus(postNr, pageId = pageId, action, request.reqrIds) + val asAlias = SiteDao.checkAliasOrThrowForbidden( + body, req.reqr, req.anyAliasPat, mayCreateAnon = false)(dao) + + val result = dao.changePostStatus(postNr, pageId = pageId, action, req.reqrIds, asAlias) OkSafeJson(Json.obj( "answerGotDeleted" -> result.answerGotDeleted, @@ -378,7 +401,7 @@ class EditController @Inject()(cc: ControllerComponents, edContext: TyContext) // COULD: don't include post in reply? It'd be annoying if other unrelated changes // were loaded just because the post was toggled open? [5GKU0234] dao.jsonMaker.postToJson2( - postNr = postNr, pageId = pageId, includeUnapproved = request.theUser.isStaff))) + postNr = postNr, pageId = pageId, includeUnapproved = req.theUser.isStaff))) } diff --git a/appsv/server/controllers/ForumController.scala b/appsv/server/controllers/ForumController.scala index 1a31d5c1c7..4a30cd8bc2 100644 --- a/appsv/server/controllers/ForumController.scala +++ b/appsv/server/controllers/ForumController.scala @@ -118,7 +118,10 @@ class ForumController @Inject()(cc: ControllerComponents, edContext: TyContext) } - def saveCategory: Action[JsValue] = AdminPostJsonAction(maxBytes = 5000) { request => + def saveCategory: Action[JsValue] = AdminPostJsonAction(maxBytes = 5000, + // It's annoying if, when edit anonymity settings, one needs to enter Self mode. + // And feels pretty obvious that one isn't editing the category settings anonymously. + ignoreAlias = true) { request => BUG // fairly harmless in this case: The lost update bug. import request.{dao, body, requester} diff --git a/appsv/server/controllers/PageController.scala b/appsv/server/controllers/PageController.scala index 7d1f4394a9..e381690c68 100644 --- a/appsv/server/controllers/PageController.scala +++ b/appsv/server/controllers/PageController.scala @@ -20,6 +20,7 @@ package controllers import com.debiki.core._ import com.debiki.core.Prelude._ import debiki._ +import debiki.dao.CreatePageResult import debiki.EdHttp._ import debiki.JsonUtils._ import debiki.dao.SiteDao @@ -41,7 +42,9 @@ class PageController @Inject()(cc: ControllerComponents, edContext: TyContext) import context.security.throwNoUnless - def createPage: Action[JsValue] = PostJsonAction(RateLimits.CreateTopic, maxBytes = 20 * 1000) { + + def createPage: Action[JsValue] = PostJsonAction( + RateLimits.CreateTopic, maxBytes = 20 * 1000, canUseAlias = true) { request => import request.{dao, theRequester => requester} // Similar to Do API with CreatePageParams. [create_page] @@ -60,15 +63,9 @@ class PageController @Inject()(cc: ControllerComponents, edContext: TyContext) val bodyText = (body \ "pageBody").as[String] val showId = (body \ "showId").asOpt[Boolean].getOrElse(true) val deleteDraftNr = (body \ "deleteDraftNr").asOpt[DraftNr] - val doAsAnon: Opt[WhichAnon] = parser.parseWhichAnonJson(body) getOrIfBad { prob => - throwBadReq("TyEANONPARCRPG", s"Bad anon params: $prob") - } - val doAsNewAnon: Opt[WhichAnon.NewAnon] = doAsAnon map { - case _new: WhichAnon.NewAnon => _new - case _: WhichAnon.SameAsBefore => throwBadReq("TyE5MWE2J8", o"""Cannot keep - reusing an old anonym, when creating a new page. Anonyms are per page.""") - } - // val anonStatus = parseOptInt32(body, "anonStatus").flatMap(AnonStatus.fromInt) + val asAlias: Opt[WhichAliasPat] = + SiteDao.checkAliasOrThrowForbidden(body, requester, request.anyAliasPat, + mayReuseAnon = false)(dao) val postRenderSettings = dao.makePostRenderSettings(pageRole) val bodyTextAndHtml = dao.textAndHtmlMaker.forBodyOrComment(bodyText, @@ -91,20 +88,25 @@ class PageController @Inject()(cc: ControllerComponents, edContext: TyContext) throwForbidden("DwE8GKE4", "No category specified") } - val categoriesRootLast = dao.getAncestorCategoriesRootLast(anyCategoryId) - - throwNoUnless(Authz.mayCreatePage( // [dupl_api_perm_check] use createPageIfAuZ() instead CLEAN_UP - request.theUserAndLevels, dao.getOnesGroupIds(request.theUser), - pageRole, PostType.Normal, pinWhere = None, anySlug = anySlug, anyFolder = anyFolder, - inCategoriesRootLast = categoriesRootLast, - tooManyPermissions = dao.getPermsOnPages(categories = categoriesRootLast)), - "EdE5KW20A") - - val pagePath = dao.createPage(pageRole, pageStatus, anyCategoryId, anyFolder, - anySlug, titleSourceAndHtml, bodyTextAndHtml, showId, deleteDraftNr = deleteDraftNr, - request.who, request.spamRelatedStuff, doAsAnon = doAsNewAnon) - - OkSafeJson(Json.obj("newPageId" -> pagePath.pageId)) + val res: CreatePageResult = dao.createPageIfAuZ( + pageRole, + pageStatus, + inCatId = anyCategoryId, + withTags = Nil, // later + anyFolder = anyFolder, + anySlug = anySlug, + title = titleSourceAndHtml, + bodyTextAndHtml = bodyTextAndHtml, + showId = showId, + deleteDraftNr = deleteDraftNr, + reqrAndCreator = request.reqrTargetSelf, // [alias_4_principal] + spamRelReqStuff = request.spamRelatedStuff, + asAlias = asAlias, + discussionIds = Set.empty, + embeddingUrl = None, + refId = None) + + OkSafeJson(Json.obj("newPageId" -> res.path.pageId)) } @@ -265,61 +267,75 @@ class PageController @Inject()(cc: ControllerComponents, edContext: TyContext) */ - def acceptAnswer: Action[JsValue] = PostJsonAction(RateLimits.TogglePage, maxBytes = 100) { - request => - val pageId = (request.body \ "pageId").as[PageId] - val postUniqueId = (request.body \ "postId").as[PostId] // id not nr - - // DO_AS_ALIAS ! - ANON_UNIMPL /* If created a page as anon, would accept it as anon too? [anon_pages] So need: - val doAsAnon: Opt[WhichAnon.SameAsBefore] = parser.parseWhichAnonJson(body) ... - case _new: WhichAnon.NewAnon => throwBadReq(..., o"""Cannot create - a new anonym, when accepting an answer. Should instead use the anonym - that posted the page in the first place.""") */ + def acceptAnswer: Action[JsValue] = PostJsonAction(RateLimits.TogglePage, maxBytes = 100, + canUseAlias = true) { request => + import request.{dao, reqr} + val body = asJsObject(request.body, "acceptAnswer request body") + val pageId = parseSt(body, "pageId") + val postId = parseInt32(body, "postId") // id not nr + val asAlias: Opt[WhichAliasPat] = + SiteDao.checkAliasOrThrowForbidden(body, reqr, request.anyAliasPat, + mayCreateAnon = false)(dao) val acceptedAt: Option[ju.Date] = request.dao.ifAuthAcceptAnswer( - pageId, postUniqueId, request.theReqerTrueId, request.theBrowserIdData) + pageId, postId, request.theReqrTargetSelf, // [alias_4_principal] + request.theBrowserIdData, asAlias) OkSafeJsValue(JsLongOrNull(acceptedAt.map(_.getTime))) } - def unacceptAnswer: Action[JsValue] = PostJsonAction(RateLimits.TogglePage, maxBytes = 100) { - request => - // DO_AS_ALIAS ! - ANON_UNIMPL // Need: doAsAnon: Opt[WhichAnon.SameAsBefore] ? [anon_pages] - val body = asJsObject(request.body, "Page-closed request body") + def unacceptAnswer: Action[JsValue] = PostJsonAction(RateLimits.TogglePage, maxBytes = 100, + canUseAlias = true) { request => + import request.{dao, reqr} + val body = asJsObject(request.body, "unacceptAnswer request body") val pageId = parseSt(body, "pageId") - val asAlias = parser.parseDoAsAliasJsonOrThrow(body) - request.dao.ifAuthUnacceptAnswer(pageId, request.theReqerTrueId, request.theBrowserIdData) + val asAlias: Opt[WhichAliasPat] = + SiteDao.checkAliasOrThrowForbidden(body, reqr, request.anyAliasPat, + mayCreateAnon = false)(dao) + + dao.ifAuthUnacceptAnswer( + pageId, request.theReqrTargetSelf, // [alias_4_principal] + request.theBrowserIdData, asAlias) Ok } - def togglePageClosed: Action[JsValue] = PostJsonAction(RateLimits.TogglePage, maxBytes = 100) { - request => + def togglePageClosed: Action[JsValue] = PostJsonAction(RateLimits.TogglePage, maxBytes = 100, + canUseAlias = true) { request => + import request.{dao, reqr} val body = asJsObject(request.body, "Page-closed request body") val pageId = parseSt(body, "pageId") - val asAlias = parser.parseDoAsAliasJsonOrThrow(body) - val closedAt: Option[ju.Date] = request.dao.ifAuthTogglePageClosed( - pageId, request.reqrIds, asAlias) - TESTS_MISSING // DO_AS_ALIAS - //ANON_UNIPL // Need: doAsAnon: Opt[WhichAnon.SameAsBefore] ? [anon_pages] + val asAlias: Opt[WhichAliasPat] = + SiteDao.checkAliasOrThrowForbidden(body, reqr, request.anyAliasPat, + mayCreateAnon = false)(dao) + + val closedAt: Opt[ju.Date] = + dao.ifAuthTogglePageClosed(pageId, request.reqrIds, asAlias) // [alias_4_principal] + OkSafeJsValue(JsLongOrNull(closedAt.map(_.getTime))) } + def deletePages: Action[JsValue] = PostJsonAction( - RateLimits.TogglePage, maxBytes = 1000) { request => - val pageIds = (request.body \ "pageIds").as[Seq[PageId]] - ANON_UNIMPL // ! Need: doAsAnon: Opt[WhichAnon.SameAsBefore] ? [anon_pages] - request.dao.deletePagesIfAuth(pageIds, request.reqrIds, undelete = false) + RateLimits.TogglePage, maxBytes = 1000, canUseAlias = true) { req => + import req.dao + val body = asJsObject(req.body, "Delete pages request body") + val pageIds = (body \ "pageIds").as[Seq[PageId]] + val asAlias = SiteDao.checkAliasOrThrowForbidden( + body, req.reqr, req.anyAliasPat, mayCreateAnon = false)(dao) + dao.deletePagesIfAuth(pageIds, req.reqrIds, asAlias, undelete = false) Ok } + def undeletePages: Action[JsValue] = PostJsonAction( - RateLimits.TogglePage, maxBytes = 1000) { request => - val pageIds = (request.body \ "pageIds").as[Seq[PageId]] - ANON_UNIMPL // ! Need: doAsAnon: Opt[WhichAnon.SameAsBefore] ? [anon_pages] - request.dao.deletePagesIfAuth(pageIds, request.reqrIds, undelete = true) + RateLimits.TogglePage, maxBytes = 1000, canUseAlias = true) { req => + import req.dao + val body = asJsObject(req.body, "Undelete pages request body") + val pageIds = (body \ "pageIds").as[Seq[PageId]] + val asAlias = SiteDao.checkAliasOrThrowForbidden( + body, req.reqr, req.anyAliasPat, mayCreateAnon = false)(dao) + dao.deletePagesIfAuth(pageIds, req.reqrIds, asAlias, undelete = true) Ok } @@ -328,7 +344,7 @@ class PageController @Inject()(cc: ControllerComponents, edContext: TyContext) request => val pageId = (request.body \ "pageId").as[PageId] val userIds = (request.body \ "userIds").as[Set[UserId]] - // Later, need: doAsAnon: Opt[WhichAnon.SameAsBefore] ? [anon_priv_msgs] + // Later, also: SiteDao.checkAliasOrThrowForbidden ? [anon_priv_msgs] request.dao.addUsersToPage(userIds, pageId, request.who) Ok } @@ -338,7 +354,7 @@ class PageController @Inject()(cc: ControllerComponents, edContext: TyContext) maxBytes = 100) { request => val pageId = (request.body \ "pageId").as[PageId] val userIds = (request.body \ "userIds").as[Set[UserId]] - // Later, need: doAsAnon: Opt[WhichAnon.SameAsBefore] ? [anon_priv_msgs] + // Later, also: SiteDao.checkAliasOrThrowForbidden ? [anon_priv_msgs] request.dao.removeUsersFromPage(userIds, pageId, request.who) Ok } diff --git a/appsv/server/controllers/PageTitleSettingsController.scala b/appsv/server/controllers/PageTitleSettingsController.scala index c605be6ee3..c3052a43b9 100644 --- a/appsv/server/controllers/PageTitleSettingsController.scala +++ b/appsv/server/controllers/PageTitleSettingsController.scala @@ -38,16 +38,17 @@ import talkyard.server.JsX.{JsPageMeta, JsStringOrNull} * which layout to use, and description. * * MOVE to PageController, right? It's confusing to have 2 controllers that do - * almost the same things. + * almost the same things. [pg_ctrl_dao] */ class PageTitleSettingsController @Inject()(cc: ControllerComponents, edContext: TyContext) extends TyController(cc, edContext) { import context.security.{throwNoUnless, throwIndistinguishableNotFound} - def editTitleSaveSettings: Action[JsValue] = PostJsonAction(RateLimits.EditPost, maxBytes = 2000) { + def editTitleSaveSettings: Action[JsValue] = PostJsonAction(RateLimits.EditPost, + maxBytes = 2000, canUseAlias = true) { request: JsonPostRequest => - import request.{body, dao, theRequester => trueEditor} + import request.{body, dao, theRequester => trueEditor} // [alias_4_principal] val pageJo = asJsObject(request.body, "the request body") CLEAN_UP // use JsonUtils below, not '\'. @@ -91,8 +92,10 @@ class PageTitleSettingsController @Inject()(cc: ControllerComponents, edContext: PageDoingStatus.fromInt(value) getOrElse throwBadArgument("TyE2ABKR04", "doingStatus") } - TESTS_MISSING // & ren to doAsAlias - val doAsAnon = parser.parseDoAsAliasJsonOrThrow(pageJo) + TESTS_MISSING // TyTALIALTERPG + val asAlias: Opt[WhichAliasPat] = + SiteDao.checkAliasOrThrowForbidden(pageJo, trueEditor, request.anyAliasPat, + mayCreateAnon = false)(dao) val hasManuallyEditedSlug = anySlug.exists(slug => { // The user interface currently makes impossible to post a new slug, without a page title. @@ -116,25 +119,31 @@ class PageTitleSettingsController @Inject()(cc: ControllerComponents, edContext: val changesOnlyTypeOrStatus = // If we got the page id (required, always present) and exactly one more field, ... (pageJo.value.size == 2 - // Except for the doAsAnon field which is off-topic + // Except for the asAlias field which is off-topic || pageJo.value.size == 3 && pageJo.\(parser.DoAsAnonFieldName).isDefined ) && // ... and it is the page type or doing status, then, pat is trying to change, // well, only the type or doing status. Nothing else. (anyNewDoingStatus.isDefined || anyNewRole.isDefined) - val anyOtherAuthor = - if (oldMeta.authorId == trueEditor.id) None - else Some(dao.getTheParticipant(oldMeta.authorId)) + // Above, mayCreateAnon = false, so any anonym would already exist. + dieIf(asAlias.exists(_.anyPat.isEmpty), "TyE5FML28PW") + + val pageAuthor = + if (oldMeta.authorId == trueEditor.id) trueEditor + else dao.getTheParticipant(oldMeta.authorId) // AuthZ check 1/3: + // (Similar to throwIfMayNotAlterPage(): Authz.mayEditPage() is inlined here + // so we can reuse some arguments, e.g. requestersGroupIds.) // Could skip authz check 2/3 below: [.dbl_auz] ? val oldCatsRootLast = dao.getAncestorCategoriesRootLast(oldMeta.categoryId) val requestersGroupIds = dao.getOnesGroupIds(trueEditor) throwNoUnless(Authz.mayEditPage( pageMeta = oldMeta, pat = trueEditor, - otherAuthor = anyOtherAuthor, + asAlias = asAlias, + pageAuthor = pageAuthor, groupIds = requestersGroupIds, pageMembers = dao.getAnyPrivateGroupTalkMembers(oldMeta), catsRootLast = oldCatsRootLast, @@ -162,9 +171,11 @@ class PageTitleSettingsController @Inject()(cc: ControllerComponents, edContext: throwForbiddenIf(forumViewChanged && pageTypeAfter != PageType.Forum, "TyE0FORMPGE", "Can only edit these properties for forum pages") - // [choose_alias] [deanon_risk] - if (!trueEditor.isStaff || doAsAnon.isDefined) { - val who = (trueEditor.isStaff && doAsAnon.isDefined) ? "Anonyms" | "You" + // Would be confusing if anyone could change the comment order. + // If anonyms could do things only mods may do, others could guess + // that the anon is a mod. [deanon_risk] + if (!trueEditor.isStaff || asAlias.isDefined) { + val who = trueEditor.isStaff ? "Anonyms" | "You" throwForbiddenIf( anyComtOrder.isSomethingButNot(oldMeta.comtOrder), "TyEXCMTORD", s"$who may not change the comment sort order") @@ -174,59 +185,10 @@ class PageTitleSettingsController @Inject()(cc: ControllerComponents, edContext: "TyEXCMTNST", s"$who may not change the comment nesting max depth") } - val isEditorsAnonsPage = doAsAnon.exists(_.anySameAnonId.is(oldMeta.authorId)) - val isEditorsOwnPage = trueEditor.id == oldMeta.authorId - - if (doAsAnon.isDefined) { - if (isEditorsAnonsPage) { - // Fine. If you create a page anonymously, you can close/reopen it, - // mark it as solved, etc, using the same anonym. - } - else if (isEditorsOwnPage) { - // It's the trueEditor's page, but han created it using hans real account. - // Disallow, so others won't know that `doAsAnon` is the same person. - throwForbidden("TyECHPGUSINGALIAS", - s"You cannot alter this page as anonym ${doAsAnon - } — you created it using your real account, ${trueEditor.nameParaId}") - } - else if (anyOtherAuthor.exists(_.trueId2.trueId == trueEditor.id)) { - // It's the trueEditor's page, but han created it using _another_ anonym - // or pseudonym. `doAsAnon` is the wrong anonym. Disallow, so others can't - // know that the two aliases (anyOtherAuthor and doAsAnon) are in fact - // the same person. [deanon_risk] - throwForbidden("TyECHPGWRONGALIAS", - s"You cannot alter this page as anonym ${doAsAnon - } — you created it as ${anyOtherAuthor.get.nameParaId}") - } - else { - // This must be beore the `isStaff` test — anonymous moderation [anon_mods] is - // not yet suppored. (If the same anon was reused, others might see that - // that anon is a moderator.) - throwForbidden("TyECHOTRPGSANO", - "You cannot alter other people's pages anonymously" + ( - trueEditor.isStaffOrCoreMember ? // [deanon_risk] - " even if you're a moderator or core member" | "")) - } - } - else if (trueEditor.isStaff || isEditorsOwnPage) { - // Fine. Moderators can edit pages they have access too, and others can - // edit pages they created themselves. - // (Maybe later there will be more [granular_perms].) - } - else if (changesOnlyTypeOrStatus && trueEditor.isStaffOrCoreMember) { - // Fine: Core members can alter page type, or mark pages as solved or closed. - } - else { - throwForbidden("TyECHOTRPGS", "You may not alter other people's pages") - } - - // If anonyms or pseudonyms could do the things in this block (e.g. change page slug - // or folder, change layout), if the true user is an admin, then, - // others could guess who the anonym is. [deanon_risk] [choose_alias] - // (Since probably there are just a few admins, and if there might be some meta message - // about what was done by the anonym, who must thus be one of the admins.) - if (!trueEditor.isAdmin || doAsAnon.isDefined) { - val prefix = doAsAnon.isDefined ? "Anonyms cannot" | "Only admins can" + // If anonyms or pseudonyms could do things only admins can do (e.g. change page slug + // or folder, change layout), others could guess that the anon is an admin. [deanon_risk] + if (!trueEditor.isAdmin || asAlias.isDefined) { + val prefix = trueEditor.isAdmin ? "Anonyms cannot" | "Only admins can" // Forum page URL. throwForbiddenIf(hasManuallyEditedSlug, @@ -299,7 +261,8 @@ class PageTitleSettingsController @Inject()(cc: ControllerComponents, edContext: // move it, in a separate step.) val newTextAndHtml = dao.textAndHtmlMaker.forTitle(newTitle) request.dao.editPostIfAuth(pageId = pageId, postNr = PageParts.TitleNr, deleteDraftNr = None, - request.who, request.spamRelatedStuff, newTextAndHtml, doAsAnon) + request.who, // [alias_4_principal] + request.spamRelatedStuff, newTextAndHtml, asAlias) }} // Load old section page id before changing it. @@ -336,17 +299,23 @@ class PageTitleSettingsController @Inject()(cc: ControllerComponents, edContext: // allow that, but only if no *additional* people then gets to see the page. // (But if fewer, that's ok.) — There'll also be [alterPage] maybe move page permissions. // - // [deanon_risk] If the page author, or hans anonym, appear in a page changelog, then, - // others can guess they're the same? (If only few people have access to the destination - // category). + // COULD check if may use alias in the new cat, so cannot move a page [move_anon_page] + // with anon comments to a category where anon comments are disabled? Let's wait. + // + // [deanon_risk] If few people have access to both the old and new category, then, it's + // simpler for others to guess who a [pseudonym who moves the page] is. [pseudonyms_later] + // And if one could move one's anonymous page using one's true id (but one cannot, + // see [true_0_ed_alias]), then, others could also guess that the anon and oneself is + // the same (otherwise, why would one have been able to move it (unless is e.g. a mod)). // if (newMeta.categoryId != oldMeta.categoryId) { + // (Similar to throwIfMayNotAlterPage().) val newCatsRootLast = dao.getAncestorCategoriesRootLast(newMeta.categoryId) throwNoUnless(Authz.mayEditPage( pageMeta = newMeta, pat = trueEditor, - // asAlias = — Check if [may_use_alias] in the new category DO_AS_ALIAS - otherAuthor = anyOtherAuthor, + asAlias = asAlias, + pageAuthor = pageAuthor, groupIds = requestersGroupIds, pageMembers = dao.getAnyPrivateGroupTalkMembers(newMeta), catsRootLast = newCatsRootLast, @@ -360,7 +329,7 @@ class PageTitleSettingsController @Inject()(cc: ControllerComponents, edContext: tx.updatePageMeta(newMeta, oldMeta = oldMeta, markSectionPageStale = true) val aliasOrTrue: Pat = SiteDao.getAliasOrTruePat(truePat = trueEditor, pageId = pageId, - doAsAnon, mayCreateAnon = false)(tx, IfBadAbortReq) + asAlias, mayCreateAnon = false)(tx, IfBadAbortReq) if (addsNewDoingStatusMetaPost) { dao.addMetaMessage(aliasOrTrue, s" marked this topic as ${newMeta.doingStatus}", pageId, tx) diff --git a/appsv/server/controllers/ReplyController.scala b/appsv/server/controllers/ReplyController.scala index 08318a5b3b..8f99004f0b 100644 --- a/appsv/server/controllers/ReplyController.scala +++ b/appsv/server/controllers/ReplyController.scala @@ -22,6 +22,7 @@ import com.debiki.core.Prelude._ import debiki._ import debiki.EdHttp._ import debiki.JsonUtils.asJsObject +import debiki.dao.CreatePageResult import talkyard.server.{TyContext, TyController} import talkyard.server.authz.Authz import talkyard.server.http._ @@ -43,7 +44,7 @@ class ReplyController @Inject()(cc: ControllerComponents, edContext: TyContext) def handleReply: Action[JsValue] = PostJsonAction(RateLimits.PostReply, - MinAuthnStrength.EmbeddingStorageSid12, maxBytes = MaxPostSize) { + MinAuthnStrength.EmbeddingStorageSid12, maxBytes = MaxPostSize, canUseAlias = true) { request: JsonPostRequest => import request.{dao, theRequester => requester} val body = asJsObject(request.body, "request body") @@ -57,9 +58,9 @@ class ReplyController @Inject()(cc: ControllerComponents, edContext: TyContext) val postType = PostType.fromInt((body \ "postType").as[Int]) getOrElse throwBadReq( "DwE6KG4", "Bad post type") val deleteDraftNr = (body \ "deleteDraftNr").asOpt[DraftNr] - val doAsAnon: Opt[WhichAnon] = parser.parseWhichAnonJson(body) getOrIfBad { prob => - throwBadReq("TyEANONPARRE", s"Bad anon params: $prob") - } + + val asAlias: Opt[WhichAliasPat] = + debiki.dao.SiteDao.checkAliasOrThrowForbidden(body, requester, request.anyAliasPat)(dao) throwBadRequestIf(text.isEmpty, "EdE85FK03", "Empty post") throwForbiddenIf(requester.isGroup, "EdE4GKRSR1", "Groups may not reply") @@ -84,7 +85,7 @@ 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, dao.getOnesGroupIds(request.theUser), + request.theUserAndLevels, asAlias, dao.getOnesGroupIds(request.theUser), postType, pageMeta, replyToPosts, dao.getAnyPrivateGroupTalkMembers(pageMeta), inCategoriesRootLast = categoriesRootLast, tooManyPermissions = dao.getPermsOnPages(categoriesRootLast)), @@ -103,8 +104,9 @@ class ReplyController @Inject()(cc: ControllerComponents, edContext: TyContext) embeddedOriginOrEmpty = postRenderSettings.embeddedOriginOrEmpty, followLinks = false) - val result = dao.insertReply(textAndHtml, pageId = pageId, replyToPostNrs, - postType, deleteDraftNr, request.who, request.spamRelatedStuff, doAsAnon) + val result = dao.insertReplySkipAuZ(textAndHtml, pageId = pageId, replyToPostNrs, + postType, deleteDraftNr, request.who, // [alias_4_principal] + request.spamRelatedStuff, asAlias) var responseJson: JsObject = result.storePatchJson if (newEmbPage.isDefined) { @@ -122,6 +124,8 @@ class ReplyController @Inject()(cc: ControllerComponents, edContext: TyContext) val text = (body \ "text").as[String].trim val deleteDraftNr = (body \ "deleteDraftNr").asOpt[DraftNr] + // Not yet supported, for chat messages. + // val asAlias: Opt[WhichAliasPat] = ... throwBadRequestIf(text.isEmpty, "EsE0WQCB", "Empty chat message") @@ -132,7 +136,7 @@ class ReplyController @Inject()(cc: ControllerComponents, edContext: TyContext) val categoriesRootLast = dao.getAncestorCategoriesRootLast(pageMeta.categoryId) throwNoUnless(Authz.mayPostReply( - request.theUserAndLevels, dao.getOnesGroupIds(request.theMember), + request.theUserAndLevels, asAlias = None, dao.getOnesGroupIds(request.theMember), PostType.ChatMessage, pageMeta, replyToPosts, dao.getAnyPrivateGroupTalkMembers(pageMeta), inCategoriesRootLast = categoriesRootLast, tooManyPermissions = dao.getPermsOnPages(categoriesRootLast)), @@ -336,21 +340,28 @@ object EmbeddedCommentsPageCreator { REFACTOR; CLEAN_UP; // moe to talkyard.se val pageRole = PageType.EmbeddedComments context.security.throwNoUnless(Authz.mayCreatePage( - request.theUserAndLevels, dao.getGroupIdsOwnFirst(requester), + request.theUserAndLevels, asAlias = None, dao.getGroupIdsOwnFirst(requester), pageRole, PostType.Normal, pinWhere = None, anySlug = slug, anyFolder = folder, inCategoriesRootLast = categoriesRootLast, tooManyPermissions = dao.getPermsOnPages(categories = categoriesRootLast)), "EdE7USC2R8") // This won't generate any new page notf — but the first *reply*, does. [new_emb_pg_notf] - dao.createPage(pageRole, PageStatus.Published, - anyCategoryId = Some(placeInCatId), anyFolder = slug, anySlug = folder, + // + // Don't use dao.createPageIfAuZ() — we don't want request.reqr to be the page author; + // instead, the page is auto generated by System. + // + val res: CreatePageResult = dao.createPageSkipAuZ(pageRole, PageStatus.Published, + anyCategoryId = Some(placeInCatId), withTags = Nil, anyFolder = folder, anySlug = slug, title = TitleSourceAndHtml(s"Comments for $embeddingUrl"), bodyTextAndHtml = dao.textAndHtmlMaker.forBodyOrComment( s"Comments for: $embeddingUrl"), showId = true, deleteDraftNr = None, // later, will be a draft to delete? [BLGCMNT1] - Who.System, request.spamRelatedStuff, discussionIds = anyDiscussionId.toSet, - embeddingUrl = Some(embeddingUrl)) + Who.System, request.spamRelatedStuff, asAlias = None, + discussionIds = anyDiscussionId.toSet, + embeddingUrl = Some(embeddingUrl), extId = None) + + res.path } } diff --git a/appsv/server/controllers/SearchController.scala b/appsv/server/controllers/SearchController.scala index 4ae1e30ffb..b252126cca 100644 --- a/appsv/server/controllers/SearchController.scala +++ b/appsv/server/controllers/SearchController.scala @@ -55,8 +55,8 @@ class SearchController @Inject()(cc: ControllerComponents, edContext: TyContext) } - def doSearch(): Action[JsValue] = AsyncPostJsonAction(RateLimits.FullTextSearch, maxBytes = 1000) { - request: JsonPostRequest => + def doSearch(): Action[JsValue] = AsyncPostJsonAction(RateLimits.FullTextSearch, + maxBytes = 1000, ignoreAlias = true) { request: JsonPostRequest => import request.dao val rawQuery = (request.body \ "rawQuery").as[String] diff --git a/appsv/server/controllers/UserController.scala b/appsv/server/controllers/UserController.scala index 1e6a3debda..6db2ee96d3 100644 --- a/appsv/server/controllers/UserController.scala +++ b/appsv/server/controllers/UserController.scala @@ -524,7 +524,7 @@ class UserController @Inject()(cc: ControllerComponents, edContext: TyContext) def setPrimaryEmailAddresses: Action[JsValue] = - PostJsonAction(RateLimits.AddEmailLogin, maxBytes = 300) { request => + PostJsonAction(RateLimits.AddEmailLogin, maxBytes = 300, ignoreAlias = true) { request => import request.{dao, body, theRequester => requester} // SECURITY maybe send an email and verify with the old address that changing to the new is ok? @@ -552,8 +552,8 @@ class UserController @Inject()(cc: ControllerComponents, edContext: TyContext) } - def addUserEmail: Action[JsValue] = PostJsonAction(RateLimits.AddEmailLogin, maxBytes = 300) { - request => + def addUserEmail: Action[JsValue] = PostJsonAction(RateLimits.AddEmailLogin, maxBytes = 300, + ignoreAlias = true) { request => import request.{dao, body, theRequester => requester} val userId = (body \ "userId").as[UserId] @@ -588,7 +588,7 @@ class UserController @Inject()(cc: ControllerComponents, edContext: TyContext) def resendEmailAddrVerifEmail: Action[JsValue] = PostJsonAction( - RateLimits.ConfirmEmailAddress, maxBytes = 300) { request => + RateLimits.ConfirmEmailAddress, maxBytes = 300, ignoreAlias = true) { request => import request.{dao, body, theRequester => requester} @@ -730,8 +730,8 @@ class UserController @Inject()(cc: ControllerComponents, edContext: TyContext) } - def removeUserEmail: Action[JsValue] = PostJsonAction(RateLimits.AddEmailLogin, maxBytes = 300) { - request => + def removeUserEmail: Action[JsValue] = PostJsonAction(RateLimits.AddEmailLogin, maxBytes = 300, + ignoreAlias = true) { request => import request.{dao, body, theRequester => requester} val userId = (body \ "userId").as[UserId] @@ -1013,8 +1013,17 @@ class UserController @Inject()(cc: ControllerComponents, edContext: TyContext) } - def trackReadingProgress: Action[JsValue] = PostJsonAction(RateLimits.TrackReadingActivity, - MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 1000) { request => + /** Ignores any [persona_mode] — the purpose is to help the true user remember what + * han has read, regardless of they're anonymous or not for the moment. And others + * can't access hans reading progress. [anon_read_progr] + */ + def trackReadingProgress: Action[JsValue] = PostJsonAction( + RateLimits.TrackReadingActivity, + MinAuthnStrength.EmbeddingStorageSid12, + maxBytes = 1000, + ignoreAlias = true, + ) { request => + import request.{dao, theRequester} val readMoreResult = trackReadingProgressImpl(request, request.body) val result = @@ -1045,7 +1054,7 @@ class UserController @Inject()(cc: ControllerComponents, edContext: TyContext) /** In the browser, navigator.sendBeacon insists on sending plain text. So need this text handler. */ def trackReadingProgressText: Action[String] = PostTextAction(RateLimits.TrackReadingActivity, - MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 1000) { request => + MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 1000, ignoreAlias = true) { request => val bodyXsrfTokenRemoved = request.body.dropWhile(_ != '\n') // [7GKW20TD] val json = Json.parse(bodyXsrfTokenRemoved) trackReadingProgressImpl(request, json) @@ -1157,7 +1166,7 @@ class UserController @Inject()(cc: ControllerComponents, edContext: TyContext) def toggleTips: Action[JsValue] = UserPostJsonAction(RateLimits.TrackReadingActivity, - maxBytes = 200) { request => + maxBytes = 200, ignoreAlias = true) { request => import request.{dao, body, theRequester => requester} val tipsId: Opt[St] = parseOptSt(body, "tipsId") val hide: Bo = parseBo(body, "hide") @@ -1176,22 +1185,22 @@ class UserController @Inject()(cc: ControllerComponents, edContext: TyContext) } - def loadNotificationsImpl(userId: UserId, upToWhen: Option[When], request: DebikiRequest[_]) + private def loadNotificationsImpl(userId: UserId, upToWhen: Opt[When], req: DebikiRequest[_]) : mvc.Result = { - val notfsAndCounts = request.dao.loadNotificationsSkipReviewTasks(userId, upToWhen, request.who) - OkSafeJson(notfsAndCounts.notfsJson) + val notfsAndCounts = req.dao.loadNotificationsSkipReviewTasks(userId, upToWhen, req.who) + OkSafeJson(Json.obj("notfs" -> notfsAndCounts.notfsJson)) // ts: NotfSListResponse } def markAllNotfsAsSeen(): Action[JsValue] = PostJsonAction(RateLimits.MarkNotfAsSeen, - maxBytes = 200) { request => + maxBytes = 200, ignoreAlias = true) { request => request.dao.markAllNotfsAsSeen(request.theUserId) loadNotificationsImpl(request.theUserId, upToWhen = None, request) } def markNotificationAsSeen(): Action[JsValue] = PostJsonAction(RateLimits.MarkNotfAsSeen, - maxBytes = 200) { request => + maxBytes = 200, ignoreAlias = true) { request => import request.{dao, theRequesterId} val notfId = (request.body \ "notfId").as[NotificationId] dao.markNotificationAsSeen(theRequesterId, notfId) @@ -1200,7 +1209,7 @@ class UserController @Inject()(cc: ControllerComponents, edContext: TyContext) def snoozeNotifications(): Action[JsValue] = PostJsonAction(RateLimits.ConfigUser, - maxBytes = 200) { request => + maxBytes = 200, ignoreAlias = true) { request => import request.{dao, theRequesterId} val untilWhen: Option[When] = (request.body \ "untilMins").as[JsValue] match { @@ -1215,7 +1224,8 @@ class UserController @Inject()(cc: ControllerComponents, edContext: TyContext) def saveContentNotfPref: Action[JsValue] = PostJsonAction(RateLimits.ConfigUser, - MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 500) { request => + MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 500, ignoreAlias = true, + ) { request => import request.{dao, theRequester => requester} val body = request.body val memberId = (body \ "memberId").as[MemberId] @@ -1407,20 +1417,15 @@ class UserController @Inject()(cc: ControllerComponents, edContext: TyContext) RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request => import request.{dao, requester} - val pageMeta = dao.getPageMeta(pageId) getOrElse throwIndistinguishableNotFound("EdE4Z0B8P5") - val categoriesRootLast = dao.getAncestorCategoriesRootLast(pageMeta.categoryId) - SECURITY // Later: skip authors of hidden / deleted / private comments. [priv_comts] + // & bookmarks, once implemented. [dont_list_bookmarkers] // Or if some time in the future there will be "hidden" accounts [private_pats] // — someone who don't want strangers and new members to see hens profile — // then, would need to exclude those accounts here. CHECK_AUTHN_STRENGTH // disallow if just sid part 1+2 but not embedded page - throwNoUnless(Authz.maySeePage( - pageMeta, request.user, dao.getGroupIdsOwnFirst(request.user), - dao.getAnyPrivateGroupTalkMembers(pageMeta), categoriesRootLast, - tooManyPermissions = dao.getPermsOnPages(categoriesRootLast)), "EdEZBXKSM2") + dao.throwIfMayNotSeePage2(pageId, request.reqrTargetSelf)(anyTx = None) // Also load deleted anon12345 members. Simpler, and they'll typically be very few or none. [5KKQXA4] COULD // load groups too, so it'll be simpler to e.g. mention @support. @@ -1465,7 +1470,7 @@ class UserController @Inject()(cc: ControllerComponents, edContext: TyContext) /** maxBytes = 3000 because the about text might be fairly long. */ def saveAboutMemberPrefs: Action[JsValue] = PostJsonAction(RateLimits.ConfigUser, - maxBytes = 3000) { request => + maxBytes = 3000, ignoreAlias = true) { request => val prefs = aboutMemberPrefsFromJson(request.body) _quickThrowUnlessMayEditPrefs(prefs.userId, request.theRequester) request.dao.saveAboutMemberPrefsIfAuZ(prefs, request.who) @@ -1478,7 +1483,10 @@ class UserController @Inject()(cc: ControllerComponents, edContext: TyContext) def saveAboutGroupPreferences: Action[JsValue] = PostJsonAction(RateLimits.ConfigUser, - maxBytes = 3000) { request => + maxBytes = 3000, + // ignoreAlias = true, — or do care? Groups != oneself, maybe some admin + // might believe they'd be editing the group settings anonymously? + ) { request => import request.{dao, theRequester => requester} val prefs = aboutGroupPrefsFromJson(request.body) if (!requester.isAdmin) @@ -1506,7 +1514,7 @@ class UserController @Inject()(cc: ControllerComponents, edContext: TyContext) def saveUiPreferences: Action[JsValue] = PostJsonAction(RateLimits.ConfigUser, - maxBytes = 3000) { request => + maxBytes = 3000, ignoreAlias = true) { request => import request.{body, dao, theRequester => requester} val memberId = (body \ "memberId").as[UserId] val prefs = (body \ "prefs").as[JsObject] @@ -1578,7 +1586,7 @@ class UserController @Inject()(cc: ControllerComponents, edContext: TyContext) def saveMemberPrivacyPrefs: Action[JsValue] = PostJsonAction(RateLimits.ConfigUser, - maxBytes = 100) { request => + maxBytes = 100, ignoreAlias = true) { request => val userId = parseInt32(request.body, "userId") val prefs: MemberPrivacyPrefs = JsX.memberPrivacyPrefsFromJson(request.body) _quickThrowUnlessMayEditPrefs(userId, request.theRequester) diff --git a/appsv/server/controllers/ViewPageController.scala b/appsv/server/controllers/ViewPageController.scala index d7c11d6267..513aa29836 100644 --- a/appsv/server/controllers/ViewPageController.scala +++ b/appsv/server/controllers/ViewPageController.scala @@ -247,8 +247,10 @@ class ViewPageController @Inject()(cc: ControllerComponents, edContext: TyContex } + /** Ignores any [persona_mode]. See [anon_read_progr]. + */ def markPageAsSeen(pageId: PageId): Action[JsValue] = PostJsonAction(NoRateLimits, - MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 2) { request => + MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 2, ignoreAlias = true) { request => CHECK_AUTHN_STRENGTH request.dao.getAnyWatchbar(request.theReqerId) foreach { watchbar => val newWatchbar = watchbar.markPageAsSeen(pageId) diff --git a/appsv/server/controllers/VoteController.scala b/appsv/server/controllers/VoteController.scala index 87d723de00..fc7608ecd4 100644 --- a/appsv/server/controllers/VoteController.scala +++ b/appsv/server/controllers/VoteController.scala @@ -22,6 +22,7 @@ import com.debiki.core.Prelude._ import collection.immutable import debiki._ import debiki.EdHttp._ +import debiki.dao.SiteDao import debiki.JsonUtils.{asJsObject, parseInt32} import talkyard.server.{TyContext, TyController} import talkyard.server.authz.Authz @@ -51,7 +52,7 @@ class VoteController @Inject()(cc: ControllerComponents, edContext: TyContext) * postIdsRead: [1, 9, 53, 82] */ def handleVotes: Action[JsValue] = PostJsonAction(RateLimits.RatePost, - MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 500) { + MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 500, canUseAlias = true) { request: JsonPostRequest => import request.{dao, theRequester => requester} val body = asJsObject(request.body, "votes request body") @@ -69,9 +70,8 @@ class VoteController @Inject()(cc: ControllerComponents, edContext: TyContext) val actionStr = (body \ "action").as[String] val postNrsReadSeq = (body \ "postNrsRead").asOpt[immutable.Seq[PostNr]] - val doAsAnon: Opt[WhichAnon] = parser.parseWhichAnonJson(body) getOrIfBad { prob => - throwBadReq("TyEANONPARVO", s"Bad anon params: $prob") - } + val asAlias: Opt[WhichAliasPat] = + SiteDao.checkAliasOrThrowForbidden(body, requester, request.anyAliasPat)(dao) val postNrsRead = postNrsReadSeq.getOrElse(Nil).toSet @@ -106,7 +106,7 @@ class VoteController @Inject()(cc: ControllerComponents, edContext: TyContext) CHECK_AUTHN_STRENGTH - val reqrTgt = request.reqrTargetSelf.denyUnlessLoggedIn() + val reqrTgt = request.reqrTargetSelf.denyUnlessLoggedIn() // [alias_4_principal] var anyNewAnon: Opt[Anonym] = None @@ -115,7 +115,7 @@ class VoteController @Inject()(cc: ControllerComponents, edContext: TyContext) } else { anyNewAnon = dao.addVoteIfAuZ(pageId, postNr, voteType, reqrAndVoter = reqrTgt, - voterIp = Some(request.ip), postNrsRead, doAsAnon) + voterIp = Some(request.ip), postNrsRead, asAlias) } RACE // Fine, harmless. diff --git a/appsv/server/debiki/MailerActor.scala b/appsv/server/debiki/MailerActor.scala index fda8f4ed0f..84d617cf85 100644 --- a/appsv/server/debiki/MailerActor.scala +++ b/appsv/server/debiki/MailerActor.scala @@ -355,7 +355,7 @@ class MailerActor( emailMaybeWrongAddr.sentTo } else { - val anyPp = emailMaybeWrongAddr.toUserId.flatMap(siteDao.getParticipant) + val anyPp = emailMaybeWrongAddr.toUserId.flatMap(id => siteDao.getParticipant(id)) anyPp.map(_.email) getOrElse emailMaybeWrongAddr.sentTo } diff --git a/appsv/server/debiki/ReactJson.scala b/appsv/server/debiki/ReactJson.scala index 7a967a6942..42b05beaaa 100644 --- a/appsv/server/debiki/ReactJson.scala +++ b/appsv/server/debiki/ReactJson.scala @@ -498,7 +498,7 @@ class JsonMaker(dao: SiteDao) { val site = dao.theSite() val idps = dao.getSiteCustomIdentityProviders(onlyEnabled = true) - val jsonObj = Json.obj( + var jsonObj = Json.obj( "dbgSrc" -> "PgToJ", // These render params need to be known client side, so the page can be rendered in exactly // the same way, client side. Otherwise React can mess up the html structure, & things = broken. @@ -534,6 +534,16 @@ class JsonMaker(dao: SiteDao) { "tagTypesById" -> JsObject(tagTypes.map(tt => tt.id.toString -> JsTagType(tt))), "pagesById" -> Json.obj(page.id -> pageJsonObj)) + // If listing topics, we've set `store.topics` to the topic list (just above), + // but the client also wants to know for which category we're listing topics. + // (Pat might be listing topics in a sub category in the forum. Currently, the + // server side renderer always lists recently active topics in *all* forum + // categories though.) + if (page.pageType == PageType.Forum) { + devDieIf(anyCurCatId.isEmpty, "TyE2GPS7N3") + anyCurCatId.foreach(id => jsonObj += "listingCatId" -> JsNumber(id)) + } + val reactStoreJsonString = jsonObj.toString() val version = CachedPageVersion( diff --git a/appsv/server/debiki/dao/CategoriesDao.scala b/appsv/server/debiki/dao/CategoriesDao.scala index 92fe22fe5e..f1df848d83 100644 --- a/appsv/server/debiki/dao/CategoriesDao.scala +++ b/appsv/server/debiki/dao/CategoriesDao.scala @@ -544,9 +544,14 @@ trait CategoriesDao { // just because all most-recent-pages are e.g. hidden. val filteredPages = pagesInclForbidden filter { page => val categories = getAncestorCategoriesRootLast(page.categoryId) + val pageAuthor = getParticipant(page.meta.authorId) COULD_OPTIMIZE // Do for all pages in the same cat at once? [authz_chk_mny_pgs] + COULD_OPTIMIZE // Lazy-load e.g. page author and members — usually not needed. + COULD_OPTIMIZE // Don't load author — instead, compare directly with [list_by_alias] + // posts3 & pages3.true_author_id_c / true_owner_id_c val may = talkyard.server.authz.Authz.maySeePage( page.meta, + pageAuthor = pageAuthor.getOrDie("TyE402SKJF4"), user = authzCtx.requester, groupIds = authzCtx.groupIdsUserIdFirst, pageMembers = getAnyPrivateGroupTalkMembers(page.meta), diff --git a/appsv/server/debiki/dao/PageDao.scala b/appsv/server/debiki/dao/PageDao.scala index ad571c046c..2d90ec94d1 100644 --- a/appsv/server/debiki/dao/PageDao.scala +++ b/appsv/server/debiki/dao/PageDao.scala @@ -34,6 +34,8 @@ case class PageDao(override val id: PageId, settings: AllSettings, transaction: SiteTransaction, anyDao: Opt[SiteDao]) extends Page { + assert(id ne null) + def sitePageId = SitePageId(transaction.siteId, id) var _path: Option[PagePathWithId] = null @@ -99,6 +101,7 @@ case class PagePartsDao( anyDao: Opt[SiteDao] = None, ) extends PageParts { + assert(pageId ne null) private var _meta: Option[PageMeta] = null diff --git a/appsv/server/debiki/dao/PagesDao.scala b/appsv/server/debiki/dao/PagesDao.scala index 937027a489..f78b0167f2 100644 --- a/appsv/server/debiki/dao/PagesDao.scala +++ b/appsv/server/debiki/dao/PagesDao.scala @@ -24,7 +24,7 @@ import com.debiki.core.PageParts.FirstReplyNr import com.debiki.core.Participant.SystemUserId import debiki._ import debiki.EdHttp._ -import talkyard.server.authz.{Authz, ReqrAndTgt} +import talkyard.server.authz.{Authz, ReqrAndTgt, ReqrStranger, AnyReqrAndTgt} import talkyard.server.spam.SpamChecker import java.{util => ju} import scala.collection.immutable @@ -51,6 +51,10 @@ case class CreatePageResult( * SECURITY SHOULD either continue creating review tasks for new users, until they've been * reviewed and we know the user is safe. Or block the user from posting more comments, * until his/her first ones have been reviewed. + * + * REFACTOR: Split into CreatePageDao and AlterPageDao. [pg_ctrl_dao] + * Merge PageTitleSettingsController into AlterPageDao, except for HTTP req + * handling which could be merged into PageController? */ trait PagesDao { self: SiteDao => @@ -71,17 +75,17 @@ trait PagesDao { anyFolder: Option[String], anySlug: Option[String], title: TitleSourceAndHtml, bodyTextAndHtml: TextAndHtml, showId: Boolean, deleteDraftNr: Option[DraftNr], byWho: Who, spamRelReqStuff: SpamRelReqStuff, - doAsAnon: Opt[WhichAnon.NewAnon] = None, // make non-optional? discussionIds: Set[AltPageId] = Set.empty, embeddingUrl: Option[String] = None, extId: Option[ExtId] = None, ): PagePathWithId = { + dieIf(!globals.isOrWasTest, "TyE306MGJCW2") // see deprecation note above val withTags = Nil - createPage2(pageRole, pageStatus = pageStatus, anyCategoryId = anyCategoryId, withTags, + createPageSkipAuZ(pageRole, pageStatus = pageStatus, anyCategoryId = anyCategoryId, withTags, anyFolder = anyFolder, anySlug = anySlug, title = title, bodyTextAndHtml = bodyTextAndHtml, showId = showId, deleteDraftNr = deleteDraftNr, byWho = byWho, spamRelReqStuff = spamRelReqStuff, - doAsAnon = doAsAnon, + asAlias = None, discussionIds = discussionIds, embeddingUrl = embeddingUrl, extId = extId, ).path @@ -102,15 +106,24 @@ trait PagesDao { withTags: ImmSeq[TagTypeValue], anyFolder: Opt[St], anySlug: Opt[St], title: TitleSourceAndHtml, bodyTextAndHtml: TextAndHtml, showId: Bo, deleteDraftNr: Opt[DraftNr], - reqrAndCreator: ReqrAndTgt, + reqrAndCreator: AnyReqrAndTgt, spamRelReqStuff: SpamRelReqStuff, - doAsAnon: Opt[WhichAnon.NewAnon] = None, // make non-optional? + asAlias: Opt[WhichAliasPat], discussionIds: Set[AltPageId] = Set.empty, embeddingUrl: Opt[St] = None, refId: Opt[RefId] = None, ): CreatePageResult = { - val reqrAndLevels = readTx(loadUserAndLevels(reqrAndCreator.reqrToWho, _)) + // [dupl_load_lvls] + val reqrAndLevels: AnyUserAndLevels = reqrAndCreator match { + case rt: ReqrAndTgt => + readTx(loadUserAndLevels(rt.reqrToWho, _)) + case _: ReqrStranger => + val threatLevel = this.readTx(this.loadThreatLevelNoUser( + reqrAndCreator.browserIdData, _)) + StrangerAndThreatLevel(threatLevel) + } + val catsRootLast = getAncestorCategoriesSelfFirst(inCatId) val tooManyPermissions = getPermsOnPages(categories = catsRootLast) @@ -119,45 +132,67 @@ trait PagesDao { // Both the requester and the creator need the mayCreatePage() permission. [2_perm_chks] // See docs in docs/ty-security.adoc [api_do_as]. - // [dupl_api_perm_check] + TESTS_MISSING + // Dupl permission check, below? [dupl_prem_chk] throwNoUnless(Authz.mayCreatePage( - reqrAndLevels, getOnesGroupIds(reqrAndLevels.user), + reqrAndLevels, + asAlias = + if (reqrAndCreator.areNotTheSame) { + // Then the alias is for the user who will be the page author, [4_doer_not_reqr] + // not for the HTTP request requester. + None + } + else { + // The requester is the creator, so any alias is also the requester's. + asAlias + }, + getOnesGroupIds(reqrAndLevels.anyUser getOrElse UnknownParticipant), pageType, PostType.Normal, pinWhere = None, anySlug = anySlug, anyFolder = anyFolder, inCategoriesRootLast = catsRootLast, tooManyPermissions), "TyE_CRPG_REQR_PERMS") - if (reqrAndCreator.areNotTheSame) { - val creatorAndLevels = readTx(loadUserAndLevels(reqrAndCreator.targetToWho, _)) - throwNoUnless(Authz.mayCreatePage( - creatorAndLevels, getOnesGroupIds(creatorAndLevels.user), - pageType, PostType.Normal, pinWhere = None, - anySlug = anySlug, anyFolder = anyFolder, - inCategoriesRootLast = catsRootLast, - tooManyPermissions), - "TyE_CRPG_TGT_PERMS") + val createdByWho: Who = reqrAndCreator match { + case theReqrAndCreator: ReqrAndTgt => + val creatorWho = theReqrAndCreator.targetToWho + if (theReqrAndCreator.areNotTheSame) { + TESTS_MISSING; COULD_OPTIMIZE // don't load user again [2WKG06SU] + val creatorAndLevels: UserAndLevels = readTx(loadUserAndLevels(creatorWho, _)) + throwNoUnless(Authz.mayCreatePage( + creatorAndLevels, asAlias, getOnesGroupIds(creatorAndLevels.user), + pageType, PostType.Normal, pinWhere = None, + anySlug = anySlug, anyFolder = anyFolder, + inCategoriesRootLast = catsRootLast, + // (This includes permissions for both the requester and target users, and + // everyone else, but mayCreatePage() uses only those of the creator.) + tooManyPermissions), + "TyE_CRPG_TGT_PERMS") + } + creatorWho + case _ => + Who(TrueId(UnknownUserId), reqrAndCreator.browserIdData, isAnon = false) } - createPage2( + createPageSkipAuZ( pageType, pageStatus, anyCategoryId = inCatId, withTags, anyFolder = anyFolder, anySlug = anySlug, title, bodyTextAndHtml = bodyTextAndHtml, showId = showId, deleteDraftNr = deleteDraftNr, - byWho = reqrAndCreator.targetToWho, + byWho = createdByWho, spamRelReqStuff, - doAsAnon, + asAlias, discussionIds = discussionIds, embeddingUrl = embeddingUrl, extId = refId) } - @deprecated("Merge with createPageIfAuZ?") - def createPage2(pageRole: PageType, pageStatus: PageStatus, anyCategoryId: Option[CategoryId], + + def createPageSkipAuZ(pageRole: PageType, pageStatus: PageStatus, anyCategoryId: Option[CategoryId], withTags: ImmSeq[TagTypeValue], anyFolder: Option[String], anySlug: Option[String], title: TitleSourceAndHtml, bodyTextAndHtml: TextAndHtml, showId: Boolean, deleteDraftNr: Option[DraftNr], byWho: Who, spamRelReqStuff: SpamRelReqStuff, - doAsAnon: Opt[WhichAnon.NewAnon] = None, // make non-optional? + asAlias: Opt[WhichAliasPat], discussionIds: Set[AltPageId] = Set.empty, embeddingUrl: Option[String] = None, extId: Option[ExtId] = None, ): CreatePageResult = { @@ -203,7 +238,7 @@ trait PagesDao { pinWhere = None, byWho, Some(spamRelReqStuff), - doAsAnon, + asAlias, discussionIds = discussionIds, embeddingUrl = embeddingUrl, extId = extId)(tx, staleStuff) @@ -222,7 +257,7 @@ trait PagesDao { /** Returns (PagePath, body-post, any-review-task) * - * @param doAsAnon — must be a new anonym, since anons are per page, and this page is new. + * @param asAlias — must be a new anonym, since anons are per page, and this page is new. */ def createPageImpl(pageRole: PageType, pageStatus: PageStatus, @@ -237,7 +272,7 @@ trait PagesDao { pinWhere: Option[PinPageWhere] = None, byWho: Who, spamRelReqStuff: Option[SpamRelReqStuff], - doAsAnon: Opt[WhichAnon.NewAnon] = None, + asAlias: Opt[WhichAliasPat] = None, hidePageBody: Boolean = false, layout: Option[PageLayout] = None, bodyPostType: PostType = PostType.Normal, @@ -265,8 +300,9 @@ trait PagesDao { //val authzCtx = ForumAuthzContext(Some(author), groupIds, permissions) ? [5FLK02] val settings = loadWholeSiteSettings(tx) + // Dupl permission check [dupl_prem_chk] when called via createPageIfAuZ()? dieOrThrowNoUnless(Authz.mayCreatePage( // REFACTOR COULD pass a pageAuthzCtx instead [5FLK02] - realAuthorAndLevels, groupIds, + realAuthorAndLevels, asAlias, groupIds, pageRole, bodyPostType, pinWhere, anySlug = anySlug, anyFolder = anyFolder, inCategoriesRootLast = categoryPath, permissions), "EdE5JGK2W4") @@ -286,10 +322,10 @@ trait PagesDao { val ( reviewReasons: Seq[ReviewReason], shallApprove: Boolean) = - throwOrFindReviewNewPageReasons(realAuthorAndLevels, pageRole, tx) + throwOrFindReviewNewPageReasons(realAuthorAndLevels, pageRole, tx) // [mod_deanon_risk] val approvedById = - if (realAuthor.isStaff) { + if (realAuthor.isStaff) { // [mod_deanon_risk] dieIf(!shallApprove, "EsE2UPU70") Some(realAuthor.id) } @@ -334,28 +370,9 @@ trait PagesDao { val titleUniqueId = tx.nextPostId() val bodyUniqueId = titleUniqueId + 1 - TESTS_MISSING // Dupl code. [get_anon] check all other too. - val authorMaybeAnon = - if (doAsAnon.forall(!_.anonStatus.isAnon)) { - realAuthor - } - else { - throwForbiddenIf(doAsAnon.exists(_.isInstanceOf[WhichAnon.SameAsBefore]), - "TyE5FWMJL30P", "There're no anons to reuse on a new page") - // Dupl code. [mk_new_anon] - val anonymId = tx.nextGuestId - val anonym = Anonym( - id = anonymId, - createdAt = tx.now, - anonStatus = doAsAnon.getOrDie("TyE7MF26F").anonStatus, - anonForPatId = realAuthorId, - anonOnPageId = pageId) - // We'll insert the anonym before the page exists, but there's a - // foreign key: pats_t.anon_on_page_id_st_c, so defer constraints: - tx.deferConstraints() - tx.insertAnonym(anonym) - anonym - } + val authorMaybeAnon: Pat = SiteDao.getAliasOrTruePat( + truePat = realAuthor, pageId = pageId, asAlias, mayReuseAnon = false, + isCreatingPage = true)(tx, IfBadAbortReq) val titlePost = Post.createTitle( uniqueId = titleUniqueId, @@ -377,7 +394,7 @@ trait PagesDao { approvedById = approvedById) .copy( bodyHiddenAt = ifThenSome(hidePageBody, now.toJavaDate), - bodyHiddenById = ifThenSome(hidePageBody, authorMaybeAnon.id), + bodyHiddenById = ifThenSome(hidePageBody, authorMaybeAnon.id), bodyHiddenReason = None) // add `hiddenReason` function parameter? var nextTagId: TagId = @@ -436,7 +453,7 @@ trait PagesDao { if (spamRelReqStuff.isEmpty || !globals.spamChecker.spamChecksEnabled) None else if (settings.userMustBeAuthenticated) None else if (!canStrangersSeePagesInCat_useTxMostly(anyCategoryId, tx)) None - else if (!SpamChecker.shallCheckSpamFor(realAuthorAndLevels)) None + else if (!SpamChecker.shallCheckSpamFor(realAuthorAndLevels)) None // [mod_deanon_risk] else { // The uri is now sth like /-/create-page. Change to the path to the page // we're creating. @@ -600,23 +617,24 @@ trait PagesDao { } - def ifAuthAcceptAnswer(pageId: PageId, postUniqueId: PostId, reqerTrueId: TrueId, - browserIdData: BrowserIdData): Option[ju.Date] = { + def ifAuthAcceptAnswer(pageId: PageId, postUniqueId: PostId, reqrAndDoer: ReqrAndTgt, + browserIdData: BrowserIdData, asAlias: Opt[WhichAliasPat]): Opt[ju.Date] = { + unimplIf(reqrAndDoer.areNotTheSame, "Accepting answer on behalf of sbd else") + val answeredAt = writeTx { (tx, staleStuff) => - val user = tx.loadTheParticipant(reqerTrueId.curId) + + val user = tx.loadTheParticipant(reqrAndDoer.reqrId) // reqr & doer are the same, see above val oldMeta = tx.loadThePageMeta(pageId) + + COULD_OPTIMIZE // Check see-post & alter-page at the same time, 1 fn call. + throwIfMayNotSeePost2(ThePost.WithId(postUniqueId), reqrAndDoer)(tx) + throwIfMayNotAlterPage(user, asAlias, oldMeta, changesOnlyTypeOrStatus = true, tx) + if (!oldMeta.pageType.canBeSolved) throwBadReq("DwE4KGP2", "This page is not a question so no answer can be selected") - ANON_UNIMPL; BUG // This prevents anons from accepting answers to their own - // questions? [anon_pages] - throwForbiddenIf(!user.isStaffOrCoreMember && user.id != oldMeta.authorId, - "TyE8JGY3", "Only core members and the topic author can accept an answer") - - // Pat might no longer be allowed to see the page — maybe it's been deleted, - // or moved to a private category. [may0_see_own] - SECURITY // minor: Should be may-not-see-post. - throwIfMayNotSeePage(oldMeta, Some(user))(tx) + val doingAs: Pat = SiteDao.getAliasOrTruePat( + user, pageId = pageId, asAlias, mayCreateAnon = false)(tx, IfBadAbortReq) val post = tx.loadThePost(postUniqueId) throwBadRequestIf(post.isDeleted, "TyE4BQR20", "That post has been deleted, cannot mark as answer") @@ -637,13 +655,14 @@ trait PagesDao { version = oldMeta.version + 1) tx.updatePageMeta(newMeta, oldMeta = oldMeta, markSectionPageStale = true) + addMetaMessage(doingAs, s" accepted an answer", pageId, tx) // I18N staleStuff.addPageId(pageId, memCacheOnly = true) val auditLogEntry = AuditLogEntry( siteId = siteId, id = AuditLogEntry.UnassignedId, didWhat = AuditLogEntryType.PageAnswered, - doerTrueId = reqerTrueId, + doerTrueId = doingAs.trueId2, doneAt = tx.now.toJavaDate, browserIdData = browserIdData, pageId = Some(pageId), @@ -660,12 +679,12 @@ trait PagesDao { // If a trusted member thinks the answer is ok, then, maybe resolving // any review mod tasks for the answer — and the question too. // Test: modn-from-disc-page-review-after.2browsers TyTE2E603RKG4.TyTE2E50ARMS - if (user.isStaffOrTrustedNotThreat) { - maybeReviewAcceptPostByInteracting(post, moderator = user, + if (doingAs.isStaffOrTrustedNotThreat) { + maybeReviewAcceptPostByInteracting(post, moderator = doingAs, ReviewDecision.InteractAcceptAnswer)(tx, staleStuff) tx.loadOrigPost(pageId).getOrBugWarn("TyE205WKT734") { origPost => - maybeReviewAcceptPostByInteracting(origPost, moderator = user, + maybeReviewAcceptPostByInteracting(origPost, moderator = doingAs, ReviewDecision.InteractAcceptAnswer)(tx, staleStuff) } } @@ -677,17 +696,20 @@ trait PagesDao { } - def ifAuthUnacceptAnswer(pageId: PageId, reqerTrueId: TrueId, - browserIdData: BrowserIdData): U = { + def ifAuthUnacceptAnswer(pageId: PageId, reqrAndDoer: ReqrAndTgt, + browserIdData: BrowserIdData, asAlias: Opt[WhichAliasPat]): U = { + + unimplIf(reqrAndDoer.areNotTheSame, "Unaccepting answer on behalf of sbd else") + readWriteTransaction { tx => - val user = tx.loadTheParticipant(reqerTrueId.curId) + val user = tx.loadTheParticipant(reqrAndDoer.reqrId) val oldMeta = tx.loadThePageMeta(pageId) - ANON_UNIMPL; BUG // This prevents anons from un-accepting answers? [anon_pages] - throwForbiddenIf(!user.isStaffOrCoreMember && user.id != oldMeta.authorId, - "TyE2GKUB4", "Only core members and the topic author can unaccept an answer") - // Pat might no longer be allowed to see the page. [may0_see_own] - throwIfMayNotSeePage(oldMeta, Some(user))(tx) + throwIfMayNotAlterPage(user, asAlias, oldMeta, changesOnlyTypeOrStatus = true, tx) + // (Don't require user to be able to see the current answer — maybe it's been deleted.) + + val doingAs: Pat = SiteDao.getAliasOrTruePat(user, pageId = pageId, asAlias, + mayCreateAnon = false)(tx, IfBadAbortReq) // Dupl line. [4UKP58B] val newMeta = oldMeta.copy(answeredAt = None, answerPostId = None, closedAt = None, @@ -697,7 +719,7 @@ trait PagesDao { siteId = siteId, id = AuditLogEntry.UnassignedId, didWhat = AuditLogEntryType.PageUnanswered, - doerTrueId = reqerTrueId, + doerTrueId = doingAs.trueId2, doneAt = tx.now.toJavaDate, browserIdData = browserIdData, pageId = Some(pageId), @@ -707,13 +729,14 @@ trait PagesDao { postNr = None) tx.updatePageMeta(newMeta, oldMeta = oldMeta, markSectionPageStale = true) + addMetaMessage(doingAs, s" unaccepted an answer", pageId, tx) // I18N tx.insertAuditLogEntry(auditLogEntry) } refreshPageInMemCache(pageId) } - def ifAuthTogglePageClosed(pageId: PageId, reqr: ReqrId, asAlias: Opt[WhichAnon]) + def ifAuthTogglePageClosed(pageId: PageId, reqr: ReqrId, asAlias: Opt[WhichAliasPat]) : Opt[ju.Date] = { val now = globals.now() val newClosedAt = readWriteTransaction { tx => @@ -722,7 +745,10 @@ trait PagesDao { TESTS_MISSING - throwIfMayNotSeePage(oldMeta, Some(user))(tx) + throwIfMayNotAlterPage(user, asAlias, oldMeta, changesOnlyTypeOrStatus = true, tx) + + val doingAs: Pat = SiteDao.getAliasOrTruePat(user, pageId = pageId, asAlias, + mayCreateAnon = false)(tx, IfBadAbortReq) throwBadRequestIf(oldMeta.isDeleted, "TyE0CLSPGDLD", s"Cannot close or reopen deleted pages") @@ -730,24 +756,11 @@ trait PagesDao { throwBadRequestIf(!oldMeta.pageType.canClose, "DwE4PKF7", s"Cannot close pages of type ${oldMeta.pageType}") - val aliasOrTrue: Pat = SiteDao.getAliasOrTruePat(truePat = user, pageId = pageId, - asAlias, mayCreateAnon = false)(tx, IfBadAbortReq) - - val isAuthor = aliasOrTrue.id == oldMeta.authorId - - if (!isAuthor) { - throwForbiddenIf(!user.isStaffOrCoreMember, - "TyE5JPK7", "Only core members and the topic author can close / reopen") - - // Later: Allow, but use a separate anon for moderator actions. [anon_mods] [deanon_risk] - throwForbiddenIf(aliasOrTrue.isAlias, - "TyE4PGR02P", "Can't open/close sbd else's page anonymously") - } - val (newClosedAt: Option[ju.Date], didWhat: String) = oldMeta.closedAt match { case None => (Some(now.toJavaDate), "closed") case Some(_) => (None, "reopened") } + val newMeta = oldMeta.copy( closedAt = newClosedAt, version = oldMeta.version + 1, @@ -759,13 +772,13 @@ trait PagesDao { didWhat = if (newMeta.isClosed) AuditLogEntryType.PageClosed else AuditLogEntryType.PageReopened, - doerTrueId = reqr.trueId, + doerTrueId = doingAs.trueId2, doneAt = tx.now.toJavaDate, browserIdData = reqr.browserIdData, pageId = Some(pageId)) tx.updatePageMeta(newMeta, oldMeta = oldMeta, markSectionPageStale = true) - addMetaMessage(aliasOrTrue, s" $didWhat this topic", pageId, tx) + addMetaMessage(doingAs, s" $didWhat this topic", pageId, tx) tx.insertAuditLogEntry(auditLogEntry) newClosedAt @@ -775,27 +788,40 @@ trait PagesDao { } - def deletePagesIfAuth(pageIds: Seq[PageId], reqr: ReqrId, undelete: Bo): U = { + def deletePagesIfAuth(pageIds: Seq[PageId], reqr: ReqrId, asAlias: Opt[WhichAliasPat], + undelete: Bo): U = { + // Anonyms are per page, so we can't delete many pages at a time, if asAlias is an anonym. + throwForbiddenIf(asAlias.isDefined && pageIds.size != 1, + "TyEALIMANYPAGES", s"Can delete only one page at a time, when using an alias. I got ${ + pageIds.size} pages") + writeTx { (tx, staleStuff) => - // SHOULD LATER: [4GWRQA28] If is sub community (= forum page), delete the root category too, - // so all topics in the sub community will get deleted. + // Later: [4GWRQA28] If is a sub community (= forum page), delete the root category too, + // so all topics in the sub community will get deleted. [subcomms] // And remove the sub community from the watchbar's Communities section. // (And if undeleting the sub community, undelete the root category too.) - deletePagesImpl(pageIds, reqr, undelete = undelete)(tx, staleStuff) + deletePagesImpl(pageIds, reqr, asAlias, undelete = undelete)(tx, staleStuff) } refreshPagesInMemCache(pageIds.toSet) } - def deletePagesImpl(pageIds: Seq[PageId], reqr: ReqrId, undelete: Bo = false)( - tx: SiteTx, staleStuff: StaleStuff): U = { + def deletePagesImpl(pageIds: Seq[PageId], reqr: ReqrId, asAlias: Opt[WhichAliasPat], + undelete: Bo = false)(tx: SiteTx, staleStuff: StaleStuff): U = { BUG; SHOULD // delete notfs or mark deleted? [notfs_bug] [nice_notfs] // But don't delete any review tasks — good if staff reviews, if a new // member posts something trollish, people react, then hen deletes hens page. // Later, if undeleting, then restore the notfs? [undel_posts] - val deleter = tx.loadTheParticipant(reqr.id) + val trueDeleter = tx.loadTheParticipant(reqr.id) // [alias_4_principal] + + dieIf(asAlias.isDefined && pageIds.size != 1, + "TyEALIMANYPGS2", s"Alias deleting ${pageIds.size} != 1 pages") + + val deleterPersona: Pat = SiteDao.getAliasOrTruePat( + truePat = trueDeleter, pageId = pageIds(0), asAlias, + mayCreateAnon = false)(tx, IfBadAbortReq) for { pageId <- pageIds @@ -803,23 +829,25 @@ trait PagesDao { // Hmm but trying to delete a deleted *post*, throws an error. [5WKQRH2] if pageMeta.isDeleted == undelete } { + SHOULD // use Authz and mayDeletePage instead? [authz_may_del] [granular_perms] + // Mods may not delete pages they cannot see — maybe admins have // their own internal discussions. - throwIfMayNotSeePage(pageMeta, Some(deleter))(tx) + throwIfMayNotSeePage(pageMeta, Some(trueDeleter))(tx) // Ordinary members may only delete their own pages, before others have replied. // Sync with client side. [who_del_pge] - ANON_UNIMPL // This prevents anons from deleting their pages? [anon_pages] (authorId - // is the anon, reqr.id is the true id != the anon.) - if (!deleter.isStaff) { - throwForbiddenIf(pageMeta.authorId != reqr.id, + if (!deleterPersona.isStaff) { + // (This message is misleading if trying to delete one's own post, using the wrong + // alias. But let's fix by using Authz & mayDeletePage instead? [authz_may_del]) + throwForbiddenIf(pageMeta.authorId != deleterPersona.id, "TyEDELOTRSPG_", "May not delete other people's pages") // Shouldn't have been allowed to see sbd else's deleted page. - val deletedOwn = pageMeta.deletedById.is(reqr.id) && - pageMeta.authorId == reqr.id - dieIf(undelete && !deletedOwn, "TyEUNDELOTRS", - s"s$siteId: User ${reqr.id} may not undelete sbd else's page $pageId") + val deletedOwn = pageMeta.deletedById.is(deleterPersona.id) && + pageMeta.authorId == deleterPersona.id + dieIf(undelete && !deletedOwn, "TyEUNDELOTRS", s"s$siteId: User ${ + deleterPersona.nameParaId} may not undelete sbd else's page $pageId") // When there are replies, the UX should send a request to delete the // orig post only — but not the whole page. (Unless is staff, then can delete @@ -831,14 +859,14 @@ trait PagesDao { } if ((pageMeta.pageType.isSection || pageMeta.pageType == PageType.CustomHtmlPage) && - !deleter.isAdmin) + !deleterPersona.isAdmin) throwForbidden("EsE5GKF23_", "Only admin may (un)delete sections and HTML pages") val baseAuditEntry = AuditLogEntry( siteId = siteId, id = AuditLogEntry.UnassignedId, didWhat = AuditLogEntryType.DeletePage, - doerTrueId = reqr.trueId, + doerTrueId = deleterPersona.trueId2, doneAt = tx.now.toJavaDate, browserIdData = reqr.browserIdData, pageId = Some(pageId), @@ -855,7 +883,7 @@ trait PagesDao { else { (pageMeta.copy( deletedAt = Some(tx.now.toJavaDate), - deletedById = Some(deleter.id), + deletedById = Some(deleterPersona.id), version = pageMeta.version + 1), baseAuditEntry) } @@ -884,7 +912,7 @@ trait PagesDao { tx.loadOrigPost(pageMeta.pageId) foreach { origPost => TESTS_MISSING val postAuthor = tx.loadTheParticipant(origPost.createdById) - updateSpamCheckTaskBecausePostDeleted(origPost, postAuthor, deleter = deleter, tx) + updateSpamCheckTaskBecausePostDeleted(origPost, postAuthor, deleter = deleterPersona, tx) } tx.updatePageMeta(newMeta, oldMeta = pageMeta, markSectionPageStale = true) @@ -903,7 +931,7 @@ trait PagesDao { staleStuff.addPageId(pageId, memCacheOnly = true) val un = undelete ? "un" | "" - addMetaMessage(deleter, s" ${un}deleted this topic", pageId, tx) + addMetaMessage(deleterPersona, s" ${un}deleted this topic", pageId, tx) } } diff --git a/appsv/server/debiki/dao/PostsDao.scala b/appsv/server/debiki/dao/PostsDao.scala index 3d67cef78d..df5f2c7a70 100644 --- a/appsv/server/debiki/dao/PostsDao.scala +++ b/appsv/server/debiki/dao/PostsDao.scala @@ -71,7 +71,7 @@ trait PostsDao { postType: PostType, deleteDraftNr: Option[DraftNr], reqrAndReplyer: ReqrAndTgt, spamRelReqStuff: SpamRelReqStuff, - anonHow: Opt[WhichAnon] = None, refId: Opt[RefId] = None, + asAlias: Opt[WhichAliasPat] = None, refId: Opt[RefId] = None, withTags: ImmSeq[TagTypeValue] = Nil) // oops forgot_to_use : InsertPostResult = { @@ -91,7 +91,10 @@ trait PostsDao { // [2_perm_chks] [dupl_re_authz_chk] throwNoUnless(Authz.mayPostReply( - reqrAndLevels, this.getOnesGroupIds(reqrAndLevels.user), + reqrAndLevels, + // See [4_doer_not_reqr]. + asAlias = if (reqrAndReplyer.areNotTheSame) None else asAlias, + this.getOnesGroupIds(reqrAndLevels.user), postType, pageMeta, replyToPosts, privTalkMembers, inCategoriesRootLast = catsRootLast, tooManyPermissions), "TyEM0REPLY1") @@ -99,23 +102,23 @@ trait PostsDao { if (reqrAndReplyer.areNotTheSame) { val replyerAndLevels = readTx(this.loadUserAndLevels(reqrAndReplyer.targetToWho, _)) throwNoUnless(Authz.mayPostReply( - replyerAndLevels, this.getOnesGroupIds(replyerAndLevels.user), + replyerAndLevels, asAlias, this.getOnesGroupIds(replyerAndLevels.user), postType, pageMeta, replyToPosts, privTalkMembers, inCategoriesRootLast = catsRootLast, tooManyPermissions), "TyEM0REPLY2") } - this.insertReply(textAndHtml, pageId = pageId, replyToPostNrs = replyToPostNrs, + this.insertReplySkipAuZ(textAndHtml, pageId = pageId, replyToPostNrs = replyToPostNrs, postType, deleteDraftNr = deleteDraftNr, byWho = reqrAndReplyer.targetToWho, spamRelReqStuff, - anonHow, refId = refId, withTags) + asAlias, refId = refId, withTags) } - def insertReply(textAndHtml: TextAndHtml, pageId: PageId, replyToPostNrs: Set[PostNr], + def insertReplySkipAuZ(textAndHtml: TextAndHtml, pageId: PageId, replyToPostNrs: Set[PostNr], postType: PostType, deleteDraftNr: Option[DraftNr], byWho: Who, spamRelReqStuff: SpamRelReqStuff, - anonHow: Opt[WhichAnon] = None, refId: Opt[RefId] = None, + asAlias: Opt[WhichAliasPat] = None, refId: Opt[RefId] = None, withTags: ImmSeq[TagTypeValue] = Nil) // oops forgot_to_use : InsertPostResult = { @@ -140,7 +143,7 @@ trait PostsDao { val (newPost, author, notifications, anyReviewTask) = writeTx { (tx, staleStuff) => deleteDraftNr.foreach(nr => tx.deleteDraft(byWho.id, nr)) insertReplyImpl(textAndHtml, pageId, replyToPostNrs, postType, - byWho, spamRelReqStuff, now, authorId, tx, staleStuff, anonHow, refId = refId) + byWho, spamRelReqStuff, now, authorId, tx, staleStuff, asAlias, refId = refId) } refreshPageInMemCache(pageId) @@ -163,7 +166,7 @@ trait PostsDao { // Rename authorId to what? realAuthorId? or rename author to authorMaybeAnon? authorId: UserId, tx: SiteTx, staleStuff: StaleStuff, - doAsAnon: Opt[WhichAnon] = None, + asAlias: Opt[WhichAliasPat] = None, skipNotfsAndAuditLog: Boolean = false, refId: Opt[RefId] = None) : (Post, Participant, Notifications, Option[ReviewTask]) = { @@ -180,36 +183,11 @@ trait PostsDao { val realAuthor = realAuthorAndLevels.user val realAuthorAndGroupIds = tx.loadGroupIdsMemberIdFirst(realAuthor) - // Dupl code. [get_anon] - // ------- Maybe ------------ - // Hmm, rename to otherAuthor, and None by default. And set createdById - // to the real account, always, and author_id_c to any anonym's id. [mk_new_anon] - // -------------------------- - val authorMaybeAnon: Pat = - if (doAsAnon.isEmpty) { - realAuthor - } - else doAsAnon.get match { - case WhichAnon.SameAsBefore(anonId) => - // Hmm, verify it's realAuthor's anon ??? — use getAliasOrTruePat() instead - tx.loadTheParticipant(anonId).asAnonOrThrow - case WhichAnon.NewAnon(anonStatus) => - // Dupl code. [mk_new_anon] - val anonymId = tx.nextGuestId - val anonym = Anonym( - id = anonymId, - createdAt = tx.now, - anonStatus = anonStatus, - anonForPatId = realAuthor.id, - anonOnPageId = pageId) - // We'll insert the anonym before the page exists, but there's a - // foreign key: pats_t.anon_on_page_id_st_c, so defer constraints: - tx.deferConstraints() - tx.insertAnonym(anonym) - anonym - } + val authorMaybeAnon: Pat = SiteDao.getAliasOrTruePat( + truePat = realAuthor, pageId = pageId, asAlias)(tx, IfBadAbortReq) - dieOrThrowNoUnless(Authz.mayPostReply(realAuthorAndLevels, realAuthorAndGroupIds, + dieOrThrowNoUnless(Authz.mayPostReply( + realAuthorAndLevels, asAlias /* _not_same_tx, ok */, realAuthorAndGroupIds, postType, page.meta, replyToPosts, tx.loadAnyPrivateGroupTalkMembers(page.meta), tx.loadCategoryPathRootLast(page.meta.categoryId, inclSelfFirst = true), tx.loadPermsOnPages()), "EdEMAY0RE") @@ -258,7 +236,7 @@ trait PostsDao { val approverId = if (realAuthor.isStaff) { dieIf(!shallApprove, "EsE5903") - Some(realAuthor.id) + Some(realAuthor.id) // [mod_deanon_risk] } else if (shallApprove) Some(SystemUserId) else None @@ -681,8 +659,10 @@ 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 replyToPosts = Nil // currently cannot reply to specific posts, in the chat. [7YKDW3] + val asAlias = None // [anon_chats] - dieOrThrowNoUnless(Authz.mayPostReply(authorAndLevels, tx.loadGroupIdsMemberIdFirst(author), + 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") @@ -754,7 +734,7 @@ trait PostsDao { require(textAndHtml.safeHtml.trim.nonEmpty, "TyE592MWP2") // Chat messages currently cannot be anonymous. [anon_chats] - // Note: Farily similar to insertReply() a bit above. [4UYKF21] + // Note: Farily similar to insertReplySkipAuZ() a bit above. [4UYKF21] val authorAndLevels = loadUserAndLevels(who, tx) val author: Pat = authorAndLevels.user @@ -1021,7 +1001,7 @@ trait PostsDao { */ def editPostIfAuth(pageId: PageId, postNr: PostNr, deleteDraftNr: Option[DraftNr], who: Who, spamRelReqStuff: SpamRelReqStuff, newTextAndHtml: SourceAndHtml, - doAsAnon: Opt[WhichAnon] = None): U = { + asAlias: Opt[WhichAliasPat] = None): U = { val realEditorId = who.id // Note: Farily similar to appendChatMessageToLastMessage() just above. [2GLK572] @@ -1049,38 +1029,23 @@ trait PostsDao { if (postToEdit.currentSource == newTextAndHtml.text) return - val anyOtherAuthor = - if (postToEdit.createdById == realEditor.id) None - else Some(tx.loadTheParticipant(postToEdit.createdById)) + // Won't need later, when true id stored in posts3/nodes_t? [posts3_true_id] + val postAuthor = + if (postToEdit.createdById == realEditor.id) realEditor + else tx.loadTheParticipant(postToEdit.createdById) + val pageAuthor = + if (page.meta.authorId == realEditor.id) realEditor + else tx.loadTheParticipant(page.meta.authorId) - // Dupl code. [get_anon] - val editorMaybeAnon = - if (doAsAnon.isEmpty) { - realEditor - } - else doAsAnon.get match { - case WhichAnon.SameAsBefore(anonId) => - tx.loadTheParticipant(anonId).asAnonOrThrow - case WhichAnon.NewAnon(anonStatus) => - // Dupl code. [mk_new_anon] - val anonymId = tx.nextGuestId - val anonym = Anonym( - id = anonymId, - createdAt = tx.now, - anonStatus = anonStatus, - anonForPatId = realEditor.id, - anonOnPageId = pageId) - // We'll insert the anonym before the page exists, but there's a - // foreign key: pats_t.anon_on_page_id_st_c, so defer constraints: - tx.deferConstraints() - tx.insertAnonym(anonym) - anonym - } + val editorMaybeAnon = SiteDao.getAliasOrTruePat( + truePat = realEditor, pageId = pageId, asAlias)(tx, IfBadAbortReq) + // [dupl_ed_perm_chk]? dieOrThrowNoUnless(Authz.mayEditPost( - realEditorAndLevels, tx.loadGroupIdsMemberIdFirst(realEditor), - postToEdit, otherAuthor = anyOtherAuthor, - page.meta, tx.loadAnyPrivateGroupTalkMembers(page.meta), + realEditorAndLevels, asAlias /* _not_same_tx, ok */, + groupIds = tx.loadGroupIdsMemberIdFirst(realEditor), + postToEdit, postAuthor = postAuthor, page.meta, pageAuthor = pageAuthor, + tx.loadAnyPrivateGroupTalkMembers(page.meta), inCategoriesRootLast = tx.loadCategoryPathRootLast( page.meta.categoryId, inclSelfFirst = true), tooManyPermissions = tx.loadPermsOnPages()), "EdE6JLKW2R") @@ -1129,7 +1094,7 @@ trait PostsDao { else { // Older revision already approved and post already published. // Then, continue approving it. - Some(realEditor.id) + Some(realEditor.id) // [mod_deanon_risk] } } else { @@ -1275,6 +1240,7 @@ trait PostsDao { tx.loadTheOrigPost(postToEdit.pageId) } + // [mod_deanon_risk] maybeReviewAcceptPostByInteracting(postWithModTasks, moderator = realEditor, ReviewDecision.InteractEdit)(tx, staleStuff) @@ -1323,7 +1289,7 @@ trait PostsDao { if (!globals.spamChecker.spamChecksEnabled) None else if (settings.userMustBeAuthenticated) None else if (!canStrangersSeePagesInCat_useTxMostly(page.meta.categoryId, tx)) None - else if (!SpamChecker.shallCheckSpamFor(realEditor)) None + else if (!SpamChecker.shallCheckSpamFor(realEditor)) None // [mod_deanon_risk] else Some( // This can get same prim key as earlier spam check task, if is ninja edit. [SPMCHKED] // Solution: Give each spam check task its own id field. @@ -1352,7 +1318,9 @@ trait PostsDao { pageId = Some(pageId), uniquePostId = Some(postToEdit.id), postNr = Some(postNr), - targetPatTrueId = anyOtherAuthor.map(_.trueId2)) + targetPatTrueId = + if (postAuthor.trueId2 == realEditor.trueId2) None + else Some(postAuthor.trueId2)) tx.updatePost(editedPost) // Pointless, if edits not approved? We only index the approved plain text? [ix_unappr] @@ -1879,10 +1847,10 @@ trait PostsDao { } - def changePostStatus(postNr: PostNr, pageId: PageId, action: PostStatusAction, reqr: ReqrId) - : ChangePostStatusResult = { + def changePostStatus(postNr: PostNr, pageId: PageId, action: PostStatusAction, reqr: ReqrId, + asAlias: Opt[WhichAliasPat]): ChangePostStatusResult = { val result = writeTx { (tx, staleStuff) => - changePostStatusImpl(postNr, pageId = pageId, action, reqr, tx, staleStuff) + changePostStatusImpl(postNr, pageId = pageId, action, reqr, asAlias, tx, staleStuff) } refreshPageInMemCache(pageId) result @@ -1897,7 +1865,7 @@ trait PostsDao { * and UI buttons? [deld_post_mod_tasks] */ def changePostStatusImpl(postNr: PostNr, pageId: PageId, action: PostStatusAction, - reqr: ReqrId, tx: SiteTx, staleStuff: StaleStuff) + reqr: ReqrId, asAlias: Opt[WhichAliasPat], tx: SiteTx, staleStuff: StaleStuff) : ChangePostStatusResult = { import com.debiki.core.{PostStatusAction => PSA} import context.security.throwIndistinguishableNotFound @@ -1906,28 +1874,36 @@ trait PostsDao { if (!pageBef.exists) throwIndistinguishableNotFound("TyE05KSRDM3") - val userId: UserId = reqr.id - val user = tx.loadParticipant(userId) getOrElse throwForbidden("DwE3KFW2", "Bad user id") + val trueUser = tx.loadParticipant(reqr.id // [alias_4_principal] + ) getOrElse throwForbidden("DwE3KFW2", "Bad user id") + val doerPersona: Pat = SiteDao.getAliasOrTruePat( + truePat = trueUser, pageId = pageId, asAlias, mayCreateAnon = false + )(tx, IfBadAbortReq) + + SHOULD // use Authz + mayDeleteComment instead, if deleting? [authz_may_del] [granular_perms] SECURITY; COULD // check if may see post, not just the page? [priv_comts] [staff_can_see] // If doing that, then: TESTS_MISSING — namely deleting an anon post on may not see. - throwIfMayNotSeePage(pageBef, Some(user))(tx) + throwIfMayNotSeePage(pageBef, Some(trueUser))(tx) val postBefore = pageBef.parts.thePostByNr(postNr) lazy val postAuthor = tx.loadTheParticipant(postBefore.createdById) - ANON_UNIMPL // Cannot do this as an anonym, although looks as if one can change - // one's own anon posts (using one's real account). - // Need: doAsAnon: Opt[WhichAnon.SameAsBefore] [anon_pages] // Authorization. - if (!user.isStaff) { - val isOwn = postBefore.createdById == userId || (postAuthor match { - case anon: Anonym => anon.anonForPatId == userId - case _ => false - }) + if (!doerPersona.isStaff) { + val isPersonasOwn = postBefore.createdById == doerPersona.id - if (!isOwn) + if (!isPersonasOwn) { + val isTrueUsers = postAuthor match { + case anon: Anonym => anon.anonForPatId == trueUser.id + case _ => false + } + val author = tx.loadTheParticipant(postBefore.createdById) + throwForbiddenIf(isTrueUsers, "TyETRUERMALIPO", + o"""You created that post as ${author.nameParaId}, and should modify it + as the same persona""") throwForbidden("DwE0PK24", "You may not modify that post, it's not yours") + } if (!action.isInstanceOf[PSA.DeletePost] && action != PSA.CollapsePost) throwForbidden("DwE5JKF7", "You may not modify the whole tree") @@ -1991,13 +1967,13 @@ trait PostsDao { // ----- Update the directly affected post val postAfter = action match { - case PSA.HidePost => postBefore.copyWithNewStatus(now, userId, bodyHidden = true) - case PSA.UnhidePost => postBefore.copyWithNewStatus(now, userId, bodyUnhidden = true) - case PSA.CloseTree => postBefore.copyWithNewStatus(now, userId, treeClosed = true) - case PSA.CollapsePost => postBefore.copyWithNewStatus(now, userId, postCollapsed = true) - case PSA.CollapseTree => postBefore.copyWithNewStatus(now, userId, treeCollapsed = true) - case PSA.DeletePost(clearFlags) => postBefore.copyWithNewStatus(now, userId, postDeleted = true) - case PSA.DeleteTree => postBefore.copyWithNewStatus(now, userId, treeDeleted = true) + case PSA.HidePost => postBefore.copyWithNewStatus(now, doerPersona.id, bodyHidden = true) + case PSA.UnhidePost => postBefore.copyWithNewStatus(now, doerPersona.id, bodyUnhidden = true) + case PSA.CloseTree => postBefore.copyWithNewStatus(now, doerPersona.id, treeClosed = true) + case PSA.CollapsePost => postBefore.copyWithNewStatus(now, doerPersona.id, postCollapsed = true) + case PSA.CollapseTree => postBefore.copyWithNewStatus(now, doerPersona.id, treeCollapsed = true) + case PSA.DeletePost(clearFlags) => postBefore.copyWithNewStatus(now, doerPersona.id, postDeleted = true) + case PSA.DeleteTree => postBefore.copyWithNewStatus(now, doerPersona.id, treeDeleted = true) } rememberBacklinksUpdCounts(postBefore, postAfter = postAfter) @@ -2027,14 +2003,15 @@ trait PostsDao { val anyUpdatedSuccessor: Option[Post] = action match { case PSA.CloseTree => if (successor.closedStatus.areAncestorsClosed) None - else Some(successor.copyWithNewStatus(now, userId, ancestorsClosed = true)) + else Some(successor.copyWithNewStatus(now, doerPersona.id, ancestorsClosed = true)) case PSA.CollapseTree => if (successor.collapsedStatus.areAncestorsCollapsed) None - else Some(successor.copyWithNewStatus(now, userId, ancestorsCollapsed = true)) + else Some(successor.copyWithNewStatus(now, doerPersona.id, ancestorsCollapsed = true)) case PSA.DeleteTree => if (successor.deletedStatus.areAncestorsDeleted) None else { - val successorDeleted = successor.copyWithNewStatus(now, userId, ancestorsDeleted = true) + val successorDeleted = successor.copyWithNewStatus( + now, doerPersona.id, ancestorsDeleted = true) postsDeleted.append(successorDeleted) Some(successorDeleted) } @@ -2086,7 +2063,7 @@ trait PostsDao { siteId = siteId, id = AuditLogEntry.UnassignedId, didWhat = AuditLogEntryType.PageUnanswered, - doerTrueId = user.trueId2, + doerTrueId = doerPersona.trueId2, doneAt = tx.now.toJavaDate, browserIdData = reqr.browserIdData, pageId = Some(pageId), @@ -2131,7 +2108,7 @@ trait PostsDao { dieIf(!action.isInstanceOf[PostStatusAction.DeletePost] && action != PostStatusAction.DeleteTree, "TyE205MKSD") // + true_pat_id_c? - updateSpamCheckTaskBecausePostDeleted(postBefore, postAuthor, deleter = user, tx) + updateSpamCheckTaskBecausePostDeleted(postBefore, postAuthor, deleter = doerPersona, tx) } // ----- Update the page @@ -2468,18 +2445,11 @@ trait PostsDao { } - def deletePost(pageId: PageId, postNr: PostNr, deletedBy: ReqrId): U = { - writeTx { (tx, staleStuff) => - deletePostImpl(pageId, postNr = postNr, deletedBy, tx, staleStuff) - } - refreshPageInMemCache(pageId) ; REMOVE // auto do via [staleStuff] - } - - def deletePostImpl(pageId: PageId, postNr: PostNr, deletedBy: ReqrId, tx: SiteTx, staleStuff: StaleStuff): ChangePostStatusResult = { val result = changePostStatusImpl(pageId = pageId, postNr = postNr, action = PostStatusAction.DeletePost(clearFlags = false), reqr = deletedBy, + asAlias = None, // [anon_mods] tx = tx, staleStuff = staleStuff) BUG; SHOULD // delete notfs or mark deleted? [notfs_bug] [nice_notfs] @@ -2562,7 +2532,7 @@ trait PostsDao { def addVoteIfAuZ(pageId: PageId, postNr: PostNr, voteType: PostVoteType, reqrAndVoter: ReqrAndTgt, voterIp: Opt[IpAdr], postNrsRead: Set[PostNr], - doAsAnon: Opt[WhichAnon] = None): Opt[Anonym] = { + asAlias: Opt[WhichAliasPat] = None): Opt[Anonym] = { require(postNr >= PageParts.BodyNr, "TyE5WKAB20") SECURITY; SLEEPING // May the requester vote on behalf of voter? [vote_as_otr] @@ -2576,32 +2546,11 @@ trait PostsDao { // Could do an [authz_pre_check] in VoteController? But why? this.throwIfMayNotSeePost2(ThePost.Here(post), reqrAndVoter)(tx) + // Later: A may-vote permission? [granular_perms] Can be important for Do-It votes f.ex. val trueVoter = reqrAndVoter.target - // Dupl code. [get_anon] - val voterMaybeAnon = - if (doAsAnon.isEmpty) { - trueVoter - } - else doAsAnon.get match { - case WhichAnon.SameAsBefore(anonId) => - // Hmm, verify it's target's anon ??? — use getAliasOrTruePat() instead - tx.loadTheParticipant(anonId).asAnonOrThrow - case WhichAnon.NewAnon(anonStatus) => - // Dupl code. [mk_new_anon] - val anonymId = tx.nextGuestId - val anonym = Anonym( - id = anonymId, - createdAt = tx.now, - anonStatus = anonStatus, - anonForPatId = trueVoter.id, - anonOnPageId = pageId) - // We'll insert the anonym before the page exists, but there's a - // foreign key: pats_t.anon_on_page_id_st_c, so defer constraints: - tx.deferConstraints() - tx.insertAnonym(anonym) - anonym - } + val voterMaybeAnon = SiteDao.getAliasOrTruePat( + truePat = trueVoter, pageId = pageId, asAlias)(tx, IfBadAbortReq) if (voteType == PostVoteType.Bury && !voterMaybeAnon.isStaffOrFullMember && // [7UKDR10] page.meta.authorId != voterMaybeAnon.id) @@ -2686,7 +2635,7 @@ trait PostsDao { // Test: modn-from-disc-page-review-after TyTE2E603RKG4.TyTE2E5ART25 // Don't let anonyms or pseudonyms approve-by-voting — that could leak info - // (namely that han is a mod or admin). [deanon_risk] + // (namely that han is a mod or admin). [mod_deanon_risk] if (!voterMaybeAnon.isAlias) { maybeReviewAcceptPostByInteracting(post, moderator = trueVoter, ReviewDecision.InteractLike)(tx, staleStuff) @@ -2882,7 +2831,7 @@ trait PostsDao { if (postToMove.nr == PageParts.TitleNr || postToMove.nr == PageParts.BodyNr) throwForbidden("EsE7YKG25_", "Cannot move page title or body") - val postAuthor = tx.loadTheUser(postToMove.createdById) + val postAuthor = tx.loadTheParticipant(postToMove.createdById) val newParentPost = tx.loadPost(newParent) getOrElse throwForbidden( "EsE7YKG42_", "New parent post not found") @@ -2935,7 +2884,7 @@ trait PostsDao { // others will be able to see / might-deduce-that hen also wrote [the post that got // moved to another page]. // So, for now, disallow this. (Could allow, if the author deanonymizes the post.) - ANON_UNIMPL; TESTS_MISSING // Verify this not allowed. TyTMOVANONCOMT + TESTS_MISSING // Verify this not allowed. TyTMOVANONCOMT throwForbiddenIf(postAuthor.isAnon, "TyE4MW2LR5", "Cannot move an anonymous post to another page") } @@ -3103,9 +3052,9 @@ trait PostsDao { private def doFlagPost(pageId: PageId, postNr: PostNr, flagType: PostFlagType, flaggerId: UserId): (Post, Boolean) = { - // DO_AS_ALIAS: Anonymous flags might be needed? So an anon can flag a toxic comment - // by another anon, without the mods realizing who the flagger is just because they - // had to use their true account. + ANON_UNIMPL // [anon_flags] Anonymous flags might be needed? So an anon can flag + // a toxic comment by another anon, without the mods realizing who the flagger is + // just because they had to use their true account. writeTx { (tx, staleStuff) => val flagger = tx.loadTheUser(flaggerId) diff --git a/appsv/server/debiki/dao/ReviewsDao.scala b/appsv/server/debiki/dao/ReviewsDao.scala index 9ca27375bd..7209be1b2c 100644 --- a/appsv/server/debiki/dao/ReviewsDao.scala +++ b/appsv/server/debiki/dao/ReviewsDao.scala @@ -414,6 +414,7 @@ trait ReviewsDao { // RENAME to ModerationDao, MOVE to talkyard.server.modn // So it's missing [save_mod_br_inf]. But not missing when called // from this.moderatePostInstantly(). Oh well. browserIdData = BrowserIdData.Missing), + asAlias = None, // [anon_mods] tx, staleStuff).updatedPost } else { @@ -525,7 +526,7 @@ trait ReviewsDao { // RENAME to ModerationDao, MOVE to talkyard.server.modn // If staff deletes many posts by this user, mark it as a moderate threat? // That'll be done from inside update-because-deleted fn below. [DETCTHR] else if (taskIsForBothTitleAndBody) { - deletePagesImpl(Seq(pageId), reqr)(tx, staleStuff) + deletePagesImpl(Seq(pageId), reqr, asAlias = None /*[anon_mods]*/)(tx, staleStuff) // Posts not individually deleted, instead, whole page gone // [62AKDN46] (Seq.empty, Some(pageId)) } diff --git a/appsv/server/debiki/dao/SearchDao.scala b/appsv/server/debiki/dao/SearchDao.scala index af1ec30a7e..a29a910e2b 100644 --- a/appsv/server/debiki/dao/SearchDao.scala +++ b/appsv/server/debiki/dao/SearchDao.scala @@ -20,6 +20,7 @@ package debiki.dao import com.debiki.core._ import com.debiki.core.Prelude._ import talkyard.server.search._ +import talkyard.server.authz.Authz import talkyard.server.authz.AuthzCtxOnForum import scala.collection.{mutable => mut} import scala.collection.immutable.Seq @@ -127,7 +128,7 @@ trait SearchDao { val hitsAndPostsMaySee: Seq[(SearchHit, Post)] = hits flatMap { hit => postsById.get(hit.postId) flatMap { post => val (seePost, debugCode) = - maySeePostIfMaySeePage(reqr, post) + Authz.maySeePostIfMaySeePage(reqr, post) if (!seePost.may) None else Some(hit -> post) } diff --git a/appsv/server/debiki/dao/SiteDao.scala b/appsv/server/debiki/dao/SiteDao.scala index fbe2e65b7a..031e88a4c6 100644 --- a/appsv/server/debiki/dao/SiteDao.scala +++ b/appsv/server/debiki/dao/SiteDao.scala @@ -31,9 +31,11 @@ import scala.collection.immutable import talkyard.server.TyContext import talkyard.server.authz.MayMaybe import talkyard.server.notf.NotificationGenerator +import talkyard.server.parser import talkyard.server.pop.PagePopularityDao import talkyard.server.pubsub.{PubSubApi, StrangerCounterApi} import talkyard.server.summaryemails.SummaryEmailsDao +import play.api.libs.json.JsObject import org.scalactic.{ErrorMessage, Or} import java.util.concurrent.TimeUnit import java.util.concurrent.locks.ReentrantLock @@ -895,51 +897,129 @@ object SiteDao extends TyLogging { } - /** If the requester is doing sth anonymously (e.g. anon comments or votes), + /** For rejecting a request early, if an invalid alias is specified, + * while `getAliasOrTruePat()` (below) is used later, in a db tx, to get or lazy-create + * the alias. + * + * @param reqBody If a persona is specified in the request body, it overrides + * any persona mode. [choose_persona] + * @param reqr The person doing the thing. Rename to 'prin(cipal)'? [rename_2_principal] + * @param modeAlias Any persona mode alias (e.g. if entering Anonymous Mode). + * @param mayCreateAnon If editing one's old comment, should reuse the same anon + * as when posting the comment; may not create a new. + * @param mayReuseAnon If creating a new page, there's no anonym on that page + * to reuse, yet. + */ + def checkAliasOrThrowForbidden(reqBody: JsObject, reqr: Pat, modeAlias: Opt[WhichAliasPat], + mayCreateAnon: Bo = true, mayReuseAnon: Bo = true, + )(dao: SiteDao): Opt[WhichAliasPat] = { + + TESTS_MISSING // [misc_alias_tests], check callers + + // Any 'doAsAnon' in the request json body telling us which persona to use? + val anyAliasIdInReqBody: Opt[WhichAliasId] = + parser.parseDoAsAnonField(reqBody) getOrIfBad { prob => + throwBadReq("TyEPERSBDYJSN", s"Bad persona req body json: $prob") + } + + // The actual anonym or pseudonym, after having looked up by id. + val anyAliasPatInReqBody: Opt[Opt[WhichAliasPat]] = anyAliasIdInReqBody map { + case WhichAliasId.Oneself => + // Any alias should be the *principal*'s alias, not the requester's — but + // currently the requester and principal are the same, for all endpoints that + // support persona mode, see [alias_4_principal]. + None + + case sa: WhichAliasId.SameAnon => + throwForbiddenIf(!mayReuseAnon, "TyEREUSEANON", "Cannot reuse anonym here") + val anon = dao.getTheParticipant(sa.sameAnonId).asAnonOrThrow + throwForbiddenIf(anon.anonForPatId != reqr.id, + "TyE0YOURANON01", "No your anonym") + Some(WhichAliasPat.SameAnon(anon)) + + case n: WhichAliasId.LazyCreatedAnon => + throwForbiddenIf(!mayCreateAnon, "TyENEWANON1", "Cannot create anonym now") + Some(WhichAliasPat.LazyCreatedAnon(n.anonStatus)) + } + + // if anyAliasPatInReqBody.get != modeAlias { + // Noop. It's ok if any Persona Mode alias is different from any alias in the + // request body. Then, one can switch to Persona Mode, but still do one-off + // things as sbd else if in an old discussion one was sbd else. + // } + + anyAliasPatInReqBody getOrElse modeAlias + } + + + + /** Gets or lazy-creates the specified alias, or just returns the user hanself (`truePat`). + * + * If the requester is doing sth anonymously (e.g. anon comments or votes), * looks up & returns the anonymous user. Or creates a new anonymous user if - * needed. Otherwise just returns `truePat`. + * needed. * * @param truePat The true person behind any anonym or pseudonym. * @param pageId Anonyms are per page, each one is restricted to a single page. - * @param doAsAlias The anonym or pseudonym to use. + * @param asAlias The anonym or pseudonym to use. * @param mayCreateAnon If the author of a page closes it or reopens it etc, - * han cannot create a new anonym to do that. + * han cannot create a new anonym to do that. Han needs to reuse the same + * persona, as when creating the page. (Otherwise others can guess that + * the real person and any anonym of hans are the same — if they both can + * alter the same page. [deanon_risk]) */ - def getAliasOrTruePat(truePat: Pat, pageId: PageId, doAsAlias: Opt[WhichAnon], - mayCreateAnon: Bo, isCreatingPage: Bo = false)(tx: SiteTx, mab: MessAborter): Pat = { - // Dupl code. [get_anon] - if (doAsAlias.isEmpty) + def getAliasOrTruePat(truePat: Pat, pageId: PageId, asAlias: Opt[WhichAliasPat], + mayCreateAnon: Bo = true, mayReuseAnon: Bo = true, isCreatingPage: Bo = false, + )(tx: SiteTx, mab: MessAborter): Pat = { + TESTS_MISSING // [misc_alias_tests], of callers. + + if (asAlias.isEmpty) return truePat - doAsAlias.get match { - case WhichAnon.SameAsBefore(anonId) => - val anon = tx.loadTheParticipant(anonId).asAnonOrThrow - if (anon.anonForPatId != truePat.trueId2.trueId) - mab.abortDeny("TyE0YOURANON", "No your alias") + asAlias.get match { + case WhichAliasPat.SameAnon(anonOtherTx) => + if (!mayReuseAnon) + mab.abort("TyEOLDANON2", "Cannot reuse anonym now") + + // (Maybe we don't actually need to reload the anon. Oh well.) + val anon: Anonym = tx.loadTheParticipant(anonOtherTx.id).asAnonOrThrow + if (anon.anonForPatId != truePat.id) + mab.abortDeny("TyE0YOURANON2", "No your anon") anon - case WhichAnon.NewAnon(anonStatus) => + case WhichAliasPat.LazyCreatedAnon(anonStatus: AnonStatus) => + if (!anonStatus.isAnon) + return truePat + if (!mayCreateAnon) - mab.abort("TyENEWANON02", "Cannot create new anonym now") + mab.abort("TyENEWANON2", "Cannot create new anonym now") + + // Reuse any already existing anonym [one_anon_per_page] — at most one per page + // and person, for now. + val anyAnon = tx.loadAnyAnon(truePat.id, pageId = pageId, anonStatus) + anyAnon foreach { anon => + dieIf(anon.anonForPatId != truePat.id, "TyE7L02SLP3") + return anon + } - // Dupl code. [mk_new_anon] - val anonymId = tx.nextGuestId - val anonym = Anonym( - id = anonymId, + val anonId = tx.nextGuestId + val newAnon = Anonym( + id = anonId, createdAt = tx.now, anonStatus = anonStatus, anonForPatId = truePat.id, anonOnPageId = pageId) - // We might insert a new anonym before the page exists, but there's a foreign key + // We might insert the anonym before the page exists, but there's a foreign key // from anons to pages: pats_t.anon_on_page_id_st_c, so defer constraints. if (isCreatingPage) { tx.deferConstraints() } - tx.insertAnonym(anonym) - anonym + tx.insertAnonym(newAnon) + newAnon } } + } diff --git a/appsv/server/debiki/dao/UserDao.scala b/appsv/server/debiki/dao/UserDao.scala index e107d5180b..9034bbf5d2 100644 --- a/appsv/server/debiki/dao/UserDao.scala +++ b/appsv/server/debiki/dao/UserDao.scala @@ -944,7 +944,7 @@ trait UserDao { case ParsedRef.ExternalId(extId) => getParticipantByExtId(extId) case ParsedRef.TalkyardId(tyId) => - tyId.toIntOption flatMap getParticipant + tyId.toIntOption.flatMap(id => getParticipant(id)) case ParsedRef.UserId(id) => val pat = getParticipant(id) returnBadUnlessIsUser(pat) @@ -1006,7 +1006,10 @@ trait UserDao { } - def getParticipant(userId: UserId): Option[Participant] = { + def getParticipant(userId: UserId, anyTx: Opt[SiteTx] = None): Opt[Pat] = { + anyTx foreach { tx => + return tx.loadParticipant(userId) + } memCache.lookup[Participant]( patKey(userId), orCacheAndReturn = { diff --git a/appsv/server/talkyard/server/JsX.scala b/appsv/server/talkyard/server/JsX.scala index 7f93c67f0b..3a8e60fbc8 100644 --- a/appsv/server/talkyard/server/JsX.scala +++ b/appsv/server/talkyard/server/JsX.scala @@ -29,6 +29,7 @@ import org.scalactic.{Bad, Good, Or} import com.debiki.core.Notification.NewPost import play.api.libs.json._ +import talkyard.server.parser.DoAsAnonFieldName import scala.collection.immutable import scala.util.matching.Regex @@ -178,25 +179,6 @@ object JsX { RENAME // to JsonPaSe } - /* rm - def JsAnon(anon: Anonym, inclRealId: Bo = false): JsObject = { // ts: Anonym - var json = Json.obj( - "id" -> JsNumber(anon.id), - "isAnon" -> JsTrue, - "isEmailUnknown" -> JsTrue, // or skip? - ) - if (inclRealId) { - json += "anonForId" -> JsNumber(anon.anonForPatId) - //json += "anonOnPageId" -> JsString(anon.anonOnPageId), - json += "anonStatus" -> JsNumber(anon.anonStatus.toInt) - } - if (anon.isGone) { - json += "isGone" -> JsTrue - } - json - } */ - - def JsUserOrNull(user: Option[Participant]): JsValue = // RENAME to JsParticipantOrNull user.map(JsUser(_)).getOrElse(JsNull) @@ -223,9 +205,11 @@ object JsX { RENAME // to JsonPaSe /** If 'user' is an anonym or pseudonym, then, hens true id is *not* included, unless * toShowForPatId is hens true id (or, later, if toShowForPatId has permission to - * see anonyms ANON_UNIMPL). That is, if the person requesting to see a page, + * see anonyms [see_alias]). That is, if the person requesting to see a page, * is the the same the ano/pseudony, then, the ano/pseudonym's true id is included * so that that person can see hens own anonym(s). + * + * ts: Pat and subclasses, e.g. Guest, Anonym. */ def JsUser(user: Pat, tags: Seq[Tag] = Nil, toShowForPatId: Opt[PatId] = None): JsObject = { //RENAME to JsPat, ts: Pat var json = JsPatNameAvatar(user) @@ -233,22 +217,20 @@ object JsX { RENAME // to JsonPaSe json += "avatarSmallHashPath" -> JsString(uploadRef.hashPath) } - if (user.isAnon) { - json += "isAnon" -> JsTrue - // If this anonym is user `toShowForPatId`s own anonym, include details — so - // that user can see it's hens own anonym. - toShowForPatId foreach { showForPatId: PatId => - user match { - case anon: Anonym => - if (anon.anonForPatId == showForPatId) { - // [see_own_alias] - json += "anonForId" -> JsNumber(anon.anonForPatId) - //on += "anonOnPageId" -> JsString(anon.anonOnPageId), - json += "anonStatus" -> JsNumber(anon.anonStatus.toInt) - } - case x => die(s"An isAnon pat isn't an Anonym, it's an: ${classNameOf(x)}") + if (user.isAnon) user match { + case anon: Anonym => + json += "isAnon" -> JsTrue ; REMOVE // ?, maybe anonStatus is enough + json += "anonStatus" -> JsNumber(anon.anonStatus.toInt) + // If this anonym is user `toShowForPatId`s own anonym, include details — so + // that user can see it's hens own anonym. + toShowForPatId foreach { showForPatId: PatId => + if (anon.anonForPatId == showForPatId) { + // [see_own_alias] + json += "anonForId" -> JsNumber(anon.anonForPatId) + //on += "anonOnPageId" -> JsString(anon.anonOnPageId), + } } - } + case x => die(s"An isAnon pat isn't an Anonym, it's an: ${classNameOf(x)}") } else if (user.isGuest) { json += "isGuest" -> JsTrue @@ -1165,7 +1147,7 @@ object JsX { RENAME // to JsonPaSe draft.map(JsDraft).getOrElse(JsNull) def JsDraft(draft: Draft): JsObject = { - Json.obj( + var res = Json.obj( "byUserId" -> draft.byUserId, "draftNr" -> draft.draftNr, "forWhat" -> JsDraftLocator(draft.forWhat), @@ -1176,6 +1158,33 @@ object JsX { RENAME // to JsonPaSe "postType" -> JsNumberOrNull(draft.postType.map(_.toInt)), "title" -> JsString(draft.title), "text" -> JsString(draft.text)) + draft.doAsAnon foreach { whichAlias => + res += DoAsAnonFieldName -> JsWhichAliasId(whichAlias) + } + res + } + + + def JsWhichAliasId(which: WhichAliasId): JsObject = { + import WhichAliasId._ + which match { + case Oneself => + Json.obj( + "self" -> true) + case SameAnon(anonId: PatId) => + Json.obj( + "sameAnonId" -> JsNumber(anonId), + // Later, also incl: (but not yet saved in db, for drafts) [chk_alias_status] + //"anonStatus" -> anon.anonStatus + ) + case LazyCreatedAnon(anonStatus: AnonStatus) => + Json.obj( + "anonStatus" -> JsNumber(anonStatus.toInt), + "lazyCreate" -> JsTrue) + // Maybe later, also: + // case NewAnon(anonStatus: AnonStatus) => + // "createNew_tst" -> ... + } } diff --git a/appsv/server/talkyard/server/TyController.scala b/appsv/server/talkyard/server/TyController.scala index 23bec26603..d98a72c13f 100644 --- a/appsv/server/talkyard/server/TyController.scala +++ b/appsv/server/talkyard/server/TyController.scala @@ -75,8 +75,10 @@ class TyController(cc: ControllerComponents, val context: TyContext) def GetActionRateLimited( rateLimits: RateLimits = RateLimits.ExpensiveGetRequest, minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal, - allowAnyone: Boolean = false)(f: GetRequest => Result): Action[Unit] = - PlainApiAction(cc.parsers.empty, rateLimits, minAuthnStrength, allowAnyone = allowAnyone)(f) + allowAnyone: Bo = false, canUseAlias: Bo = false, ignoreAlias: Bo = false, + )(f: GetRequest => Result): Action[Unit] = + PlainApiAction(cc.parsers.empty, rateLimits, minAuthnStrength, allowAnyone = allowAnyone, + canUseAlias = canUseAlias, ignoreAlias = ignoreAlias)(f) def StaffGetAction(f: GetRequest => Result): Action[Unit] = PlainApiActionStaffOnly(NoRateLimits, cc.parsers.empty)(f) @@ -106,19 +108,25 @@ class TyController(cc: ControllerComponents, val context: TyContext) skipXsrfCheck = skipXsrfCheck)(f) def AsyncPostJsonAction(rateLimits: RateLimits, maxBytes: i32, + canUseAlias: Bo = false, ignoreAlias: Bo = false, allowAnyone: Bo = false, isGuestLogin: Bo = false, avoidCookies: Bo = false)( f: JsonPostRequest => Future[Result]): Action[JsValue] = PlainApiAction(cc.parsers.json(maxLength = maxBytes), - rateLimits, allowAnyone = allowAnyone, isGuestLogin = isGuestLogin, + rateLimits, allowAnyone = allowAnyone, + canUseAlias = canUseAlias, ignoreAlias = ignoreAlias, + isGuestLogin = isGuestLogin, avoidCookies = avoidCookies).async(f) def PostJsonAction(rateLimits: RateLimits, minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal, maxBytes: Int, + canUseAlias: Bo = false, ignoreAlias: Bo = false, allowAnyone: Boolean = false, isLogin: Boolean = false)( f: JsonPostRequest => Result): Action[JsValue] = PlainApiAction(cc.parsers.json(maxLength = maxBytes), - rateLimits, minAuthnStrength, allowAnyone = allowAnyone, isLogin = isLogin)(f) + rateLimits, minAuthnStrength, allowAnyone = allowAnyone, + canUseAlias = canUseAlias, ignoreAlias = ignoreAlias, + isLogin = isLogin)(f) def AsyncUserPostJsonAction(rateLimits: RateLimits, maxBytes: i32, avoidCookies: Bo = false)( @@ -126,40 +134,45 @@ class TyController(cc: ControllerComponents, val context: TyContext) PlainApiAction(cc.parsers.json(maxLength = maxBytes), rateLimits, authnUsersOnly = true, avoidCookies = avoidCookies).async(f) - def UserPostJsonAction(rateLimits: RateLimits, maxBytes: i32)( + def UserPostJsonAction(rateLimits: RateLimits, maxBytes: i32, ignoreAlias: Bo = false)( f: JsonPostRequest => Result): Action[JsValue] = PlainApiAction(cc.parsers.json(maxLength = maxBytes), - rateLimits, authnUsersOnly = true)(f) + rateLimits, authnUsersOnly = true, ignoreAlias = ignoreAlias)(f) def PostTextAction(rateLimits: RateLimits, minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal, - maxBytes: Int, allowAnyone: Boolean = false)( + maxBytes: Int, allowAnyone: Bo = false, ignoreAlias: Bo = false)( f: ApiRequest[String] => Result): Action[String] = PlainApiAction(cc.parsers.text(maxLength = maxBytes), - rateLimits, minAuthnStrength, allowAnyone = allowAnyone)(f) + rateLimits, minAuthnStrength, allowAnyone = allowAnyone, ignoreAlias = ignoreAlias)(f) SECURITY // add rate limits for staff too. Started, use StaffPostJsonAction2 below. def StaffPostJsonAction( minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal, - maxBytes: Int)(f: JsonPostRequest => Result): Action[JsValue] = + maxBytes: i32, canUseAlias: Bo = false, ignoreAlias: Bo = false, + )(f: JsonPostRequest => Result): Action[JsValue] = PlainApiActionStaffOnly( - NoRateLimits, cc.parsers.json(maxLength = maxBytes), minAuthnStrength)(f) + NoRateLimits, cc.parsers.json(maxLength = maxBytes), minAuthnStrength, + canUseAlias = canUseAlias, ignoreAlias = ignoreAlias)(f) def StaffPostJsonAction2( rateLimits: RateLimits, minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal, - maxBytes: Int)(f: JsonPostRequest => Result): Action[JsValue] = + maxBytes: i32, canUseAlias: Bo = false, ignoreAlias: Bo = false) + (f: JsonPostRequest => Result): Action[JsValue] = PlainApiActionStaffOnly( - rateLimits, cc.parsers.json(maxLength = maxBytes), minAuthnStrength)(f) + rateLimits, cc.parsers.json(maxLength = maxBytes), minAuthnStrength, + canUseAlias = canUseAlias, ignoreAlias = ignoreAlias)(f) SECURITY // add rate limits for admins — use AdminPostJsonAction2, then remove this & rm '2' from name. - def AdminPostJsonAction(maxBytes: Int)(f: JsonPostRequest => Result): Action[JsValue] = + def AdminPostJsonAction(maxBytes: i32, ignoreAlias: Bo = false, + )(f: JsonPostRequest => Result): Action[JsValue] = PlainApiActionAdminOnly( - NoRateLimits, cc.parsers.json(maxLength = maxBytes))(f) + NoRateLimits, cc.parsers.json(maxLength = maxBytes), ignoreAlias = ignoreAlias)(f) - def AdminPostJsonAction2(rateLimits: RateLimits, maxBytes: Int)( + def AdminPostJsonAction2(rateLimits: RateLimits, maxBytes: Int, ignoreAlias: Bo = false)( f: JsonPostRequest => Result): Action[JsValue] = PlainApiActionAdminOnly( - rateLimits, cc.parsers.json(maxLength = maxBytes))(f) + rateLimits, cc.parsers.json(maxLength = maxBytes), ignoreAlias = ignoreAlias)(f) def ApiSecretPostJsonAction(whatSecret: WhatApiSecret, rateLimits: RateLimits, maxBytes: i32)( f: JsonPostRequest => Result): Action[JsValue] = diff --git a/appsv/server/talkyard/server/api/ActionDoer.scala b/appsv/server/talkyard/server/api/ActionDoer.scala index 39f4f3932d..1a3360aa67 100644 --- a/appsv/server/talkyard/server/api/ActionDoer.scala +++ b/appsv/server/talkyard/server/api/ActionDoer.scala @@ -67,7 +67,7 @@ case class ActionDoer(dao: SiteDao, reqrInf: ReqrInf, mab: MessAborter) { params.pageType, PageStatus.Published, inCatId = Some(cat.id), tags, anyFolder = None, anySlug = params.urlSlug, titleSourceAndHtml, bodyTextAndHtml, showId = true, deleteDraftNr = None, reqrTgt, - spamRelReqStuff = SystemSpamStuff, doAsAnon = None, refId = params.refId) + spamRelReqStuff = SystemSpamStuff, asAlias = None, refId = params.refId) Json.obj( "pageId" -> result.path.pageId, "pagePath" -> result.path.value) @@ -82,7 +82,7 @@ case class ActionDoer(dao: SiteDao, reqrInf: ReqrInf, mab: MessAborter) { dao.insertReplyIfAuZ( textAndHtml, pageId = pageMeta.pageId, replyToPostNrs = params.parentNr.toSet, params.postType, deleteDraftNr = None, reqrTgt, - spamRelReqStuff = SystemSpamStuff, anonHow = None, refId = params.refId, + spamRelReqStuff = SystemSpamStuff, asAlias = None, refId = params.refId, tags) // ooops forgot_to_use Json.obj( "postNr" -> result.post.nr, diff --git a/appsv/server/talkyard/server/api/GetController.scala b/appsv/server/talkyard/server/api/GetController.scala index 3f06f93947..56bdcf74ec 100644 --- a/appsv/server/talkyard/server/api/GetController.scala +++ b/appsv/server/talkyard/server/api/GetController.scala @@ -129,10 +129,13 @@ class GetController @Inject()(cc: ControllerComponents, edContext: TyContext) case Some(page: PagePathAndMeta) => COULD_OPTIMIZE // will typically always be same cat, for emb cmts. val categories = dao.getAncestorCategoriesRootLast(page.categoryId) + // COULD_OPTIMIZE [list_by_alias] + val author = dao.getParticipant(page.meta.authorId).getOrDie("TyE502WTJT4") val may = talkyard.server.authz.Authz.maySeePage( // _access_control page.meta, user = authzCtx.requester, groupIds = authzCtx.groupIdsUserIdFirst, + pageAuthor = author, pageMembers = Set.empty, // getAnyPrivateGroupTalkMembers(page.meta), catsRootLast = categories, tooManyPermissions = authzCtx.tooManyPermissions, diff --git a/appsv/server/talkyard/server/authz/Authz.scala b/appsv/server/talkyard/server/authz/Authz.scala index c9f5bd7eb5..f2f0e2985d 100644 --- a/appsv/server/talkyard/server/authz/Authz.scala +++ b/appsv/server/talkyard/server/authz/Authz.scala @@ -19,6 +19,7 @@ package talkyard.server.authz import com.debiki.core._ import com.debiki.core.Prelude._ +import debiki.EdHttp.throwForbidden import scala.collection.immutable import MayMaybe._ import MayWhat._ @@ -201,7 +202,8 @@ object Authz { def mayCreatePage( - userAndLevels: UserAndLevels, + userAndLevels: AnyUserAndLevels, + asAlias: Opt[WhichAliasPat], groupIds: immutable.Seq[GroupId], pageRole: PageType, bodyPostType: PostType, @@ -211,10 +213,12 @@ object Authz { inCategoriesRootLast: immutable.Seq[Category], tooManyPermissions: immutable.Seq[PermsOnPages]): MayMaybe = { - val user = userAndLevels.user + val anyUser: Opt[Pat] = userAndLevels.anyUser val mayWhat = checkPermsOnPages( - Some(user), groupIds, pageMeta = None, pageMembers = None, + anyUser, asAlias = asAlias, groupIds, + // Doesnt' yet exist. + pageMeta = None, pageAuthor = None, pageMembers = None, catsRootLast = inCategoriesRootLast, tooManyPermissions) def isPrivate = pageRole.isPrivateGroupTalk && groupIds.nonEmpty && @@ -230,7 +234,7 @@ object Authz { if (!mayWhat.mayCreatePage && !isPrivate) return NoMayNot(s"EdEMN0CR-${mayWhat.debugCode}", "May not create a page in this category") - if (!user.isStaff) { + if (!anyUser.exists(_.isStaff)) { if (inCategoriesRootLast.isEmpty && !isPrivate) return NoMayNot("EsEM0CRNOCAT", "Only staff may create pages outside any category") @@ -271,13 +275,21 @@ object Authz { pageMeta: PageMeta, user: Opt[Pat], groupIds: immutable.Seq[GroupId], + pageAuthor: Pat, pageMembers: Set[UserId], catsRootLast: immutable.Seq[Cat], tooManyPermissions: immutable.Seq[PermsOnPages], maySeeUnlisted: Bo = true): MayMaybe = { val mayWhat = checkPermsOnPages( - user, groupIds, Some(pageMeta), Some(pageMembers), + user, + // [pseudonyms_later] Maybe should matter, for pseudonyms? If a pseudonym + // gets added to a group, maybe it's good if one's true user cannot + // accidentally post in a new category, just because one's pseudonym got + // added there? [deanon_risk] But for a start, simpler to just not + // support granting permissions to pseuonyms. + asAlias = None, // doesn't matter when deriving `maySee` + groupIds, Some(pageMeta), pageAuthor = Some(pageAuthor), Some(pageMembers), catsRootLast = catsRootLast, tooManyPermissions, maySeeUnlisted = maySeeUnlisted) @@ -298,16 +310,17 @@ object Authz { /** If pat may edit the page title & body, and settings e.g. page type, * and open/close it, delete/undeleted, move to another category. * - * @param otherAuthor the page author, if it's someone else than `pat` — needed, - * so we can check if `pat` is actually the true author (if `otherAuthor` is hans alias). - * Confusing param name? Maybe simpler to rename to `pageAuthor` and always include, - * also if same as `pat`? - * Or better: [_pass_alias] ? + * @param pat The user or guest who wants to edit the page. + * @param asAlias If pat is editing the page anonymously or pseudonymously. + * @param pageAuthor Needed, to know if `pat` is actually the true page author, + * but maybe created the page anonymously or pseudonymously, using alias `asAlias`. + * @param groupIds `pat`s group ids. */ def mayEditPage( pageMeta: PageMeta, pat: Pat, - otherAuthor: Opt[Pat], + asAlias: Opt[WhichAliasPat], + pageAuthor: Pat, groupIds: immutable.Seq[GroupId], pageMembers: Set[UserId], catsRootLast: immutable.Seq[Cat], @@ -315,32 +328,45 @@ object Authz { changesOnlyTypeOrStatus: Bo, maySeeUnlisted: Bo): MayMaybe = { + // Any `asAlias` "inherits" may-see-page permissions from the real user (`pat`). val mayWhat = checkPermsOnPages( - Some(pat), groupIds, Some(pageMeta), Some(pageMembers), + Some(pat), asAlias = asAlias, groupIds = groupIds, Some(pageMeta), + pageAuthor = Some(pageAuthor), pageMembers = Some(pageMembers), catsRootLast = catsRootLast, tooManyPermissions, - maySeeUnlisted = maySeeUnlisted, otherAuthor = otherAuthor) + maySeeUnlisted = maySeeUnlisted) if (mayWhat.maySee isNot true) return NoNotFound(s"TyEM0SEE2-${mayWhat.debugCode}") - val isOwnPage = _isOwn(pat, authorId = pageMeta.authorId, otherAuthor) + val (isOwnPage, ownButWrongAlias) = _isOwn(pat, asAlias, postAuthor = pageAuthor) // For now, Core Members can change page type and doing status, if they // can see the page (which we checked just above). Later, this will be - // the [alterPage] permission. + // the [alterPage] permission. [granular_perms] if (changesOnlyTypeOrStatus && pat.isStaffOrCoreMember) { // Fine (skip the checks below). } else if (isOwnPage) { - // Do this check in mayEditPost() too? [.mayEditOwn] + // Do this check in mayEditPost() too? [.mayEditOwn] [granular_perms] + BUG // Too restrictive: Might still be ok to edit, if `mayEditPage`. if (!mayWhat.mayEditOwn) return NoMayNot(s"TyEM0EDOWN-${mayWhat.debugCode}", "") + + // Fine, may edit — if using the correct alias, see _checkDeanonRiskOfEdit(). } + // else if (is mind map) — doesn't matter here + // else if (is wiki) — doesn't matter (mayEditWiki is only for the page text, + // but not the title, page type, category etc) else { if (!mayWhat.mayEditPage) return NoMayNot(s"TyEM0EDPG-${mayWhat.debugCode}", "") } + _checkDeanonRiskOfEdit(isOwn = isOwnPage, ownButWrongAlias = ownButWrongAlias, + asAlias = asAlias) foreach { mayNot => + return mayNot + } + // [wiki_perms] Maybe the whole page, and not just each post individually, // could be wiki-editable? Then those with wiki-edit-permissions, // could change the page type, doing-status, answer-post, title, etc. @@ -356,8 +382,8 @@ object Authz { def maySeeCategory(authzCtx: ForumAuthzContext, catsRootLast: immutable.Seq[Category]) : MayWhat = { - checkPermsOnPages(authzCtx.requester, authzCtx.groupIdsUserIdFirst, - pageMeta = None, pageMembers = None, catsRootLast = catsRootLast, + checkPermsOnPages(authzCtx.requester, asAlias = None, authzCtx.groupIdsUserIdFirst, + pageMeta = None, pageAuthor = None, pageMembers = None, catsRootLast = catsRootLast, authzCtx.tooManyPermissions, maySeeUnlisted = false) } @@ -369,10 +395,39 @@ object Authz { } + def maySeePostIfMaySeePage(pat: Opt[Pat], post: Post): (MaySeeOrWhyNot, St) = { + CLEAN_UP // Dupl code, this stuff repeated in Authz.mayPostReply. [8KUWC1] + + // Below: Since the requester may see the page, it's ok if hen learns + // if a post has been deleted or it never existed? (Probably hen can + // figure that out anyway, just by looking for holes in the post nr + // sequence.) + + // Staff may see all posts, if they may see the page. [5I8QS2A] + ANON_UNIMPL // if post.createdById is pat's own alias, han is the author and can see it. + // Don't fix now — wait until true author id is incl in posts3/nodes_t? [posts3_true_id] + def isStaffOrAuthor = + pat.exists(_.isStaff) || pat.exists(_.id == post.createdById) + + // Later, [priv_comts]: Exclude private sub threads, also if is staff. + + if (post.isDeleted && !isStaffOrAuthor) + return (MaySeeOrWhyNot.NopePostDeleted, "6PKJ2RU-Post-Deleted") + + if (!post.isSomeVersionApproved && !isStaffOrAuthor) + return (MaySeeOrWhyNot.NopePostNotApproved, "6PKJ2RW-Post-0Apr") + + // Later: else if is meta discussion ... [METADISC] + + (MaySeeOrWhyNot.YesMaySee, "") + } + + /** Sync w ts: store_mayIReply() */ def mayPostReply( userAndLevels: UserAndLevels, + asAlias: Opt[WhichAliasPat], groupIds: immutable.Seq[GroupId], postType: PostType, pageMeta: PageMeta, @@ -383,11 +438,16 @@ object Authz { val user = userAndLevels.user - SHOULD // be check-perms-on pageid + postnr, not just page + SHOULD // check perms on post too, not just page. Need post author. [posts3_true_id] val mayWhat = checkPermsOnPages( - Some(user), groupIds, Some(pageMeta), - Some(privateGroupTalkMemberIds), catsRootLast = inCategoriesRootLast, - tooManyPermissions) + 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, + pageMembers = Some(privateGroupTalkMemberIds), + catsRootLast = inCategoriesRootLast, tooManyPermissions) if (mayWhat.maySee isNot true) return NoNotFound(s"TyEM0RE0SEE_-${mayWhat.debugCode}") @@ -431,35 +491,54 @@ object Authz { */ + /** Used also for pages, if editing the *text*. + * Otherwise, for pages, mayEditPage() is used, e.g. to alter page type or + * move the page to another category. + * + * @param ignoreAlias Helpful when just loading the source text to show in + * the editor — later, pat can choose [a persona to use for editing the post] + * that is allowed to edit it. + * @param pageAuthor Needed, because of the `maySeeOwn` and `mayEditOwn` permissions. + * @param postAuthor Needed, because of the `mayEditOwn`. + */ def mayEditPost( - userAndLevels: UserAndLevels, + userAndLevels: UserAndLevels, // CLEAN_UP: just use Pat instead? + asAlias: Opt[WhichAliasPat], groupIds: immutable.Seq[GroupId], post: Post, - otherAuthor: Opt[Pat], + postAuthor: Pat, pageMeta: PageMeta, + pageAuthor: Pat, privateGroupTalkMemberIds: Set[UserId], inCategoriesRootLast: immutable.Seq[Category], - tooManyPermissions: immutable.Seq[PermsOnPages]): MayMaybe = { + tooManyPermissions: immutable.Seq[PermsOnPages], + ignoreAlias: Bo = false, + ): MayMaybe = { - if (post.isDeleted) + val user = userAndLevels.user + + if (post.isDeleted && !user.isStaff) return NoNotFound("TyEM0EDPOSTDELD") - val user = userAndLevels.user val mayWhat = checkPermsOnPages( - Some(user), groupIds, Some(pageMeta), - Some(privateGroupTalkMemberIds), catsRootLast = inCategoriesRootLast, + Some(user), asAlias = asAlias, groupIds, Some(pageMeta), + pageAuthor = Some(pageAuthor), + pageMembers = Some(privateGroupTalkMemberIds), catsRootLast = inCategoriesRootLast, tooManyPermissions) if (mayWhat.maySee isNot true) return NoNotFound(s"TyEM0ED0SEE-${mayWhat.debugCode}") - val isOwnPost = _isOwn(user, authorId = post.createdById, otherAuthor) + val (isOwnPost, ownButWrongAlias) = _isOwn(user, asAlias, postAuthor = postAuthor) + var mayBecauseWiki = false if (isOwnPost) { - // Fine, may edit. + // Fine, may edit — if using the correct alias, see _checkDeanonRiskOfEdit(). + // But shouldn't: isOwnPost && mayWhat[.mayEditOwn] ? (2020-07-17) } else if (pageMeta.pageType == PageType.MindMap) { // [0JUK2WA5] + BUG // Too restrictive: Might still be ok to edit, if `mayEditWiki. if (!mayWhat.mayEditPage) return NoMayNot("TyEM0ED0YOURMINDM", "You may not edit other people's mind maps") } @@ -467,7 +546,8 @@ object Authz { // Fine, may edit. But exclude titles, for now. Otherwise, could be // surprising if an attacker renames a page to sth else, and edits it, // and the staff don't realize which page got edited, since renamed? - // I think titles aren't wikifiable at all, currently, anyway. + // I think titles aren't wikifiable at all, currently, anyway. [alias_ed_wiki] + mayBecauseWiki = true } else if (post.isOrigPost || post.isTitle) { if (!mayWhat.mayEditPage) @@ -476,7 +556,24 @@ object Authz { // Later: else if is meta discussion ... [METADISC] else { if (!mayWhat.mayEditComment) - return NoMayNot("EdEM0ED0YOURPOST", "You may not edit other people's posts") + return NoMayNot("EdEM0ED0YOURPOST", "You may not edit other people's comments") + } + + if (ignoreAlias) { + // Skip the deanon risk check. (Pat is e.g. just *loading* the source text + // of something to edit — then, not important to specify the correct alias, since + // not modifying anything. + } + else if (mayBecauseWiki) { + // The problem that others might guess that your anonym is you, if you + // edit [a post originally posted as yourself] anonymously, doesn't apply to + // wiki posts, since "everyone" can edit wiki posts. [deanon_risk] + } + else { + _checkDeanonRiskOfEdit(isOwn = isOwnPost, ownButWrongAlias = ownButWrongAlias, + asAlias = asAlias) foreach { mayNot => + return mayNot + } } Yes @@ -485,6 +582,7 @@ object Authz { def mayFlagPost( member: User, + // asAlias — anonymous flags not yet supported groupIds: immutable.Seq[GroupId], post: Post, pageMeta: PageMeta, @@ -508,7 +606,10 @@ object Authz { SHOULD // be maySeePost pageid, postnr, not just page val mayWhat = checkPermsOnPages( - Some(member), groupIds, Some(pageMeta), + Some(member), asAlias = None, groupIds, Some(pageMeta), + // Needed for maySeeOwn? [granular_perms] + // Wait until true author id in posts3/nodes_t. [posts3_true_id] + pageAuthor = None, Some(privateGroupTalkMemberIds), catsRootLast = inCategoriesRootLast, tooManyPermissions) @@ -520,16 +621,17 @@ object Authz { def maySubmitCustomForm( - userAndLevels: AnyUserAndThreatLevel, + userAndLevels: AnyUserAndLevels, + // asAlias — not supported groupIds: immutable.Seq[GroupId], pageMeta: PageMeta, inCategoriesRootLast: immutable.Seq[Category], tooManyPermissions: immutable.Seq[PermsOnPages]): MayMaybe = { - val user = userAndLevels.user - val mayWhat = checkPermsOnPages( - user, groupIds, Some(pageMeta), None, catsRootLast = inCategoriesRootLast, + userAndLevels.anyUser, asAlias = None, groupIds, + Some(pageMeta), pageAuthor = None, pageMembers = None, + catsRootLast = inCategoriesRootLast, tooManyPermissions) if (mayWhat.maySee isNot true) @@ -562,15 +664,22 @@ object Authz { */ private def checkPermsOnPages( user: Opt[Pat], + asAlias: Opt[WhichAliasPat], groupIds: immutable.Seq[GroupId], pageMeta: Opt[PageMeta], + pageAuthor: Opt[Pat], pageMembers: Opt[Set[UserId]], catsRootLast: immutable.Seq[Category], tooManyPermissions: immutable.Seq[PermsOnPages], maySeeUnlisted: Bo = true, - otherAuthor: Opt[Pat] = None, ): MayWhat = { + require(pageMeta.isDefined || pageAuthor.isEmpty, "TyEAUTHORMETA") + + val anyAnonAlias: Opt[Anonym] = asAlias.flatMap(_.anyPat.flatMap(_.asAnonOrNone)) + require(anyAnonAlias.forall(_.anonForPatId == user.getOrDie("TyEALI0USR").id), + "Anon true id != user id [TyEANONFORID]") // [throw_or_may_not] + // Admins, but not moderators, have access to everything. // Why not mods? Can be good with a place for members to bring up problems // with misbehaving mods, where those mods cannot read and edit & delete @@ -580,15 +689,13 @@ object Authz { val isStaff = user.exists(_.isStaff) - val isOwnPage = user.exists(u => pageMeta exists { page => - _isOwn(u, authorId = page.authorId, otherAuthor) - - // Won't work, if creating as anon and then editing as oneself?: - // user.exists(_.id == m.authorId) || doAsAlias.exists(_.id == m.authorId) - // - // But it's good if that won't work? [deanon_risk] Let's use `useAlias: Opt[Anonym]` - // instead of `otherAuthor` ? [_pass_alias] - }) + // (We ignore `_ownPageWrongAlias`, because it's about the *page* but we might be + // interested in a comment on the page. Instead, the caller looks at the comment or + // page. [alias_0_ed_others]) + // + val (isOwnPage, _ownPageWrongAlias) = user.flatMap(u => pageAuthor map { thePageAuthor => + _isOwn(u, asAlias, postAuthor = thePageAuthor) + }) getOrElse (false, false) // For now, don't let people see pages outside any category. Hmm...? // (<= 1 not 0: don't count the root category, no pages should be placed directly in them.) @@ -623,6 +730,8 @@ object Authz { debugCode = "EdMEDOWNMINDM") // Only page participants may see things like private chats. [PRIVCHATNOTFS] + // Later: If some page members are aliases, [anon_priv_msgs][anon_chats] + // need to consider true ids. if (meta.pageType.isPrivateGroupTalk) { val thePageMembers: Set[MembId] = pageMembers getOrDie "EdE2SUH5G" val theUser = user getOrElse { @@ -668,9 +777,15 @@ object Authz { // — and thereafter, an admin sets the page author to someone else. Then, // the user should not able to see or undelete the page any longer. // Tests: delete-pages.2br TyTE2EDELPG602 - val deletedOwnPage = user exists { theUser => - pageMeta.exists(p => p.authorId == theUser.id && p.deletedById.is(theUser.id)) - } + val deletedOwnPage = isOwnPage && user.exists({ theUser => + pageMeta.exists(page => + // Pat deleted the page as hanself? + page.deletedById.is(theUser.id) || + // Pat deleted the page using an alias? — Since its pat's page (`isOwnPage`), + // the page author must be pat's alias. Don't think needed: [posts3_true_id] + pageAuthor.exists(a => page.deletedById.is(a.id))) + }) + if (!deletedOwnPage) return MayWhat.mayNotSee("TyEPAGEDELD_") } @@ -761,30 +876,131 @@ object Authz { mayWhat = mayWhat.copyAsDeleted } + // If it's the wrong alias, it's still ok for the requester to look at the thing, + // but han can't edit anything. + for (thePageMeta <- pageMeta; anon <- anyAnonAlias) { + // Is the anonym for this page? (We've checked `anon.anonForPatId` above already.) + if (anon.anonOnPageId != thePageMeta.pageId) + // Is it best to throw, or set mayWhat to false? [throw_or_may_not] + mayWhat = mayWhat.copyAsMayNothingOrOnlySee("-TyEANONPAGEID") + } + + // Check if page or category settings allows anonyms. [derive_node_props_on_server] + // [pseudonyms_later] + val anyComtsStartAnon: Opt[NeverAlways] = + pageMeta.flatMap(_.comtsStartAnon).orElse( + // (Root last — so, the *first* category with `.comtsStartAnon` defined, + // is the most specific one.) + catsRootLast.find(_.comtsStartAnon.isDefined).flatMap(_.comtsStartAnon)) + val comtsStartAnon = anyComtsStartAnon getOrElse NeverAlways.NeverButCanContinue + + // Later: + // if (comtsStartAnon.toInt <= NeverAlways.Never.toInt) { + // // Can't even continue being anonymous. + // throwForbiddenIf(asAlias.isDefined, ...) + // } + // else + if (comtsStartAnon.toInt <= NeverAlways.NeverButCanContinue.toInt) { + asAlias foreach { + case _: WhichAliasPat.LazyCreatedAnon => + // Can't create new anonyms here — anon comments aren't enabled, ... + // (This might be a browser tab left open for long, and category settings + // got changed, and thereafter the user submitted an anon comment which + // was previously allowed but now isn't.) + // (Throw or set mayWhat to false? [throw_or_may_not]) + throwForbidden("TyEM0MKANON_", "You cannot be anonymous in this category (any longer)") + case _: WhichAliasPat.SameAnon => + // ... But it's ok to continue replying as an already existing anonym — + // that's the "can continue" in `NeverAlways.NeverButCanContinue`. + // (So, noop.) + } + } + else { + // COULD require an alias, if `cat.comtsStartAnon >= NeverAlways.AlwaysButCanContinue`, + // but not really important now. + } + + mayWhat } - /** Says if whatever-it-is was created by `pat`, by comparing pat's id with that of - * the author — but that won't work, if the author is one of pat's aliases (because - * aliases have their own ids). Therefore, also compares with the true id of - * any alias author (anonym or pseudonym). + /** Says if a user or hans alias is the same as a post author. Or if the user + * is using the wrong alias: * - * Maybe remove? [_pass_alias] + * Returns two bools, the first says if the post is trueUser' posts. If it is, + * then, the 2nd says if trueUser is using the wrong alias. Look: + * - (false, false) = sbd else's post + * - (true, false) = is trueUser's page, created under alias `asAlias` if defined. + * - (true, true) = problem: It's trueUser's page, but the wrong alias + * (wrong alias includes specifying an alias, when originally posting as oneself). + * - (false, true) = can't happen: can't be both someone else's page and also + * tueUser's alias' page. + * + * @param trueUser the one who wants to view/edit/alter the post, possibly using + * an anonym or pseudonym `asAlias`. + * @param asAlias must be `trueUser`s anonym or pseudonym. + * @param postAuthor – so we can compare the author's true id with trueUser, + * for better error messages. */ - def _isOwn(pat: Pat, authorId: PatId, author: Opt[Pat]): Bo = { - if (pat.id == authorId) { - true - } - else author.exists({ - // BUT isn't it safer to pass pat & patsAlias and require that the alias is - // the same? [deanon_risk] So can't accidentally edit an anon page as oneself - // (one's true id) just because it's one's own anonym — which others might then guess. - case anon: Anonym => - dieIf(anon.id != authorId, "TyE0PGAUTHOR", s"Anon ${anon.id} != author ${authorId}") - anon.anonForPatId == pat.id - case _ => false - }) + private def _isOwn(trueUser: Pat, asAlias: Opt[WhichAliasPat], postAuthor: Pat): (Bo, Bo) = { + val isTrueUsers = trueUser.id == postAuthor.trueId2.trueId + asAlias.flatMap(_.anyPat) match { + case None => + val isByTrueUsersAlias = isTrueUsers && trueUser.id != postAuthor.id + // If by an alias, then, we've specified the wrong alias, namely no alias or + // a not-yet-created `LazyCreatedAnon`. + val wrongAlias = isByTrueUsersAlias + (isTrueUsers, wrongAlias) + + case Some(alias) => + require(alias.trueId2.trueId == trueUser.id, s"Not trueUser's alias. True user: ${ + trueUser.trueId2}, alias: ${alias.trueId2} [TyE0OWNALIAS1]") + val correctAlias = alias.id == postAuthor.id + val ownButWrongAlias = isTrueUsers && !correctAlias + (isTrueUsers, ownButWrongAlias) + } + } + + + /** Returns `Some(NoMayNot)` if editing the post using the persona specified + * (either an alias, if asAlias defined, or as oneself) would mean that others + * might guess that the anonym you were/are using, is actually you. [deanon_risk] + */ + private def _checkDeanonRiskOfEdit(isOwn: Bo, ownButWrongAlias: Bo, + asAlias: Opt[WhichAliasPat]): Opt[NoMayNot] = { + + // If true user U wrote something as hanself, han should edit it as hanself (as U). + // Otherwise others might guess that [U's anonym or pseudonym doing the edits], is U. + // + ANON_UNIMPL // [mods_ed_own_anon] If user U can edit the page as hanself for + // *other reasons than* `mayWhat.mayEditOwn`, then, that _is_ok. For example, + // if U is a mod, then, others can see that han can edit the page because han is + // a mod — and they can't conclude that han and [the anonym who posted the page] + // are the same. Not yet impl though. Currently, need to continue editing + // anonymously (also if U is admin / mod). + // + if (ownButWrongAlias) + return Some(asAlias.isEmpty + ? NoMayNot("TyEM0ALIASEDTRUE", // [true_0_ed_alias] + // (This _is_ok, actually — if pat is a mod or admin. See comment above.) + o"""You're trying to edit your post as yourself, but you posted it + anonymously. Then you should edit it anonymously too.""") + | NoMayNot("TyEM0TRUEEDALIAS", + // [pseudonyms_later] Need to edit this message, if might be the wrong + // pseudonym, not an anonym, that tries to edit. + o"""You're trying to edit your own post anonymously, but you posted it + as yourself. Then you should edit it as yourself too.""")) + + // Typically, only few users, say, moderators, can edit *other* people's posts. + // So, if a mod edits others' posts anonymously, the others could guess that the + // anonym is one of the moderators, that's no good. [alias_0_ed_others] + if (!isOwn && asAlias.isDefined) + return Some(NoMayNot("TyEM0ALIASEDOTHR", + "You cannot edit other people's pages anonymously, as of now")) + + // Fine. Posted as oneself, is editing as oneself. Or posted as anon, edits as anon. + None } } @@ -890,7 +1106,18 @@ case class MayWhat( mayDeletePage = false, mayDeleteComment = false, mayCreatePage = false, - mayPostComment = false) + mayPostComment = false, + debugCode = debugCode + "-CPDELD") + + /** Sets everything to false (no-you-may-not), except for `maySee` and `maySeeOwn` which + * are left as-is. + */ + def copyAsMayNothingOrOnlySee(debugCode: St): MayWhat = + MayWhat().copy( // everything false by default + maySee = maySee, + maySeeOwn = maySeeOwn, + debugCode = this.debugCode + debugCode) + } diff --git a/appsv/server/talkyard/server/authz/AuthzSiteDaoMixin.scala b/appsv/server/talkyard/server/authz/AuthzSiteDaoMixin.scala index 24672323cd..945f33dced 100644 --- a/appsv/server/talkyard/server/authz/AuthzSiteDaoMixin.scala +++ b/appsv/server/talkyard/server/authz/AuthzSiteDaoMixin.scala @@ -20,7 +20,7 @@ package talkyard.server.authz import com.debiki.core._ import com.debiki.core.Prelude._ import debiki.dao.{MemCacheKey, SiteDao, CacheOrTx} -import debiki.EdHttp.throwNotFound +import debiki.EdHttp.{throwNotFound, throwForbidden, throwForbiddenIf} import MayMaybe.{NoMayNot, NoNotFound, Yes} import talkyard.server.http._ import scala.collection.immutable @@ -36,7 +36,7 @@ trait AuthzSiteDaoMixin { */ self: SiteDao => - import context.security.throwIndistinguishableNotFound + import context.security.{throwNoUnless, throwIndistinguishableNotFound} def deriveEffPatPerms(groupIdsAnyOrder: Iterable[GroupId]): EffPatPerms = { @@ -219,14 +219,16 @@ trait AuthzSiteDaoMixin { } - def throwIfMayNotSeePage2(pageId: PageId, reqrTgt: ReqrAndTgt, checkOnlyReqr: Bo = false - )(anyTx: Opt[SiteTx]): U = { + /** @return the page meta — the caller sort of always needs it. + */ + def throwIfMayNotSeePage2(pageId: PageId, reqrTgt: AnyReqrAndTgt, checkOnlyReqr: Bo = false + )(anyTx: Opt[SiteTx]): PageMeta = { val pageMeta: PageMeta = anyTx.map(_.loadPageMeta(pageId)).getOrElse(getPageMeta(pageId)) getOrElse { throwIndistinguishableNotFound(s"TyEM0SEEPG1") } { - val seePageResult = maySeePageImpl(pageMeta, Some(reqrTgt.reqr), anyTx) + val seePageResult = maySeePageImpl(pageMeta, reqrTgt.anyReqr, anyTx) if (!seePageResult.maySee) throwIndistinguishableNotFound(s"TyEM0SEEPG2-${seePageResult.debugCode}") } @@ -234,10 +236,16 @@ trait AuthzSiteDaoMixin { if (reqrTgt.areNotTheSame && !checkOnlyReqr) { COULD_OPTIMIZE // Getting categories and permissions a 2nd time here. val res2 = maySeePageImpl(pageMeta, reqrTgt.otherTarget, anyTx) - if (!res2.maySee) - throwNotFound(s"TyEM0SEEPG3-${res2.debugCode}", - o"${reqrTgt.target.nameParaId} may not see page $pageId") + if (!res2.maySee) { + // (It's ok with a more detailed Not Fond message — we already know that the + // requester can see the page, so han can figure out that `otherTarget` + // can't see it, in any case.) + throwNotFound(s"TyEM0SEEPG3-${res2.debugCode}", s"${reqrTgt.otherTarget.getOrDie( + "TyE70SKJF4").nameParaId} may not see page $pageId") + } } + + pageMeta } @@ -320,8 +328,13 @@ trait AuthzSiteDaoMixin { getAnyPrivateGroupTalkMembers(pageMeta) } - Authz.maySeePage(pageMeta, authzContext.requester, authzContext.groupIdsUserIdFirst, memberIds, - categories, authzContext.tooManyPermissions, maySeeUnlisted) match { + val pageAuthor = this.getParticipant(pageMeta.authorId, anyTx) getOrElse { + return NotSeePage("TyE0PGAUTHOR2") + } + + Authz.maySeePage(pageMeta, authzContext.requester, + authzContext.groupIdsUserIdFirst, pageAuthor = pageAuthor, memberIds, + categories, authzContext.tooManyPermissions, maySeeUnlisted) match { case Yes => PageCtx(categories) case mayNot: NoMayNot => NotSeePage(mayNot.code) case mayNot: NoNotFound => NotSeePage(mayNot.debugCode) @@ -431,34 +444,7 @@ trait AuthzSiteDaoMixin { if (!seePageResult.maySee) return (MaySeeOrWhyNot.NopeUnspecified, s"${seePageResult.debugCode}-ABX94WN_") - maySeePostIfMaySeePage(ppt, post) - } - - - def maySeePostIfMaySeePage(pat: Opt[Pat], post: Post): (MaySeeOrWhyNot, St) = { - val ppt = pat - - MOVE // to Authz, should be a pure fn. - CLEAN_UP // Dupl code, this stuff repeated in Authz.mayPostReply. [8KUWC1] - - // Below: Since the requester may see the page, it's ok if hen learns - // if a post has been deleted or it never existed? (Probably hen can - // figure that out anyway, just by looking for holes in the post nr - // sequence.) - - // Staff may see all posts, if they may see the page. [5I8QS2A] - def isStaffOrAuthor = - ppt.exists(_.isStaff) || ppt.exists(_.id == post.createdById) - - if (post.isDeleted && !isStaffOrAuthor) - return (MaySeeOrWhyNot.NopePostDeleted, "6PKJ2RU-Post-Deleted") - - if (!post.isSomeVersionApproved && !isStaffOrAuthor) - return (MaySeeOrWhyNot.NopePostNotApproved, "6PKJ2RW-Post-0Apr") - - // Later: else if is meta discussion ... [METADISC] - - (MaySeeOrWhyNot.YesMaySee, "") + Authz.maySeePostIfMaySeePage(ppt, post) } @@ -477,6 +463,28 @@ trait AuthzSiteDaoMixin { } + def throwIfMayNotAlterPage(user: Pat, asAlias: Opt[WhichAliasPat], pageMeta: PageMeta, + changesOnlyTypeOrStatus: Bo, tx: SiteTx): U = { + val pageAuthor = + if (pageMeta.authorId == user.id) user + else this.getTheParticipant(pageMeta.authorId) + + val catsRootLast = this.getAncestorCategoriesSelfFirst(pageMeta.categoryId) + val requestersGroupIds = this.getOnesGroupIds(user) + throwNoUnless(Authz.mayEditPage( + pageMeta = pageMeta, + pat = user, + asAlias = asAlias, + pageAuthor = pageAuthor, + groupIds = requestersGroupIds, + pageMembers = this.getAnyPrivateGroupTalkMembers(pageMeta), + catsRootLast = catsRootLast, + tooManyPermissions = this.getPermsOnPages(catsRootLast), + changesOnlyTypeOrStatus = changesOnlyTypeOrStatus, + maySeeUnlisted = true), "TyE0ALTERPGP01") + } + + @deprecated("now", "use getPermsForPeople instead?") def getPermsOnPages(categories: immutable.Seq[Category]): immutable.Seq[PermsOnPages] = { getAllPermsOnPages().permsOnPages diff --git a/appsv/server/talkyard/server/authz/ReqrAndTgt.scala b/appsv/server/talkyard/server/authz/ReqrAndTgt.scala index ca497d1e00..bef7792807 100644 --- a/appsv/server/talkyard/server/authz/ReqrAndTgt.scala +++ b/appsv/server/talkyard/server/authz/ReqrAndTgt.scala @@ -24,6 +24,9 @@ sealed trait AnyReqrAndTgt { */ def otherTarget: Opt[Pat] = None + /** If the requester and target are not the same user. */ + def areNotTheSame: Bo = false + /** For casting the requester to admin, to invoke admin-only functions. * But if the requester is *not* an admin, then, this fn aborts the request, * the server replies Forbidden. @@ -51,18 +54,24 @@ sealed trait AnyReqrAndTgt { } -/** Requester and target. +/** Requester and target. Or, RENAME "target" to "principal"? [rename_2_principal] + * "Prin" is an abbreviation for 1) "principal" and 2) "principle" — let's use "prin"? + * See: https://www.merriam-webster.com/dictionary/prin + * + * And RENAME this class to ReqrAndPrin for "requester and principal", + * and instead of "tgt", use "prin" everywhere. (It's ok to abbreviate + * more commonly used words, and "principal" will be "everywhere") * - * The requester (the participant doing the request), and the target of the request, - * are usually the same. For example, a user configures their own settings, + * The requester (the participant doing the request) and the principal + * are usually the same. For example, a user configures *hans own* settings, * or looks at a page, or replies to a post. * * But admins and mods can do things on behalf of others. For example, the requester * can be an admin, who configures notification settings for another user, - * or for a group — that other user or group, is then the target user. + * or for a group — that other user or group, is then the principal (or "target"). * * (The browser info, e.g. ip addr, is about the requester's browser. — The - * target user might not be at their computer at all, or might be a bot or group.) + * principal might not be at their computer at all, or might be a bot or group.) * * (Short name: "Reqr", "Tgt", because these requester-and-target classes will be * frequently used — namely in *all* request handling code, eventually?) @@ -89,7 +98,7 @@ sealed trait ReqrAndTgt extends AnyReqrAndTgt { if (target.id == reqr.id) None // not an *other* target, but the *same* as reqr else Some(target) - def areNotTheSame: Bo = target.id != reqr.id + override def areNotTheSame: Bo = target.id != reqr.id } diff --git a/appsv/server/talkyard/server/http/DebikiRequest.scala b/appsv/server/talkyard/server/http/DebikiRequest.scala index 3a5bf0fde0..f030fff72f 100644 --- a/appsv/server/talkyard/server/http/DebikiRequest.scala +++ b/appsv/server/talkyard/server/http/DebikiRequest.scala @@ -106,6 +106,10 @@ abstract class AuthnReqHeader extends SomethingToRateLimit { def theReqer: Pat = theUser // shorter, better def reqr: Pat = theUser // better + def anyAliasPat: Opt[WhichAliasPat] = + die("TyEUSINGALIAS", "Cannot use an anonym or pseudonym when doing this") + + def tenantId: SiteId = dao.siteId def siteId: SiteId = dao.siteId def isDefaultSite: Boolean = siteId == globals.defaultSiteId @@ -133,6 +137,11 @@ abstract class AuthnReqHeader extends SomethingToRateLimit { case Some(theReqr) => ReqrAndTgt(theReqr, theBrowserIdData, target = theReqr) } + def theReqrTargetSelf: ReqrAndTgt = reqer match { + case None => throwForbidden("TyE0LGDIN2", "Not logged in") + case Some(theReqr) => ReqrAndTgt(theReqr, theBrowserIdData, target = theReqr) + } + def reqrAndTarget(target: Pat): ReqrAndTgt = ReqrAndTgt(theReqer, theBrowserIdData, target = target) @@ -165,20 +174,20 @@ abstract class AuthnReqHeader extends SomethingToRateLimit { def theReqerId: PatId = theRequesterId // shorter, nice def theReqerTrueId: TrueId = theUser.trueId2 - def userAndLevels: AnyUserAndThreatLevel = { - val threatLevel = user match { + // [dupl_load_lvls] + def userAndLevels: AnyUserAndLevels = { + user match { case Some(user) => - COULD_OPTIMIZE // this loads the user again (2WKG06SU) - val userAndLevels = theUserAndLevels - userAndLevels.threatLevel + COULD_OPTIMIZE // this loads the user again [2WKG06SU] + theUserAndLevels case None => - dao.readOnlyTransaction(dao.loadThreatLevelNoUser(theBrowserIdData, _)) + val threatLevel = dao.readTx(dao.loadThreatLevelNoUser(theBrowserIdData, _)) + StrangerAndThreatLevel(threatLevel) } - AnyUserAndThreatLevel(user, threatLevel) } def theUserAndLevels: UserAndLevels = { - COULD_OPTIMIZE // cache levels + user in dao (2WKG06SU), + don't load user again + COULD_OPTIMIZE // cache levels + user in dao [2WKG06SU], + don't load user again dao.readOnlyTransaction(dao.loadUserAndLevels(who, _)) } diff --git a/appsv/server/talkyard/server/http/PlainApiActions.scala b/appsv/server/talkyard/server/http/PlainApiActions.scala index 058fcf2369..58b1ec845f 100644 --- a/appsv/server/talkyard/server/http/PlainApiActions.scala +++ b/appsv/server/talkyard/server/http/PlainApiActions.scala @@ -29,12 +29,14 @@ import java.{util => ju} import play.api.mvc._ import scala.concurrent.{ExecutionContext, Future} import scala.util.{Failure, Success} +import EdSecurity.PersonaHeaderName import EdSecurity.AvoidCookiesHeaderName import scala.collection.mutable import play.api.http.{HeaderNames => p_HNs} import play.api.mvc.{Results => p_Results} import talkyard.server.TyLogging import talkyard.server.authn.MinAuthnStrength +import JsonUtils.{parseOptSt, asJsObject, parseOptJsObject, parseOptBo} /** Play Framework Actions for requests to Talkyard's HTTP API. @@ -58,11 +60,14 @@ class PlainApiActions( isGuestLogin: Bo = false, isLogin: Bo = false, authnUsersOnly: Bo = false, + canUseAlias: Bo = false, + ignoreAlias: Bo = false, avoidCookies: Bo = false, skipXsrfCheck: Bo = false, ): ActionBuilder[ApiRequest, B] = PlainApiActionImpl(parser, rateLimits, minAuthnStrength = minAuthnStrength, authnUsersOnly = authnUsersOnly, + canUseAlias = canUseAlias, ignoreAlias = ignoreAlias, allowAnyone = allowAnyone, isLogin = isLogin, isGuestLogin = isGuestLogin, avoidCookies = avoidCookies, skipXsrfCheck = skipXsrfCheck) @@ -70,12 +75,14 @@ class PlainApiActions( rateLimits: RateLimits, parser: BodyParser[B], minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal, + canUseAlias: Bo = false, ignoreAlias: Bo = false, ): ActionBuilder[ApiRequest, B] = - PlainApiActionImpl(parser, rateLimits, minAuthnStrength, staffOnly = true) + PlainApiActionImpl(parser, rateLimits, minAuthnStrength, staffOnly = true, + canUseAlias = canUseAlias, ignoreAlias = ignoreAlias) - def PlainApiActionAdminOnly[B](rateLimits: RateLimits, parser: BodyParser[B]) - : ActionBuilder[ApiRequest, B] = - PlainApiActionImpl(parser, rateLimits, adminOnly = true) + def PlainApiActionAdminOnly[B](rateLimits: RateLimits, parser: BodyParser[B], + ignoreAlias: Bo = false): ActionBuilder[ApiRequest, B] = + PlainApiActionImpl(parser, rateLimits, adminOnly = true, ignoreAlias = ignoreAlias) def PlainApiActionApiSecretOnly[B](whatSecret: WhatApiSecret, rateLimits: RateLimits, parser: BodyParser[B]) @@ -104,6 +111,8 @@ class PlainApiActions( adminOnly: Boolean = false, staffOnly: Boolean = false, authnUsersOnly: Bo = false, + canUseAlias: Bo = false, + ignoreAlias: Bo = false, allowAnyone: Boolean = false, // try to delete 'allowAnyone'? REFACTOR avoidCookies: Boolean = false, isGuestLogin: Bo = false, @@ -113,6 +122,9 @@ class PlainApiActions( skipXsrfCheck: Bo = false): ActionBuilder[ApiRequest, B] = new ActionBuilder[ApiRequest, B] { + // Can't both use and ignore. + assert(!(canUseAlias && ignoreAlias), "TyE5LFG8M2") + val isUserLogin = isLogin // rename later dieIf(isGuestLogin && isUserLogin, "TyE306RJU243") @@ -546,10 +558,14 @@ class PlainApiActions( private def runBlockIfAuthOk[A](request: Request[A], site: SiteBrief, dao: SiteDao, anyUserMaybeSuspended: Option[Participant], anyTySession: Opt[TySession], sidStatus: SidStatus, - xsrfOk: XsrfOk, browserId: Option[BrowserId], block: ApiRequest[A] => Future[Result]) + xsrfOk: XsrfOk, browserId: Option[BrowserId], + block: ApiRequest[A] => Future[Result]) : Future[Result] = { + val siteId = site.id + // ----- User suspended? + if (anyUserMaybeSuspended.exists(_.isAnon)) { // Client side bug? return Future.successful( @@ -588,6 +604,8 @@ class PlainApiActions( None } + // ----- Valid request? + // Re the !superAdminOnly test: Do allow access for superadmin endpoints, // so they can reactivate this site, in case this site is the superadmin site itself. // Sync w WebSocket endpoint. [SITESTATUS]. @@ -647,6 +665,8 @@ class PlainApiActions( dieUnless(anyTySession.exists(_.part4Absent), "TyE70MWEG25SM") } + // ----- For super admins? + if (superAdminOnly) { globals.config.superAdmin.siteIdString match { case Some(siteId) if site.id.toString == siteId => @@ -699,6 +719,8 @@ class PlainApiActions( } } + // ----- Authenticated and approved? + if (authnUsersOnly) { if (!anyUser.exists(_.isUserNotGuest)) throwForbidden( @@ -753,8 +775,121 @@ class PlainApiActions( } } + // ----- Anonymity? + + val anyPersonaHeaderVal: Opt[St] = + if (ignoreAlias) { + // This reqest is on behalf of the true user, even if han has switched + // to Anonymous mode or to a pseudonym. + // E.g. to track one's reading progress [anon_read_progr]. + None + } + else { + // The user might have swithecd to Anonymous mode [alias_mode], + // or han is anonymous automatically. + request.headers.get(PersonaHeaderName) + } + + // [parse_pers_mode_hdr] (Could break out function.) + val anyAliasPat: Opt[WhichAliasPat] = anyPersonaHeaderVal flatMap { headerVal: St => + import play.api.libs.json.{JsObject, JsValue, Json} + val personaJs: JsValue = scala.util.Try(Json.parse(headerVal)) getOrElse { + throwBadRequest("TyEPERSOJSON", s"Invalid $PersonaHeaderName json") + } + val personaJo: JsObject = asJsObject(personaJs, s"Header $PersonaHeaderName") + val reqr = anyUser.getOrElse(throwForbidden("TyEPERSSTRA", + "Can't specify persona when not logged in")) + + val mightModify = request.method != "GET" && request.method != "OPTIONS" + + // If this endpoint modifies something (e.g. posts or edits a comment), but + // doesn't support using anonyms or pseudonyms, we'll reject the request — + // otherwise the modification(s) would be done as the true user (which would be + // no good, since the user thinks han is using an alias). + val rejectIfAlias = mightModify && !canUseAlias + + // For safety: [persona_indicator_chk] If no persona selected, but the browser + // shows "ones_username —> Anonymous" (because one has been anonymous before + // on the current page, or it's recommended), then, the browser sends + // { indicated: { self: true } | { anonStatus: ...} } + // as persona mode json. + val anyIndicatedJo: Opt[JsObject] = parseOptJsObject(personaJo, "indicated") + val anyChoosenJo: Opt[JsObject] = parseOptJsObject(personaJo, "choosen") + val isAmbiguous: Opt[Bo] = parseOptBo(personaJo, "ambiguous") + + val numFields = personaJo.value.size + val numKnownFields = anyIndicatedJo.oneIfDefined + anyChoosenJo.oneIfDefined + + isAmbiguous.oneIfDefined + + throwBadReqIf(numFields > numKnownFields, "TyEPERSUNKFLD", + s"${numFields - numKnownFields} unknown persona json fields in: ${ + personaJo.value.keySet}") + + throwBadReqIf(anyChoosenJo.isDefined && anyIndicatedJo.isDefined, + "TyEPERSCHOIND", "Both choosen and indicated") + + throwBadReqIf(anyChoosenJo.isDefined && isAmbiguous.is(true), + "TyEPERSCHOAMB", "The choosen one is not ambiguous") + + if (rejectIfAlias) { + // If the browser has indicated [persona_indicator] to the user that they're + // currently anonymous or using a pseudonym, but if this endpoint doesn't + // support that, then, we should not continue. + anyIndicatedJo foreach { indicatedJo => + // Doing things as oneself is fine, also for endpoints that don't support aliases. + val anyIsSelf = parseOptBo(indicatedJo, "self") + val ok = anyIsSelf is true + throwForbiddenIf(!ok, "TyEPERSONAUNSUP1", o"""You cannot yet do this anonymously + — you need to enter Yourself mode, to do this, right now.""") + } + // Apart from the above test, we actually ignore any indicated persona — + // the persona to use, is instead in the request body. (This makes it + // possible to do one-off things as some other persona, if f.ex. you visit + // an old discussion where you were using an old pseudonym, and want to + // reply once as that old pseudonym, without having to enter and + // then leave persona mode as that pseudonym. See the [choose_persona] fns.) + // (We do consider any explicitly choosen Persona Mode though — see + // `anyAliasId` and `anyAliasPat` just below.) + } + + val anyAliasId: Opt[WhichAliasId] = anyChoosenJo flatMap { jo => + talkyard.server.parser.parseWhichAliasIdJson(jo) getOrIfBad { prob => + throwBadReq("TyEPERSHDRJSN", s"Bad persona header json: $prob") + } + } + + val anyAliasPat: Opt[WhichAliasPat] = anyAliasId flatMap { + case WhichAliasId.Oneself => + None // oneself is the default + case which: WhichAliasId.SameAnon => + val anon = dao.getTheParticipant(which.sameAnonId).asAnonOrThrow + // If, later on, the requester and principal can be different, [alias_4_principal] + // we should compare w the principal's id instead. + throwForbiddenIf(anon.anonForPatId != reqr.id, + "TyE0YOURANON02", "No your anonym in persona header") + Some(WhichAliasPat.SameAnon(anon)) + case which: WhichAliasId.LazyCreatedAnon => + Some(WhichAliasPat.LazyCreatedAnon(which.anonStatus)) + } + + // If the user is trying to do / change something, using an + // alias — but this endpoint doesn't currently support that, then, + // reject this request. Better than suddenly & surprisingly doing + // something as the user hanself (not anonymously). + throwForbiddenIf(rejectIfAlias && anyAliasPat.isDefined, + "TyEPERSONAUNSUP2", o"""You cannot yet do this anonymously + — you need to leave Anonymous mode, to do this, right now.""") + + anyAliasPat + } + + // ----- Construct request (Ty's wrapper) + val apiRequest = ApiRequest[A]( - site, anyTySession, sidStatus, xsrfOk, browserId, anyUser, dao, request) + site, anyTySession, sidStatus, xsrfOk, browserId, anyUser, + dao, request)(anyAliasPat, mayUseAlias = canUseAlias) + + // ----- Rate limits, tracing rateLimiter.rateLimit(rateLimits, apiRequest) @@ -772,10 +907,18 @@ class PlainApiActions( if (user.isModerator) tracerSpan.setTag("isModerator", true) } + // ----- Run request handler + val timer = globals.metricRegistry.timer(request.path) val timerContext = timer.time() var result = try { - block(apiRequest) + + // Invoke the request handler, do the actually interesting thing. + val res = block(apiRequest) + + devDieIf(canUseAlias && !apiRequest.aliasRead, "TyEALIAS0READ", "Forgot to use alias") + devDieIf(!canUseAlias && apiRequest.aliasRead, "TyEALIASREAD2", "Tried to use alias") + res } catch { // case ProblemException ? NEXT [ADMERRLOG] + tracer tag? @@ -798,6 +941,8 @@ class PlainApiActions( timerContext.stop() } + // ----- Handle async errors + result.onComplete({ case Success(_) => //logger.debug( diff --git a/appsv/server/talkyard/server/http/package.scala b/appsv/server/talkyard/server/http/package.scala index 1f614b510d..e8267d4e15 100644 --- a/appsv/server/talkyard/server/http/package.scala +++ b/appsv/server/talkyard/server/http/package.scala @@ -18,6 +18,7 @@ package talkyard.server import com.debiki.core._ +import com.debiki.core.Prelude.devDieIf import debiki.dao.SiteDao import talkyard.server.security.{BrowserId, SidStatus, XsrfOk} import play.api.http.{HeaderNames => play_HeaderNames} @@ -103,7 +104,19 @@ package object http { browserId: Opt[BrowserId], user: Opt[Pat], dao: SiteDao, - request: p_Request[A]) extends DebikiRequest[A] { + request: p_Request[A], + )(private val _aliasPat: Opt[WhichAliasPat], private val mayUseAlias: Bo) + extends DebikiRequest[A] { + + private var _aliasRead: Bo = false + + def aliasRead: Bo = _aliasRead + + override def anyAliasPat: Opt[WhichAliasPat] = { + devDieIf(!mayUseAlias, "TyEALIASREAD1", "Trying to use an alias, not allowed here") + _aliasRead = true + _aliasPat + } } diff --git a/appsv/server/talkyard/server/parser/package.scala b/appsv/server/talkyard/server/parser/package.scala index 1811dfbcf8..8233be25fe 100644 --- a/appsv/server/talkyard/server/parser/package.scala +++ b/appsv/server/talkyard/server/parser/package.scala @@ -1,11 +1,10 @@ package talkyard.server import com.debiki.core._ -import com.debiki.core.Prelude.dieIf -import debiki.JsonUtils.parseOptJsObject -import debiki.EdHttp.throwBadReq +import com.debiki.core.Prelude._ +import debiki.JsonUtils.{parseOptBo, parseOptInt32, parseOptJsObject} import org.scalactic.{Bad, Good, Or} -import play.api.libs.json.JsObject +import play.api.libs.json.{JsObject, JsValue, JsFalse} /** Parsers and serializers, e.g. from-to JSON or from PASETO token claims. @@ -56,51 +55,83 @@ package object parser { } - def parseDoAsAliasJsonOrThrow(jOb: JsObject): Opt[WhichAnon] = { - parseWhichAnonJson(jOb) getOrIfBad { prob => - throwBadReq("TyEBADALIAS", s"Bad doAsAnon params: $prob") + val DoAsAnonFieldName = "doAsAnon" + + + def parseDoAsAnonField(jOb: JsObject): Opt[WhichAliasId] Or ErrMsg = { + import play.api.libs.json.JsDefined + (jOb \ DoAsAnonFieldName) match { + case jsDef: JsDefined => parseWhichAliasIdJson(jsDef.value) + case _ => Good(None) } } - val DoAsAnonFieldName = "doAsAnon" - - /** Sync w parseWhichAnon(..) in com.debiki.dao.rdb. */ - def parseWhichAnonJson(jsOb: JsObject): Opt[WhichAnon] Or ErrMsg = { - import debiki.JsonUtils.parseOptInt32 + /** Sync w parseWhichAliasId(..) in com.debiki.dao.rdb. */ + def parseWhichAliasIdJson(jsVal: JsValue): Opt[WhichAliasId] Or ErrMsg = { + val doAsJsOb: JsObject = jsVal match { + case jOb: JsObject => jOb + case JsFalse => return Good(Some(WhichAliasId.Oneself)) // [oneself_0_false] + case x => return Bad(s"Bad persona json, got a: ${classNameOf(x)} [TyEPERSJSN]") + } - val doAsJsOb = parseOptJsObject(jsOb, DoAsAnonFieldName, falseAsNone = true) getOrElse { - return Good(None) + val numFields = doAsJsOb.value.size + if (numFields > 2) + return Bad(s"Too many which-persona json fields: ${doAsJsOb.toString} [TyEPERSFIELDS1]") + + val self: Opt[Bo] = parseOptBo(doAsJsOb, "self") + if (self is true) { + // Any ambiguities because of unknown fields? + if (numFields > 1) + return Bad(s"Too many fields in a { self: true } which-persona json object, this: ${ + doAsJsOb.toString} [TyEPERSFIELDS2]") + return Good(Some(WhichAliasId.Oneself)) } val sameAnonId: Opt[AnonId] = parseOptInt32(doAsJsOb, "sameAnonId") + val lazyCreate: Bo = parseOptBo(doAsJsOb, "lazyCreate") getOrElse false + val createNew_tst: Bo = parseOptBo(doAsJsOb, "createNew_tst") getOrElse false + + // Later, when pseudonyms implemented, anonStatus might be absent. [pseudonyms_later] + val anyAnonStatus: Opt[AnonStatus] = parseOptInt32(doAsJsOb, "anonStatus") map { int => + if (int == AnonStatus.NotAnon.IntVal) { + if (numFields > 1) + return Bad(s"Anon fields, but anonStatus is NotAnon [TyEPERS0ANON]") - val newAnonStatus: Opt[AnonStatus] = parseOptInt32(doAsJsOb, "newAnonStatus") map { int => - if (int == AnonStatus.NotAnon.IntVal) return Good(None) + } AnonStatus.fromInt(int) getOrElse { - return Bad(s"Invalid newAnonStatus: $int") + return Bad(s"Invalid anonStatus: $int [TyEPERSANONSTS]") } } - if (sameAnonId.isDefined && newAnonStatus.isDefined) - return Bad("Both sameAnonId and newAnonStatus specified") + anyAnonStatus match { + case None => + if (numFields > 0) + return Bad("anonStatus missing but there are anon fields [TyEPERS0ANONSTS]") - Good { - if (sameAnonId.isDefined) { - val id = sameAnonId.get - if (id > Pat.MaxAnonId) - return Bad(s"Bad anon id: $id, it's > MaxAnonId = ${Pat.MaxAnonId} [TyEBADANIDJSN]") + return Good(None) - Some(WhichAnon.SameAsBefore(id)) - } - else if (newAnonStatus.isDefined) { - Some(WhichAnon.NewAnon(newAnonStatus.get)) - } - else { - None - } + case Some(anonStatus) => + if (sameAnonId.isDefined) { + val id = sameAnonId.get + if (id > Pat.MaxAnonId) + return Bad(s"Bad anon id: $id, it's > MaxAnonId = ${Pat.MaxAnonId} [TyEPERSANONID]") + + COULD // remember anonStatus and verify later, when looking up the anon, + // that it has the same anonStatus. [chk_alias_status] + Good(Some(WhichAliasId.SameAnon(id))) + } + else if (lazyCreate) { + Good(Some(WhichAliasId.LazyCreatedAnon(anonStatus))) + } + else if (createNew_tst) { + Bad("Unimplemented: createNew_tst [TyEPERSUNIMP]") + } + else { + Bad("Anon fields missing: Reuse or create anon? [TyEPERS0FLDS]") + } } } } diff --git a/appsv/server/talkyard/server/plugins/utx/UsabilityTestingExchangeController.scala b/appsv/server/talkyard/server/plugins/utx/UsabilityTestingExchangeController.scala index e6299c7fa0..938c371f3a 100644 --- a/appsv/server/talkyard/server/plugins/utx/UsabilityTestingExchangeController.scala +++ b/appsv/server/talkyard/server/plugins/utx/UsabilityTestingExchangeController.scala @@ -20,6 +20,7 @@ package talkyard.server.plugins.utx import com.debiki.core._ import com.debiki.core.Prelude._ import debiki._ +import debiki.dao.CreatePageResult import debiki.EdHttp._ import talkyard.server._ import talkyard.server.http.ApiRequest @@ -79,9 +80,23 @@ class UsabilityTestingExchangeController @Inject()(cc: ControllerComponents, tyC val category = request.dao.getCategoryBySlug(categorySlug).getOrThrowBadArgument( "EsE0FYK42", s"No category with slug: $categorySlug") - val pagePath = request.dao.createPage(pageType, PageStatus.Published, Some(category.id), - anyFolder = None, anySlug = None, titleSourceAndHtml, bodyTextAndHtml, - showId = true, deleteDraftNr = None, request.who, request.spamRelatedStuff) + val res: CreatePageResult = dao.createPageIfAuZ( + pageType, + PageStatus.Published, + inCatId = Some(category.id), + withTags = Nil, + anyFolder = None, + anySlug = None, + title = titleSourceAndHtml, + bodyTextAndHtml = bodyTextAndHtml, + showId = true, + deleteDraftNr = None, + reqrAndCreator = request.reqrTargetSelf, + spamRelReqStuff = request.spamRelatedStuff, + asAlias = None, + discussionIds = Set.empty, + embeddingUrl = None, + refId = None) Ok } diff --git a/appsv/server/talkyard/server/security/package.scala b/appsv/server/talkyard/server/security/package.scala index 29bdc03c3d..b0eb02ff83 100644 --- a/appsv/server/talkyard/server/security/package.scala +++ b/appsv/server/talkyard/server/security/package.scala @@ -181,6 +181,9 @@ object EdSecurity { */ private val XsrfTokenHeaderName = "X-XSRF-TOKEN" + //private val PersonaCookieName = "TyCoPersona" + val PersonaHeaderName = "X-Ty-Persona" + private val BrowserIdCookieName = "dwCoBrId" def tooLowEntropy(value: St): Bo = { @@ -1419,6 +1422,9 @@ class EdSecurity(globals: Globals) { throwNotFound("TyE404_" + suffix, "Not found") } + /** Throws 404 Not Found if pat may not see the post, or 403 Forbidden if + * han may see it, but lacks permissions to do whatever han is up to. + */ def throwNoUnless(mayMaybe: MayMaybe, errorCode: String): Unit = { import MayMaybe._ mayMaybe match { diff --git a/appsv/server/talkyard/server/sitepatch/SitePatchParser.scala b/appsv/server/talkyard/server/sitepatch/SitePatchParser.scala index 62a3f30a57..67a648ad87 100644 --- a/appsv/server/talkyard/server/sitepatch/SitePatchParser.scala +++ b/appsv/server/talkyard/server/sitepatch/SitePatchParser.scala @@ -1433,8 +1433,8 @@ case class SitePatchParser(context: TyContext) { return Bad(s"Bad DraftLocator json: ${ex.getMessage} [TyE603KUTDGJ]") } - UNTESTED; TESTS_MISSING // exp imp anons? True ids are incl in json dumps? - val doAsAnon: Opt[WhichAnon] = parser.parseWhichAnonJson(jsObj) getOrIfBad { prob => + UNTESTED; TESTS_MISSING // exp imp anons? True ids are incl in json dumps? [export_privid] + val doAsAnon: Opt[WhichAliasId] = parser.parseDoAsAnonField(jsObj) getOrIfBad { prob => return Bad(s"Bad anon params: $prob [TyEANONPARDFT]") } diff --git a/client/app-editor/editor/editor.editor.ts b/client/app-editor/editor/editor.editor.ts index 6cd5fe64b7..425f2301fc 100644 --- a/client/app-editor/editor/editor.editor.ts +++ b/client/app-editor/editor/editor.editor.ts @@ -130,14 +130,23 @@ export const listUsernamesTrigger = { interface EditorState { inFrame?: DiscWin, - inFrameStore?: DiscStore; + inFrameStore?: DiscStore & Origins; store: Store; visible: boolean; replyToPostNrs: PostNr[]; anyPostType?: PostType; + + // If posting anonymously or using a pseudonym. doAsAnon?: MaybeAnon; + + // Any pseudonyms and anonyms pat can choose among, when posting. ("Opts" = options) myAliasOpts?: MaybeAnon[] + + // Properties of the page where the post will appear. [_new_disc_props] Can be different + // than `store.curDiscProps`, if composing a new page in a category with different + // settings than the forum homepage. So, can't use `EditorState.store.discProps`. discProps?: DiscPropsDerived; + authorId?: PatId; // remove? editorsCategories?: Category[]; editorsPageId?: PageId; @@ -214,7 +223,7 @@ export const Editor = createFactory<any, EditorState>({ }, - getDiscStore(): DiscStore { + getDiscStore(): DiscStore & Origins { const state: EditorState = this.state; return state.inFrameStore || state.store; // [many_embcom_iframes] }, @@ -226,7 +235,7 @@ export const Editor = createFactory<any, EditorState>({ /// we just return the React store of the current window as is (which is then /// the top window). /// - getOrCloneDiscStore(inFrame?: DiscWin): DiscStore { + getOrCloneDiscStore(inFrame?: DiscWin): DiscStore & Origins { if (!eds.isInIframe) { // @ifdef DEBUG dieIf(inFrame, 'TyE507MWEG25'); @@ -245,7 +254,7 @@ export const Editor = createFactory<any, EditorState>({ // `inFrame` is sometimes available before this.state has been updated, so // try to use it first. const state: EditorState = this.state; - const discFrameStore: Partial<DiscStore> = + const discFrameStore: Partial<DiscStore & Origins> = inFrame?.theStore || ( // [ONESTORE] [many_embcom_iframes] state.inFrame ? state.inFrame.theStore : ( // This'd be weird, would mean the comments iframe was deleted @@ -264,17 +273,30 @@ export const Editor = createFactory<any, EditorState>({ // And 2) so that the data won't get changed at any time by code in the other iframe // — React.js wouldn't like that. // - let storeClone: DiscStore; + let storeClone: DiscStore & Origins; try { + // [break_out_clone_store_fn]? storeClone = _.cloneDeep({ + // SessWinStore me: discFrameStore.me, + + // Origins embeddedOriginOrEmpty: discFrameStore.embeddedOriginOrEmpty, + anyCdnOrigin: discFrameStore.anyCdnOrigin, + anyUgcOrigin: discFrameStore.anyUgcOrigin, + pubSiteId: discFrameStore.pubSiteId, + + // DiscStore currentPage: discFrameStore.currentPage, currentPageId: discFrameStore.currentPageId, currentCategories: discFrameStore.currentCategories, curCatsById: {}, // updated below (actually not needed? feels better, oh well) usersByIdBrief: discFrameStore.usersByIdBrief || {}, pagesById: {}, // updated below + + curPersonaOptions: discFrameStore.curPersonaOptions, + curDiscProps: discFrameStore.curDiscProps, + indicatedPersona: discFrameStore.indicatedPersona, }); storeClone.curCatsById = groupByKeepOne(storeClone.currentCategories, c => c.id); } @@ -884,7 +906,7 @@ export const Editor = createFactory<any, EditorState>({ // with only the editor). // We'll then clone the parts we need of that other store, and remember // in this.state.inFrameStore. - const discStore: DiscStore = this.getOrCloneDiscStore(inFrame); + const discStore: DiscStore & Origins = this.getOrCloneDiscStore(inFrame); if (inclInReply && postNrs.length) { // This means we've started replying to a post, and then clicked Reply @@ -952,7 +974,7 @@ export const Editor = createFactory<any, EditorState>({ postType = PostType.Flat; } - let inFrameStore: DiscStore | U; + let inFrameStore: (DiscStore & Origins) | U; if (eds.isInEmbeddedEditor && inFrame?.eds) { // [many_embcom_iframes] inFrameStore = discStore; @@ -964,14 +986,6 @@ export const Editor = createFactory<any, EditorState>({ const editorsPageId = discStore.currentPageId || eds.embeddedPageId; - // Annoying! Try to get rid of eds.embeddedPageId? So can remove discStore2. - const discStore2: DiscStore = { ...discStore, currentPageId: editorsPageId }; - - const discProps: DiscPropsDerived = page_deriveLayout( - discStore.currentPage, discStore, LayoutFor.PageNoTweaks); - - const choosenAnon = anon.maybeChooseAnon({ store: discStore2, discProps, postNr }); - const newState: Partial<EditorState> = { inFrame, inFrameStore, @@ -981,9 +995,7 @@ export const Editor = createFactory<any, EditorState>({ // [editorsNewLazyPageRole] = PageRole.EmbeddedComments if eds.isInEmbeddedEditor? replyToPostNrs: postNrs, text: state.text || makeDefaultReplyText(discStore, postNrs), - myAliasOpts: choosenAnon.myAliasOpts, - doAsAnon: choosenAnon.doAsAnon, - discProps, + discProps: discStore.curDiscProps, }; this.showEditor(newState); @@ -996,7 +1008,6 @@ export const Editor = createFactory<any, EditorState>({ const draftType = postType === PostType.BottomComment ? DraftType.ProgressPost : DraftType.Reply; - const draftLocator: DraftLocator = { draftType, pageId: newState.editorsPageId, @@ -1012,7 +1023,6 @@ export const Editor = createFactory<any, EditorState>({ draftLocator.discussionId = eds.embeddedPageAltId; // [draft_diid] } - let writingWhat = WritingWhat.ReplyToNotOriginalPost; if (_.isEqual([BodyNr], postNrs)) writingWhat = WritingWhat.ReplyToOriginalPost; else if (_.isEqual([NoPostId], postNrs)) writingWhat = WritingWhat.ChatComment; @@ -1037,7 +1047,7 @@ export const Editor = createFactory<any, EditorState>({ eds.embeddingUrl = inFrame.eds.embeddingUrl; eds.embeddedPageAltId = inFrame.eds.embeddedPageAltId; delete eds.lazyCreatePageInCatId; // page already exists - const inFrameStore: DiscStore = this.getOrCloneDiscStore(inFrame); + const inFrameStore: DiscStore & Origins = this.getOrCloneDiscStore(inFrame); const newState: Partial<EditorState> = { inFrame, inFrameStore }; this.setState(newState); } @@ -1050,7 +1060,7 @@ export const Editor = createFactory<any, EditorState>({ const draft: Draft | U = response.draft; // In case the draft was created when one wasn't logged in, then, now, set a user id. - const discStore: DiscStore = this.getDiscStore(); + const discStore: DiscStore & Origins = this.getDiscStore(); if (draft && discStore.me) { draft.byUserId = discStore.me.id; } @@ -1059,29 +1069,45 @@ export const Editor = createFactory<any, EditorState>({ // gets a new postNr. Then do what? Show a "this post was moved to: ..." dialog? dieIf(postNr !== response.postNr, 'TyE23GPKG4'); - const editorsDiscStore: DiscStore = { ...discStore, currentPageId: response.pageId }; - const discProps: DiscPropsDerived = page_deriveLayout( - discStore.currentPage, discStore, LayoutFor.PageNoTweaks); - - const choosenAnon = anon.maybeChooseAnon({ store: editorsDiscStore, discProps, postNr }); - - const newState: Partial<EditorState> = { - anyPostType: null, - editorsCategories: discStore.currentCategories, // [many_embcom_iframes] - editorsPageId: response.pageId, - editingPostNr: postNr, - editingPostUid: response.postUid, - editingPostRevisionNr: response.currentRevisionNr, - text: draft ? draft.text : response.currentText, - onDone: onDone, - draftStatus: DraftStatus.NothingHappened, - draft, - doAsAnon: choosenAnon.doAsAnon, - myAliasOpts: choosenAnon.myAliasOpts, - discProps, - }; + // If showing any which-persona message, it should appear close to the edit button + // just clicked. So, find its coordinates. — But this won't work if the editor is in its + // own iframe. Then, we need to load the draft, and get the edit button coordinates, in the + // comments iframe, show the `chooseEditorPersona()` in the comments iframe, and + // pass the result to the editor iframe. [find_persona_diag_atRect] + // Right now, anon blog comments not supported anyway. [anon_blog_comments] + // + let atRect = { top: 100, left: 100, right: 200, bottom: 200 }; + if (!inFrame) { + // Later: Use `cloneEventTargetRect(mouse-click-event)` instead. + const elm: HElm | N = $first(`#post-${postNr} + .esPA .dw-a-edit`); + if (elm) { + atRect = elm.getBoundingClientRect(); + } + else { + // The post just disappeared? D_DIE + // Let's just use the above hardcoded `atRect` for now. + } + } - this.showEditor(newState); + persona.chooseEditorPersona({ store: discStore, postNr, draft, atRect }, doAsOpts => { + const newState: Partial<EditorState> = { + anyPostType: null, + editorsCategories: discStore.currentCategories, // [many_embcom_iframes] + editorsPageId: response.pageId, + editingPostNr: postNr, + editingPostUid: response.postUid, + editingPostRevisionNr: response.currentRevisionNr, + text: draft ? draft.text : response.currentText, + onDone: onDone, + draftStatus: DraftStatus.NothingHappened, + draft, + doAsAnon: doAsOpts.doAsAnon, + myAliasOpts: doAsOpts.myAliasOpts, + discProps: discStore.curDiscProps, + }; + + this.showEditor(newState); + }); }); }, @@ -1119,17 +1145,17 @@ export const Editor = createFactory<any, EditorState>({ const text = state.text || ''; + // Bit dupl code. [_new_disc_props] const futurePage: PageDiscPropsSource = { categoryId, pageRole: newPageRole, }; // Props for the future page, with settings inherited from the ancestor categories. + // (Can't use `store.curDiscProps` — it's for the forum homepage, not the new page.) const discProps: DiscPropsDerived = page_deriveLayout( futurePage, store, LayoutFor.PageNoTweaks); - const choosenAnon = anon.maybeChooseAnon({ store, discProps }); - const newState: Partial<EditorState> = { discProps, anyPostType: null, @@ -1141,8 +1167,6 @@ export const Editor = createFactory<any, EditorState>({ text: text, showSimilarTopics: true, searchResults: null, - doAsAnon: choosenAnon.doAsAnon, - myAliasOpts: choosenAnon.myAliasOpts, }; this.showEditor(newState); @@ -1331,26 +1355,56 @@ export const Editor = createFactory<any, EditorState>({ } } - logD("Setting draft and guidelines: !!anyDraft: " + !!anyDraft + - " !!draft: " + !!draft + - " !!anyGuidelines: " + !!anyGuidelines); - const newState: Partial<EditorState> = { - draft, - draftStatus: DraftStatus.NothingHappened, - text: draft ? draft.text : '', - title: draft ? draft.title : '', - // For now, skip guidelines, for blog comments — they would break e2e tests, - // and maybe are annoying? - guidelines: eds.isInIframe ? undefined : anyGuidelines, - }; - if (draft && draft.doAsAnon) { - // TESTS_MISSING TyTANONDFLOAD - newState.doAsAnon = draft.doAsAnon; - } - this.setState(newState, () => { - this.focusInputFields(); - this.scrollToPreview = true; - this.updatePreviewSoon(); + + // Post anonymously? + // If new forum page, use its props. + const discStore0: DiscStore = inFrameStore || state.store; + const discStore: DiscStore = { ...discStore0, curDiscProps: state.discProps } + + // Open any persona dialog, where? This'll be good enough for now. Later, + // this find-atRect code will be moved to the more-bundle and discussion iframe + // anyway. [find_persona_diag_atRect] + const selector = draftLocator.postNr && !state.inFrame + ? `#post-${draftLocator.postNr} + .esPA .dw-a-reply` + : '.s_E_DoingRow'; + const elm: HElm | N = $first(selector); + const atRect = elm ? elm.getBoundingClientRect() : + { top: 100, left: 100, right: 200, bottom: 200 }; // whatever + + // TESTS_MISSING TyTANONDFLOAD draft + logD("Maybe choosing persona..."); + persona.choosePosterPersona({ me: discStore.me, origins: state.store, discStore, + postNr: draftLocator.postNr, draft, atRect }, + (doAsOpts: DoAsAndOpts | 'CANCEL') => { + + const state: EditorState = this.state; + if (this.isGone || !state.visible) return; + + if (doAsOpts === 'CANCEL') { + this.clearAndCloseFineIfGone({ keepDraft: !!draft, upToDateDraft: draft }); + return; + } + + logD("Setting draft and guidelines: !!anyDraft: " + !!anyDraft + + " !!draft: " + !!draft + + " !!anyGuidelines: " + !!anyGuidelines); + const newState: Partial<EditorState> = { + draft, + draftStatus: DraftStatus.NothingHappened, + text: draft ? draft.text : '', + title: draft ? draft.title : '', + myAliasOpts: doAsOpts.myAliasOpts, + doAsAnon: doAsOpts.doAsAnon, + // For now, skip guidelines, for blog comments — they would break e2e tests, + // and maybe are annoying? + guidelines: eds.isInIframe ? undefined : anyGuidelines, + }; + + this.setState(newState, () => { + this.focusInputFields(); + this.scrollToPreview = true; + this.updatePreviewSoon(); + }); }); }; @@ -1436,6 +1490,21 @@ export const Editor = createFactory<any, EditorState>({ // in a modal dialog instead — guidelines are supposedly fairly important. perhapsShowGuidelineModal: function() { const state: EditorState = this.state; + + // For now: If anon comments, for sensitive discussions (rather than + // temp anon, for ideation), tell the user that anon comments are + // experimental. (Since this code will be removed later, we might as well + // place it here. Works, & it's just for now.) + if (state.doAsAnon && !this._hasShownAnonTips) { + const isTempAnon = state.doAsAnon.anonStatus === AnonStatus.IsAnonCanAutoDeanon; + if (!isTempAnon) { + this._hasShownAnonTips = true; + setTimeout(function() { + debiki2.help.openHelpDialogUnlessHidden(anonExperimentalMsg); + }, 0); + } + } + if (!this.refs.guidelines || state.showGuidelinesInModal) return; @@ -1609,7 +1678,76 @@ export const Editor = createFactory<any, EditorState>({ }, changeCategory: function(categoryId: CategoryId) { - this.setState({ newForumTopicCategoryId: categoryId }); + const state: EditorState = this.state; + const me: Me = state.store.me; + + // ----- Derive disc props + + // Changing category, might change discussion properties, e.g. if anonymity is allowed. + // Bit dupl code. [_new_disc_props] + + const futurePage: PageDiscPropsSource = { + categoryId, + pageRole: state.newPageRole, + }; + + const discProps: DiscPropsDerived = page_deriveLayout( + futurePage, state.store, LayoutFor.PageNoTweaks); + + // ----- Can be anonymous? + + // Could [ask_if_needed] which persona to use, if can't be the same in the new cat? + const newDoAsOpts = persona.choosePosterPersona({ + me, origins: state.store, discStore: { ...state.store, curDiscProps: discProps }}); + + if (!any_isDeepEqIgnUndef(newDoAsOpts.doAsAnon, state.doAsAnon)) { + // Was but won't be anonymous? Then, inform the user – so they won't mistakenly + // post something anonymously, they thought, but appears under their real name. + // If was-using/will-use a pseudonym, need to edit the `msg` below. [pseudonyms_later] + // (`false` means oneself, not anon. Will refactor [oneself_0_false]) + const canBeAnon = newDoAsOpts.myAliasOpts.some(x => x !== false); + const wasAnon = !!state.doAsAnon; // [oneself_0_false] + const willBeAnon = !!newDoAsOpts.doAsAnon; // [oneself_0_false] + const msg = willBeAnon + ? (wasAnon + // Less important, but still good to know: (and happens very rarely) + ? "This category has different anonymity settings" // I18N & just below + : "You are anonymous in this category, by default") + : (canBeAnon + // UX, COULD: Would be good if Anonymous remained as the selected option, + // if the category allows anonymity. + ? "You aren't anonymous by default, in this category" + : "You cannot be anonymous in this category"); + + // Show `msg` in a notification box under the "Create new topic [anonymously v]" + // text & button, but not at the very left edge – add some margin: + const doingRowElm = document.getElementsByClassName('s_E_DoingRow'); + if (doingRowElm.length) { + const atRect = doingRowElm[0].getBoundingClientRect(); + atRect.x = atRect.x + 75; + morekit.openSimpleProxyDiag({ atRect, showCloseButton: false, + // It can be important that people read this text, and don't accidentally + // close the dialog. + closeOnClickOutside: false, + body: r.p({}, msg), + }); + } + } + + // ----- Update state + + const newState: Partial<EditorState> = { + discProps, + newForumTopicCategoryId: categoryId, + doAsAnon: newDoAsOpts.doAsAnon, + myAliasOpts: newDoAsOpts.myAliasOpts, + }; + + // (Currently no need to patch the main store, by calling call onEditorOpen() – the + // EditorStorePatch doesn't need any of the Partial<EditorState> fields above — + // those fields are for the not-yet-existing page, not for the current store.) + + this.setState(newState); }, changeNewForumPageRole: function(pageRole: PageRole) { @@ -1992,7 +2130,7 @@ export const Editor = createFactory<any, EditorState>({ postChatMessage: function() { const state: EditorState = this.state; - // ANON_UNIMPL: send state.doAsAnon, + // [anon_chats]: send state.doAsAnon, ReactActions.insertChatMessage(state.text, state.draft, () => { this.callOnDoneCallback(true); this.clearAndCloseFineIfGone(); @@ -2588,6 +2726,7 @@ export const Editor = createFactory<any, EditorState>({ // By default, anon posts are disabled, and the "post as ..." dropdown left out. + // Break out component? [choose_alias_btn] let maybeAnonymously: RElm | U; if (!me.isAuthenticated) { // Only logged in users can post anonymously. (At least for now.) @@ -2597,14 +2736,14 @@ export const Editor = createFactory<any, EditorState>({ // a draft, as anon, but then an admin changed the settings, so cannot // be anon any more. Then it's nevertheless ok to continue, anonymously. // (That's what "continue" in NeverAlways.NeverButCanContinue means.) - // ANON_UNIMPL, UNPOLITE, SHOULD add some server side check, so no one toggles - // this in the browser only, and the server accepts? [derive_node_props_on_server] - // But pretty harmless. - state.doAsAnon) { + // (There's a server side check [derive_node_props_on_server], in case of + // misbehaving clients, or sbd taking really long until they submit a post, + // and an admin disables anon comments in between.) + state.doAsAnon) { // [oneself_0_false] maybeAnonymously = - Button({ className: 'c_AliasB', ref: 'aliasB', onClick: () => { - const atRect = reactGetRefRect(this.refs.aliasB); - anon.openAnonDropdown({ atRect, open: true, + Button({ className: 'c_AliasB', onClick: (event: MouseEvent) => { + const atRect = cloneEventTargetRect(event); + persona.openAnonDropdown({ atRect, open: true, curAnon: state.doAsAnon, me, myAliasOpts: state.myAliasOpts, discProps: state.discProps, @@ -2617,7 +2756,7 @@ export const Editor = createFactory<any, EditorState>({ this.updatePreviewSoon(); } }); } }, - anon.whichAnon_titleShort(state.doAsAnon, { me }), + persona.whichAnon_titleShort(state.doAsAnon, { me }), ' ', r.span({ className: 'caret' })); } @@ -3075,6 +3214,38 @@ export function DraftStatusInfo(props: { draftStatus: DraftStatus, draftNr: numb } +// Later, when more tested: Remove this message, and show instead a warning/tips +// only to mods & admins, since when they're in Anonymous mode, they can (as of now) +// still see some things only they can see, e.g. unapproved comments. And by e.g. +// approving & replying anonymously to a to others not-visible unapproved comment, +// they might accidentally reveal that their anonymous comments are by a mod or admin. +// (If a comment got approved & visible, and then there's an anonymous reply a second +// later.) [deanon_risk] [mod_deanon_risk] +// +// Even later, an intro guide that explains anon comments? [anon_comts_guide] +// +const anonExperimentalMsg: HelpMessage = { + id: 'TyHANOX1', + version: 1, + isWarning: true, + // Can be important to read this (and not close by mistake). + closeOnClickOutside: false, + // Let's show it many times, until they tick "Hide this tips". + defaultHide: false, + content: rFr({}, + r.h3({} , + "You're anonymous  (hopefully)"), // the space "  " before the '(' is a  . + r.p({}, + "Do ", r.b({}, "not"), " write anything sensitive!"), + r.p({}, + "Anonymous comments are pretty new. There might be bugs"), + r.p({ style: { marginLeft: '2em' }}, + "— including ways to find out who you are."), + r.br(), + r.p({}, + "Anyway.  Look in the upper right corner — you should see the text " + + "\"Anonymous\", if you're in an anonymous section of this forum.")), +}; //------------------------------------------------------------------------------ } diff --git a/client/app-more/editor/title-editor.more.ts b/client/app-more/editor/title-editor.more.ts index 9995fad513..0b634e1e54 100644 --- a/client/app-more/editor/title-editor.more.ts +++ b/client/app-more/editor/title-editor.more.ts @@ -32,9 +32,11 @@ const MaxSlugLength = 100; // sync with Scala [MXPGSLGLN] interface TitleEditorPops { closeEditor: () => V; store: Store; + doAsOpts?: DoAsAndOpts; } interface TitleEditorState { + doAsOpts?: DoAsAndOpts; categoryId: CatId; pageRole: PageRole; editorScriptsLoaded?: Bo; @@ -65,6 +67,7 @@ export const TitleEditor = createComponent({ return { pageRole: page.pageRole, categoryId: page.categoryId, + doAsOpts: _.clone(props.doAsOpts), }; }, @@ -188,6 +191,7 @@ export const TitleEditor = createComponent({ htmlTagCssClasses: state.htmlTagCssClasses, htmlHeadTitle: state.htmlHeadTitle, htmlHeadDescription: state.htmlHeadDescription, + doAsAnon: state.doAsOpts?.doAsAnon, }; return settings; }, @@ -209,6 +213,33 @@ export const TitleEditor = createComponent({ return r.div({ style: { height: 80 }}); } + // Break out component? [choose_alias_btn] + let maybeAnonymously: RElm | U; + if (!me.isAuthenticated || !state.doAsOpts) { + // Only logged in users can post anonymously. (At least for now.) + } + else if (store.curDiscProps?.comtsStartAnon >= NeverAlways.Allowed || + state.doAsOpts.doAsAnon) { // [oneself_0_false] + maybeAnonymously = rFr({}, + r.span({ className: 's_E_DoingWhat' }, "Edit title and page: "), // I18N + Button({ className: 'c_AliasB', onClick: (event: MouseEvent) => { + const atRect = cloneEventTargetRect(event); + persona.openAnonDropdown({ atRect, open: true, + curAnon: state.doAsOpts.doAsAnon, me, + myAliasOpts: state.doAsOpts.myAliasOpts, + discProps: store.curDiscProps, + saveFn: (doAsAnon: MaybeAnon) => { + const newState: Partial<TitleEditorState> = { doAsOpts: { + doAsAnon, + myAliasOpts: state.doAsOpts.myAliasOpts + }}; + this.setState(newState); + } }); + } }, + persona.whichAnon_titleShort(state.doAsOpts.doAsAnon, { me }), + ' ', r.span({ className: 'caret' }))); + } + let layoutAndSettings: RElm | U; if (simpleChanges) { const layoutBtnTitle = r.span({}, @@ -368,7 +399,8 @@ export const TitleEditor = createComponent({ return ( r.div({ className: 'dw-p-ttl-e' }, - Input({ type: 'text', ref: 'titleInput', className: 'dw-i-title', id: 'e2eTitleInput', + maybeAnonymously, + Input({ type: 'text', ref: 'titleInput', className: 'c_TtlE_TtlI', defaultValue: titlePost.unsafeSource, onChange: this.onTitleChanged }), r.div({ className: 'form-horizontal' }, selectCategoryInput), r.div({ className: 'form-horizontal' }, selectTopicType), diff --git a/client/app-more/editor/title-editor.styl b/client/app-more/editor/title-editor.styl index 7afe95cbdd..a4933af637 100644 --- a/client/app-more/editor/title-editor.styl +++ b/client/app-more/editor/title-editor.styl @@ -1,4 +1,7 @@ +.c_AliasB + div .c_TtlE_TtlI + margin-top: 10px; + .esTtlEdtr_urlSettings background: hsl(0, 0%, 99%); border: 1px solid hsl(0, 0%, 92%); diff --git a/client/app-more/help/help-dialog.more.ts b/client/app-more/help/help-dialog.more.ts index 6d2bbb475e..1381633098 100644 --- a/client/app-more/help/help-dialog.more.ts +++ b/client/app-more/help/help-dialog.more.ts @@ -37,6 +37,8 @@ let helpDialog; function getHelpDialog() { if (!helpDialog) { + // Apparently, if you're somewhere in a React componentDidUpdate() handler, then, + // `helpDialog` becomes null. Then you can wrap getHelpDialog() in setTimeout(). helpDialog = ReactDOM.render(HelpDialog(), utils.makeMountNode()); } return helpDialog; @@ -86,6 +88,9 @@ const HelpDialog = createComponent({ onChange: (event) => this.setState({ hideNextTime: event.target.checked }), label: "Hide this tips" }); + const maybeClose = + !message || message.closeOnClickOutside === false ? undefined : this.close; + content = !content ? null : ModalBody({ className: message.className }, r.div({ className: 'esHelpDlg_body_wrap'}, @@ -95,7 +100,7 @@ const HelpDialog = createComponent({ PrimaryButton({ onClick: this.close, className: 'e_HelpOk' }, "Okay")))); return ( - Modal({ show: this.state.isOpen, onHide: this.close, dialogClassName: 'esHelpDlg' }, + Modal({ show: this.state.isOpen, onHide: maybeClose, dialogClassName: 'esHelpDlg' }, content)); } }); diff --git a/client/app-more/login/login-dialog.more.ts b/client/app-more/login/login-dialog.more.ts index df1ef6f704..3c49c48a53 100644 --- a/client/app-more/login/login-dialog.more.ts +++ b/client/app-more/login/login-dialog.more.ts @@ -199,6 +199,7 @@ const LoginDialog = createClassAndFactory({ getSetCookie('dwCoMayCreateUser', null); getSetCookie('dwCoOAuth2State', null); getSetCookie('esCoImp', null); + //getSetCookie('TyCoPersona', null); if (!eds.isInLoginWindow) { // We're in a login popup, not in a dedicated "full screen" login window. diff --git a/client/app-more/more-bundle-already-loaded.d.ts b/client/app-more/more-bundle-already-loaded.d.ts index 8e2b0df73a..5978a9f0fd 100644 --- a/client/app-more/more-bundle-already-loaded.d.ts +++ b/client/app-more/more-bundle-already-loaded.d.ts @@ -32,6 +32,7 @@ declare namespace debiki2 { namespace morekit { function openProxyDiag(ps: ProxyDiagParams, childrenFn: (close: () => V) => RElm); + function openSimpleProxyDiag(ps: ProxyDiagParams & { body: RElm }); } var Expandable; @@ -73,7 +74,7 @@ declare namespace debiki2.pagedialogs { function openAddPeopleDialog(ps: { curPatIds?: PatId[], curPats?: Pat[], mayClear?: Bo, onChanges: (PatsToAddRemove) => Vo }); - function openDeletePostDialog(post: Post, at: Rect); + function openDeletePostDialog(ps: { post: Post, at: Rect, doAsAnon?: MaybeAnon }); function openFlagDialog(postId: PostId, at: Rect); function openMovePostsDialog(store: Store, post: Post, closeCaller, at: Rect); function openSeeWrenchDialog(); @@ -87,18 +88,17 @@ declare namespace debiki2.pagedialogs { function getProgressBarDialog(); } -declare namespace debiki2.anon { - function maybeChooseModAlias(ps: MaybeChooseAnonPs, then?: (res: ChoosenAnon) => V); - function maybeChooseAnon(ps: MaybeChooseAnonPs, then?: (_: ChoosenAnon) => V): ChoosenAnon; - function openAnonDropdown(ps: ChooseAnonDlgPs): V; +declare namespace debiki2.persona { + function chooseEditorPersona(ps: ChooseEditorPersonaPs, then?: (_: DoAsAndOpts) => V): V; + function choosePosterPersona(ps: ChoosePosterPersonaPs, then?: (_: DoAsAndOpts | 'CANCEL') => V) + : DoAsAndOpts; + function openAnonDropdown(ps: ChoosePersonaDlgPs): V; function whichAnon_titleShort(doAsAnon: MaybeAnon, ps: { me: Me, pat?: Pat }): RElm; function whichAnon_title(doAsAnon: MaybeAnon, ps: { me: Me, pat?: Pat }): St | RElm; function whichAnon_descr(doAsAnon: MaybeAnon, ps: { me: Me, pat?: Pat }): St | RElm; -} -declare namespace debiki2 { - function disc_findAnonsToReuse(discStore: DiscStore, ps: { - forWho: Pat | Me | U, startAtPostNr?: PostNr }): MyPatsOnPage; + function openPersonaInfoDiag(ps: { atRect: Rect, isSectionPage: Bo, + me: Me, personaOpts: PersonaOptions, discProps: DiscPropsDerived }): V; } declare namespace debiki2.subcommunities { diff --git a/client/app-more/morekit/proxy-diag.more.ts b/client/app-more/morekit/proxy-diag.more.ts index 804edc2f3b..21acf21e97 100644 --- a/client/app-more/morekit/proxy-diag.more.ts +++ b/client/app-more/morekit/proxy-diag.more.ts @@ -35,6 +35,37 @@ interface ProxyDiagState { let setDropdownStateFromOutside: U | ((_: ProxyDiagState | N) => V); +// Much later, support all StupidDialogStuff, and [replace_stupid_diag_w_simple_proxy_diag]. +// +export function openSimpleProxyDiag(ps: SimpleProxyDiagParams) { + openProxyDiag({ ...ps, flavor: DiagFlavor.Dropdown }, closeDiag => { + + const primaryButton = PrimaryButton({ className: 'e_SPD_OkB', + ref: (e: HElm | N) => e && e.focus(), + onClick: () => { + closeDiag(); + if (ps.onPrimaryClick) ps.onPrimaryClick(); + if (ps.onCloseOk) ps.onCloseOk(1); + }}, + ps.primaryButtonTitle || t.Okay); + + const secondaryButton = !ps.secondaryButonTitle ? null : Button({ + onClick: () => { + closeDiag(); + if (ps.onCloseOk) ps.onCloseOk(2); + }, + className: 'e_SPD_2ndB' }, + ps.secondaryButonTitle); + + return rFr({}, + r.div({ style: { marginBottom: '2em' }}, ps.body), + r.div({ style: { float: 'right' }}, + primaryButton, + secondaryButton)); + }); +} + + export function openProxyDiag(params: ProxyDiagParams, childrenFn: (close: () => V) => RElm) { if (!setDropdownStateFromOutside) { ReactDOM.render(ProxyDiag(), utils.makeMountNode()); @@ -61,8 +92,11 @@ const ProxyDiag = React.createFactory<{}>(function() { const state: ProxyDiagState = diagState; const ps: ProxyDiagParams = state.params; - const close = () => setDiagState(null); const flavorClass = ps.flavor === DiagFlavor.Dropdown ? 'c_PrxyD-Drpd ' : ''; + const close = () => { + setDiagState(null); + if (diagState.params.onHide) diagState.params.onHide(); + }; return utils.DropdownModal({ show: true, @@ -74,6 +108,7 @@ const ProxyDiag = React.createFactory<{}>(function() { className: ps.contentClassName, allowFullWidth: ps.allowFullWidth, showCloseButton: ps.showCloseButton !== false, + closeOnClickOutside: ps.closeOnClickOutside, // bottomCloseButton: not yet impl onContentClick: !ps.closeOnButtonClick ? null : (event: MouseEvent) => { // Don't close if e.g. clicking a <p>, maybe to select text — only if diff --git a/client/app-more/morekit/proxy-diag.styl b/client/app-more/morekit/proxy-diag.styl index 4ac296b491..b87f6740cc 100644 --- a/client/app-more/morekit/proxy-diag.styl +++ b/client/app-more/morekit/proxy-diag.styl @@ -4,6 +4,8 @@ .c_PrxyD .esDropModal_content padding: 30px 20px 20px 20px; + .esDropModal_header + margin: 7px 0 7px 11px; // less margin-top – already 30px padding-top, see above .c_PrxyD-Drpd // The dropdown items have their own padding, so, could use less padding-left, diff --git a/client/app-more/oop.more.ts b/client/app-more/oop.more.ts deleted file mode 100644 index cd6a2b7e41..0000000000 --- a/client/app-more/oop.more.ts +++ /dev/null @@ -1,229 +0,0 @@ -/* - * Copyright (c) 2023 Kaj Magnus Lindberg - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -/// <reference path="more-prelude.more.ts" /> - -//------------------------------------------------------------------------------ - namespace debiki2 { -//------------------------------------------------------------------------------ - - -/// disc_findAnonsToReuse() -/// -/// If pat is posting a reply anonymously, then, if han has posted or voted earlier -/// anonymously on the same page, usually han wants hens new reply, to be -/// by the same anonym, so others see they're talking with the same person -/// (although they don't know who it is, just that it's the same). -/// -/// This fn finds anonyms a pat has used, so the pat can reuse them. First it -/// looks for anonyms-to-reuse in the sub thread where pats reply will appear, -/// thereafter anywhere on the same page. -/// -/// Returns sth like this, where 200 is pat's id, and 2001, 2002, 2003, 2004 are -/// anons pat has used on the current page: (just nice looking numbers) -/// { -/// // Just an example -/// byId: { -/// 2001: patsAnon2001, // = { id: 2001, forPatId: 200, ... } -/// 2002: patsAnon2002, // = { id: 2002, forPatId: 200, ... } -/// 2003: patsAnon2003, // = { id: 2003, forPatId: 200, ... } -/// 2004: patsAnon2004, // = { id: 2004, forPatId: 200, ... } -/// }, -/// sameThread: [ -/// patsAnon2002, // pat's anon (or pat henself) who made pat's last comment -// // in the path from startAtPostNr, back to the orig post. -/// patHenself, // here pat posted using hens real account (not anonymously) -/// patsAnon2001, // pat upvoted a comment using anon 2001, along this path -/// ], -/// outsideThread: [ -/// // If pat has posted earlier in the thread (closer to the orig post), using -/// // any of the above (anon 2002, 2001, or as henself), those comments are -/// // ignored: we don't add an anon more than once to the list.) -/// -/// patsAnon2004, // Pat replied elsewhere on the page using hens anon 2004 -/// patsAnon2003, // ... and before that, han posted as anon 2003, also -/// // elsewhere on the same page. -/// ] -/// } -/// -/// In the above example, patsAnon2003 didn't post anything in the thread from -/// startAtPostNr up to the orig post — but that anon did post something, -/// *elsewhere* in the same discussion. So that anon is still in the list of anons -/// pat might want to use again, on this page. -/// -export function disc_findAnonsToReuse(discStore: DiscStore, ps: { - forWho: Pat | Me | U, startAtPostNr?: PostNr }): MyPatsOnPage { - - const result: MyPatsOnPage = { - sameThread: [], - outsideThread: [], - byId: {}, - }; - - const forWho: Pat | Me | U = ps.forWho; - if (!forWho) - return result; - - const forWhoId: PatId = ps.forWho.id; - const curPage: Page | U = discStore.currentPage; - - if (!forWhoId || !curPage) - return result; - - // ----- Same thread - - // Find out if pat was henself, or was anonymous, in any earlier posts by hen, - // in the path from ps.startAtPostNr and back towards the orig post. - // (patsAnon2002, patHenself, and patsAnon2001 in the example above (i.e. in - // the docs comment to this fn)). - - const startAtPost: Post | U = ps.startAtPostNr && curPage.postsByNr[ps.startAtPostNr]; - const nrsSeen = {}; - let myVotesByPostNr: { [postNr: PostNr]: Vote[] } = {}; - - const isMe = pat_isMe(forWho); - if (isMe) { - myVotesByPostNr = forWho.myDataByPageId[curPage.pageId]?.votesByPostNr || {}; - } - else { - die('TyE0MYVOTS'); // [_must_be_me] - } - - let nextPost: Post | U = startAtPost; - const myAliasesInThread = []; - - for (let i = 0; i < StructsAndAlgs.TooLongPath && nextPost; ++i) { - // Cycle? (Would be a bug somewhere.) - if (nrsSeen[nextPost.nr]) - break; - nrsSeen[nextPost.nr] = true; - - // Bit dupl code: [.find_anons] - - // We might have added this author, already. - if (result.byId[nextPost.authorId]) - continue; - - const author: Pat | U = discStore.usersByIdBrief[nextPost.authorId]; - if (!author) - continue; // would be a bug somewhere, or a rare & harmless race? Oh well. - - const postedAsSelf = author.id === forWhoId; - const postedAnonymously = author.anonForId === forWhoId; - - if (postedAsSelf || postedAnonymously) { - // This places pat's most recently used anons first. - myAliasesInThread.push(author); - result.byId[author.id] = author; - } - else { - // This comment is by someone else. If we've voted anonymously, let's - // continue using the same anonym. Or using our main user account, if we've - // voted not-anonymously. - const votes: Vote[] = myVotesByPostNr[nextPost.nr] || []; - for (const myVote of votes) { - // If myVote.byId is absent, it's our own vote (it's not anonymous). [_must_be_me] - const voterId = myVote.byId || forWho.id; - // Have we added this alias (or our real account) already? - if (result.byId[voterId]) - continue; - const voter: Pat = discStore.usersByIdBrief[voterId]; - myAliasesInThread.push(voter); - result.byId[voter.id] = voter; - } - } - - nextPost = curPage.postsByNr[nextPost.parentNr]; - } - - // ----- Same page - - // If pat posted outside [the thread from the orig post to ps.startAtPostNr], - // then include any anons pat used, so Pat can choose to use those anons, now - // when being active in sub thread startAtPostNr. (See patsAnon2003 and patsAnon2004 - // in this fn's docs above.) - - // Sleeping BUG:, ANON_UNIMPL: What if it's a really big page, and we don't have - // all parts here, client side? Maybe this ought to be done server side instead? - // Or the server could incl all one's anons on the current page, in a list [fetch_alias] - - const myAliasesOutsideThread: Pat[] = []; - - _.forEach(curPage.postsByNr, function(post: Post) { - if (nrsSeen[post.nr]) - return; - - // Bit dupl code: [.find_anons] - - // Each anon pat has used, is to be included at most once. - if (result.byId[post.authorId]) - return; - - const author: Pat | U = discStore.usersByIdBrief[post.authorId]; - if (!author) - return; - - const postedAsSelf = author.id === forWhoId; - const postedAnonymously = author.anonForId === forWhoId; - - if (postedAsSelf || postedAnonymously) { - myAliasesOutsideThread.push(author); - result.byId[author.id] = author; - } - }); - - _.forEach(myVotesByPostNr, function(votes: Vote[], postNrSt: St) { - if (nrsSeen[postNrSt]) - return; - - for (const myVote of votes) { - // The voter is oneself or one's anon or pseudonym. [_must_be_me] - const voterId = myVote.byId || forWho.id; - - if (result.byId[voterId]) - return; - - const voter: Pat | U = discStore.usersByIdBrief[voterId]; // [voter_needed] - if (!voter) - return; - - myAliasesOutsideThread.push(voter); - result.byId[voter.id] = voter; - } - }); - - - // Sort, newest first. Could sort votes by voted-at, not the comment posted-at — but - // doesn't currently matter, not until [many_anons_per_page]. - // Old — now both comments and votes, so won't work: - //myPostsOutsideThread.sort((p: Post) => -p.createdAtMs); - //const myPatsOutside = myPostsOutsideThread.map(p => discStore.usersByIdBrief[p.authorId]); - - // ----- The results - - result.sameThread = myAliasesInThread; - result.outsideThread = myAliasesOutsideThread; - - return result; -} - - - -//------------------------------------------------------------------------------ - } -//------------------------------------------------------------------------------ -// vim: fdm=marker et ts=2 sw=2 tw=0 fo=r list diff --git a/client/app-more/page-dialogs/ChangePageModal.more.ts b/client/app-more/page-dialogs/ChangePageModal.more.ts index a36bab4351..5b0a0d5dae 100644 --- a/client/app-more/page-dialogs/ChangePageModal.more.ts +++ b/client/app-more/page-dialogs/ChangePageModal.more.ts @@ -112,15 +112,19 @@ const ChangePageDialog = createComponent({ const savePage = (changes: EditPageRequestData) => { // E.g. EditPageRequestData.showId and .htmlTagCssClasses can be edited by admins only, // and should be grayed out if one is in Anon Mode for example? [alias_mode] - anon.maybeChooseModAlias({ store, atRect: state.atRect }, (choices: ChoosenAnon) => { + // (A [pick_persona_click_handler] could save a few lines of code?) + persona.chooseEditorPersona({ store, atRect: state.atRect, isInstantAction: true }, + (choices: DoAsAndOpts) => { ReactActions.editTitleAndSettings({ ...changes, doAsAnon: choices.doAsAnon }, this.close, null); }); } const togglePageClosed = () => { - anon.maybeChooseModAlias({ store, atRect: state.atRect }, (choices: ChoosenAnon) => { - ReactActions.togglePageClosed(choices.doAsAnon, this.close); + persona.chooseEditorPersona({ store, atRect: state.atRect, isInstantAction: true }, + (choices: DoAsAndOpts) => { + const pageId = Server.getPageId(); + ReactActions.togglePageClosed({ pageId, doAsAnon: choices.doAsAnon }, this.close); }); }; @@ -294,23 +298,14 @@ const ChangePageDialog = createComponent({ deletePageListItem = rFr({}, r.div({ className: 's_ExplDrp_Ttl' }, "Delete page" + '?'), // I18N r.div({ className: 's_ExplDrp_ActIt' }, - Button({ className: 'e_DelPgB', - onClick: () => { - // ANON_UNIMPL Do as (mod) alias? - ReactActions.deletePages([page.pageId], this.close); - } }, - "Delete"))); // I18N + DeletePageBtn({ pageIds: [page.pageId], store, close: this.close }))); } else if (store_canUndeletePage(store)) { // [.store_or_state_pg] undeletePageListItem = rFr({}, r.div({ className: 's_ExplDrp_Ttl' }, "Undelete page" + '?'), // I18N r.div({ className: 's_ExplDrp_ActIt' }, - Button({ className: 'e_UndelPgB', - onClick: () => { - // ANON_UNIMPL Do as (mod) alias? - ReactActions.undeletePages([page.pageId], this.close); - } }, - "Undelete"))); // I18N + DeletePageBtn({ pageIds: [store.currentPageId], store, + undel: true, close: this.close }))); } } diff --git a/client/app-more/page-dialogs/about-user-dialog.more.ts b/client/app-more/page-dialogs/about-user-dialog.more.ts index 2a29a021f4..bd89400379 100644 --- a/client/app-more/page-dialogs/about-user-dialog.more.ts +++ b/client/app-more/page-dialogs/about-user-dialog.more.ts @@ -333,7 +333,7 @@ const AboutAnon = React.createFactory<AboutAnonymProps>(function(props) { r.div({ className: 'dw-about-user-actions' }, LinkButton({ href: linkToUserProfilePage(anon) }, t.aud.ViewComments)), r.p({}, - t.Anonym))); + anonStatus_toStr(anon.anonStatus)))); }); diff --git a/client/app-more/page-dialogs/choose-author-owner.more.ts b/client/app-more/page-dialogs/choose-author-owner.more.ts index c76ab0098f..da4ab0e504 100644 --- a/client/app-more/page-dialogs/choose-author-owner.more.ts +++ b/client/app-more/page-dialogs/choose-author-owner.more.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Kaj Magnus Lindberg + * Copyright (c) 2024 Kaj Magnus Lindberg * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -16,9 +16,10 @@ */ /// <reference path="../more-prelude.more.ts" /> +/// <reference path="../morekit/proxy-diag.more.ts" /> //------------------------------------------------------------------------------ - namespace debiki2.anon { + namespace debiki2.persona { //------------------------------------------------------------------------------ const r = ReactDOMFactories; @@ -26,160 +27,379 @@ const DropdownModal = utils.DropdownModal; const ExplainingListItem = util.ExplainingListItem; -export function maybeChooseModAlias(ps: MaybeChooseAnonPs, then?: (res: ChoosenAnon) => V) { +/// Figures out, or asks, what persona to use when editing a post. [choose_persona] +/// +/// Ensures pat will continue using the same persona, when editing a post, as when +/// creating it. E.g. if posting a question, and later on, accepting an answer. +/// +/// If pat were to use a different persona when editing a post of hans, +/// others might guess that the two personas (pat, and hans alias) +/// are in fact the same (sine they were able to edit the same thing). [deanon_risk] +/// (E.g. pat creates a page anonymously, and then edits it as hanself, +/// without being a moderator. This function makes sure han will instead +/// reuse the anonym, when editing the page.) +/// +/// If a moderator edits someone else's page, then, if han uses a persona +/// (e.g. a pseudonym), others might guess that that persona is a moderator +/// (since that persona was able to edit other's posts). +/// And that's no good. So, for now, one can't use an alias when altering +/// someone else's page — one has to do that as oneself. [deanon_risk] +/// Later on, if a moderator really wants to alter sbd elses page using +/// an alias, maybe that should by default be a different alias, e.g. +/// a new anonym, than what they use if they're part of the discussion +/// on the page and trying to be anonymous. [anon_mods] +/// +/// If trying to do sth not-allowed, shows an error dialog and won't call `then`. +/// +export function chooseEditorPersona(ps: ChooseEditorPersonaPs, then?: (res: DoAsAndOpts) => V) { + + // TESTS_MISSING TyTALIALTERPG + const page: Page = ps.store.currentPage; dieIf(!page, 'TyE56032MWJ'); const post = page.postsByNr[ps.postNr || BodyNr]; const author = ps.store.usersByIdBrief[post.authorId]; const me: Me = ps.store.me; + const dp: DiscPropsDerived | U = ps.store.curDiscProps; + + const pseudonymsAllowed = false; // [pseudonyms_later] + const anonsAllowed = dp && dp.comtsStartAnon >= NeverAlways.Allowed; + const mustBeAnon = dp && dp.comtsStartAnon >= NeverAlways.AlwaysButCanContinue; + + const switchedToPseudonym = false; // [pseudonyms_later] + const switchedToAnonym = me.usePersona && !!me.usePersona.anonStatus; + const switchedToSelf = me.usePersona && me.usePersona.self; + const postedAsSelf = author.id === me.id; const postedAsAlias = author.anonForId === me.id; - // ANON_UNIMPL Might want to do anonymously, or using a pseudonym, regardless - // of history. [alias_mode] if (!postedAsSelf && !postedAsAlias) { // Ask the server for any existing anon, or create a new one? - // But for now, continue as oneself (don't use an alias) — only works if is - // mod or admin, otherwise the server will return a permission error. [anon_mods] - then({ doAsAnon: false, myAliasOpts: [false] }); + // For now, continue as oneself (don't use an alias) — only works if is + // pat has `mayEditPage` or `mayEditComment` or `mayEditWiki` permissions, + // otherwise the server will return a permission error. Later, could create + // a new anon for any moderator actions (so others can't know that some anonymous + // comments in the discussion, are by a moderator). [anon_mods] + + if (ps.draft) { + if (!ps.draft.doAsAnon) { // [oneself_0_false] + // Pat has already started editing as hanself — don't pop up any dialog again. + editAsSelf(); + return; + } + else { + // Pat started editing using an alias, but that's not supported / allowed. + // Continue below: Show the info message, and edit as hanself. + } + } + + // COULD allow if is wiki [alias_ed_wiki] [alias_0_ed_others] + const errMsg: St | N = !mustBeAnon ? null : + "You cannot edit other people's posts here. This is an anonymous-" + + "only secttion, but you cannot edit others' posts anonymously, as of now."; + if (errMsg) { + morekit.openSimpleProxyDiag({ atRect: ps.atRect, body: rFr({}, errMsg) }); + return; + } + + const infoMsg: St | N = + switchedToAnonym ? "You cannot edit other people's posts anonymously" : ( + switchedToPseudonym ? "You cannot edit other people's posts under " + + "a pseudonymous name" : ( + // For clarity, if it's possible to be anonymous, then require that pat has + // explicitly chooses to post as hanself. + // UX COULD [alias_ux] If pat has commented or done sth on/with the + // page as hanself, then, don't ask, just continue as pat hanself. + // Look in `store.curPersonaOptions.optsList[0]` — if it's pat hanself + // (not an alias), and it's also the best guess, the that's enough + // (set infoMsg to null). + (anonsAllowed || pseudonymsAllowed) && !switchedToSelf ? + "You cannot edit other people's posts anonymously" : ( + // Need not show any message (continue with `editAsSelf()` below). + null))); + if (infoMsg) { + // If it's a one-click thing, like accepting an answer, "Edit post as" is confusing? + // There's no editor involved. "Do as: ..." is better, right. + const editAs = ps.isInstantAction ? "Do as" : "Edit post as"; // I18N + const body = rFr({}, + r.p({}, `${editAs} yourself, ${pat_name(me)}?`), // I18N + infoMsg); + morekit.openSimpleProxyDiag({ atRect: ps.atRect, body, + primaryButtonTitle: "Yes, do as me", // I18N + secondaryButonTitle: t.Cancel, + onPrimaryClick: () => { + editAsSelf(); + }}); + return; + } + + function editAsSelf() { + // _Only_one_item (`false`) in the list — must edit as oneself. + then({ doAsAnon: false, myAliasOpts: [false] }); // [oneself_0_false] + } + + editAsSelf(); } else { - // [deanon_risk] Might not want to do mod-only things using an anonym that has - // also posted comments (so won't show that that anon is a mod). - const anyAlias = postedAsSelf - ? false - : { sameAnonId: author.id, anonStatus: author.anonStatus } as SameAnon; - - then({ doAsAnon: anyAlias, myAliasOpts: [anyAlias] }) + // Continue using the same persona, as when creating the page. (See the descr + // of this fn.) + const anyAlias: MaybeAnon = postedAsSelf + ? false // [oneself_0_false] + : { sameAnonId: author.id, anonStatus: author.anonStatus } satisfies SameAnon; + + const modeAuthorMismatch = + postedAsSelf && switchedToAnonym || postedAsAlias && switchedToSelf; + + // If draft author != post author, pat needs to continue editing as the *post author*. + // (If using the draft author persona, others might guess that that one, and the + // post author, are the same. For example, if you posted as Anonym A (post author), + // then somehow managed to save a draft with yourself as author, then, when you resume + // the draft, you'll be editing as Anon A again, so others can't see that both + // you and Anon A can edit the same post.) [true_0_ed_alias] [alias_0_ed_others] + if (ps.draft && isVal(ps.draft.doAsAnon) && ps.draft.doAsAnon !== anyAlias // [oneself_0_false] + || modeAuthorMismatch) { + // TESTS_MISSING TyTDRAFTALI + // [pseudonyms_later] + const edit = ps.isInstantAction ? "do" : "edit"; + const asWho = // I18N and below + anyAlias === false ? // [oneself_0_false] + rFr({}, r.b({}, "yourself"), " (not anonymously)") : ( + anyAlias.anonStatus ? + r.b({}, anonStatus_toStr(anyAlias.anonStatus, Verbosity.Full)) : + "unknown [TyEUNKALI]"); // D_DIE + // [close_cross_css] + const body = r.p({ style: { marginRight: '30px' }}, + `You will ${edit} as `, asWho); + const asMeOrAnon = anyAlias === false ? "as me" : "anonymously"; + + morekit.openSimpleProxyDiag({ atRect: ps.atRect, body, + primaryButtonTitle: `Ok, I'll ${edit} ${asMeOrAnon}`, + secondaryButonTitle: t.Cancel, + onPrimaryClick: () => { + then({ doAsAnon: anyAlias, myAliasOpts: [anyAlias] }); + }}); + } + else { + // _Only_one_item (`anyAlias`) in the list. + then({ doAsAnon: anyAlias, myAliasOpts: [anyAlias] }); + } } } -/// Figures out which alias to use (if any), when replying, voting, editing one's -/// page, etc. Uses the same anon as most recently in the same sub thread, if any, -/// otherwise the same as elsewhere on the page. ANON_UNIMPL: But if a moderator does sth -/// only mods may do, creates a new anon for moderator actions (to not show that -/// any anon used for comments, is a moderator). [anon_mods] -/// [alias_mode] If one is in e.g. Anon Mode, does things anonymously if allowed, -/// or pops up a dialog and says it's not allowed here (e.g. this category). +/// Figures out, or pops up a dialog and asks, which alias to use (if any), when +/// posting comments or pages, or voting ("posting" a vote). [choose_persona] /// -/// Later: if `then` specified, might ask the server to suggest which alias to -/// use and/or pop up a dialog. Otherwise, suggests something directly -/// (which can be good, if opening the editor — there's an alias dropdown there, -/// so can change later, no need for another dialog). +/// Prefers the same alias as most recently in the same sub thread, if any, +/// Then the same as elsewhere on the page. (See findPersonaOptions().) +/// Then any persona mode alias [alias_mode], then any per category default persona, +/// e.g. anonymous in anon-by-default cats. /// -export function maybeChooseAnon(ps: MaybeChooseAnonPs, then?: (_: ChoosenAnon) => V) - : ChoosenAnon { - - const discStore: DiscStore = ps.store; - const postNr: PostNr | U = ps.postNr; - const discProps: DiscPropsDerived = ps.discProps || page_deriveLayout( - discStore.currentPage, discStore, LayoutFor.PageNoTweaks); +/// Later: Might ask the server to suggest which alias to use [fetch_alias]. +/// +export function choosePosterPersona(ps: ChoosePosterPersonaPs, + then?: (_: DoAsAndOpts | 'CANCEL') => V) + : DoAsAndOpts { - const myAliasesHere: MyPatsOnPage | U = postNr && disc_findAnonsToReuse(discStore, { - forWho: discStore.me, startAtPostNr: postNr }); + // @ifdef DEBUG + dieIf(then && !ps.atRect, "Wouldn't know where to open dialog [TyE70WJE35]"); + // @endif - const anonsAllowed = discProps.comtsStartAnon >= NeverAlways.Allowed; - const anonsRecommended = discProps.comtsStartAnon >= NeverAlways.Recommended; + // ----- Derive persona options list - // When someone starts posting on the relevant page, must they be anonymous? - const mustBeAnon = discProps.comtsStartAnon >= NeverAlways.AlwaysButCanContinue; + // (We can't use `DiscStore.curPersonaOptions`: We want persona options for the + // comments thread ending at `ps.postNr`, but `curPersonaOptions` is for the whole + // page, no specific sub thread.) - // It is ok to *continue* posting using one's real account, on - // this page, if comments-start-anon is Always-**But-Can-Continue** posting - // using one's real name. - const canContinueAsSelf = - discProps.comtsStartAnon <= NeverAlways.AlwaysButCanContinue; + const myPersonasThisPage = disc_findMyPersonas( + ps.discStore, { forWho: ps.me, startAtPostNr: ps.postNr }); + const personaOpts: PersonaOptions = findPersonaOptions({ + myPersonasThisPage, me: ps.me, discProps: ps.discStore.curDiscProps }); - // @ifdef DEBUG - dieIf(mustBeAnon, "Unimpl: mustBeAnon [TyE602MKG1]"); - dieIf(!canContinueAsSelf, "Unimpl: !canContinueAsSelf [TyE602MKG2]"); - dieIf(anonsAllowed && discProps.newAnonStatus === AnonStatus.NotAnon, "TyE6@NJ04"); - // @endif + const res: DoAsAndOpts = { + doAsAnon: personaOpts ? personaOpts.optsList[0].doAs : false, // [oneself_0_false] + myAliasOpts: personaOpts ? personaOpts.optsList.map(a => a.doAs) : [false], + } - if (myAliasesHere && - !myAliasesHere.sameThread.length && - myAliasesHere.outsideThread.length >= 2) { - // UX: ask which alias to use? [choose_alias] unless it's clear from any - // current [alias_mode] which one to use. ANON_UNIMPL - // Pat has commented or voted using two or more different aliases, or hans real - // account and an alias, on this page, but not in this thread. — Then, hard to know - // which alias han wants to continue using. - // If asking, and pat wants to use hans real name, maybe show an extra - // "Use your real name? (That is, @your_username) [yes / no]" dialog? - // - // For now, default to doing things anonymously if pat has been anon - // anywhere on this page. - // So: noop() here, for now. + if (ps.draft && isVal(ps.draft.doAsAnon)) { + addDraftAuthor_inPl(ps.draft, res); + personaOpts.isAmbiguous = false; // will use the author of the draft } - // The user accounts the current has used on this page — first looking higher up in - // the same sub thread, then looking at the whole page. - const patsByThreadThenLatest = !myAliasesHere ? [] : - [...myAliasesHere.sameThread, ...myAliasesHere.outsideThread]; - - // Only looking at the same sub thread (parent comment, grandparent etc). - const lastPatSameThread: Pat | U = myAliasesHere?.sameThread[0]; - - const lastAnonPat: Pat | U = patsByThreadThenLatest.find(p => p.isAnon); - const lastAnon: WhichAnon | N = !lastAnonPat ? null : - { sameAnonId: lastAnonPat.id, anonStatus: lastAnonPat.anonStatus } as SameAnon; - - const doAsAnon: WhichAnon | false = !patsByThreadThenLatest.length - ? ( - // We haven't posted or voted on this page before. - // Create a new anonym, iff recommended. - anonsRecommended - ? { newAnonStatus: discProps.newAnonStatus } as NewAnon - : false // don't do as anon, use real account - ) - : ( - // We have posted on this page before. But in the same sub thread? - lastPatSameThread - ? ( - // Continue using our earlier account, from the same sub thread. - // (Either an anonym, or ourself.) - lastPatSameThread.isAnon ? lastAnon : false) - : - // If we've posted or commented anonymously anywhere else - // on the page, continue anonymously. - // Otherwise, continue using our real name (since we've used our - // real name previously, on this page). - // Here, could make sense to explicitly [choose_alias] or derive - // based on the [alias_mode]. - lastAnon || false - ); - - // Even if we'll by default continue as ourself (!doAsAnon), we might need an anonym - // to show in the ChooseAnonModal dropdown. - const anyAnon = doAsAnon || lastAnon || - anonsAllowed && ({ newAnonStatus: discProps.newAnonStatus } as NewAnon); - - // Distant future: [pseudonyms_later] - // const anyPseudonyms: WhichPseudonym[] = ... - // and also a way to: - // openCreatePseudonymsDialog(..) ? - // — there could be a Create Pseudonym button? - - const res: ChoosenAnon = { - doAsAnon, - myAliasOpts: anyAnon - ? [false, anyAnon] // options are: ourself, or the anonym `anyAnon` - : [false], // option is: we can only be ourself - }; - - if (then) { + // ----- Choose persona + + // If we're unsure which alias (if any) the user wants to use, we'll open a dialog + // and ask. But if the caller didn't pass any `then()` fn, then, return the + // result immediately. + + if (!then) + return res; + + if (personaOpts.isAmbiguous) { + openChoosePersonaDiag({ atRect: ps.atRect, + personaOpts, me: ps.me, origins: ps.origins, + }, then); + } + else { then(res); } - return res; } -let setStateExtFn: (_: ChooseAnonDlgPs) => Vo; +/// A next-to-the-button-clicked dialog that asks the user which alias to use, +/// if it's unclear. E.g. han has switched to Anonmous mode, but has commented +/// on the page as hanself already. When posting another comment, does +/// han want to be anonymous (because of Anonymous mode), or continue commenting +/// as hanself? We'd better ask, not guess. +/// +function openChoosePersonaDiag(ps: { atRect: Rect, + personaOpts: PersonaOptions, me: Me, origins: Origins, }, + then?: (_: (DoAsAndOpts | 'CANCEL')) => V) { + morekit.openProxyDiag({ atRect: ps.atRect, flavor: DiagFlavor.Dialog, + onHide: () => { if (then) then('CANCEL'); }, dialogClassName: 'c_' }, + (closeDiag: () => V) => { + + const opts = ps.personaOpts.optsList.map(a => a.doAs); + + function closeAndThen(doAsAnon: MaybeAnon) { + closeDiag(); + then({ doAsAnon, myAliasOpts: opts }); + } + + let debugJson = null; + // @ifdef DEBUG + //debugJson = r.pre({}, JSON.stringify(ps, undefined, 3)); + // @endif + + // (This list might include more than one type of anonymous user, say, 1) temporarily + // anonymous and 2) permanently anonymous. This can happen if a user U replies as + // perm anon on a page in a perm anon category. But then an admin moves the page to + // a temp anon category. Now, user U replies again, but chooses to be temp anon, this + // time (which is allowed in this different category). [move_anon_page] [dif_anon_status]) + return rFr({}, + r.div({ className: 'esDropModal_header' }, + `Do as ...`), + r.ol({}, + ps.personaOpts.optsList.map((opt: PersonaOption) => { + const avatarElm = avatar.Avatar({ + user: opt.alias, origins: ps.origins, ignoreClicks: true, + size: AvatarSize.Small }); + return ExplainingListItem({ + key: opt.alias.id, + title: pat_name(opt.alias), + img: avatarElm, + text: ambiguityToDescr(opt, ps.me), + tabIndex: 100, + onClick: () => closeAndThen(opt.doAs), + }); + })), + debugJson); + }); +} + + +function ambiguityToDescr(opt: PersonaOption, me: Me): RElm { + // UX SHOULD require 2 clicks to choose? At different coordinates. [deanon_risk] [mouse_slip] + const isMe = opt.alias.id === me.id; + const selfOrAnon = isMe ? "Yourself" : anonStatus_toStr( // [pseudonyms_later] I18N + opt.alias.anonStatus, Verbosity.Full); + const selfOrAnonLower = selfOrAnon.toLowerCase(); + + const part1 = + opt.inSameThread ? + // "Earlier", but not necessarily "above" (as in higher up on the page). Depends + // on the sort order. + r.span({}, `You are ${selfOrAnonLower} earlier in this thread`) : ( + opt.onSamePage ? + r.span({}, `You are ${selfOrAnonLower} elsewhere on this page`) : ( + null)); + + let part2 = part1 ? '.' : ''; + if (opt.isFromMode) { + if (!part1) part2 = `You're in ${selfOrAnon} mode.`; + else part2 = `, and you're in ${selfOrAnon} mode.`; + } + + // Skip this — "You're recommended ..." sounds a bit paternalistic? + // (Would make sense if [the *recommended* persona] being different from e.g. [any previously + // *used* persona] made the choose-persona dialog to appear (`PersonaOptions.isAmbiguous`) + // — but that's no longer the case.) + //if (opt.isRecommended) { + // part3 = ` You're recommended to be ${selfOrAnon} here.`; + //} + + const part4 = part2 || !isMe ? '' : "Yourself"; + + const part5 = opt.isNotAllowed ? + ` However, you cannot be ${selfOrAnonLower} here.` : ''; + + return rFr({}, part1, part2, part4, part5); +} + + +function addDraftAuthor_inPl(draft: Draft, doAsOpts: DoAsAndOpts) { + dieIf(!isVal(draft.doAsAnon), 'TyE3076MSRDw') // [oneself_0_false] + + // Let's add the persona pat has already choosen as the author, to doAsOpts, if missing. + // (Not impossible the server will refuse to save the post —  maybe anonymous + // comments have been disabled, for example. Then, the user can choose another persona, + // and try again.) + let optFound: MaybeAnon | U; + for (let opt of doAsOpts.myAliasOpts) { + if (opt === false || draft.doAsAnon === false) { // false is oneself [oneself_0_false] + if (opt === draft.doAsAnon) { + optFound = opt; + break; + } + } + else { + // We know it's WhichAnon, since is not oneself (tested above). [pseudonyms_later] + const optAlias = opt as WhichAnon; + const draftAlias = draft.doAsAnon as WhichAnon; + if (optAlias.sameAnonId || draftAlias.sameAnonId) { + if (optAlias.sameAnonId === draftAlias.sameAnonId) { + optFound = opt; + break; + } + } + else if (optAlias.anonStatus || draftAlias.anonStatus) { + if (optAlias.anonStatus === draftAlias.anonStatus) { + // Must be `WhichAnon.lazyCreate` — `createNew_tst` hasn't been implemented. + optFound = opt; + break; + } + } + else { + // Can't happen, until later when implementing pseudonyms. + } + } + } + + // If [the alias pat is using as author of the draft] isn't among the current options, + // add it. + if (!isVal(optFound)) { // [oneself_0_false] + doAsOpts.myAliasOpts.push(draft.doAsAnon); + optFound = draft.doAsAnon; + } -export function openAnonDropdown(ps: ChooseAnonDlgPs) { + // Continue using the same author, for this draft, as before. + // (`optFound` also includes any anon status, so use it. But `draft.doAsAnon` + // might not incl the anon status — it'd be better if it did, see [chk_alias_status].) + doAsOpts.doAsAnon = optFound; +} + + + +// ---------------------------------------------------------------------------- +// REFACTOR: +// The rest of this file, should be in its own file? ChoosePersonaDropdown.ts? + + +let setStateExtFn: (_: ChoosePersonaDlgPs) => V; + +export function openAnonDropdown(ps: ChoosePersonaDlgPs) { if (!setStateExtFn) { ReactDOM.render(ChooseAnonModal(), utils.makeMountNode()); // or [use_portal] ? } @@ -187,6 +407,7 @@ export function openAnonDropdown(ps: ChooseAnonDlgPs) { } + /// Some dupl code? [6KUW24] but this with React hooks. /// /// Or use instead: client/app-more/page-dialogs/add-remove-people-dialogs.more.ts ? @@ -200,7 +421,7 @@ const ChooseAnonModal = React.createFactory<{}>(function() { // TESTS_MISSING - const [state, setState] = React.useState<ChooseAnonDlgPs | N>(null); + const [state, setState] = React.useState<ChoosePersonaDlgPs | N>(null); setStateExtFn = setState; @@ -223,7 +444,8 @@ const ChooseAnonModal = React.createFactory<{}>(function() { const active = // `whichAnon` and `state.curAnon` can be false, or a WhichAnon object. any_isDeepEqIgnUndef(whichAnon, state.curAnon); - const status = whichAnon && (whichAnon.anonStatus || whichAnon.newAnonStatus); + // [oneself_0_false] `&&` won't work with `{ self: true }`. + const status = whichAnon && whichAnon.anonStatus; return ( ExplainingListItem({ title, text, active, key: whichAnon ? whichAnon.sameAnonId || 'new' : 'self', @@ -239,7 +461,7 @@ const ChooseAnonModal = React.createFactory<{}>(function() { })); } - items = state.myAliasOpts.map(makeItem); + items = state.myAliasOpts.map(makeItem);// [ali_opts_only_needed_here] } return ( @@ -277,7 +499,8 @@ const enum TitleDescr { function whichAnon_titleDescrImpl(doAs: MaybeAnon, ps: { me: Me, pat?: Pat }, // I18N what: TitleDescr): St | RElm { - const anonStatus = doAs ? doAs.anonStatus || doAs.newAnonStatus : AnonStatus.NotAnon; + // [oneself_0_false] `?` won't work with `{ self: true }`. + const anonStatus = doAs ? doAs.anonStatus : AnonStatus.NotAnon; // UX SHOULD if doAs.sameAnonId, then, show which anon (one might have > 1 on the // same page) pat will continue posting as / using. // But not a hurry? Right now one cannot have more than one anon per diff --git a/client/app-more/page-dialogs/delete-post-dialog.more.ts b/client/app-more/page-dialogs/delete-post-dialog.more.ts index 8a163e87f4..c6ee542124 100644 --- a/client/app-more/page-dialogs/delete-post-dialog.more.ts +++ b/client/app-more/page-dialogs/delete-post-dialog.more.ts @@ -31,11 +31,11 @@ const ModalFooter = rb.ModalFooter; let deletePostDialog; -export function openDeletePostDialog(post: Post, at: Rect) { +export function openDeletePostDialog(ps: { post: Post, at: Rect, doAsAnon?: MaybeAnon }) { if (!deletePostDialog) { deletePostDialog = ReactDOM.render(DeletePostDialog(), utils.makeMountNode()); } - deletePostDialog.open(post, at); + deletePostDialog.open(ps); } @@ -48,11 +48,12 @@ const DeletePostDialog = createComponent({ }; }, - open: function(post: Post, at: Rect) { + open: function(ps: { post: Post, at: Rect, doAsAnon?: MaybeAnon }) { this.setState({ isOpen: true, - post: post, - atRect: at, + post: ps.post, + atRect: ps.at, + doAsAnon: ps.doAsAnon, windowWidth: window.innerWidth, }); }, @@ -62,7 +63,7 @@ const DeletePostDialog = createComponent({ }, doDelete: function() { - ReactActions.deletePost(this.state.post.nr, this._delRepls, this.close); + ReactActions.deletePost(this.state.post.nr, this._delRepls, this.state.doAsAnon, this.close); }, render: function () { diff --git a/client/app-more/page-tools/page-tools.more.ts b/client/app-more/page-tools/page-tools.more.ts index d613fedf1c..7e5463455e 100644 --- a/client/app-more/page-tools/page-tools.more.ts +++ b/client/app-more/page-tools/page-tools.more.ts @@ -66,16 +66,6 @@ const PageToolsDialog = createComponent({ ReactActions.unpinPage(this.close); }, - deletePage: function() { - const store: Store = this.state.store; - ReactActions.deletePages([store.currentPageId], this.close); - }, - - undeletePage: function() { - const store: Store = this.state.store; - ReactActions.undeletePages([store.currentPageId], this.close); - }, - render: function () { const store: Store = this.state.store; const me: Myself = store.me; @@ -88,8 +78,8 @@ const PageToolsDialog = createComponent({ //let selectPostsButton = !store_canSelectPosts(store) ? null : //Button({ onClick: this.selectPosts }, "Select posts"); - let pinPageButton; - let pinPageDialog; + let pinPageButton: RElm | U; + let pinPageDialog: RElm | U; if (store_canPinPage(store)) { pinPageDialog = PinPageDialog(_.assign({ ref: 'pinPageDialog' }, childProps)); pinPageButton = @@ -101,10 +91,12 @@ const PageToolsDialog = createComponent({ Button({ onClick: this.unpinPage, className: 'e_UnpinPg' }, "Unpin Topic"); const deletePageButton = !store_canDeletePage(store) ? null : - Button({ onClick: this.deletePage, className: 'e_DelPg' }, "Delete Topic"); + DeletePageBtn({ pageIds: [store.currentPageId], store, + verb: Verbosity.Full, close: this.close }); const undeletePageButton = !store_canUndeletePage(store) ? null : - Button({ onClick: this.undeletePage, className: 'e_RstrPg' }, "Restore Topic"); + DeletePageBtn({ pageIds: [store.currentPageId], store, undel: true, + verb: Verbosity.Full, close: this.close }); const idsAndUrlsButton = page.pageRole !== PageRole.EmbeddedComments || !me.isAdmin ? null : Button({ onClick: () => openPageIdsUrlsDialog(page.pageId), className: 'e_PgIdsUrls' }, diff --git a/client/app-more/persona/persona-info-diag.ts b/client/app-more/persona/persona-info-diag.ts new file mode 100644 index 0000000000..628868bef2 --- /dev/null +++ b/client/app-more/persona/persona-info-diag.ts @@ -0,0 +1,297 @@ +/* + * Copyright (c) 2024 Kaj Magnus Lindberg + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/// <reference path="../../macros/macros.d.ts" /> +/// <reference path="../more-prelude.more.ts" /> + +//------------------------------------------------------------------------------ + namespace debiki2.persona { +//------------------------------------------------------------------------------ + +const r = ReactDOMFactories; + + +export function openPersonaInfoDiag(ps: { atRect: Rect, isSectionPage: Bo, + me: Me, personaOpts: PersonaOptions, discProps: DiscPropsDerived }): V { + + const discProps: DiscPropsDerived = ps.discProps; + const me: Me = ps.me; + + // This'll be page_users3.prefer_alias_id_c once implemented. + //const thisPagePrefAlias: KnownAnonym | Me | N = null; + + //const pseudonymsRecommended = false; [pseudonyms_later] + const anonsAllowed = discProps.comtsStartAnon >= NeverAlways.Allowed; + const anonsRecommended = discProps.comtsStartAnon >= NeverAlways.Recommended; + const mustBeAnon = discProps.comtsStartAnon >= NeverAlways.AlwaysButCanContinue; + const switchedToPseudonym = false; + const switchedToAnonStatus: AnonStatus | U = me.usePersona && me.usePersona.anonStatus; + const switchedToSelf = me.usePersona && me.usePersona.self; + + morekit.openProxyDiag({ atRect: ps.atRect, flavor: DiagFlavor.Dialog, + dialogClassName: 'e_PersInfD' }, (closeDiag: () => V) => { + + const inThisPlace = ps.isSectionPage ? "in this category" : "on this page"; + const thereforeIllAsk = "Therefore, I'll ask you if you want to be yourself, " + + "or be anonymous, if you post something here."; + + // Either: "But you have also posted as ..." + // Or: "You have posted both as ... and as ..." + const hasBeenBoth = ps.personaOpts.hasBeenAnon && ps.personaOpts.hasBeenSelf; + const butYou = hasBeenBoth ? "You" : "But you"; + const also = hasBeenBoth ? '' : "also "; + const both = hasBeenBoth ? "both " : ''; + + let whatMode: St | U; + let content: RElm; + let enterSelfModeBtn: RElm | U; + let enterAnonModeBtn: RElm | U; + let okPersona = true; + + if (switchedToSelf) { // [alias_mode] + whatMode = "Yourself"; // I18N, this whole fn + + const youreInSelfMode = rFr({}, "You're in ", r.b({}, "Yourself mode")); + const colonPostingAsSelf = ": You're posting and voting as yourself."; + + const ambigMsg = !ps.personaOpts.hasBeenAnon ? null : rFr({}, + r.p({}, + `${butYou} have ${also}posted ${both}anonymously${ + both ? ", and as yourself," : ''} ${inThisPlace}.`), + r.p({}, + thereforeIllAsk)); + + if (mustBeAnon && ps.personaOpts.hasBeenSelf) { + // 1: _May_not_but_can_continue + // One can continue as oneself, if one has commented as oneself already before + // this page/category became anonymous-only. + content = rFr({}, + r.p({}, youreInSelfMode, '.'), + r.p({}, + `Normally, you cannot post as yourself ${inThisPlace}, ` + + "but you have done that already; therefore you can continue as yourself.")); + } + else if (mustBeAnon) { + // 2: _May_not + okPersona = false; + content = r.p({}, + youreInSelfMode, ". But you ", r.b({}, "cannot"), + " post as yourself here — everyone needs to be anonymous."); + } + else if (anonsAllowed) { // [pseudonyms_later] + // 3: _Can_choose + content = rFr({}, + r.p({}, youreInSelfMode, colonPostingAsSelf), + ambigMsg || r.p("You can be anonymous, though, if you want.")); + } + else { + // 4: _Everyone_is + content = rFr({}, + r.p({}, youreInSelfMode, colonPostingAsSelf), + ambigMsg || + // Maybe a bit interesting that Yourself mode isn't needed, here: + // (But one can continue posting anonymously if one has done that already, + // on the current page.) + r.p({}, "(On this page, you cannot be anonymous anyway.)"), + ); + } + } + else if (switchedToAnonStatus) { + whatMode = anonStatus_toStr(switchedToAnonStatus); + const youreInAnonMode = rFr({}, "You're in ", r.b({}, whatMode + " mode")); + const colonPostingAnonly = ": You're posting and voting anonymously."; + const ambigMsg = !ps.personaOpts.hasBeenSelf ? null : rFr({}, + r.p({}, + `${butYou} have ${also}posted ${both}as yourself${ + both ? ", and anonymously," : ''} ${inThisPlace}.`), + r.p({}, + thereforeIllAsk)); + + // (Maybe not the same anon status. [dif_anon_status]) + if (!anonsAllowed && ps.personaOpts.hasBeenAnon) { + // 1: _May_not_but_can_continue + // One can continue anonymously if one has been anonymous on this page already, + // some time ago when that was allowed (or if the page got moved to another category?). + content = rFr({}, + r.p({}, youreInAnonMode, '.', + r.p({}, + `Normally, you cannot post anonymously ${inThisPlace}, ` + + `but you have done that already; therefore you can continue.`))); + } + else if (!anonsAllowed) { + // 2: _May_not + okPersona = false; + content = r.p({}, + youreInAnonMode, ". But you ", r.b({}, "cannot"), " be anonymous here."); + } + else if (!mustBeAnon) { // [pseudonyms_later] + // 3: _Can_choose + // (Maybe not the same type of anonyms? [dif_anon_status]) + content = rFr({}, + r.p({}, youreInAnonMode, colonPostingAnonly), + ambigMsg || (!anonsRecommended ? '' : + r.p({}, ` Everyone is anonymous ${inThisPlace} by default.`))); + } + else { + // 4: _Everyone_is + // (Maybe not the same type of anonyms? [dif_anon_status]) + content = rFr({}, + r.p({}, youreInAnonMode, colonPostingAnonly), + ambigMsg || + r.p({}, `Everyone is anonymous ${inThisPlace} anyway.`)); + } + } + else if (switchedToPseudonym) { + + // @ifdef DEBUG + die('TyE206MFKG'); // [pseudonyms_later] + // @endif + void 0; + /* + whatMode = "Pseudonymous Mode as user (S) Some Psuedonym"; + if (mustBeAnon || !pseudonymsAllowed) { + okAlias = false; + content = rFr({}, "You're in pseudonymous mode, but you cannot use pseudonyms here" + + (mustBeAnon ? " — everyone must be anonymous." : '.')); + } + else { + content = r.span({}, "You're using a pseudonym, (P) some_pseudonym, which will " + + "be used if you post topics or comments, or upvote anything." + + (!ambiguity ? '' : "However, you have also posted anonymously here, " + + "and I'll need to ask you if you want to use the pseudonym, or " + + "continue anonymously.")); + } + */ + } + else { + const whenYouBlaBla = "when you post comments or upvote others."; + if (ps.personaOpts.hasBeenSelf && ps.personaOpts.hasBeenAnon) { + content = rFr({}, + r.p({}, "You have been both anonymous and yourself on this page. "), + r.p({}, thereforeIllAsk)); + } + else if (ps.personaOpts.hasBeenSelf) { + content = rFr({}, + r.p({}, + "You have been yourself on this page, so, you'll continue " + + "being yourself, " + whenYouBlaBla), + !anonsAllowed ? null : r.p({}, + "You can be anonymous instead, if you want.")); + // There'll be an [Enter Anonymous mode] button below (if allowed here) + // – that's clear enough? (No need for "Click ... below if ...".) + } + else if (ps.personaOpts.hasBeenAnon) { + // COULD: If has been both temp anon and permanently anon, say sth like: + // "You have been both temporarily and permanently anonymous on this page." + // + thereforeIllAsk. [dif_anon_status] + content = rFr({}, + r.p({}, + "You have been anonymous on this page, so, you'll continue " + + "being anonymous, " + whenYouBlaBla), + mustBeAnon ? null: r.p({}, + "You can post as yourself instead, if you want.")); + // There'll be an [Enter Yourself mode] button below (if allowed). + } + else if (anonsRecommended) { + // Say "temporarily anonymous" if temp anon. [dif_anon_status] + content = r.p({}, + `You and others are anonymous by default, ${inThisPlace}.`); + } /* + else if (pseudonymsRecommended) { + // [pseudonyms_later] + // How's this going to work? Can't create pseudonyms automatically – pat needs + // to choose a pseudonym name, hmm. Maybe a pop-up question like: + // "Create a pseudonym? If you don't want to use your real name, ... [Yes] [No]" + } */ + else if (anonsAllowed) { + content = rFr({}, + r.p({}, + // Say "temporarily anonymous" if temp anon. [dif_anon_status] + `You can post anonymously here, if you want.`), + r.p({}, + "By default, your posting as yourself though.")); + } + else { + // Dead code? The personas info button shouldn't appear, if one cannot + // be and hasn't been anonymous. + content = r.p({}, + `You're yourself — you cannot post anonymously here.`); + } + + if (!mustBeAnon) enterSelfModeBtn = + mkBtn(r.span({}, + "Enter ", r.u({}, "Yourself"), " mode"), + { self: true } satisfies Oneself); + + if (anonsAllowed) enterAnonModeBtn = + mkBtn(r.span({}, + "Enter ", r.u({}, anonStatus_toStr(discProps.newAnonStatus)), " mode"), + { anonStatus: discProps.newAnonStatus, + lazyCreate: true } satisfies LazyCreatedAnon); + + // [pseudonyms_later] More buttons: + // [ Switch to (P) pseudonym_of_yours ] + // [ Switch to (S) some_other_pseudonym] + // [ Create Pseudonym ] + } + + /* Sometimes incl cur page aliases in content? But when? Maybe if clicking a [More v] + // button, then, could show a list of all one's aliases on the current page, in the + // contextbar — there's already a "Users on this page" list there, see [users_here], + // and filtering out only one's own personas can make sense? + if (me.myAliasCurPage.length >= 1) { + const user = me.myAliasCurPage.length === 1 ? "user " : "users "; + content = rFr({}, + "You are anonymous " + user, + rFr({}, + me.myAliasCurPage.map((alias: KnownAnonym) => + rFr({}, + avatar.Avatar({ key: alias.id, user: alias, origins: store }), + r.span({ className: '' }, alias.username || alias.fullName)))), + "on this page." + everyoneIs); + } */ + + const leaveModeBtn = !whatMode ? null : + mkBtn(r.span({}, "Leave ", r.u({}, whatMode), " mode"), null); + + function mkBtn(title: St | RElm, usePersona: Oneself | LazyCreatedAnon | N): RElm { + return Button({ onClick: () => { + ReactActions.patchTheStore({ me: { usePersona } }); + closeDiag(); + }}, title); + } + + return rFr({}, + // Better without any header. The mode is in bold in the first sentence already, + // feels like enough. + // r.div({ className: 'esDropModal_header' }, !whatMode ? '' : whatMode + " Mode"), + r.div({}, content), + r.div({ className: 'c_PersInfD_Bs' }, + leaveModeBtn, + enterSelfModeBtn, + enterAnonModeBtn), + ); + }); +} + + + + +//------------------------------------------------------------------------------ + } +//------------------------------------------------------------------------------ +// vim: fdm=marker et ts=2 sw=2 tw=0 fo=tcqwn list diff --git a/client/app-more/users/users-page.more.ts b/client/app-more/users/users-page.more.ts index 7fdbf78ea1..9b7b4a5080 100644 --- a/client/app-more/users/users-page.more.ts +++ b/client/app-more/users/users-page.more.ts @@ -455,7 +455,7 @@ const PatTopPanel = createComponent({ let isWhatInfo: St | N = null; if (user.isAnon) { - isWhatInfo = t.Anonym || "Anonym"; + isWhatInfo = anonStatus_toStr(user.anonStatus); } else if (isGuest(user)) { isWhatInfo = t.upp.isGuest; diff --git a/client/app-more/util/stupid-dialog.more.ts b/client/app-more/util/stupid-dialog.more.ts index 84a58de414..6de8f9b599 100644 --- a/client/app-more/util/stupid-dialog.more.ts +++ b/client/app-more/util/stupid-dialog.more.ts @@ -64,6 +64,7 @@ export function openDefaultStupidDialog(stuff: StupidDialogStuff) { } +// Much later: Remove, and [replace_stupid_diag_w_simple_proxy_diag]. export const StupidDialog = createComponent({ displayName: 'StupidDialog', diff --git a/client/app-more/widgets.more.ts b/client/app-more/widgets.more.ts index ab0daa1f61..937f1ca9f6 100644 --- a/client/app-more/widgets.more.ts +++ b/client/app-more/widgets.more.ts @@ -94,7 +94,7 @@ export const GroupList = function(member: UserDetailsStatsGroups, groupsMaySee: export const Expandable = ( props: { header: any, onHeaderClick: any, isOpen?: boolean, className?: string, openButtonId?: string }, - ...children) => { + ...children): RElm => { let body = !props.isOpen ? null : r.div({ className: 's_Expandable_Body' }, children); @@ -115,6 +115,27 @@ export const Expandable = ( }; + +// Wouldn't it be better with a [pick_persona_click_handler]? +export function DeletePageBtn(ps: { pageIds: PageId[], store: Store, undel?: true, + verb?: Verbosity, close: () => V }): RElm { + + const page = ps.verb > Verbosity.Full ? " page" : ''; + const title = (ps.undel ? "Undelete" : "Delete") + page; // I18N + + return Button({ className: ps.undel ? 'e_UndelPgB' : 'e_DelPgB', + onClick: (event: MouseEvent) => { + const atRect = cloneEventTargetRect(event); + persona.chooseEditorPersona({ store: ps.store, atRect, + isInstantAction: true }, (doAsOpts: DoAsAndOpts) => { + const delOrUndel = ps.undel ? ReactActions.undeletePages : ReactActions.deletePages; + delOrUndel({ + pageIds: ps.pageIds, doAsAnon: doAsOpts.doAsAnon }, ps.close); + }); + } }, + title); +} + //------------------------------------------------------------------------------ } //------------------------------------------------------------------------------ diff --git a/client/app-more/widgets/anon-purpose-btn.more.ts b/client/app-more/widgets/anon-purpose-btn.more.ts index 3fd5abd626..8a7f5c4076 100644 --- a/client/app-more/widgets/anon-purpose-btn.more.ts +++ b/client/app-more/widgets/anon-purpose-btn.more.ts @@ -72,7 +72,7 @@ function openAnonPurposeDiag(ps: DiscLayoutDiagState) { dialogClassName: 'e_AnonPurpD' }, (closeDiag) => { - const layout: DiscPropsSource | NU = ps.layout; + const layout: DiscPropsSource = ps.layout; let diagTitle: St | RElm | U; let sensitiveItem: RElm | U; @@ -86,7 +86,7 @@ function openAnonPurposeDiag(ps: DiscLayoutDiagState) { function makeItem(itemValue: AnonStatus, e2eClass: St): RElm { let active: Bo; let title: St | RElm; - active = itemValue === ps.layout.newAnonStatus; + active = itemValue === layout.newAnonStatus; title = anonPurpose_title(itemValue); return ExplainingListItem({ diff --git a/client/app-slim/ReactActions.ts b/client/app-slim/ReactActions.ts index 2bee3712c1..0cc5af0a32 100644 --- a/client/app-slim/ReactActions.ts +++ b/client/app-slim/ReactActions.ts @@ -262,31 +262,31 @@ export function unpinPage(success: () => void) { } -export function deletePages(pageIds: PageId[], success: () => void) { - Server.deletePages(pageIds, () => { - success(); +export function deletePages(ps: { pageIds: PageId[], doAsAnon: MaybeAnon }, onOk: () => V) { + Server.deletePages(ps, () => { + onOk(); // CLEAN_UP REFACTOR use patchTheStore( StorePatch.deletedPageIds ) instead ReactDispatcher.handleViewAction({ actionType: actionTypes.DeletePages, - pageIds: pageIds, + pageIds: ps.pageIds, }); }); } -export function undeletePages(pageIds: PageId[], success: () => void) { - Server.undeletePages(pageIds, () => { - success(); +export function undeletePages(ps: { pageIds: PageId[], doAsAnon: MaybeAnon }, onOk: () => V) { + Server.undeletePages(ps, () => { + onOk(); ReactDispatcher.handleViewAction({ actionType: actionTypes.UndeletePages, - pageIds: pageIds, + pageIds: ps.pageIds, }); }); } -export function togglePageClosed(doAsAnon: MaybeAnon, onDone?: () => V) { - Server.togglePageClosed(doAsAnon, (closedAtMs) => { +export function togglePageClosed(ps: { pageId: PageId, doAsAnon: MaybeAnon }, onDone?: () => V) { + Server.togglePageClosed(ps, (closedAtMs) => { ReactDispatcher.handleViewAction({ actionType: actionTypes.TogglePageClosed, closedAtMs: closedAtMs @@ -298,19 +298,19 @@ export function togglePageClosed(doAsAnon: MaybeAnon, onDone?: () => V) { } -export function acceptAnswer(postId: number, doAsAnon: MaybeAnon) { - Server.acceptAnswer(postId, doAsAnon, (answeredAtMs) => { +export function acceptAnswer(ps: { pageId: PageId, postId: PostId, doAsAnon: MaybeAnon }) { + Server.acceptAnswer(ps, (answeredAtMs) => { ReactDispatcher.handleViewAction({ actionType: actionTypes.AcceptAnswer, answeredAtMs: answeredAtMs, - answerPostUniqueId: postId, + answerPostUniqueId: ps.postId, }); }); } -export function unacceptAnswer(doAsAnon: MaybeAnon) { - Server.unacceptAnswer(doAsAnon, () => { +export function unacceptAnswer(ps: { pageId: PageId, doAsAnon: MaybeAnon }) { + Server.unacceptAnswer(ps, () => { unacceptAnswerClientSideOnly(); }); } @@ -392,9 +392,10 @@ export function setPostHidden(postNr: number, hide: boolean, success?: () => voi } -export function deletePost(postNr: number, repliesToo: boolean, success: () => void) { - Server.deletePostInPage(postNr, repliesToo, (response: { deletedPost, answerGotDeleted }) => { - success(); +export function deletePost(postNr: PostNr, repliesToo: Bo, + doAsAnon: MaybeAnon | U, onOk: () => V) { + Server.deletePostInPage(postNr, repliesToo, doAsAnon, (response: { deletedPost, answerGotDeleted }) => { + onOk(); ReactDispatcher.handleViewAction({ actionType: actionTypes.UpdatePost, post: response.deletedPost @@ -997,6 +998,8 @@ function markAnyNotificationAsSeen(postNr: number) { } +// A separate function here, so accessible also via embedded iframes (rather than +// inlined in editor.editor.ts) export function onEditorOpen(ps: EditorStorePatch, onDone?: () => void) { if (eds.isInEmbeddedEditor) { sendToCommentsIframe(['onEditorOpen', ps]); @@ -1578,6 +1581,18 @@ function loadAndShowNewPage(newUrlPath: St, history: ReactRouterHistory) { const pats = _.values(newStore.usersByIdBrief); const pubCats = newStore.publicCategories; + // COULD_OPTIMIZE If listing recently active topics, they're already + // included in the page json: + // - newStore.topics, + // - newStore.me.restrictedTopics + // - newStore.me.restrictedTopicsUsers + // Add them to the store, as is done here: [add_restr_topics], then, can skip + // a request to the server to list recent topics. + // + // Currently we do add `newStore.me.restrictedCategories` (see + // store_addRestrictedCurCatsInPl()) – otherwise access restricted pages + // wouldn't render (would be category missing errors). + // This'll trigger ReactStore onChange() event; everything will redraw to show the new page. showNewPage({ newPage, diff --git a/client/app-slim/ReactStore.ts b/client/app-slim/ReactStore.ts index 6a1778cc0d..13e099bb0d 100644 --- a/client/app-slim/ReactStore.ts +++ b/client/app-slim/ReactStore.ts @@ -59,6 +59,16 @@ const storeEventListeners: StoreEventListener[] = []; // Read-only hooks based store state. WOULD REFACTOR make it read-write and delete ReactActions, // and remove EventEmitter too? [4WG20ABG2] Have all React code use `useStoreState` // instead of that old "flux" stuff. +// +// This is similar to useReducer() and accessing the state via useContext() — see: +// https://react.dev/learn/scaling-up-with-reducer-and-context, +// `useStoreState()` corresponds to `useTasks()` in their example. +// Hmm, they've separated only-*using*-the-state and *changing* the state into +// `useTasks()` and `useTasksDispatch()`. Then it could be nice if `useStoreState()` +// also was only for *using* the state — which is how it already works actually, +// I'll just need to remove the can't-use-anyway `setState()` return value. +// And a new hook function, `useStoreActions()` for *doing* things? +// export function useStoreState(): [Store, () => void] { const [state, setState] = React.useState<Store>(store); @@ -779,6 +789,8 @@ ReactStore.activateMyself = function(anyNewMe: Myself | NU, stuffForMe?: StuffFo // Show the user's own unapproved posts, or all, for admins. store_addAnonsAndUnapprovedPosts(store, myPageData); // TyTE2E603SKD + // Add any topics not included in the cached json, because they're access + // restricted (but the current user can see them). [add_restr_topics] if (_.isArray(store.topics)) { const currentPage: Page = store.currentPage; store.topics = store.topics.concat(store.me.restrictedTopics); @@ -827,6 +839,22 @@ ReactStore.activateMyself = function(anyNewMe: Myself | NU, stuffForMe?: StuffFo }, 0); } + // ----- Personas [update_personas] + + // Later, if remembering any persona mode across page reloads, and different + // browser tabs: [remember_persona_mode] + // const asPersona = Server.getPersonaCookie(); + // if (asPersona) { + // store.me.usePersona = asPersona; + // } + + // Remember which aliases pat has used when posting or voting on the current page, + // so we can know who pat wants to be now, or if we need to ask. + // Do here, after `me.usePersona`, unapproved posts and access restricted cats inited. + store_updatePersonaOpts(store); + + // ----- Websocket + debiki2.pubsub.subscribeToServerEvents(store.me); store.quickUpdate = false; }; @@ -1091,6 +1119,8 @@ function updatePost(post: Post, pageId: PageId, isCollapsing?: boolean) { // Add or update the post itself. page.postsByNr[post.nr] = post; + // Should remember any new persona, if new post? Or earlier, when adding pat? [update_personas] + const layout: DiscPropsDerived = page_deriveLayout(page, store, LayoutFor.PageWithTweaks); // In case this is a new post, update its parent's child id list. @@ -1161,7 +1191,7 @@ function voteOnPost(action: { doWhat: 'DeleteVote' | 'CreateVote', voteType: PostVoteType, postNr: PostNr, - voter: Pat}) { + voter: Pat}) { // CLEAN_UP: redundant, already in the storePatch.yourAnon, or is `me`. const postNr: PostNr = action.postNr; @@ -1174,14 +1204,32 @@ function voteOnPost(action: { } if (action.doWhat === 'CreateVote') { + const voter: Pat = action.voter; votes.push({ // The voter might be the current user's anonym, so we need the voter id. - byId: action.voter.id, + byId: voter.id, type: action.voteType, }); + + // The voter might be a just-now lazy-created anonym – then remember it. [lazy_anon_voter] + if (voter.isAnon && voter.anonForId) { + // @ifdef DEBUG + dieIf(!voter.anonStatus, 'TyE206MLP4'); + // Page id currently not added server side, so skip: [see_own_alias] + // dieIf(voter.anonOnPageId !== myPageData.pageId, 'TyE206MLP5'); + // @endif + + const isNewAnon = !myPageData.knownAnons.find(a => a.id === voter.id); + if (isNewAnon) { + myPageData.knownAnons.push(voter as KnownAnonym); + } + } } else { _.remove(votes, v => v.type === action.voteType); + + // (We could update myPageData.knownAnons, if this vote was the only thing + // the anon has done on the page. But not really needed.) } // If this vote is the user's first action on the page, and han is voting @@ -1725,6 +1773,7 @@ function patchTheStore(respWithStorePatch: any) { // REFACTOR just call directl if (storePatch.me) { // [redux] modifying the store in place, again. + let personaBefore = store.me.usePersona; let patchedMe: Myself | U; if (eds.isInIframe) { // Don't forget [data about pat] loaded by other frames. [mny_ifr_pat_dta] @@ -1732,6 +1781,7 @@ function patchTheStore(respWithStorePatch: any) { // REFACTOR just call directl const sessWin = getMainWin(); const sessStore: SessWinStore = sessWin.theStore; if (_.isObject(sessStore.me)) { + personaBefore ||= sessStore.me.usePersona; patchedMe = me_merge(sessStore.me, store.me, storePatch.me); // [emb_ifr_shortcuts] sessStore.me = _.cloneDeep(patchedMe); } @@ -1744,6 +1794,12 @@ function patchTheStore(respWithStorePatch: any) { // REFACTOR just call directl patchedMe = _.assign(store.me || {} as Myself, storePatch.me); } store.me = patchedMe; + + // Maybe later: [remember_persona_mode] + // const personaAfter = patchedMe.usePersona; + // if (!any_isDeepEqIgnUndef(personaBefore, personaAfter)) { + // Server.setPersonaCookie(personaAfter); + // } } // ----- Drafts @@ -1797,6 +1853,11 @@ function patchTheStore(respWithStorePatch: any) { // REFACTOR just call directl addBackRecentTopicsInPl(oldCurCats, store.curCatsById); } + // If we're on a forum homepage (or other topic index page), and selected a different + // forum category to list topics in. + if (storePatch.listingCatId) + store.listingCatId = storePatch.listingCatId; + // ----- Tag types // @ifdef DEBUG @@ -1864,6 +1925,9 @@ function patchTheStore(respWithStorePatch: any) { // REFACTOR just call directl dieIf(store.currentPageId !== currentPage.pageId, 'EdE7GBW2'); currentPage.pageId = storePatch.newlyCreatedPageId; store.currentPageId = storePatch.newlyCreatedPageId; + // This not needed though – we didn't jump to a different page; we just got an + // id for the current page, which didn't exist but now does. + //store.currentPage = currentPage; // not needed // This'll make the page indexed by both EmptyPageId and newlyCreatedPageId: // (Could remove the NoPageId key? But might cause some bug?) @@ -1936,16 +2000,16 @@ function patchTheStore(respWithStorePatch: any) { // REFACTOR just call directl store_relayoutPageInPlace(store, currentPage, layoutAfter); } + // ----- Posts, new or edited? + // Update the current page. if (!storePatch.pageVersionsByPageId) { // No page. Currently storePatch.usersBrief is for the current page (but there is none) // so ignore it too. - return; } - - // ----- Posts, new or edited? - - _.each(store.pagesById, patchPage); + else { + _.each(store.pagesById, patchPage); + } function patchPage(page: Page) { const storePatchPageVersion = storePatch.pageVersionsByPageId[page.pageId]; @@ -1979,6 +2043,10 @@ function patchTheStore(respWithStorePatch: any) { // REFACTOR just call directl Server.markCurrentPageAsSeen(); } } + + // [update_personas], here after both comments & the user's persona mode + // have been patched. + store_updatePersonaOpts(store); } @@ -2033,9 +2101,16 @@ function showNewPage(ps: ShowNewPageParams) { delete store.curPageTweaks; + if (isSection(newPage)) { + store.listingCatId = newPage.categoryId; + } + else { + delete store.listingCatId; + } + // Forget any topics from the original page load. Maybe we're now in a different sub community, // or some new topics have been created. Better reload. - store.topics = null; + delete store.topics; let myData: MyPageData; if (ps.me) { @@ -2071,6 +2146,8 @@ function showNewPage(ps: ShowNewPageParams) { const oldCurCats = store.currentCategories; store_initCurCatsFromPubCats(store); + // Could add access restricted topics & users too [add_restr_topics], + // iff we're listing the most recently active topics. if (ps.me) { store_addRestrictedCurCatsInPl(store, ps.me.restrictedCategories); // Not needed? But anyway, less confusing if is updated too: @@ -2171,6 +2248,12 @@ function showNewPage(ps: ShowNewPageParams) { // Like: addLocalStorageDataTo(me, isNewPage = true); addMyDraftPosts(store, store.me.myCurrentPageData); + // ----- Personas [update_personas] + + // Remember which aliases pat has used when posting or voting on the current page, + // so we can know who pat wants to be now, or if we need to ask. + store_updatePersonaOpts(store); + // ----- Misc & "hacks" // Make Back button work properly. @@ -2198,6 +2281,96 @@ function showNewPage(ps: ShowNewPageParams) { } +/// REFACTOR: Split this fn into two: store_updateDiscProps(), and store_updatePersonaOpts()? +/// The former makes sense also if not logged in; the latter does not (in effect, a "big noop"). +/// +function store_updatePersonaOpts(store: Store) { + if (!store.userSpecificDataAdded) + return; + + const me = store.me; + + // Later: The server incls this in the show-page response? [fetch_alias] + const myPersonasThisPage: MyPersonasThisPage = + disc_findMyPersonas(store, { forWho: me }); + + // Use which category properties? + // If we're on a forum homepage, and viewing topics in an anonymous-by-default + // category, we'd want the persona options to include posting-anonymously, + // so we need that category's properties. + const isSectionPage = !store.currentPage ? false : isSection(store.currentPage.pageRole); + const listingCat = store.listingCatId && store.currentCategories.find( + c => c.id === store.listingCatId); + const discProps: DiscPropsDerived | U = isSectionPage + ? (listingCat + ? // We're on a forum homepage, looking at a specific category to see + // topics in that cat. + cat_deriveLayout(listingCat, store, LayoutFor.PageNoTweaks) + : // If we don't have access to the category (then, `listingCat` missing). + // Or on initial page load, until startStuff() more done. + // Or if on All Cats page: [site_disc_props] + undefined) + : (store.currentPage + ? page_deriveLayout(store.currentPage, store, LayoutFor.PageNoTweaks) + : // [pseudonyms_later] Let people switch to a pseudonym also when they + // post direct messages? Look at a whole-site-default setting, to know + // if that should be allowed or not? [site_disc_props] + // (Since then they're on a user profile page, which + // isn't a discussion page so `currentPage` is absent.) + undefined); // if no page + + const anonsRecommended = discProps && discProps.comtsStartAnon >= NeverAlways.Recommended; + + const switchedToPseudonym = false; // [pseudonyms_later] + const switchedToAnonStatus: AnonStatus | NU = me.usePersona && me.usePersona.anonStatus; + const switchedToSelf = me.usePersona && (me.usePersona as Oneself).self; + + const curPersonaOptions = + findPersonaOptions({ myPersonasThisPage, me, discProps }); + + const numOpts = curPersonaOptions.optsList.length; + const firstOpt: PersonaOption = curPersonaOptions.optsList[0]; + + let debug = false; + // @ifdef DEBUG + debug = true; + dieIf(numOpts < 1, 'TyEPSLS2M63'); + // @endif + + const mode: PersonaMode | U = + !me.id ? undefined : ( + switchedToSelf ? { self: true } satisfies PersonaMode : ( + switchedToAnonStatus ? { anonStatus: switchedToAnonStatus } satisfies PersonaMode : ( + switchedToPseudonym ? die('TyE206MFKG') as any : ( + // Anon or self, if we've been that before on this page? + firstOpt.alias.isAnon ? + { anonStatus: firstOpt.alias.anonStatus } satisfies PersonaMode : ( + firstOpt.isSelf ? ( + // If can't use, hasn't used, and hasn't switched to any alias, + // then, don't show any persona info – it's more likely off-topic + // and confusing. + numOpts <= 1 ? undefined : { self: true } satisfies PersonaMode) : ( + anonsRecommended ? { anonStatus: discProps.newAnonStatus } satisfies PersonaMode : + // Dead code? Either `isSelf` or `isAnon` above should be tue. + (debug ? die(`TyE206SWl4`) : undefined) + // Could show "Self" if anons allowed, but not recommended? + // anonsAllowed ? { autoSelf: true } satisfies PersonaMode : undefined + )))))); + + store.me.myCurrentPageData.myPersonas = myPersonasThisPage; + store.curDiscProps = discProps; + + if (mode) { + store.curPersonaOptions = curPersonaOptions; + store.indicatedPersona = mode; + } + else { + delete store.curPersonaOptions; + delete store.indicatedPersona; + } +} + + function watchbar_markAsUnread(watchbar: Watchbar, pageId: PageId) { watchbar_markReadUnread(watchbar, pageId, false); } @@ -2288,6 +2461,7 @@ function watchbar_copyUnreadStatusFromTo(old: Watchbar, newWatchbar: Watchbar) { function makeStranger(store: Store): Myself { const stranger = { dbgSrc: '5BRCW27', + id: Pats.NoPatId, isStranger: true, trustLevel: TrustLevel.Stranger, threatLevel: ThreatLevel.HopefullySafe, diff --git a/client/app-slim/Server.ts b/client/app-slim/Server.ts index ab7ed88806..8fd51148b3 100644 --- a/client/app-slim/Server.ts +++ b/client/app-slim/Server.ts @@ -28,14 +28,34 @@ namespace debiki2.Server { //------------------------------------------------------------------------------ -const d: any = { i: debiki.internal }; - const BadNameOrPasswordErrorCode = '_TyE403BPWD'; const NoPasswordErrorCode = '_TyMCHOOSEPWD'; const XsrfTokenHeaderName = 'X-XSRF-TOKEN'; // CLEAN_UP rename to X-Ty-Xsrf-Token const SessionIdHeaderName = 'X-Ty-Sid'; const AvoidCookiesHeaderName = 'X-Ty-Avoid-Cookies'; +//const PersonaCookieName = "TyCoPersona"; +const PersonaHeaderName = 'X-Ty-Persona'; + + +// Mayble later. [remember_persona_mode] +/* +export function setpersonacookie(asPersona: Oneself | LazyCreatedAnon | N) { + // Or remember in local storage instead? + getSetCookie(PersonaCookieName, asPersona ? JSON.stringify(asPersona) : null); +} + +export function getPersonaCookie(): Oneself | LazyCreatedAnon | NU { + const cookieVal: St | N = getCookie(PersonaCookieName); + if (!cookieVal) return undefined; + const persona = JSON.parse(cookieVal); + // @ifdef DEBUG + dieIf(!(persona as Oneself).self && !(persona as LazyCreatedAnon).anonStatus, + `Bad persona cookie value: [[ ${cookieVal} ]] [TyE320JVMR4]`); + // @endif + return persona; +} +*/ export function getPageId(): PageId | U { // move elsewhere? return !isNoPage(eds.embeddedPageId) ? eds.embeddedPageId : // [4HKW28] @@ -754,6 +774,11 @@ export function addAnyNoCookieHeaders(headers: { [headerName: string]: St }) { const typs: PageSession = mainWin.typs; const currentPageXsrfToken: St | U = typs.xsrfTokenIfNoCookies; const currentPageSid: St | U = typs.weakSessionId; + const asPersona: WhichPersona | NU = mainWin.theStore && mainWin.theStore.me.usePersona; + const curPersonaOptions: PersonaOptions | U = + mainWin.theStore && mainWin.theStore.curPersonaOptions; + const indicatedPersona: PersonaMode | U = + mainWin.theStore && mainWin.theStore.indicatedPersona; if (!win_canUseCookies(mainWin)) { headers[AvoidCookiesHeaderName] = 'Avoid'; @@ -766,9 +791,61 @@ export function addAnyNoCookieHeaders(headers: { [headerName: string]: St }) { if (currentPageSid) { headers[SessionIdHeaderName] = currentPageSid; } + + // ----- Persona mode? + + // Any Persona Mode is included in each request, so a cookie would have been a good idea + // — but cookies are often blocked, nowadays, if in an iframe (embedded comments). So, + // let's use a header instead. + const personaHeaderVal: St | U = + // If the user has choosen to use a persona, e.g. a pseudonym. + asPersona ? persModeToHeaderVal('choosen', asPersona) : ( + // If the user is automatically anonymous, e.g. because of category settings, + // or because they replied anonymously before on the same page. + // For a server side safety check, so won't accidentally do sth as oneself. + // [persona_indicator_chk] + indicatedPersona ? persModeToHeaderVal( + 'indicated', indicatedPersona, curPersonaOptions.isAmbiguous) : + // No alias in use. + // Pat has not entered Anonymous or Self mode, and isn't anonymous by default. + undefined); + + if (personaHeaderVal) { + headers[PersonaHeaderName] = personaHeaderVal; + } } +/// Won't stringify any not-needed fields in `mode`. +/// Parsed by the server here: [parse_pers_mode_hdr]. +/// +/// Json in a header value is ok — all visible ascii chars are ok, see: +/// https://www.rfc-editor.org/rfc/rfc7230#section-3.2 +/// +function persModeToHeaderVal(field: 'choosen' | 'indicated', mode: WhichPersona | PersonaMode, + ambiguous?: Bo): St { + //D_DIE_IF(isVal(mode.anonStatus) && !_.isNumber(mode.anonStatus), 'TyEANONSTATUSNAN'); + const modeValue: WhichPersona = + mode.self ? { self : true } : ( + mode.anonStatus ? { anonStatus: mode.anonStatus, lazyCreate: true } : ( + // [pseudonyms_later] + 'TyEUNKINDPERS' as any)); + const obj = {} as any; + obj[field] = modeValue; + if (ambiguous) obj.ambiguous = true; + return JSON.stringify(obj as PersonaHeaderVal); +} + + +type PersonaHeaderVal = + { choosen: PersonaHeaderValVal } | + { indicated: PersonaHeaderValVal; ambiguous?: true }; + +type PersonaHeaderValVal = + { self: true } | + { anonStatus: AnonStatus, lazyCreate: true }; + + function appendE2eAndForbiddenPassword(url: string) { let newUrl = url; const e2eTestPassword = anyE2eTestPassword(); @@ -1543,21 +1620,21 @@ export function listDrafts(userId: UserId, export function loadNotifications(userId: UserId, upToWhenMs: number, - success: (notfs: Notification[]) => void, error: () => void) { + onOk: (notfs: Notification[]) => void, error: () => void) { const query = '?userId=' + userId + '&upToWhenMs=' + upToWhenMs; - get('/-/load-notifications' + query, success, error); + get('/-/load-notifications' + query, (resp: NotfSListResponse) => onOk(resp.notfs), error); } export function markNotfsRead() { - postJsonSuccess('/-/mark-all-notfs-as-seen', (notfs) => { + postJsonSuccess('/-/mark-all-notfs-as-seen', (resp: NotfSListResponse) => { // Should be the same as [7KABR20], server side. const myselfPatch: MyselfPatch = { numTalkToMeNotfs: 0, numTalkToOthersNotfs: 0, numOtherNotfs: 0, thereAreMoreUnseenNotfs: false, - notifications: notfs, + notifications: resp.notfs, }; ReactActions.patchTheStore({ me: myselfPatch }); }, null, {}); @@ -1679,10 +1756,15 @@ export function loadForumCategoriesTopics(forumPageId: St, topicFilter: St, // Change the reply // 'users' field to 'usersBrief', no, 'patsBr'? 'Tn = Tiny, Br = Brief, Vb = Verbose? [.get_n_patch] export function loadForumTopics(categoryId: CatId, orderOffset: OrderOffset, - onOk: (resp: LoadTopicsResponse) => Vo) { + onOk: (resp: LoadTopicsResponse) => V) { const url = '/-/list-topics?categoryId=' + categoryId + '&' + ServerApi.makeForumTopicsQueryParams(orderOffset); - getAndPatchStore(url, onOk); // [2WKB04R] + return get(url, function(resp: LoadTopicsResponse) { + // (Alternatively, the server could incl `listingCatId` in its response.) + const patch: StorePatch = { ...resp.storePatch, listingCatId: categoryId }; + ReactActions.patchTheStore(patch); // [2WKB04R] + onOk(resp); + }); } @@ -1807,14 +1889,7 @@ export function fetchLinkPreview(url: St, inline: Bo, /* later: curPageId: PageI } -export function saveVote(data: { - pageId: PageId, - postNr: PostNr, - vote: PostVoteType, - action: 'DeleteVote' | 'CreateVote', - postNrsRead: PostNr[], - doAsAnon?: MaybeAnon, -}, onDone: (storePatch: StorePatch) => Vo) { +export function saveVote(data: SaveVotePs, onDone: (storePatch: StorePatch) => V) { // Specify altPageId and embeddingUrl, so any embedded page can be created lazily. [4AMJX7] // @ifdef DEBUG dieIf(data.pageId && data.pageId !== EmptyPageId && data.pageId !== getPageId(), 'TyE2ABKSY7'); @@ -2155,12 +2230,13 @@ export function hidePostInPage(postNr: number, hide: boolean, success: (postAfte } -export function deletePostInPage(postNr: number, repliesToo: boolean, - success: (deletedPost) => void) { - postJsonSuccess('/-/delete-post', success, { +export function deletePostInPage(postNr: PostNr, repliesToo: Bo, doAsAnon: MaybeAnon | U, + onOk: (deletedPost) => V) { + postJsonSuccess('/-/delete-post', onOk, { pageId: getPageId(), postNr: postNr, repliesToo: repliesToo, + doAsAnon, }); } @@ -2311,26 +2387,30 @@ export function loadPageJson(path: string, success: (response) => void) { } -export function acceptAnswer(postId: PostNr, doAsAnon: MaybeAnon, +export function acceptAnswer(ps: { pageId: PageId, postId: PostId, doAsAnon: MaybeAnon }, onOk: (answeredAtMs: Nr) => V) { - postJsonSuccess('/-/accept-answer', onOk, { pageId: getPageId(), postId, doAsAnon }); + postJsonSuccess('/-/accept-answer', onOk, ps); } -export function unacceptAnswer(doAsAnon: MaybeAnon, onOk: () => V) { - postJsonSuccess('/-/unaccept-answer', onOk, { pageId: getPageId(), doAsAnon }); +export function unacceptAnswer(ps: { pageId: PageId, doAsAnon: MaybeAnon }, onOk: () => V) { + postJsonSuccess('/-/unaccept-answer', onOk, ps); } -export function togglePageClosed(doAsAnon: MaybeAnon, onOk: (closedAtMs: Nr) => V) { - postJsonSuccess('/-/toggle-page-closed', onOk, { pageId: getPageId(), doAsAnon }); + +export function togglePageClosed(ps: { pageId: PageId, doAsAnon: MaybeAnon }, + onOk: (closedAtMs: Nr) => V) { + postJsonSuccess('/-/toggle-page-closed', onOk, ps); } -export function deletePages(pageIds: PageId[], success: () => void) { - postJsonSuccess('/-/delete-pages', success, { pageIds: pageIds }); + +export function deletePages(ps: { pageIds: PageId[], doAsAnon: MaybeAnon }, onOk: () => V) { + postJsonSuccess('/-/delete-pages', onOk, ps); } -export function undeletePages(pageIds: PageId[], success: () => void) { - postJsonSuccess('/-/undelete-pages', success, { pageIds: pageIds }); + +export function undeletePages(ps: { pageIds: PageId[], doAsAnon: MaybeAnon }, onOk: () => V) { + postJsonSuccess('/-/undelete-pages', onOk, ps); } diff --git a/client/app-slim/avatar/avatar.ts b/client/app-slim/avatar/avatar.ts index 1469e7a612..8fc62ebad7 100644 --- a/client/app-slim/avatar/avatar.ts +++ b/client/app-slim/avatar/avatar.ts @@ -37,22 +37,25 @@ export function resetAvatars() { } -export const Avatar = createComponent({ +export const Avatar = createFactory<AvatarProps, {}>({ displayName: 'Avatar', - onClick: function(event) { + onClick: function(event: MouseEvent) { event.stopPropagation(); event.preventDefault(); - morebundle.openAboutUserDialog(this.props.user.id, event.target, this.props.title); + const props: AvatarProps = this.props; + morebundle.openAboutUserDialog(props.user.id, event.target, props.title); }, tiny: function() { - return !this.props.size || this.props.size === AvatarSize.Tiny; + const props: AvatarProps = this.props; + return !props.size || props.size === AvatarSize.Tiny; }, makeTextAvatar: function() { - const user: BriefUser = this.props.user; - const hidden: boolean = this.props.hidden; + const props: AvatarProps = this.props; + const user: BriefUser = props.user; + const hidden = props.hidden; let result = textAvatarsByUserId[user.id]; if (result) return result; @@ -157,9 +160,9 @@ export const Avatar = createComponent({ }, render: function() { - const props = this.props; - const user: BriefUser | MemberInclDetails = this.props.user; - const ignoreClicks = this.props.ignoreClicks || + const props: AvatarProps = this.props; + const user: BriefUser | MemberInclDetails = props.user; + const ignoreClicks = props.ignoreClicks || // The user is unknow when rendering the author avatar, in // the new reply preview, if we haven't logged in. [305KGWGH2] user.id === UnknownUserId; @@ -179,16 +182,16 @@ export const Avatar = createComponent({ if (largestPicPath) { // If we don't know the hash path to the avatar of the requested size, then use another size. let picPath; - if (this.props.size === AvatarSize.Medium) { + if (props.size === AvatarSize.Medium) { picPath = largestPicPath; } - else if (this.props.size === AvatarSize.Small) { + else if (props.size === AvatarSize.Small) { picPath = smlPath || tnyPath || medPath; } else { picPath = tnyPath || smlPath || medPath; } - const origins: Origins = this.props.origins; + const origins: Origins = props.origins; content = r.img({ src: linkToUpload(origins, picPath) }); } else { @@ -200,8 +203,8 @@ export const Avatar = createComponent({ } } let title = user.username || user.fullName; - if (this.props.title) { - title += ' — ' + this.props.title; + if (props.title) { + title += ' — ' + props.title; } if (props.showIsMine) { @@ -215,7 +218,7 @@ export const Avatar = createComponent({ const elemName = ignoreClicks ? 'span' : 'a'; const elemFn = <any> r[elemName]; const href = ignoreClicks ? null : linkToUserProfilePage(user); - const onClick = ignoreClicks || this.props.clickOpensUserProfilePage ? + const onClick = ignoreClicks || props.clickOpensUserProfilePage ? null : this.onClick; return ( diff --git a/client/app-slim/forum/forum.ts b/client/app-slim/forum/forum.ts index 5d99ae1137..8c9d035b62 100644 --- a/client/app-slim/forum/forum.ts +++ b/client/app-slim/forum/forum.ts @@ -988,6 +988,7 @@ const LoadAndListTopics = createFactory({ // can continue reading at the top of the newly loaded topics. $byId('esPageColumn').classList.add('s_NoScrlAncr'); + // This updates `store.listingCatId`. Server.loadForumTopics(categoryId, orderOffset, (response: LoadTopicsResponse) => { // (4AB2D) if (this.isGone) return; let topics: any = isNewView ? [] : (this.state.topics || []); diff --git a/client/app-slim/model.ts b/client/app-slim/model.ts index 8c26407d6c..291ba02611 100644 --- a/client/app-slim/model.ts +++ b/client/app-slim/model.ts @@ -256,6 +256,16 @@ interface Vote { } +interface SaveVotePs { + pageId: PageId, + postNr: PostNr, + vote: PostVoteType, + action: 'DeleteVote' | 'CreateVote', + postNrsRead: PostNr[], + doAsAnon?: MaybeAnon, +} + + interface DraftLocator { draftType: DraftType; categoryId?: number; @@ -283,7 +293,7 @@ interface DraftDeletor { interface Draft { byUserId: UserId; - doAsAnon?: MaybeAnon; // not yet impl [doAsAnon_draft] + doAsAnon?: MaybeAnon; draftNr: DraftNr; forWhat: DraftLocator; createdAt: WhenMs; @@ -363,11 +373,17 @@ interface ShowPostOpts extends ScrollIntoViewOpts { interface Post { // Client side only ------ + // [drafts_as_posts] Later, the drafts3 table will get deleted, and drafts moved to + // posts3 / nodes_t instead. These fields are in fact for drafts / previews: + // If this post / these changes don't yet exist — it's a preview. isPreview?: boolean; // Is a number if the draft has been saved server side (then the server has // assigned it a number). isForDraftNr?: DraftNr | true; + // If this post is a draft and we need to lazy-create an anonym, this tells us what type + // of anonym: temporarily or permanently anonymous (its AnonStatus). + doAsAnon?: MaybeAnon, // If we're editing this post right now. isEditing?: boolean; // ----------------------- @@ -485,6 +501,10 @@ interface MyPageData { // For the current page only. marksByPostId: { [postId: number]: any }; // sleeping BUG: probably using with Nr (although the name implies ID), but should be ID + + // Needed for deriving Store.curPersonaOptions. + // Currently derived client side. Later: The server can include in response. [fetch_alias] + myPersonas?: MyPersonasThisPage; } @@ -505,13 +525,16 @@ interface OwnPageNotfPrefs { // RENAME to MembersPageNotfPrefs? // Extend Pat, set id to a new StrangerId if not logged in? type Myself = Me; // renaming to Me -interface Me extends OwnPageNotfPrefs { // + extends Pat? +interface Me extends OwnPageNotfPrefs, Pat { dbgSrc?: string; // This is not the whole session id — it's the first 16 chars only [sid_part1]; // the remaining parts have (a lot) more entropy than necessary. mySidPart1?: St | N; - id?: UserId; - //useAlias?: Pat; // maybe? [alias_mode] and this.id is one's true id + // Is Pats.NoPatId (0, zero) for strangers, so this Me interface fullfills the Pat interface. + id: PatId; + + usePersona?: Oneself | LazyCreatedAnon | N; + isStranger?: Bo; // missing?: isGuest?: Bo isGroup?: boolean; // currently always undefined (i.e. false) @@ -706,6 +729,7 @@ interface HelpMessage { version: number; content: any; defaultHide?: boolean; + closeOnClickOutside?: false; // default true doAfter?: () => void; type?: number; className?: string; @@ -1195,6 +1219,8 @@ interface SessWinStore { /// Can be 1) the main ('top') browser win (incl topbar, editor, sidebars etc), or /// 2) a copy of the store in an embedded comments iframe. [many_embcom_iframes] /// +/// Maybe should [break_out_clone_store_fn]? +/// interface DiscStore extends SessWinStore { currentPage?: Page; currentPageId?: PageId; @@ -1202,6 +1228,11 @@ interface DiscStore extends SessWinStore { curCatsById: { [catId: CatId]: Cat }; usersByIdBrief: { [userId: number]: Pat }; // = PatsById pagesById: { [pageId: string]: Page }; + + // Derived client side from: MyPageData.myPersonas and Me.usePersona. + curPersonaOptions?: PersonaOptions // ? move to SessWinStore ? + curDiscProps?: DiscPropsDerived + indicatedPersona?: PersonaMode // ? move to SessWinStore ? } @@ -1235,6 +1266,18 @@ interface Store extends Origins, DiscStore, PartialEditorStoreState { publicCategories: Category[]; // RENAME [concice_is_nice] pubCats newCategorySlug: string; // for temporarily highlighting a newly created category topics?: Topic[]; + + // Says which category we're listing topics in, if we're on a forum homepage (or + // other topic index page). Could be the forum root category, or a base category, + // or sub category, or sub sub ...). + // + // This is good to know, when composing a new forum topic, because new topics inherit + // some properties from the currently listed category. For example, if you're on a + // forum homepage, and view an anonymous-by-default category and click Create Topic, + // the new topic will be anonymous. + // + listingCatId?: CatId; + user: Myself; // try to remove, use 'me' instead: me: Myself; userSpecificDataAdded?: boolean; // is always false, server side @@ -1356,6 +1399,47 @@ type PropsFromRefs<Type> = { // RENAME to DiscLayoutDerived? There's an interface Layout_not_in_use too (below) merging all layouts. +/// Discussion properties, intherited from the page, ancestor categories, site defaults, +/// built-in defaults. +/// +/// The `from` fields says from where each property got inherited, so admins can know what +/// they might want to change, if sth isn't to their satisfaction. +/// +/// Example: { +/// comtOrder: 3, +/// comtNesting: -1, +/// comtsStartHidden :2, +/// comtsStartAnon: 3, <—— modified, and +/// opStartsAnon:2, +/// newAnonStatus: 65535, <—— modified ... +/// pseudonymsAllowed: 2, +/// from: { +/// comtsStartAnon: { +/// // ... in this category: +/// id: 5, +/// parentId: 1, +/// name: "AA", +/// ... +/// comtsStartAnon: 3, <—— category setting changed +/// newAnonStatus: 65535, +/// ... +/// }, +/// newAnonStatus: { +/// // Same category, again: (just an example) +/// id:5, +/// parentId: 1, +/// ... +/// comtsStartAnon: 3, +/// newAnonStatus":65535, <—— +/// ... +/// }, +/// comtOrder: "BuiltIn", +/// comtNesting: "BuiltIn", +/// comtsStartHidden: "BuiltIn", +/// opStartsAnon: "BuiltIn", +/// pseudonymsAllowed: "BuiltIn", +/// } +/// }' interface DiscPropsDerived extends DiscPropsBase { from: DiscPropsComesFrom; } @@ -1528,52 +1612,21 @@ type PpsById = { [ppId: number]: Participant }; // RENAME to PatsById interface Anonym extends GuestOrAnon { - isAnon: true; + anonStatus: AnonStatus + //anonOnPageId: PageId; // not yet incl server side + isAnon: true; // REMOVE isGuest?: false; // = !isAuthenticated — no! BUG RISK ensure ~isGuest isn't relied on // anywhere, to "know" it's a user / group } interface KnownAnonym extends Anonym { - isAnon: true; anonForId: PatId; - anonStatus: AnonStatus; - anonOnPageId: PageId; -} - -/// `false` means don't-do-anonymously, use one's real account instead. -type MaybeAnon = WhichAnon | false; - -// For choosing an anonym. Maybe rename to ChooseAnon? Or ChoosenAnon / SelectedAnon? -interface WhichAnon { - sameAnonId?: PatId; // Either this ... - anonStatus?: AnonStatus; // and this, - newAnonStatus?: AnonStatus; // ... or this. } -interface SameAnon extends WhichAnon { - sameAnonId: PatId; - anonStatus: AnonStatus.IsAnonOnlySelfCanDeanon | AnonStatus.IsAnonCanAutoDeanon; - newAnonStatus?: U; -} -interface NewAnon extends WhichAnon { - sameAnonId?: U; - newAnonStatus: AnonStatus.IsAnonOnlySelfCanDeanon | AnonStatus.IsAnonCanAutoDeanon; -} - -/* Confusing! type MaybeAnon above, is better? -interface NotAnon extends WhichAnon { - sameAnonId?: U; - newAnonStatus?: U; - anonStatus: AnonStatus.NotAnon; -} */ - -interface MyPatsOnPage { - // Each item can be an anonym or pseudonym of pat, or pat henself. No duplicates. - sameThread: Pat[]; - outsideThread: Pat[] - byId: { [patId: PatId]: Pat | SameAnon } +interface FutureAnon extends KnownAnonym { + id: Pats.FutureAnonId, } @@ -1711,6 +1764,204 @@ interface PatVvb extends UserInclDetailsWithStats { } type UserDetailsStatsGroups = PatVvb; // old name + + +// ========================================================================= +// Personas +// ========================================================================= + + +/// Anonyms, pseudonyms, and oneself, are personas. +/// +interface WhichPersona { + self?: Bo + anonStatus?: AnonStatus; +} + + +interface Oneself extends WhichPersona { + self: true + anonStatus?: U; +}; + + +/// Anonyms and pseudonyms are aliases. (But oneself is not an alias.) +/// +interface AsAlias extends WhichPersona { + self?: false +} + + +interface SamePseudonym extends AsAlias { + pseudonymId: UserId; + anonStatus?: U; +} + + +/// `false` means don't-do-anonymously, use one's real account instead. +/// REFACTOR: Use `Oneself` instead of `false`? Better: just use type WhichPersona? +/// Here: [oneself_0_false] and at maaany other places. +type MaybeAnon = WhichAnon | false; + +/// Which anonym to use (if any) when doing something, e.g. posting a comment. +/// See [one_anon_per_page] in tyworld.adoc. +/// +interface WhichAnon extends AsAlias { + anonStatus: AnonStatus + sameAnonId?: PatId // either this + lazyCreate?: Bo // or this + createNew_tst?: Bo // or this +} + +/// The anonym with id `sameAnonId` should be looked up server side, and reused. +/// +interface SameAnon extends WhichAnon { + sameAnonId: PatId // RENAME to just `id`? + lazyCreate?: false + createNew_tst?: false +} + +/// If there's an anonym with the same `anonStatus`, on the same page, for the +/// same user, then, that anonym should be reused (the server looks in the database +/// to find out, see `SiteTransaction.loadAnyAnon()`). Otherwise, a new created. +/// +interface LazyCreatedAnon extends WhichAnon { + sameAnonId?: U + lazyCreate: true + createNew_tst?: U +} + +/// Always creates a new anonym, even if one exists with the same status. +/// Not currently in use. [one_anon_per_page] +interface NewAnon extends WhichAnon { + sameAnonId?: U + lazyCreate?: false + createNew_tst: true +} + + +interface MyPersonasThisPage { + // Each item can be an anonym or pseudonym of pat, or pat hanself. No duplicates. + sameThread: Pat[]; + // Pats in `sameThread` are not also in `outsideThread` (not needed). + outsideThread: Pat[] + byId: { [patId: PatId]: Pat } +} + + +/// Which anonyms and pseudonyms, and oneself, the user can choose among when +/// posting something or voting, or editing. Is just one item, if anonyms and pseudonyms +/// haven't been enabled: the user hanself. +/// +interface PersonaOptions { + // If we should ask the user which persona they want to use, before + // doing anything (e.g. voting or commenting). So they don't accidentally + // use the wrong persona. + isAmbiguous: Bo + + // If has commented or voted or sth as oneself, on the current page. + hasBeenSelf?: Bo + // If has commented or voted or sth anonymously, on the current page. + // Could split into two: `hasBeenTempAnon` and `hasBeenPermAnon`, [dif_anon_status] + // (or even some kind of Set, if there'll be many anon status types). + hasBeenAnon?: Bo + hasBeenPseudo?: Bo + + // Never empty — would be oneself, if nothing else. + optsList: PersonaOption[] +} + + +interface PersonaOption { + alias: Pat /*me*/ | KnownAnonym | FutureAnon // | Pseudonym + // RENAME to whichPersonaId? + doAs: MaybeAnon; + isBestGuess?: Bo // then should be first in PersonaOptions.list. + inSameThread?: Bo + onSamePage?: Bo + // If is from any Persona Mode, e.g. Anonymous Mode. + isFromMode?: Bo + // If is from the category or page properties. + isFromProps?: Bo + // If recommended by the cat or page properties. + isRecommended?: Bo + // If persona not allowed, e.g. anon comments used to be enabled and we posted an + // anonymous comment, but anon comments then got disabled. + isNotAllowed?: Bo + isSelf?: Bo +} + +// Exactly one set. +type PersonaMode = { pat?: Pat, self?: true, anonStatus?: AnonStatus }; + + +/// The persona the user has choosen to use, and the ones han can choose among (can +/// depend on the current page, since anonyms are per page). +/// +interface DoAsAndOpts { + doAsAnon: MaybeAnon + /// Which participants the current usr can choose among: hanself (`false`), + /// hans anonyms and pseudonyms. + /// CLEAN_UP: Is this field really needed? Can't `PersonaOptions.optsList` be used + /// instead somehow? (`optsList.map(_.doAs)`) should be the same as `myAliasOpts`. + myAliasOpts: MaybeAnon[] // [ali_opts_only_needed_here] +} + + +/// For choosing which persona to use, if editing / altering a comment or a page. +/// +interface ChooseEditorPersonaPs { + // [Origins_needed_for_avatar_images]. + store: DiscStore & Origins + postNr?: PostNr + atRect: Rect + draft?: Draft + isInstantAction?: true +} + + +/// For choosing which persona to use, when posting comments or pages, or voting. +/// +interface ChoosePosterPersonaPs { + atRect?: Rect + me: Me + // [Origins_needed_for_avatar_images] — so can show one's pseudonyms' avatars (which + // might be images hosted by a CDN — origin needed) if listing pseudonyms (to select one). + origins: Origins + // Might be different from the current store — if pat is composing a new page in + // a category with different settings (`DiscPropsDerived`) than the forum root category. + // CLEAN_UP: `store: DiscStore & Origins` instead, like in ChooseEditorPersonaPs? + discStore: DiscStore + postNr?: PostNr + draft?: Draft +} + + +/// A dropdown for choosing which persona to use (e.g. if posting anonymous comments). +/// +/// CLEAN_UP: Would it be possible/better to use ChooseEditorPersonaPs or +/// ChoosePosterPersonaPs instead? +/// +interface ChoosePersonaDlgPs { + atRect: Rect; + open?: Bo; + pat?: Pat; + me: Me, + // Any currently selected alias. + curAnon?: MaybeAnon; + // Which anonyms and pseudonyms to list in the choose-alias menu. (The options.) + myAliasOpts?: MaybeAnon[]; + discProps: DiscPropsDerived; + saveFn: (_: MaybeAnon) => V; +} + + + +// ========================================================================= +// +// ========================================================================= + + interface CreateUserParams { idpName?: St; idpHasVerifiedEmail?: Bo; @@ -1961,6 +2212,8 @@ interface StorePatch publicCategories?: Category[]; restrictedCategories?: Category[]; + listingCatId?: CatId; + pageVersionsByPageId?: { [pageId: string]: PageVersion }; postsByPageId?: { [pageId: string]: Post[] }; @@ -2300,6 +2553,20 @@ interface IdentityProviderSecretConf extends IdentityProviderPubFields { // ========================================================================= +/// For rendering a (A) text/image circle with one's first letter or tiny profile pic. +interface AvatarProps { + user: BriefUser + origins: Origins + size?: AvatarSize + title?: any + hidden?: Bo + showIsMine?: Bo + ignoreClicks?: Bo + clickOpensUserProfilePage?: Bo + key?: St | Nr; +} + + /// For rendering a new page. interface ShowNewPageParams { newPage: Page; // | AutoPage; @@ -2356,40 +2623,6 @@ interface AuthnDlgIf { } -/// If clicking e.g. Like, one might need to choose if the like vote should -/// be anonymous or not. -interface MaybeChooseAnonPs { - store: DiscStore - discProps?: DiscPropsDerived - postNr?: PostNr - atRect?: Rect -} - -interface ChoosenAnon { - doAsAnon: MaybeAnon - /// Which participants the current usr can choose among: hanself (`false`), - /// hans anonyms and pseudonyms. - myAliasOpts: MaybeAnon[] -} - - -/// A dropdown for choosing which anonym to use (e.g. if posting anonymous comments). -/// DlgPs = dialog parameters, hmm. -/// -interface ChooseAnonDlgPs { - atRect: Rect; - open?: Bo; - pat?: Pat; - me: Me, - // Any currently selected alias. - curAnon?: MaybeAnon; - // Which anonyms and pseudonyms to list in the choose-alias menu. (The options.) - myAliasOpts?: MaybeAnon[]; - discProps: DiscPropsDerived; - saveFn: (_: MaybeAnon) => V; -} - - /// For rendering category trees. interface CatsTree { rootCats: CatsTreeCat[]; @@ -2512,6 +2745,7 @@ interface PatsToAddRemove { interface ExplainingTitleText { iconUrl?: St; + img?: any; // RElm; title: any; // St | RElm; —> compil err in server & blog comments bundles text?: any; // St | RElm; key?: any; @@ -2542,10 +2776,20 @@ interface ExplainingListItemProps extends ExplainingTitleText { } +interface SimpleProxyDiagParams extends ProxyDiagParams { + body: any // RElm + primaryButtonTitle?: any // RElm + secondaryButonTitle?: any // RElm + closeButtonTitle?: any; + onPrimaryClick?: () => V + onCloseOk?: (whichBtn: Nr) => V; +} + + interface ProxyDiagParams extends SharedDiagParams { atRect: Rect; // required (optional in SharedDiagParams) flavor?: DiagFlavor; - showCloseButton?: Bo; // default true + showCloseButton?: Bo; // default true, RENAME to showCloseCross? (upper right corner) dialogClassName?: St; contentClassName?: St; closeOnButtonClick?: Bo; // default false @@ -2559,8 +2803,6 @@ interface DropdownProps extends SharedDiagParams { show: Bo; showCloseButton?: true; //bottomCloseButton?: true; — not yet impl - onHide: () => Vo; - closeOnClickOutside?: false; // default true onContentClick?: (event: MouseEvent) => Vo; atX?: Nr; atY?: Nr; @@ -2573,6 +2815,8 @@ interface SharedDiagParams { atRect?: Rect; pullLeft?: Bo; allowFullWidth?: Bo; + closeOnClickOutside?: false; // default true + onHide?: () => V; } @@ -2975,6 +3219,11 @@ interface TerminateSessionsResponse { } +interface NotfSListResponse { + notfs: Notification[] +} + + // COULD also load info about whether the user may apply and approve the edits. interface LoadDraftAndTextResponse { pageId: PageId; diff --git a/client/app-slim/more-bundle-not-yet-loaded.ts b/client/app-slim/more-bundle-not-yet-loaded.ts index 818514ae8d..47eab0e8b4 100644 --- a/client/app-slim/more-bundle-not-yet-loaded.ts +++ b/client/app-slim/more-bundle-not-yet-loaded.ts @@ -58,19 +58,26 @@ export function showCreateUserDialog(params: CreateUserParams) { } -export function maybeChooseModAlias(ps: MaybeChooseAnonPs, then?: (_: ChoosenAnon) => V) { +export function chooseEditorPersona(ps: ChooseEditorPersonaPs, then?: (_: DoAsAndOpts) => V) { Server.loadMoreScriptsBundle(() => { - anon.maybeChooseModAlias(ps, then); + persona.chooseEditorPersona(ps, then); }); } -export function maybeChooseAnon(ps: MaybeChooseAnonPs, then: (_: ChoosenAnon) => V) { +export function choosePosterPersona(ps: ChoosePosterPersonaPs, + then: (_: DoAsAndOpts | 'CANCEL') => V) { Server.loadMoreScriptsBundle(() => { - anon.maybeChooseAnon(ps, then); + persona.choosePosterPersona(ps, then); }); } +export function openPersonaInfoDiag(ps: { atRect: Rect, isSectionPage: Bo, + me: Me, personaOpts: PersonaOptions, discProps: DiscPropsDerived }): V { + Server.loadMoreScriptsBundle(() => { + persona.openPersonaInfoDiag(ps); + }); +} export function openAboutUserDialog(who: number | string | BriefUser, at, extraInfo?: string) { Server.loadMoreScriptsBundle(() => { @@ -99,9 +106,9 @@ export function openAddPeopleDialog(ps: { curPatIds?: PatId[], curPats?: Pat[], } -export function openDeletePostDialog(post: Post, at: Rect) { +export function openDeletePostDialog(ps: { post: Post, at: Rect, doAsAnon?: MaybeAnon }) { Server.loadMoreScriptsBundle(() => { - debiki2.pagedialogs.openDeletePostDialog(post, at); + debiki2.pagedialogs.openDeletePostDialog(ps); }); } diff --git a/client/app-slim/oop-methods.ts b/client/app-slim/oop-methods.ts index 9b7c21b2b2..769107a004 100644 --- a/client/app-slim/oop-methods.ts +++ b/client/app-slim/oop-methods.ts @@ -198,6 +198,7 @@ export function pageNotfPrefTarget_findEffPref( // unapproved replies (and edit them). // The real fix would be to include a `MyPageData.iHaveReplied` field server side? // Like so: ownPrefs.myDataByPageId[target.pageId].iHaveReplied ? + // That's also needed for finding out which alias (if any) to use. [fetch_alias] const anyReplyByPat = _.find(store.pagesById[target.pageId]?.postsByNr, // [On1] (p: Post) => p.authorId === store.me.id); @@ -688,9 +689,13 @@ export function member_isBuiltIn(member: Member): Bo { // Dupl code [disp_name] -export function pat_name(pat: Me | Pat): St { +export function pat_name(patOrLazyAnon: Me | Pat | LazyCreatedAnon | NewAnon): St { + if (patOrLazyAnon.anonStatus) + return anonStatus_toStr(patOrLazyAnon.anonStatus); // [anon_2_str] + // Or prioritize username? Did, in the annon posts branch: // if (pat.username) return '@' + pat.username; + const pat = patOrLazyAnon as Pat; return pat.fullName || (pat.username ? '@' + pat.username : "_no_name_"); } @@ -766,6 +771,60 @@ export function pat_mayEditTags(me: Me, ps: { forPost?: Post, forPat?: Pat, +// Anonyms +//---------------------------------- + + +export function anon_create(ps: { anonForId: UserId, anonStatus: AnonStatus, + isMe?: true }): FutureAnon { + const fullName = anonStatus_toStr(ps.anonStatus) + (ps.isMe ? " (you)" : ''); // I18N + return { + id: Pats.FutureAnonId, + anonForId: ps.anonForId, + fullName, + isAnon: true, + anonStatus: ps.anonStatus, + } satisfies FutureAnon; +} + + +export function anonStatus_toStr(anonStatus: AnonStatus, verb: Verbosity = Verbosity.Brief): St { + // [dif_anon_status] + if (anonStatus === AnonStatus.IsAnonCanAutoDeanon) + return verb >= Verbosity.Full ? "Temporarily Anonymous" : ( // I18N [anon_2_str] + verb >= Verbosity.Brief ? "Temp Anonymous" : ( + verb >= Verbosity.Terse ? "Temp Anon" : + "Temp")); // Verbosity.VeryTerse + + let prefix = ''; + // @ifdef DEBUG + prefix = "P"; // for "Permanently". Nice in dev mode. + // @endif + if (anonStatus === AnonStatus.IsAnonOnlySelfCanDeanon) + return prefix + (verb >= Verbosity.Brief ? "Anonymous" : ( // I18N + verb >= Verbosity.Terse ? "Anonym" : + "Anon")); // Verbosity.VeryTerse + + // Unknown status. + return `TyEUNKANSTA-${anonStatus}`; +} + + +// Persona mode +//---------------------------------- + +export function persMode_toStr(mode: PersonaMode, verb: Verbosity): St { + return mode.pat ? + // This might be pretty long! Not Verbosity.Brief at all, depending + // on the name. [pseudonyms_later] + mode.pat.username || mode.pat.fullName : ( + mode.self ? (verb <= Verbosity.Terse ? "Self" : "Yourself") : ( // I18N + mode.anonStatus ? anonStatus_toStr(mode.anonStatus, verb) : + // Unknown mode. + "TyEUNKPERMODE")); +} + + // Settings //---------------------------------- @@ -1300,7 +1359,7 @@ export function store_makeNewPostPreviewPatch(store: Store, page: Page, // make an anonym with '?' as sequence number appear. const authorId = doAsAnon ? doAsAnon.sameAnonId || Pats.FutureAnonId : store.me.id; const previewPost = store_makePreviewPost({ - authorId, parentPostNr, safePreviewHtml, newPostType, isEditing: true }); + authorId, doAsAnon, parentPostNr, safePreviewHtml, newPostType, isEditing: true }); return page_makePostPatch(page, previewPost); } @@ -1353,8 +1412,8 @@ export function store_makePostForDraft(authorId: PatId, draft: Draft): Post | Nl // For now, use the CommonMark source instead. const previewPost = store_makePreviewPost({ - authorId, parentPostNr, unsafeSource: draft.text, newPostType: postType, - isForDraftNr: draft.draftNr || true }); + authorId, doAsAnon: draft.doAsAnon, parentPostNr, unsafeSource: draft.text, + newPostType: postType, isForDraftNr: draft.draftNr || true }); return previewPost; } @@ -1379,6 +1438,7 @@ export function post_makePreviewIdNr(parentNr: PostNr, newPostType: PostType): P interface MakePreviewParams { authorId: PatId; + doAsAnon?: MaybeAnon; parentPostNr?: PostNr; safePreviewHtml?: string; unsafeSource?: string; @@ -1391,7 +1451,7 @@ interface MakePreviewParams { function store_makePreviewPost({ - authorId, parentPostNr, safePreviewHtml, unsafeSource, + authorId, doAsAnon, parentPostNr, safePreviewHtml, unsafeSource, newPostType, isForDraftNr, isEditing }: MakePreviewParams): Post { dieIf(!newPostType, "Don't use for edit previews [TyE4903KS]"); @@ -1403,6 +1463,7 @@ function store_makePreviewPost({ const previewPost: Post = { isPreview: true, isForDraftNr, + doAsAnon, isEditing, uniqueId: previewPostIdNr, @@ -1883,12 +1944,6 @@ function deriveLayoutImpl(page: PageDiscPropsSource, cat: Cat, store: DiscStore, } -export function page_authorId(page: Page): PatId | U { - const origPost = page.postsByNr[BodyNr]; - return origPost && origPost.authorId; -} - - export function page_isClosedUnfinished(page: Page | Topic): Bo { return page_isClosed(page) && !page_isSolved(page) && !page_isDone(page); } @@ -2032,6 +2087,7 @@ export function page_canChangeCategory(page: Page): boolean { export function page_mostRecentPostNr(page: Page): number { // BUG not urgent. COULD incl the max post nr in Page, so even if not yet loaded, // we'll know its nr, and can load and scroll to it, from doUrlFragmentAction(). + // Related to: [fetch_alias] let maxNr = -1; _.values(page.postsByNr).forEach((post: Post) => { // COULD use _.reduce instead maxNr = Math.max(post.nr, maxNr); diff --git a/client/app-slim/page/chat.ts b/client/app-slim/page/chat.ts index 7cb316ac81..68f05a035e 100644 --- a/client/app-slim/page/chat.ts +++ b/client/app-slim/page/chat.ts @@ -176,13 +176,15 @@ const ChatMessage = createComponent({ edit: function() { this.setState({ isEditing: true }); const post: Post = this.props.post; + // Later: Pass alias, if any. [anon_chats] editor.openToEditPostNr(post.nr, (wasSaved, text) => { this.setState({ isEditing: false }); }); }, - delete_: function(event) { - morebundle.openDeletePostDialog(this.props.post, cloneEventTargetRect(event)); + delete_: function(event: MouseEvent) { + // Later: [anon_chats]. + morebundle.openDeletePostDialog({ post: this.props.post, at: cloneEventTargetRect(event) }); }, render: function() { diff --git a/client/app-slim/page/discussion.ts b/client/app-slim/page/discussion.ts index 3f4c742064..cbd54019b7 100644 --- a/client/app-slim/page/discussion.ts +++ b/client/app-slim/page/discussion.ts @@ -360,9 +360,12 @@ export const Title = createComponent({ return { editingPageId: null }; }, - editTitle: function() { + editTitle: function(event: MouseEvent) { const store: Store = this.props.store; - this.setState({ editingPageId: store.currentPageId }); + const atRect = cloneEventTargetRect(event); + morebundle.chooseEditorPersona({ store, atRect, postNr: TitleNr }, doAsOpts => { + this.setState({ editingPageId: store.currentPageId, doAsOpts }); + }); }, closeEditor: function() { @@ -428,6 +431,7 @@ export const Title = createComponent({ if (this.state.editingPageId) { const editorProps = _.clone(this.props); editorProps.closeEditor = this.closeEditor; + editorProps.doAsOpts = this.state.doAsOpts; contents = morebundle.TitleEditor(editorProps); } else { diff --git a/client/app-slim/page/post-actions.ts b/client/app-slim/page/post-actions.ts index 96518dba6b..6cdd958d49 100644 --- a/client/app-slim/page/post-actions.ts +++ b/client/app-slim/page/post-actions.ts @@ -145,16 +145,23 @@ function makeReplyBtnTitle(store: Store, post: Post) { export const PostActions = createComponent({ displayName: 'PostActions', - onAcceptAnswerClick: function() { - morebundle.maybeChooseModAlias({ store: this.props.store, atRect: undefined }, - (choices: ChoosenAnon) => { - ReactActions.acceptAnswer(this.props.post.uniqueId, choices.doAsAnon); + onAcceptAnswerClick: function(event: MouseEvent) { + const atRect: Rect = cloneEventTargetRect(event); + // A [pick_persona_click_handler] could save a few lines of code? + morebundle.chooseEditorPersona({ store: this.props.store, atRect, isInstantAction: true }, + (doAsOpts: DoAsAndOpts) => { + const pageId = Server.getPageId(); + const postId = this.props.post.uniqueId; + ReactActions.acceptAnswer({ pageId, postId, doAsAnon: doAsOpts.doAsAnon }); }); }, - onUnacceptAnswerClick: function() { - morebundle.maybeChooseModAlias({ store: this.props.store, atRect: undefined }, - (choices: ChoosenAnon) => { - ReactActions.unacceptAnswer(choices.doAsAnon); + + onUnacceptAnswerClick: function(event: MouseEvent) { + const atRect: Rect = cloneEventTargetRect(event); + morebundle.chooseEditorPersona({ store: this.props.store, atRect, isInstantAction: true }, + (doAsOpts: DoAsAndOpts) => { + const pageId = Server.getPageId(); + ReactActions.unacceptAnswer({ pageId, doAsAnon: doAsOpts.doAsAnon }); }); }, @@ -557,8 +564,8 @@ const MoreVotesDropdownModal = createComponent({ const atRect = cloneEventTargetRect(event); const post: Post = this.state.post; loginIfNeededThen(LoginReason.LoginToDisagree, post.nr, () => { - toggleVote(this.state.store, post, PostVoteType.Disagree, !this.hasVoted(PostVoteType.Disagree), atRect); - this.closeSoon(); + toggleVote(this.state.store, post, PostVoteType.Disagree, !this.hasVoted(PostVoteType.Disagree), + atRect, this.closeSoon); }); }, onBuryClick: function(event: MouseEvent) { @@ -566,16 +573,16 @@ const MoreVotesDropdownModal = createComponent({ const post: Post = this.state.post; // Not visible unless logged in. // [anon_mods] - toggleVote(this.state.store, post, PostVoteType.Bury, !this.hasVoted(PostVoteType.Bury), atRect); - this.closeSoon(); + toggleVote(this.state.store, post, PostVoteType.Bury, !this.hasVoted(PostVoteType.Bury), + atRect, this.closeSoon); }, onUnwantedClick: function(event: MouseEvent) { const atRect = cloneEventTargetRect(event); const post: Post = this.state.post; // Not visible unless logged in. // [anon_mods] - toggleVote(this.state.store, post, PostVoteType.Unwanted, !this.hasVoted(PostVoteType.Unwanted), atRect); - this.closeSoon(); + toggleVote(this.state.store, post, PostVoteType.Unwanted, !this.hasVoted(PostVoteType.Unwanted), + atRect, this.closeSoon); }, makeVoteButtons: function() { @@ -633,8 +640,8 @@ const MoreVotesDropdownModal = createComponent({ }); -// CLEAN_UP use enum PostVoteType for voteType, instead of string. -function toggleVote(store: Store, post: Post, voteType: PostVoteType, toggleOn: Bo, atRect: Rect) { +function toggleVote(store: Store, post: Post, voteType: PostVoteType, toggleOn: Bo, atRect: Rect, + closeSoon?: () => V) { const page: Page = store.currentPage; let action: 'DeleteVote' | 'CreateVote'; let postNrsRead: PostNr[]; @@ -646,21 +653,39 @@ function toggleVote(store: Store, post: Post, voteType: PostVoteType, toggleOn: postNrsRead = findPostNrsRead(page.postsByNr, post); } - morebundle.maybeChooseAnon({ store, postNr: post.nr, atRect }, (choices: ChoosenAnon) => { - const data = { - pageId: store.currentPageId, - postNr: post.nr, - vote: voteType, - action, - postNrsRead, - doAsAnon: choices.doAsAnon, - }; + const data: SaveVotePs = { + pageId: store.currentPageId, + postNr: post.nr, + vote: voteType, + action, + postNrsRead, + }; + + if (toggleOn) { + morebundle.choosePosterPersona({ me: store.me, origins: store, + discStore: store, postNr: post.nr, atRect, + }, (doAsOpts: DoAsAndOpts | 'CANCEL') => { + if (closeSoon) closeSoon(); + if (doAsOpts === 'CANCEL') + return; + data.doAsAnon = doAsOpts.doAsAnon; + save(); + }); + } + else { + // The server will delete the vote, no matter which one of pat's aliases (if any) voted. + // Later: Incl the id of the alias who voted, if one can create be > 1 anonym/alias + // per page. [one_anon_per_page]. + if (closeSoon) closeSoon(); + save(); + } + function save() { debiki2.Server.saveVote(data, function(storePatch: StorePatch) { const by = storePatch.yourAnon || me_toBriefUser(store.me); ReactActions.vote(storePatch, action, voteType, post.nr, by); }); - }); + } } @@ -760,8 +785,15 @@ const MoreDropdownModal = createComponent({ this.close(); }, - onDeleteClick: function(event) { - morebundle.openDeletePostDialog(this.state.post, this.state.buttonRect); + onDeleteClick: function(event: MouseEvent) { + const atRect: Rect = cloneEventTargetRect(event); + const state = this.state; + const post: Post = state.post; + morebundle.chooseEditorPersona({ store: state.store, postNr: post.nr, atRect, + isInstantAction: true }, (doAsOpts: DoAsAndOpts) => { + morebundle.openDeletePostDialog({ post: this.state.post, + at: atRect, doAsAnon: doAsOpts.doAsAnon }); + }); this.close(); }, diff --git a/client/app-slim/personas/PersonaIndicator.ts b/client/app-slim/personas/PersonaIndicator.ts new file mode 100644 index 0000000000..8acd203707 --- /dev/null +++ b/client/app-slim/personas/PersonaIndicator.ts @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2024 Kaj Magnus Lindberg + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/// <reference path="../../macros/macros.d.ts" /> +/// <reference path="../ReactActions.ts" /> +/// <reference path="../widgets.ts" /> +/// <reference path="../page-methods.ts" /> +/// <reference path="../utils/utils.ts" /> +/// <reference path="../avatar/avatar.ts" /> +/// <reference path="../more-bundle-not-yet-loaded.ts" /> + +//------------------------------------------------------------------------------ + namespace debiki2.personas { +//------------------------------------------------------------------------------ + +// [persona_indicator] +export function PersonaIndicator(ps: { store: Store, isSectionPage: Bo }): RElm | N { + const store = ps.store; + const me = store.me; + const personaOpts: PersonaOptions | U = store.curPersonaOptions; + const discProps: DiscPropsDerived | U = store.curDiscProps; + const mode: PersonaMode | U = store.indicatedPersona; + + if (!personaOpts || !discProps || !mode) + return null; + + const fullName = persMode_toStr(mode, Verbosity.Brief); + const shortName = persMode_toStr(mode, Verbosity.VeryTerse); + + const title: RElm = rFr({}, + // If using a pseudonym, show it's avatar image, if any: [pseudonyms_later] + !mode.pat ? null : avatar.Avatar({ user: mode.pat, origins: store, ignoreClicks: true }), + // If screen wide: + r.span({ className: 'esAvtrName_name' }, fullName), + // If screen narrow, always visible if narrow: [narrow] + r.span({ className: 'esAvtrName_Anon' }, shortName), + // If we'll need to ask pat which persona to use, then, show a question mark: + !personaOpts.isAmbiguous ? null : r.span({ className: 'c_AliAmbig' }, ' ?')); + + const aliasElm = + Button({ className: 'esAvtrName esMyMenu s_MMB c_Tb_Ali' + + (!me.usePersona ? '' : ' c_Tb_Ali-Switched'), + onClick: (event: MouseEvent) => { + const atRect: Rect = cloneEventTargetRect(event); + morebundle.openPersonaInfoDiag({ atRect, isSectionPage: ps.isSectionPage, + me, personaOpts, discProps }); + }}, + title); + + return aliasElm; +} + + + +//------------------------------------------------------------------------------ + } +//------------------------------------------------------------------------------ +// vim: fdm=marker et ts=2 sw=2 tw=0 fo=tcqwn list diff --git a/client/app-slim/personas/personas.ts b/client/app-slim/personas/personas.ts new file mode 100644 index 0000000000..59409baf7d --- /dev/null +++ b/client/app-slim/personas/personas.ts @@ -0,0 +1,533 @@ +/* + * Copyright (c) 2023 Kaj Magnus Lindberg + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +/// <reference path="../prelude.ts" /> + +//------------------------------------------------------------------------------ + namespace debiki2 { +//------------------------------------------------------------------------------ + + +/// disc_findMyPersonas() +/// +/// If pat is posting a reply anonymously, then, if han has posted or voted earlier +/// anonymously on the same page, usually han wants hens new reply, to be +/// by the same anonym, so others see they're talking with the same person +/// (although they don't know who it is, just that it's the same). +/// +/// This fn finds anonyms a pat has used, so the pat can reuse them. First it +/// looks for anonyms-to-reuse in the sub thread where pats reply will appear, +/// thereafter anywhere on the same page. +/// +/// Returns sth like this, where 200 is pat's id, and 2001, 2002, 2003, 2004 are +/// anons pat has used on the current page: (just nice looking numbers) +/// { +/// // Just an example +/// byId: { +/// 2001: patsAnon2001, // = { id: 2001, forPatId: 200, ... } +/// 2002: patsAnon2002, // = { id: 2002, forPatId: 200, ... } +/// 2003: patsAnon2003, // = { id: 2003, forPatId: 200, ... } +/// 2004: patsAnon2004, // = { id: 2004, forPatId: 200, ... } +/// }, +/// sameThread: [ +/// patsAnon2002, // pat's anon (or pat henself) who made pat's last comment +// // in the path from startAtPostNr, back to the orig post. +/// patHenself, // here pat posted using hens real account (not anonymously) +/// patsAnon2001, // pat upvoted a comment using anon 2001, along this path +/// ], +/// outsideThread: [ +/// // If pat has posted earlier in the thread (closer to the orig post), using +/// // any of the above (anon 2002, 2001, or as henself), those comments are +/// // ignored: we don't add an anon more than once to the list.) +/// +/// patsAnon2004, // Pat replied elsewhere on the page using hens anon 2004 +/// patsAnon2003, // ... and before that, han posted as anon 2003, also +/// // elsewhere on the same page. +/// ] +/// } +/// +/// In the above example, patsAnon2003 didn't post anything in the thread from +/// startAtPostNr up to the orig post — but that anon did post something, +/// *elsewhere* in the same discussion. So that anon is still in the list of anons +/// pat might want to use again, on this page. +/// +export function disc_findMyPersonas(discStore: DiscStore, ps: { + forWho: Pat | Me | U, startAtPostNr?: PostNr }): MyPersonasThisPage { + + const result: MyPersonasThisPage = { + sameThread: [], + outsideThread: [], + byId: {}, + }; + + const forWho: Pat | Me | U = ps.forWho; + if (!forWho || !forWho.id) + return result; + + const curPage: Page | U = discStore.currentPage; + if (!curPage) + return result; + + // ----- Same thread + + // Find out if pat was henself, or was anonymous, in any earlier posts by hen, + // in the path from ps.startAtPostNr and back towards the orig post. + // (patsAnon2002, patHenself, and patsAnon2001 in the example above (i.e. in + // the docs comment to this fn)). + // (Much later: Should fetch from server, if page big – an 999 comments long thread + // – maybe not all comments in the middle were included by the server? [fetch_alias]) + + const startAtPost: Post | U = ps.startAtPostNr && curPage.postsByNr[ps.startAtPostNr]; + const nrsSeen = {}; + let myVotesByPostNr: { [postNr: PostNr]: Vote[] } = {}; + + const isMe = pat_isMe(forWho); + if (isMe) { + myVotesByPostNr = forWho.myDataByPageId[curPage.pageId]?.votesByPostNr || {}; + } + else { + die('TyE0MYVOTS'); // [_must_be_me] + } + + let nextPost: Post | U = startAtPost; + const myAliasesInThread = []; + + for (let i = 0; i < StructsAndAlgs.TooLongPath && nextPost; ++i) { + // Cycle? (Would be a bug somewhere.) + if (nrsSeen[nextPost.nr]) + break; + nrsSeen[nextPost.nr] = true; + + // Bit dupl code: [_find_anons] + + // We might have added this author, already. + if (result.byId[nextPost.authorId]) + continue; + + const author: Pat | U = discStore.usersByIdBrief[nextPost.authorId]; + if (!author) + continue; // would be a bug somewhere, or a rare & harmless race? Oh well. + + const postedAsSelf = author.id === forWho.id; + const postedAnonymously = author.anonForId === forWho.id; + + if (postedAsSelf || postedAnonymously) { + // This places pat's most recently used anons first. + myAliasesInThread.push(author); + result.byId[author.id] = author; + } + else { + // This comment is by someone else. If we've voted anonymously, let's + // continue using the same anonym. Or using our main user account, if we've + // voted not-anonymously. + const votes: Vote[] = myVotesByPostNr[nextPost.nr] || []; + for (const myVote of votes) { + // If myVote.byId is absent, it's our own vote (it's not anonymous). [_must_be_me] + const voterId = myVote.byId || forWho.id; + // Have we added this alias (or our real account) already? + if (result.byId[voterId]) + continue; + const voter: Pat = discStore.usersByIdBrief[voterId]; + // Sometimes voters are lazy-created and added. Might be some bug. [lazy_anon_voter] + // @ifdef DEBUG + dieIf(!voter, `Voter ${voterId} missing [TyE502SRKJ5]`); + // @endif + myAliasesInThread.push(voter); + result.byId[voter.id] = voter; + } + } + + nextPost = curPage.postsByNr[nextPost.parentNr]; + } + + // ----- Same page + + // If pat posted outside [the thread from the orig post to ps.startAtPostNr], + // then include any anons pat used, so Pat can choose to use those anons, now + // when being active in sub thread startAtPostNr. (See patsAnon2003 and patsAnon2004 + // in this fn's docs above.) + + // Sleeping BUG:, ANON_UNIMPL: What if it's a really big page, and we don't have + // all parts here, client side? Maybe this ought to be done server side instead? + // Or the server could incl all one's anons on the current page, in a list [fetch_alias] + + const myAliasesOutsideThread: Pat[] = []; + + _.forEach(curPage.postsByNr, function(post: Post) { + if (nrsSeen[post.nr]) + return; + + // Bit dupl code: [_find_anons] + + // Each anon pat has used, is to be included at most once. + if (result.byId[post.authorId]) + return; + + const author: Pat | U = discStore.usersByIdBrief[post.authorId]; + if (!author) + return; + + const postedAsSelf = author.id === forWho.id; + const postedAnonymously = author.anonForId === forWho.id; + + if (postedAsSelf || postedAnonymously) { + myAliasesOutsideThread.push(author); + result.byId[author.id] = author; + } + }); + + _.forEach(myVotesByPostNr, function(votes: Vote[], postNrSt: St) { + if (nrsSeen[postNrSt]) + return; + + for (const myVote of votes) { + // The voter is oneself or one's anon or pseudonym. [_must_be_me] + const voterId = myVote.byId || forWho.id; + + if (result.byId[voterId]) + return; + + const voter: Pat | U = discStore.usersByIdBrief[voterId]; // [voter_needed] + if (!voter) + return; + + myAliasesOutsideThread.push(voter); + result.byId[voter.id] = voter; + } + }); + + + // Sort, newest first. Could sort votes by voted-at, not the comment posted-at — but + // doesn't currently matter, not until [many_anons_per_page]. + // Old — now both comments and votes, so won't work: + //myPostsOutsideThread.sort((p: Post) => -p.createdAtMs); + //const myPatsOutside = myPostsOutsideThread.map(p => discStore.usersByIdBrief[p.authorId]); + + // ----- The results + + result.sameThread = myAliasesInThread; + result.outsideThread = myAliasesOutsideThread; + + return result; +} + + + +/// Makes a list of personas pat can use, for replying or voting. The first +/// item in the list is the one to use by default, and if it's unclear what +/// pat might want, sets `PersonaOptions.isAmbiguous` in the response to true. +/// +/// Ambiguity matrix: +/// +/// D = the persona in the left column is automatically used (has priority). +/// d = We'll ask which persona pat wants to use, and this persona (the one in +/// the left column) is the default, listed first. +/// Not totally implemented for the editor though? +/// - = ignored, doesn't have priority +/// A = ambiguous, should ask the user +/// (AP = ambiguous, should ask and update the Preferred-persona-on-page. +/// But not implemented – means A instead, for now.) +/// +/// None Thread (PrfOnP) Page Recom Pers Mode +/// None n/a - - - - - +/// Persona in Thread* D n/a A d D d +/// (Preferred Persona on Page)** D d n/a D D d +/// Persona on Page D - - n/a D A +/// Recommended Persona d - - - n/a - +/// Persona mode persona D A A A D n/a +/// +/// * "Persona in Thread" means that pat has replied or voted earlier in the same +/// page, same sub thread, as that persona. Example: +/// +/// A comment by Alice +/// `—> A comment by this user as anonym A <—— this persona (anonym A) is in +/// | `MyPersonasThisPage.sameThread` +/// `—> A comment by Bob +/// `—> Here our user starts replying to Bob, +/// and findPersonaOptions() gets called. +/// Our user likely wants to reply as anonym A again. +/// Some other comment, same page but not same sub thread +/// `—> A reply +/// `——> A comment by our <— this persona (the user hanself) is +/// user, as hanself in `MyPersonasThisPage.outsideThread`, and +/// that's "Persona on Page" in the table above +/// +/// ** "Preferred Persona on Page" is if in the future it'll be possible to +/// remember "Always use this persona, on this page" — so won't have to choose, +/// repeatedly. But maybe that's just an over complicated idea. +/// +export function findPersonaOptions(ps: { + // Missing, if on a forum homepage (no discussions directly on such pages). + myPersonasThisPage?: MyPersonasThisPage, + me: Me, + // Missing, if on an auto generated page (rather than a discussion page). + // Would be good with site-default props [site_disc_props] instead of `undefined`. + discProps?: DiscPropsDerived, + }): PersonaOptions { + + const dp: DiscPropsDerived | U = ps.discProps; + const anonsAllowed = dp && dp.comtsStartAnon >= NeverAlways.Allowed; + const anonsRecommended = dp && dp.comtsStartAnon >= NeverAlways.Recommended; + //nst pseudonymsRecommended = false; // [pseudonyms_later] + const mustBeAnon = dp && dp.comtsStartAnon >= NeverAlways.AlwaysButCanContinue; + const newAnonStatus = dp && dp.newAnonStatus; + const selfRecommended = !anonsRecommended; // later: dp.comtsStartAnon <= AllowedMustChoose + + // @ifdef DEBUG + dieIf(anonsAllowed && !newAnonStatus, `[TyE4WJE281]`); + dieIf(anonsRecommended && !anonsAllowed, `[TyE4WJE282]`); + dieIf(mustBeAnon && !anonsRecommended, `[TyE4WJE282]`); + // @endif + + const myPersThisPage: MyPersonasThisPage | U = ps.myPersonasThisPage; + const me = ps.me; + + const result: PersonaOptions = { isAmbiguous: false, optsList: [] }; + let selfAdded = false; + let anonFromPropsAdded = false; + let modePersonaAdded = false; + let recommendedAdded = false; + + // ----- Personas from the current discussion + + if (!myPersThisPage) { + // We're on a forum homeage, or user profile page, or sth like that, which + // itself has no discussions or comments. + } + else { + // We're on some discussion page, with comments, authors, votes. + + // Same sub thread has priority – so we continue replying as the same person, + // won't become sbd else in the middle of a thread. + for (let ix = 0; ix < myPersThisPage.sameThread.length; ++ix) { + const pat = myPersThisPage.sameThread[ix]; + const opt: PersonaOption = { + alias: pat, + doAs: patToMaybeAnon(pat, me), + // The first item is who pat most recently replied or voted as, in the relevant sub thread. + isBestGuess: ix === 0, + inSameThread: true, + } + initOption(opt); + result.optsList.push(opt); + } + + // Thereafter, elsewhere on the same page, but not in the same sub thread. + for (let ix = 0; ix < myPersThisPage.outsideThread.length; ++ix) { + const pat = myPersThisPage.outsideThread[ix]; + const opt: PersonaOption = { + alias: pat, + doAs: patToMaybeAnon(pat, me), + isBestGuess: + !myPersThisPage.sameThread.length && + // If pat has commented using different aliases on this page, we can't + // know which one is the best guess? Maybe the most recently used one? + // Or the most frequently used one? Who knows. + myPersThisPage.outsideThread.length === 1, + onSamePage: true, + } + initOption(opt); + result.optsList.push(opt); + } + } + + // ----- Persona mode + + // The user might be in e.g. anon mode also on pages without any discussions. + + if (me.usePersona && !modePersonaAdded) { + let opt: PersonaOption; + if (me.usePersona.self) { + opt = { + alias: me, + doAs: false, // not anon, be oneself [oneself_0_false] + isFromMode: true, + isSelf: true, + }; + if (mustBeAnon) { + opt.isNotAllowed = true; + } + if (selfRecommended) { + opt.isRecommended = true; + recommendedAdded = true; + } + selfAdded = true; + } + else if (me.usePersona.anonStatus) { + // Since not added above (!modePersonaAdded), this'd be a new anon. + const anonStatus = me.usePersona.anonStatus; + const anonPat = anon_create({ anonStatus, anonForId: me.id }); + opt = { + alias: anonPat, + doAs: patToMaybeAnon(anonPat, me), + isFromMode: true, + }; + if (!anonsAllowed) { + opt.isNotAllowed = true; + } + if (anonStatus === newAnonStatus) { + if (anonsRecommended) { + opt.isRecommended = true; + recommendedAdded = true; + } + opt.isFromProps = true; + anonFromPropsAdded = true; // [dif_anon_status] + } + } + else { + // [pseudonyms_later] ? + } + + result.optsList.push(opt); + } + + // Are there more than two persona options? Then we don't know which one to use. + // + // (We ignore additional options added below. Only any Persona Mode option (added above), + // and personas pat has been on the current page (also added above), can make us unsure + // about who pat wants to be now. But not just because anonymity or posting as oneself + // is the default / recommended on the page.) + // + result.isAmbiguous = result.optsList.length >= 2; + + // Add Anonymous as an option, if not done already. + if (!anonFromPropsAdded && anonsAllowed) { + const anonPat = anon_create({ anonStatus: newAnonStatus, anonForId: me.id }); + const opt: PersonaOption = { + alias: anonPat, + doAs: patToMaybeAnon(anonPat, me), + }; + if (!recommendedAdded && anonsRecommended) { + // (It's the recommended type of anon — we created it with newAnonStatus just above.) + opt.isRecommended = true; + recommendedAdded = true; + } + result.optsList.push(opt); + + // If not in alias mode, maybe it's good to tell the user that han can be anonymous + // if han wants? – No, this feels just annoying. If someone started commenting + // on a page as hanself, then it's pretty pointless to suddenly have han replaced by + // "Anon 123" in subsequent comments, when "everyone" can guess who Anon 123 is anyway. + // if (ambiguities2.aliases.length >= 2 && !me.usePersona && anonsRecommended) { + // ambiguities2.isAmbiguous = true; + // } + } + + // Add oneself as an option, if not done already. (But don't consider this an ambiguity.) + if (!selfAdded && !mustBeAnon) { + const opt: PersonaOption = { + alias: me, + doAs: false, // not anon [oneself_0_false] + isSelf: true, + }; + if (!recommendedAdded && selfRecommended) { + opt.isRecommended = true; + recommendedAdded = true; + } + result.optsList.push(opt); + } + + result.optsList.sort((a: PersonaOption, b: PersonaOption) => { + if (a.isNotAllowed !== b.isNotAllowed) + return a.isNotAllowed ? +1 : -1; // place `a` last + + if (a.isBestGuess !== b.isBestGuess) + return a.isBestGuess ? -1 : +1; // place `a` first + + if (a.isFromMode !== b.isFromMode) + return a.isFromMode ? -1 : +1; + + if (a.isRecommended !== b.isRecommended) + return a.isRecommended ? -1 : +1; + + if (a.isSelf !== b.isSelf) + return a.isSelf ? -1 : +1; + }) + + function initOption(opt: PersonaOption) { + const pat: Pat = opt.alias; + const isMe = pat.id === me.id; + const isAnonFromProps = pat.anonStatus && pat.anonStatus === newAnonStatus; + + if (isMe) { + opt.isSelf = true; + selfAdded = true; + result.hasBeenSelf = true; + } + + if (pat.isAnon) { + // (opt.isAnon not needed — opt.alias.isAnon is enough.) + if (isAnonFromProps) { + opt.isFromProps = true; + anonFromPropsAdded = true; // [dif_anon_status] + } + result.hasBeenAnon = true; + } + + // if (...) [pseudonyms_later] + + // Is the same persona as any current Persona Mode? + if (me.usePersona) { + const isSelfFromMode = me.usePersona.self && isMe; + const isAnonFromMode = me.usePersona.anonStatus === pat.anonStatus && pat.anonStatus; + if (isSelfFromMode || isAnonFromMode) { + opt.isFromMode = true; + modePersonaAdded = true; + } + } + + // Any persona mode anonym, might not be of the recommended anonymity status. [dif_anon_status] + // But one from the category properties, would be. + if ((isAnonFromProps && anonsRecommended) || (isMe && selfRecommended)) { + opt.isRecommended = true; + recommendedAdded = true; + } + + if ((pat.isAnon && !anonsAllowed) || (isMe && mustBeAnon)) { + opt.isNotAllowed = true; + } + } + + return result; +} + + +export function patToMaybeAnon(p: Pat | KnownAnonym | NewAnon, me: Me): MaybeAnon { + if ((p as WhichAnon).createNew_tst) { + // Then `p` is a WhichAnon already, not a Pat. + return p as NewAnon; + } + + const pat = p as Pat; + + if (pat.id === me.id) { + return false; // means not anon, instead, oneself [oneself_0_false] + } + else if (pat.id === Pats.FutureAnonId) { + // Skip `NewAnon.createNew_tst` for now. [one_anon_per_page] + return { anonStatus: pat.anonStatus, lazyCreate: true } satisfies LazyCreatedAnon; + } + else { + return { anonStatus: pat.anonStatus, sameAnonId: pat.id } as SameAnon; + } +} + + +//------------------------------------------------------------------------------ + } +//------------------------------------------------------------------------------ +// vim: fdm=marker et ts=2 sw=2 tw=0 fo=r list diff --git a/client/app-slim/sidebar/sidebar.ts b/client/app-slim/sidebar/sidebar.ts index e5de7c3f25..9fb9486c70 100644 --- a/client/app-slim/sidebar/sidebar.ts +++ b/client/app-slim/sidebar/sidebar.ts @@ -241,6 +241,7 @@ export var Sidebar = createComponent({ // RENAME to ContextBar // Find 1) all unread comments, sorted in the way they appear on the page // And 2) all visible comments. + // Should fetch from server, if page big. [fetch_alias] const addRecursively = (postNrs: number[]) => { _.each(postNrs, (postNr) => { const post: Post = page.postsByNr[postNr]; @@ -281,7 +282,7 @@ export var Sidebar = createComponent({ // RENAME to ContextBar const rootPost = page.postsByNr[store.rootPostId]; addRecursively(rootPost.childNrsSorted); - _.each(page.postsByNr, (child: Post, childId) => { + _.each(page.postsByNr, (child: Post) => { if (child.postType === PostType.Flat) { addPost(child); } @@ -407,7 +408,7 @@ export var Sidebar = createComponent({ // RENAME to ContextBar title = ''; // "People here recently:" // I18N, unimplemented } else { - const titleText = isChat ? t.cb.UsersInThisChat : t.cb.UsersInThisTopic; + const titleText = isChat ? t.cb.UsersInThisChat : t.cb.UsersInThisTopic; // [users_here] title = r.div({}, titleText, r.span({ className: 'esCtxbar_onlineCol' }, t.Online)); diff --git a/client/app-slim/slim-bundle.d.ts b/client/app-slim/slim-bundle.d.ts index 1c103246e3..506e6f314e 100644 --- a/client/app-slim/slim-bundle.d.ts +++ b/client/app-slim/slim-bundle.d.ts @@ -389,7 +389,8 @@ declare namespace debiki2 { function user_isTrustMinNotThreat(me: UserInclDetails | Myself, trustLevel: TrustLevel): boolean; //function threatLevel_toString(threatLevel: ThreatLevel): [St, St]; function threatLevel_toElem(threatLevel: ThreatLevel); - function pat_name(pat: Me | Pat): St; + function persMode_toStr(mode: PersonaMode, verb: Verbosity): St; + function pat_name(pat: Me | Pat | LazyCreatedAnon | NewAnon): St; function pat_isMe(pat: UserInclDetails | Me | Pat | PatId): pat is Me; function pat_isMember(pat: UserInclDetails | Me | Pat | PatId): Bo; var isGuest; @@ -401,11 +402,18 @@ declare namespace debiki2 { function store_maySendDirectMessageTo(store: Store, user: UserInclDetails): boolean; function pat_isBitAdv(pat: PatVb | Me): Bo; function pat_isMoreAdv(pat: PatVb | Me): Bo; + function anonStatus_toStr(anonStatus: AnonStatus, verb?: Verbosity): St; var page_isGroupTalk; function store_getAuthorOrMissing(store: DiscStore, post: Post): Pat; function store_getUserOrMissing(store: DiscStore, userId: PatId, errorCode2?: St): Pat; - var store_thisIsMyPage; + function store_thisIsMyPage(store: DiscStore): Bo; + function disc_findMyPersonas(discStore: DiscStore, ps: { + forWho: Pat | Me | U, startAtPostNr?: PostNr }): MyPersonasThisPage; + + function findPersonaOptions(ps: { myPersonasThisPage?: MyPersonasThisPage, me: Me, + discProps: DiscPropsDerived }): PersonaOptions; + function patToMaybeAnon(p: Pat | KnownAnonym | NewAnon, me: Me): MaybeAnon; function draftType_toPostType(draftType: DraftType): PostType | U; function postType_toDraftType(postType: PostType): DraftType | U; @@ -511,7 +519,7 @@ declare namespace debiki2 { function timeExact(whenMs: number, clazz?: string); namespace avatar { - var Avatar; + function Avatar(props: AvatarProps): RElm; } function pageNotfPrefTarget_findEffPref(target: PageNotfPrefTarget, store: Store, ownPrefs: OwnPageNotfPrefs): EffPageNotfPref; diff --git a/client/app-slim/start-page.ts b/client/app-slim/start-page.ts index ad27521d1a..ee04005bfb 100644 --- a/client/app-slim/start-page.ts +++ b/client/app-slim/start-page.ts @@ -282,6 +282,7 @@ function renderPageInBrowser() { debiki2.processTimeAgo(numPosts > 20 ? '.dw-ar-p-hd' : ''); const timeAfterTimeAgo = performance.now(); + // (Also calls ReactStore.activateMyself().) debiki2.ReactStore.activateVolatileData(); const timeAfterUserData = performance.now(); diff --git a/client/app-slim/start-stuff.ts b/client/app-slim/start-stuff.ts index cecabdf370..6a1b921cd5 100644 --- a/client/app-slim/start-stuff.ts +++ b/client/app-slim/start-stuff.ts @@ -100,4 +100,4 @@ debiki2.dieIf(location.port && eds.debugOrigin.indexOf(':' + location.port) === } -// vim: fdm=marker et ts=2 sw=2 fo=tcqwn list \ No newline at end of file +// vim: fdm=marker et ts=2 sw=2 fo=tcqwn list diff --git a/client/app-slim/store-getters.ts b/client/app-slim/store-getters.ts index c488489ebe..894f4772f3 100644 --- a/client/app-slim/store-getters.ts +++ b/client/app-slim/store-getters.ts @@ -35,7 +35,7 @@ export function pat_isAuthorOf(pat: Me | Pat, post: Post, patsById: PpsById): Bo // @ifdef DEBUG dieIf(!post, 'TyE2065MRTJ3'); // @endif - // If pat typeof Me, and not logged in, .id is undefined. + // If pat typeof Me, and not logged in, pat.id is Pats.NoPatId == 0, zero. if (!pat.id || !post) return false; // If pat used hens real account (or if pat is an anonym and the post author too). if (pat.id === post.authorId) return true; @@ -45,7 +45,7 @@ export function pat_isAuthorOf(pat: Me | Pat, post: Post, patsById: PpsById): Bo } -export function store_thisIsMyPage(store: Store): boolean { +export function store_thisIsMyPage(store: DiscStore): Bo { const page: Page = store.currentPage; if (!page || !store.me.id) return false; const me: Me = store.me; @@ -75,14 +75,14 @@ export function store_getAuthorOrMissing(store: DiscStore, post: Post): Pat { } // If replying using a new anonym, its future id is not yet konw: + // (This happens when *previewing* one's first anonymous comments on a page. Once + // the comment gets saved, an anonym is created and gets an id.) if (post.authorId === Pats.FutureAnonId) { - return { - id: Pats.FutureAnonId, - // We don't know for sure what name sequence number this anonym will get, - // so let's use '?' instead of A1 or A2 etc. - fullName: "Anonym (you)", - isAnon: true, - }; + // @ifdef DEBUG + dieIf(!post.doAsAnon, 'TyE6032SKGN4'); + // @endif + const anonStatus = post.doAsAnon && post.doAsAnon.anonStatus; + return anon_create({ anonStatus, anonForId: store.me.id, isMe: true }); } const user = store_getUserOrMissing(store, post.authorId); @@ -108,7 +108,7 @@ export function store_getUserOrMissing(store: DiscStore, userId: PatId, // so it'll be easier to debug-find-out that something is amiss. fullName: `□ missing, id: ${userId} [EsE4FK07_]`, isMissing: true, - }; + } satisfies Pat; } return user; } @@ -129,6 +129,7 @@ export function store_getUsersOnThisPage(store: Store): BriefUser[] { const page: Page = store.currentPage; const users: BriefUser[] = []; _.each(page.postsByNr, (post: Post) => { + // Isn't this [On2]? Or rather num-authors*num-posts? if (_.every(users, u => u.id !== post.authorId)) { const user = store_getAuthorOrMissing(store, post); users.push(user); @@ -195,15 +196,14 @@ export function store_canDeletePage(store: Store): Bo { !isSection(page.pageRole) || store_numSubCommunities(store) > 1); return someoneCanDelete && ( isStaff(store.me) || ( - page_authorId(page) === store.me.id && - page.numRepliesVisible == 0)); + store_thisIsMyPage(store) && page.numRepliesVisible == 0)); } export function store_canUndeletePage(store: Store): Bo { const page: Page = store.currentPage; const pat = store.me; - return !!page.pageDeletedAtMs && (isStaff(pat) || page_authorId(page) === pat.id); + return !!page.pageDeletedAtMs && (isStaff(pat) || store_thisIsMyPage(store)); // later, change to: page.deletedById === pat.id } // ------- @@ -253,6 +253,7 @@ export function store_numSubCommunities(store: Store): number { export function store_thereAreFormReplies(store: Store): boolean { + // Should fetch from server, if page big. [fetch_alias] const page: Page = store.currentPage; return _.some(page.postsByNr, (post: Post) => { return post.postType === PostType.CompletedForm; diff --git a/client/app-slim/topbar/topbar.styl b/client/app-slim/topbar/topbar.styl index b6012d014e..e25f50eaeb 100644 --- a/client/app-slim/topbar/topbar.styl +++ b/client/app-slim/topbar/topbar.styl @@ -295,9 +295,12 @@ html.c_Html-EdLeft #esPageColumn margin-top: 0; -.esTB_SearchBtn - padding: 2px 5px 0px 4px; +.DW.DW.DW .esTB_SearchBtn + padding: 5px 5px 5px 4px margin: 0; + vertical-align: middle; + +.esTB_SearchBtn .icon-search color: hsl(0, 0%, 50%); font-size: 21px; @@ -357,19 +360,22 @@ html.c_Html-EdLeft #esPageColumn opacity: 1; .esAvtrName_you, - .esAvtrName_name + .esAvtrName_name, + .esAvtrName_Anon color: #666; letter-spacing: 0.2px; vertical-align: middle; font-size: 13.5px; // Replace the user's perhaps long username with "You" if screen too narrow. - .esAvtrName_you + .esAvtrName_you, + .esAvtrName_Anon display: none; @media (max-width: 640px) .esAvtrName_name display: none; - .esAvtrName_you + .esAvtrName_you, + .esAvtrName_Anon display: inline-block; @media (max-width: 480px) .esAvtrName_you @@ -477,11 +483,17 @@ $snoozeIconSize = 28px; position: relative; top: 1px; +// What's this? Adds padding without breaking right-alignment with things below in +// <divs> of the same width? .esMyMenu padding-right: 6px; position: relative; right: -6px; +// Dim one's true account, if using an alias (so the alias becomes more prominent). +.s_MMB.n_AliOn + opacity: 0.83; + .esMyMenu .esAvtr margin-right: 5px; @@ -514,6 +526,32 @@ $snoozeIconSize = 28px; left: 0; top: 6px; +.c_Tb_AliAw + font-size: 150%; + margin: 0 -6px 0 4px; + @media (max-width: 640px) + margin: 0 -9px 0 0px; + +.DW .c_Tb_Ali.s_MMB.btn + padding: 16px 6px; + margin-left: -6px; // cancel padding (+padding -margin —> button larger) + +.c_Tb_Ali-Switched.btn + font-weight: bold; + text-decoration: underline; + text-underline-offset: 6px; + +// Make [the question mark indicating an alias ambiguity] simpler to spot. +.c_AliAmbig + font-size: 110%; + vertical-align: middle; + +.c_PersInfD_Bs + margin: 23px 0px 9px; + .btn + margin: 0 15px 10px 0; + + .esTopbar position: relative; z-index: 5; @@ -587,12 +625,15 @@ html.esSidebarsOverlayPage .s_Tb_CuNv, .s_Tb_Pg_Cs, .s_Tb_MyBs - margin-top: 15px; + margin-top: 17px; .s_Tb .s_Tb_MyBs white-space: nowrap; .btn + vertical-align: baseline; + padding-top: 2px; + line-height: 1; border-radius: 0; border: none; diff --git a/client/app-slim/topbar/topbar.ts b/client/app-slim/topbar/topbar.ts index a7dad37bc2..481c0f4efb 100644 --- a/client/app-slim/topbar/topbar.ts +++ b/client/app-slim/topbar/topbar.ts @@ -15,6 +15,7 @@ * along with this program. If not, see <http://www.gnu.org/licenses/>. */ +/// <reference path="../../macros/macros.d.ts" /> /// <reference path="../ReactStore.ts" /> /// <reference path="../ReactActions.ts" /> /// <reference path="../links.ts" /> @@ -30,6 +31,7 @@ /// <reference path="../page/cats-or-home-link.ts" /> /// <reference path="../Server.ts" /> /// <reference path="../more-bundle-not-yet-loaded.ts" /> +/// <reference path="../personas/PersonaIndicator.ts" /> //------------------------------------------------------------------------------ namespace debiki2.topbar { @@ -316,17 +318,32 @@ export const TopBar = createComponent({ const otherNotfs = makeNotfIcon('other', me.numOtherNotfs); let isImpersonatingClass = ''; - let impersonatingStrangerInfo; + let impersonatingStrangerInfo: St | U; if (store.isImpersonating) { isImpersonatingClass = ' s_MMB-IsImp'; if (!me.isLoggedIn) { isImpersonatingClass += ' s_MMB-IsImp-Stranger'; - impersonatingStrangerInfo = "Viewing as stranger"; // (skip i18n, is for staff) + impersonatingStrangerInfo = "Viewing as stranger"; // (0I18N, is for staff) // SECURITY COULD add a logout button, so won't need to first click stop-viewing-as, // and then also click Logout. 2 steps = a bit risky, 1 step = simpler, safer. } } + + // ------- Alias info + + // Shows if we're anonymous or have switched to any pseudonym. + + const aliasInfo: RElm | N = !store.userSpecificDataAdded ? null : + personas.PersonaIndicator({ store, isSectionPage }); + + // @ifdef DEBUG + // COULD_OPTIMIZE: This actually happens — we're rerendering at least once too much? + //dieIf(me.usePersona && !aliasInfo, 'TyE60WMJLS256'); + // @endif + + // ------- Username (click opens: ../../app-more/topbar/my-menu.more.ts) + const myAvatar = !me.isLoggedIn ? null : avatar.Avatar({ user: me, origins: store, ignoreClicks: true }); @@ -351,10 +368,11 @@ export const TopBar = createComponent({ const avatarNameDropdown = !me.isLoggedIn && !impersonatingStrangerInfo ? null : utils.ModalDropdownButton({ - // RENAME 'esMyMenu' to 's_MMB' (my-menu button). - className: 'esAvtrName esMyMenu s_MMB' + isImpersonatingClass, // CLEAN_UP RENAME to s_MMB + // RENAME 'esMyMenu' to 'c_MMB' (my-menu button). + className: 'esAvtrName esMyMenu s_MMB' + // CLEAN_UP RENAME to c_MMB + isImpersonatingClass + + (me.usePersona ? ' n_AliOn' : ''), dialogClassName: 's_MM', - ref: 'myMenuButton', showCloseButton: true, bottomCloseButton: true, // MyMenu might list many notifications, and people Command-Click to open @@ -375,7 +393,8 @@ export const TopBar = createComponent({ talkToMeNotfs, talkToOthersNotfs, otherNotfs, - snoozeIcon) }, + snoozeIcon, + ) }, MyMenuContentComponent({ store })); @@ -533,7 +552,9 @@ export const TopBar = createComponent({ // We don't know when CSS hides or shows the 2nd row, so better to // include always. toolsButton, - avatarNameDropdown); + avatarNameDropdown, + aliasInfo && r.span({ className: 'c_Tb_AliAw' }, "→ "), + aliasInfo); // No: //(!use2Rows || justOneRow) && toolsButton, //(!use2Rows || justOneRow) && avatarNameDropdown); diff --git a/client/app-slim/translations.d.ts b/client/app-slim/translations.d.ts index 93cc18833d..d7e7c052a8 100644 --- a/client/app-slim/translations.d.ts +++ b/client/app-slim/translations.d.ts @@ -26,7 +26,16 @@ interface TalkyardTranslations { AddComment?: string; Admin: string; AdvSearch: string; + // Wrap all these "Temp", "Temp Anon", "Perma.. Anon.." in an `anon: {...}` obj, [anon_2_str] + // so they'll end up next to each other? + //Temp?: string; + //TempAnon?: string; + //TempAnonym?: string; + //TemporarilyAnonymous?: string; + //Anon?: string; Anonym?: string; + //Anonymous?: string; + //PermanentlyAnonymous?: string; Away: string; Back: string; BlogN: string; diff --git a/client/app-slim/util/ExplainingDropdown.styl b/client/app-slim/util/ExplainingDropdown.styl index addfbc7a96..4d5a62e0f2 100644 --- a/client/app-slim/util/ExplainingDropdown.styl +++ b/client/app-slim/util/ExplainingDropdown.styl @@ -78,3 +78,21 @@ $sideSpace = 20px; .esExplDrp_entry_sub margin: 8px 20px 16px; + +// If there's an image/icon to the left of the tilte & text. +.esExplDrp_entry:has(.esExplDrp_entry_img) button + display: flex; + // The images a bit function as padding-left, so reduce from $sideSpace = 20px + // to just .esDropModal_header's padding-left = 11px. [proxy_diag_padding] + padding-left: 11px; + +.esExplDrp_entry_img + flex: 0 + + .esAvtr + position: static; + margin-right: 15px; + +.esExplDrp_entry_TtlTxt + flex: 1 + diff --git a/client/app-slim/util/ExplainingDropdown.ts b/client/app-slim/util/ExplainingDropdown.ts index 2df4f4030a..2fd931739b 100644 --- a/client/app-slim/util/ExplainingDropdown.ts +++ b/client/app-slim/util/ExplainingDropdown.ts @@ -81,6 +81,15 @@ export var ExplainingListItem = createComponent({ const elemFn = isButton ? r.button : (props.linkTo ? LinkUnstyled : r.a); + let imgTitleText = rFr({}, + r.div({ className: 'esExplDrp_entry_title' }, entry.title), + r.div({ className: 'esExplDrp_entry_expl' }, entry.text)); + if (props.img) { + imgTitleText = rFr({}, + r.div({ className: 'esExplDrp_entry_img' }, props.img), + r.div({ className: 'esExplDrp_entry_TtlTxt' }, imgTitleText)); + } + return ( r.li({ className: 'esExplDrp_entry' + activeClass + disabledClass }, elemFn.apply(this, [ @@ -96,8 +105,7 @@ export var ExplainingListItem = createComponent({ }, tabIndex: props.tabIndex || 1000, }, - r.div({ className: 'esExplDrp_entry_title' }, entry.title), - r.div({ className: 'esExplDrp_entry_expl' }, entry.text)]), + imgTitleText]), subStuff)); }, }); diff --git a/client/app-slim/utils/DropdownModal.styl b/client/app-slim/utils/DropdownModal.styl index aaad222e6f..8ae87214ff 100644 --- a/client/app-slim/utils/DropdownModal.styl +++ b/client/app-slim/utils/DropdownModal.styl @@ -17,6 +17,8 @@ max-width: 560px; // or annoyingly wide. [dropdown_width] .esDropModal_CloseB + // This doesn't work well: sometimes overlaps long contents. + // Wouldn't float: right be better? But need one extra <div>? [close_cross_css] position: absolute; right: 0; top: 0; @@ -36,7 +38,7 @@ .esDropModal_header font-style: italic; font-size: 14px; - margin: 14px 0 4px 11px; + margin: 14px 0 4px 11px; // [proxy_diag_padding] &:first-child margin-top: 3px; diff --git a/client/app-slim/utils/DropdownModal.ts b/client/app-slim/utils/DropdownModal.ts index fd5c50cbb9..243fbce3de 100644 --- a/client/app-slim/utils/DropdownModal.ts +++ b/client/app-slim/utils/DropdownModal.ts @@ -224,6 +224,7 @@ export const DropdownModal = createComponent({ const props: DropdownProps = this.props; let content; if (props.show) { + // Need one more <div> to be able to use float: right for the close [x]? [close_cross_css] const closeButton = !props.showCloseButton ? null : r.div({ className: 'esDropModal_CloseB esCloseCross', onClick: props.onHide }); diff --git a/client/app-slim/widgets.ts b/client/app-slim/widgets.ts index cdb4df8213..f2cbbfa80e 100644 --- a/client/app-slim/widgets.ts +++ b/client/app-slim/widgets.ts @@ -300,8 +300,7 @@ export function UserName(props: { let namePartTwo: St | RElm | U; if (user.isAnon) { - // There's already "By" before, and "anonym" isn't a name, so use lowercase. - namePartOne = r.span({className: 'esP_By_F esP_By_F-G' }, t.Anonym); + namePartOne = r.span({className: 'esP_By_F esP_By_F-G' }, anonStatus_toStr(user.anonStatus)); if (props.store && user.anonForId) { // maybe always take a DiscStore as fn props? const store = props.store; if (store.me.id === user.anonForId) { diff --git a/client/server/personas/PersonaIndicator.ts b/client/server/personas/PersonaIndicator.ts new file mode 100644 index 0000000000..90daf470f8 --- /dev/null +++ b/client/server/personas/PersonaIndicator.ts @@ -0,0 +1,16 @@ +/// <reference path="../prelude.ts" /> + +//------------------------------------------------------------------------------ + namespace debiki2.personas { +//------------------------------------------------------------------------------ + + +export function PersonaIndicator(ps: any): RElm | N { + return null; +} + + +//------------------------------------------------------------------------------ + } +//------------------------------------------------------------------------------ +// vim: fdm=marker et ts=2 sw=2 tw=0 fo=tcqwn list diff --git a/client/types-and-const-enums.ts b/client/types-and-const-enums.ts index 7095b406d2..a0ef058c05 100644 --- a/client/types-and-const-enums.ts +++ b/client/types-and-const-enums.ts @@ -755,6 +755,17 @@ const enum DiagFlavor { } +const enum Verbosity { + // Can be nice for [power_users], e.g. "T" or "TA" for "Temporarily Anonymous", + // and they'd know what it means. + //Letters = 1, + VeryTerse = 2, + Terse = 3, + Brief = 4, + Full = 6, +} + + const enum Time { OneDayInMillis = 24 * 3600 * 1000, } diff --git a/docs/abbreviations.txt b/docs/abbreviations.txt index 0153ec9ec4..4a7bbbedd4 100644 --- a/docs/abbreviations.txt +++ b/docs/abbreviations.txt @@ -86,10 +86,15 @@ And t_ —> i_ for 'id' attr. s_w-X = wraps an X, i.e., somewhere inside, there's an X. Or "'W'ith a X inside". w_X = wraps X. Always a class, not an id. +0I18N: Internationalization not needed (0 = zero = not), that is, no need to + translate to different languages – only English is fine for now. (Typically because + the message is for admins only, who are supposed to know some English.) + In-the-middle or at-a-word-end abbreviations: (but not at the start of the selector) Ab = about Adm = admin Aft = after +Ali = alias (anonym or pseudonym) Alw = allow(ed) Ann, An = announcement, also see SAn: Server Announcement Asg = assign @@ -180,6 +185,7 @@ Nv = navigation, e.g. NvLs_Ln = Navigation-List Link Nw = new Op = Original post Ord = order +Par = parenhtesis? Maybe sth else too Pat = participant Pb = pagebar Pf = preference, e.g. NfPfs = notification preferences diff --git a/docs/db-schema/README.txt b/docs/db-schema/README.txt index 0b37e01aea..c8a078f666 100644 --- a/docs/db-schema/README.txt +++ b/docs/db-schema/README.txt @@ -9,14 +9,14 @@ for table_name in $(docker-compose exec -T rdb psql -c '\d' talkyard talkyard \ | awk '{ print $3 }') do docker-compose exec -T rdb psql talkyard talkyard -c '\d '"$table_name" \ - | tee ./docs/db-schema-dump/$table_name.txt + | tee ./docs/db-schema/$table_name.txt done # Custom domains, with and without comments: docker-compose exec -T rdb psql talkyard talkyard -c '\dD+' \ - | tee ./docs/db-schema-dump/domains.txt + | tee ./docs/db-schema/domains.txt docker-compose exec -T rdb psql talkyard talkyard -c '\dD' \ - | tee ./docs/db-schema-dump/domains-no-comments.txt + | tee ./docs/db-schema/domains-no-comments.txt ``` Details: \d lists all tables and sequences, this format: diff --git a/docs/db-schema/drafts3.txt b/docs/db-schema/drafts3.txt index f23b32fe9a..a992ef88b8 100644 --- a/docs/db-schema/drafts3.txt +++ b/docs/db-schema/drafts3.txt @@ -19,6 +19,7 @@ text | character varying | | not null | new_anon_status_c | anonym_status_d | | | post_as_id_c | pat_id_d | | | + order_c | f32_d | | | Indexes: "drafts_byuser_nr_p" PRIMARY KEY, btree (site_id, by_user_id, draft_nr) "drafts_byuser_deldat_i" btree (site_id, by_user_id, deleted_at DESC) WHERE deleted_at IS NOT NULL diff --git a/docs/db-schema/notifications3.txt b/docs/db-schema/notifications3.txt index 0cbd87a73d..ec8f081e16 100644 --- a/docs/db-schema/notifications3.txt +++ b/docs/db-schema/notifications3.txt @@ -21,32 +21,38 @@ about_tag_id_c | tag_id_d | | | about_thing_type_c | thing_type_d | | | about_sub_type_c | sub_type_d | | | + by_true_id_c | pat_id_d | | | + to_true_id_c | pat_id_d | | | Indexes: - "dw1_notfs_id__p" PRIMARY KEY, btree (site_id, notf_id) - "dw1_ntfs_createdat_email_undecided__i" btree (created_at) WHERE email_status = 1 - "dw1_ntfs_emailid" btree (site_id, email_id) - "dw1_ntfs_postid__i" btree (site_id, about_post_id_c) WHERE about_post_id_c IS NOT NULL - "dw1_ntfs_seen_createdat__i" btree (( -CASE - WHEN seen_at IS NULL THEN created_at + '100 years'::interval - ELSE created_at -END) DESC) + "notfs_p_notfid" PRIMARY KEY, btree (site_id, notf_id) "notfs_i_aboutcat_topat" btree (site_id, about_cat_id_c, to_user_id) WHERE about_cat_id_c IS NOT NULL "notfs_i_aboutpage_topat" btree (site_id, about_page_id_str_c, to_user_id) WHERE about_page_id_str_c IS NOT NULL "notfs_i_aboutpat_topat" btree (site_id, about_pat_id_c, to_user_id) WHERE about_pat_id_c IS NOT NULL "notfs_i_aboutpost_patreltype_frompat_subtype" btree (site_id, about_post_id_c, action_type, by_user_id, action_sub_id) WHERE about_post_id_c IS NOT NULL + "notfs_i_aboutpostid" btree (site_id, about_post_id_c) WHERE about_post_id_c IS NOT NULL "notfs_i_abouttag_topat" btree (site_id, about_tag_id_c, to_user_id) WHERE about_tag_id_c IS NOT NULL "notfs_i_aboutthingtype_subtype" btree (site_id, about_thing_type_c, about_sub_type_c) WHERE about_thing_type_c IS NOT NULL "notfs_i_bypat" btree (site_id, by_user_id) WHERE by_user_id IS NOT NULL - "notfs_touser_createdat__i" btree (site_id, to_user_id, created_at DESC) - "notfs_touser_post__i" btree (site_id, to_user_id, about_post_id_c) + "notfs_i_bytrueid" btree (site_id, by_true_id_c) WHERE by_true_id_c IS NOT NULL + "notfs_i_createdat_but_unseen_first" btree (( +CASE + WHEN seen_at IS NULL THEN created_at + '100 years'::interval + ELSE created_at +END) DESC) + "notfs_i_createdat_if_undecided" btree (created_at) WHERE email_status = 1 + "notfs_i_emailid" btree (site_id, email_id) + "notfs_i_totrueid_createdat" btree (site_id, to_true_id_c, created_at DESC) WHERE to_true_id_c IS NOT NULL + "notfs_i_touserid_aboutpostid" btree (site_id, to_user_id, about_post_id_c) + "notfs_i_touserid_createdat" btree (site_id, to_user_id, created_at DESC) Check constraints: "dw1_notfs_emailstatus__c_in" CHECK (email_status >= 1 AND email_status <= 20) "dw1_notfs_seenat_ge_createdat__c" CHECK (seen_at > created_at) "dw1_ntfs__c_action" CHECK ((action_type IS NOT NULL) = (action_sub_id IS NOT NULL)) "dw1_ntfs_by_to__c_ne" CHECK (by_user_id::text <> to_user_id::text) "notfs_c_aboutthingtype_subtype_null" CHECK ((about_thing_type_c IS NULL) = (about_sub_type_c IS NULL)) + "notfs_c_byuserid_ne_bytrueid" CHECK (by_user_id <> by_true_id_c::integer) "notfs_c_notftype_range" CHECK (notf_type >= 101 AND notf_type <= 999) + "notfs_c_touserid_ne_totrueid" CHECK (to_user_id <> to_true_id_c::integer) "notifications_c_id_not_for_imp" CHECK (notf_id < 2000000000) Foreign-key constraints: "notfs_aboutcat_r_cats" FOREIGN KEY (site_id, about_cat_id_c) REFERENCES categories3(site_id, id) DEFERRABLE @@ -55,6 +61,8 @@ Foreign-key constraints: "notfs_aboutpost_reltype_frompat_subtype_r_patrels" FOREIGN KEY (site_id, about_post_id_c, action_type, by_user_id, action_sub_id) REFERENCES post_actions3(site_id, to_post_id_c, rel_type_c, from_pat_id_c, sub_type_c) DEFERRABLE "notfs_abouttag_r_tags" FOREIGN KEY (site_id, about_tag_id_c) REFERENCES tags_t(site_id_c, id_c) DEFERRABLE "notfs_bypat_r_pats" FOREIGN KEY (site_id, by_user_id) REFERENCES users3(site_id, user_id) DEFERRABLE + "notfs_bytrueid_r_pats" FOREIGN KEY (site_id, by_true_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE + "notfs_totrueid_r_pats" FOREIGN KEY (site_id, to_true_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE "ntfs_post_r_posts" FOREIGN KEY (site_id, about_post_id_c) REFERENCES posts3(site_id, unique_post_id) DEFERRABLE "ntfs_r_emails" FOREIGN KEY (site_id, email_id) REFERENCES emails_out3(site_id, email_id_c) DEFERRABLE "ntfs_r_sites" FOREIGN KEY (site_id) REFERENCES sites3(id) DEFERRABLE diff --git a/docs/db-schema/post_actions3.txt b/docs/db-schema/post_actions3.txt index f7cabf676d..64b42b3460 100644 --- a/docs/db-schema/post_actions3.txt +++ b/docs/db-schema/post_actions3.txt @@ -16,14 +16,16 @@ to_post_rev_nr_c | rev_nr_d | | | dormant_status_c | dormant_status_d | | | val_i32_c | i32_d | | | - as_pat_id_c | pat_id_d | | | + from_true_id_c | pat_id_d | | | added_by_id_c | member_id_d | | | + order_c | f32_d | | | Indexes: "dw2_postacs__p" PRIMARY KEY, btree (site_id, to_post_id_c, rel_type_c, from_pat_id_c, sub_type_c) "dw2_postacs_deletedby__i" btree (site_id, deleted_by_id) WHERE deleted_by_id IS NOT NULL - "dw2_postacs_page_byuser" btree (site_id, page_id, from_pat_id_c) + "patnoderels_i_pageid_frompatid" btree (site_id, page_id, from_pat_id_c) + "patnoderels_i_pageid_fromtrueid" btree (site_id, page_id, from_true_id_c) WHERE from_true_id_c IS NOT NULL "patnodesinrels_i_addedbyid" btree (site_id, added_by_id_c) WHERE added_by_id_c IS NOT NULL - "patnodesinrels_i_aspatid" btree (site_id, as_pat_id_c) WHERE as_pat_id_c IS NOT NULL + "patnodesinrels_i_aspatid" btree (site_id, from_true_id_c) WHERE from_true_id_c IS NOT NULL "patrels_i_frompat_reltype_addedat" btree (site_id, from_pat_id_c, rel_type_c, created_at DESC) "patrels_i_frompat_reltype_addedat_0dormant" btree (site_id, from_pat_id_c, rel_type_c, created_at DESC) WHERE dormant_status_c IS NULL Check constraints: @@ -35,8 +37,8 @@ Check constraints: "postactions_c_postnr_not_for_imp" CHECK (post_nr < 2000000000) "postactions_c_subid_not_for_imp" CHECK (sub_type_c < 2000000000) Foreign-key constraints: + "patnoderels_fromtrueid_r_pats" FOREIGN KEY (site_id, from_true_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE "patnodesinrels_addedbyid_r_pats" FOREIGN KEY (site_id, added_by_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE - "patnodesinrels_aspatid_r_pats" FOREIGN KEY (site_id, as_pat_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE "postacs_createdby_r_people" FOREIGN KEY (site_id, from_pat_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE "postacs_deletedby_r_people" FOREIGN KEY (site_id, deleted_by_id) REFERENCES users3(site_id, user_id) DEFERRABLE "postacs_r_posts" FOREIGN KEY (site_id, to_post_id_c) REFERENCES posts3(site_id, unique_post_id) DEFERRABLE diff --git a/docs/db-schema/posts3.txt b/docs/db-schema/posts3.txt index 120ba39963..1b3ea9781d 100644 --- a/docs/db-schema/posts3.txt +++ b/docs/db-schema/posts3.txt @@ -65,10 +65,12 @@ index_prio_c | index_prio_d | | | private_status_c | private_status_d | | | creator_status_c | creator_status_d | | | + order_c | f32_d | | | Indexes: "dw2_posts_id__p" PRIMARY KEY, btree (site_id, unique_post_id) "dw2_posts_page_postnr__u" UNIQUE, btree (site_id, page_id, post_nr) "posts_u_extid" UNIQUE, btree (site_id, ext_id) + "posts_u_patid_pageid_parentnr_if_bookmark_0deld" UNIQUE, btree (site_id, created_by_id, page_id, parent_nr) WHERE type::smallint = 51 AND deleted_status = 0 "dw2_posts_approvedbyid__i" btree (site_id, approved_by_id) WHERE approved_by_id IS NOT NULL "dw2_posts_closedbyid__i" btree (site_id, closed_by_id) WHERE closed_by_id IS NOT NULL "dw2_posts_collapsedbyid__i" btree (site_id, collapsed_by_id) WHERE collapsed_by_id IS NOT NULL @@ -84,6 +86,8 @@ Indexes: "posts_i_createdat_id" btree (site_id, created_at DESC, unique_post_id DESC) "posts_i_createdby_createdat_id" btree (site_id, created_by_id, created_at DESC, unique_post_id DESC) "posts_i_lastapprovedat_0deld" btree (site_id, GREATEST(approved_at, last_approved_edit_at)) WHERE approved_at IS NOT NULL AND deleted_status = 0 + "posts_i_patid_createdat_postid_if_bookmark_0deld" btree (site_id, created_by_id, created_at, unique_post_id) WHERE type::smallint = 51 AND deleted_status = 0 + "posts_i_patid_createdat_postid_if_bookmark_deld" btree (site_id, created_by_id, created_at, unique_post_id) WHERE type::smallint = 51 AND deleted_status <> 0 "posts_i_privatepatsid" btree (site_id, private_pats_id_c) WHERE private_pats_id_c IS NOT NULL Check constraints: "dw2_posts__c_approved" CHECK ((approved_rev_nr IS NULL) = (approved_at IS NULL) AND (approved_rev_nr IS NULL) = (approved_by_id IS NULL) AND (approved_rev_nr IS NULL) = (approved_source IS NULL)) @@ -111,11 +115,15 @@ Check constraints: "dw2_posts_multireply__c_num" CHECK (multireply::text ~ '^([0-9]+,)*[0-9]+$'::text) "dw2_posts_parent__c_not_title" CHECK (parent_nr <> 0) "posts__c_first_rev_by_creator" CHECK (curr_rev_by_id = created_by_id OR curr_rev_nr > 1) + "posts_c_bookmark_neg_nr" CHECK (type::smallint <> 51 OR post_nr <= '-1001'::integer) + "posts_c_bookmark_not_appr" CHECK (type::smallint <> 51 OR approved_at IS NULL) "posts_c_currevnr_not_for_imp" CHECK (curr_rev_nr < 2000000000) "posts_c_extid_ok" CHECK (is_valid_ext_id(ext_id)) "posts_c_id_not_for_imp" CHECK (unique_post_id < 2000000000) "posts_c_nr_not_for_imp" CHECK (post_nr < 2000000000) + "posts_c_order_only_bookmarks_for_now" CHECK (order_c IS NULL OR type::smallint = 51) "posts_c_parentnr_not_for_imp" CHECK (parent_nr < 2000000000) + "posts_c_privatecomt_neg_nr" CHECK (private_status_c IS NULL OR post_nr <= '-1001'::integer) Foreign-key constraints: "posts_approvedby_r_people" FOREIGN KEY (site_id, approved_by_id) REFERENCES users3(site_id, user_id) DEFERRABLE "posts_closedby_r_people" FOREIGN KEY (site_id, closed_by_id) REFERENCES users3(site_id, user_id) DEFERRABLE diff --git a/docs/db-schema/settings3.txt b/docs/db-schema/settings3.txt index cd4a0a8040..66aa9f67c0 100644 --- a/docs/db-schema/settings3.txt +++ b/docs/db-schema/settings3.txt @@ -130,6 +130,8 @@ ai_conf_c | jsonb_ste16000_d | | | enable_online_status_c | boolean | | | follow_links_to_c | text_nonempty_ste2000_d | | | + own_domains_c | text_nonempty_ste2000_d | | | + authn_diag_conf_c | jsonb_ste8000_d | | | Indexes: "settings3_site_category" UNIQUE, btree (site_id, category_id) WHERE category_id IS NOT NULL "settings3_site_page" UNIQUE, btree (site_id, page_id) WHERE page_id IS NOT NULL diff --git a/docs/db-schema/tags_t.txt b/docs/db-schema/tags_t.txt index a83a6c9f39..d4f5393703 100644 --- a/docs/db-schema/tags_t.txt +++ b/docs/db-schema/tags_t.txt @@ -14,7 +14,7 @@ val_url_c | http_url_ste_250_d | | | val_jsonb_c | jsonb_ste1000_d | | | val_i32_b_c | i32_d | | | - val_f64_b_c | i32_d | | | + val_f64_b_c | f64_d | | | Indexes: "tags_p_id" PRIMARY KEY, btree (site_id_c, id_c) "tags_u_id_patid" UNIQUE CONSTRAINT, btree (site_id_c, id_c, on_pat_id_c) diff --git a/docs/db-schema/users3.txt b/docs/db-schema/users3.txt index 47c24b4ce8..272315f773 100644 --- a/docs/db-schema/users3.txt +++ b/docs/db-schema/users3.txt @@ -219,6 +219,8 @@ Referenced by: TABLE "links_t" CONSTRAINT "links_toppid_r_pps" FOREIGN KEY (site_id_c, to_pat_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE TABLE "notifications3" CONSTRAINT "notfs_aboutpat_r_pats" FOREIGN KEY (site_id, about_pat_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE TABLE "notifications3" CONSTRAINT "notfs_bypat_r_pats" FOREIGN KEY (site_id, by_user_id) REFERENCES users3(site_id, user_id) DEFERRABLE + TABLE "notifications3" CONSTRAINT "notfs_bytrueid_r_pats" FOREIGN KEY (site_id, by_true_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE + TABLE "notifications3" CONSTRAINT "notfs_totrueid_r_pats" FOREIGN KEY (site_id, to_true_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE TABLE "notices_t" CONSTRAINT "notices_r_pats" FOREIGN KEY (site_id_c, to_pat_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE TABLE "notifications3" CONSTRAINT "ntfs_touser_r_people" FOREIGN KEY (site_id, to_user_id) REFERENCES users3(site_id, user_id) DEFERRABLE TABLE "page_notf_prefs_t" CONSTRAINT "pagenotfprefs_r_people" FOREIGN KEY (site_id, pat_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE @@ -244,8 +246,8 @@ Referenced by: TABLE "page_users3" CONSTRAINT "pageusers_joinedby_r_people" FOREIGN KEY (site_id, joined_by_id) REFERENCES users3(site_id, user_id) DEFERRABLE TABLE "page_users3" CONSTRAINT "pageusers_kickedby_r_people" FOREIGN KEY (site_id, kicked_by_id) REFERENCES users3(site_id, user_id) DEFERRABLE TABLE "page_users3" CONSTRAINT "pageusers_user_r_people" FOREIGN KEY (site_id, user_id) REFERENCES users3(site_id, user_id) DEFERRABLE + TABLE "post_actions3" CONSTRAINT "patnoderels_fromtrueid_r_pats" FOREIGN KEY (site_id, from_true_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE TABLE "post_actions3" CONSTRAINT "patnodesinrels_addedbyid_r_pats" FOREIGN KEY (site_id, added_by_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE - TABLE "post_actions3" CONSTRAINT "patnodesinrels_aspatid_r_pats" FOREIGN KEY (site_id, as_pat_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE TABLE "users3" CONSTRAINT "pats_trueid_r_pats" FOREIGN KEY (site_id, true_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE TABLE "perms_on_pages3" CONSTRAINT "permsonpages_r_people" FOREIGN KEY (site_id, for_people_id) REFERENCES users3(site_id, user_id) DEFERRABLE TABLE "post_actions3" CONSTRAINT "postacs_createdby_r_people" FOREIGN KEY (site_id, from_pat_id_c) REFERENCES users3(site_id, user_id) DEFERRABLE diff --git a/docs/maybe-do-later.txt b/docs/maybe-do-later.txt index 374f459aba..8b8ff18b04 100644 --- a/docs/maybe-do-later.txt +++ b/docs/maybe-do-later.txt @@ -142,6 +142,7 @@ regardless of how they inherited permissions from other groups. [granular_perms] E.g. may-see-others'-sessions. Currently only amdins may, but sometimes, good if mods could too? +Also see: [wiki_perms] [tags] Nice props / tags in Gerrit: https://chromium-review.googlesource.com/c/v8/v8/+/2537690 @@ -214,6 +215,7 @@ Can start at 21 = lightweight mod, 22 = mod, 23 = admin, 24 = owner? Should there be a move-page-perm, not just alter-page? [power_mod] +[power_users] [inherit_group_priv_prefs] One should inherit privacy and noise settings from one's closest trust level group, diff --git a/docs/tests-map.txt b/docs/tests-map.txt index 005575bdf0..8bb5f2501f 100644 --- a/docs/tests-map.txt +++ b/docs/tests-map.txt @@ -298,22 +298,83 @@ anonymous comments, anons: partly impl: - tests/app/debiki/dao/AnonymAppSpec.scala + Make work again, and code review: + - tests/e2e-wdio7/specs/alias-anons-basic.2br.f.e2e.ts + - tests/e2e-wdio7/specs/alias-anons-true-mixed.2br.f.e2e.ts TESTS_MISSING: + - See ../wip/aliases/auto-test-thoughts.txt + - Anon mode, send priv msg? + - Callers of checkAliasOrThrowForbidden() & getAliasOrTruePat() [misc_alias_tests] - Anons for drafts, UNTSETED. - Load anon drafts, restore as anon. TyTANONDFLOAD - Cannot move anon post to other page. TyTMOVANONCOMT - Notfs from anons (comments, votes, more?), don't include real user name TyTNOTFFROMANON - - UNTESTED; TESTS_MISSING // exp imp anons? True ids are incl in json dumps? - - Different anon statuses: Comts start anon / NeverAlways.AlwaysButCanContinue / etc + - UNTESTED; TESTS_MISSING // exp imp anons? + True ids are incl in json dumps? [export_privid] + - Can't vote many times using different personas TyTALIVOTES + - Reuse anons: At most one per page & person [one_anon_per_page]. + Verify cannot create many, by opening many browser tabs + & posting one anon comment in each? - Edit & update the draft. TyTEDUPDDFT04 - Save anon draft, reuse an anon. TyTDFTREUSEANON. + - Save draft using the wrong alias — should work, but needs to choose an + ok alias before actually posting (not just saving a draft). + - When editing own post TyTDRAFTALI + - When editing sbd else's post - Edit comment as anon: Anon name in new edit revision, & email notfs. TyTANONEDIT - Post pages as anon - - Accept/unaccept answer - - Close/reopen page - - Delete/undelete own page - - Rename page - - Change page status PageTitleSettingsController + - Post page as anon, + edit as self + alter as self + - Post page as self + edit as anon + alter as anon + - Post comment as anon + edit as self + alter as self + - Post comment as self + edit as anon + alter as anon + - Change own anon post to wiki & back + others' not/anon post to wiki & back as anon / not-anon oneself + - Edit other's page text + - Alter other's page TyTALIALTERPG, PageTitleSettingsController + - Alter = + - Accept/unaccept answer + - Close/reopen page + - Delete/undelete own anon page + - Delete/undelete own anon comment + - Rename page + - Change page type, doing status, answer + - Move to other category, where: + - Alias allowed + - Alias not allowed + - Start composing new topic, change to/from anon cat, see Post As ... + appear/disappear, and "You cannot be anonymous ..." etc info diags pop up. + - Reply to oneself on anon-rec page where's been anon elsewhere —> self, etc + - Reply anonly, go to sbd's profile page, compose priv msg: Should become oneself + - Anon-by-def page, start composig a reply (as anon), cancel, go to sbd's profile page, + compose priv msg: Should now post as oneself (not anon any more). + - Edit history: Proper names (e.g. Anon, not one's true name) shown? + - Comments + - Page title + - Orig post + - Different anon statuses: Comts start anon / NeverAlways.AlwaysButCanContinue / etc + - Page with both Perm Anon and Temp Anon comments: [dif_anon_status] + - Like-vote OP, gets to choose: Perm Anon or Temp Anon, or Self + - Temp Anon in temp anon sub thread + - Perm Anon in perm anon sub thread + - Can't move anon comments to other pages + - Persona indicator updated properly when: (lots of combinations!) + starting on anon never / allowed / recommened / always page, + - navigating to anon recommended cat, then to not-anon + - navigating to not-anon cat, then recommended cat, then not-anon + back to anon / not-anon page + - Anon posts enabled + - tests/app/debiki/dao/AnonymAppSpec.scala TyTANON0ENBL TyEM0MKANON_ + - in one cat, not in another + - in sub cat + - on specific page - ... and more different users - diff --git a/docs/tyworld.adoc b/docs/tyworld.adoc index 56e881f2a3..d8f28fc9df 100644 --- a/docs/tyworld.adoc +++ b/docs/tyworld.adoc @@ -138,14 +138,23 @@ A new post gets a new id and can work as the new id for a new flag of the same t == Anonyms and Pseudonyms +Also see: ../wip/aliases/wip.txt. This section here in tyworld.adoc is out-of-date! + === Anonyms posts When you anonymously, then, a new anonym (anonymous user) is created, for you, -to use on that particular page. You can't use it outside that page +to use in the current discussion, on that specific page. You can't use it outside that page (instead, another then gets created). Anon comments cannot be moved to other pages, and aren't visible to others (only to yourself) in the activity list on your user profile page, or in your posts counts. [list_anon_posts] +Currently, you can have only one anonym persona per page [one_anon_per_page]. +(Actually, you can have more, if someone moves the page between categories that use +different types of anonyms (temporary anonyms or permanent anonyms), and you post +anonymous comments of each type. In such cases, you can have one anonym +per page and anonym type (`AnonStatus`)) It'd be confusing if people could create many +anonymous users and pretend to be many people, in a single discussion. + The software remembers if you want to be anonymous or not, per discussion page, by looking at your previous comments in that discussion and sub thread you're replying in — because maybe you're using using your @@ -169,10 +178,11 @@ Categories can be 1) always-anonymous, or 2) anon by default, or 3) real account by default but anon posts allowed. Or 4) anon posts not allowed (only real accounts). Edit: See `[NeverAlways]`. -A category can be configured to get _de-anonymized_ after a while (!). -That is, say two weeks after a new page has been posted in that category, -the real usernames are shown so everyone can see who wrote what. - +Not implemented: If, when enabling anonymity in a category, the admin selects +_Better ideas & decisions_ as the purpose (but not _Sensitive Discussions_), then, +the category can be configured to get _de-anonymized_ after a while (!). +For example, two weeks after a new page has been posted in that category, +the real usernames get shown so everyone can see who wrote what. In such categories, before posting, there's an obtrusive info box about this, so everyone will know how it works. @@ -209,8 +219,12 @@ your pseudonym's comments surprisingly often or infrequently — then, your pseu could get a different reputation than your main account. Although you're the same person. `[pseudonyms_trust]` +Possibly, there'll be different types of pseudonyms. See +../wip/aliases/wip.txt [pseudonym_types]. + === Tech notes +Not impl, and this'll change: Implementation wise, to show notifications from all one's pseudonyms, Talkyard does one lookup per pseudonym. So that's why you cannot have hundereds of pseudonyms (because then this'd be slow). diff --git a/relchans/tyse-v0-dev b/relchans/tyse-v0-dev index 9502168518..9b9f590da4 160000 --- a/relchans/tyse-v0-dev +++ b/relchans/tyse-v0-dev @@ -1 +1 @@ -Subproject commit 95021685184b96db8ebcca93f8b7e42dbf0547b2 +Subproject commit 9b9f590da473869c6bb9336b1226eaad545c23bc diff --git a/s/run-e2e-tests.sh b/s/run-e2e-tests.sh index 56ebd95233..56d41d6698 100755 --- a/s/run-e2e-tests.sh +++ b/s/run-e2e-tests.sh @@ -535,6 +535,13 @@ function runAllE2eTests { $r s/wdio-7 --only may-see-email-adrs.2br.d --cd -i $args + # Aliases (anonyms, pseudonyms) + # ------------ + + #$r s/wdio-7 --only alias-anons-true-mixed.2br.f --cd -i $args + #$r s/wdio-7 --only alias-anons-basic.2br.f --cd -i $args + + # API # ------------ diff --git a/tests/app/debiki/dao/AnonymAppSpec.scala b/tests/app/debiki/dao/AnonymAppSpec.scala index 3e9a73e943..a46ff1ea19 100644 --- a/tests/app/debiki/dao/AnonymAppSpec.scala +++ b/tests/app/debiki/dao/AnonymAppSpec.scala @@ -74,12 +74,30 @@ class AnonymAppSpec extends DaoAppSuite( userTwoS1 = createPasswordUser("mm33ww77", site1.dao, trustLevel = TrustLevel.BasicMember) } - "Try post anonymously — but anon posts are disabled, by default" in { + "Try post anonymously — but anon posts not enabled, request rejected TyTANON0ENBL" in { + val dao = site1.dao + val ex = intercept[ResultException] { + createPage2(PageType.Discussion, dao.textAndHtmlMaker.forTitle("Anon Test"), + bodyTextAndHtml = dao.textAndHtmlMaker.forBodyOrComment("Test anon post."), + authorId = userOneS1.id, browserIdData, dao, anyCategoryId = Some(catA.id), + asAlias = Some(WhichAliasPat.LazyCreatedAnon(AnonStatus.IsAnonOnlySelfCanDeanon))) + } + ex.getMessage must include("TyEM0MKANON_") + } + + "Enable anon posts" in { + val dao = site1.dao + editCategory(catA, createCatAResult.permissionsWithIds, + browserIdData, dao, + comtsStartAnon = Some(Some(NeverAlways.Recommended))) + } + + "Try post anonymously — now anon posts are enabled" in { val dao = site1.dao createPageResult = createPage2(PageType.Discussion, dao.textAndHtmlMaker.forTitle("Anon Test"), bodyTextAndHtml = dao.textAndHtmlMaker.forBodyOrComment("Test anon post."), authorId = userOneS1.id, browserIdData, dao, anyCategoryId = Some(catA.id), - doAsAnon = Some(WhichAnon.NewAnon(AnonStatus.IsAnonOnlySelfCanDeanon))) + asAlias = Some(WhichAliasPat.LazyCreatedAnon(AnonStatus.IsAnonOnlySelfCanDeanon))) pageId = createPageResult.id } diff --git a/tests/app/debiki/dao/DaoAppSuite.scala b/tests/app/debiki/dao/DaoAppSuite.scala index adf562d0f2..853c9e1785 100644 --- a/tests/app/debiki/dao/DaoAppSuite.scala +++ b/tests/app/debiki/dao/DaoAppSuite.scala @@ -348,19 +348,20 @@ class DaoAppSuite( def editCategory(cat: Cat, permissions: ImmSeq[PermsOnPages], browserIdData: BrowserIdData, dao: SiteDao, newParentId: Opt[CatId] = None, - newSectPageId: Opt[PageId] = None): Cat = { + newSectPageId: Opt[PageId] = None, + comtsStartAnon: Opt[Opt[NeverAlways]] = None, + ): Cat = { var catToSave = CategoryToSave.initFrom(cat) - newParentId map { parCatId => - catToSave = catToSave.copy(parentId = parCatId) - } - newSectPageId map { sectPageId => - catToSave = catToSave.copy(sectionPageId = sectPageId) - } + catToSave = catToSave.copy( + parentId = newParentId.getOrElse(catToSave.parentId), + sectionPageId = newSectPageId.getOrElse(catToSave.sectionPageId), + comtsStartAnon = comtsStartAnon.getOrElse(catToSave.comtsStartAnon), + ) dao.editCategory(catToSave, permissions, who = Who.System) } - REMOVE; CLEAN_UP // use createPage2 instead, and rename it to createPage(). + REMOVE; CLEAN_UP // use createPageSkipAuZ instead, and rename it to createPage(). def createPage(pageRole: PageType, titleTextAndHtml: TitleSourceAndHtml, bodyTextAndHtml: TextAndHtml, authorId: UserId, browserIdData: BrowserIdData, dao: SiteDao, anyCategoryId: Option[CategoryId] = None, @@ -374,15 +375,14 @@ class DaoAppSuite( def createPage2(pageRole: PageType, titleTextAndHtml: TitleSourceAndHtml, bodyTextAndHtml: TextAndHtml, authorId: UserId, browserIdData: BrowserIdData, dao: SiteDao, anyCategoryId: Option[CategoryId] = None, - doAsAnon: Opt[WhichAnon.NewAnon] = None, + asAlias: Opt[WhichAliasPat.LazyCreatedAnon] = None, extId: Option[ExtId] = None, discussionIds: Set[AltPageId] = Set.empty): CreatePageResult = { - dao.createPage2( + dao.createPageSkipAuZ( pageRole, PageStatus.Published, anyCategoryId = anyCategoryId, withTags = Nil, anyFolder = Some("/"), anySlug = Some(""), title = titleTextAndHtml, bodyTextAndHtml = bodyTextAndHtml, showId = true, deleteDraftNr = None, Who(authorId, browserIdData), dummySpamRelReqStuff, - doAsAnon = doAsAnon, - discussionIds = discussionIds, extId = extId) + asAlias = asAlias, discussionIds = discussionIds, extId = extId) } @@ -391,7 +391,7 @@ class DaoAppSuite( val textAndHtml = if (skipNashorn) textAndHtmlMaker.testBody(text) else textAndHtmlMaker.forBodyOrComment(text) - dao.insertReply(textAndHtml, pageId, + dao.insertReplySkipAuZ(textAndHtml, pageId, replyToPostNrs = Set(parentNr getOrElse PageParts.BodyNr), PostType.Normal, deleteDraftNr = None, Who(TrueId(memberId), browserIdData), dummySpamRelReqStuff).post } diff --git a/tests/app/debiki/dao/DeletePageAppSpec.scala b/tests/app/debiki/dao/DeletePageAppSpec.scala index d7991d271c..a934666808 100644 --- a/tests/app/debiki/dao/DeletePageAppSpec.scala +++ b/tests/app/debiki/dao/DeletePageAppSpec.scala @@ -56,7 +56,7 @@ class DeletePageAppSpec extends DaoAppSuite(disableScripts = true, disableBackgr // Delete all pages. dao.deletePagesIfAuth(Seq(discussionId, forumId, htmlPageId), - Who(admin.trueId2, browserIdData), undelete = false) + Who(admin.trueId2, browserIdData), asAlias = None, undelete = false) // Verify marked as deleted. dao.getPageMeta(discussionId).get.deletedAt mustBe defined @@ -66,7 +66,7 @@ class DeletePageAppSpec extends DaoAppSuite(disableScripts = true, disableBackgr // Undelete, verify no longer marked as deleted. dao.deletePagesIfAuth(Seq(discussionId, forumId, htmlPageId), - Who(admin.trueId2, browserIdData), undelete = true) + Who(admin.trueId2, browserIdData), asAlias = None, undelete = true) dao.getPageMeta(discussionId).get.deletedAt mustBe None dao.getPageMeta(forumId).get.deletedAt mustBe None dao.getPageMeta(htmlPageId).get.deletedAt mustBe None @@ -79,7 +79,7 @@ class DeletePageAppSpec extends DaoAppSuite(disableScripts = true, disableBackgr for (pageId <- Seq(discussionId, htmlPageId, otherPageId)) { intercept[ResultException] { dao.deletePagesIfAuth( - Seq(pageId), Who(moderator.trueId2, browserIdData), undelete = false) + Seq(pageId), Who(moderator.trueId2, browserIdData), None, undelete = false) dao.getPageMeta(discussionId).get.deletedAt mustBe defined }.getMessage must include("TyEM0SEEPG_") } @@ -90,7 +90,7 @@ class DeletePageAppSpec extends DaoAppSuite(disableScripts = true, disableBackgr "non-staff also may not delete pages they cannot see" in { intercept[ResultException] { dao.deletePagesIfAuth( - Seq(discussionId), Who(user.trueId2, browserIdData), undelete = false) + Seq(discussionId), Who(user.trueId2, browserIdData), None, undelete = false) }.getMessage must include("TyEM0SEEPG_") } @@ -113,34 +113,34 @@ class DeletePageAppSpec extends DaoAppSuite(disableScripts = true, disableBackgr "now mods can delete discussions — they may now see them" - { "delete page" in { dao.deletePagesIfAuth( - Seq(discussionId), Who(moderator.trueId2, browserIdData), undelete = false) + Seq(discussionId), Who(moderator.trueId2, browserIdData), None, undelete = false) dao.getPageMeta(discussionId).get.deletedAt mustBe defined } "undelete page" in { dao.deletePagesIfAuth( - Seq(discussionId), Who(moderator.trueId2, browserIdData), undelete = true) + Seq(discussionId), Who(moderator.trueId2, browserIdData), None, undelete = true) dao.getPageMeta(discussionId).get.deletedAt mustBe None } "still cannot delete the *other* page, it's still not in the forum" in { intercept[ResultException] { dao.deletePagesIfAuth( - Seq(otherPageId), Who(moderator.trueId2, browserIdData), undelete = false) + Seq(otherPageId), Who(moderator.trueId2, browserIdData), None, undelete = false) }.getMessage must include("TyEM0SEEPG_") } "cannot delete forum" in { intercept[ResultException] { dao.deletePagesIfAuth( - Seq(forumId), Who(moderator.trueId2, browserIdData), undelete = false) + Seq(forumId), Who(moderator.trueId2, browserIdData), None, undelete = false) }.getMessage must include("EsE5GKF23_") } "cannot delete custom html page" in { intercept[ResultException] { dao.deletePagesIfAuth( - Seq(htmlPageId), Who(moderator.trueId2, browserIdData), undelete = false) + Seq(htmlPageId), Who(moderator.trueId2, browserIdData), None, undelete = false) }.getMessage must include("EsE5GKF23_") } } @@ -148,14 +148,15 @@ class DeletePageAppSpec extends DaoAppSuite(disableScripts = true, disableBackgr "do nothing if page doesn't exist" in { val admin = createPasswordOwner(s"dltr_adm2", dao) val badPageId = "zzwwffpp" - dao.deletePagesIfAuth(Seq(badPageId), Who(admin.trueId2, browserIdData), undelete = false) + dao.deletePagesIfAuth( + Seq(badPageId), Who(admin.trueId2, browserIdData), None, undelete = false) dao.getPageMeta(badPageId) mustBe None } "non-staff users still cannot see the pages — they're in the staff cat" in { intercept[ResultException] { dao.deletePagesIfAuth( - Seq(discussionId), Who(user.trueId2, browserIdData), undelete = false) + Seq(discussionId), Who(user.trueId2, browserIdData), None, undelete = false) }.getMessage must include("TyEM0SEEPG_") } @@ -166,7 +167,7 @@ class DeletePageAppSpec extends DaoAppSuite(disableScripts = true, disableBackgr "non-staff can now see it — but still may not delete it" in { intercept[ResultException] { dao.deletePagesIfAuth( - Seq(discussionId), Who(user.trueId2, browserIdData), undelete = false) + Seq(discussionId), Who(user.trueId2, browserIdData), None, undelete = false) }.getMessage must include("TyEDELOTRSPG_") } diff --git a/tests/app/debiki/dao/DraftsDaoAppSpec.scala b/tests/app/debiki/dao/DraftsDaoAppSpec.scala index dd4bad6053..90b1f7af5e 100644 --- a/tests/app/debiki/dao/DraftsDaoAppSpec.scala +++ b/tests/app/debiki/dao/DraftsDaoAppSpec.scala @@ -416,7 +416,7 @@ class DraftsDaoAppSpec extends DaoAppSuite(disableScripts = true, disableBackgro createdAt = now, forWhat = locator, postType = Some(PostType.Normal), - doAsAnon = Some(WhichAnon.NewAnon(AnonStatus.IsAnonCanAutoDeanon)), + doAsAnon = Some(WhichAliasId.LazyCreatedAnon(AnonStatus.IsAnonCanAutoDeanon)), title = "", text = DraftSixAnonReplyText) diff --git a/tests/app/debiki/dao/MovePostsAppSpec.scala b/tests/app/debiki/dao/MovePostsAppSpec.scala index 83b6723bb3..3f28a1e0f9 100644 --- a/tests/app/debiki/dao/MovePostsAppSpec.scala +++ b/tests/app/debiki/dao/MovePostsAppSpec.scala @@ -188,7 +188,7 @@ class MovePostsAppSpec extends DaoAppSuite(disableScripts = true, disableBackgro val pageTwoId = createPage(PageType.Discussion, textAndHtmlMaker.testTitle("Page Two"), textAndHtmlMaker.testBody("Body two."), SystemUserId, browserIdData, dao) - val postOnPageTwo = dao.insertReply(textAndHtmlMaker.testBody("Post on page 2."), pageTwoId, + val postOnPageTwo = dao.insertReplySkipAuZ(textAndHtmlMaker.testBody("Post on page 2."), pageTwoId, replyToPostNrs = Set(PageParts.BodyNr), PostType.Normal, deleteDraftNr = None, Who(SystemUserId, browserIdData = browserIdData), dummySpamRelReqStuff).post @@ -256,7 +256,7 @@ class MovePostsAppSpec extends DaoAppSuite(disableScripts = true, disableBackgro val pageTwoId = createPage(PageType.Discussion, textAndHtmlMaker.testTitle("Page Two"), textAndHtmlMaker.testBody("Body two."), SystemUserId, browserIdData, dao) - val postOnPageTwo = dao.insertReply(textAndHtmlMaker.testBody("Post on page 2."), pageTwoId, + val postOnPageTwo = dao.insertReplySkipAuZ(textAndHtmlMaker.testBody("Post on page 2."), pageTwoId, replyToPostNrs = Set(PageParts.BodyNr), PostType.Normal, deleteDraftNr = None, Who(SystemUserId, browserIdData = browserIdData), dummySpamRelReqStuff).post @@ -299,7 +299,7 @@ class MovePostsAppSpec extends DaoAppSuite(disableScripts = true, disableBackgro lastReplyOrigPage.nr mustBe (otherPost.nr + 1) info("can add replies to the new page") - val lastPostPageTwo = dao.insertReply(textAndHtmlMaker.testBody("Last post, page 2."), pageTwoId, + val lastPostPageTwo = dao.insertReplySkipAuZ(textAndHtmlMaker.testBody("Last post, page 2."), pageTwoId, replyToPostNrs = Set(maxNewNr), PostType.Normal, deleteDraftNr = None, Who(SystemUserId, browserIdData), dummySpamRelReqStuff).post @@ -316,7 +316,7 @@ class MovePostsAppSpec extends DaoAppSuite(disableScripts = true, disableBackgro val pageTwoId = createPage(PageType.Discussion, textAndHtmlMaker.testTitle("Page Two"), textAndHtmlMaker.testBody("Body two."), SystemUserId, browserIdData, dao) - val postOnPageTwo = dao.insertReply(textAndHtmlMaker.testBody("Post on page 2."), pageTwoId, + val postOnPageTwo = dao.insertReplySkipAuZ(textAndHtmlMaker.testBody("Post on page 2."), pageTwoId, replyToPostNrs = Set(PageParts.BodyNr), PostType.Normal, deleteDraftNr = None, Who(SystemUserId, browserIdData = browserIdData), dummySpamRelReqStuff).post diff --git a/tests/app/debiki/dao/ReviewStuffAppSuite.scala b/tests/app/debiki/dao/ReviewStuffAppSuite.scala index d34a9bec50..73577cd785 100644 --- a/tests/app/debiki/dao/ReviewStuffAppSuite.scala +++ b/tests/app/debiki/dao/ReviewStuffAppSuite.scala @@ -57,7 +57,7 @@ class ReviewStuffAppSuite(randomString: String) def testAdminsRepliesApproved(adminId: UserId, pageId: PageId) { for (i <- 1 to 10) { - val result = dao.insertReply(textAndHtmlMaker.testBody(s"reply_9032372 $r, i = $i"), pageId, + val result = dao.insertReplySkipAuZ(textAndHtmlMaker.testBody(s"reply_9032372 $r, i = $i"), pageId, replyToPostNrs = Set(PageParts.BodyNr), PostType.Normal, deleteDraftNr = None, Who(adminId, browserIdData), dummySpamRelReqStuff) result.post.isCurrentVersionApproved mustBe true @@ -66,7 +66,7 @@ class ReviewStuffAppSuite(randomString: String) } def reply(memberId: UserId, text: String): InsertPostResult = { - dao.insertReply(textAndHtmlMaker.testBody(text), thePageId, + dao.insertReplySkipAuZ(textAndHtmlMaker.testBody(text), thePageId, replyToPostNrs = Set(PageParts.BodyNr), PostType.Normal, deleteDraftNr = None, Who(memberId, browserIdData), dummySpamRelReqStuff) } diff --git a/tests/app/talkyard/server/links/LinksAppSpec.scala b/tests/app/talkyard/server/links/LinksAppSpec.scala index a8138b68af..382c314735 100644 --- a/tests/app/talkyard/server/links/LinksAppSpec.scala +++ b/tests/app/talkyard/server/links/LinksAppSpec.scala @@ -401,7 +401,7 @@ class LinksAppSpec extends DaoAppSuite { "Links from deleted *pages* are ignored TyT7RD3LM5" - { "Delete page A".inWriteTx(daoSite1) { (tx, staleStuff) => daoSite1.deletePagesImpl( - Seq(pageA.id), systemWho)(tx, staleStuff) + Seq(pageA.id), systemWho, asAlias = None)(tx, staleStuff) } "Now only pages B, C and D links to Z".inReadTx(daoSite1) { tx => @@ -433,7 +433,7 @@ class LinksAppSpec extends DaoAppSuite { "Undelete page A".inWriteTx(daoSite1) { (tx, staleStuff) => daoSite1.deletePagesImpl( - Seq(pageA.id), systemWho, undelete = true)(tx, staleStuff) + Seq(pageA.id), systemWho, asAlias = None, undelete = true)(tx, staleStuff) } "Undelete category".inWriteTx(daoSite1) { (tx, staleStuff) => @@ -467,7 +467,7 @@ class LinksAppSpec extends DaoAppSuite { "Delete page Z".inWriteTx(daoSite1) { (tx, staleStuff) => daoSite1.deletePagesImpl( - Seq(pageZ.id), systemWho, undelete = true)(tx, staleStuff) + Seq(pageZ.id), systemWho, asAlias = None, undelete = true)(tx, staleStuff) } "Can find links to deleted page Z".inReadTx(daoSite1) { tx => diff --git a/tests/e2e-wdio7/package.json b/tests/e2e-wdio7/package.json index 7c72296a88..fb14c395a1 100644 --- a/tests/e2e-wdio7/package.json +++ b/tests/e2e-wdio7/package.json @@ -14,7 +14,7 @@ "@wdio/spec-reporter": "^7.20.3", "@wdio/types": "^7.20.3", "axios": "^0.26.1", - "chromedriver": "^126.0.3", + "chromedriver": "^128.0.0", "paseto.js": "^0.1.7", "ts-node": "^10.9.1", "wdio-chromedriver-service": "^7.3.2" diff --git a/tests/e2e-wdio7/specs/page-type-problem-statuses.2br.d.e2e.ts b/tests/e2e-wdio7/specs/page-type-problem-statuses.2br.d.e2e.ts index fc1194acbd..d0bbbc52ab 100644 --- a/tests/e2e-wdio7/specs/page-type-problem-statuses.2br.d.e2e.ts +++ b/tests/e2e-wdio7/specs/page-type-problem-statuses.2br.d.e2e.ts @@ -28,7 +28,7 @@ const mariasOpReplyReply = 'mariasOpReplyReply'; // .Last_status_change post nr will be 5, and the first answer will be 5 + 1: const okaySolutionPostNr = 6; -const optimalSolutionPostNr = 7; +const optimalSolutionPostNr = 6 + 2; describe("page-type-problem-statuses.2br.d TyT602AKK73", () => { @@ -139,10 +139,13 @@ describe("page-type-problem-statuses.2br.d TyT602AKK73", () => { }); it(`But Maria can — it's her page. She selects her comment as the solution`, async () => { + // Generates meta comment nr = okaySolutionPostNr + 1: "@maria accepted an answer" await mariasBrowser.topic.selectPostNrAsAnswer(okaySolutionPostNr); }); it(`Maria posts an even better solution`, async () => { + // Becomes nr = okaySolutionPostNr + 2. + assert.eq(optimalSolutionPostNr, okaySolutionPostNr + 2); await mariasBrowser.complex.replyToOrigPost(`Do it three times`) }); @@ -188,7 +191,6 @@ describe("page-type-problem-statuses.2br.d TyT602AKK73", () => { }); it(`Corax, wisely, selects as solution Maria's best comment TyTCORECAN`, async () => { - assert.eq(optimalSolutionPostNr, okaySolutionPostNr + 1); await corax_brB.topic.selectPostNrAsAnswer(optimalSolutionPostNr); }); diff --git a/tests/e2e-wdio7/specs/page-type-question-closed.2br.d.e2e.ts b/tests/e2e-wdio7/specs/page-type-question-closed.2br.d.e2e.ts index 3e049ca5f3..1eed088f23 100644 --- a/tests/e2e-wdio7/specs/page-type-question-closed.2br.d.e2e.ts +++ b/tests/e2e-wdio7/specs/page-type-question-closed.2br.d.e2e.ts @@ -27,8 +27,9 @@ let mariasTopicUrl: string; const catAnserNr = c.FirstReplyNr; const otterAnserNr = c.SecondReplyNr; -const closeEventPostNr = 4; -const reopenEventPostNr = 7; +const selectAnswerPostNr = 4; +const closeEventPostNr = selectAnswerPostNr + 6; // = 10 +const reopenEventPostNr = closeEventPostNr + 3; // = 13 describe(`page-type-question-closed.2br.d TyTPATYQUESTCLOSD`, () => { @@ -91,20 +92,21 @@ describe(`page-type-question-closed.2br.d TyTPATYQUESTCLOSD`, () => { await mariasBrowser.refresh2(); await mariasBrowser.topic.waitForPostNrVisible(catAnserNr); assert.ok(await mariasBrowser.topic.canSelectAnswer()); // (2PR5PH) - await mariasBrowser.topic.selectPostNrAsAnswer(2); // a cat + // Meta comment, nr 4 == selectAnswerPostNr + await mariasBrowser.topic.selectPostNrAsAnswer(catAnserNr); // a cat }); it("... unselects", async () => { - await mariasBrowser.topic.unselectPostNrAsAnswer(2); // no cat + await mariasBrowser.topic.unselectPostNrAsAnswer(catAnserNr); // nr 5 }); it("... selects another", async () => { - await mariasBrowser.topic.selectPostNrAsAnswer(3); // an otter + await mariasBrowser.topic.selectPostNrAsAnswer(otterAnserNr); // nr 6 }); it("... unselects it, selects it again", async () => { - await mariasBrowser.topic.unselectPostNrAsAnswer(3); - await mariasBrowser.topic.selectPostNrAsAnswer(3); + await mariasBrowser.topic.unselectPostNrAsAnswer(otterAnserNr); // nr 7 + await mariasBrowser.topic.selectPostNrAsAnswer(otterAnserNr); // nr 8 }); it("She can click the check mark icon next to the title, to view the answer", async () => { @@ -118,8 +120,16 @@ describe(`page-type-question-closed.2br.d TyTPATYQUESTCLOSD`, () => { await owensBrowser.complex.loginWithPasswordViaTopbar(owen); }); + it("... sees 5 select or unselect answer meta comments", async () => { + assert.that(await owensBrowser.topic.isPostNrVisible(selectAnswerPostNr)); + assert.that(await owensBrowser.topic.isPostNrVisible(selectAnswerPostNr + 1)); + assert.that(await owensBrowser.topic.isPostNrVisible(selectAnswerPostNr + 2)); + assert.that(await owensBrowser.topic.isPostNrVisible(selectAnswerPostNr + 3)); + assert.that(await owensBrowser.topic.isPostNrVisible(selectAnswerPostNr + 4)); + }); + it("... and unselects the answer", async () => { - await owensBrowser.topic.unselectPostNrAsAnswer(otterAnserNr); + await owensBrowser.topic.unselectPostNrAsAnswer(otterAnserNr); // nr 9 }); it("... and closes the topic", async () => { @@ -130,54 +140,69 @@ describe(`page-type-question-closed.2br.d TyTPATYQUESTCLOSD`, () => { it("Maria wants to select Otter as answer again, but she cannot (page closed)", async () => { await mariasBrowser.topic.refreshUntilPostNrAppears(closeEventPostNr, { isMetaPost: true }); await mariasBrowser.topic.waitForPostNrVisible(c.FirstReplyNr); + assert.not(await owensBrowser.topic.isPostNrVisible(closeEventPostNr + 1)); // ttt assert.not(await mariasBrowser.topic.canSelectAnswer()); // (2PR5PH) }); it("... instead she replies", async () => { - await mariasBrowser.complex.replyToPostNr(3, "Thanks! Such a good idea"); // becomes post nr 5 + await mariasBrowser.complex.replyToPostNr(otterAnserNr, "Thanks! Such a good idea"); // nr 11 }); it("... and post a progress reply", async () => { - await mariasBrowser.complex.addProgressReply("Thanks everyone! An otter then, a bath tube, and fish.") + await mariasBrowser.complex.addProgressReply( + "Thanks everyone! An otter then, a bath tube, and fish.") // nr 12 }); it("Owen reopens the topic", async () => { assert.not(await owensBrowser.topic.isPostNrVisible(reopenEventPostNr)); // ttt - await owensBrowser.topic.reopenTopic(); // generates post nr 7 = reopenEventPostNr + await owensBrowser.topic.reopenTopic(); // generates post nr `reopenEventPostNr` nr 13 }); it("Now Maria can select Otter — but oh no! She accidentally selects Cat", async () => { await mariasBrowser.refresh2(); - await mariasBrowser.topic.selectPostNrAsAnswer(catAnserNr); + await mariasBrowser.topic.selectPostNrAsAnswer(catAnserNr); // nr 14 }); it("... Currently needs to refres for all posts to appear", async () => { - await mariasBrowser.topic.refreshUntilPostNrAppears(7, { isMetaPost: true }); - // There's no post nr 8 (accepting & unaccepting answers, don't currently - // generate meta posts). - assert.not(await owensBrowser.topic.isPostNrVisible(8)); // ttt + await mariasBrowser.topic.refreshUntilPostNrAppears(reopenEventPostNr + 1, { isMetaPost: true }); + // There's no post nr `reopenEventPostNr + 2`. + assert.not(await owensBrowser.topic.isPostNrVisible(reopenEventPostNr + 2)); // ttt }); it("Everything is in the correct order", async () => { await mariasBrowser.topic.waitForPostAssertTextMatches(1, "a cat or an otter?"); await mariasBrowser.topic.assertPostTextMatches(2, "Yes, a cat"); await mariasBrowser.topic.assertPostTextMatches(3, "Yes, an otter"); - assert.eq(closeEventPostNr, 4); - await mariasBrowser.topic.assertMetaPostTextMatches(4, "closed"); - await mariasBrowser.topic.assertPostTextMatches(5, "good idea"); - await mariasBrowser.topic.assertPostTextMatches(6, "Thanks everyone!"); - assert.eq(reopenEventPostNr, 7); - await mariasBrowser.topic.assertMetaPostTextMatches(7, "reopened"); + await mariasBrowser.topic.assertMetaPostTextMatches(selectAnswerPostNr, /^accepted an answer/); + await mariasBrowser.topic.assertMetaPostTextMatches(selectAnswerPostNr + 1, "unaccepted an answer"); + await mariasBrowser.topic.assertMetaPostTextMatches(closeEventPostNr, "closed"); // nr 10 + await mariasBrowser.topic.assertPostTextMatches(closeEventPostNr + 1, "good idea"); // nr 11 + await mariasBrowser.topic.assertPostTextMatches(closeEventPostNr + 2, "Thanks everyone!"); // nr 12 + + await mariasBrowser.topic.assertMetaPostTextMatches(reopenEventPostNr, "reopened"); + }); + + it("Everything is in the correct order", async () => { await mariasBrowser.topic.assertPostOrderIs([ c.TitleNr, c.BodyNr, 2, // cat 3, // otter - 5, // `——— the "Good idea" reply - 4, // the topic-closed event - 6, // the "Thanks everyone" comment - 7]); // the topic-reopened event + 11, // `——— the "Good idea" reply + + 4, // selects cat + 5, // unselects + 6, // selects otter + 7, // unselects + 8, // reselects + 9, // Owen unselects + 10, // Owen closes page + + 12, // the "Thanks everyone" comment + 13, // Owen reopens page + 14, // Maria selects cat, accidentally + ]); }); it("Owen leaves, Corax arrives", async () => { diff --git a/tests/e2e-wdio7/utils/ty-e2e-test-browser.ts b/tests/e2e-wdio7/utils/ty-e2e-test-browser.ts index 3628a44bcb..64bec67d9b 100644 --- a/tests/e2e-wdio7/utils/ty-e2e-test-browser.ts +++ b/tests/e2e-wdio7/utils/ty-e2e-test-browser.ts @@ -3654,16 +3654,16 @@ export class TyE2eTestBrowser { deletePage: async () => { await this.waitAndClick('.dw-a-tools'); - await this.waitUntilDoesNotMove('.e_DelPg'); - await this.waitAndClick('.e_DelPg'); + await this.waitUntilDoesNotMove('.e_DelPgB'); + await this.waitAndClick('.e_DelPgB'); await this.waitUntilModalGone(); await this.topic.waitUntilPageDeleted(); }, restorePage: async () => { await this.waitAndClick('.dw-a-tools'); - await this.waitUntilDoesNotMove('.e_RstrPg'); - await this.waitAndClick('.e_RstrPg'); + await this.waitUntilDoesNotMove('.e_UndelPgB'); + await this.waitAndClick('.e_UndelPgB'); await this.waitUntilModalGone(); await this.topic.waitUntilPageRestored(); }, @@ -4921,7 +4921,7 @@ export class TyE2eTestBrowser { }, editTitle: async (title: string) => { - await this.waitAndSetValue('#e2eTitleInput', title); + await this.waitAndSetValue('.c_TtlE_TtlI input', title); }, save: async () => { @@ -6500,7 +6500,7 @@ export class TyE2eTestBrowser { } }, - assertMetaPostTextMatches: async (postNr: PostNr, text: St) => { + assertMetaPostTextMatches: async (postNr: PostNr, text: St | RegExp) => { await this.assertTextMatches(`#post-${postNr} .s_MP_Text`, text) }, diff --git a/tests/e2e-wdio7/yarn.lock b/tests/e2e-wdio7/yarn.lock index 8fdb7a85a4..ea7b9e43ab 100644 --- a/tests/e2e-wdio7/yarn.lock +++ b/tests/e2e-wdio7/yarn.lock @@ -960,10 +960,10 @@ axios@^0.26.1: dependencies: follow-redirects "^1.14.8" -axios@^1.6.7: - version "1.6.8" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.8.tgz#66d294951f5d988a00e87a0ffb955316a619ea66" - integrity sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ== +axios@^1.7.4: + version "1.7.5" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.5.tgz#21eed340eb5daf47d29b6e002424b3e88c8c54b1" + integrity sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw== dependencies: follow-redirects "^1.15.6" form-data "^4.0.0" @@ -1251,13 +1251,13 @@ chrome-launcher@^0.15.0: is-wsl "^2.2.0" lighthouse-logger "^1.0.0" -chromedriver@^126.0.3: - version "126.0.3" - resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-126.0.3.tgz#5c1c8f586b0832f00286391218a56460b2d605c5" - integrity sha512-4o+ZK8926/8lqIlnnvcljCHV88Z8IguEMB5PInOiS9/Lb6cyeZSj2Uvz+ky1Jgyw2Bn7qCLJFfbUslaWnvUUbg== +chromedriver@^128.0.0: + version "128.0.0" + resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-128.0.0.tgz#7f75a984101199e0bcc2c92fe9f91917fcd1f918" + integrity sha512-Ggo21z/dFQxTOTgU0vm0V59Mi79yyR+9AUk/KiVAsRfbDRdVZQYQWfgxnIvD/x8KOKn0oB7haRzDO/KfrKyvOA== dependencies: "@testim/chrome-version" "^1.1.4" - axios "^1.6.7" + axios "^1.7.4" compare-versions "^6.1.0" extract-zip "^2.0.1" proxy-agent "^6.4.0" diff --git a/tests/e2e/utils/pages-for.ts b/tests/e2e/utils/pages-for.ts index 77e03332d7..143e2247e8 100644 --- a/tests/e2e/utils/pages-for.ts +++ b/tests/e2e/utils/pages-for.ts @@ -3269,16 +3269,16 @@ export class TyE2eTestBrowser { deletePage: () => { this.waitAndClick('.dw-a-tools'); - this.waitUntilDoesNotMove('.e_DelPg'); - this.waitAndClick('.e_DelPg'); + this.waitUntilDoesNotMove('.e_DelPgB'); + this.waitAndClick('.e_DelPgB'); this.waitUntilModalGone(); this.topic.waitUntilPageDeleted(); }, restorePage: () => { this.waitAndClick('.dw-a-tools'); - this.waitUntilDoesNotMove('.e_RstrPg'); - this.waitAndClick('.e_RstrPg'); + this.waitUntilDoesNotMove('.e_UndelPgB'); + this.waitAndClick('.e_UndelPgB'); this.waitUntilModalGone(); this.topic.waitUntilPageRestored(); }, @@ -4376,7 +4376,7 @@ export class TyE2eTestBrowser { }, editTitle: (title: string) => { - this.waitAndSetValue('#e2eTitleInput', title); + this.waitAndSetValue('.c_TtlE_TtlI input', title); }, save: () => { diff --git a/version.txt b/version.txt index f3959c16ee..798315efb5 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v0.2024.005 +v0.2024.006 diff --git a/wip/aliases/auto-test-thoughts.txt b/wip/aliases/auto-test-thoughts.txt new file mode 100644 index 0000000000..ee3c20d892 --- /dev/null +++ b/wip/aliases/auto-test-thoughts.txt @@ -0,0 +1,64 @@ + Category: + Anons always: + + Self selected: + Anons allowed / recommended: + + Pseudonym selected: + Anons allowed / recommended: + + Anonym selected: + Anons allowed / recommended: + + Nothing selected: + Anons allowed: + Anons recommended: + + + Page: + Self selected: + Has anon already: + Anons allowed: + Anons recommended: + Anons always: + + Has no anon: + Anons allowed: + Anons recommended: + Anons always: + + + Pseudonym selected: + Has anon already: + Anons allowed: + Anons recommended: + Anons always: + + Has no anon: + Anons allowed: + Anons recommended: + Anons always: + + + Anonym selected: + Has anon already: + Anons allowed: + Anons recommended: + Anons always: + + Has no anon: + Anons allowed: + Anons recommended: + Anons always: + + Nothing selected: + Has anon already: + Anons allowed: + Anons recommended: + Anons always: + + Has no anon: + Anons allowed: + Anons recommended: + Anons always: + diff --git a/wip/aliases/wip.txt b/wip/aliases/wip.txt index 7ce8904598..6ff1011488 100644 --- a/wip/aliases/wip.txt +++ b/wip/aliases/wip.txt @@ -4,12 +4,12 @@ Alias = anonym or pseudonym Intro: -- Anonyms are per page. This is safer — can't track a single anonym accross different - conversations. And more social? In that, if you want, you can show who you - actually are, in one discussion, while staying anonymous in all others. +- Anonyms are per page. [one_anon_per_page] This is safer — can't track a single anonym + accross different conversations. And more social? In that, if you want, you can show + who you actually are, in one discussion, while remaining anonymous in all others. And good for ideation: Anonymous, temporarily. See the Ideation section. - Pseduonyms (a.k.a pen names) can be reused accross pages and categories. - They are more social, but less "safe". + They are more social, but less "safe". (Not implemented.) - Both anonyms and pseudonyms inherit (some of) the permissions of the underlying true user. (Later, this will be more configurable? But not initially?) See [pseudonym_types] below. @@ -30,35 +30,53 @@ then, this is a big box of butterflies! Left to do ----------------------- -Quick: +Now: + - Save persona mode in ... localStorage? Cookie? [remember_persona_mode] + - [hide_peoples_dates] [deanon_risk] + - Notifications: List notifications to all one's aliases (anonyms & pseudonyms), + not just to oneself. [list_alias_things] [posts3_true_id] + - Actions: List comments & pages by one's aliases, on one's recent comment page + (but of course others, not even mods or admins, can see this). [list_by_alias] + - Bind anonForPatId to ... Unknown user id? for now, in json dumps. [export_privid] + + +Later, quick: - RENAME // to ...ByTrueId? val anonsByRealId = anons.groupBy(_.anonForPatId) - - - Code review: - - tests/e2e-wdio7/specs/alias-anons-basic.2br.f.e2e.ts - - tests/e2e-wdio7/specs/alias-anons-true-mixed.2br.f.e2e.ts + - Rename def getAliasOrTruePat() to getPersona()? And "nnnMaybeAnon" to "nnnPersona"? Minor & medium: - - Notifications: List notifications to all one's aliases (anonyms & pseudonyms), - not just to oneself. [list_alias_things] - - Actions: List comments & pages by one's aliases, on one's recent comment page - (but of course others, not even mods or admins, can see this). - - Authz.scala: [_pass_alias] [deanon_risk] - - Reuse getAliasOrTruePat() everywhere [get_anon] - - Accept/unaccept answer - - Close/reopen page - - Delete/undelete own page - - Rename page - - mayEditPage(.., asAlias, ...) in PageTitleSettingsController [may_use_alias] - - Flag (report) things anonymously? - - Search for all DO_AS_ALIAS - - Search for all ANON_UNIMPL -(Also see: [anon_pages]) + - Remember post-as-oneself in drafts3, use post_as_id_c, and, + - Remember anon status, also when reusing anons, in drafts3. + - Verify anonStatus is correct, when reusing an anon or resuming a draft [chk_alias_status] + + - Button title: "Post as yourself", "Post anonymously]", in >= anon-allowed cats. + - Require 2 clicks to choose anon, but not at the same coordinates? [deanon_risk] [mouse_slip] -Later: + - Let aliases edit wikis [alias_ed_wiki] (normally, editing + others' posts anonymously isn't allowed) + - wikify-dialog.more.ts choose persona (if posted as anon, should wikify as same anon) + - Flag (report) things anonymously? [anon_flags] + - Search for all ANON_UNIMPL + - Better error messages: [alias_ux] [dif_anon_status] + - All audit log doerTrueId should be like: doingAs.trueId2 not reqr.trueId2 + - Let mods & admins edit their own anon posts as themselves? See [true_0_ed_alias] + and [mods_ed_own_anon] in _checkDeanonRiskOfEdit(). + + - An optional intro guide that explains anon comments? [anon_comts_guide] + Like the admin intro tour, but about anon comments & votes, and for everyone. + See `staffTours: StaffTours` in ../../client/app-staff/admin/staff-tours.staff.ts. + + - Also maybe show a dialog if a mod or admin edits group settings or helps someone + configure their settings: Clarify that such changes aren't anonymous. + Basically any changes on the user and group profile pages. + +Even later: + - A [see_alias] permission, so can choose to see who an anonym is, + or can vote to see who an anon is. - Ask the server which alias to use, instead of deriving client side [fetch_alias] - e.g. if the page is big? Also prevents any misbehaving clients. + e.g. if the page is big? - Incl true id in json dumps, depending on site sensitivity settings [export_privid] Maybe add sth like talkyard.server.parser.JsonConf that says if any doerId.privId should be included or not? SensitiveConf or PrivacyConf maybe? @@ -70,22 +88,39 @@ Later: - Think about anonymous votes and how many have read this-or-that comment, when the same person visits & comments sometimes as hanself, sometimes using an alias. See: PagePopularityCalculator [alias_vote_stats] + Answer: If sensitive discussions, each anonym has the same weight (doesn't matter who + the true user is). And if ideation, and temporary anon comments: Then, votes might even + be hidden (to prevent anchoring bias and social biases), and later, when showing + people's votes, people aren't temp-anonymous any more anyway (so, can calculate + page popularity as usual). - [pseudonyms_later] [pseudonyms_trust] - - [anon_priv_msgs][anon_chats] + - Later: Anonymous chat messages? [anon_chats] - [sql_true_id_eq] Look at later, realted to anonyms and pseudonyms (looking up on both true id & anon id). + - Anonymous/pseudonymous direct messages [anon_priv_msgs]. (Disabled by default?) + - Panic button + - Ask which persona to use, if changing category when composing new topic. [ask_if_needed] + (Not that important, now there's an info message.) + - Pass doer (usually the author) and trueDoer to NotificationGenerator, + so won't need to lookup inside. [notf_pass_doer] Tests missing: /^alias/ in test-map.txt. Large: - - Alias selector (see below) - - Dim buttons + - Alias selector (see below) ... Oh, now implemented alraedy. + + - Dim buttons [_dim_buttons] (that you cannot click if in Anon mode — but this requires + the client to somehow reproduce all can-do-what permission calculations the server does, + and for both (!) oneself (does a button appear at all), and one's current persona + (should the button be dimmed, that is, one can click it using one's real account, + but not as one's current persona). - Anonymous moderator actions [anon_mods]: Let mods do things anonymously, but using different per-page-&-user anons, so no one can see if replies by an anon, are in fact by one of the mods (if that anon later did - sth only mods can do). — Look at all `getAliasOrTruePat()` and [get_anon]. + sth only mods can do). — Look at all `getAliasOrTruePat()`. + Or maybe post-as-group is better, all that's needed? (see just below) - Group aliases / post as group? [group_pseudonyms] So many people can post, using the same name. @@ -98,12 +133,22 @@ Much later: - More anonymous modes, e.g. one that doesn't remember who an anonym is — not in email logs, audit logs, etc. +Skip?: + - Anonymous embedded comments. Not that important — can comment as a guest instead? + Would need to ask which persona to use, in the comments iframe, before opening editor, + so can show dialog next to the reply/edit btns. + - Optionally disallow moving a page with anonymous comments, to a category + where anon comments aern't allowed. + See: mayEditPage(.., asAlias, ...) in PageTitleSettingsController [move_anon_page] + - One person with [many_anons_per_page]. (Except for anonymous moderation, but maybe + a shared moderator pseudonym is better.) + For sensitive discussions ======================= -Alias selector [alias_mode] [choose_alias] +Alias selector [persona_mode] [alias_mode] ----------------------- A preferred-alias selector in one's upper right username menu, which determines which of one's aliases gets used, in places where one can post as: @@ -114,23 +159,19 @@ which of one's aliases gets used, in places where one can post as: If you comment or vote on a page where you have used another alias that the one currently selected (in the alias selector), then, Talkyard asks: "You previously commented here as X, but you've activated pseudonym Y. - Continue commenting as X? [yes, as X / no, as Y / cancel]" + Continue commenting as: X? Y? [Cancel]" -And, since Y is a somewhat unlikely answer, then dobuble check: +And, since Y is a somewhat unlikely answer, dobuble check: (not impl) [mouse_slip] "Ok, you'll appear as Y [yes, fine / no, cancel]" -Or, if the current mode is Anonymous, and you visit a discussion where you've -replied as yourself previously, and you hit Reply, then, a dialog: - - "Continue using your real name in this discussion? [y / n] - You're in Anonymous mode but on this page you've used your real name previously." - (Might seem as if this double check question is an extra step, but it'll almost never happen that anyone replies as X and later on what to continue as Y in the same discussion. — Maybe shouldn't even be allowed; could be a config setting.) +Or, if the current mode is Anonymous, and you visit a discussion where you've +replied as yourself previously, then the choose-persona dialog appears too. -Dim buttons +Dim buttons [_dim_buttons] ----------------------- When a not-oneself alias has been selected, then, all buttons that don't work with alias mode, could be dimmed a bit? For example, if one is a moderator, @@ -171,12 +212,12 @@ Think about Can even make sense with different types of pseudonyms? - One "type" that has the same access permissions as one's true account? - Another that doesn't inherit anything from one's true account? - But can instead be granted permissions independently of one's true account. + But can optionally be granted permissions independently of one's true account. This, though, is more like a completely separate account, but you access it by first logging in to your true account, then switching accounts. -Guessing who is who? [deanon_risk] +Guessing who is who? [deanon_risk] [mod_deanon_risk] ----------------------- - People's slightly unique writing style can be a "problem", can be mitigated by letting an AI transform all anon comments to a similar writing style. @@ -184,8 +225,10 @@ Guessing who is who? [deanon_risk] comments from the same time span, otherwise just silence the whole night. Repeated a few times, then, easy to make a good guess about who posted the anon comments. — Mitigated by hiding last-active-at & posted-at timestamps, or making timestamps coarse-grained, - e.g. showing only day or week (but not hour and minute). + e.g. showing only day or week (but not hour and minute). [hide_peoples_dates] - Inherits permissions — see [pseudonym_types] below. + - Posting anonymously in a category few people have access too. + - Anonymously moving a page from one access restricted cat to another. - 9999 more things... When posting anonymously, often safer to [not_use_true_users]' permissions. @@ -203,19 +246,11 @@ Lots of work good guesses about who the pseudonyms are? - If repeatedly doing very different things that few others can do, using the same pseudonym, then what? Calculate total % likelihood that someone - correctly guesses their true identidy, looking at all past actions, and if too high, + correctly guesses their true identity, looking at all past actions + (can use AI and spend a bunch of lifetimes, researching this?), and if too high, consider switching to a new pseudonym? — Maybe simpler to just suggest that the user switches to a new pseudonym after N time units or M interactions/comments. - -Would be nice ------------------------ -- Pass doer (usually the author) and trueDoer to NotificationGenerator, - so won't need to lookup inside. [notf_pass_doer] - - -Unnecessary ------------------------ - If an admin Alice impersonates Bob, then, even if Bob's list of aliases are hidden (so Alice can't see them), it can still be partly possible for Alice to figure out which aliases are Bob's aliases — by looking at when Bob did *not* get notified about @@ -227,8 +262,11 @@ Unnecessary to impersonate anyone (say, >= 2 admins need to agree) combined with impersonation notifications [imp_notfs]. -- One person with [many_anons_per_page]. (Except for anonymous moderation, but maybe - a shared moderator pseudonym is better.) + Or, hide notifications, if impersonating. Maybe could be different impersonation + capabilities, e.g. see true user's comments / see anon comments. Can be nice if + it's possible for an admin to help sbd out by impersonating their account and + figure out why sth doesn't work, *without* any chance that the admin accidentally + learns who that person are, anonymously. For ideation diff --git a/wip/bookmarks/bookmarks-wip.txt b/wip/bookmarks/bookmarks-wip.txt new file mode 100644 index 0000000000..56117dcc9b --- /dev/null +++ b/wip/bookmarks/bookmarks-wip.txt @@ -0,0 +1,2 @@ + +[dont_list_bookmarkers] diff --git a/wip/joint-decisions/joint-decisions.txt b/wip/joint-decisions/joint-decisions.txt index 877bd74d8c..6c879aac8f 100644 --- a/wip/joint-decisions/joint-decisions.txt +++ b/wip/joint-decisions/joint-decisions.txt @@ -8,7 +8,7 @@ If cannot trust all admins, then, optionally (forum wide settings) require many to agree (e.g. two), and notify all other admins, if wants to do any of: - Impersonating anyone. [imp_notfs] -- See who an anonymous user or pseudonym is +- See who an anonymous user or pseudonym is [see_alias] - Looking at email addresses or IP addresses in email logs, request logs, audit logs (e.g. when this anonym got a reply, to what email address did the notification get sent?). - Changing any related forum setting. diff --git a/wip/upgr-scala-2.13/upgr-scala-2.13.txt b/wip/upgr-scala-2.13/upgr-scala-2.13.txt new file mode 100644 index 0000000000..e9b578bd4d --- /dev/null +++ b/wip/upgr-scala-2.13/upgr-scala-2.13.txt @@ -0,0 +1,11 @@ + +https://confadmin.trifork.com/dl/2018/GOTO_Berlin/Migrating_to_Scala_2.13.pdf + +https://github.com/guardian/maintaining-scala-projects/issues/2 + +https://docs.scala-lang.org/overviews/core/collections-migration-213.html + + +https://www.playframework.com/documentation/3.0.x/Migration29 + +