fix(web): code review round 1 -- critical data-layer bugs

Three parallel adversarial code-review passes surfaced real defects across
the app. This commit covers the data/logic layer:

- CRITICAL: file upload/download used the wrong auth header entirely.
  Built from docs/IM-前后端对接文档.md as `X-MeetingIM-Auth: token <token>`,
  but the old app's own upload code carries an explicit comment that the
  real header is `Authorization: Token <token>` (capitalized scheme,
  standard header name), "verified live against the real server, not just
  read from source" -- and separately warns the SDK's own built-in
  LargeFileHelper gets this wrong too (sends X-Tinode-APIKey/X-Tinode-Auth,
  which this backend doesn't recognize). This means every external
  (non-inline) image/file/voice attachment -- anything too large to send as
  base64 -- was silently broken; only the earlier live tests happened to
  use small inline attachments, which never exercise this code path.
  Confirmed fixed live: a real >256KB image now uploads (200, real file_id)
  and downloads (blob URL, correct 800x600 dimensions) correctly.
- CRITICAL: TopicMe.onContactUpdate was never wired. Marking a conversation
  read (topic.noteRead()) updates the contact's unread count through ONLY
  this single-slot callback (confirmed against the SDK's own
  Topic.note()/TopicMe._refreshContact -- it does not also fire onData/
  onInfo/onPres, unlike every other _refreshContact call site, which do).
  Without it the chat list's unread badge/ordering went stale after reading
  a conversation until an unrelated topic event happened to invalidate the
  shared snapshot cache.
- HIGH: chat-list message preview didn't check isServerDeleted(msg) (the
  `hi` soft-delete flag), only `content == null` -- a hi-flagged message
  with non-null content leaked its real text/attachment label into the
  preview instead of showing "[消息已删除]".
