From 2a06aba3b2563b70a94e4be40a56fff89a98d607 Mon Sep 17 00:00:00 2001 From: NGPixel Date: Sat, 10 Oct 2026 03:21:12 -0400 Subject: [PATCH] feat: user profile comments view + Talk view improvements --- backend/api/comments.ts | 107 ++++++++++++- backend/api/notifications.ts | 6 +- backend/api/pages.ts | 12 +- backend/api/schemas/comments.ts | 32 ++++ backend/api/schemas/notifications.ts | 2 +- backend/locales/en.json | 11 ++ backend/models/comments.ts | 147 ++++++++++++++++- backend/models/mail.ts | 15 +- backend/models/notifications.ts | 36 ++++- .../authentication/oidc/definition.yml | 7 +- backend/notifications/mailer.ts | 23 ++- frontend/src/assets/icons.generated.js | 3 +- frontend/src/components/PageComment.vue | 105 +++++++++++-- frontend/src/components/PageTalk.vue | 44 +++++- frontend/src/pages/InboxMessages.vue | 6 + frontend/src/pages/Index.vue | 22 ++- frontend/src/pages/UserProfile.vue | 148 ++++++++++++++++-- 17 files changed, 665 insertions(+), 61 deletions(-) diff --git a/backend/api/comments.ts b/backend/api/comments.ts index d005b02b8..1c8b6b38b 100644 --- a/backend/api/comments.ts +++ b/backend/api/comments.ts @@ -1,6 +1,6 @@ import { audit } from '../helpers/audit.ts' import { maskSensitiveProps } from '../helpers/common.ts' -import { mayOnPage, unpublishedFor } from './pages.ts' +import { mayOnPage, unlockedFor, unpublishedFor } from './pages.ts' import { ANNOTATION_NOTE_MAX_LENGTH, ANNOTATIONS_MAX, @@ -243,7 +243,7 @@ async function routes(app: FastifyInstance) { schema: { summary: 'List the comments of a page', description: - 'Every comment on the page, oldest first and flat — the one level of nesting is assembled by the client from `parentId`, which keeps a reply beside the comment it answers however old that comment is.\n\nOnly for a site using the built-in provider; a site whose discussions live at a third party answers 404 here. Needs `read:comments` on the page, which the guests group may hold.\n\n`mentions` resolves the handles written in these comments, so that a mention is drawn as a link to the right person without a lookup per `@`.', + 'Every comment on the page, oldest first and flat — the one level of nesting is assembled by the client from `parentId`, which keeps a reply beside the comment it answers however old that comment is.\n\nOnly for a site using the built-in provider; a site whose discussions live at a third party answers 404 here. Needs `read:comments` on the page, which the guests group may hold. A password-protected page answers 403 until the session has unlocked it (`POST …/unlock`), as every comment route does: the discussion is behind the password along with the body.\n\n`mentions` resolves the handles written in these comments, so that a mention is drawn as a link to the right person without a lookup per `@`.', tags: ['Comments'], params: pageIdParam, response: { @@ -289,6 +289,73 @@ async function routes(app: FastifyInstance) { } ) + /** + * LIST A USER'S COMMENTS + */ + /* + No route-level `permissions`, for the reason above: what decides each comment is the reader's page + rules on the page it is on, which the model settles row by row before it pages. + */ + app.get<{ + Params: { siteId: string; userId: string } + Querystring: { offset?: number; limit?: number } + }>( + '/sites/:siteId/users/:userId/comments', + { + schema: { + summary: 'List the comments a user has written', + description: + 'The comments and replies one user wrote on this site, newest first — the Comments tab of their public profile. Each comes with a short excerpt and the page it is on.\n\nOnly what the caller could open on the page itself is listed: `read:pages` and `read:comments` on the page, the page live (or one the caller may see unpublished), its password satisfied where it has one (unlocked in this session, or a caller the password does not bind), and the page still taking comments. Both the batch and `total` are counted after that, so neither says anything about comments the caller may not read. Deleted comments are never listed.\n\nOnly for a site using the built-in provider; any other answers 404.', + tags: ['Comments'], + params: { + type: 'object', + properties: { + siteId: { type: 'string', format: 'uuid' }, + userId: { type: 'string', format: 'uuid' } + }, + required: ['siteId', 'userId'] + }, + querystring: { + type: 'object', + properties: { + offset: { type: 'integer', minimum: 0, default: 0 }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 25 } + } + }, + response: { + 200: { + description: 'One batch of the comments this caller may read', + type: 'object', + properties: { + results: { + type: 'array', + items: { $ref: 'AuthoredComment#' } + }, + total: { + type: 'integer', + description: 'How many of this user’s comments the caller may read, in all.' + } + } + } + } + } + }, + async (req, reply) => { + if (!WIKI.models.comments.usesBuiltIn(req.params.siteId)) { + return reply.notFound('This site does not use the built-in comments provider.') + } + return WIKI.models.comments.listByAuthor({ + siteId: req.params.siteId, + authorId: req.params.userId, + actor: WIKI.models.groups.actorForRequest(req), + unpublished: unpublishedFor(req), + unlocked: (pageId) => unlockedFor(req, pageId), + offset: req.query.offset, + limit: req.query.limit + }) + } + ) + /** * POST A COMMENT */ @@ -995,16 +1062,42 @@ async function requireBuiltInPage( } // -> Carrying the site, since everything below asks a page rule about this page and a rule may be // limited to particular sites - const { isLive, ...ref } = { ...row, siteId: req.params.siteId } + const { isLive, isProtected, ...ref } = { ...row, siteId: req.params.siteId } // -> A page that is not live is not there for its discussion either, on the page view's own terms const unpublished = unpublishedFor(req) if (!isLive && !(unpublished && unpublished(ref))) { reply.notFound('This page does not exist.') return null } + if (!passwordSatisfied(req, reply, { id: ref.id, isProtected })) { + return null + } return ref } +/** + * Whether the caller is past the page's password, answering 403 when they are not. + * + * A page behind a password withholds its discussion along with its body: comments quote the page, + * annotations word for word, and talk about what it says. So until the session has unlocked the page + * (`POST …/unlock`), or the caller is somebody the password does not bind (`mayBypassPassword`), every + * comment route refuses it -- reading, posting, and acting on what is already there, moderators + * included. The same 403 the history and links routes give a locked page. + * + * @returns Whether to go on; false once it has sent the reply itself + */ +function passwordSatisfied( + req: FastifyRequest, + reply: FastifyReply, + page: { id: string; isProtected: boolean } +): boolean { + if (page.isProtected && !unlockedFor(req, page.id)) { + reply.forbidden('This page is password protected.') + return false + } + return true +} + /** * The comment a request is about, once it is established that the caller may change it. * @@ -1031,6 +1124,9 @@ async function requireWritableComment( reply.notFound('This comment does not exist.') return null } + if (!passwordSatisfied(req, reply, { id: comment.pageId, isProtected: comment.isProtected })) { + return null + } const page = { siteId: req.params.siteId, path: comment.path, @@ -1075,6 +1171,11 @@ async function requireAnnotation( reply.notFound('This annotation does not exist.') return null } + if ( + !passwordSatisfied(req, reply, { id: annotation.pageId, isProtected: annotation.isProtected }) + ) { + return null + } const page = { siteId: req.params.siteId, path: annotation.path, diff --git a/backend/api/notifications.ts b/backend/api/notifications.ts index 4e02a474f..d28022c29 100644 --- a/backend/api/notifications.ts +++ b/backend/api/notifications.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto' import { audit } from '../helpers/audit.ts' +import { unlockedFor } from './pages.ts' import { NOTIFICATION_CATEGORY_KEYS } from '../notifications/index.ts' import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' @@ -101,7 +102,10 @@ async function routes(app: FastifyInstance) { if (!userId || !siteId) { return reply } - return WIKI.models.notifications.list(userId, siteId, req.query) + return WIKI.models.notifications.list(userId, siteId, { + ...req.query, + unlocked: (pageId) => unlockedFor(req, pageId) + }) } ) diff --git a/backend/api/pages.ts b/backend/api/pages.ts index 53d097a72..a4afd841d 100644 --- a/backend/api/pages.ts +++ b/backend/api/pages.ts @@ -804,9 +804,10 @@ async function routes(app: FastifyInstance) { /* The badge on the Talk tab, which has to be there before the tab is opened — so it comes with the page rather than with the comments. One indexed count, and not even that for a site - whose discussions live at a third party or that has comments turned off. + whose discussions live at a third party or that has comments turned off -- nor for a page + still waiting for its password, whose discussion is behind it with the body. */ - WIKI.models.comments.usesBuiltIn(req.params.siteId) + WIKI.models.comments.usesBuiltIn(req.params.siteId) && !page.isLocked ? WIKI.models.comments.countForPage(page.id) : 0, /* @@ -933,7 +934,12 @@ async function routes(app: FastifyInstance) { path: page.path }) - return page + // -> The Talk tab's badge, withheld while the page was locked (see the page route) and now the + // reader's to see along with the discussion itself + const commentsCount = WIKI.models.comments.usesBuiltIn(req.params.siteId) + ? await WIKI.models.comments.countForPage(page.id) + : 0 + return { ...page, commentsCount } } ) diff --git a/backend/api/schemas/comments.ts b/backend/api/schemas/comments.ts index 970146204..d75d676da 100644 --- a/backend/api/schemas/comments.ts +++ b/backend/api/schemas/comments.ts @@ -215,6 +215,38 @@ export async function registerSchemas(app: FastifyInstance): Promise { } }) + /** + * AUTHORED COMMENT - One comment in a listing of what one person wrote, with the page it is on + */ + app.addSchema({ + $id: 'AuthoredComment', + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + parentId: { + type: 'string', + format: 'uuid', + nullable: true, + description: 'The comment this one answers, or null for one that starts a thread.' + }, + excerpt: { + type: 'string', + description: + 'The start of the markdown source, cut at a fixed length with no regard for its syntax -- enough for a line or two of preview, never the whole comment. Empty for a comment whose annotations say everything.' + }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, + annotationCount: { + type: 'integer', + description: 'How many passages of the article it annotates.' + }, + pageId: { type: 'string', format: 'uuid' }, + pageTitle: { type: 'string' }, + pagePath: { type: 'string' }, + pageLocale: { type: 'string' } + } + }) + /** * MENTION TARGET - A handle that resolved to somebody */ diff --git a/backend/api/schemas/notifications.ts b/backend/api/schemas/notifications.ts index db09e5bdf..b742f5a19 100644 --- a/backend/api/schemas/notifications.ts +++ b/backend/api/schemas/notifications.ts @@ -47,7 +47,7 @@ export async function registerSchemas(app: FastifyInstance): Promise { type: 'object', additionalProperties: true, description: - 'A snapshot of what the entry is about, taken when it happened, so that it can still be drawn after the page has moved or gone: `page` (`id`, `title`, `path`, `locale`), `actorName`, `variants` (every variant absorbed), `previousPath`, `submissionId`, `origin` (`import` or `bulk` when nobody did it by hand), and `excerpt` — a comment’s first lines, present only while the comment exists.' + 'A snapshot of what the entry is about, taken when it happened, so that it can still be drawn after the page has moved or gone: `page` (`id`, `title`, `path`, `locale`), `actorName`, `variants` (every variant absorbed), `previousPath`, `submissionId`, `origin` (`import` or `bulk` when nobody did it by hand), and `excerpt` — a comment’s first lines, present only while the comment exists. A comment on a password-protected page this session has not unlocked carries `excerptWithheld: true` instead of `excerpt`.' }, isRead: { type: 'boolean' }, createdAt: { type: 'string', format: 'date-time' }, diff --git a/backend/locales/en.json b/backend/locales/en.json index 0552f46d7..502240f09 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -2010,8 +2010,10 @@ "common.comments.beFirst": "Be the first to comment.", "common.comments.charsLeft": "{count} left", "common.comments.closed": "Comments are turned off for this page.", + "common.comments.collapseReplies": "Collapse", "common.comments.commentsTitle": "Comments", "common.comments.contentMissingError": "Comment is empty or too short!", + "common.comments.copyLink": "Copy link to this comment", "common.comments.deleteCommentOnly": "Delete this comment only", "common.comments.deleteConfirmTitle": "Delete Comment", "common.comments.deleteFailed": "Could not delete the comment.", @@ -2027,12 +2029,14 @@ "common.comments.deleteWithReplies": "Delete comment + replies", "common.comments.deleted": "Comment was deleted.", "common.comments.edited": "edited", + "common.comments.expandReplies": "Expand ({count} reply) | Expand ({count} replies)", "common.comments.fieldContent": "Comment Content", "common.comments.fieldEmail": "Your Email Address", "common.comments.fieldEmailHint": "Never shown to anybody. It is used to check the comment for spam.", "common.comments.fieldName": "Your Name", "common.comments.guest": "Guest", "common.comments.hideAllAnnotations": "Hide All", + "common.comments.linkCopied": "Link to the comment copied to the clipboard.", "common.comments.loadFailed": "Could not load the comments.", "common.comments.loading": "Loading comments…", "common.comments.markdownFormat": "Markdown Format", @@ -3253,6 +3257,7 @@ "notifications.categories.watchedPageComment.description": "Somebody comments on a page you watch.", "notifications.categories.watchedPageComment.title": "Comments on watched pages", "notifications.count": "{count} updates", + "notifications.excerptWithheld": "This comment is not quoted here because the page is password protected. Go to the page to view it.", "notifications.messages.commentReply.new": "{actor} replied to your comment on {page}", "notifications.messages.mention.edited": "{actor} mentioned you in a comment on {page}", "notifications.messages.mention.new": "{actor} mentioned you in a comment on {page}", @@ -3498,17 +3503,23 @@ "unsubscribe.invalid": "This unsubscribe link is not valid. You can still change your notification settings from your profile.", "unsubscribe.manage": "Notification settings", "unsubscribe.title": "Unsubscribe", + "userProfile.commentAnnotationsOnly": "Annotated {count} passage | Annotated {count} passages", + "userProfile.commentOn": "Comment on {title}", + "userProfile.comments": "Comments", + "userProfile.commentsLoadingFailed": "Failed to load comments.", "userProfile.lastLogin": "Last Login", "userProfile.loadMore": "Load More", "userProfile.loadingFailed": "Failed to load user profile.", "userProfile.localTime": "Local Time", "userProfile.location": "Location", + "userProfile.noComments": "This user hasn't posted any comment yet.", "userProfile.noPagesCreated": "This user hasn't created any page yet.", "userProfile.noPagesUpdated": "This user hasn't modified any page yet.", "userProfile.notFound": "There is no such user.", "userProfile.pagesCreated": "Pages Created", "userProfile.pagesLoadingFailed": "Failed to load pages.", "userProfile.pagesUpdated": "Last Modified Pages", + "userProfile.replyOn": "Reply on {title}", "userProfile.title": "User Profile", "welcome.admin": "Administration Area", "welcome.createHome": "Create the homepage", diff --git a/backend/models/comments.ts b/backend/models/comments.ts index 8de57fc76..b4a71e112 100644 --- a/backend/models/comments.ts +++ b/backend/models/comments.ts @@ -1,7 +1,7 @@ import fs from 'node:fs/promises' import path from 'node:path' import { load } from 'js-yaml' -import { and, asc, count, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm' +import { and, asc, count, desc, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm' import { commentAnnotations as annotationsTable, comments as commentsTable, @@ -15,7 +15,9 @@ import { parseModuleProps } from '../helpers/common.ts' import type { ModuleProp } from '../helpers/common.ts' +import type { RulePageRef } from '../helpers/pageRules.ts' import { liveCondition } from '../helpers/publishing.ts' +import type { AccessActor } from './groups.ts' /** * The key of the provider that IS this wiki, as opposed to the ones that are somebody else's service. @@ -264,6 +266,28 @@ export interface CommentEntry { annotations: AnnotationEntry[] } +/** + * How much of a comment a listing of somebody's comments carries, in characters. A row shows a line or + * two of it, so the rest of up to `COMMENT_MAX_LENGTH` would be bytes nobody reads. + */ +export const AUTHORED_EXCERPT_LENGTH = 400 + +/** One comment in a listing of what one person wrote, with the page it is on. */ +export interface AuthoredCommentEntry { + id: string + parentId: string | null + /** The start of the markdown source, cut at `AUTHORED_EXCERPT_LENGTH` with no regard for syntax. */ + excerpt: string + createdAt: Date + updatedAt: Date + /** How many passages it annotates -- what stands in for its text when it has none of its own. */ + annotationCount: number + pageId: string + pageTitle: string + pagePath: string + pageLocale: string +} + /** A handle that resolved to somebody, as the renderer needs it to draw the mention as a link. */ export interface MentionTarget { handle: string @@ -754,13 +778,121 @@ class Comments { })) } + /** + * The comments one person has written on a site, newest first, as one reader may see them -- what + * the Comments tab of a public profile lists. + * + * A comment is listed where the reader could follow it to the page's Talk view and find it there: + * `read:pages` to open the page, `read:comments` for its discussion, the page live or one this reader + * may see while it is not (`unpublished`, as the page view decides it), the password satisfied where + * the page has one (`unlocked`) -- the discussion is behind it as much as the body is -- and the page + * still taking comments, since with that switched off it has no Talk view to land on. Placeholders of deleted comments + * are never listed; there is nothing of the author left in one. + * + * Decided BEFORE paging, as `search.searchPages` does it and for the same reason: a page rule can be + * a pattern or a set of tags, so only a row can answer it, and a batch filtered after its `LIMIT` + * comes back short with a total still counting what the reader was refused -- which is itself + * something about pages they may not see. The first query reads only what a rule looks at, the + * second the full rows of the one batch returned. + */ + async listByAuthor({ + siteId, + authorId, + actor, + unpublished, + unlocked, + offset = 0, + limit = 25 + }: { + siteId: string + authorId: string + actor: AccessActor + unpublished: false | ((page: RulePageRef) => boolean) + /** Whether this reader has satisfied the password of a protected page, by its id. */ + unlocked: (pageId: string) => boolean + offset?: number + limit?: number + }): Promise<{ results: AuthoredCommentEntry[]; total: number }> { + const conditions = [ + eq(commentsTable.authorId, authorId), + eq(pagesTable.siteId, siteId), + isNull(commentsTable.deletedAt), + sql`coalesce((${pagesTable.config} ->> 'allowComments')::boolean, true)` + ] + // -> The reader who may see no unpublished page anywhere is held to live ones here, as the search does + if (unpublished === false) { + conditions.push(liveCondition(pagesTable)) + } + const candidates = await WIKI.db + .select({ + id: commentsTable.id, + pageId: pagesTable.id, + path: pagesTable.path, + locale: pagesTable.locale, + tags: pagesTable.tags, + isLive: sql`${liveCondition(pagesTable)}`.mapWith(Boolean), + isProtected: sql`${pagesTable.password} IS NOT NULL`.mapWith(Boolean) + }) + .from(commentsTable) + .innerJoin(pagesTable, eq(pagesTable.id, commentsTable.pageId)) + .where(and(...conditions)) + .orderBy(desc(commentsTable.createdAt), desc(commentsTable.id)) + + // -> Per page rather than per comment: a person's comments cluster on the pages they discuss + const pageVerdicts = new Map() + const readable = candidates.filter((row) => { + let verdict = pageVerdicts.get(row.pageId) + if (verdict === undefined) { + const ref = { siteId, path: row.path, locale: row.locale, tags: row.tags ?? [] } + verdict = + WIKI.models.groups.checkAccess(actor, 'read:pages', ref) && + WIKI.models.groups.checkAccess(actor, 'read:comments', ref) && + (row.isLive || (unpublished !== false && unpublished(ref))) && + (!row.isProtected || unlocked(row.pageId)) + pageVerdicts.set(row.pageId, verdict) + } + return verdict + }) + const ids = readable.slice(offset, offset + limit).map((row) => row.id) + if (ids.length < 1) { + return { results: [], total: readable.length } + } + + const rows = await WIKI.db + .select({ + id: commentsTable.id, + parentId: commentsTable.parentId, + excerpt: sql`left(${commentsTable.content}, ${AUTHORED_EXCERPT_LENGTH})`, + createdAt: commentsTable.createdAt, + updatedAt: commentsTable.updatedAt, + annotationCount: + sql`(SELECT count(*) FROM ${annotationsTable} WHERE ${annotationsTable.commentId} = ${commentsTable.id})`.mapWith( + Number + ), + pageId: pagesTable.id, + pageTitle: pagesTable.title, + pagePath: pagesTable.path, + pageLocale: pagesTable.locale + }) + .from(commentsTable) + .innerJoin(pagesTable, eq(pagesTable.id, commentsTable.pageId)) + .where(inArray(commentsTable.id, ids)) + // -> Back into the order the first query settled, which `IN` does not keep + const byId = new Map(rows.map((row) => [row.id, row])) + return { + results: ids.map((id) => byId.get(id)).filter((row) => row !== undefined), + total: readable.length + } + } + /** * The page a comment is about, as everything that guards one needs it. * * Its path, locale and tags because that is what a page rule is matched against, and * `allowComments` because a page can be closed to discussion from its own properties dialog * whatever the site has configured. Deliberately not `pages.getPage` — that assembles a page for - * reading, and this is a few columns and a scoping check. `isLive` is the caller's to act on. + * reading, and this is a few columns and a scoping check. `isLive` and `isProtected` are the + * caller's to act on. * * @returns The reference, or null when no such page exists on this site */ @@ -775,7 +907,10 @@ class Comments { allowComments: sql`coalesce((${pagesTable.config} ->> 'allowComments')::boolean, true)`, // -> A discussion quotes its page, annotations word for word, so it is only there while the // page is — see `helpers/publishing.ts` - isLive: sql`${liveCondition(pagesTable)}`.mapWith(Boolean) + isLive: sql`${liveCondition(pagesTable)}`.mapWith(Boolean), + // -> Whether the page is behind a password, which hides its discussion as well as its body + // until the session has unlocked it (`unlockedFor` in `api/pages.ts`) + isProtected: sql`${pagesTable.password} IS NOT NULL`.mapWith(Boolean) }) .from(pagesTable) .where(and(eq(pagesTable.id, pageId), eq(pagesTable.siteId, siteId))) @@ -823,6 +958,9 @@ class Comments { locale: pagesTable.locale, tags: pagesTable.tags, title: pagesTable.title, + // -> Whether the page is behind a password, which hides its discussion as well as its body + // until the session has unlocked it (`unlockedFor` in `api/pages.ts`) + isProtected: sql`${pagesTable.password} IS NOT NULL`.mapWith(Boolean), allowComments: sql`coalesce((${pagesTable.config} ->> 'allowComments')::boolean, true)` }) .from(commentsTable) @@ -1015,6 +1153,9 @@ class Comments { path: pagesTable.path, locale: pagesTable.locale, tags: pagesTable.tags, + // -> Whether the page is behind a password, which hides its discussion as well as its body + // until the session has unlocked it (`unlockedFor` in `api/pages.ts`) + isProtected: sql`${pagesTable.password} IS NOT NULL`.mapWith(Boolean), title: pagesTable.title }) .from(annotationsTable) diff --git a/backend/models/mail.ts b/backend/models/mail.ts index 33e560f79..f171b2cd4 100644 --- a/backend/models/mail.ts +++ b/backend/models/mail.ts @@ -58,6 +58,8 @@ export interface MailNotificationEntry { pageTitle: string /** A comment's first lines, as typed. */ excerpt?: string + /** Set in place of `excerpt` for a comment on a password-protected page, which is not quoted. */ + excerptWithheld?: boolean /** Set when nobody did it by hand: `import` or `bulk`. */ origin?: string /** Where the entry leads. */ @@ -564,6 +566,13 @@ class Mail { ...(entry.count > 1 ? [t('notifications.count', { count: entry.count })] : []), ...(entry.origin ? [t(`notifications.origin.${entry.origin}`)] : []) ].join(' · ') + // -> What stands where the comment would be quoted: its text, or why it is not there + const quoteOf = (entry: MailNotificationEntry) => + entry.excerptWithheld + ? t('notifications.excerptWithheld') + : entry.excerpt + ? `“${entry.excerpt}”` + : '' const footer = t('mail.notification.footer', { siteName }) const links = [ { label: t('mail.notification.manage'), url: d.manageUrl }, @@ -577,7 +586,7 @@ class Mail { return { subject: t('mail.notification.subject', { siteName, message }), title: message, - body: [...(entry.excerpt ? [`“${entry.excerpt}”`] : []), ...(detail ? [detail] : [])], + body: [quoteOf(entry), detail].filter(Boolean), action: { label: t(`mail.notification.actions.${entry.category}`), url: entry.url @@ -593,9 +602,7 @@ class Mail { title: t('mail.notificationDigest.title', { siteName }), body: [t('mail.notificationDigest.body', { count: total })], items: d.entries.map((entry) => { - const detail = [entry.excerpt ? `“${entry.excerpt}”` : '', detailOf(entry)] - .filter(Boolean) - .join(' — ') + const detail = [quoteOf(entry), detailOf(entry)].filter(Boolean).join(' — ') return { text: describe(entry), url: entry.url, ...(detail && { detail }) } }), action: { diff --git a/backend/models/notifications.ts b/backend/models/notifications.ts index 3cf23db03..9feed2588 100644 --- a/backend/models/notifications.ts +++ b/backend/models/notifications.ts @@ -3,6 +3,7 @@ import { and, count, desc, eq, gte, inArray, isNotNull, isNull, lt, or, sql } fr import { notificationEvents as eventsTable, notifications as notificationsTable, + pages as pagesTable, userNotificationPrefs as prefsTable } from '../db/schema.ts' import { durationToSeconds } from '../helpers/common.ts' @@ -381,11 +382,27 @@ class Notifications { * * Keyset-paginated on `(updatedAt, id)` — the cursor is the last entry of the previous page — since * an entry that absorbs a new event moves to the top, and an offset would skip or repeat around it. + * + * **A comment on a password-protected page is not quoted** until the reader has satisfied that + * password (`unlocked`, which is `unlockedFor` in `api/pages.ts`): the entry says `excerptWithheld` + * in place of the excerpt, as the Talk view refuses the comment itself. Decided from the page as it + * is NOW rather than when the entry was written, so a password added since withholds what is already + * in the inbox, and one taken off quotes it again -- the excerpt stays in the snapshot either way. */ async list( userId: string, siteId: string, - { cursor, unread, limit }: { cursor?: string; unread?: boolean; limit?: number } = {} + { + cursor, + unread, + limit, + unlocked + }: { + cursor?: string + unread?: boolean + limit?: number + unlocked: (pageId: string) => boolean + } ): Promise<{ entries: InboxEntry[]; next: string | null }> { const size = Math.min(Math.max(limit ?? INBOX_PAGE_SIZE, 1), INBOX_PAGE_MAX) const conditions = [this.inboxScope(userId, siteId)] @@ -410,9 +427,12 @@ class Notifications { data: notificationsTable.data, readAt: notificationsTable.readAt, createdAt: notificationsTable.createdAt, - updatedAt: notificationsTable.updatedAt + updatedAt: notificationsTable.updatedAt, + // -> Null where the page has gone, which leaves nothing behind a password + isProtected: sql`${pagesTable.password} IS NOT NULL`.mapWith(Boolean) }) .from(notificationsTable) + .leftJoin(pagesTable, eq(pagesTable.id, notificationsTable.pageId)) .where(and(...conditions)) .orderBy(desc(notificationsTable.updatedAt), desc(notificationsTable.id)) .limit(size + 1) @@ -420,12 +440,18 @@ class Notifications { const last = page[page.length - 1] return { entries: page.map((row) => { - const { readAt, data, ...rest } = row + const { readAt, data, isProtected, ...rest } = row const { excerpt, ...snapshot } = (data ?? {}) as Record + // -> A comment's text only while the comment exists: deleting it takes it out of every inbox + const quoted = Boolean(row.commentId) && excerpt !== undefined + const withheld = quoted && isProtected && !!row.pageId && !unlocked(row.pageId) return { ...rest, - // -> A comment's text only while the comment exists: deleting it takes it out of every inbox - data: row.commentId && excerpt !== undefined ? { ...snapshot, excerpt } : snapshot, + data: withheld + ? { ...snapshot, excerptWithheld: true } + : quoted + ? { ...snapshot, excerpt } + : snapshot, isRead: readAt !== null } }), diff --git a/backend/modules/authentication/oidc/definition.yml b/backend/modules/authentication/oidc/definition.yml index 3b6ae09ef..65ba07e56 100644 --- a/backend/modules/authentication/oidc/definition.yml +++ b/backend/modules/authentication/oidc/definition.yml @@ -198,6 +198,11 @@ props: refs: callbackUrl: title: Authorization Callback URL - hint: Register this as the redirect URI at the provider. It is the same for every provider. + hint: Register this as the redirect / callback URI at the provider. If you have multiple sites using this strategy, you must register all the site hostnames as well. icon: back value: '{host}/_api/auth/{id}/callback' + logoutUrl: + title: Post Logout URL + hint: Register this as the post logout redirect / callback URI at the provider. If you have multiple sites using this strategy, you must register all the site hostnames as well. Note that if you override the logout URL in the site login settings or at the group level, you must also allow these URLs or use a wildcard match. + icon: back + value: '{host}/' diff --git a/backend/notifications/mailer.ts b/backend/notifications/mailer.ts index 1b38a9348..8c24c8b92 100644 --- a/backend/notifications/mailer.ts +++ b/backend/notifications/mailer.ts @@ -2,6 +2,7 @@ import NodeCache from 'node-cache' import { and, asc, eq, inArray, lte, min, sql } from 'drizzle-orm' import { notifications as notificationsTable, + pages as pagesTable, sites as sitesTable, userNotificationPrefs as prefsTable, users as usersTable @@ -60,6 +61,8 @@ interface DueRow { commentId: string | null data: Record inApp: boolean + /** Whether the page is behind a password now. False where the page has gone. */ + isProtected: boolean } /** @@ -108,8 +111,14 @@ function entryOf(baseUrl: string, row: DueRow): MailNotificationEntry { count: row.count, actorName: row.data.actorName ?? null, pageTitle: row.data.page?.title ?? '', - // -> Only while the comment exists, the same rule the inbox applies - ...(row.commentId && row.data.excerpt && { excerpt: row.data.excerpt }), + /* + Only while the comment exists, the same rule the inbox applies -- and never for a page behind a + password, which says so instead. Unlike the inbox there is no session here to have unlocked the + page, and a mail goes wherever it is forwarded, so the password is never taken as satisfied. + */ + ...(row.commentId && + row.data.excerpt && + (row.isProtected ? { excerptWithheld: true } : { excerpt: row.data.excerpt })), ...(row.data.origin && { origin: row.data.origin }), url: urlOf(baseUrl, row) } @@ -161,7 +170,12 @@ async function claim(pair: { userId: string; siteId: string | null }): Promise new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()) @@ -173,7 +187,8 @@ async function claim(pair: { userId: string; siteId: string | null }): Promise","width":32,"height":32}, "la:angle-left": {"body":"","width":32,"height":32}, "la:angle-right": {"body":"","width":32,"height":32}, + "la:angle-up": {"body":"","width":32,"height":32}, "la:arrow-circle-left": {"body":"","width":32,"height":32}, "la:arrow-circle-right": {"body":"","width":32,"height":32}, "la:arrow-left": {"body":"","width":32,"height":32}, diff --git a/frontend/src/components/PageComment.vue b/frontend/src/components/PageComment.vue index 01e4a257f..84fd64c94 100644 --- a/frontend/src/components/PageComment.vue +++ b/frontend/src/components/PageComment.vue @@ -225,6 +225,26 @@ in its place carries its own buttons then. -->