Skip to content

feat(preview): inline comments, replies, resolve and anonymous commenting - #2079

Merged
nevo-david merged 5 commits into
mainfrom
feat/preview-inline-comments
Sep 24, 2026
Merged

nevo-david merged 5 commits into
mainfrom
feat/preview-inline-comments

Conversation

@giladresisi

@giladresisi giladresisi commented Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator

What kind of change does this PR introduce?

Feature (backend + frontend, post preview page /p/:id and its comments). Reviewers can now comment on a selected span of a post item's text, reply to comments (one level), and signed-in members of the post's organization can resolve and reopen threads. Commenting no longer requires a Postiz account: an anonymous reviewer is asked for a name, and anonymous writes are protected by optional invisible reCAPTCHA v2 plus the per-IP throttler. The whole page is restyled with the dashboard surfaces and controls.

Concretely: the Comments model gains nullable displayName, parentId, anchorStart, anchorEnd, anchorQuote and resolvedAt columns and userId becomes nullable; GET /public/posts/:id/comments returns the comments of every item in the thread with a display name; new POST /public/posts/:id/comments (anonymous) and PUT /posts/comments/:commentId/resolve (authenticated) routes; POST /posts/:id/comments now takes the same DTO as the public route. Saving a post detaches the highlight of any anchored comment whose quoted text no longer matches, keeping the quote. The editor, notifications, comment deletion and tier gating are deliberately unchanged.

Why was this change needed?

Requested by a customer who shares post previews with clients and needs them to give precise, in-context feedback without creating a Postiz account. The approved spec is in #2069.

Previously only signed-in users could comment, comments could only target the post as a whole, and commenters showed up as "User1", "User2".

Other information:

  • Backward compatibility: the schema change is additive and nullable, so existing comments read as general, unresolved, root comments under their author's real name. Apply with pnpm run prisma-db-push.
  • POST /posts/:id/comments body changed from { comment } to { content }. The preview sidebar was its only caller in this repo.
  • reCAPTCHA is optional: with RECAPTCHA_SITE_KEY / RECAPTCHA_SECRET_KEY unset (documented in .env.example), anonymous comments are accepted and Google's script is never loaded, so self-hosters are unaffected. With keys set it is invisible v2: most reviewers see nothing, and Google shows a puzzle only for suspicious traffic, so a real user can still get their comment through. Production needs an invisible v2 key pair.
  • Rate limiting reuses the existing global throttler configuration (API_LIMIT per hour), keyed by IP for the public comment route, rather than adding a separate bucket.
  • Anchor offsets index the plain text of the sanitised post HTML, computed the same way on both sides (postContentPlainText next to sanitizePostContent).
  • Automated tests: unit, integration and Playwright E2E tests for this feature were written and passed on the staging-based version of this branch. They are not included here because main has no test infrastructure yet; they can land together with it.
  • New translation keys, added to the English source and translated via lingo.dev for all configured locales (ka_ge is not a lingo.dev target, so it falls back to English as before): preview_comment_name_required, preview_comment_your_name, preview_comment_failed, preview_comment_detach_hint, preview_comment_text_changed, preview_no_comments_yet, add_a_comment, add_a_comment_placeholder, write_a_reply, reply, reviewer, resolved, resolve, reopen, collapse, comment.

QA

Testing done on this branch: verified end to end in the browser, signed out and signed in, with real invisible reCAPTCHA v2 keys: general and anchored anonymous comments (selection by drag, double-click and triple-click), highlights and card/highlight hover and click sync, replies, resolve and reopen, a dismissed puzzle (the Post button returns to idle and a retry works), a wrong puzzle answer, and a solved puzzle. Forged and missing tokens were rejected with 400. Without reCAPTCHA keys no Google script is loaded. Frontend, backend and orchestrator build and typecheck.

  1. Run pnpm run prisma-db-push and confirm the diff only adds nullable columns and indexes to Comments
  2. Open the preview of an existing post that already has comments (/p/<postId>): the old comments still show, now under their authors' real names
  3. In a signed-out (incognito) window, type a general comment and click Post: a modal asks for your name; after Continue the comment appears with that name
  4. Select a few words in the post body (drag, double-click or triple-click): a Comment button appears under the selection; click it, write a comment and post: the text is highlighted in the post and the card shows the quote
  5. Hover the card: its highlight is outlined; click the highlight: the card scrolls into view and flashes; click the quote in the card: the highlight scrolls into view and pulses
  6. Reply to a comment: the reply shows indented under it; replies have no Reply button of their own
  7. Sign in as a member of the post's organization and open the preview: Resolve on a thread greys it out, collapses it, sorts it last and removes its highlight; Reopen restores it
  8. Signed in as a member of a different organization: no Resolve buttons are shown
  9. Edit the post in the dashboard so the highlighted words change and save: the comment keeps its quote with a "Text changed" tag and the highlight is gone
  10. Without RECAPTCHA_* set: anonymous posting works and no recaptcha/api.js request is made
  11. With invisible v2 keys set: anonymous posting works, the reCAPTCHA badge is visible, and curl -X POST <backend>/public/posts/<postId>/comments -H 'Content-Type: application/json' -d '{"content":"x","displayName":"Eve","recaptchaToken":"forged"}' returns 400 "Captcha verification failed"
  12. If Google shows a puzzle, click outside it: the Post button is not stuck spinning, and clicking Post again brings the puzzle back