- MEDIUM: upload failures showed raw untranslated SDK ctrl.text (e.g. "too
  large" instead of "文件大小超出限制") -- ported the old app's
  UPLOAD_CTRL_TEXT_CN table, which it built specifically because this
  native-protocol endpoint's errors never pass through the REST layer's
  own reason-code translation.
- Ambient SDK type corrections (tinode-sdk.d.ts): AccessMode.mode/given/want
  are numeric bitmasks at runtime, not the "JRWPS"-string type they were
  declared as (dormant today, nothing reads them yet, but wrong ahead of
  Phase 3's group-role logic); Drafty.sanitizeEntities's doc comment
  claimed a deep copy it doesn't do (verified it mutates entity .data in
  place); onAutoreconnectIteration's first param was misnamed "seconds"
  when the SDK always passes milliseconds.
- sendVoice() now passes `filename` to Drafty.insertAudio (was the only one
  of sendImage/sendFile/sendVoice omitting it).
- friends.ts's accept/reject-friend-request `version` param used a falsy
  check (`version ? ... : ...`) that would silently drop a legitimate `0`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-08 03:23:01 +08:00
co-authored by Claude Sonnet 5
parent d5440b962b
commit 986432addd
7 changed files with 89 additions and 23 deletions
+8 -2
View File
@@ -75,8 +75,14 @@ export async function sendVoice(topic: Topic, blob: Blob, durationMs: number): P
const file = new File([blob], `voice.${blob.type.includes('mp4') ? 'm4a' : 'webm'}`, { type: blob.type });
const content =
file.size <= MAX_INBAND_ATTACHMENT_SIZE
? Drafty.insertAudio(undefined, 0, { mime: file.type, bits: await fileToBase64(file), duration: durationMs, size: file.size })
: Drafty.insertAudio(undefined, 0, { mime: file.type, refurl: (await uploadFile(file, topic.name)).url, duration: durationMs, size: file.size });
? Drafty.insertAudio(undefined, 0, { mime: file.type, bits: await fileToBase64(file), duration: durationMs, size: file.size, filename: file.name })
: Drafty.insertAudio(undefined, 0, {
mime: file.type,
refurl: (await uploadFile(file, topic.name)).url,
duration: durationMs,
size: file.size,
filename: file.name,
});
const msg = topic.createMessage(content, false);
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'voice' });
return topic.publishDraft(msg, Promise.resolve());
+30 -10
View File
@@ -47,9 +47,11 @@ declare module 'tinode-sdk' {
}
export class AccessMode {
mode: string;
given: string;
want: string;
/** Numeric bitmasks at runtime (tinode.dev.js:26-33's AccessMode.decode()), NOT the "JRWPS"-style
* strings that only come out of getMode()/getGiven()/getWant()/toString(). */
mode: number;
given: number;
want: number;
isOwner(): boolean;
isAdmin(): boolean;
isSharer(): boolean;
@@ -234,6 +236,13 @@ declare module 'tinode-sdk' {
contacts(callback: (contact: Contact) => void): void;
pinnedTopicRank(topic: string): number;
getMeta(query: unknown): Promise<unknown>;
/**
* Fires when a contact's read/recv/unread state changes via `Topic.note()` (the base `noteRead`/
* noteRecv` path -- confirmed against tinode.dev.js's `TopicMe._refreshContact`/`Topic.note`):
* that path calls ONLY this callback, not `onData`/`onInfo`/`onPres`. Marking a conversation read
* (`topic.noteRead(0)`) updates the contact's unread count via this callback alone.
*/
onContactUpdate?: (what: string, contact: Contact) => void;
}
export class Tinode {
@@ -248,6 +257,9 @@ declare module 'tinode-sdk' {
disconnect(): void;
/** Skips the remaining exponential-backoff wait and retries the connection now. */
reconnect(force?: boolean): void;
/** Monotonically-increasing per-connection request id string; the file-upload endpoint's own
* multipart form expects one in an `id` field (mirrors the SDK's own LargeFileHelper). */
getNextUniqueId(): string;
isConnected(): boolean;
isAuthenticated(): boolean;
getCurrentUserID(): string;
@@ -293,13 +305,14 @@ declare module 'tinode-sdk' {
onLogin?: (code: number, text: string) => void;
onNetworkProbe?: () => void;
/**
* Fires on every SDK-internal reconnect attempt. `seconds` is the SDK's own next-retry countdown
* (exponential backoff, ~2s base doubling up to 10 iterations, +/-30% jitter -- entirely internal
* to the SDK, this app does not implement its own backoff). `promise` is only present while a
* retry is actually in flight; when absent, the app is expected to run its own 1s countdown
* display down to the next attempt.
* Fires on every SDK-internal reconnect attempt. `timeoutMs` is the SDK's own next-retry
* countdown IN MILLISECONDS (exponential backoff, 2000ms base doubling up to 10 iterations,
* +/-30% jitter -- entirely internal to the SDK, verified against tinode.dev.js:451-456,553-556;
* this app does not implement its own backoff, only its own 1s display tick off this one value).
* `promise` is only present while a retry is actually in flight; `timeoutMs < 0` means retrying
* has stopped entirely (e.g. an explicit disconnect).
*/
onAutoreconnectIteration?: (seconds: number, promise?: Promise<unknown>) => void;
onAutoreconnectIteration?: (timeoutMs: number, promise?: Promise<unknown>) => void;
}
export const Drafty: {
@@ -312,7 +325,14 @@ declare module 'tinode-sdk' {
isPlainText(content: DraftyContent | string): boolean;
hasEntities(content: DraftyContent | string): boolean;
entities(content: DraftyContent | string, callback: (data: Record<string, unknown> | undefined) => void, context?: unknown): void;
/** Deep-copies `content` and strips anything the server would reject when re-sent (e.g. as a quote/forward payload). */
/**
* MUTATES `content` in place (tinode.dev.js:2573-2588) -- only each entity's `.data` gets
* replaced with a fresh shallow copy; the document itself, its `.ent` array, and each entity
* wrapper are the SAME objects passed in, not a deep copy. Callers that need to preserve the
* original (e.g. a still-displayed message) must clone before calling this, same as this app's
* own send.ts already does (`JSON.parse(JSON.stringify(...))`) -- don't drop that clone trusting
* this function to have done it.
*/
sanitizeEntities(content: DraftyContent): DraftyContent;
/** Concatenates two Drafty documents, remapping `fmt[].key` entity indices in `second`. */
append(first: DraftyContent | undefined, second: DraftyContent): DraftyContent;
+5
View File
@@ -75,6 +75,11 @@ export function getMessagesSnapshot(topicName: string): StoredMessage[] {
export function subscribeMeTopic(): Promise<unknown> {
const me = getTinode().getMeTopic();
wireTopicCallbacks(me);
// Read/recv/unread updates (e.g. from noteRead() when opening a conversation) travel through
// TopicMe's OWN single-slot callback, separate from onData/onInfo/onPres -- wireTopicCallbacks
// doesn't touch it. Without this, the chat list's unread badge/ordering goes stale after reading
// a conversation until some unrelated topic event happens to invalidate its cached snapshot.
me.onContactUpdate = () => notifyTopic(me.name);
return me.subscribe(me.startMetaQuery().withLaterSub().withDesc().withTags().withCred().build());
}