@lightworkai.official/debug-capture 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -80
- package/dist/index.d.ts +1 -10
- package/dist/index.mjs +9 -14
- package/dist/portal.d.ts +24 -0
- package/package.json +1 -1
- package/src/index.ts +10 -54
- package/src/portal.ts +65 -0
- package/dist/mytickets/api.d.ts +0 -115
- package/dist/mytickets/format.d.ts +0 -84
- package/dist/mytickets/sanitize.d.ts +0 -66
- package/dist/mytickets/strings.d.ts +0 -74
- package/dist/mytickets/toolbar.d.ts +0 -17
- package/src/mytickets/api.ts +0 -226
- package/src/mytickets/format.ts +0 -191
- package/src/mytickets/sanitize.ts +0 -221
- package/src/mytickets/strings.ts +0 -217
- package/src/mytickets/toolbar.ts +0 -48
package/src/mytickets/api.ts
DELETED
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The reporter's own tickets, read back from a Lightwork Support host.
|
|
3
|
-
*
|
|
4
|
-
* Everything here goes to `/ingest/me`, which needs two things: the realm's
|
|
5
|
-
* public key (which app is asking) and a host-signed identity token (who is
|
|
6
|
-
* asking). See `identity` in config.ts for why the second one exists — without
|
|
7
|
-
* it a public key would be enough to read anybody's reports.
|
|
8
|
-
*/
|
|
9
|
-
import { getConfig } from "../config";
|
|
10
|
-
|
|
11
|
-
/** A board column as the realm configured it. Only public lanes are returned. */
|
|
12
|
-
export interface StatusColumn {
|
|
13
|
-
key: string;
|
|
14
|
-
label: string;
|
|
15
|
-
description: string;
|
|
16
|
-
color: string;
|
|
17
|
-
bucket: "active" | "closed";
|
|
18
|
-
awaitingReply: boolean;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface RealmConfig {
|
|
22
|
-
realm: { id: string; slug: string; name: string };
|
|
23
|
-
timezone: string;
|
|
24
|
-
statusColumns: StatusColumn[];
|
|
25
|
-
featureFlags: { selfServiceReopen?: boolean; attachments?: boolean; conversation?: boolean };
|
|
26
|
-
locale: { default: string; labels: Record<string, string> };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface TicketMessage {
|
|
30
|
-
id: string;
|
|
31
|
-
authorId: string | null;
|
|
32
|
-
authorName: string | null;
|
|
33
|
-
authorRole: "REPORTER" | "AGENT";
|
|
34
|
-
body: string;
|
|
35
|
-
createdAt: string;
|
|
36
|
-
editedAt: string | null;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface TicketAttachment {
|
|
40
|
-
id: string;
|
|
41
|
-
objectKey: string;
|
|
42
|
-
filename: string | null;
|
|
43
|
-
contentType: string | null;
|
|
44
|
-
sizeBytes: number | null;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export interface MyTicket {
|
|
48
|
-
id: string;
|
|
49
|
-
number: number;
|
|
50
|
-
title: string;
|
|
51
|
-
description: string | null;
|
|
52
|
-
category: string | null;
|
|
53
|
-
routeUrl: string | null;
|
|
54
|
-
status: string;
|
|
55
|
-
priority: string;
|
|
56
|
-
resolutionNote: string | null;
|
|
57
|
-
reopened: boolean;
|
|
58
|
-
createdAt: string;
|
|
59
|
-
updatedAt: string;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export interface MyTicketListItem extends MyTicket {
|
|
63
|
-
messageCount: number;
|
|
64
|
-
/** How many replies came from the team — "has anyone answered me yet". */
|
|
65
|
-
agentReplyCount: number;
|
|
66
|
-
lastMessage: { at: string; authorRole: "REPORTER" | "AGENT"; preview: string } | null;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export interface MyTicketDetail extends MyTicket {
|
|
70
|
-
messages: TicketMessage[];
|
|
71
|
-
attachments: TicketAttachment[];
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** One recorded status change. Internal lanes are already resolved server-side. */
|
|
75
|
-
export interface StatusEvent {
|
|
76
|
-
id: string;
|
|
77
|
-
fromStatus: string | null;
|
|
78
|
-
toStatus: string;
|
|
79
|
-
via: string;
|
|
80
|
-
createdAt: string;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Thrown with the server's own words, so a panel can say what went wrong. */
|
|
84
|
-
export class TicketApiError extends Error {
|
|
85
|
-
constructor(
|
|
86
|
-
message: string,
|
|
87
|
-
readonly status: number,
|
|
88
|
-
) {
|
|
89
|
-
super(message);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Raised when there is no signed-in reporter to ask about. */
|
|
94
|
-
export class NoIdentityError extends Error {
|
|
95
|
-
constructor() {
|
|
96
|
-
super("[debug-capture] no reporter identity — configure `identity` to use the tickets panel");
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function base(): string {
|
|
101
|
-
return getConfig().host.replace(/\/$/, "");
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* The host's current identity token.
|
|
106
|
-
*
|
|
107
|
-
* Read per request rather than once at configure time: these are short-lived by
|
|
108
|
-
* design, and a panel left open across lunch would otherwise hold a token that
|
|
109
|
-
* expired an hour ago. Caching belongs on the host's side, where it knows when
|
|
110
|
-
* it last minted one.
|
|
111
|
-
*/
|
|
112
|
-
async function identityToken(): Promise<string> {
|
|
113
|
-
const read = getConfig().identity;
|
|
114
|
-
if (!read) throw new NoIdentityError();
|
|
115
|
-
const token = await read();
|
|
116
|
-
if (!token) throw new NoIdentityError();
|
|
117
|
-
return token;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
121
|
-
const token = await identityToken();
|
|
122
|
-
const res = await fetch(`${base()}/ingest/me${path}`, {
|
|
123
|
-
...init,
|
|
124
|
-
headers: {
|
|
125
|
-
"x-realm-key": getConfig().realmKey,
|
|
126
|
-
authorization: `Bearer ${token}`,
|
|
127
|
-
...(init.body ? { "content-type": "application/json" } : {}),
|
|
128
|
-
...(init.headers as Record<string, string> | undefined),
|
|
129
|
-
},
|
|
130
|
-
});
|
|
131
|
-
if (!res.ok) {
|
|
132
|
-
const detail = await res
|
|
133
|
-
.json()
|
|
134
|
-
.then((b: { error?: string }) => b.error)
|
|
135
|
-
.catch(() => null);
|
|
136
|
-
throw new TicketApiError(detail ?? `Request failed (${res.status})`, res.status);
|
|
137
|
-
}
|
|
138
|
-
return (await res.json()) as T;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
export async function listMyTickets(): Promise<MyTicketListItem[]> {
|
|
142
|
-
const { items } = await request<{ items: MyTicketListItem[] }>("/tickets");
|
|
143
|
-
return items;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
export function getMyTicket(id: string): Promise<MyTicketDetail> {
|
|
147
|
-
return request<MyTicketDetail>(`/tickets/${encodeURIComponent(id)}`);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
export function getMyTicketByNumber(num: number): Promise<MyTicketDetail> {
|
|
151
|
-
return request<MyTicketDetail>(`/tickets/by-number/${num}`);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* A short-lived URL for a file on one's own ticket.
|
|
156
|
-
*
|
|
157
|
-
* Fetched rather than pointed at: the endpoint needs the realm key and the
|
|
158
|
-
* identity token, and an `<img>` or an `<a download>` cannot send a header.
|
|
159
|
-
*/
|
|
160
|
-
export function getAttachmentUrl(attachmentId: string): Promise<{ url: string; filename: string | null }> {
|
|
161
|
-
return request<{ url: string; filename: string | null }>(`/attachments/${encodeURIComponent(attachmentId)}`);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export function getMyTicketHistory(id: string): Promise<StatusEvent[]> {
|
|
165
|
-
return request<StatusEvent[]>(`/tickets/${encodeURIComponent(id)}/history`);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
export function replyToTicket(id: string, body: string): Promise<TicketMessage> {
|
|
169
|
-
return request<TicketMessage>(`/tickets/${encodeURIComponent(id)}/messages`, {
|
|
170
|
-
method: "POST",
|
|
171
|
-
body: JSON.stringify({ body }),
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Rewrite one's own reply.
|
|
177
|
-
*
|
|
178
|
-
* The server refuses any message the caller did not write — authorship, not
|
|
179
|
-
* role — so this cannot touch the team's side of the thread.
|
|
180
|
-
*/
|
|
181
|
-
export function editMyMessage(ticketId: string, messageId: string, body: string): Promise<TicketMessage> {
|
|
182
|
-
return request<TicketMessage>(
|
|
183
|
-
`/tickets/${encodeURIComponent(ticketId)}/messages/${encodeURIComponent(messageId)}`,
|
|
184
|
-
{ method: "PUT", body: JSON.stringify({ body }) },
|
|
185
|
-
);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
export function reopenMyTicket(id: string, note?: string): Promise<MyTicket> {
|
|
189
|
-
return request<MyTicket>(`/tickets/${encodeURIComponent(id)}/reopen`, {
|
|
190
|
-
method: "POST",
|
|
191
|
-
body: JSON.stringify({ note: note?.trim() || undefined }),
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
export function uploadInlineImage(id: string, dataUri: string, filename?: string): Promise<{ url: string }> {
|
|
196
|
-
return request<{ url: string }>(`/tickets/${encodeURIComponent(id)}/inline-image`, {
|
|
197
|
-
method: "POST",
|
|
198
|
-
body: JSON.stringify({ dataUri, filename }),
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* The realm's status vocabulary. Public config, so no identity token — and
|
|
204
|
-
* cached for the life of the page, because it changes when an admin edits the
|
|
205
|
-
* board and not between two clicks of a list.
|
|
206
|
-
*/
|
|
207
|
-
let configPromise: Promise<RealmConfig> | null = null;
|
|
208
|
-
|
|
209
|
-
export function fetchRealmConfig(): Promise<RealmConfig> {
|
|
210
|
-
if (!configPromise) {
|
|
211
|
-
const key = encodeURIComponent(getConfig().realmKey);
|
|
212
|
-
configPromise = fetch(`${base()}/ingest/config?key=${key}`)
|
|
213
|
-
.then((res) => {
|
|
214
|
-
if (!res.ok) throw new TicketApiError(`Config failed (${res.status})`, res.status);
|
|
215
|
-
return res.json() as Promise<RealmConfig>;
|
|
216
|
-
})
|
|
217
|
-
// A failed fetch must not be remembered as the answer: the next open
|
|
218
|
-
// should try again rather than reuse a rejected promise forever.
|
|
219
|
-
.catch((e) => {
|
|
220
|
-
configPromise = null;
|
|
221
|
-
throw e;
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
return configPromise;
|
|
225
|
-
}
|
|
226
|
-
|
package/src/mytickets/format.ts
DELETED
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The panel's decisions that are not DOM: how a ticket is named, dated, sorted,
|
|
3
|
-
* searched and described. Pure, so they can be tested without a browser — which
|
|
4
|
-
* matters here, because the DOM around them cannot be.
|
|
5
|
-
*/
|
|
6
|
-
import type { MyTicketListItem, StatusColumn } from "./api";
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* `ROOT-1099`, from the realm's slug — the same key the board and the admin app
|
|
10
|
-
* print, so a reporter quoting a number is quoting the one support will search
|
|
11
|
-
* for. A realm with no usable slug falls back to `#1099` rather than inventing
|
|
12
|
-
* a prefix.
|
|
13
|
-
*/
|
|
14
|
-
export function ticketKey(slug: string | undefined, number: number): string {
|
|
15
|
-
const prefix = (slug ?? "").trim().toUpperCase().replace(/[^A-Z0-9]+/g, "");
|
|
16
|
-
return prefix ? `${prefix}-${number}` : `#${number}`;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* `24 พ.ค. 2568 13:00` — the original's `DD MMM BBBB HH:mm`, from the platform's
|
|
21
|
-
* own Intl rather than a date library this package will not depend on.
|
|
22
|
-
*
|
|
23
|
-
* Formatted in the REALM's timezone, not the reader's. A support desk in
|
|
24
|
-
* Bangkok and a reporter travelling in Berlin must not disagree about which day
|
|
25
|
-
* a ticket was filed, and the realm is the side that also runs the "today"
|
|
26
|
-
* filters.
|
|
27
|
-
*/
|
|
28
|
-
export function formatDateTime(iso: string | null | undefined, timezone: string, locale: "th" | "en" | undefined): string {
|
|
29
|
-
if (!iso) return "—";
|
|
30
|
-
const date = new Date(iso);
|
|
31
|
-
if (Number.isNaN(date.getTime())) return "—";
|
|
32
|
-
try {
|
|
33
|
-
return new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "th-TH-u-ca-buddhist", {
|
|
34
|
-
dateStyle: "medium",
|
|
35
|
-
timeStyle: "short",
|
|
36
|
-
timeZone: timezone,
|
|
37
|
-
}).format(date);
|
|
38
|
-
} catch {
|
|
39
|
-
// An unknown timezone from config must not blank out every date on screen.
|
|
40
|
-
return date.toISOString().slice(0, 16).replace("T", " ");
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Lookups over the realm's configured lanes, with sane answers for a key that isn't there. */
|
|
45
|
-
export interface StatusMaps {
|
|
46
|
-
labelOf: (key: string) => string;
|
|
47
|
-
columnOf: (key: string) => StatusColumn | undefined;
|
|
48
|
-
isAwaitingReply: (key: string) => boolean;
|
|
49
|
-
isClosed: (key: string) => boolean;
|
|
50
|
-
/** Board order, for sorting a column of statuses the way the board reads. */
|
|
51
|
-
rankOf: (key: string) => number;
|
|
52
|
-
options: { value: string; label: string }[];
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function statusMaps(columns: StatusColumn[], allLabel: string): StatusMaps {
|
|
56
|
-
const byKey = new Map(columns.map((c) => [c.key, c]));
|
|
57
|
-
const order = columns.map((c) => c.key);
|
|
58
|
-
return {
|
|
59
|
-
// An unknown key prints itself rather than an empty pill: a lane deleted
|
|
60
|
-
// after a ticket entered it should still say something.
|
|
61
|
-
labelOf: (key) => byKey.get(key)?.label ?? key,
|
|
62
|
-
columnOf: (key) => byKey.get(key),
|
|
63
|
-
isAwaitingReply: (key) => byKey.get(key)?.awaitingReply === true,
|
|
64
|
-
isClosed: (key) => byKey.get(key)?.bucket === "closed",
|
|
65
|
-
rankOf: (key) => {
|
|
66
|
-
const i = order.indexOf(key);
|
|
67
|
-
return i === -1 ? order.length : i;
|
|
68
|
-
},
|
|
69
|
-
options: [{ value: "", label: allLabel }, ...columns.map((c) => ({ value: c.key, label: c.label }))],
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Has the team written a solution note? */
|
|
74
|
-
export function hasSolution(t: MyTicketListItem): boolean {
|
|
75
|
-
return Boolean(t.resolutionNote && t.resolutionNote.trim());
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Has anyone from the team replied at all? */
|
|
79
|
-
export function hasTeamReply(t: MyTicketListItem): boolean {
|
|
80
|
-
return t.agentReplyCount > 0;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Solution beats a reply beats silence — the order the column sorts in. */
|
|
84
|
-
export function responseRank(t: MyTicketListItem): number {
|
|
85
|
-
return hasSolution(t) ? 2 : hasTeamReply(t) ? 1 : 0;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export type SortKey = "number" | "title" | "module" | "status" | "response" | "createdAt" | "updatedAt";
|
|
89
|
-
|
|
90
|
-
export interface Sort {
|
|
91
|
-
key: SortKey;
|
|
92
|
-
direction: "asc" | "desc";
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Filter by what the reporter SEES.
|
|
97
|
-
*
|
|
98
|
-
* The haystack is the rendered row — key, title, module, status label — because
|
|
99
|
-
* that is what someone searching has in front of them. Searching the raw status
|
|
100
|
-
* key would match `IN_PROGRESS` for a lane the reader only ever saw called
|
|
101
|
-
* กำลังดำเนินการ.
|
|
102
|
-
*/
|
|
103
|
-
export function matchesKeyword(
|
|
104
|
-
t: MyTicketListItem,
|
|
105
|
-
keyword: string,
|
|
106
|
-
slug: string | undefined,
|
|
107
|
-
labelOf: (key: string) => string,
|
|
108
|
-
): boolean {
|
|
109
|
-
const needle = keyword.trim().toLowerCase();
|
|
110
|
-
if (!needle) return true;
|
|
111
|
-
const hay = [ticketKey(slug, t.number), String(t.number), t.title, t.category ?? "", labelOf(t.status)]
|
|
112
|
-
.join(" ")
|
|
113
|
-
.toLowerCase();
|
|
114
|
-
return hay.includes(needle);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Comparators for the orders a raw value compare gets wrong: status follows the
|
|
119
|
-
* board rather than the alphabet, dates are chronological rather than
|
|
120
|
-
* lexicographic, and the response column ranks by a signal that is not one field.
|
|
121
|
-
*/
|
|
122
|
-
export function compareBy(sort: Sort, maps: StatusMaps, slug: string | undefined) {
|
|
123
|
-
const direction = sort.direction === "asc" ? 1 : -1;
|
|
124
|
-
return (a: MyTicketListItem, b: MyTicketListItem): number => {
|
|
125
|
-
let result = 0;
|
|
126
|
-
switch (sort.key) {
|
|
127
|
-
case "number":
|
|
128
|
-
result = a.number - b.number;
|
|
129
|
-
break;
|
|
130
|
-
case "title":
|
|
131
|
-
result = (a.title ?? "").localeCompare(b.title ?? "", "th");
|
|
132
|
-
break;
|
|
133
|
-
case "module":
|
|
134
|
-
result = (a.category ?? "").localeCompare(b.category ?? "", "th");
|
|
135
|
-
break;
|
|
136
|
-
case "status":
|
|
137
|
-
result = maps.rankOf(a.status) - maps.rankOf(b.status);
|
|
138
|
-
break;
|
|
139
|
-
case "response":
|
|
140
|
-
result = responseRank(a) - responseRank(b);
|
|
141
|
-
break;
|
|
142
|
-
case "createdAt":
|
|
143
|
-
result = Date.parse(a.createdAt) - Date.parse(b.createdAt);
|
|
144
|
-
break;
|
|
145
|
-
case "updatedAt":
|
|
146
|
-
result = Date.parse(a.updatedAt) - Date.parse(b.updatedAt);
|
|
147
|
-
break;
|
|
148
|
-
}
|
|
149
|
-
// Ties resolve by ticket number so a re-sort never reshuffles equal rows —
|
|
150
|
-
// a list that changes order when you click the same header twice reads as a
|
|
151
|
-
// bug even when the sort is right.
|
|
152
|
-
return result !== 0 ? result * direction : (a.number - b.number) * direction;
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/** Rows per page — the original's `defaultPageSize`. */
|
|
157
|
-
export const PAGE_SIZE = 10;
|
|
158
|
-
|
|
159
|
-
export function pageOf<T>(rows: T[], page: number, size = PAGE_SIZE): T[] {
|
|
160
|
-
return rows.slice((page - 1) * size, page * size);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
export function pageCount(total: number, size = PAGE_SIZE): number {
|
|
164
|
-
return Math.max(1, Math.ceil(total / size));
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* The same ten colour tokens the board uses, as concrete pairs.
|
|
169
|
-
*
|
|
170
|
-
* A status arrives as DATA — a realm adds lanes whenever it likes — so a pill is
|
|
171
|
-
* coloured with an inline style from this map rather than a class a stylesheet
|
|
172
|
-
* could never know the name of. Keeping the values identical
|
|
173
|
-
* to the app's own STATUS_TONES is the point: a reporter and an agent looking at
|
|
174
|
-
* the same ticket should see the same colour for it.
|
|
175
|
-
*/
|
|
176
|
-
export const STATUS_TONES: Record<string, { fg: string; bg: string }> = {
|
|
177
|
-
blue: { fg: "#1c5cab", bg: "#e8f1fd" },
|
|
178
|
-
amber: { fg: "#92400e", bg: "#fef3c7" },
|
|
179
|
-
violet: { fg: "#4a3aa7", bg: "#ece9fb" },
|
|
180
|
-
emerald: { fg: "#0f766e", bg: "#d9f2ec" },
|
|
181
|
-
gray: { fg: "#4b5563", bg: "#f1f2f4" },
|
|
182
|
-
red: { fg: "#b91c1c", bg: "#fde8e8" },
|
|
183
|
-
sky: { fg: "#0369a1", bg: "#e0f2fe" },
|
|
184
|
-
rose: { fg: "#be123c", bg: "#ffe4e9" },
|
|
185
|
-
teal: { fg: "#0f766e", bg: "#d7f2ef" },
|
|
186
|
-
orange: { fg: "#c2410c", bg: "#ffedd5" },
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
export function statusTone(token: string | undefined): { fg: string; bg: string } {
|
|
190
|
-
return STATUS_TONES[token ?? ""] ?? STATUS_TONES.gray!;
|
|
191
|
-
}
|
|
@@ -1,221 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Rendering a ticket body inside somebody else's app.
|
|
3
|
-
*
|
|
4
|
-
* Bodies are HTML — the composer's formatting and its inline screenshots are the
|
|
5
|
-
* point of the thread, so stripping to text would lose what a reporter actually
|
|
6
|
-
* came to read. But that HTML was written by other people, and this package
|
|
7
|
-
* renders it inside a HOST application's page. Markup that ran there would run
|
|
8
|
-
* with the host's origin, against the host's session. A support widget must not
|
|
9
|
-
* be the way an app gets XSS.
|
|
10
|
-
*
|
|
11
|
-
* The support app solves this with DOMPurify. A package that every consumer
|
|
12
|
-
* installs cannot take that dependency for one screen, so this is the same
|
|
13
|
-
* POLICY over the platform's own parser: parse the string with DOMParser (never
|
|
14
|
-
* a regex), walk the result, and delete every node and attribute not on the
|
|
15
|
-
* list. Anything unrecognised is removed rather than escaped, and the walk is
|
|
16
|
-
* over an inert document, so nothing loads or executes while it happens.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
/** Tags an ordinary note actually uses. Everything else goes. */
|
|
20
|
-
export const ALLOWED_TAGS = new Set([
|
|
21
|
-
"p", "br", "strong", "b", "em", "i", "u", "s", "code", "pre",
|
|
22
|
-
"ul", "ol", "li", "blockquote", "h1", "h2", "h3", "a", "img", "span",
|
|
23
|
-
]);
|
|
24
|
-
|
|
25
|
-
export const ALLOWED_ATTR = new Set(["href", "target", "rel", "src", "alt", "title"]);
|
|
26
|
-
|
|
27
|
-
/** `/api/inline/<64 hex>` — the shape the support host mints for an embedded image. */
|
|
28
|
-
const INLINE_PATH = /^\/api\/inline\/[a-f0-9]{64}$/i;
|
|
29
|
-
|
|
30
|
-
/** Links: ordinary web destinations only. No javascript:, no data:. */
|
|
31
|
-
export function isAllowedHref(url: string): boolean {
|
|
32
|
-
return /^(?:https?:\/\/|mailto:|#|\/)/i.test(url.trim());
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Image sources: OUR inline images and nothing else.
|
|
37
|
-
*
|
|
38
|
-
* Not a tightening for its own sake. Any other src turns every ticket into a
|
|
39
|
-
* tracking pixel aimed at whoever opens it — a body can be written by a stranger
|
|
40
|
-
* who filed a report, and the reader here is the reporter, on the host's page.
|
|
41
|
-
* Accepts the server-relative form and the same path already made absolute
|
|
42
|
-
* against the configured host.
|
|
43
|
-
*/
|
|
44
|
-
export function isAllowedImageSrc(url: string, host: string): boolean {
|
|
45
|
-
try {
|
|
46
|
-
// Resolved against the host rather than pattern-matched, so the relative and
|
|
47
|
-
// absolute spellings of the same image get the same answer — and so that
|
|
48
|
-
// `//evil.example/api/inline/…` and `/api/inline/../../x` are judged by
|
|
49
|
-
// where they actually land, not by how they read.
|
|
50
|
-
const parsed = new URL(url.trim(), host);
|
|
51
|
-
return parsed.origin === new URL(host).origin && INLINE_PATH.test(parsed.pathname);
|
|
52
|
-
} catch {
|
|
53
|
-
return false;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const BLOCK_END = /<\/(p|div|li|h[1-6]|tr)\s*>/gi;
|
|
58
|
-
const BREAK = /<br\s*\/?>/gi;
|
|
59
|
-
const TAG = /<[^>]*>/g;
|
|
60
|
-
const ENTITIES: Record<string, string> = {
|
|
61
|
-
" ": " ", "&": "&", "<": "<", ">": ">", """: '"', "'": "'",
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
export function looksLikeHtml(body: string): boolean {
|
|
65
|
-
return /<[a-z/][^>]*>/i.test(body);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Tags out, line structure kept — for previews and anywhere text is all that fits. */
|
|
69
|
-
export function toPlainText(body: string): string {
|
|
70
|
-
if (!looksLikeHtml(body)) return body;
|
|
71
|
-
return body
|
|
72
|
-
.replace(BREAK, "\n")
|
|
73
|
-
.replace(BLOCK_END, "\n")
|
|
74
|
-
.replace(TAG, "")
|
|
75
|
-
.replace(/&[a-z#0-9]+;/gi, (entity) => ENTITIES[entity.toLowerCase()] ?? entity)
|
|
76
|
-
.split("\n")
|
|
77
|
-
.map((line) => line.trim())
|
|
78
|
-
.filter(Boolean)
|
|
79
|
-
.join("\n");
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Fill `target` with a safe rendering of `body`.
|
|
84
|
-
*
|
|
85
|
-
* A node rather than a string, deliberately: handing back HTML invites a caller
|
|
86
|
-
* to concatenate it with something else and re-parse the result, which is where
|
|
87
|
-
* sanitisers stop holding.
|
|
88
|
-
*/
|
|
89
|
-
export function renderBody(target: HTMLElement, body: string, host: string): void {
|
|
90
|
-
target.textContent = "";
|
|
91
|
-
if (!looksLikeHtml(body)) {
|
|
92
|
-
// Older plain-text messages. Line breaks are the only markup they have, and
|
|
93
|
-
// dropping them runs a written-out list into one paragraph.
|
|
94
|
-
for (const [i, line] of body.split("\n").entries()) {
|
|
95
|
-
if (i) target.append(document.createElement("br"));
|
|
96
|
-
target.append(document.createTextNode(line));
|
|
97
|
-
}
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
// An inert document: parsing here neither runs scripts nor fetches an <img>.
|
|
101
|
-
const parsed = new DOMParser().parseFromString(body, "text/html");
|
|
102
|
-
for (const node of Array.from(parsed.body.childNodes)) {
|
|
103
|
-
const safe = clean(node, host);
|
|
104
|
-
if (safe) target.append(safe);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* One node, cleaned — or null.
|
|
110
|
-
*
|
|
111
|
-
* A disallowed ELEMENT is unwrapped rather than dropped: a `<div>` around a
|
|
112
|
-
* paragraph is not an attack, and deleting it would take the paragraph with it.
|
|
113
|
-
* The exceptions are the tags whose content is not content at all — a script's
|
|
114
|
-
* text is code and a style's is CSS, and unwrapping either would print it.
|
|
115
|
-
*/
|
|
116
|
-
const DROP_WHOLE = new Set(["script", "style", "iframe", "object", "embed", "noscript", "template", "svg", "math"]);
|
|
117
|
-
|
|
118
|
-
function clean(node: Node, host: string): Node | null {
|
|
119
|
-
if (node.nodeType === 3) return document.createTextNode(node.nodeValue ?? "");
|
|
120
|
-
if (node.nodeType !== 1) return null;
|
|
121
|
-
|
|
122
|
-
const source = node as Element;
|
|
123
|
-
const tag = source.tagName.toLowerCase();
|
|
124
|
-
if (DROP_WHOLE.has(tag)) return null;
|
|
125
|
-
|
|
126
|
-
const keep = ALLOWED_TAGS.has(tag);
|
|
127
|
-
const out = keep ? document.createElement(tag) : document.createDocumentFragment();
|
|
128
|
-
|
|
129
|
-
if (keep) {
|
|
130
|
-
const element = out as HTMLElement;
|
|
131
|
-
for (const attr of Array.from(source.attributes)) {
|
|
132
|
-
const name = attr.name.toLowerCase();
|
|
133
|
-
// Every `on*` handler fails this test, and so does anything else nobody
|
|
134
|
-
// listed — the allowlist is the rule, not a blocklist of known-bad names.
|
|
135
|
-
if (!ALLOWED_ATTR.has(name)) continue;
|
|
136
|
-
if (name === "href" && !isAllowedHref(attr.value)) continue;
|
|
137
|
-
if (name === "src") {
|
|
138
|
-
if (tag !== "img" || !isAllowedImageSrc(attr.value, host)) continue;
|
|
139
|
-
element.setAttribute("src", absolute(attr.value, host));
|
|
140
|
-
continue;
|
|
141
|
-
}
|
|
142
|
-
element.setAttribute(name, attr.value);
|
|
143
|
-
}
|
|
144
|
-
if (tag === "a") {
|
|
145
|
-
// Opening someone else's link must not hand it a handle on this page.
|
|
146
|
-
element.setAttribute("target", "_blank");
|
|
147
|
-
element.setAttribute("rel", "noopener noreferrer nofollow");
|
|
148
|
-
}
|
|
149
|
-
if (tag === "img" && !element.getAttribute("src")) return null;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
for (const child of Array.from(source.childNodes)) {
|
|
153
|
-
const safe = clean(child, host);
|
|
154
|
-
if (safe) out.append(safe);
|
|
155
|
-
}
|
|
156
|
-
return out;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* The same walk, but returning a string — for content going OUT.
|
|
161
|
-
*
|
|
162
|
-
* `renderBody` deliberately hands back a node, because handing back HTML invites
|
|
163
|
-
* a caller to concatenate and re-parse it. This one exists for the single case
|
|
164
|
-
* where a string is the only possible answer: the composer has to put what
|
|
165
|
-
* somebody typed into a JSON body.
|
|
166
|
-
*
|
|
167
|
-
* It matters for the same reason the display side does, from the other
|
|
168
|
-
* direction. A contenteditable takes whatever the clipboard had in it — a
|
|
169
|
-
* pasted block of a web page arrives with its scripts, its styles and its
|
|
170
|
-
* tracking pixels intact — and posting that unfiltered would store it for
|
|
171
|
-
* every future reader of the thread.
|
|
172
|
-
*/
|
|
173
|
-
export function cleanHtml(html: string, host: string): string {
|
|
174
|
-
const holder = document.createElement("div");
|
|
175
|
-
renderBody(holder, html, host);
|
|
176
|
-
/*
|
|
177
|
-
* Stored relative, displayed absolute.
|
|
178
|
-
*
|
|
179
|
-
* `renderBody` makes an inline image absolute because the panel runs on the
|
|
180
|
-
* HOST app's origin, where `/api/inline/…` would resolve to the host and 404.
|
|
181
|
-
* That is right for showing it and wrong for keeping it: a body stored with
|
|
182
|
-
* this deployment's hostname in it breaks if the deployment is ever renamed,
|
|
183
|
-
* and the support app's own editor stores the path. So the origin goes back
|
|
184
|
-
* off on the way out, and the display side puts it on again.
|
|
185
|
-
*/
|
|
186
|
-
const origin = safeOrigin(host);
|
|
187
|
-
if (origin) {
|
|
188
|
-
for (const image of Array.from(holder.querySelectorAll("img"))) {
|
|
189
|
-
const src = image.getAttribute("src") ?? "";
|
|
190
|
-
if (src.startsWith(`${origin}/`)) image.setAttribute("src", src.slice(origin.length));
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
return holder.innerHTML;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function safeOrigin(host: string): string | null {
|
|
197
|
-
try {
|
|
198
|
-
return new URL(host).origin;
|
|
199
|
-
} catch {
|
|
200
|
-
return null;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* True when there is nothing worth sending.
|
|
206
|
-
*
|
|
207
|
-
* An image alone counts as content — a screenshot with no words is the most
|
|
208
|
-
* common reply a reporter makes — but a contenteditable that has been typed in
|
|
209
|
-
* and cleared is full of empty tags and ` `, and those are not.
|
|
210
|
-
*/
|
|
211
|
-
export function htmlIsEmpty(html: string): boolean {
|
|
212
|
-
if (!html) return true;
|
|
213
|
-
if (/<img\b/i.test(html)) return false;
|
|
214
|
-
return html.replace(/<[^>]*>/g, "").replace(/ /g, " ").trim() === "";
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function absolute(url: string, host: string): string {
|
|
218
|
-
const value = url.trim();
|
|
219
|
-
if (!value.startsWith("/")) return value;
|
|
220
|
-
return `${host.replace(/\/$/, "")}${value}`;
|
|
221
|
-
}
|