Skip to content

Commit cda1150

Browse files
HMarzbanclaude
andcommitted
fix(webapp): harden chat avatars, pasted nodes and new slugs
A live chat message now loads its author's public profile once per author. It no longer reads workspace presence, because any client can forge a presence payload. Your own messages still use the user_details on the optimistic row. Pasted HTML can no longer create an upload placeholder node, because the node has no parseHTML now. An unknown file type falls back to the image icon. The heading unread sync escapes each channel id before it builds a selector. So a pasted node or a channel row cannot crash a document page. A Cypress paste case pins the regression. randomDocumentSlug() now builds an 11-character 0-9a-z slug from crypto.getRandomValues, with no modulo bias. That covers /new, the new. host in src/proxy.ts, the home page and /receive. Root CLAUDE.md §Settled now protects only the Health and API routing parts of proxy.ts, and names #411 and #442 as approved edits. Refs #428 Refs #436 Refs #442 Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 6c0c8e2 commit cda1150

10 files changed

Lines changed: 84 additions & 29 deletions

File tree

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ Each was decided, and in most cases built and reverted. If you want to change on
5050
- **Collab session helpers.** Auth and disconnect predicates live in `apps/webapp/src/hooks/collabSession.ts`. Do not rewrite `HocuspocusProvider` construction. Do not re-export those helpers from `@utils`.
5151
- **Report route storage.** The Report action posts to the published contact address in `LEGAL_CONTACT_EMAIL` and keeps nothing. That was the deliberate scope, taken 2026-09-06 with the launch-legal review. Build storage or triage tooling only when real report volume shows it is needed, and bring the count rather than a design. One canonical home, `apps/webapp/src/utils/reportContent.ts` — do not add a second report path. Document report is a Settings row for signed-in users on a pad (`/[...slugs]`). Hide it on Home. Do not restore a pad-title control. The chat message Report row stays at `display: false`. Contract in [apps/webapp/CLAUDE.md](apps/webapp/CLAUDE.md) §Report Route.
5252
- **Passkeys are off.** Removed 2026-09-07. No sign-in button, no Conditional UI, no Settings card, no `auth.experimental.passkey`. Local `[auth.passkey] enabled = false`. Sign-in is Google or an email link. Do not rebuild the client. A production dashboard toggle that is still on does not restore the UI.
53-
- **Next product APIs.** Health stays on Next. Validate lives on the email router as `POST /api/email/validate`. Status does not become a Hono route. Confirm is deleted and stays deleted. Leave `apps/webapp/src/utils/supabase/api.ts`. Do not edit `src/proxy.ts`, Next health rewrites, Traefik, or Docker HEALTHCHECK. Names live in `CONTEXT.md` §Product HTTP.
53+
- **Next product APIs.** Health stays on Next. Validate lives on the email router as `POST /api/email/validate`. Status does not become a Hono route. Confirm is deleted and stays deleted. Leave `apps/webapp/src/utils/supabase/api.ts`. Do not change the Health and API routing in `src/proxy.ts`, Next health rewrites, Traefik, or Docker HEALTHCHECK. Two other `src/proxy.ts` edits are approved: #411 forwards only `error_code` to `/auth/error`, and #442 makes the `new.` host mint its slug with `randomDocumentSlug()`. Any other `src/proxy.ts` edit needs a maintainer ruling. Names live in `CONTEXT.md` §Product HTTP.
5454
- **History restore is signed-in only.** Visitors may read history. They never see Restore. Do not replace the hidden control with a sign-in toast on the same button. Server refusal is `unauthorized` / `read-only`. See webapp §Document Version History and `CONTEXT.md` §Document access.
5555
- **Chat send animation.** An iMessage-style send flight was built and reverted at maintainer request on 2026-09-21, and #263 closed with motion rejected. Own sends show only the static pending clock. Do not rebuild any send motion unless the maintainer asks. See [apps/webapp/src/components/chatroom/CLAUDE.md](apps/webapp/src/components/chatroom/CLAUDE.md).
5656
- **Phone mic is hold-only.** A tap shows a hint and never sends; release sends the note, alone, once its upload is ready. Maintainer ruling, 2026-09-22. Do not add tap-to-record on a phone. Rules and reasons in [apps/webapp/src/components/chatroom/CLAUDE.md](apps/webapp/src/components/chatroom/CLAUDE.md).

