w-web-api 1.0.81 → 1.0.83

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.
@@ -14,7 +14,9 @@ function procApis(deps = {}) {
14
14
 
15
15
 
16
16
  let getApisList = async (userId) => {
17
- return await woItems.apis.select({})
17
+ //經 procOrm(與 save/del 同一入口)而非直呼 woItems:ORM 層政策——useCheckUser 之使用者檢核、
18
+ //useExcludeWhenNotAdmin 非管理員濾 isActive='n'、擴充之前後處理——才會一併套到讀取;改造前直呼 woItems 全數繞過
19
+ return await procOrm(userId, 'apis', 'select', {})
18
20
  }
19
21
 
20
22
 
@@ -33,6 +33,10 @@ let kpLang = {
33
33
  eng: 'OK',
34
34
  cht: '確認',
35
35
  },
36
+ treeRootAll: {
37
+ eng: 'All',
38
+ cht: '全部',
39
+ },
36
40
  no: {
37
41
  eng: 'No',
38
42
  cht: '取消',
@@ -529,6 +533,10 @@ let kpLang = {
529
533
  eng: 'Invalid rows',
530
534
  cht: '資料列無效',
531
535
  },
536
+ errRowInvalid: { //syncAndReplaceTabs 逐列檢核:某列非物件或缺必填欄位(列索引與欄位名入 srLog)
537
+ eng: 'Invalid row data (each row must be an object with name and url)',
538
+ cht: '列資料無效(每列須為物件且 name 與 url 必填)',
539
+ },
532
540
  errTokenNoPermission: {
533
541
  eng: 'Token does not have permission',
534
542
  cht: 'token 沒有權限',
@@ -537,6 +545,10 @@ let kpLang = {
537
545
  eng: 'Invalid table',
538
546
  cht: '資料表無效',
539
547
  },
548
+ errTimeLengthInvalid: {
549
+ eng: 'Invalid time length',
550
+ cht: '時間長度無效',
551
+ },
540
552
 
541
553
  }
542
554
 
@@ -1,18 +1,68 @@
1
1
  import get from 'lodash-es/get.js'
2
2
  import isestr from 'wsemi/src/isestr.mjs'
3
3
  import isobj from 'wsemi/src/isobj.mjs'
4
- import axios from 'axios'
4
+ import isarr from 'wsemi/src/isarr.mjs'
5
+
6
+
7
+ //proxy 不轉送之標頭(小寫比對):host / content-length 由連線層決定;其餘為 RFC 7230 §6.1 hop-by-hop,
8
+ //轉送即錯(Node fetch 對 transfer-encoding / keep-alive 直接拋錯)。其他標頭一律原樣轉送(標頭表為唯一真理源)。
9
+ let ksHeaderStrip = ['host', 'content-length', 'connection', 'keep-alive', 'transfer-encoding', 'te', 'trailer', 'upgrade', 'proxy-connection']
10
+
11
+ //轉址:狀態碼(RFC 9110 §15.4)、跳數上限(同瀏覽器與 undici 預設)、方法改為 GET 時須移除之請求內容標頭(Fetch 標準 §4.4)
12
+ let ksRedirectStatus = [301, 302, 303, 307, 308]
13
+ let MAX_REDIRECTS = 20
14
+ let ksBodyHeader = ['content-encoding', 'content-language', 'content-location', 'content-type']
15
+
16
+
17
+ //toPairs:headers/query 之輸入 → 有序鍵值對 [[name, value], ...]。
18
+ //接受兩種形狀:①物件(向後相容其他呼叫端;值為陣列即同鍵多值)②有序鍵值對陣列(本前端請求建構器:重複鍵各自保留)。
19
+ //其餘 → []。值以 String() 轉字串(同改造前)。
20
+ function toPairs(v) {
21
+ let pairs = []
22
+ if (isarr(v)) {
23
+ for (let p of v) {
24
+ if (isarr(p) && isestr(p[0])) {
25
+ pairs.push([p[0], String(p[1])])
26
+ }
27
+ }
28
+ return pairs
29
+ }
30
+ if (isobj(v)) {
31
+ for (let k of Object.keys(v)) {
32
+ let vs = isarr(v[k]) ? v[k] : [v[k]]
33
+ for (let x of vs) {
34
+ pairs.push([k, String(x)])
35
+ }
36
+ }
37
+ }
38
+ return pairs
39
+ }
5
40
 
6
41
 
7
42
  function procProxy(opt = {}) {
8
43
 
9
- //srLog(後端 log;錯誤一律 log err-key,axios 例外另 log 真實訊息供排查)
44
+ //srLog(後端 log;錯誤一律 log err-key,fetch 例外另 log 真實訊息供排查)
10
45
  let srLog = get(opt, 'srLog', null)
11
46
 
12
- //isAllowTarget
47
+ //isAllowTarget:目標位址檢核函數(回 false 即拒絕);首跳與轉址之每一跳皆檢核
13
48
  let isAllowTarget = get(opt, 'isAllowTarget', null)
49
+ let isTargetAllowed = (url) => {
50
+ if (typeof isAllowTarget !== 'function') {
51
+ return true
52
+ }
53
+ return isAllowTarget(url) !== false
54
+ }
14
55
 
15
56
 
57
+ //proxyRequest:以 Node 內建 fetch 代打目標(繞過瀏覽器 CORS)。契約(見 spec/流程_測試API.md「規則摘要」):
58
+ //- 請求內容原文送出:字串 body 以 TextEncoder 轉位元組原封送達,不解析、不驗證、不補 Content-Type
59
+ // (fetch 對字串 body 會自動補 text/plain,位元組 body 則不補;同 Scalar api-client 作法)。
60
+ //- 物件/陣列 body(非本前端之其他呼叫端)JSON 序列化送出,無 Content-Type 時補 application/json。
61
+ //- headers/query 接受物件或有序鍵值對;僅剝除 ksHeaderStrip;重複之 query 鍵各自 append(?id=1&id=2),
62
+ // 重複之標頭由 Headers 依 Fetch 標準合併(', ';cookie 為 '; ');4xx/5xx 視為有回應(不 reject)。
63
+ //- 轉址手動跟隨:每一跳重新檢核 isAllowTarget(fetch 之 redirect:'follow' 無逐跳 hook,合法首跳可 302 到不允許之位址繞過檢核);
64
+ // 方法/body 改寫依 Fetch 標準 §4.4(301/302 之 POST 與 303 之非 GET/HEAD 改 GET 並丟 body 與 content-* 標頭;307/308 保留);
65
+ // 跨來源(origin 不同)剝除 Authorization;上限 20 跳;3xx 無 Location 視為最終回應;整條鏈共用同一 timeout。
16
66
  let proxyRequest = async (inp = {}) => {
17
67
 
18
68
  //method
@@ -31,39 +81,54 @@ function procProxy(opt = {}) {
31
81
  return Promise.reject('errReqUrlInvalid')
32
82
  }
33
83
 
34
- //isAllowTarget check
35
- if (typeof isAllowTarget === 'function') {
36
- let allowed = isAllowTarget(url)
37
- if (allowed === false) {
38
- return Promise.reject('errReqTargetNotAllowed')
39
- }
84
+ //isAllowTarget check(首跳)
85
+ if (!isTargetAllowed(url)) {
86
+ return Promise.reject('errReqTargetNotAllowed')
40
87
  }
41
88
 
42
- //headers
43
- let headers = get(inp, 'headers', {})
44
- if (!isobj(headers)) {
45
- headers = {}
89
+ //headers(剝除 ksHeaderStrip,其餘原樣;Headers 對重複鍵依 Fetch 標準合併)
90
+ let headers = new Headers()
91
+ try {
92
+ for (let [k, v] of toPairs(get(inp, 'headers', {}))) {
93
+ if (ksHeaderStrip.includes(k.toLowerCase())) {
94
+ continue
95
+ }
96
+ headers.append(k, v)
97
+ }
46
98
  }
99
+ catch (err) {
100
+ //非法標頭名/值(Headers 拋 TypeError):log 真實訊息,回前端只給 err-key
101
+ if (srLog) {
102
+ srLog.error({ event: 'proxyRequest-headers', msg: get(err, 'message', String(err)) })
103
+ }
104
+ return Promise.reject('errProxyRequestFailed')
105
+ }
106
+ let hasContentType = headers.has('content-type')
47
107
 
48
- //移除 host content-length (交給 axios)
49
- delete headers['host']
50
- delete headers['Host']
51
- delete headers['content-length']
52
- delete headers['Content-Length']
53
-
54
- //query
55
- let query = get(inp, 'query', null)
56
- let params = null
57
- if (isobj(query) && Object.keys(query).length > 0) {
58
- params = query
108
+ //query:併入 URL(保留 url 既有 query;重複鍵各自 append)
109
+ let u
110
+ try {
111
+ u = new URL(url)
112
+ }
113
+ catch (err) {
114
+ return Promise.reject('errReqUrlInvalid')
115
+ }
116
+ for (let [k, v] of toPairs(get(inp, 'query', null))) {
117
+ u.searchParams.append(k, v)
59
118
  }
60
119
 
61
- //body
120
+ //body(GET/HEAD 不帶;fetch 對 GET/HEAD 帶 body 會拋錯)
62
121
  let body = get(inp, 'body', undefined)
63
- let data = undefined
122
+ let data
64
123
  if (method !== 'GET' && method !== 'HEAD') {
65
- if (body !== undefined) {
66
- data = body
124
+ if (isestr(body)) {
125
+ data = new TextEncoder().encode(body) //原文位元組,不補 Content-Type
126
+ }
127
+ else if (isobj(body) || isarr(body)) {
128
+ data = new TextEncoder().encode(JSON.stringify(body))
129
+ if (!hasContentType) {
130
+ headers.set('Content-Type', 'application/json')
131
+ }
67
132
  }
68
133
  }
69
134
 
@@ -73,19 +138,88 @@ function procProxy(opt = {}) {
73
138
  timeout = 30000
74
139
  }
75
140
 
76
- //send
141
+ //send(手動跟隨轉址,規則見上方契約)
77
142
  let r
78
143
  let t0 = Date.now()
79
144
  try {
80
- let res = await axios({
81
- method,
82
- url,
83
- headers,
84
- params,
85
- data,
86
- timeout,
87
- validateStatus: () => true,
145
+ let signal = AbortSignal.timeout(timeout) //整條轉址鏈共用同一 timeout
146
+ let curUrl = u
147
+ let curMethod = method
148
+ let curBody = data
149
+ let hops = 0
150
+ let res
151
+ for (;;) {
152
+ res = await fetch(curUrl, {
153
+ method: curMethod,
154
+ headers,
155
+ body: curBody,
156
+ signal,
157
+ redirect: 'manual',
158
+ })
159
+ if (!ksRedirectStatus.includes(res.status)) {
160
+ break
161
+ }
162
+ let loc = res.headers.get('location')
163
+ if (loc === null) {
164
+ break //3xx 無 Location:視為最終回應(同 fetch 行為)
165
+ }
166
+ if (res.body) {
167
+ await res.body.cancel() //釋放此跳之連線
168
+ }
169
+ hops += 1
170
+ if (hops > MAX_REDIRECTS) {
171
+ throw Object.assign(new Error(`too many redirects (> ${MAX_REDIRECTS}), last: ${curUrl.href}`), { errKey: 'errProxyRequestFailed' })
172
+ }
173
+ let nextUrl
174
+ try {
175
+ nextUrl = new URL(loc, curUrl)
176
+ }
177
+ catch (err) {
178
+ throw Object.assign(new Error(`invalid redirect location: ${loc}`), { errKey: 'errProxyRequestFailed' })
179
+ }
180
+ if (nextUrl.protocol !== 'http:' && nextUrl.protocol !== 'https:') {
181
+ throw Object.assign(new Error(`unsupported redirect protocol: ${nextUrl.href}`), { errKey: 'errProxyRequestFailed' })
182
+ }
183
+ if (!isTargetAllowed(nextUrl.href)) {
184
+ throw Object.assign(new Error(`redirect target not allowed: ${nextUrl.href}`), { errKey: 'errReqTargetNotAllowed' })
185
+ }
186
+ //方法/body 改寫(Fetch 標準 §4.4)
187
+ let toGet = (res.status === 303 && curMethod !== 'GET' && curMethod !== 'HEAD') || ((res.status === 301 || res.status === 302) && curMethod === 'POST')
188
+ if (toGet) {
189
+ curMethod = 'GET'
190
+ curBody = undefined
191
+ for (let h of ksBodyHeader) {
192
+ headers.delete(h)
193
+ }
194
+ }
195
+ //跨來源轉址剝除 Authorization(Fetch 標準:CORS non-wildcard request-header name)
196
+ if (nextUrl.origin !== curUrl.origin) {
197
+ headers.delete('authorization')
198
+ }
199
+ curUrl = nextUrl
200
+ }
201
+
202
+ //text → 可解析為 JSON 者回物件,否則原文(對齊改造前 axios 之 silent JSON parsing)
203
+ let text = await res.text()
204
+ let resData = text
205
+ if (isestr(text)) {
206
+ try {
207
+ resData = JSON.parse(text)
208
+ }
209
+ catch (err) {
210
+ resData = text
211
+ }
212
+ }
213
+
214
+ //headers → 物件;set-cookie 多值以陣列呈現
215
+ let resHeaders = {}
216
+ res.headers.forEach((v, k) => {
217
+ resHeaders[k] = v
88
218
  })
219
+ let cookies = res.headers.getSetCookie()
220
+ if (cookies.length > 0) {
221
+ resHeaders['set-cookie'] = cookies
222
+ }
89
223
 
90
224
  let durationMs = Date.now() - t0
91
225
 
@@ -93,16 +227,24 @@ function procProxy(opt = {}) {
93
227
  r = {
94
228
  status: res.status,
95
229
  statusText: res.statusText,
96
- headers: res.headers,
97
- data: res.data,
230
+ headers: resHeaders,
231
+ data: resData,
98
232
  durationMs,
99
233
  }
100
234
 
101
235
  }
102
236
  catch (err) {
103
- //axios 例外(timeout/DNS 等):log 真實訊息供排查,但回前端只給 err-key(前端依 lang 顯示)
237
+ //轉址鏈之刻意拒絕(帶 errKey):log 細節後回該 err-key
238
+ let errKey = get(err, 'errKey', '')
239
+ if (isestr(errKey)) {
240
+ if (srLog) {
241
+ srLog.error({ event: 'proxyRequest-redirect', err: errKey, msg: get(err, 'message', '') })
242
+ }
243
+ return Promise.reject(errKey)
244
+ }
245
+ //fetch 例外(timeout/DNS/連線拒絕/受限標頭):log 真實訊息與 cause 供排查,但回前端只給 err-key(前端依 lang 顯示)
104
246
  if (srLog) {
105
- srLog.error({ event: 'proxyRequest-axios', msg: get(err, 'message', String(err)) })
247
+ srLog.error({ event: 'proxyRequest-fetch', msg: get(err, 'message', String(err)), cause: get(err, 'cause.message', '') })
106
248
  }
107
249
  return Promise.reject('errProxyRequestFailed')
108
250
  }
@@ -1,4 +1,3 @@
1
- import axios from 'axios'
2
1
  import get from 'lodash-es/get.js'
3
2
  import genPm from 'wsemi/src/genPm.mjs'
4
3
  import haskey from 'wsemi/src/haskey.mjs'
@@ -57,16 +56,18 @@ async function provideTabs(url, keyTable, group, rows) {
57
56
  }
58
57
  // console.log('rin', rin)
59
58
 
60
- //axios
61
- await axios({
62
- method: 'post',
63
- url,
59
+ //fetch(Node 內建;改造前為 axios,非 2xx 由 axios 拋錯落 catch → 此處以 res.ok 判定維持同一語意)
60
+ await fetch(url, {
61
+ method: 'POST',
64
62
  headers: { 'Content-Type': 'application/json; charset=utf-8' },
65
- data: rin,
63
+ body: JSON.stringify(rin),
66
64
  })
67
- .then((res) => {
65
+ .then(async (res) => {
68
66
  // console.log('then', res)
69
- let data = get(res, 'data')
67
+ if (!res.ok) {
68
+ return Promise.reject(new Error(`Request failed with status code ${res.status}`)) //同 axios 之訊息格式與 Error 型別
69
+ }
70
+ let data = await res.json()
70
71
  let state = get(data, 'state')
71
72
  let msg = get(data, 'msg', '')
72
73
  if (state === 'success') {