Checklist:

  • I have read the CONTRIBUTING guide.
  • I have signed the Contributor License Agreement (CLA) (ICLA for individuals, CCLA for entities).
  • I confirm I have not used AI to submit this PR or generate code for it.
  • I checked that there were no similar issues or PRs already open for this.
  • This PR fixes just ONE issue
  • I have filled in the QA section above with real steps to verify this change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QNeur7rLjKfuLvGH34BzX9

…ting

Anchored text comments with highlights, one-level replies, org-scoped
resolve/reopen, named anonymous comments behind optional invisible
reCAPTCHA v2 and the IP throttler, and a dashboard restyle of /p/:id.
@postiz-contribution
postiz-contribution Bot changed the base branch from main to staging September 14, 2026 15:57
@strix-security

strix-security Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

Strix Security Review

1 open security finding on this PR:

Review summary

Reviewed all 33 changed files in PR #2079 (anonymous and authenticated inline preview comments, replies, resolve/reopen, reCAPTCHA, anchor offsets, and throttling). The new comment routes, DTO validation, anchor-offset validation, resolve authorization, and reCAPTCHA verification are correctly implemented. Comment content, display names, and anchor quotes are rendered with React auto-escaping, and post content remains sanitized via DOMPurify. No new security issues were identified in the changed code. The previously reported per-IP rate-limit bypass (spoofed X-Forwarded-For) remains present but was acknowledged and deferred by the maintainer as a broader trusted-proxy change, and the removal of the organization check on comment creation was confirmed as intended for public preview links.

Fixed the findings? re-run the review, or tag @strix-security in a PR comment to run a fresh review.

Updated for 32ca6ff.


Reviewed by Strix
Re-run review · Configure security review settings

@postiz-contribution postiz-contribution Bot added the contribution:approved Approved contributor label Sep 14, 2026
@postiz-agent

postiz-agent Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