‎apps/webapp/CHANGELOG.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,20 @@ plus the house order in [`RELEASE_POLICY.md`](../../RELEASE_POLICY.md).
545545
- **A notification click opens same-origin URLs only.** The service worker
546546
opens `/` for any other `action_url`
547547
([#417](https://github.com/docs-plus/docs.plus/issues/417)).
548+
- **A live chat message takes its author's avatar from the profile, not from
549+
presence.** Any client can send a presence payload. A forged one could change
550+
the avatar on another user's live messages, and the name and avatar in the
551+
media viewer. Both now read the profile. One request serves every
552+
message by that author
553+
([#428](https://github.com/docs-plus/docs.plus/issues/428)).
554+
- **A paste or a channel row can no longer crash a document page.** Pasted
555+
HTML never creates an upload placeholder, and an unknown file type shows the
556+
image icon. The heading unread sync escapes each channel id before it builds
557+
a selector ([#436](https://github.com/docs-plus/docs.plus/issues/436)).
558+
- **A new document link comes from a cryptographic random source.** `/new`,
559+
the `new.` host, the home page and shared files make an 11-character link
560+
that nobody can predict and claim first
561+
([#442](https://github.com/docs-plus/docs.plus/issues/442)).
548562

549563
### Removed
550564

‎apps/webapp/cypress/e2e/editor/copy-paste/standard-paste.cy.js‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,21 @@ describe('Standard Paste (Flat Schema)', () => {
5656
cy.get('h2[data-toc-id]').should('contain', 'My Heading')
5757
cy.get('.docy_editor .tiptap.ProseMirror p').should('contain', 'Plain paragraph text')
5858
})
59+
60+
it('should not create an upload placeholder from pasted HTML', () => {
61+
cy.clearEditor()
62+
cy.get('.docy_editor > .tiptap.ProseMirror').type('Paste Target{enter}')
63+
64+
cy.pasteWithMimeTypes({
65+
'text/html':
66+
'<div data-type="media-upload-placeholder" filetype="constructor"></div><p>after</p>',
67+
'text/plain': 'after'
68+
})
69+
70+
cy.get('.docy_editor .tiptap.ProseMirror p').should('contain', 'after')
71+
cy.get('.media-upload-placeholder').should('not.exist')
72+
73+
cy.get('.docy_editor > .tiptap.ProseMirror').type(' still typing')
74+
cy.get('.docy_editor .tiptap.ProseMirror').should('contain', 'still typing')
75+
})
5976
})

‎apps/webapp/src/components/TipTap/nodes/MediaUploadPlaceholder.tsx‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const MediaUploadPlaceholderComponent: React.FC<NodeViewProps> = ({ node, delete
4444

4545
// The node type stays registered so stored docs containing zombie placeholders
4646
// from past sessions remain valid under enableContentCheck; nothing inserts it anymore.
47+
// It has no parseHTML, so a paste can never create it.
4748
export default Node.create({
4849
name: 'mediaUploadPlaceholder',
4950
group: 'block',
@@ -61,10 +62,6 @@ export default Node.create({
6162
}
6263
},
6364

64-
parseHTML() {
65-
return [{ tag: 'div[data-type="media-upload-placeholder"]' }]
66-
},
67-
6865
renderHTML({ HTMLAttributes }) {
6966
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'media-upload-placeholder' })]
7067
},

‎apps/webapp/src/components/TipTap/nodes/MediaUploadPlaceholderBody.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const FileTypeIcon: React.FC<{ fileType: FileType }> = ({ fileType }) => {
1616
audio: HiOutlineMusicalNote,
1717
image: HiOutlinePhoto
1818
}
19-
const IconComponent = icons[fileType] || icons.image
19+
const IconComponent = Object.hasOwn(icons, fileType) ? icons[fileType] : icons.image
2020
return <IconComponent className={ICON_CLASS} />
2121
}
2222

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import { getUserProfileForModal } from '@api'
12
import type { MessageRowUserDetails } from '@components/chatroom/types/chat-items'
2-
import { useStore } from '@stores'
3+
import { useQuery } from '@tanstack/react-query'
34
import type { TGroupedMsgRow } from '@types'
45
import { useMemo } from 'react'
56

@@ -20,29 +21,39 @@ const toMessageAuthorDetails = (
2021
avatar_updated_at: raw.avatar_updated_at ?? null
2122
})
2223

23-
/** Realtime `messages` rows omit `user_details`; fall back to workspace presence profile. */
24+
const fetchMessageAuthor = async (userId: string): Promise<MessageRowUserDetails> => {
25+
const { data, error } = await getUserProfileForModal(userId)
26+
if (error) throw error
27+
return toMessageAuthorDetails(userId, data)
28+
}
29+
30+
/**
31+
* Realtime rows omit `user_details`, so load the author's public profile.
32+
* Never use presence here, because any client can forge a presence payload.
33+
*/
2434
export const useMessageAuthorDetails = (message: TGroupedMsgRow): MessageRowUserDetails | null => {
25-
const presenceProfile = useStore((state) =>
26-
message.user_id ? state.usersPresence.get(message.user_id) : undefined
27-
)
35+
const userId = message.user_id
36+
const { data: profile } = useQuery({
37+
queryKey: ['chat-author', userId],
38+
queryFn: () => fetchMessageAuthor(userId),
39+
enabled: !message.user_details?.id && !!userId,
40+
staleTime: Infinity,
41+
// A missing user row stays missing, so a retry only delays the id-only avatar.
42+
retry: false
43+
})
2844

2945
return useMemo((): MessageRowUserDetails | null => {
3046
const ud = message.user_details
3147
if (ud?.id) return toMessageAuthorDetails(ud.id, ud)
32-
33-
const userId = message.user_id
3448
if (!userId) return null
49+
if (profile) return profile
3550

36-
if (!presenceProfile) {
37-
return {
38-
id: userId,
39-
username: null,
40-
fullname: null,
41-
avatar_url: null,
42-
avatar_updated_at: null
43-
}
51+
return {
52+
id: userId,
53+
username: null,
54+
fullname: null,
55+
avatar_url: null,
56+
avatar_updated_at: null
4457
}
45-
46-
return toMessageAuthorDetails(userId, presenceProfile)
47-
}, [message.user_details, message.user_id, presenceProfile])
58+
}, [message.user_details, userId, profile])
4859
}

‎apps/webapp/src/components/chatroom/types/chat-items.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import type { MessageStatus } from '../../../types/message'
55
/**
66
* Author profile inlined by fetch_message_window / fetch_messages_since via
77
* user_details_json(u). Optional because base-table inserts and realtime
8-
* `postgres_changes` payloads do not carry it; consumers must fall back to
9-
* the auth store for those rows.
8+
* `postgres_changes` payloads do not carry it. For those rows,
9+
* `useMessageAuthorDetails` loads the author's public profile.
1010
*/
1111
export type MessageRowUserDetails = {
1212
id: string

‎apps/webapp/src/proxy.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { logger } from '@utils/logger'
2+
import { randomDocumentSlug } from '@utils/sanitizeDocumentSlug'
23
import { type NextRequest, NextResponse } from 'next/server'
34

45
/** Runs on the Edge Runtime — keep it lightweight and free of Node-only APIs. */
@@ -10,7 +11,7 @@ export async function proxy(request: NextRequest) {
1011
// new.{domain} → a fresh random document, mirrored by the /new page route.
1112
const hostname = request.headers.get('host') || ''
1213
if (hostname.startsWith('new.')) {
13-
const randomSlug = (Math.random() + 1).toString(36).substring(2)
14+
const randomSlug = randomDocumentSlug()
1415
const mainHost = hostname.replace(/^new\./, '')
1516
return NextResponse.redirect(`${request.nextUrl.protocol}//${mainHost}/${randomSlug}`, 307)
1617
}

‎apps/webapp/src/services/headingWidgetUnread.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ export function syncHeadingWidgetUnread(): void {
3838
})
3939

4040
source.channels.forEach((_channel, channelId) => {
41-
const headingEl = document.querySelector<HTMLElement>(`[data-toc-id="${channelId}"]`)
41+
const headingEl = document.querySelector<HTMLElement>(
42+
`[data-toc-id="${CSS.escape(channelId)}"]`
43+
)
4244
if (!headingEl) return
4345
const el = headingEl.querySelector<HTMLElement>(
4446
`${headingChatBtnSelector}:not([data-heading-id])`

‎apps/webapp/src/utils/sanitizeDocumentSlug.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,22 @@ import { isReservedSlug } from './reservedSlugs'
44

55
const MIN_SLUG_LENGTH = 3
66
const MAX_SLUG_LENGTH = 30
7+
const SLUG_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'
8+
const RANDOM_SLUG_LENGTH = 11
9+
// 252 is 36 x 7. A byte at or above it would favour the start of the alphabet.
10+
const UNBIASED_BYTE_LIMIT = 252
711

12+
/** The first signed-in edit owns a new document, so its slug must not be guessable. */
813
export function randomDocumentSlug(): string {
9-
return (Math.random() + 1).toString(36).substring(2)
14+
let slug = ''
15+
while (slug.length < RANDOM_SLUG_LENGTH) {
16+
for (const byte of crypto.getRandomValues(new Uint8Array(16))) {
17+
if (byte < UNBIASED_BYTE_LIMIT && slug.length < RANDOM_SLUG_LENGTH) {
18+
slug += SLUG_ALPHABET[byte % SLUG_ALPHABET.length]
19+
}
20+
}
21+
}
22+
return slug
1023
}
1124

1225
export function sanitizeDocumentSlug(input?: string): string {

0 commit comments

Comments
 (0)