@jsbot-tellus/core 2.3.0 → 2.5.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 +19 -0
- package/bot.config.example.json +3 -0
- package/dist/app.js +215 -98
- package/dist/app.js.map +1 -1
- package/dist/core/client.d.ts +1 -0
- package/dist/core/client.d.ts.map +1 -1
- package/dist/core/client.js +3 -0
- package/dist/core/client.js.map +1 -1
- package/dist/core/config/schema.d.ts +19 -0
- package/dist/core/config/schema.d.ts.map +1 -1
- package/dist/core/config/schema.js +37 -0
- package/dist/core/config/schema.js.map +1 -1
- package/dist/core/dispatch/command.d.ts.map +1 -1
- package/dist/core/dispatch/command.js +5 -1
- package/dist/core/dispatch/command.js.map +1 -1
- package/dist/core/dispatch/router.d.ts +3 -0
- package/dist/core/dispatch/router.d.ts.map +1 -1
- package/dist/core/dispatch/router.js +14 -1
- package/dist/core/dispatch/router.js.map +1 -1
- package/dist/core/index.d.ts +2 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/kernel.d.ts +8 -0
- package/dist/core/kernel.d.ts.map +1 -1
- package/dist/core/kernel.js +17 -0
- package/dist/core/kernel.js.map +1 -1
- package/dist/core/logging/log-buffer.d.ts +27 -0
- package/dist/core/logging/log-buffer.d.ts.map +1 -0
- package/dist/core/logging/log-buffer.js +71 -0
- package/dist/core/logging/log-buffer.js.map +1 -0
- package/dist/core/logging/logger.d.ts +3 -0
- package/dist/core/logging/logger.d.ts.map +1 -1
- package/dist/core/logging/logger.js +6 -3
- package/dist/core/logging/logger.js.map +1 -1
- package/dist/core/plugin/command-params.d.ts +4 -3
- package/dist/core/plugin/command-params.d.ts.map +1 -1
- package/dist/core/plugin/command-params.js +122 -3
- package/dist/core/plugin/command-params.js.map +1 -1
- package/dist/core/storage/session.d.ts +15 -0
- package/dist/core/storage/session.d.ts.map +1 -1
- package/dist/core/storage/session.js +55 -0
- package/dist/core/storage/session.js.map +1 -1
- package/dist/core/web/api.d.ts +16 -0
- package/dist/core/web/api.d.ts.map +1 -0
- package/dist/core/web/api.js +617 -0
- package/dist/core/web/api.js.map +1 -0
- package/dist/core/web/auth.d.ts +43 -0
- package/dist/core/web/auth.d.ts.map +1 -0
- package/dist/core/web/auth.js +161 -0
- package/dist/core/web/auth.js.map +1 -0
- package/dist/core/web/config-store.d.ts +30 -0
- package/dist/core/web/config-store.d.ts.map +1 -0
- package/dist/core/web/config-store.js +216 -0
- package/dist/core/web/config-store.js.map +1 -0
- package/dist/core/web/event-feed.d.ts +26 -0
- package/dist/core/web/event-feed.d.ts.map +1 -0
- package/dist/core/web/event-feed.js +107 -0
- package/dist/core/web/event-feed.js.map +1 -0
- package/dist/core/web/http.d.ts +39 -0
- package/dist/core/web/http.d.ts.map +1 -0
- package/dist/core/web/http.js +233 -0
- package/dist/core/web/http.js.map +1 -0
- package/dist/core/web/index.d.ts +11 -0
- package/dist/core/web/index.d.ts.map +1 -0
- package/dist/core/web/index.js +6 -0
- package/dist/core/web/index.js.map +1 -0
- package/dist/core/web/server.d.ts +30 -0
- package/dist/core/web/server.d.ts.map +1 -0
- package/dist/core/web/server.js +437 -0
- package/dist/core/web/server.js.map +1 -0
- package/dist/core/web/types.d.ts +84 -0
- package/dist/core/web/types.d.ts.map +1 -0
- package/dist/core/web/types.js +2 -0
- package/dist/core/web/types.js.map +1 -0
- package/dist/webui/assets/ActionsView-BDuXBWG1.js +1 -0
- package/dist/webui/assets/ActionsView-Ciib5lC6.css +1 -0
- package/dist/webui/assets/ConfigView-B3Gv2uRG.js +1 -0
- package/dist/webui/assets/ConfigView-CrEpss2y.css +1 -0
- package/dist/webui/assets/DashboardView-DR6yCaqs.css +1 -0
- package/dist/webui/assets/DashboardView-n9LGPmQL.js +1 -0
- package/dist/webui/assets/EventsView-DhO8twWb.js +1 -0
- package/dist/webui/assets/EventsView-PMkYKxZ-.css +1 -0
- package/dist/webui/assets/KvView-BCz_dOVC.js +1 -0
- package/dist/webui/assets/KvView-CaMvbxUz.css +1 -0
- package/dist/webui/assets/LoginView-BVnFkwSW.css +1 -0
- package/dist/webui/assets/LoginView-Br3_AC5z.js +1 -0
- package/dist/webui/assets/LogsView-Ba_12frI.js +1 -0
- package/dist/webui/assets/LogsView-NR4L1-XY.css +1 -0
- package/dist/webui/assets/MainLayout-D2LTc57u.js +1 -0
- package/dist/webui/assets/MainLayout-glfVNbMz.css +1 -0
- package/dist/webui/assets/PluginsView-DL9OxGph.css +1 -0
- package/dist/webui/assets/PluginsView-vQKBSCU8.js +1 -0
- package/dist/webui/assets/SessionsView-DSl37qpo.js +1 -0
- package/dist/webui/assets/SessionsView-p-u3JGTV.css +1 -0
- package/dist/webui/assets/api-CNzxhTWe.js +3 -0
- package/dist/webui/assets/index-CUvpnClx.js +50 -0
- package/dist/webui/assets/index-CePlRZX6.css +1 -0
- package/dist/webui/index.html +110 -0
- package/docs/plugin-api.md +2 -0
- package/docs/webui.md +142 -0
- package/package.json +14 -3
- package/src/app.ts +286 -118
- package/src/core/client.ts +4 -0
- package/src/core/config/schema.ts +40 -0
- package/src/core/dispatch/command.ts +7 -1
- package/src/core/dispatch/router.ts +17 -1
- package/src/core/index.ts +3 -0
- package/src/core/kernel.ts +25 -1
- package/src/core/logging/log-buffer.ts +109 -0
- package/src/core/logging/logger.ts +7 -3
- package/src/core/plugin/command-params.ts +165 -7
- package/src/core/storage/session.ts +95 -0
- package/src/core/web/api.ts +932 -0
- package/src/core/web/auth.ts +245 -0
- package/src/core/web/config-store.ts +333 -0
- package/src/core/web/event-feed.ts +163 -0
- package/src/core/web/http.ts +340 -0
- package/src/core/web/index.ts +25 -0
- package/src/core/web/server.ts +621 -0
- package/src/core/web/types.ts +100 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { SnowLumaEvent } from '@snowluma/sdk'
|
|
2
|
+
|
|
3
|
+
export type FeedEventType = 'message' | 'notice' | 'request' | 'meta' | 'other'
|
|
4
|
+
|
|
5
|
+
export interface FeedEvent {
|
|
6
|
+
id: number
|
|
7
|
+
time: number
|
|
8
|
+
type: FeedEventType
|
|
9
|
+
summary: string
|
|
10
|
+
event: SnowLumaEvent
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface FeedFilter {
|
|
14
|
+
type?: FeedEventType | undefined
|
|
15
|
+
limit?: number | undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function recordOf(event: SnowLumaEvent): Record<string, unknown> {
|
|
19
|
+
return event as unknown as Record<string, unknown>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function eventTypeOf(event: SnowLumaEvent): FeedEventType {
|
|
23
|
+
const postType = recordOf(event).post_type
|
|
24
|
+
|
|
25
|
+
switch (postType) {
|
|
26
|
+
case 'message':
|
|
27
|
+
return 'message'
|
|
28
|
+
case 'notice':
|
|
29
|
+
return 'notice'
|
|
30
|
+
case 'request':
|
|
31
|
+
return 'request'
|
|
32
|
+
case 'meta_event':
|
|
33
|
+
return 'meta'
|
|
34
|
+
default:
|
|
35
|
+
return 'other'
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readString(
|
|
40
|
+
value: Record<string, unknown>,
|
|
41
|
+
key: string
|
|
42
|
+
): string | undefined {
|
|
43
|
+
const item = value[key]
|
|
44
|
+
|
|
45
|
+
return typeof item === 'string' ? item : undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readNumber(
|
|
49
|
+
value: Record<string, unknown>,
|
|
50
|
+
key: string
|
|
51
|
+
): number | undefined {
|
|
52
|
+
const item = value[key]
|
|
53
|
+
|
|
54
|
+
return typeof item === 'number' ? item : undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function truncate(value: string, max = 200): string {
|
|
58
|
+
return value.length > max ? `${value.slice(0, max)}…` : value
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function summarize(event: SnowLumaEvent): string {
|
|
62
|
+
const value = recordOf(event)
|
|
63
|
+
const type = eventTypeOf(event)
|
|
64
|
+
|
|
65
|
+
if (type === 'message') {
|
|
66
|
+
const sender = value.sender
|
|
67
|
+
|
|
68
|
+
if (typeof sender === 'object' && sender !== null) {
|
|
69
|
+
const senderRecord = sender as Record<string, unknown>
|
|
70
|
+
const name =
|
|
71
|
+
readString(senderRecord, 'card') ??
|
|
72
|
+
readString(senderRecord, 'nickname') ??
|
|
73
|
+
String(readNumber(value, 'user_id') ?? 'unknown')
|
|
74
|
+
const raw = readString(value, 'raw_message') ?? ''
|
|
75
|
+
|
|
76
|
+
return truncate(`${name}: ${raw}`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return truncate(readString(value, 'raw_message') ?? 'message')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (type === 'notice') {
|
|
83
|
+
return truncate(
|
|
84
|
+
`notice ${readString(value, 'notice_type') ?? 'unknown'}`
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (type === 'request') {
|
|
89
|
+
return truncate(
|
|
90
|
+
`request ${readString(value, 'request_type') ?? 'unknown'}`
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (type === 'meta') {
|
|
95
|
+
return truncate(
|
|
96
|
+
`meta ${readString(value, 'meta_event_type') ?? 'unknown'}`
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return truncate(`event ${String(value.post_type ?? 'unknown')}`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export class EventFeed {
|
|
104
|
+
private readonly entries: FeedEvent[] = []
|
|
105
|
+
private readonly listeners = new Set<(entry: FeedEvent) => void>()
|
|
106
|
+
private nextId = 1
|
|
107
|
+
|
|
108
|
+
constructor(private readonly capacity = 200) {}
|
|
109
|
+
|
|
110
|
+
get size(): number {
|
|
111
|
+
return this.entries.length
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
add(event: SnowLumaEvent): void {
|
|
115
|
+
const entry: FeedEvent = {
|
|
116
|
+
id: this.nextId,
|
|
117
|
+
time: Date.now(),
|
|
118
|
+
type: eventTypeOf(event),
|
|
119
|
+
summary: summarize(event),
|
|
120
|
+
event,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
this.nextId += 1
|
|
124
|
+
this.entries.push(entry)
|
|
125
|
+
|
|
126
|
+
if (this.entries.length > this.capacity) {
|
|
127
|
+
this.entries.splice(0, this.entries.length - this.capacity)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const listener of this.listeners) {
|
|
131
|
+
try {
|
|
132
|
+
listener(entry)
|
|
133
|
+
} catch {
|
|
134
|
+
// Event consumers must never break dispatch.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
list(filter: FeedFilter = {}): FeedEvent[] {
|
|
140
|
+
const matched = this.entries.filter(
|
|
141
|
+
(entry) => filter.type === undefined || entry.type === filter.type
|
|
142
|
+
)
|
|
143
|
+
const limit = filter.limit
|
|
144
|
+
|
|
145
|
+
if (limit === undefined || limit < 0 || limit >= matched.length) {
|
|
146
|
+
return matched
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return matched.slice(-limit)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
clear(): void {
|
|
153
|
+
this.entries.length = 0
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
subscribe(listener: (entry: FeedEvent) => void): () => void {
|
|
157
|
+
this.listeners.add(listener)
|
|
158
|
+
|
|
159
|
+
return () => {
|
|
160
|
+
this.listeners.delete(listener)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
2
|
+
|
|
3
|
+
export const CSRF_HEADER = 'x-requested-with'
|
|
4
|
+
export const CSRF_VALUE = 'jsbot-webui'
|
|
5
|
+
|
|
6
|
+
export interface CookieOptions {
|
|
7
|
+
maxAgeSeconds?: number | undefined
|
|
8
|
+
secure?: boolean | undefined
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface SendHeaders {
|
|
12
|
+
[key: string]: string | undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class HttpError extends Error {
|
|
16
|
+
readonly status: number
|
|
17
|
+
readonly details: unknown
|
|
18
|
+
|
|
19
|
+
constructor(status: number, message: string, details?: unknown) {
|
|
20
|
+
super(message)
|
|
21
|
+
this.name = 'HttpError'
|
|
22
|
+
this.status = status
|
|
23
|
+
this.details = details
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
28
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function readJsonBody(
|
|
32
|
+
req: IncomingMessage,
|
|
33
|
+
limitBytes = 1_048_576
|
|
34
|
+
): Promise<unknown> {
|
|
35
|
+
const chunks: Buffer[] = []
|
|
36
|
+
let size = 0
|
|
37
|
+
|
|
38
|
+
for await (const chunk of req) {
|
|
39
|
+
const buffer = chunk as Buffer
|
|
40
|
+
|
|
41
|
+
size += buffer.length
|
|
42
|
+
|
|
43
|
+
if (size > limitBytes) {
|
|
44
|
+
throw new HttpError(413, 'request body too large')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
chunks.push(buffer)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const text = Buffer.concat(chunks).toString('utf8').trim()
|
|
51
|
+
|
|
52
|
+
if (text.length === 0) return {}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(text) as unknown
|
|
56
|
+
} catch {
|
|
57
|
+
throw new HttpError(400, 'request body must be valid JSON')
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface URLSearchParamsLike {
|
|
62
|
+
get(name: string): string | null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function sendData(
|
|
66
|
+
res: ServerResponse,
|
|
67
|
+
status: number,
|
|
68
|
+
data: unknown,
|
|
69
|
+
headers: SendHeaders = {}
|
|
70
|
+
): void {
|
|
71
|
+
sendJson(res, status, { data }, headers)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function sendJson(
|
|
75
|
+
res: ServerResponse,
|
|
76
|
+
status: number,
|
|
77
|
+
data: unknown,
|
|
78
|
+
headers: SendHeaders = {}
|
|
79
|
+
): void {
|
|
80
|
+
res.writeHead(status, {
|
|
81
|
+
'content-type': 'application/json; charset=utf-8',
|
|
82
|
+
'cache-control': 'no-store',
|
|
83
|
+
...headers,
|
|
84
|
+
})
|
|
85
|
+
res.end(safeStringify(data))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function sendError(
|
|
89
|
+
res: ServerResponse,
|
|
90
|
+
status: number,
|
|
91
|
+
message: string,
|
|
92
|
+
details?: unknown,
|
|
93
|
+
headers: SendHeaders = {}
|
|
94
|
+
): void {
|
|
95
|
+
sendJson(
|
|
96
|
+
res,
|
|
97
|
+
status,
|
|
98
|
+
{ error: details === undefined ? { message } : { message, details } },
|
|
99
|
+
headers
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function detailsOf(error: unknown): unknown {
|
|
104
|
+
if (error instanceof HttpError) return error.details
|
|
105
|
+
|
|
106
|
+
return undefined
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function respondWithError(res: ServerResponse, error: unknown): void {
|
|
110
|
+
if (error instanceof HttpError) {
|
|
111
|
+
if (!res.headersSent) {
|
|
112
|
+
sendError(res, error.status, error.message, detailsOf(error))
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
} else if (!res.headersSent) {
|
|
116
|
+
sendError(res, 500, 'internal server error')
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
res.end()
|
|
122
|
+
} catch {
|
|
123
|
+
// Socket already destroyed.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function parseCookies(
|
|
128
|
+
header: string | undefined
|
|
129
|
+
): Record<string, string> {
|
|
130
|
+
const result: Record<string, string> = {}
|
|
131
|
+
|
|
132
|
+
if (!header) return result
|
|
133
|
+
|
|
134
|
+
for (const part of header.split(';')) {
|
|
135
|
+
const separator = part.indexOf('=')
|
|
136
|
+
|
|
137
|
+
if (separator <= 0) continue
|
|
138
|
+
|
|
139
|
+
const key = part.slice(0, separator).trim()
|
|
140
|
+
const value = part.slice(separator + 1).trim()
|
|
141
|
+
|
|
142
|
+
if (key.length === 0) continue
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
result[key] = decodeURIComponent(value)
|
|
146
|
+
} catch {
|
|
147
|
+
result[key] = value
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return result
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function serializeCookie(
|
|
155
|
+
name: string,
|
|
156
|
+
value: string | null,
|
|
157
|
+
options: CookieOptions = {}
|
|
158
|
+
): string {
|
|
159
|
+
const parts = [`${name}=`]
|
|
160
|
+
|
|
161
|
+
if (value === null) {
|
|
162
|
+
parts.push('; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT')
|
|
163
|
+
} else {
|
|
164
|
+
parts.push(value)
|
|
165
|
+
|
|
166
|
+
if (options.maxAgeSeconds !== undefined) {
|
|
167
|
+
parts.push(
|
|
168
|
+
`; Max-Age=${Math.max(Math.trunc(options.maxAgeSeconds), 0)}`
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
parts.push('; Path=/; HttpOnly; SameSite=Strict')
|
|
174
|
+
|
|
175
|
+
if (options.secure === true) parts.push('; Secure')
|
|
176
|
+
|
|
177
|
+
return parts.join('')
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function clearCookie(name: string): string {
|
|
181
|
+
return serializeCookie(name, null)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function forwardedHost(
|
|
185
|
+
req: IncomingMessage,
|
|
186
|
+
trustProxy: boolean
|
|
187
|
+
): string | undefined {
|
|
188
|
+
if (!trustProxy) return undefined
|
|
189
|
+
|
|
190
|
+
const value = req.headers['x-forwarded-host']
|
|
191
|
+
|
|
192
|
+
if (typeof value !== 'string' || value.length === 0) return undefined
|
|
193
|
+
|
|
194
|
+
return value.split(',')[0]?.trim()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function clientIp(req: IncomingMessage, trustProxy: boolean): string {
|
|
198
|
+
if (trustProxy) {
|
|
199
|
+
const forwarded = req.headers['x-forwarded-for']
|
|
200
|
+
|
|
201
|
+
if (typeof forwarded === 'string' && forwarded.length > 0) {
|
|
202
|
+
const first = forwarded.split(',')[0]?.trim()
|
|
203
|
+
|
|
204
|
+
if (first) return first
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const address = req.socket.remoteAddress ?? 'unknown'
|
|
209
|
+
|
|
210
|
+
return address.startsWith('::ffff:') ? address.slice(7) : address
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function isLoopbackHost(host: string): boolean {
|
|
214
|
+
try {
|
|
215
|
+
const url = new URL(`http://${host}`)
|
|
216
|
+
|
|
217
|
+
return (
|
|
218
|
+
url.hostname === '127.0.0.1' ||
|
|
219
|
+
url.hostname === '[::1]' ||
|
|
220
|
+
url.hostname === '::1' ||
|
|
221
|
+
url.hostname === 'localhost'
|
|
222
|
+
)
|
|
223
|
+
} catch {
|
|
224
|
+
return false
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function sameOrigin(req: IncomingMessage, trustProxy: boolean): boolean {
|
|
229
|
+
const origin = req.headers.origin
|
|
230
|
+
|
|
231
|
+
if (typeof origin !== 'string') return true
|
|
232
|
+
|
|
233
|
+
if (origin.length === 0 || origin === 'null') return false
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const originUrl = new URL(origin)
|
|
237
|
+
const host = forwardedHost(req, trustProxy) ?? req.headers.host
|
|
238
|
+
|
|
239
|
+
if (host === undefined) return false
|
|
240
|
+
|
|
241
|
+
return originUrl.host.toLowerCase() === host.trim().toLowerCase()
|
|
242
|
+
} catch {
|
|
243
|
+
return false
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function assertSameOrigin(
|
|
248
|
+
req: IncomingMessage,
|
|
249
|
+
trustProxy: boolean
|
|
250
|
+
): void {
|
|
251
|
+
if (sameOrigin(req, trustProxy)) return
|
|
252
|
+
|
|
253
|
+
throw new HttpError(403, 'cross-origin request rejected')
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function csrfHeader(req: IncomingMessage): string | undefined {
|
|
257
|
+
const value = req.headers[CSRF_HEADER]
|
|
258
|
+
|
|
259
|
+
return typeof value === 'string' ? value : undefined
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function assertCsrf(req: IncomingMessage): void {
|
|
263
|
+
const value = csrfHeader(req)
|
|
264
|
+
|
|
265
|
+
if (value?.toLowerCase() === CSRF_VALUE) return
|
|
266
|
+
|
|
267
|
+
throw new HttpError(403, `missing ${CSRF_HEADER}: ${CSRF_VALUE} header`)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function safeStringify(value: unknown): string {
|
|
271
|
+
const seen = new WeakSet<object>()
|
|
272
|
+
|
|
273
|
+
return JSON.stringify(value, (_key, item: unknown) => {
|
|
274
|
+
if (typeof item === 'bigint') return item.toString()
|
|
275
|
+
|
|
276
|
+
if (typeof item !== 'object' || item === null) return item
|
|
277
|
+
|
|
278
|
+
if (seen.has(item)) return '[Circular]'
|
|
279
|
+
|
|
280
|
+
seen.add(item)
|
|
281
|
+
|
|
282
|
+
return item
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const MIME_TYPES: Record<string, string> = {
|
|
287
|
+
'.css': 'text/css; charset=utf-8',
|
|
288
|
+
'.gif': 'image/gif',
|
|
289
|
+
'.html': 'text/html; charset=utf-8',
|
|
290
|
+
'.ico': 'image/x-icon',
|
|
291
|
+
'.jpeg': 'image/jpeg',
|
|
292
|
+
'.jpg': 'image/jpeg',
|
|
293
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
294
|
+
'.json': 'application/json; charset=utf-8',
|
|
295
|
+
'.map': 'application/json',
|
|
296
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
297
|
+
'.png': 'image/png',
|
|
298
|
+
'.svg': 'image/svg+xml',
|
|
299
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
300
|
+
'.webp': 'image/webp',
|
|
301
|
+
'.woff': 'font/woff',
|
|
302
|
+
'.woff2': 'font/woff2',
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function mimeType(filePath: string): string {
|
|
306
|
+
const separator = filePath.lastIndexOf('.')
|
|
307
|
+
|
|
308
|
+
if (separator < 0) return 'application/octet-stream'
|
|
309
|
+
|
|
310
|
+
return (
|
|
311
|
+
MIME_TYPES[filePath.slice(separator).toLowerCase()] ??
|
|
312
|
+
'application/octet-stream'
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function isImmutableAsset(pathname: string): boolean {
|
|
317
|
+
return pathname.startsWith('/assets/')
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function withTimeout<T>(
|
|
321
|
+
promise: Promise<T>,
|
|
322
|
+
timeoutMs: number
|
|
323
|
+
): Promise<T> {
|
|
324
|
+
return new Promise<T>((resolve, reject) => {
|
|
325
|
+
const timer = setTimeout(() => {
|
|
326
|
+
reject(new Error(`timeout after ${timeoutMs}ms`))
|
|
327
|
+
}, timeoutMs)
|
|
328
|
+
|
|
329
|
+
promise.then(
|
|
330
|
+
(value) => {
|
|
331
|
+
clearTimeout(timer)
|
|
332
|
+
resolve(value)
|
|
333
|
+
},
|
|
334
|
+
(error) => {
|
|
335
|
+
clearTimeout(timer)
|
|
336
|
+
reject(error)
|
|
337
|
+
}
|
|
338
|
+
)
|
|
339
|
+
})
|
|
340
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type { FeedEvent, FeedEventType, FeedFilter } from './event-feed.js'
|
|
2
|
+
export { EventFeed } from './event-feed.js'
|
|
3
|
+
export type { AuthSession } from './auth.js'
|
|
4
|
+
export { AuthManager } from './auth.js'
|
|
5
|
+
export type { MaskedConfig } from './config-store.js'
|
|
6
|
+
export {
|
|
7
|
+
ConfigStore,
|
|
8
|
+
ConfigStoreError,
|
|
9
|
+
maskConfig,
|
|
10
|
+
mergeSecrets,
|
|
11
|
+
} from './config-store.js'
|
|
12
|
+
export type {
|
|
13
|
+
ConfigApplyResult,
|
|
14
|
+
ConfigDocument,
|
|
15
|
+
ConfigValidationIssue,
|
|
16
|
+
ConfigValidationResult,
|
|
17
|
+
WebUiAddress,
|
|
18
|
+
WebUiDeps,
|
|
19
|
+
WebUiPaths,
|
|
20
|
+
WebUiRuntime,
|
|
21
|
+
WebUiServerOptions,
|
|
22
|
+
} from './types.js'
|
|
23
|
+
export type { WebUiConfig } from '../config/schema.js'
|
|
24
|
+
export { WebUiServer } from './server.js'
|
|
25
|
+
export { CSRF_HEADER, CSRF_VALUE, HttpError } from './http.js'
|