✅ Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
✅ Open Source Security 0 0 0 0 0 issues
✅ Licenses 0 0 0 0 0 issues
✅ Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@postiz-contribution
postiz-contribution Bot changed the base branch from staging to main September 14, 2026 16:00
Comment on lines 80 to +84
@Param('id') id: string,
@Body() body: { comment: string }
@Body() body: CreatePublicCommentDto,
@RealIP() ip: string
) {
return this._postsService.createComment(org.id, user.id, id, body.comment);
return this._postsService.createPublicComment(id, body, user.id, ip);

This comment was marked as outdated.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intended. Preview links are public and the feature is built so that anyone with the link can comment, including people outside the organization such as clients and sponsors, signed in or not. The new public route accepts anonymous comments on any post the visitor has the link for, so a signed in user from another organization gains nothing extra through this route; their comment just carries their profile name instead of a typed one. The previous version of this endpoint had no check that the post belongs to the caller's organization either; it only stamped the caller's organization id on the comment, which stored the wrong id. The service now takes the organization from the post itself. Actions that must stay scoped are checked: resolving a thread verifies that the comment's post belongs to the caller's organization and returns 404 otherwise.

@strix-security strix-security Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strix flagged a new security finding below. See the pinned summary comment for the full PR status.

Comment on lines +27 to +32
if (!req.org) {
const forwarded = String(req.headers?.['x-forwarded-for'] || '')
.split(',')[0]
.trim();
return 'ip_' + (forwarded || req.ip || 'unknown');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Per-IP rate limiting on anonymous comment route bypassable via spoofed X-Forwarded-For header

Severity: LOW · CWE-799

The new anonymous comment route POST /public/posts/:id/comments is protected against abuse solely by the ThrottlerBehindProxyGuard per-IP limit when reCAPTCHA is unset (the default self-hosted configuration). The throttle key is derived from the first entry of the X-Forwarded-For header, which is fully attacker-controlled: the backend never configures app.set('trust proxy', ...), and the bundled reverse-proxy config appends ($proxy_add_x_forwarded_for) rather than overwrites the header, so the first entry is always the client-supplied value. Rotating this header per request yields a unique tracker key each time, defeating the rate limit and enabling unbounded anonymous comment spam/spam on any post preview.

Suggested change
if (!req.org) {
const forwarded = String(req.headers?.['x-forwarded-for'] || '')
.split(',')[0]
.trim();
return 'ip_' + (forwarded || req.ip || 'unknown');
}
if (!req.org) {
return 'ip_' + (req.ip || 'unknown');
}
Prompt to fix with AI
This is a security vulnerability found during a code review.

Vulnerability: Per-IP rate limiting on anonymous comment route bypassable via spoofed X-Forwarded-For header
Severity: LOW
CWE: CWE-799

The new anonymous comment route `POST /public/posts/:id/comments` is protected against abuse solely by the `ThrottlerBehindProxyGuard` per-IP limit when reCAPTCHA is unset (the default self-hosted configuration). The throttle key is derived from the first entry of the `X-Forwarded-For` header, which is fully attacker-controlled: the backend never configures `app.set('trust proxy', ...)`, and the bundled reverse-proxy config appends (`$proxy_add_x_forwarded_for`) rather than overwrites the header, so the first entry is always the client-supplied value. Rotating this header per request yields a unique tracker key each time, defeating the rate limit and enabling unbounded anonymous comment spam/spam on any post preview.

Location: libraries/nestjs-libraries/src/throttler/throttler.provider.ts:27-32
Context: Derive IP-agnostic throttle key from req.ip (trust-proxy aware) instead of untrusted X-Forwarded-For
```
// Before:
    if (!req.org) {
      const forwarded = String(req.headers?.['x-forwarded-for'] || '')
        .split(',')[0]
        .trim();
      return 'ip_' + (forwarded || req.ip || 'unknown');
    }
// After:
    if (!req.org) {
      return 'ip_' + (req.ip || 'unknown');
    }
```

How to fix:
Do not trust the raw `X-Forwarded-For` header for the throttle key. Either (1) configure a trusted-proxy allowlist via `app.set('trust proxy', ...)` so Express only honors `X-Forwarded-For` from the known upstream proxy, and key the throttle off `req.ip`; or (2) key off a header the trusted reverse proxy overwrites with the true client address (the repo's nginx config already sets `X-Real-IP` to `$remote_addr`), consistent with the `@RealIP()` decorator. Ensure the same trusted-proxy allowlist is applied so the header cannot be spoofed by direct access to the backend.

Please fix this vulnerability. If you propose a fix, make it concise and minimal.

React 👍 / 👎 to tune Strix for this repo. A repo collaborator (or the PR author) can resolve this thread to dismiss the finding.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, the observation is correct: the first X-Forwarded-For entry is client controlled. The suggested fix would break rate limiting in the bundled setup, though: the backend runs behind nginx on localhost:3000 with no trust proxy configured, so req.ip is the same local address for every visitor and all anonymous reviewers would share one bucket, turning the limit into a site wide cap. A correct key depends on the deployment's proxy chain and needs a trusted proxy setting, which is a broader change than this PR and applies to every place the app resolves client IPs. For this route the primary control in production is reCAPTCHA: every anonymous comment requires a token verified by Google, which a spoofed header does not bypass. Leaving this as is for this PR.

…omments

# Conflicts:
#	apps/frontend/src/app/(app)/layout.tsx
#	libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts
@egelhaus egelhaus added the contribution:evaluate Evaluate the PR again label Sep 16, 2026
@postiz-contribution

This comment was marked as off-topic.

@postiz-contribution postiz-contribution Bot added the guard:blocked Touches a guarded path and is awaiting sign-off label Sep 16, 2026
@egelhaus egelhaus removed the contribution:evaluate Evaluate the PR again label Sep 16, 2026
Comment on lines +1442 to +1446
if (
body.anchorQuote !== plainText.slice(body.anchorStart!, body.anchorEnd!)
) {
throw new BadRequestException('Anchor does not match the post text');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The API returns a misleading error if anchorStart is provided without anchorQuote because anchorQuote is incorrectly marked as optional in the DTO for this case.
Severity: LOW

Suggested Fix

Update the DTO to enforce that anchorQuote must be a non-empty string if anchorStart is present. This can be achieved using a custom validation decorator like @ValidateIf from class-validator to create a conditional requirement. Alternatively, add an explicit check at the start of the service logic: if (hasStart && typeof body.anchorQuote !== 'string') { throw new BadRequestException('anchorQuote is required when an anchor is provided'); }.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location:
libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts#L1442-L1446

Potential issue: The DTO for creating a comment with an anchor has `anchorQuote` marked
as optional, while `anchorStart` and `anchorEnd` are conditionally required. However,
the backend validation logic at `posts.service.ts` implicitly assumes `anchorQuote` will
be a string if `anchorStart` is present. If an API client submits a request with
`anchorStart` and `anchorEnd` but omits `anchorQuote`, the comparison `body.anchorQuote
!== plainText.slice(...)` becomes `undefined !== "some text"`. This incorrectly triggers
a `BadRequestException` with the misleading message "Anchor does not match the post
text", when the actual issue is the missing `anchorQuote` field. This behavior is
confined to direct API interaction, as the client-side implementation always sends the
complete anchor object.

@nevo-david nevo-david added guard:approved and removed guard:blocked Touches a guarded path and is awaiting sign-off labels Sep 24, 2026
@nevo-david
nevo-david added this pull request to the merge queue Sep 24, 2026
Merged via the queue into main with commit 33e126a Sep 24, 2026
10 checks passed
@nevo-david
nevo-david deleted the feat/preview-inline-comments branch September 24, 2026 03:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants